discgod/extension/popup.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

183 lines
6.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* discgod popup — talks to the background worker only (which owns the token,
* the Discogs API calls, and the server I/O). */
const $ = (id) => document.getElementById(id);
const send = (msg) => chrome.runtime.sendMessage(msg);
function toast(text, kind = "ok") {
const t = $("toast");
t.textContent = text;
t.className = `toast show ${kind}`;
setTimeout(() => (t.className = "toast"), 2600);
}
function setDot(state) {
$("statusDot").className = "dot" + (state ? " " + state : "");
}
// --- load config ------------------------------------------------------------
async function loadConfig() {
const cfg = await send({ type: "getConfig" });
$("token").value = cfg.token || "";
$("serverUrl").value = cfg.serverUrl || "";
$("intervalHours").value = String(cfg.intervalHours || 12);
$("autoCrawl").checked = cfg.autoCrawl !== false;
}
// --- watchlist --------------------------------------------------------------
function renderSellers(sellers) {
const box = $("watchlist");
if (!sellers || !sellers.length) {
box.innerHTML = '<div class="empty">No sellers yet — add one above.</div>';
return;
}
box.innerHTML = "";
for (const s of sellers) {
const el = document.createElement("div");
el.className = "seller";
const last = s.last_crawled_at
? new Date(s.last_crawled_at).toLocaleDateString()
: "never";
const statusErr = (s.last_status || "").startsWith("error");
el.innerHTML = `
<div style="flex:1; min-width:0">
<div class="name">${escapeHtml(s.username)}</div>
<div class="meta ${statusErr ? "err" : ""}">
last ${last}${statusErr ? " · failed" : ""}
</div>
</div>
<span class="count">${s.active_listings ?? 0}</span>
<button data-act="scrape" data-u="${escapeAttr(s.username)}">Scrape</button>
<button class="danger" data-act="remove" data-u="${escapeAttr(s.username)}">✕</button>`;
box.appendChild(el);
}
box.querySelectorAll("button").forEach((b) =>
b.addEventListener("click", onSellerAction)
);
}
async function onSellerAction(e) {
const u = e.currentTarget.dataset.u;
const act = e.currentTarget.dataset.act;
if (act === "remove") {
await send({ type: "untrack", username: u });
toast(`Stopped tracking ${u}`);
refresh();
} else if (act === "scrape") {
await send({ type: "scrapeOne", username: u });
toast(`Scraping ${u}`);
setDot("busy");
pollStatus();
}
}
async function refresh() {
const tracked = await send({ type: "getTracked" }).catch(() => null);
if (tracked && tracked.ok) renderSellers(tracked.sellers);
else if (tracked && !tracked.ok) toast(tracked.error || "server unreachable", "err");
const stats = await send({ type: "getStats" }).catch(() => null);
if (stats && stats.ok) {
$("s_active").textContent = fmt(stats.active_listings);
$("s_watched").textContent = fmt(stats.sellers_watched);
$("s_gone").textContent = fmt(stats.gone_7d);
$("s_price").textContent = fmt(stats.price_changes_7d);
$("lastCapture").lastElementChild.textContent = stats.last_capture
? new Date(stats.last_capture).toLocaleString()
: "";
setDot("ok");
} else {
setDot("err");
}
pollStatus();
}
async function pollStatus() {
const st = await send({ type: "status" }).catch(() => null);
const line = $("statusLine");
if (st && st.crawling) {
setDot("busy");
line.firstElementChild.textContent = `crawling… ${st.remaining} seller(s) left`;
line.lastElementChild.textContent = st.queued ? `${st.queued} queued` : "";
setTimeout(pollStatus, 2000);
} else if (st) {
line.firstElementChild.textContent = st.lastCrawlAt
? "last sweep " + new Date(st.lastCrawlAt).toLocaleTimeString()
: "idle";
line.lastElementChild.textContent = st.queued ? `${st.queued} queued (retrying)` : "";
}
}
// --- events -----------------------------------------------------------------
$("save").addEventListener("click", async () => {
await send({
type: "setConfig",
patch: {
token: $("token").value.trim(),
serverUrl: $("serverUrl").value.trim().replace(/\/$/, ""),
intervalHours: parseInt($("intervalHours").value, 10),
autoCrawl: $("autoCrawl").checked,
},
});
toast("Saved");
refresh();
});
$("testToken").addEventListener("click", async () => {
await send({ type: "setConfig", patch: { token: $("token").value.trim() } });
const r = await send({ type: "testToken" });
if (r.ok) toast(`Token OK — signed in as ${r.username}`);
else toast(r.error || "token failed", "err");
});
$("track").addEventListener("click", async () => {
const username = $("newSeller").value.trim().replace(/^.*\/seller\//, "").split(/[/?]/)[0];
if (!username) return;
const filter = parseFilter($("newFilter").value.trim());
const r = await send({ type: "track", body: { username, filter } });
if (r.ok) {
$("newSeller").value = "";
$("newFilter").value = "";
toast(`Tracking ${username}`);
refresh();
} else toast(r.error || "could not track", "err");
});
$("scrapeAll").addEventListener("click", async () => {
await send({ type: "scrapeAll" });
toast("Scraping all watched sellers…");
setDot("busy");
pollStatus();
});
$("refresh").addEventListener("click", refresh);
// Enter-to-track
$("newSeller").addEventListener("keydown", (e) => {
if (e.key === "Enter") $("track").click();
});
// --- helpers ----------------------------------------------------------------
function parseFilter(str) {
// "format=Vinyl&ships_from=Australia" -> {format:'Vinyl', ships_from:'Australia'}
const out = {};
if (!str) return out;
for (const part of str.split("&")) {
const [k, v] = part.split("=");
if (k && v) out[k.trim()] = v.trim();
}
return out;
}
function fmt(n) { return n == null ? "" : Number(n).toLocaleString(); }
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
function escapeAttr(s) { return escapeHtml(s).replace(/"/g, "&quot;"); }
// --- init -------------------------------------------------------------------
(async function init() {
await loadConfig();
await refresh();
})();