Monitor de prețuri la concurență
Două scripturi. Primul urmărește produsele tale în magazinele rivale. Al doilea e pentru o rețea de benzinării, stație cu stație, față de plafonul ANRE.
Magazine: produsele tale, pe id
- Găsești id-ul fiecărui produs o singură dată, cu
/v1/prices/search, și îl pui în listă, cu prețul tău. - La fiecare rulare scriptul citește
/v1/prices/items/{id}: toate ofertele, cea mai ieftină prima. - Compară cu prețul tău și cu rularea trecută, ținută într-un fișier JSON.
- Alertează când un rival e mai ieftin decât tine și când un preț s-a schimbat.
- Pentru ce s-a schimbat, citește trendul din
/v1/prices/items/{id}/history. Istoricul începe pe 28 octombrie 2026.
Cât costă: Cu 20 de produse: 20 × 2 = 40 de credite, plus 5 pentru fiecare produs schimbat. Cu 3 schimbări pe zi: 55 de credite pe zi, 1 650 pe lună. Încape în planul Start.
Cum îl rulezi
export SATELIT_KEY=sat_live_...
python3 price_monitor.py search "lapte sana"
python3 price_monitor.pyScriptul complet
#!/usr/bin/env python3
"""Competitor price monitor: your products in rival shops, by item id.
Find an id once: SATELIT_KEY=sat_live_... python3 price_monitor.py search "lapte sana"
Watch the list: SATELIT_KEY=sat_live_... python3 price_monitor.py
Standard library only. Put it in cron: 0 7 * * * python3 /path/price_monitor.py
"""
import json
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
sys.stdout.reconfigure(encoding="utf-8") # names hold ă, ș, ț whatever the console's code page
BASE = os.environ.get("SATELIT_BASE", "https://api.satelit.md/v1")
KEY = os.environ["SATELIT_KEY"]
ALERT_URL = os.environ.get("ALERT_URL", "") # a Slack-style incoming webhook; empty prints the alerts
MY_SHOP = os.environ.get("MY_SHOP", "") # your shop's key in /v1/prices/stores, if you are listed
STATE = Path(os.environ.get("STATE_FILE", "price-state.json"))
# item id -> your own price, in lei
WATCH = {
18342: 16.90,
}
credits_used = 0
def api(path, **params):
global credits_used
query = urllib.parse.urlencode({"lang": "ro", **params})
req = urllib.request.Request(f"{BASE}{path}?{query}", headers={"Authorization": f"Bearer {KEY}"})
for attempt in range(3):
try:
with urllib.request.urlopen(req, timeout=20) as res:
body = json.load(res)
credits_used += body["meta"].get("credits", 0)
return body["data"]
except urllib.error.HTTPError as err:
retry = err.headers.get("Retry-After")
if err.code in (429, 503) and retry and attempt < 2:
time.sleep(int(retry))
continue
raise SystemExit(f"{path}: {err.code} {err.read().decode()}")
def alert(text):
print(text)
if ALERT_URL:
req = urllib.request.Request(ALERT_URL, json.dumps({"text": text}).encode(), {"Content-Type": "application/json"})
urllib.request.urlopen(req, timeout=10).close()
def search(query):
for item in api("/prices/search", q=query):
print(item["id"], item["name"], item["min_price"], f"{item['stores']} shops")
def trend(item_id, store):
points = [p for p in api(f"/prices/items/{item_id}/history", days=30, store=store)["points"] if p["store"] == store]
if len(points) < 2:
return ""
return f" (30 days: {points[0]['price']} to {points[-1]['price']})"
def main():
state = json.loads(STATE.read_text()) if STATE.exists() else {}
for item_id, my_price in WATCH.items():
item = api(f"/prices/items/{item_id}")
rivals = [o for o in item["offers"] if o["in_stock"] and o["store"]["key"] != MY_SHOP]
seen = state.get(str(item_id), {})
for offer in rivals:
key, price = offer["store"]["key"], offer["price"]
if key in seen and seen[key] != price:
alert(f"{item['name']}: {offer['store']['name']} {seen[key]} -> {price} lei{trend(item_id, key)}")
seen[key] = price
state[str(item_id)] = seen
if rivals:
cheapest = min(rivals, key=lambda o: o["price"])
if cheapest["price"] < my_price:
alert(f"{item['name']}: {cheapest['store']['name']} {cheapest['price']} lei, you {my_price} lei. {cheapest['url']}")
STATE.write_text(json.dumps(state))
print(f"{len(WATCH)} items checked, {credits_used} credits")
if __name__ == "__main__":
if len(sys.argv) > 2 and sys.argv[1] == "search":
search(sys.argv[2])
else:
main()
Pe Business: price.changed în loc de întrebări
Pe planurile Business și Enterprise, o adresă webhook listează până la 500 de produse. Comparația rulează la fiecare 30 de minute și primești un eveniment pentru fiecare preț schimbat. Acest server îl primește, verifică semnătura și trimite alerta.
#!/usr/bin/env node
// Receives price.changed from Satelit (Business and Enterprise), checks the signature, prints the change.
// SATELIT_WEBHOOK_SECRET=... PORT=8080 node price_webhook.mjs
// Put it behind HTTPS and add the address in the dashboard, with your item ids.
import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
const SECRET = process.env.SATELIT_WEBHOOK_SECRET;
if (!SECRET) throw new Error("Set SATELIT_WEBHOOK_SECRET");
const seen = new Set();
function verified(rawBody, header = "") {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = Buffer.from(createHmac("sha256", SECRET).update(`${t}.${rawBody}`).digest("hex"));
const given = Buffer.from(parts.v1 ?? "");
return expected.length === given.length && timingSafeEqual(expected, given);
}
const server = createServer((req, res) => {
const chunks = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => {
const raw = Buffer.concat(chunks).toString("utf8");
if (!verified(raw, req.headers["satelit-signature"])) {
res.writeHead(401).end();
return;
}
const event = JSON.parse(raw);
res.writeHead(200).end();
if (seen.has(event.id)) return; // a retry of what we already handled
seen.add(event.id);
if (event.type === "price.changed") {
const { item, store, old, new: price, at } = event.data;
console.log(`${at}: item ${item} at ${store}: ${old} -> ${price} lei`);
}
});
});
server.listen(Number(process.env.PORT ?? 8080), () => console.log(`listening on ${server.address().port}`));
Carburanți: rivalii, stație cu stație, față de plafon
Pentru o rețea de benzinării. Citește stațiile tale și ale rivalilor, compară prețurile pe același raion și cu plafonul ANRE, apoi trimite alerte.
- Citește plafonul și prețurile pe rețele din
/v1/fuel. - Citește stațiile tale și ale rivalilor din
/v1/fuel/stations, pagină cu pagină. - Pentru fiecare raion, compară stația ta cu cea mai ieftină stație rivală.
- Alertează când un rival e mai ieftin cu cel puțin pragul ales, când o stație e peste plafon și când plafonul se schimbă.
- Citește trendul pe 30 de zile din
/v1/fuel/historypentru fiecare rival.
Cât costă: Cu rețeaua ta și doi rivali, câte o pagină de stații fiecare: 1 + 3 × 5 + 3 × 2 = 22 de credite la o rulare. O rulare pe zi: 660 de credite pe lună, încape în planul Start.
Cum îl rulezi
export SATELIT_KEY=sat_live_...
MY_NETWORK=bemol RIVALS=lukoil,rompetrol FUEL=a95 GAP=0.05 python3 fuel_monitor.pyScriptul complet
#!/usr/bin/env python3
"""Fuel price monitor for a network: rivals station by station, against the ANRE cap.
SATELIT_KEY=sat_live_... MY_NETWORK=bemol RIVALS=lukoil,rompetrol python3 fuel_monitor.py
Standard library only. Put it in cron: 30 13 * * * python3 /path/fuel_monitor.py
"""
import json
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
sys.stdout.reconfigure(encoding="utf-8") # names hold ă, ș, ț whatever the console's code page
BASE = os.environ.get("SATELIT_BASE", "https://api.satelit.md/v1")
KEY = os.environ["SATELIT_KEY"]
MY_NETWORK = os.environ.get("MY_NETWORK", "bemol")
RIVALS = os.environ.get("RIVALS", "lukoil,rompetrol").split(",")
FUEL = os.environ.get("FUEL", "a95") # a95, diesel or lpg
GAP = float(os.environ.get("GAP", "0.05")) # alert when a rival is this many lei cheaper
ALERT_URL = os.environ.get("ALERT_URL", "") # a Slack-style incoming webhook; empty prints the alerts
STATE = Path(os.environ.get("STATE_FILE", "fuel-state.json"))
credits_used = 0
def api(path, **params):
global credits_used
query = urllib.parse.urlencode({"lang": "ro", **params})
req = urllib.request.Request(f"{BASE}{path}?{query}", headers={"Authorization": f"Bearer {KEY}"})
for attempt in range(3):
try:
with urllib.request.urlopen(req, timeout=20) as res:
body = json.load(res)
credits_used += body["meta"].get("credits", 0)
return body["data"], body["meta"]
except urllib.error.HTTPError as err:
retry = err.headers.get("Retry-After")
if err.code in (429, 503) and retry and attempt < 2:
time.sleep(int(retry))
continue
raise SystemExit(f"{path}: {err.code} {err.read().decode()}")
def alert(text):
print(text)
if ALERT_URL:
req = urllib.request.Request(ALERT_URL, json.dumps({"text": text}).encode(), {"Content-Type": "application/json"})
urllib.request.urlopen(req, timeout=10).close()
def stations(network):
rows, page = [], 1
while True:
data, meta = api("/fuel/stations", network=network, page=page)
rows += data
if page >= meta["page"]["pages"]:
return rows
page += 1
def by_raion(rows):
out = {}
for s in rows:
price = (s.get("prices") or {}).get(FUEL)
if price is not None:
out.setdefault(s["raion"], []).append((price, s))
return out
def main():
state = json.loads(STATE.read_text()) if STATE.exists() else {}
board, _ = api("/fuel")
cap = board["cap"].get(FUEL)
if cap is not None and state.get("cap") not in (None, cap):
alert(f"ANRE cap {FUEL}: {state['cap']} -> {cap} lei/l")
state["cap"] = cap
mine = by_raion(stations(MY_NETWORK))
rivals = {}
for rival in RIVALS:
for raion, rows in by_raion(stations(rival)).items():
rivals.setdefault(raion, []).extend((price, rival, s) for price, s in rows)
for raion, own in sorted(mine.items()):
for price, station in own:
if cap is not None and price > cap:
alert(f"Over the cap: {station['name']}, {raion}: {price} lei/l, cap {cap}")
cheaper = [r for r in rivals.get(raion, []) if r[0] <= price - GAP]
if cheaper:
best = min(cheaper, key=lambda r: r[0])
alert(f"{raion}: {best[1]} {best[2]['name']} {best[0]} lei/l, your {station['name']} {price} lei/l")
for rival in RIVALS:
data, _ = api("/fuel/history", fuel=FUEL, network=rival, days=30)
points = data["points"]
if len(points) > 1:
print(f"{rival}: {points[0]['value']} to {points[-1]['value']} lei/l in {len(points)} days")
STATE.write_text(json.dumps(state))
print(f"{len(mine)} raions compared, {credits_used} credits")
if __name__ == "__main__":
main()