#!/usr/bin/env python3 """ discgod ingest server — STANDALONE, single file. Everything (schema, Discogs-API→DB mapping, upsert/price-diff/sold-detection, watchlist, stats, HTTP server) lives here so it's drop-in: copy it to the box, run it, done. It coexists with the legacy pliceclogs server on :5002 — discgod listens on :5057, writes the same `discogs_full` DB, and touches none of the legacy endpoints. If you ever want one server, the handlers below paste straight into pliceclogs_server.py. The extension's background worker pulls each watched seller's full inventory from api.discogs.com (client-side, with your token) and POSTs the raw listing pages here; this server maps + upserts them into the proven `mp_*` tables and detects price changes / sales. Stdlib + psycopg2 only. DB auth: plain psycopg2.connect(dbname=...) — local peer auth on the Ultra (run as johnking). Off-box: set PGHOST/PGUSER/PGPASSWORD. Run: python3 discgod_server.py # or, like the legacy server: nohup /opt/homebrew/bin/python3 discgod_server.py > discgod_server.log 2>&1 & Endpoints GET /discgod/health GET /discgod/stats GET /discgod/tracked POST /discgod/track {username, note?, filter?, enabled?, seller_id?} POST /discgod/untrack {username} POST /discgod/ingest {username, seller_id?, seller?, page, total?, crawl_id, filter?, listings:[...]} POST /discgod/crawl_complete {username, crawl_id, filter?, treat_complete?} """ import json import os import sys from datetime import datetime from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import psycopg2 import psycopg2.extras try: sys.stdout.reconfigure(line_buffering=True) except Exception: pass DB_NAME = os.environ.get("DISCGOD_DB", os.environ.get("PLICE_DB", "discogs_full")) PORT = int(os.environ.get("DISCGOD_PORT", "5057")) # Params that describe the search *filter*, not a position within it. NON_FILTER_PARAMS = {"page", "pages", "per_page", "limit", "sort", "sort_order", "status", "token"} def get_conn(): return psycopg2.connect(dbname=DB_NAME) # =========================================================================== # Schema (idempotent). mp_* mirror the marketwatcher schema exactly so existing # queries and the join to the XML `release` table keep working. # =========================================================================== SCHEMA = """ CREATE TABLE IF NOT EXISTS mp_seller ( seller_id BIGINT PRIMARY KEY, username TEXT NOT NULL, rating NUMERIC(3,1), rating_percent NUMERIC(5,2), ratings_count INTEGER, ships_from TEXT, first_seen TIMESTAMPTZ DEFAULT now(), last_seen TIMESTAMPTZ DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_mp_seller_username ON mp_seller (username); CREATE TABLE IF NOT EXISTS mp_listing ( item_id BIGINT PRIMARY KEY, release_id BIGINT NOT NULL, seller_id BIGINT, seller_username TEXT, title TEXT, label TEXT, cat_no TEXT, media_condition TEXT, sleeve_condition TEXT, notes TEXT, price_value NUMERIC(12,2), price_currency TEXT, shipping_value NUMERIC(12,2), converted_value NUMERIC(12,2), have_count INTEGER, want_count INTEGER, ships_from TEXT, status TEXT NOT NULL DEFAULT 'active', gone_at TIMESTAMPTZ, first_seen TIMESTAMPTZ DEFAULT now(), last_seen TIMESTAMPTZ DEFAULT now(), times_seen INTEGER DEFAULT 1, last_crawl_id TEXT ); CREATE INDEX IF NOT EXISTS idx_mp_listing_release ON mp_listing (release_id); CREATE INDEX IF NOT EXISTS idx_mp_listing_seller ON mp_listing (seller_username, status); CREATE INDEX IF NOT EXISTS idx_mp_listing_seen ON mp_listing (last_seen DESC); CREATE TABLE IF NOT EXISTS mp_listing_event ( id BIGSERIAL PRIMARY KEY, item_id BIGINT NOT NULL, release_id BIGINT, seller_username TEXT, event TEXT NOT NULL, old_price NUMERIC(12,2), new_price NUMERIC(12,2), currency TEXT, details JSONB, recorded_at TIMESTAMPTZ DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_mp_event_item ON mp_listing_event (item_id, recorded_at DESC); CREATE INDEX IF NOT EXISTS idx_mp_event_type ON mp_listing_event (event, recorded_at DESC); CREATE INDEX IF NOT EXISTS idx_mp_event_release ON mp_listing_event (release_id); CREATE TABLE IF NOT EXISTS mp_seller_snapshot ( id BIGSERIAL PRIMARY KEY, seller_id BIGINT, username TEXT NOT NULL, total_listings BIGINT, source TEXT NOT NULL, filter_key TEXT, filter_params JSONB, captured_at TIMESTAMPTZ DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_mp_snapshot_series ON mp_seller_snapshot (username, source, filter_key, captured_at DESC); CREATE TABLE IF NOT EXISTS mp_page_log ( id BIGSERIAL PRIMARY KEY, url TEXT, page_type TEXT, seller_page TEXT, params JSONB, page INTEGER, total_results BIGINT, item_count INTEGER, item_ids BIGINT[], new_items INTEGER, price_changes INTEGER, crawl_id TEXT, scraped_at TIMESTAMPTZ DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_mp_page_log_time ON mp_page_log (scraped_at DESC); -- discgod watchlist: who to auto-crawl, shared by the extension + cron crawler. CREATE TABLE IF NOT EXISTS discgod_seller_watch ( username TEXT PRIMARY KEY, seller_id BIGINT, enabled BOOLEAN DEFAULT TRUE, note TEXT, filter_params JSONB, added_at TIMESTAMPTZ DEFAULT now(), last_crawled_at TIMESTAMPTZ, last_listing_count INTEGER, last_status TEXT ); CREATE INDEX IF NOT EXISTS idx_discgod_watch_enabled ON discgod_seller_watch (enabled); """ def ensure_schema(conn): with conn.cursor() as cur: cur.execute(SCHEMA) cur.execute("ALTER TABLE mp_listing ADD COLUMN IF NOT EXISTS last_crawl_id TEXT") cur.execute("ALTER TABLE mp_page_log ADD COLUMN IF NOT EXISTS crawl_id TEXT") conn.commit() # =========================================================================== # Coercion helpers # =========================================================================== def _num(val): if val is None or val == "": return None try: return float(val) except (TypeError, ValueError): return None def _int(val): if val is None or val == "": return None try: return int(val) except (TypeError, ValueError): return None def _txt(val): if val is None: return None s = str(val).strip() return s if s else None def _cents(val): return None if val is None else int(round(val * 100)) def filter_key_from_params(params): if not params: return "" items = sorted((k, str(v)) for k, v in params.items() if k not in NON_FILTER_PARAMS) return "&".join(f"{k}={v}" for k, v in items) # =========================================================================== # Map a raw Discogs API inventory listing -> our flat item dict # =========================================================================== def map_listing(raw, seller_username=None, seller_id=None): """ Convert one Discogs API listing object (from /users/{u}/inventory) into the flat shape our upsert expects. Defensive about missing/renamed fields; returns None if it lacks the two required keys. """ item_id = _int(raw.get("id")) release = raw.get("release") or {} release_id = _int(release.get("id")) if not item_id or not release_id: return None price = raw.get("price") or {} # original_price = price converted into the authed account's currency # (Discogs' confusingly-named field). Good as "converted_value". converted = raw.get("original_price") or {} shipping = raw.get("shipping_price") or {} stats = release.get("stats") or {} community = stats.get("community") or release.get("community") or {} seller = raw.get("seller") or {} sstats = seller.get("stats") or {} return { "item_id": item_id, "release_id": release_id, "seller_id": _int(seller.get("id")) or _int(seller_id), "seller_username": _txt(seller.get("username")) or _txt(seller_username), # Discogs: stats.stars = 0-5 star score (-> rating NUMERIC(3,1)); # stats.rating = percent positive, e.g. 100.0 (-> rating_percent). "seller_rating": _num(sstats.get("stars")), "seller_percent": _num(sstats.get("rating")), "seller_ratings_count": _int(sstats.get("total")), "title": _txt(release.get("description")), "label": _txt(release.get("label")), "cat_no": _txt(release.get("catalog_number") or release.get("catno")), "media_condition": _txt(raw.get("condition")), "sleeve_condition": _txt(raw.get("sleeve_condition")), "notes": _txt(raw.get("comments")), "price_value": _num(price.get("value")), "price_currency": _txt(price.get("currency")), "shipping_value": _num(shipping.get("value")), "converted_value": _num(converted.get("value")), "have_count": _int(community.get("in_collection")), "want_count": _int(community.get("in_wantlist")), "ships_from": _txt(raw.get("ships_from")), } # =========================================================================== # Upserts (adapted from the marketwatcher engine, mp_* compatible) # =========================================================================== def upsert_seller_profile(cur, username, seller_id, profile): """Record seller identity + location from the /users/{username} blob. Reputation (stars/percent/count) comes from each listing's seller.stats, not from this profile resource (whose `rating` field is ambiguous and can overflow rating NUMERIC(3,1)); COALESCE preserves it.""" username = _txt(username) seller_id = _int(seller_id) or _int((profile or {}).get("id")) if not seller_id or not username: return 0 profile = profile or {} cur.execute(""" INSERT INTO mp_seller (seller_id, username, ships_from) VALUES (%s, %s, %s) ON CONFLICT (seller_id) DO UPDATE SET username = EXCLUDED.username, ships_from = COALESCE(EXCLUDED.ships_from, mp_seller.ships_from), last_seen = now() """, (seller_id, username, _txt(profile.get("location")))) return 1 def upsert_sellers_from_items(cur, items): """Upsert sellers gleaned from listing items (when listings carry seller).""" sellers = {} for it in items: sid = _int(it.get("seller_id")) if not sid or not _txt(it.get("seller_username")): continue sellers[sid] = ( sid, _txt(it.get("seller_username")), _num(it.get("seller_rating")), _num(it.get("seller_percent")), _int(it.get("seller_ratings_count")), _txt(it.get("ships_from")), ) if not sellers: return 0 psycopg2.extras.execute_values(cur, """ INSERT INTO mp_seller (seller_id, username, rating, rating_percent, ratings_count, ships_from) VALUES %s ON CONFLICT (seller_id) DO UPDATE SET username = EXCLUDED.username, rating = COALESCE(EXCLUDED.rating, mp_seller.rating), rating_percent = COALESCE(EXCLUDED.rating_percent, mp_seller.rating_percent), ratings_count = COALESCE(EXCLUDED.ratings_count, mp_seller.ratings_count), ships_from = COALESCE(EXCLUDED.ships_from, mp_seller.ships_from), last_seen = now() """, list(sellers.values())) return len(sellers) def upsert_listings(cur, items, crawl_id=None): """Upsert listings, emitting events for new / price-changed / condition- changed / relisted items. Returns (new, price_changes, relisted).""" by_id = {} for it in items: if _int(it.get("item_id")) and _int(it.get("release_id")): by_id[_int(it["item_id"])] = it valid = list(by_id.values()) if not valid: return 0, 0, 0 ids = list(by_id.keys()) cur.execute(""" SELECT item_id, price_value, media_condition, sleeve_condition, status FROM mp_listing WHERE item_id = ANY(%s) FOR UPDATE """, (ids,)) existing = {row[0]: row for row in cur.fetchall()} rows, events = [], [] new_count = price_changes = relists = 0 for it in valid: item_id = _int(it["item_id"]) release_id = _int(it["release_id"]) price = _num(it.get("price_value")) currency = _txt(it.get("price_currency")) media = _txt(it.get("media_condition")) sleeve = _txt(it.get("sleeve_condition")) seller = _txt(it.get("seller_username")) prev = existing.get(item_id) if prev is None: new_count += 1 events.append((item_id, release_id, seller, "listed", None, price, currency, json.dumps({"media": media, "sleeve": sleeve}))) else: _, prev_price, prev_media, prev_sleeve, prev_status = prev prev_price = float(prev_price) if prev_price is not None else None if prev_status == "gone": relists += 1 events.append((item_id, release_id, seller, "relisted", prev_price, price, currency, None)) if price is not None and prev_price is not None and _cents(price) != _cents(prev_price): price_changes += 1 events.append((item_id, release_id, seller, "price_change", prev_price, price, currency, None)) if (media != prev_media and media and prev_media) or \ (sleeve != prev_sleeve and sleeve and prev_sleeve): events.append((item_id, release_id, seller, "condition_change", None, None, None, json.dumps({"old_media": prev_media, "new_media": media, "old_sleeve": prev_sleeve, "new_sleeve": sleeve}))) rows.append(( item_id, release_id, _int(it.get("seller_id")), seller, _txt(it.get("title")), _txt(it.get("label")), _txt(it.get("cat_no")), media, sleeve, _txt(it.get("notes")), price, currency, _num(it.get("shipping_value")), _num(it.get("converted_value")), _int(it.get("have_count")), _int(it.get("want_count")), _txt(it.get("ships_from")), crawl_id, )) psycopg2.extras.execute_values(cur, """ INSERT INTO mp_listing (item_id, release_id, seller_id, seller_username, title, label, cat_no, media_condition, sleeve_condition, notes, price_value, price_currency, shipping_value, converted_value, have_count, want_count, ships_from, last_crawl_id) VALUES %s ON CONFLICT (item_id) DO UPDATE SET release_id = EXCLUDED.release_id, seller_id = COALESCE(EXCLUDED.seller_id, mp_listing.seller_id), seller_username = COALESCE(EXCLUDED.seller_username, mp_listing.seller_username), title = COALESCE(EXCLUDED.title, mp_listing.title), label = COALESCE(EXCLUDED.label, mp_listing.label), cat_no = COALESCE(EXCLUDED.cat_no, mp_listing.cat_no), media_condition = COALESCE(EXCLUDED.media_condition, mp_listing.media_condition), sleeve_condition = COALESCE(EXCLUDED.sleeve_condition, mp_listing.sleeve_condition), notes = COALESCE(EXCLUDED.notes, mp_listing.notes), price_value = COALESCE(EXCLUDED.price_value, mp_listing.price_value), price_currency = COALESCE(EXCLUDED.price_currency, mp_listing.price_currency), shipping_value = COALESCE(EXCLUDED.shipping_value, mp_listing.shipping_value), converted_value = COALESCE(EXCLUDED.converted_value, mp_listing.converted_value), have_count = COALESCE(EXCLUDED.have_count, mp_listing.have_count), want_count = COALESCE(EXCLUDED.want_count, mp_listing.want_count), ships_from = COALESCE(EXCLUDED.ships_from, mp_listing.ships_from), last_crawl_id = COALESCE(EXCLUDED.last_crawl_id, mp_listing.last_crawl_id), status = 'active', gone_at = NULL, last_seen = now(), times_seen = mp_listing.times_seen + 1 """, rows) if events: psycopg2.extras.execute_values(cur, """ INSERT INTO mp_listing_event (item_id, release_id, seller_username, event, old_price, new_price, currency, details) VALUES %s """, events) return new_count, price_changes, relists SNAPSHOT_DEDUP_HOURS = 6 def record_seller_snapshot(cur, username, seller_id, total, source, params): """Insert an inventory-size snapshot unless an identical recent one exists.""" if not username or total is None: return False fkey = filter_key_from_params(params) cur.execute(""" SELECT total_listings FROM mp_seller_snapshot WHERE username = %s AND source = %s AND filter_key = %s AND captured_at > now() - make_interval(hours => %s) ORDER BY captured_at DESC LIMIT 1 """, (username, source, fkey, SNAPSHOT_DEDUP_HOURS)) row = cur.fetchone() if row and row[0] == total: return False cur.execute(""" INSERT INTO mp_seller_snapshot (seller_id, username, total_listings, source, filter_key, filter_params) VALUES (%s, %s, %s, %s, %s, %s::jsonb) """, (seller_id, username, total, source, fkey, json.dumps(params or {}))) return True def mark_gone(cur, username, crawl_id, filtered, treat_complete): """After a full inventory crawl, mark this seller's active listings the crawl did NOT touch (last_crawl_id != crawl_id) as gone — sold or delisted. Only safe when the crawl was the complete inventory. Returns count, or None when skipped.""" username = _txt(username) crawl_id = _txt(crawl_id) if not username or not crawl_id: return None if filtered and not treat_complete: return None cur.execute(""" UPDATE mp_listing SET status = 'gone', gone_at = now() WHERE seller_username = %s AND status = 'active' AND last_crawl_id IS DISTINCT FROM %s RETURNING item_id, release_id, price_value, price_currency """, (username, crawl_id)) gone = cur.fetchall() if gone: psycopg2.extras.execute_values(cur, """ INSERT INTO mp_listing_event (item_id, release_id, seller_username, event, old_price, new_price, currency, details) VALUES %s """, [ (item_id, release_id, username, "gone", price, None, currency, json.dumps({"presumed": "sold_or_delisted", "crawl_filtered": filtered})) for item_id, release_id, price, currency in gone ]) return len(gone) # =========================================================================== # High-level operations (own their own transaction unless a conn is passed) # =========================================================================== def ingest_inventory_page(payload, conn=None): """Process one page of a seller's inventory. payload = {username, seller_id?, seller?, page, total?, crawl_id, filter?, listings:[raw API objects]}""" owns = conn is None conn = conn or get_conn() try: username = _txt(payload.get("username")) seller_id = _int(payload.get("seller_id")) profile = payload.get("seller") page = _int(payload.get("page")) or 1 total = _int(payload.get("total")) crawl_id = _txt(payload.get("crawl_id")) params = payload.get("filter") or {} raw_listings = payload.get("listings") or [] items = [m for m in (map_listing(r, username, seller_id) for r in raw_listings) if m] with conn.cursor() as cur: sellers = 0 if profile or seller_id: sellers += upsert_seller_profile(cur, username, seller_id, profile) sellers += upsert_sellers_from_items(cur, items) new_count, price_changes, relists = upsert_listings(cur, items, crawl_id) snapshots = 0 if page == 1 and total is not None: if record_seller_snapshot(cur, username, seller_id, total, "api_inventory", params): snapshots += 1 item_ids = [it["item_id"] for it in items] cur.execute(""" INSERT INTO mp_page_log (url, page_type, seller_page, params, page, total_results, item_count, item_ids, new_items, price_changes, crawl_id) VALUES (%s, %s, %s, %s::jsonb, %s, %s, %s, %s, %s, %s, %s) """, ( f"api:/users/{username}/inventory?page={page}", "api_inventory", username, json.dumps(params), page, total, len(items), item_ids, new_count, price_changes, crawl_id, )) conn.commit() return {"ok": True, "username": username, "page": page, "items": len(items), "new": new_count, "price_changes": price_changes, "relisted": relists, "sellers": sellers, "snapshots": snapshots} finally: if owns: conn.close() def finish_crawl(payload, conn=None): """Close out a full inventory crawl: mark untouched listings gone. payload = {username, crawl_id, filter?, treat_complete?}""" owns = conn is None conn = conn or get_conn() try: username = _txt(payload.get("username")) crawl_id = _txt(payload.get("crawl_id")) params = payload.get("filter") or {} filtered = bool(filter_key_from_params(params)) treat_complete = bool(payload.get("treat_complete")) if not username or not crawl_id: return {"ok": False, "error": "username and crawl_id required"} with conn.cursor() as cur: marked = mark_gone(cur, username, crawl_id, filtered, treat_complete) conn.commit() if marked is None: return {"ok": True, "marked_gone": 0, "skipped": "filtered crawl not treated as complete inventory"} return {"ok": True, "marked_gone": marked} finally: if owns: conn.close() # ---- watchlist ------------------------------------------------------------ def list_tracked(conn=None, enabled_only=False): owns = conn is None conn = conn or get_conn() try: with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: cur.execute(f""" SELECT w.username, w.seller_id, w.enabled, w.note, w.filter_params, w.added_at, w.last_crawled_at, w.last_listing_count, w.last_status, (SELECT count(*) FROM mp_listing l WHERE l.seller_username = w.username AND l.status = 'active') AS active_listings FROM discgod_seller_watch w {"WHERE w.enabled" if enabled_only else ""} ORDER BY w.last_crawled_at DESC NULLS LAST, w.username """) rows = cur.fetchall() for r in rows: for k in ("added_at", "last_crawled_at"): if r.get(k): r[k] = r[k].isoformat() return rows finally: if owns: conn.close() def track_seller(username, note=None, filter_params=None, enabled=True, seller_id=None, conn=None): owns = conn is None conn = conn or get_conn() try: username = _txt(username) if not username: return {"ok": False, "error": "username required"} with conn.cursor() as cur: cur.execute(""" INSERT INTO discgod_seller_watch (username, seller_id, enabled, note, filter_params) VALUES (%s, %s, %s, %s, %s::jsonb) ON CONFLICT (username) DO UPDATE SET enabled = EXCLUDED.enabled, note = COALESCE(EXCLUDED.note, discgod_seller_watch.note), filter_params = COALESCE(EXCLUDED.filter_params, discgod_seller_watch.filter_params), seller_id = COALESCE(EXCLUDED.seller_id, discgod_seller_watch.seller_id) """, (username, _int(seller_id), bool(enabled), _txt(note), json.dumps(filter_params) if filter_params else None)) conn.commit() return {"ok": True, "username": username} finally: if owns: conn.close() def untrack_seller(username, conn=None): owns = conn is None conn = conn or get_conn() try: with conn.cursor() as cur: cur.execute("DELETE FROM discgod_seller_watch WHERE username = %s", (_txt(username),)) n = cur.rowcount conn.commit() return {"ok": True, "removed": n} finally: if owns: conn.close() def mark_watch_crawled(username, listing_count, status="ok", conn=None): owns = conn is None conn = conn or get_conn() try: with conn.cursor() as cur: cur.execute(""" UPDATE discgod_seller_watch SET last_crawled_at = now(), last_listing_count = %s, last_status = %s WHERE username = %s """, (_int(listing_count), _txt(status), _txt(username))) conn.commit() finally: if owns: conn.close() # ---- dashboard stats ------------------------------------------------------ def stats(conn=None): owns = conn is None conn = conn or get_conn() try: with conn.cursor() as cur: cur.execute(""" SELECT (SELECT count(*) FROM mp_listing WHERE status = 'active'), (SELECT count(*) FROM mp_listing WHERE status = 'gone'), (SELECT count(*) FROM discgod_seller_watch WHERE enabled), (SELECT count(*) FROM mp_seller), (SELECT count(*) FROM mp_page_log WHERE page_type = 'api_inventory' AND scraped_at > now() - interval '24 hours'), (SELECT max(scraped_at) FROM mp_page_log), (SELECT count(*) FROM mp_listing_event WHERE event = 'gone' AND recorded_at > now() - interval '7 days'), (SELECT count(*) FROM mp_listing_event WHERE event = 'price_change' AND recorded_at > now() - interval '7 days') """) (active, gone, watched, sellers, pages_24h, last_capture, gone_7d, price_changes_7d) = cur.fetchone() return {"ok": True, "active_listings": active, "gone_listings": gone, "sellers_watched": watched, "sellers_known": sellers, "pages_24h": pages_24h, "last_capture": last_capture.isoformat() if last_capture else None, "gone_7d": gone_7d, "price_changes_7d": price_changes_7d} finally: if owns: conn.close() # =========================================================================== # HTTP server # =========================================================================== def _log(msg): print(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}") class Handler(BaseHTTPRequestHandler): def log_message(self, *_): pass def _cors(self): self.send_header("Access-Control-Allow-Origin", "*") self.send_header("Access-Control-Allow-Headers", "Content-Type") self.send_header("Access-Control-Allow-Methods", "POST, GET, OPTIONS") self.send_header("Access-Control-Allow-Private-Network", "true") def _send(self, obj, status=200): body = json.dumps(obj).encode() self.send_response(status) self._cors() self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def _read_json(self): length = int(self.headers.get("Content-Length", 0)) body = self.rfile.read(length) if length else b"" return json.loads(body) if body else {} def _safe(self, fn, label): try: self._send(fn()) except Exception as e: import traceback traceback.print_exc() _log(f"ERROR ({label}): {e}") self._send({"ok": False, "error": str(e)}, status=500) def do_OPTIONS(self): self.send_response(200) self._cors() self.end_headers() def do_GET(self): if self.path in ("/discgod/health", "/discgod", "/"): self._send({"ok": True, "server": "discgod", "db": DB_NAME}) elif self.path == "/discgod/stats": self._safe(stats, "stats") elif self.path == "/discgod/tracked": self._safe(lambda: {"ok": True, "sellers": list_tracked()}, "tracked") else: self.send_response(404) self.end_headers() def do_POST(self): if self.path == "/discgod/ingest": def run(): res = ingest_inventory_page(self._read_json()) _log(f"[ingest] {res.get('username')} p{res.get('page')} " f"{res.get('items')} items ({res.get('new')} new, " f"{res.get('price_changes')} price changes, {res.get('snapshots')} snap)") return res self._safe(run, "ingest") elif self.path == "/discgod/crawl_complete": def run(): payload = self._read_json() res = finish_crawl(payload) if res.get("ok") and payload.get("username"): mark_watch_crawled(payload["username"], payload.get("listing_count"), "ok") _log(f"[crawl_complete] {payload.get('username')} -> " f"{res.get('marked_gone', 0)} gone {res.get('skipped','')}") return res self._safe(run, "crawl_complete") elif self.path == "/discgod/track": def run(): p = self._read_json() return track_seller(p.get("username"), p.get("note"), p.get("filter"), p.get("enabled", True), p.get("seller_id")) self._safe(run, "track") elif self.path == "/discgod/untrack": def run(): return untrack_seller(self._read_json().get("username")) self._safe(run, "untrack") else: self.send_response(404) self.end_headers() def main(): conn = get_conn() ensure_schema(conn) conn.close() _log(f"discgod server listening on 0.0.0.0:{PORT} (db={DB_NAME})") ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever() if __name__ == "__main__": main()