Lean MV3 extension + standalone single-file ingest server that pulls watched sellers' full inventories from the Discogs API into discogs_full (mp_* schema), tracking price changes and sales over time. Successor to pliceclogs/marketwatcher; runs alongside the legacy plice server on a separate port (5057). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
786 lines
31 KiB
Python
786 lines
31 KiB
Python
#!/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()
|