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>
183 lines
6.1 KiB
JavaScript
183 lines
6.1 KiB
JavaScript
/* 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) =>
|
||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||
}
|
||
function escapeAttr(s) { return escapeHtml(s).replace(/"/g, """); }
|
||
|
||
// --- init -------------------------------------------------------------------
|
||
(async function init() {
|
||
await loadConfig();
|
||
await refresh();
|
||
})();
|