diff --git a/tools/steam-sniper/db.py b/tools/steam-sniper/db.py index cedd2c4..7712b56 100644 --- a/tools/steam-sniper/db.py +++ b/tools/steam-sniper/db.py @@ -100,6 +100,18 @@ def init_db() -> None: ); CREATE UNIQUE INDEX IF NOT EXISTS idx_user_lists_unique ON user_lists(user_id, item_name, list_type); + + -- Steam Market price cache for arbitrage delta vs lis-skins. + -- Updated by background task (rate-limited, ~40 items/min). + -- Only watched items (favorites + wishlist + watchlist) are tracked, + -- not the full 23k catalog (Steam rate limit makes that infeasible). + CREATE TABLE IF NOT EXISTS steam_prices ( + name TEXT PRIMARY KEY, + lowest_usd REAL, + median_usd REAL, + volume INTEGER, + fetched_at TEXT NOT NULL DEFAULT (datetime('now')) + ); """) # Migrate: add columns if missing (safe for existing DBs) cols = {row[1] for row in conn.execute("PRAGMA table_info(watchlist)")} @@ -119,6 +131,14 @@ def init_db() -> None: conn.execute("ALTER TABLE user_lists ADD COLUMN last_notified_below_at TEXT") if "last_notified_above_at" not in list_cols: conn.execute("ALTER TABLE user_lists ADD COLUMN last_notified_above_at TEXT") + # Float-aware alerts: only fire when a listing has BOTH price and float + # in the user's accepted range. Without these, the alert can trigger on + # a listing with the right price but unusable wear (e.g. float 0.32 on + # a knife where the user wanted ≤ 0.10). + if "target_float_max" not in list_cols: + conn.execute("ALTER TABLE user_lists ADD COLUMN target_float_max REAL") + if "target_float_min" not in list_cols: + conn.execute("ALTER TABLE user_lists ADD COLUMN target_float_min REAL") @beartype @@ -456,6 +476,7 @@ def get_list_items(user_id: str, list_type: str | None = None) -> list[dict]: rows = conn.execute( "SELECT id, item_name, list_type, added_at, " "target_below_rub, target_above_rub, " + "target_float_max, target_float_min, " "last_notified_below_at, last_notified_above_at " "FROM user_lists " "WHERE user_id=? AND list_type=? ORDER BY added_at DESC", @@ -465,6 +486,7 @@ def get_list_items(user_id: str, list_type: str | None = None) -> list[dict]: rows = conn.execute( "SELECT id, item_name, list_type, added_at, " "target_below_rub, target_above_rub, " + "target_float_max, target_float_min, " "last_notified_below_at, last_notified_above_at " "FROM user_lists " "WHERE user_id=? ORDER BY added_at DESC", @@ -480,8 +502,14 @@ def set_list_item_targets( list_type: str, target_below_rub: float | None, target_above_rub: float | None, + target_float_max: float | None = None, + target_float_min: float | None = None, ) -> int: - """Set list thresholds. Changing a threshold resets its notification cooldown.""" + """Set list thresholds. Changing a threshold resets its notification cooldown. + + Float bounds (0.0..1.0) optionally restrict alerts to listings whose float + falls in [target_float_min, target_float_max]. Either bound is None → ignored. + """ with get_conn() as conn: current = conn.execute( """ @@ -502,6 +530,8 @@ def set_list_item_targets( UPDATE user_lists SET target_below_rub=?, target_above_rub=?, + target_float_max=?, + target_float_min=?, last_notified_below_at=?, last_notified_above_at=? WHERE user_id=? AND item_name=? AND list_type=? @@ -509,6 +539,8 @@ def set_list_item_targets( ( target_below_rub, target_above_rub, + target_float_max, + target_float_min, None if below_changed else current["last_notified_below_at"], None if above_changed else current["last_notified_above_at"], user_id, @@ -527,6 +559,7 @@ def get_all_list_items_with_targets() -> list[dict]: """ SELECT id, user_id, item_name, list_type, added_at, target_below_rub, target_above_rub, + target_float_max, target_float_min, last_notified_below_at, last_notified_above_at FROM user_lists WHERE target_below_rub IS NOT NULL OR target_above_rub IS NOT NULL @@ -576,3 +609,62 @@ def get_all_list_names() -> set[str]: with get_conn() as conn: rows = conn.execute("SELECT DISTINCT item_name FROM user_lists").fetchall() return {row[0] for row in rows} + + +# --- Steam Market price cache (arbitrage delta vs lis-skins) --- + + +@beartype +def upsert_steam_price( + name: str, + lowest_usd: float | None, + median_usd: float | None, + volume: int | None, +) -> None: + """Insert or replace Steam Market price for a single item.""" + with get_conn() as conn: + conn.execute( + """ + INSERT INTO steam_prices (name, lowest_usd, median_usd, volume, fetched_at) + VALUES (?, ?, ?, ?, datetime('now')) + ON CONFLICT(name) DO UPDATE SET + lowest_usd = excluded.lowest_usd, + median_usd = excluded.median_usd, + volume = excluded.volume, + fetched_at = excluded.fetched_at + """, + (name, lowest_usd, median_usd, volume), + ) + + +@beartype +def get_steam_prices() -> dict[str, dict]: + """Return {name: {lowest_usd, median_usd, volume, fetched_at}} for all cached items.""" + with get_conn() as conn: + rows = conn.execute("SELECT * FROM steam_prices").fetchall() + return { + row["name"]: { + "lowest_usd": row["lowest_usd"], + "median_usd": row["median_usd"], + "volume": row["volume"], + "fetched_at": row["fetched_at"], + } + for row in rows + } + + +@beartype +def get_steam_price(name: str) -> dict | None: + """Lookup Steam Market price for a single item. None if not cached yet.""" + with get_conn() as conn: + row = conn.execute( + "SELECT * FROM steam_prices WHERE name = ?", (name,) + ).fetchone() + if not row: + return None + return { + "lowest_usd": row["lowest_usd"], + "median_usd": row["median_usd"], + "volume": row["volume"], + "fetched_at": row["fetched_at"], + } diff --git a/tools/steam-sniper/deploy.py b/tools/steam-sniper/deploy.py index 39ffb15..78c655e 100644 --- a/tools/steam-sniper/deploy.py +++ b/tools/steam-sniper/deploy.py @@ -36,6 +36,7 @@ "main.py", "db.py", "category.py", + "digest.py", "dashboard.html", "pyproject.toml", ".env", @@ -57,6 +58,8 @@ "deploy/steam-sniper-bot.service": f"/etc/systemd/system/{SERVICE_BOT}.service", "deploy/steam-sniper-snapshot.service": "/etc/systemd/system/steam-sniper-snapshot.service", "deploy/steam-sniper-snapshot.timer": f"/etc/systemd/system/{SERVICE_SNAPSHOT_TIMER}", + "deploy/steam-sniper-digest.service": "/etc/systemd/system/steam-sniper-digest.service", + "deploy/steam-sniper-digest.timer": "/etc/systemd/system/steam-sniper-digest.timer", } # Nginx config (local path -> remote path) diff --git a/tools/steam-sniper/deploy/steam-sniper-digest.service b/tools/steam-sniper/deploy/steam-sniper-digest.service new file mode 100644 index 0000000..1c43200 --- /dev/null +++ b/tools/steam-sniper/deploy/steam-sniper-digest.service @@ -0,0 +1,13 @@ +[Unit] +Description=Steam Sniper Weekly Telegram Digest +Wants=network-online.target +After=network-online.target + +[Service] +Type=oneshot +User=root +WorkingDirectory=/opt/steam-sniper +EnvironmentFile=/opt/steam-sniper/.env +ExecStart=/opt/steam-sniper/.venv/bin/python3 digest.py +Nice=10 +TimeoutStartSec=5min diff --git a/tools/steam-sniper/deploy/steam-sniper-digest.timer b/tools/steam-sniper/deploy/steam-sniper-digest.timer new file mode 100644 index 0000000..d9e1243 --- /dev/null +++ b/tools/steam-sniper/deploy/steam-sniper-digest.timer @@ -0,0 +1,12 @@ +[Unit] +Description=Weekly Steam Sniper Telegram digest (Sunday 18:00 MSK) + +[Timer] +# Sunday at 18:00 МСК (15:00 UTC) +OnCalendar=Sun *-*-* 18:00:00 +Persistent=true +RandomizedDelaySec=10min +Unit=steam-sniper-digest.service + +[Install] +WantedBy=timers.target diff --git a/tools/steam-sniper/deploy_quick.py b/tools/steam-sniper/deploy_quick.py index 56ba7ee..9a50005 100644 --- a/tools/steam-sniper/deploy_quick.py +++ b/tools/steam-sniper/deploy_quick.py @@ -24,16 +24,30 @@ FILES = [ "db.py", "server.py", + "digest.py", "category.py", "dashboard.html", "static/css/styles.css", - "static/js/catalog.js", + # ALL js modules — historically only some were listed and adding a new + # export to utils.js silently 502'd the frontend (catalog imported + # `steamDeltaBadge` from a stale utils.js on disk, JS module load failed, + # whole dashboard went blank). Glob-style "everything in static/js" is + # safer than picking-and-choosing. + "static/js/alerts.js", "static/js/cases.js", + "static/js/catalog.js", + "static/js/chart.js", + "static/js/events.js", "static/js/item_detail.js", "static/js/lists.js", "static/js/main.js", + "static/js/modal.js", + "static/js/router.js", + "static/js/search.js", + "static/js/state.js", "static/js/stats.js", "static/js/theme.js", + "static/js/utils.js", "static/js/watchlist.js", "static/sw.js", "static/icons/logo.png", diff --git a/tools/steam-sniper/digest.py b/tools/steam-sniper/digest.py new file mode 100644 index 0000000..ad9649d --- /dev/null +++ b/tools/steam-sniper/digest.py @@ -0,0 +1,331 @@ +"""Weekly Telegram digest for Steam Sniper. + +Generates a once-a-week summary of activity in the user's lists: + - top price drops (week-over-week, requires price_history) + - alerts that fired during the week + - stale items (added >30 days ago, never alerted) + - best Steam Market arbitrages on items in user lists + +Run via: cd tools/steam-sniper && uv run python digest.py [--dry-run] + +Scheduled by systemd timer steam-sniper-digest.timer (weekly). +""" +from __future__ import annotations + +import argparse +import json +import logging +import os +import sqlite3 +import sys +import urllib.parse +import urllib.request +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import db + +logger = logging.getLogger("sniper.digest") + +# Lis-skins markup multiplier (matches server.py LIS_SKINS_RATE_MULTIPLIER). +# Without this the Steam-vs-lis comparison double-counts the markup. +LIS_SKINS_RATE_MULTIPLIER = 1.0314 + + +# --- Data collection --- + + +def _get_lis_rate() -> float: + """Get USD→RUB rate (lis-skins-adjusted) from the same DB the server uses.""" + cached = db.get_cached_rate("USD") + if cached is None or cached <= 0: + return 0.0 + return cached * LIS_SKINS_RATE_MULTIPLIER + + +def _all_list_items() -> list[dict]: + """Return all rows across all users' favorite/wishlist with full target info.""" + with db.get_conn() as conn: + rows = conn.execute( + """ + SELECT id, user_id, item_name, list_type, added_at, + target_below_rub, target_above_rub, + target_float_max, target_float_min, + last_notified_below_at, last_notified_above_at + FROM user_lists + ORDER BY added_at DESC + """ + ).fetchall() + return [dict(r) for r in rows] + + +def _alerts_in_window(days: int = 7) -> list[dict]: + """Return all alerts logged in the last N days.""" + with db.get_conn() as conn: + rows = conn.execute( + """ + SELECT name, type, price_usd, target_rub, ts, message + FROM alerts + WHERE ts >= datetime('now', ?) + ORDER BY ts DESC + """, + (f"-{days} days",), + ).fetchall() + return [dict(r) for r in rows] + + +def _price_at_or_before(name: str, days_ago: int) -> float | None: + """Find the earliest price snapshot at or after `days_ago`. Returns USD price.""" + with db.get_conn() as conn: + row = conn.execute( + """ + SELECT price_usd FROM price_history + WHERE name_lower = ? AND ts >= datetime('now', ?) + ORDER BY ts ASC + LIMIT 1 + """, + (name.lower(), f"-{days_ago} days"), + ).fetchone() + return row["price_usd"] if row else None + + +def _current_price_usd(name: str) -> float | None: + """Latest snapshot for an item.""" + with db.get_conn() as conn: + row = conn.execute( + """ + SELECT price_usd FROM price_history + WHERE name_lower = ? + ORDER BY ts DESC + LIMIT 1 + """, + (name.lower(),), + ).fetchone() + return row["price_usd"] if row else None + + +def _steam_prices_map() -> dict[str, dict]: + return db.get_steam_prices() + + +# --- Section builders --- + + +def _format_rub(rub: float) -> str: + return f"{round(rub):,} ₽".replace(",", " ") + + +def _section_top_drops(rate: float, items: list[dict], top_n: int = 5) -> list[str]: + """Items with biggest week-over-week price drop (negative %).""" + drops: list[tuple[float, str, float, float]] = [] # (pct, name, old_rub, new_rub) + for it in items: + name = it["item_name"] + new_usd = _current_price_usd(name) + old_usd = _price_at_or_before(name, days_ago=7) + if not new_usd or not old_usd or old_usd <= 0: + continue + pct = (new_usd - old_usd) / old_usd * 100 + if pct >= 0: # only drops + continue + drops.append((pct, name, old_usd * rate, new_usd * rate)) + drops.sort(key=lambda x: x[0]) # most negative first + if not drops: + return [] + lines = ["🔥 Топ скидок за неделю:"] + for pct, name, old_rub, new_rub in drops[:top_n]: + lines.append(f" • {name}") + lines.append(f" {_format_rub(old_rub)} → {_format_rub(new_rub)} ({pct:+.1f}%)") + return lines + + +def _section_alerts(alerts: list[dict], top_n: int = 5) -> list[str]: + """Alerts fired during the window. type='buy' means price dropped to target_below.""" + if not alerts: + return [] + # Group by item_name + type ("buy" or "sell"), count + counts: dict[tuple[str, str], int] = {} + for a in alerts: + key = (a["name"], a["type"]) + counts[key] = counts.get(key, 0) + 1 + if not counts: + return [] + sorted_alerts = sorted(counts.items(), key=lambda x: -x[1]) + lines = [f"🎯 Алерты за неделю ({len(alerts)} срабатываний):"] + for (name, alert_type), n in sorted_alerts[:top_n]: + icon = "🔴" if alert_type == "buy" else "🟢" + lines.append(f" {icon} {name} ({n}×)") + return lines + + +def _section_stale(items: list[dict], days: int = 30) -> list[str]: + """Items added >N days ago that never triggered an alert.""" + cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat() + stale: list[str] = [] + for it in items: + if it["added_at"] >= cutoff: + continue + if it["last_notified_below_at"] or it["last_notified_above_at"]: + continue + if it["target_below_rub"] is None and it["target_above_rub"] is None: + continue + stale.append(f" • {it['item_name']} ({it['list_type']})") + if not stale: + return [] + lines = [f"📉 Висят >{days} дней без хитов ({len(stale)} шт):"] + lines.extend(stale[:10]) + if len(stale) > 10: + lines.append(f" …и ещё {len(stale) - 10}") + return lines + + +def _section_arbitrage(rate: float, items: list[dict], top_n: int = 5) -> list[str]: + """Best Steam-vs-lis-skins arbitrages on items currently in lists.""" + steam_map = _steam_prices_map() + if not steam_map: + return [] + candidates: list[tuple[float, str, float, float, int]] = [] + seen: set[str] = set() + for it in items: + name = it["item_name"] + if name in seen: + continue + seen.add(name) + sp = steam_map.get(name) + if not sp or not sp.get("median_usd"): + continue + cur_usd = _current_price_usd(name) + if not cur_usd: + continue + lis_rub = cur_usd * rate + # Reverse the lis-skins markup for fair Steam comparison + steam_rub = sp["median_usd"] * rate / LIS_SKINS_RATE_MULTIPLIER + if steam_rub <= 0: + continue + delta_pct = (lis_rub - steam_rub) / steam_rub * 100 + if delta_pct > -15: # only real deals (after Steam's 15% commission) + continue + if (sp.get("volume") or 0) < 5: # skip noisy low-volume items + continue + candidates.append((delta_pct, name, lis_rub, steam_rub, sp.get("volume") or 0)) + candidates.sort(key=lambda x: x[0]) # biggest discount first + if not candidates: + return [] + lines = ["💎 Лучшие арбитражи к Steam:"] + for pct, name, lis_rub, steam_rub, vol in candidates[:top_n]: + lines.append(f" • {name}") + lines.append( + f" lis {_format_rub(lis_rub)} vs Steam {_format_rub(steam_rub)} " + f"({pct:+.0f}% • vol {vol}/нед)" + ) + return lines + + +# --- Main --- + + +def build_digest_text() -> str | None: + """Compose the full digest message. Returns None if nothing to report.""" + rate = _get_lis_rate() + if rate <= 0: + logger.warning("[digest] no USD/RUB rate cached; abort") + return None + + items = _all_list_items() + if not items: + logger.info("[digest] no list items; nothing to report") + return None + + alerts = _alerts_in_window(days=7) + + sections: list[list[str]] = [] + drops = _section_top_drops(rate, items) + if drops: + sections.append(drops) + alerts_section = _section_alerts(alerts) + if alerts_section: + sections.append(alerts_section) + arbitrage = _section_arbitrage(rate, items, top_n=5) + if arbitrage: + sections.append(arbitrage) + stale = _section_stale(items) + if stale: + sections.append(stale) + + if not sections: + return None + + header = ["📊 Steam Sniper — итоги недели", ""] + body = [] + for sec in sections: + body.extend(sec) + body.append("") + return "\n".join(header + body).rstrip() + + +def _get_chat_ids() -> list[str]: + raw = ( + os.environ.get("DIGEST_CHAT_IDS") + or os.environ.get("LIST_ALERT_CHAT_IDS") + or os.environ.get("LESHA_TG_CHAT_ID") + or "" + ) + return [c.strip() for c in raw.split(",") if c.strip()] + + +def _send_telegram(text: str, chat_ids: list[str]) -> int: + token = os.environ.get("TELEGRAM_BOT_TOKEN") + if not token or not chat_ids: + logger.warning("[digest] TELEGRAM_BOT_TOKEN or chat_ids missing; skipping send") + return 0 + api_url = f"https://api.telegram.org/bot{token}/sendMessage" + sent = 0 + for chat_id in chat_ids: + payload = urllib.parse.urlencode({ + "chat_id": chat_id, + "text": text, + "disable_web_page_preview": "true", + }).encode("utf-8") + req = urllib.request.Request( + api_url, + data=payload, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + try: + with urllib.request.urlopen(req, timeout=10) as resp: + data = json.loads(resp.read().decode("utf-8")) + if data.get("ok"): + sent += 1 + else: + logger.warning("[digest] Telegram failed for %s: %s", chat_id, data) + except Exception as e: + logger.warning("[digest] Telegram exception for %s: %s", chat_id, e) + return sent + + +def main() -> int: + parser = argparse.ArgumentParser(description="Weekly Steam Sniper digest") + parser.add_argument("--dry-run", action="store_true", help="Print to stdout instead of sending to TG") + args = parser.parse_args() + + if sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8": + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + sys.stderr.reconfigure(encoding="utf-8", errors="replace") + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") + + text = build_digest_text() + if not text: + print("[digest] no data to report; exiting cleanly") + return 0 + + if args.dry_run: + print(text) + return 0 + + chat_ids = _get_chat_ids() + sent = _send_telegram(text, chat_ids) + print(f"[digest] sent to {sent}/{len(chat_ids)} chats") + return 0 if sent > 0 else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/steam-sniper/extension/background.js b/tools/steam-sniper/extension/background.js index f2437d0..217cc6d 100644 --- a/tools/steam-sniper/extension/background.js +++ b/tools/steam-sniper/extension/background.js @@ -69,6 +69,23 @@ async function setTargets(itemName, listType, targetBelow, targetAbove) { } } +async function checkLists(urls) { + if (!urls || !urls.length) return { ok: true, items: {} }; + try { + const qs = encodeURIComponent(urls.join("|")); + const res = await fetchWithTimeout( + `${API_BASE}/api/lists/check?user=${DEFAULT_USER}&urls=${qs}`, + { method: "GET" }, + REQUEST_TIMEOUT_MS + ); + if (!res.ok) return { ok: false, error: `HTTP ${res.status}` }; + const data = await res.json(); + return { ok: true, items: data.items || {} }; + } catch (err) { + return { ok: false, error: err.message || "Network error" }; + } +} + chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { if (msg.action === "addToList") { addToList(msg.item_name, msg.list_type, msg.source_url).then(sendResponse); @@ -83,5 +100,9 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { ).then(sendResponse); return true; } + if (msg.action === "checkLists") { + checkLists(msg.urls).then(sendResponse); + return true; + } return false; }); diff --git a/tools/steam-sniper/extension/content.js b/tools/steam-sniper/extension/content.js index a8c288a..1978a3c 100644 --- a/tools/steam-sniper/extension/content.js +++ b/tools/steam-sniper/extension/content.js @@ -91,6 +91,80 @@ return /\/market\//i.test(location.pathname); } + // ===== Inline highlight: color cards in user's lists when browsing lis-skins ===== + + /** Find all lis-skins item-card links on the current page. + * Returns array of {url, card} where `card` is the visual card element. */ + function findCardsOnPage() { + const out = []; + const seen = new Set(); + const links = document.querySelectorAll('a[href*="/market/csgo/"]'); + for (const a of links) { + const href = a.href || ""; + // Skip current page link (item page itself) + if (href === location.href || href === location.href.replace(/\/$/, "")) continue; + // Get last path segment (slug). Skip if it's a category root (e.g. /market/csgo/knife/) + // — those don't link to a specific item. Heuristic: slug must contain at least 3 dashes + // (skin name has multiple words). + let urlObj; + try { urlObj = new URL(href); } catch (e) { continue; } + const segs = urlObj.pathname.replace(/\/$/, "").split("/").filter(Boolean); + if (segs.length < 3) continue; + const slug = segs[segs.length - 1]; + if (!/-/.test(slug)) continue; + if (seen.has(href)) continue; + seen.add(href); + // Find the visual card — climb to nearest ancestor that looks like a card + let card = a.closest('[class*="card"],[class*="Card"],[class*="item"],[class*="Item"]'); + if (!card || card === document.body) card = a; + out.push({ url: href, card }); + } + return out; + } + + /** Apply highlight to a card based on membership info. */ + function applyHighlight(card, info) { + if (!card || !info) return; + card.classList.add("sniper-highlight"); + if (info.favorite) card.classList.add("sniper-highlight-fav"); + if (info.wishlist) card.classList.add("sniper-highlight-wish"); + // Build tooltip text + const tipParts = []; + if (info.favorite) tipParts.push("♥ Избранное"); + if (info.wishlist) tipParts.push("★ Хотелки"); + if (info.target_below_rub != null) tipParts.push(`🔴 ниже ${info.target_below_rub} ₽`); + if (info.target_above_rub != null) tipParts.push(`🟢 выше ${info.target_above_rub} ₽`); + if (info.target_float_max != null) tipParts.push(`float ≤ ${info.target_float_max}`); + if (info.target_float_min != null) tipParts.push(`float ≥ ${info.target_float_min}`); + if (tipParts.length) { + card.title = "Steam Sniper — " + tipParts.join(" · "); + } + } + + /** Scan visible cards and ask backend which are in the user's lists. */ + function scanAndHighlight() { + const cards = findCardsOnPage(); + if (!cards.length) return; + const urls = cards.map(c => c.url).slice(0, 100); // batch cap + chrome.runtime.sendMessage( + { action: "checkLists", urls }, + (response) => { + if (chrome.runtime.lastError || !response || !response.ok) return; + const items = response.items || {}; + for (const { url, card } of cards) { + if (items[url]) applyHighlight(card, items[url]); + } + } + ); + } + + // Debounced re-scan on DOM changes (lis-skins lazy-loads cards on scroll) + let scanTimer = null; + function scheduleScan() { + if (scanTimer) clearTimeout(scanTimer); + scanTimer = setTimeout(scanAndHighlight, 600); + } + function showToast(message, isError) { const existing = document.querySelector(".sniper-toast"); if (existing) existing.remove(); @@ -383,6 +457,7 @@ } injectButton(); + scheduleScan(); // initial highlight pass on catalog/listing pages const interval = setInterval(() => { checkCount++; @@ -394,6 +469,7 @@ const observer = new MutationObserver(() => { checkForNavigation(); + scheduleScan(); // re-highlight on lazy-loaded cards }); observer.observe(document.body, { childList: true, subtree: true }); })(); diff --git a/tools/steam-sniper/extension/manifest.json b/tools/steam-sniper/extension/manifest.json index f307b8e..f0bc453 100644 --- a/tools/steam-sniper/extension/manifest.json +++ b/tools/steam-sniper/extension/manifest.json @@ -1,8 +1,8 @@ { "manifest_version": 3, "name": "Steam Sniper", - "version": "1.5", - "description": "Add items from lis-skins.com to Steam Sniper with target prices (🔴 below / 🟢 above) and Telegram alerts. v1.5: 8s timeout + URL slug as authoritative resolver (fixes Tiger Tooth → Scorched skin mix-up).", + "version": "1.6", + "description": "Add items from lis-skins.com to Steam Sniper with target prices (🔴 below / 🟢 above) and Telegram alerts. v1.6: inline highlight on catalog cards — see at-a-glance which items are in your favorite/wishlist while browsing.", "permissions": [], "host_permissions": [ "http://72.56.37.150/*" diff --git a/tools/steam-sniper/extension/styles.css b/tools/steam-sniper/extension/styles.css index 5353439..3a24655 100644 --- a/tools/steam-sniper/extension/styles.css +++ b/tools/steam-sniper/extension/styles.css @@ -192,3 +192,78 @@ .sniper-toast.sniper-fade-out { opacity: 0; } + +/* Inline highlight on lis-skins catalog cards (Steam Sniper v1.6). + * Adds a colored ring around items already in the user's lists so they + * spot them without clicking through. */ + +.sniper-highlight { + position: relative; + outline: 2px solid transparent; + outline-offset: 2px; + border-radius: 6px; + transition: outline-color 0.2s; +} + +.sniper-highlight-fav { + outline-color: #e74c3c; + box-shadow: 0 0 0 2px rgba(231, 76, 60, 0.18); +} + +.sniper-highlight-wish { + outline-color: #f1c40f; + box-shadow: 0 0 0 2px rgba(241, 196, 15, 0.18); +} + +.sniper-highlight-fav.sniper-highlight-wish { + outline-color: #e67e22; /* in both lists — orange (sniper brand color) */ + box-shadow: 0 0 0 2px rgba(230, 126, 34, 0.22); +} + +.sniper-highlight::after { + content: ""; + position: absolute; + top: 4px; + right: 4px; + width: 18px; + height: 18px; + border-radius: 50%; + background: rgba(0, 0, 0, 0.7); + pointer-events: none; + z-index: 10; +} + +.sniper-highlight-fav::after { + content: "♥"; + color: #e74c3c; + background: rgba(255, 255, 255, 0.95); + font-size: 12px; + display: flex; + align-items: center; + justify-content: center; + font-weight: bold; +} + +.sniper-highlight-wish:not(.sniper-highlight-fav)::after { + content: "★"; + color: #f1c40f; + background: rgba(255, 255, 255, 0.95); + font-size: 12px; + display: flex; + align-items: center; + justify-content: center; + font-weight: bold; +} + +.sniper-highlight-fav.sniper-highlight-wish::after { + content: "♥★"; + color: #e67e22; + background: rgba(255, 255, 255, 0.95); + font-size: 10px; + width: 26px; + display: flex; + align-items: center; + justify-content: center; + font-weight: bold; + border-radius: 9px; +} diff --git a/tools/steam-sniper/server.py b/tools/steam-sniper/server.py index 42ca7c5..3b48600 100644 --- a/tools/steam-sniper/server.py +++ b/tools/steam-sniper/server.py @@ -53,8 +53,17 @@ _usd_rub: float = 0.0 _last_update: str = "" _collector_task: asyncio.Task | None = None +_steam_prices_task: asyncio.Task | None = None +_steam_prices_cache: dict[str, dict] = {} # {name: {lowest_usd, median_usd, volume, fetched_at}} ITEM_META_CACHE_PATH = Path(__file__).parent / "data" / "item_meta_cache.json" +# Steam Market priceoverview — used to compute lis-skins vs Steam delta. +# Rate-limited (~40 req/min safely), so we only refresh items that users +# actually care about: favorites + wishlist + watchlist (~few hundred max). +STEAM_PRICEOVERVIEW_URL = "https://steamcommunity.com/market/priceoverview/" +STEAM_REFRESH_INTERVAL = 3600 # 1 hour between full passes +STEAM_REQUEST_DELAY = 1.5 # seconds between requests (40/min) + # Lis-skins uses their own USD/RUB rate ≈ CBR × 1.034 (3.4% markup). # All prices in lis-skins JSON are USD — multiply by this to match website RUB. @@ -641,10 +650,14 @@ async def _collect_once(send_list_alerts: bool = True) -> None: # Rebuild slug→name index used by /api/lists for source_url-based resolution _rebuild_slug_index() - # Rebuild category counts for catalog sidebar + # Pre-compute category + model on each item once per catalog refresh. + # /api/catalog hits this dict 23k times per request; computing it inline + # cost ~150ms per call. Cached as _cat / _model on the item dicts. counts: dict[str, int] = {} for item in _prices.values(): cat = classify(item["name"]) + item["_cat"] = cat + item["_model"] = _weapon_model(item["name"]) if cat in _MODEL_CATEGORIES else "" counts[cat] = counts.get(cat, 0) + 1 _category_counts = counts @@ -659,13 +672,18 @@ async def _collect_once(send_list_alerts: bool = True) -> None: if cached: _usd_rub = cached - # Snapshot watched items - watched = db.get_watchlist_names() - snapshots = [ - (name, _prices[name]["price"]) - for name in watched - if name in _prices - ] + # Snapshot prices for everything someone tracks: watchlist + favorites + wishlist. + # Required for the weekly digest's "biggest price drops in your lists" section — + # without history snapshots there's no week-over-week delta to compute. + watched_names = db.get_watchlist_names() + list_names = db.get_all_list_names() + tracked = watched_names | list_names + # _prices keys are name.lower(); items use original name as key for history + snapshots: list[tuple[str, float]] = [] + for canonical_lower in tracked: + item = _prices.get(canonical_lower.lower()) + if item: + snapshots.append((canonical_lower, item["price"])) if snapshots: db.insert_price_snapshots(snapshots) @@ -689,21 +707,145 @@ async def _collector_loop() -> None: logger.error("Collector loop error: %s", e) +def _steam_priceoverview_sync(name: str) -> dict | None: + """Fetch Steam Market priceoverview for one item (USD). + + Returns {lowest_usd, median_usd, volume} or None on error/missing data. + Costs ~400ms per call. Caller must rate-limit (~40 req/min). + """ + from urllib.parse import quote + + url = ( + f"{STEAM_PRICEOVERVIEW_URL}" + f"?appid=730¤cy=1&market_hash_name={quote(name)}" + ) + headers = {"User-Agent": "Mozilla/5.0", "Accept": "application/json"} + req = urllib.request.Request(url, headers=headers) + try: + with urllib.request.urlopen(req, timeout=8) as resp: + data = json.loads(resp.read()) + except Exception as e: + logger.warning("[steam-prices] fetch failed for %r: %s", name, e) + return None + if not data.get("success"): + return None + + def parse_dollars(s: str | None) -> float | None: + if not s: + return None + try: + return float(s.replace("$", "").replace(",", "").strip()) + except (ValueError, AttributeError): + return None + + volume_str = data.get("volume", "0").replace(",", "") + try: + volume = int(volume_str) + except ValueError: + volume = 0 + return { + "lowest_usd": parse_dollars(data.get("lowest_price")), + "median_usd": parse_dollars(data.get("median_price")), + "volume": volume, + } + + +async def _refresh_steam_prices() -> None: + """One-shot pass: fetch Steam prices for all items in user lists + watchlist. + + Rate-limited: STEAM_REQUEST_DELAY between requests. ~5 min per 200 items. + Stores results in db.steam_prices and refreshes _steam_prices_cache. + """ + global _steam_prices_cache + + # Watched items: union of user_lists + watchlist names + watched_names = db.get_all_list_names() | db.get_watchlist_names() + if not watched_names: + logger.info("[steam-prices] no watched items, skipping refresh") + return + + logger.info("[steam-prices] refreshing %d items (~%d minutes)", + len(watched_names), len(watched_names) * STEAM_REQUEST_DELAY / 60) + + ok = 0 + failed = 0 + for name in watched_names: + result = await asyncio.to_thread(_steam_priceoverview_sync, name) + if result is not None: + db.upsert_steam_price( + name=name, + lowest_usd=result["lowest_usd"], + median_usd=result["median_usd"], + volume=result["volume"], + ) + ok += 1 + else: + failed += 1 + await asyncio.sleep(STEAM_REQUEST_DELAY) + + _steam_prices_cache = db.get_steam_prices() + logger.info("[steam-prices] refresh done: ok=%d failed=%d cache_size=%d", + ok, failed, len(_steam_prices_cache)) + + +async def _steam_prices_loop() -> None: + """Background loop: refresh Steam prices every STEAM_REFRESH_INTERVAL seconds.""" + while True: + try: + await _refresh_steam_prices() + except Exception as e: + logger.error("[steam-prices] loop error: %s", e) + await asyncio.sleep(STEAM_REFRESH_INTERVAL) + + +def _compute_steam_delta(item_name: str, lis_rub: float | None) -> dict | None: + """Compute Steam Market delta for an item. + + Returns {steam_price_rub, delta_pct, volume, fetched_at} or None if no + Steam data cached yet or lis price unavailable. Uses median_usd + (more stable than lowest_usd on low-volume items). + """ + if lis_rub is None or lis_rub <= 0: + return None + cached = _steam_prices_cache.get(item_name) + if not cached: + return None + median_usd = cached.get("median_usd") + if median_usd is None or median_usd <= 0: + return None + rate = _lis_rate() + steam_rub = median_usd * rate / LIS_SKINS_RATE_MULTIPLIER # remove lis-skins markup + delta_pct = (lis_rub - steam_rub) / steam_rub * 100 + return { + "steam_price_rub": round(steam_rub, 2), + "delta_pct": round(delta_pct, 1), + "volume": cached.get("volume", 0), + "fetched_at": cached.get("fetched_at"), + } + + # --- Lifespan --- @asynccontextmanager async def lifespan(_app: FastAPI) -> AsyncGenerator[None]: """Startup: init db, first collect, start loops. Shutdown: cancel loops.""" - global _collector_task, _image_cache, _item_meta + global _collector_task, _image_cache, _item_meta, _steam_prices_task, _steam_prices_cache db.init_db() await _load_image_cache() await _load_item_meta_cache() await _collect_once(send_list_alerts=False) + # Load any existing Steam price cache from DB so first request has data + # even before the slow refresh loop completes its first pass. + _steam_prices_cache = db.get_steam_prices() + logger.info("[steam-prices] loaded %d cached prices from DB", len(_steam_prices_cache)) _collector_task = asyncio.create_task(_collector_loop()) + _steam_prices_task = asyncio.create_task(_steam_prices_loop()) yield if _collector_task: _collector_task.cancel() + if _steam_prices_task: + _steam_prices_task.cancel() # --- App --- @@ -769,6 +911,10 @@ class ListTargetUpdateRequest(BaseModel): list_type: str # "favorite" or "wishlist" target_below_rub: float | None = None target_above_rub: float | None = None + # Float bounds (0.0..1.0) — when set, BELOW alert only fires on listings + # with float in [target_float_min, target_float_max]. Either is None → ignored. + target_float_max: float | None = None + target_float_min: float | None = None # CS2 rarity → color mapping (Russian category names from Steam API) @@ -1394,7 +1540,13 @@ async def _send_telegram_message(text: str, chat_ids: list[str] | None = None) - async def _check_list_alerts() -> None: - """Send Telegram alerts for favorite/wishlist thresholds with cooldown.""" + """Send Telegram alerts for favorite/wishlist thresholds with cooldown. + + Float-aware: when target_float_min/max are set on the entry, the BELOW + alert checks listings_snapshot.db for an actual listing matching BOTH + price AND float bounds. Without this, an alert could fire on a listing + with the right price but unusable wear. + """ rate = _lis_rate() if rate <= 0: return @@ -1409,12 +1561,34 @@ async def _check_list_alerts() -> None: below_target = entry.get("target_below_rub") if below_target is not None: - if current_price_rub <= float(below_target): + # Float-aware mode: find cheapest listing matching float bounds. + float_max = entry.get("target_float_max") + float_min = entry.get("target_float_min") + triggered = False + trigger_price_rub = current_price_rub + if float_max is not None or float_min is not None: + listings, _, _ = snapshot_get_item_listings( + entry["item_name"], + limit=1, + sort="price_asc", + float_min=float_min, + float_max=float_max, + ) + if listings: + cheapest_rub = listings[0]["price"] * rate + if cheapest_rub <= float(below_target): + triggered = True + trigger_price_rub = cheapest_rub + else: + if current_price_rub <= float(below_target): + triggered = True + + if triggered: if not _notified_recently(entry.get("last_notified_below_at")): message = _format_list_alert_message( entry, "below", - current_price_rub, + trigger_price_rub, url, ) sent = await _send_telegram_message(message) @@ -1521,111 +1695,123 @@ def get_catalog( model: str | None = Query(default=None), q: str | None = Query(default=None), ) -> dict: - """Browse full catalog with pagination, filtering, sorting, and search.""" + """Browse full catalog with pagination, filtering, sorting, and search. + + Perf note: filters first on raw _prices (~23k items, all O(1) checks), + enriches (image lookup, trend calc) only the final page. Earlier version + enriched all 23k items then filtered, making every catalog hit ~700ms. + """ rate = _lis_rate() if state not in ("all", "normal", "stattrak", "souvenir"): state = "all" - # Build enriched list from in-memory prices - items: list[dict] = [] + # Pre-compute query terms once + has_q = bool(q) + q_words: list[str] = [] + q_steam_names: set[str] | None = None + if has_q: + if any("\u0400" <= c <= "\u04ff" for c in q): + en_query = _translate_ru_to_en(q.lower()) + if en_query: + q_words = en_query.lower().split() + else: + steam_results = _steam_search(q) + q_steam_names = {sr["hash_name"].lower() for sr in steam_results} + else: + q_words = q.lower().split() + + # Stage 1: filter on raw _prices — no enrichment yet (no _get_item_image, + # no _calc_trend). Cat + model are pre-computed in _collect_once and stored + # on each item dict as _cat / _model — no per-request classify() calls. + filtered: list[tuple[dict, str, str]] = [] # (raw_item, cat, model_name) for item in _prices.values(): - cat = classify(item["name"]) + cat = item.get("_cat") or classify(item["name"]) - # Category filter if category and cat != category: continue - # State filter (StatTrak / Souvenir / normal) name = item["name"] - is_stattrak = "StatTrak\u2122" in name - is_souvenir = name.startswith("Souvenir ") or " Souvenir " in name - if state == "stattrak" and not is_stattrak: - continue - if state == "souvenir" and not is_souvenir: - continue - if state == "normal" and (is_stattrak or is_souvenir): - continue + if state != "all": + is_stattrak = "StatTrak\u2122" in name + is_souvenir = name.startswith("Souvenir ") or " Souvenir " in name + if state == "stattrak" and not is_stattrak: + continue + if state == "souvenir" and not is_souvenir: + continue + if state == "normal" and (is_stattrak or is_souvenir): + continue - item_dict = { - "name": item["name"], - "category": cat, - "model": _weapon_model(item["name"]) if cat in _MODEL_CATEGORIES else "", - "price_usd": item["price"], - "price_rub": round(item["price"] * rate, 2), - "count": item.get("count", 0), - "url": item.get("url", ""), - "image": _get_item_image(item["name"]), - "available": item.get("count", 0) > 0, - } - # Add trend for case items (used by cases tab) - if cat == "case": - item_dict["trend"] = _calc_trend(item["name"].lower()) - items.append(item_dict) - - # Search filter - if q: - has_cyrillic = any("\u0400" <= c <= "\u04ff" for c in q) - if has_cyrillic: - # Translate Russian terms to English, then search locally - en_query = _translate_ru_to_en(q.lower()) - if en_query: - words = en_query.lower().split() - items = [ - it for it in items - if all(w in it["name"].lower() for w in words) - ] - else: - # No translation found — fallback to Steam Market API - steam_results = _steam_search(q) - en_names = {sr["hash_name"].lower() for sr in steam_results} - items = [it for it in items if it["name"].lower() in en_names] + if has_q: + name_lower = name.lower() + if q_steam_names is not None: + if name_lower not in q_steam_names: + continue + elif not all(w in name_lower for w in q_words): + continue + + if "_model" in item: + model_name = item["_model"] else: - # English — direct substring match - words = q.lower().split() - items = [ - it for it in items - if all(w in it["name"].lower() for w in words) - ] + model_name = _weapon_model(item["name"]) if cat in _MODEL_CATEGORIES else "" + if model and model_name.lower() != model.lower(): + continue + filtered.append((item, cat, model_name)) + + # Models facet: cheap counts on filtered, no enrichment. models: list[dict[str, int | str]] = [] if category in _MODEL_CATEGORIES: model_counts: dict[str, int] = {} - for item in items: - model_name = item.get("model") or "" - if not model_name: - continue - model_counts[model_name] = model_counts.get(model_name, 0) + 1 + for _, _, mn in filtered: + if mn: + model_counts[mn] = model_counts.get(mn, 0) + 1 models = [ - {"name": model_name, "count": count} - for model_name, count in sorted( + {"name": mn, "count": c} + for mn, c in sorted( model_counts.items(), key=lambda pair: (-pair[1], pair[0].lower()), ) ] - if model: - items = [ - item for item in items - if (item.get("model") or "").lower() == model.lower() - ] - - # Sort + # Sort by raw fields — no need for enriched dict. if sort not in _CATALOG_SORTS: sort = "name_asc" - - sort_keys: dict[str, tuple] = { - "name_asc": (lambda x: x["name"].lower(), False), - "name_desc": (lambda x: x["name"].lower(), True), - "price_asc": (lambda x: x["price_usd"], False), - "price_desc": (lambda x: x["price_usd"], True), - "count_desc": (lambda x: x["count"], True), + sort_specs: dict[str, tuple] = { + "name_asc": (lambda t: t[0]["name"].lower(), False), + "name_desc": (lambda t: t[0]["name"].lower(), True), + "price_asc": (lambda t: t[0]["price"], False), + "price_desc": (lambda t: t[0]["price"], True), + "count_desc": (lambda t: t[0].get("count", 0), True), } - key_fn, reverse = sort_keys[sort] - items.sort(key=key_fn, reverse=reverse) + key_fn, reverse = sort_specs[sort] + filtered.sort(key=key_fn, reverse=reverse) - total = len(items) - page = items[offset : offset + limit] + total = len(filtered) + page_tuples = filtered[offset : offset + limit] + + # Stage 2: enrich only the page (typically 50-200 items, not 23k). + page: list[dict] = [] + for raw_item, cat, mn in page_tuples: + name = raw_item["name"] + price_rub = round(raw_item["price"] * rate, 2) + item_dict = { + "name": name, + "category": cat, + "model": mn, + "price_usd": raw_item["price"], + "price_rub": price_rub, + "count": raw_item.get("count", 0), + "url": raw_item.get("url", ""), + "image": _get_item_image(name), + "available": raw_item.get("count", 0) > 0, + } + if cat == "case": + item_dict["trend"] = _calc_trend(name.lower()) + steam_delta = _compute_steam_delta(name, price_rub) + if steam_delta: + item_dict["steam"] = steam_delta + page.append(item_dict) return { "items": page, @@ -1678,6 +1864,19 @@ def get_item_detail( "steam_price_rub": None, "discount_pct": None, } + # Fill Steam Market data if cached. Use median (more stable than lowest). + cached_steam = _steam_prices_cache.get(summary_name) + if cached_steam and cached_steam.get("median_usd"): + median_usd = cached_steam["median_usd"] + steam_rub_raw = median_usd * rate + # lis-skins markup is built into _lis_rate; back out for fair comparison + summary["steam_price_usd"] = median_usd + summary["steam_price_rub"] = round(steam_rub_raw / LIS_SKINS_RATE_MULTIPLIER, 2) + summary["steam_volume"] = cached_steam.get("volume", 0) + summary["steam_fetched_at"] = cached_steam.get("fetched_at") + if summary["price_rub"] and summary["steam_price_rub"]: + delta = (summary["price_rub"] - summary["steam_price_rub"]) / summary["steam_price_rub"] * 100 + summary["discount_pct"] = round(delta, 1) listings_raw, total_available, listings_updated = snapshot_get_item_listings( summary_name, limit, @@ -1864,6 +2063,12 @@ def update_list_targets_endpoint(body: ListTargetUpdateRequest) -> JSONResponse for value in (body.target_below_rub, body.target_above_rub): if value is not None and value <= 0: return JSONResponse({"error": "targets must be > 0 or null"}, status_code=400) + for fv in (body.target_float_max, body.target_float_min): + if fv is not None and not (0.0 <= fv <= 1.0): + return JSONResponse({"error": "float bounds must be in [0.0, 1.0]"}, status_code=400) + if (body.target_float_min is not None and body.target_float_max is not None + and body.target_float_min > body.target_float_max): + return JSONResponse({"error": "target_float_min must be ≤ target_float_max"}, status_code=400) resolved_name = _resolve_item_name(body.item_name) count = db.set_list_item_targets( @@ -1872,6 +2077,8 @@ def update_list_targets_endpoint(body: ListTargetUpdateRequest) -> JSONResponse list_type=body.list_type, target_below_rub=body.target_below_rub, target_above_rub=body.target_above_rub, + target_float_max=body.target_float_max, + target_float_min=body.target_float_min, ) if count == 0 and resolved_name != body.item_name: count = db.set_list_item_targets( @@ -1880,12 +2087,92 @@ def update_list_targets_endpoint(body: ListTargetUpdateRequest) -> JSONResponse list_type=body.list_type, target_below_rub=body.target_below_rub, target_above_rub=body.target_above_rub, + target_float_max=body.target_float_max, + target_float_min=body.target_float_min, ) if count == 0: return JSONResponse({"error": "list item not found"}, status_code=404) return {"ok": True, "item_name": resolved_name} +@app.get("/api/lists/check") +@beartype +def check_list_membership( + user: str = Query(...), + names: str | None = Query(default=None, description="Pipe-separated item names"), + urls: str | None = Query(default=None, description="Pipe-separated lis-skins URLs"), +) -> dict: + """Bulk-check which items are in the user's favorite/wishlist. + + Used by the extension's inline-highlight feature to color cards on + lis-skins catalog pages without one request per card. + + Accepts EITHER `names` (display names) OR `urls` (lis-skins URLs, resolved + via slug index — more reliable on catalog pages where DOM parsing is fragile). + + Returns: {items: {key: membership}, url_to_name: {url: resolved_name}} + where `key` is the original input string (name or URL) and membership is + {favorite, wishlist, target_below_rub, target_above_rub, + target_float_max, target_float_min}. + Items not in any list are omitted from `items`. + """ + def _split_pipe(s: str | None) -> list[str]: + if not s: + return [] + return [x.strip() for x in s.split("|") if x.strip()] + + raw_names = _split_pipe(names) + raw_urls = _split_pipe(urls) + + # Resolve URLs to canonical names via slug index + url_to_name: dict[str, str] = {} + for u in raw_urls: + n = _resolve_from_source_url(u) + if n: + url_to_name[u] = n + + all_keys = list(raw_names) + list(url_to_name.values()) + if not all_keys: + return {"items": {}, "url_to_name": url_to_name} + + # Fetch all of user's list rows once (likely <200 entries) and build lookup + all_items = db.get_list_items(user_id=user, list_type=None) + by_name_lower: dict[str, dict] = {} + for row in all_items: + nm = row["item_name"] + key_lower = nm.lower() + slot = by_name_lower.setdefault(key_lower, { + "name": nm, + "favorite": False, + "wishlist": False, + "target_below_rub": None, + "target_above_rub": None, + "target_float_max": None, + "target_float_min": None, + }) + if row["list_type"] == "favorite": + slot["favorite"] = True + elif row["list_type"] == "wishlist": + slot["wishlist"] = True + for col in ("target_below_rub", "target_above_rub", + "target_float_max", "target_float_min"): + if row.get(col) is not None and slot[col] is None: + slot[col] = row[col] + + # Build response keyed by original input (names or urls) + result: dict[str, dict] = {} + for nm in raw_names: + hit = by_name_lower.get(nm.lower()) + if hit: + result[nm] = hit + for u, resolved in url_to_name.items(): + hit = by_name_lower.get(resolved.lower()) + if hit: + result[u] = hit + + return {"items": result, "url_to_name": url_to_name} + + @app.get("/api/lists") @beartype def get_list_items_endpoint(user: str = Query(...), type: str | None = Query(default=None)) -> dict: @@ -1917,13 +2204,18 @@ def get_list_items_endpoint(user: str = Query(...), type: str | None = Query(def ) target_below = item.get("target_below_rub") target_above = item.get("target_above_rub") - enriched.append({ + # Reuse pre-computed _cat from catalog refresh to avoid classify() per item + cat = item_data.get("_cat") if item_data else None + if cat is None: + cat = classify(name) + steam_delta = _compute_steam_delta(name, current_price_rub) + enriched_item = { **item, "image": _get_item_image(name), "price_rub": current_price_rub, "current_price_rub": current_price_rub, "count": item_data["count"] if item_data else None, - "category": classify(name), + "category": cat, "url": item_data["url"] if item_data else "", "alert_below_triggered": ( current_price_rub is not None @@ -1935,7 +2227,10 @@ def get_list_items_endpoint(user: str = Query(...), type: str | None = Query(def and target_above is not None and current_price_rub >= float(target_above) ), - }) + } + if steam_delta: + enriched_item["steam"] = steam_delta + enriched.append(enriched_item) return {"items": enriched} diff --git a/tools/steam-sniper/static/css/styles.css b/tools/steam-sniper/static/css/styles.css index 6048dd9..59d9151 100644 --- a/tools/steam-sniper/static/css/styles.css +++ b/tools/steam-sniper/static/css/styles.css @@ -1498,16 +1498,73 @@ min-height: 1.9rem; padding: 0.2rem 0.65rem; border-radius: 999px; - background: rgba(239, 68, 68, 0.12); - color: #ff8b72; - border: 1px solid rgba(239, 68, 68, 0.35); + background: rgba(115, 115, 115, 0.18); + color: var(--text-mid); + border: 1px solid rgba(115, 115, 115, 0.4); font-weight: 700; } + .detail-price-chip.steam-deal { + background: rgba(34, 197, 94, 0.16); + color: #4ade80; + border-color: rgba(34, 197, 94, 0.45); + } + + .detail-price-chip.steam-overpay { + background: rgba(251, 146, 60, 0.16); + color: #fb923c; + border-color: rgba(251, 146, 60, 0.45); + } + + .detail-price-chip.steam-parity { + background: rgba(115, 115, 115, 0.18); + color: var(--text-mid); + border-color: rgba(115, 115, 115, 0.4); + } + .detail-price-steam { color: var(--text-mid); } + .detail-price-vol { + color: var(--text-mid); + font-size: 0.85rem; + margin-left: 0.5rem; + opacity: 0.75; + } + + .detail-price-vol-warn { + color: #fb923c; + opacity: 1; + } + + /* Compact Steam delta badge for catalog/list cards (next to price) */ + .steam-delta { + display: inline-block; + margin-left: 0.4rem; + padding: 0.1rem 0.4rem; + border-radius: 4px; + font-size: 0.72rem; + font-weight: 600; + vertical-align: middle; + cursor: help; + } + + .steam-delta.steam-deal { + background: rgba(34, 197, 94, 0.15); + color: #4ade80; + } + + .steam-delta.steam-overpay { + background: rgba(251, 146, 60, 0.15); + color: #fb923c; + } + + .steam-delta.steam-parity { + background: rgba(115, 115, 115, 0.15); + color: var(--text-mid); + } + .detail-primary-link, .detail-action-btn { display: inline-flex; diff --git a/tools/steam-sniper/static/js/cases.js b/tools/steam-sniper/static/js/cases.js index b244f81..9d56076 100644 --- a/tools/steam-sniper/static/js/cases.js +++ b/tools/steam-sniper/static/js/cases.js @@ -1,5 +1,5 @@ // Cases tab: browse CS2 cases with price trends, pagination, sort, search -import { fmtRub } from './utils.js'; +import { fmtRub, steamDeltaBadge } from './utils.js'; import { events } from './events.js'; import { cacheCatalogItems } from './lists.js'; @@ -83,7 +83,7 @@ function renderCasesGrid(items) { imgHtml + '
' + item.name + '
' + '
' + - '
' + fmtRub(item.price_rub) + '
' + + '
' + fmtRub(item.price_rub) + steamDeltaBadge(item.steam) + '
' + '
' + '
' + item.count + ' qty
' + '
' + diff --git a/tools/steam-sniper/static/js/catalog.js b/tools/steam-sniper/static/js/catalog.js index 0ef53f5..dcaa6ce 100644 --- a/tools/steam-sniper/static/js/catalog.js +++ b/tools/steam-sniper/static/js/catalog.js @@ -1,5 +1,5 @@ // Catalog tab: browse full lis-skins catalog with pagination, sidebar, sort, search -import { fmtRub } from './utils.js'; +import { fmtRub, steamDeltaBadge } from './utils.js'; import { events } from './events.js'; import { cacheCatalogItems } from './lists.js'; @@ -130,7 +130,7 @@ function renderGrid(items) { imgHtml + '
' + item.name + '
' + '
' + - '
' + fmtRub(item.price_rub) + '
' + + '
' + fmtRub(item.price_rub) + steamDeltaBadge(item.steam) + '
' + '
' + '
' + (CAT_LABELS[item.category] || item.category) + '
' + '
' + item.count + ' шт.
' + diff --git a/tools/steam-sniper/static/js/item_detail.js b/tools/steam-sniper/static/js/item_detail.js index fe9e313..46cab38 100644 --- a/tools/steam-sniper/static/js/item_detail.js +++ b/tools/steam-sniper/static/js/item_detail.js @@ -196,13 +196,29 @@ function renderStateFlags(summary) { function renderPriceCompare(summary) { if (summary.steam_price_rub == null) return ''; - const discount = summary.discount_pct != null - ? `${summary.discount_pct > 0 ? '-' : ''}${summary.discount_pct}%` - : ''; + // discount_pct = (lis - steam) / steam * 100 + // negative → lis cheaper (good for buyer) + // positive → lis more expensive (you're paying premium for instant delivery) + const delta = summary.discount_pct; + const volume = summary.steam_volume ?? 0; + const lowVol = volume < 5; + let chip = ''; + if (delta != null) { + let cls = 'steam-parity'; + if (delta < -15) cls = 'steam-deal'; + else if (delta > 5) cls = 'steam-overpay'; + const sign = delta > 0 ? '+' : ''; + const warn = lowVol ? ' ⚠' : ''; + chip = `Δ ${sign}${delta.toFixed(0)}%${warn}`; + } + const volText = lowVol + ? `vol ${volume}/нед` + : `vol ${volume}/нед`; return `
- ${discount} - Steam: ${fmtRub(summary.steam_price_rub)} + ${chip} + Steam median: ${fmtRub(summary.steam_price_rub)} + ${volText}
`; } @@ -305,8 +321,34 @@ function renderAlertSettings(summary) { data-alert-field="above" > + +
-
Введи число и нажми Enter (или кликни вне поля) — сохранится автоматически. Уведомление придёт в Telegram когда цена пересечёт порог.
+
Введи число и нажми Enter (или кликни вне поля) — сохранится автоматически. Уведомление придёт в Telegram когда цена пересечёт порог. Float-границы (опционально) фильтруют алерт «ниже» — сработает только если есть листинг с подходящим float.
`).join('')} @@ -614,6 +656,8 @@ async function commitAlertCard(card) { const message = card.querySelector('[data-alert-message]'); const belowInput = card.querySelector('input[data-alert-field="below"]'); const aboveInput = card.querySelector('input[data-alert-field="above"]'); + const floatMaxInput = card.querySelector('input[data-alert-field="float_max"]'); + const floatMinInput = card.querySelector('input[data-alert-field="float_min"]'); if (!belowInput || !aboveInput) return; if (!updateAlertValidation(card)) { if (message) message.textContent = 'Введите число больше нуля или оставь поле пустым.'; @@ -622,6 +666,19 @@ async function commitAlertCard(card) { const below = parseAlertInputValue(belowInput.value).value; const above = parseAlertInputValue(aboveInput.value).value; + const floatMax = floatMaxInput ? parseAlertInputValue(floatMaxInput.value).value : null; + const floatMin = floatMinInput ? parseAlertInputValue(floatMinInput.value).value : null; + // Validate float bounds in [0,1] + for (const v of [floatMax, floatMin]) { + if (v != null && (v < 0 || v > 1)) { + if (message) message.textContent = 'Float должен быть в диапазоне 0…1'; + return; + } + } + if (floatMin != null && floatMax != null && floatMin > floatMax) { + if (message) message.textContent = 'Float ≥ должен быть меньше или равен Float ≤'; + return; + } card.dataset.saving = '1'; card.classList.add('is-saving'); if (message) message.textContent = 'Сохраняю…'; @@ -629,6 +686,8 @@ async function commitAlertCard(card) { await saveListTargets(card.dataset.itemName || _currentName || '', card.dataset.listType || '', { targetBelowRub: below, targetAboveRub: above, + targetFloatMax: floatMax, + targetFloatMin: floatMin, }); await refreshCurrentDetail(); } catch (err) { diff --git a/tools/steam-sniper/static/js/lists.js b/tools/steam-sniper/static/js/lists.js index 2441a83..a62d167 100644 --- a/tools/steam-sniper/static/js/lists.js +++ b/tools/steam-sniper/static/js/lists.js @@ -1,5 +1,5 @@ // Lists module: favorites + wishlist state, toggle logic, tab rendering, indicator sync -import { fmtRub } from './utils.js'; +import { fmtRub, steamDeltaBadge } from './utils.js'; import { events } from './events.js'; // Hardcoded user (2-person app, no auth) @@ -79,7 +79,12 @@ export async function loadUserLists() { } } -export async function saveListTargets(itemName, listType, { targetBelowRub = null, targetAboveRub = null } = {}) { +export async function saveListTargets(itemName, listType, { + targetBelowRub = null, + targetAboveRub = null, + targetFloatMax = null, + targetFloatMin = null, +} = {}) { const resp = await fetch('/api/lists/target', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, @@ -89,6 +94,8 @@ export async function saveListTargets(itemName, listType, { targetBelowRub = nul list_type: listType, target_below_rub: targetBelowRub, target_above_rub: targetAboveRub, + target_float_max: targetFloatMax, + target_float_min: targetFloatMin, }), }); if (!resp.ok) { @@ -226,7 +233,7 @@ function renderListTab(listType, containerId) { ${imgHtml}
${name}
-
${price}
+
${price}${steamDeltaBadge(item.steam)}
${catBadge ? `
${catBadge}
` : ''}
${count} qty
diff --git a/tools/steam-sniper/static/js/utils.js b/tools/steam-sniper/static/js/utils.js index cf9c14f..495ebc0 100644 --- a/tools/steam-sniper/static/js/utils.js +++ b/tools/steam-sniper/static/js/utils.js @@ -25,3 +25,27 @@ export function timeAgo(ts) { if (hrs < 24) return hrs + 'h ago'; return Math.floor(hrs / 24) + 'd ago'; } + +/** + * Steam delta badge HTML for catalog/list cards. + * Returns empty string if no Steam data cached for this item. + * + * Color logic: + * - delta < -15%: green (real arbitrage after Steam's 15% commission floor) + * - delta -15% .. +5%: gray (price parity) + * - delta > +5%: orange (paying premium for instant lis-skins delivery) + * + * Low volume (<5/week) shows ⚠ since Steam median is shaky on thin markets. + */ +export function steamDeltaBadge(steam) { + if (!steam || steam.delta_pct == null) return ''; + const delta = steam.delta_pct; + let cls = 'steam-parity'; + if (delta < -15) cls = 'steam-deal'; + else if (delta > 5) cls = 'steam-overpay'; + const sign = delta > 0 ? '+' : ''; + const lowVol = (steam.volume ?? 0) < 5; + const warn = lowVol ? ' ⚠' : ''; + const tip = `Steam median: ${Math.round(steam.steam_price_rub).toLocaleString('ru-RU')} ₽${warn ? ' (low volume — ' + (steam.volume ?? 0) + '/week)' : ' • volume ' + (steam.volume ?? 0) + '/week'}`; + return `Δ ${sign}${delta.toFixed(0)}%${warn}`; +}