discgod/extension/background.js
type-two 1945c19fb3 discgod: API-token Discogs seller-inventory tracker
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>
2026-06-13 19:54:33 +10:00

313 lines
12 KiB
JavaScript

/*
* discgod — background service worker (the engine).
*
* Holds the Discogs API token, runs the schedule, paginates each watched
* seller's full inventory from api.discogs.com (rate-limited, client-side so it
* uses *your* token's 60/min quota), and POSTs the raw listing pages to the
* ingest server, which maps + upserts them into discogs_full and detects price
* changes / sales.
*
* The watchlist itself lives server-side (discgod_seller_watch) so the popup,
* this worker, and the headless cron crawler all agree on who to track.
*/
const DEFAULTS = {
token: "",
serverUrl: "http://100.91.239.7:5057",
intervalHours: 12,
status: "For Sale",
perPage: 100,
baseDelayMs: 1100, // ~55 Discogs calls/min, under the 60/min ceiling
autoCrawl: true,
};
const ALARM_CRAWL = "discgod-crawl"; // periodic full sweep
const ALARM_KEEPALIVE = "discgod-tick"; // resurrects the worker mid-crawl / drains queue
const QUEUE_KEY = "sendQueue"; // failed server POSTs, retried later
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
async function getConfig() {
const stored = await chrome.storage.local.get(Object.keys(DEFAULTS));
return { ...DEFAULTS, ...stored };
}
async function setConfig(patch) {
await chrome.storage.local.set(patch);
if ("intervalHours" in patch) await scheduleCrawl();
return getConfig();
}
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
chrome.runtime.onInstalled.addListener(scheduleCrawl);
chrome.runtime.onStartup.addListener(scheduleCrawl);
async function scheduleCrawl() {
const { intervalHours, autoCrawl } = await getConfig();
await chrome.alarms.clear(ALARM_CRAWL);
if (autoCrawl) {
const period = Math.max(15, Math.round(intervalHours * 60)); // minutes; min 15
chrome.alarms.create(ALARM_CRAWL, { periodInMinutes: period, delayInMinutes: 1 });
}
}
chrome.alarms.onAlarm.addListener(async (alarm) => {
if (alarm.name === ALARM_CRAWL) {
crawlAll().catch((e) => log("crawlAll failed", e.message));
} else if (alarm.name === ALARM_KEEPALIVE) {
// Worker was revived (or never died) mid-crawl: keep draining.
drainQueue();
const { crawling } = await chrome.storage.local.get("crawling");
if (crawling) processQueue().catch(() => {});
}
});
function keepaliveOn() {
chrome.alarms.create(ALARM_KEEPALIVE, { periodInMinutes: 1 });
}
function keepaliveOff() {
chrome.alarms.clear(ALARM_KEEPALIVE);
}
// ---------------------------------------------------------------------------
// Discogs API (client-side, authenticated, rate-limited)
// ---------------------------------------------------------------------------
async function discogsGet(path, params) {
const cfg = await getConfig();
if (!cfg.token) throw new Error("No Discogs token set");
const url = new URL("https://api.discogs.com" + path);
if (params) for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
for (let attempt = 0; attempt < 5; attempt++) {
const resp = await fetch(url, {
headers: { Authorization: `Discogs token=${cfg.token}`, Accept: "application/json" },
});
if (resp.status === 429) {
const wait = Math.min(60000, 2000 * 2 ** attempt);
await sleep(wait);
continue;
}
if (resp.status === 401) throw new Error("401 — bad or missing token");
if (resp.status === 404) throw new Error("404 — no such user/inventory");
if (!resp.ok) {
await sleep(1500 * (attempt + 1));
continue;
}
const remaining = parseInt(resp.headers.get("X-Discogs-Ratelimit-Remaining") || "60", 10);
await sleep(remaining <= 2 ? 5000 : cfg.baseDelayMs);
return resp.json();
}
throw new Error(`Discogs API gave up: ${path}`);
}
// Quick token check — returns the authenticated identity.
async function testToken() {
const me = await discogsGet("/oauth/identity");
return { ok: true, username: me.username };
}
// ---------------------------------------------------------------------------
// Server I/O (the thing that writes Postgres). Failed posts queue + retry.
// ---------------------------------------------------------------------------
async function serverFetch(endpoint, { method = "GET", body } = {}) {
const { serverUrl } = await getConfig();
const resp = await fetch(serverUrl + endpoint, {
method,
headers: { "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
if (!resp.ok) throw new Error(`server ${endpoint} -> ${resp.status}`);
return resp.json();
}
// Post to the ingest server; on failure, queue for later retry so a momentary
// server/network blip never loses a captured page.
async function postOrQueue(endpoint, body) {
try {
return await serverFetch(endpoint, { method: "POST", body });
} catch (e) {
const { [QUEUE_KEY]: q = [] } = await chrome.storage.local.get(QUEUE_KEY);
q.push({ endpoint, body, attempts: 0, ts: Date.now() });
await chrome.storage.local.set({ [QUEUE_KEY]: q });
setBadge(String(q.length), "#c0392b");
throw e;
}
}
async function drainQueue() {
const { [QUEUE_KEY]: q = [] } = await chrome.storage.local.get(QUEUE_KEY);
if (!q.length) return;
const keep = [];
for (const item of q) {
try {
await serverFetch(item.endpoint, { method: "POST", body: item.body });
} catch {
item.attempts++;
if (item.attempts < 10) keep.push(item);
}
}
await chrome.storage.local.set({ [QUEUE_KEY]: keep });
setBadge(keep.length ? String(keep.length) : "", keep.length ? "#c0392b" : "#2e7d32");
}
// ---------------------------------------------------------------------------
// Crawl one seller: paginate their whole inventory, post each page, finish.
// ---------------------------------------------------------------------------
async function crawlSeller(username, filter) {
const cfg = await getConfig();
filter = filter || {};
const crawlId = `${username}-${Date.now()}`;
// Profile gives seller_id + a reliable num_for_sale for the snapshot.
let profile = null, sellerId = null;
try {
profile = await discogsGet(`/users/${encodeURIComponent(username)}`);
sellerId = profile.id;
} catch (e) {
log(`profile ${username}`, e.message);
}
let page = 1, pages = 1, total = null;
const baseParams = {
per_page: cfg.perPage, status: cfg.status, sort: "listed", sort_order: "desc", ...filter,
};
while (page <= pages) {
const data = await discogsGet(`/users/${encodeURIComponent(username)}/inventory`,
{ ...baseParams, page });
const pg = data.pagination || {};
pages = pg.pages || 1;
total = pg.items ?? total;
await postOrQueue("/discgod/ingest", {
username, seller_id: sellerId,
seller: page === 1 ? profile : null,
page, total, crawl_id: crawlId, filter,
listings: data.listings || [],
});
setBadge("⟳", "#1565c0");
page++;
}
const filtered = Object.keys(filter).length > 0;
await postOrQueue("/discgod/crawl_complete", {
username, crawl_id: crawlId, filter,
treat_complete: !filtered, listing_count: total,
});
return total;
}
// ---------------------------------------------------------------------------
// Crawl every watched seller (queue-driven so it survives worker restarts).
// ---------------------------------------------------------------------------
async function crawlAll() {
let tracked;
try {
const res = await serverFetch("/discgod/tracked");
tracked = (res.sellers || []).filter((s) => s.enabled);
} catch (e) {
log("cannot load watchlist", e.message);
return;
}
const queue = tracked.map((s) => ({ username: s.username, filter: s.filter_params || {} }));
await chrome.storage.local.set({ crawlQueue: queue, crawling: true });
await processQueue();
}
async function crawlOne(username) {
await chrome.storage.local.set({
crawlQueue: [{ username, filter: {} }], crawling: true,
});
await processQueue();
}
// Drains crawlQueue one seller at a time, persisting progress between sellers.
let processing = false;
async function processQueue() {
if (processing) return;
processing = true;
keepaliveOn();
try {
for (;;) {
const { crawlQueue = [] } = await chrome.storage.local.get("crawlQueue");
if (!crawlQueue.length) break;
const job = crawlQueue[0];
try {
const n = await crawlSeller(job.username, job.filter);
log(`crawled ${job.username}`, `${n} listings`);
} catch (e) {
log(`crawl ${job.username} failed`, e.message);
}
const { crawlQueue: cur = [] } = await chrome.storage.local.get("crawlQueue");
cur.shift();
await chrome.storage.local.set({ crawlQueue: cur, lastCrawlAt: Date.now() });
}
await drainQueue();
} finally {
await chrome.storage.local.set({ crawling: false });
keepaliveOff();
setBadge("", "#2e7d32");
processing = false;
notify("discgod", "Inventory sweep complete");
}
}
// ---------------------------------------------------------------------------
// Messages from the popup / content script
// ---------------------------------------------------------------------------
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
(async () => {
try {
switch (msg.type) {
case "getConfig": return sendResponse(await getConfig());
case "setConfig": return sendResponse(await setConfig(msg.patch));
case "testToken": return sendResponse(await testToken());
case "getStats": return sendResponse(await serverFetch("/discgod/stats"));
case "getTracked": return sendResponse(await serverFetch("/discgod/tracked"));
case "track": return sendResponse(await serverFetch("/discgod/track",
{ method: "POST", body: msg.body }));
case "untrack": return sendResponse(await serverFetch("/discgod/untrack",
{ method: "POST", body: { username: msg.username } }));
case "scrapeAll": crawlAll(); return sendResponse({ ok: true, started: true });
case "scrapeOne": crawlOne(msg.username); return sendResponse({ ok: true, started: true });
case "status": return sendResponse(await statusSnapshot());
default: return sendResponse({ ok: false, error: "unknown message" });
}
} catch (e) {
sendResponse({ ok: false, error: e.message });
}
})();
return true; // async
});
async function statusSnapshot() {
const { crawling, crawlQueue = [], lastCrawlAt, [QUEUE_KEY]: q = [] } =
await chrome.storage.local.get(["crawling", "crawlQueue", "lastCrawlAt", QUEUE_KEY]);
return { ok: true, crawling: !!crawling, remaining: crawlQueue.length,
queued: q.length, lastCrawlAt: lastCrawlAt || null };
}
// ---------------------------------------------------------------------------
// Small utilities
// ---------------------------------------------------------------------------
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
function setBadge(text, color) {
chrome.action.setBadgeText({ text: text || "" });
if (color) chrome.action.setBadgeBackgroundColor({ color });
}
function notify(title, message) {
try {
chrome.notifications.create({
type: "basic", iconUrl: "icons/icon128.png", title, message,
});
} catch { /* notifications optional */ }
}
function log(label, detail) {
console.log(`[discgod] ${label}${detail ? ": " + detail : ""}`);
}