From 607a4ab45e0568ee7e1f786858d9fb837c25eec2 Mon Sep 17 00:00:00 2001 From: paul Date: Wed, 17 Jun 2026 00:36:55 +0000 Subject: [PATCH] fix: background-script fetch (CSP/mixed-content), prefer-Spotify links, toolbar icon (task/fix-extension-and-linkui) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The extension linked nothing and the resolver saw no requests: the content script fetched http://localhost:8787 directly from the HTTPS reddit page, which reddit's CSP / mixed-content blocks. Move the network call to a background script (extension origin, exempt) and add stage logging so failures are visible. - extension/background.js (new): does the resolver fetch on message; sets the toolbar badge. - extension/content.js: talks to background via runtime messaging (no page-context fetch); [rsl] console logs at boot/root/candidates/resolve/scan; single primary link. - extension/manifest.json: background.scripts + browser_action (icon.svg, title). - extension/{styles.css,icon.svg}: platform-coloured link (green=spotify, teal=bandcamp) + toolbar icon. - service/lib.js: primaryOf()/isSearchUrl() — link Spotify when the artist is on Spotify, else Bandcamp, else Spotify search; spotifyResult/mbResult attach `primary` (unit-tested). - README: link behaviour, toolbar, Troubleshooting. Verified: scripts/check.sh green (10 tests); resolver returns `primary` end-to-end. The in-browser fix is logic-verified only (no headless Firefox here) — reload the temp add-on and watch the page console `[rsl]` logs to confirm. Source: project human-feedback 2026-06-16 (notes 01KV8VC58QMYBDY6VVVJHR8YNY + 01KV91AMJCN2C4GFCKMKQRGNXE). Co-Authored-By: Claude Opus 4.8 --- README.md | 27 +++++++++++++++--- extension/background.js | 48 ++++++++++++++++++++++++++++++++ extension/content.js | 61 ++++++++++++++++++++--------------------- extension/icon.svg | 8 ++++++ extension/manifest.json | 7 +++++ extension/styles.css | 27 +++++++----------- service/lib.js | 27 ++++++++++++++---- service/lib.test.js | 23 +++++++++++++++- 8 files changed, 168 insertions(+), 60 deletions(-) create mode 100644 extension/background.js create mode 100644 extension/icon.svg diff --git a/README.md b/README.md index bfce6fd..f056855 100644 --- a/README.md +++ b/README.md @@ -26,10 +26,12 @@ service/ tiny Node.js resolver (one file, zero npm deps) resolver.js package.json .env.example -extension/ Firefox MV2 extension (content script only) +extension/ Firefox MV2 extension manifest.json + background.js (does the resolver fetch; talks to the content script) content.js styles.css + icon.svg ``` ## 1. Run the resolver service @@ -91,9 +93,26 @@ with `xpinstall.signatures.required = false`.) 1. Make sure the resolver service is running. 2. Open any Reddit comments thread (a URL with `/comments/` in it). -3. Artist names in the comments become links: the name links to **Spotify**, with - a small superscript **`bc`** link to **Bandcamp** beside it. New comments loaded - as you scroll are linked automatically. +3. Artist names become a single link — to **Spotify** if the artist is on Spotify, + otherwise to **Bandcamp** — colour-coded (green = Spotify, teal = Bandcamp). New + comments loaded as you scroll are linked automatically. +4. The toolbar icon shows the extension is active; its badge shows how many links were + added on the current page. + +## Troubleshooting + +If nothing gets linked: + +1. Confirm the resolver is running — `curl localhost:8787/health` should return + `{"ok":true,...}`. +2. On the reddit thread, open the browser console (F12) and look for `[rsl]` logs: they + report the root element, candidate count, and resolver responses on each scan. A + `resolve failed` line means the background script can't reach the resolver (is it on + `:8787`?); `candidates found: 0` means the comment text wasn't matched (please report + the thread). +3. The content script reaches the resolver **through the extension's background script** + — a page-context fetch to `http://localhost` is blocked by reddit's CSP — so make sure + the add-on loaded without errors in `about:debugging`. ## Configuration diff --git a/extension/background.js b/extension/background.js new file mode 100644 index 0000000..2e7a050 --- /dev/null +++ b/extension/background.js @@ -0,0 +1,48 @@ +// Background script: performs the cross-origin fetch to the resolver, and drives the +// toolbar badge. +// +// Why this exists: a content script running in the HTTPS reddit page CANNOT reach +// http://localhost:8787 — reddit's Content-Security-Policy (and mixed-content rules) +// block the request, so it never leaves the browser. The background page runs in the +// extension origin (moz-extension://), which is exempt, and may fetch any host listed +// in `permissions`. The content script therefore asks us to do the request. + +const api = globalThis.browser ?? globalThis.chrome; + +// Keep in sync with manifest.json `permissions` if you change host/port. +const RESOLVER_URL = "http://localhost:8787"; + +async function resolveCandidates(candidates) { + const r = await fetch(`${RESOLVER_URL}/resolve`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ candidates }), + }); + if (!r.ok) throw new Error(`resolver http ${r.status}`); + return r.json(); +} + +api.runtime.onMessage.addListener((msg, sender) => { + if (!msg || typeof msg !== "object") return; + + if (msg.type === "resolve") { + // Returning a Promise makes sendMessage() in the content script resolve with it. + return resolveCandidates(msg.candidates) + .then((data) => ({ ok: true, data })) + .catch((e) => { + console.warn("[rsl/bg] resolve failed:", e && e.message); + return { ok: false, error: String((e && e.message) || e) }; + }); + } + + if (msg.type === "badge") { + const tabId = sender.tab && sender.tab.id; + if (tabId != null) { + const n = msg.count | 0; + try { + api.browserAction.setBadgeBackgroundColor({ color: "#1db954", tabId }); + api.browserAction.setBadgeText({ text: n > 0 ? String(n) : "", tabId }); + } catch (e) { /* badge is best-effort */ } + } + } +}); diff --git a/extension/content.js b/extension/content.js index 1e7c0e6..41ab4cf 100644 --- a/extension/content.js +++ b/extension/content.js @@ -8,9 +8,8 @@ (() => { "use strict"; - // If you run the resolver on a different host/port, change this AND the matching - // host entry in manifest.json `permissions`. - const RESOLVER_URL = "http://localhost:8787"; + const api = globalThis.browser ?? globalThis.chrome; + const log = (...a) => console.log("[rsl]", ...a); // ---- candidate extraction ------------------------------------------------- // A token is a Capitalized or ALL-CAPS word; a candidate is 1-4 tokens joined by @@ -80,37 +79,25 @@ } // ---- link building / rewriting -------------------------------------------- + // Per the user's "if on spotify, link that, else bandcamp" rule: one link to the + // primary platform, colour-coded so you can tell which at a glance. function makeLink(label, links) { - const frag = document.createDocumentFragment(); + const primary = links.primary || { platform: "spotify", url: links.spotify }; const a = document.createElement("a"); - a.className = "rsl-link"; + a.className = `rsl-link rsl-${primary.platform}`; a.textContent = label; - a.href = links.spotify; + a.href = primary.url; a.target = "_blank"; a.rel = "noopener noreferrer"; - a.title = "Open on Spotify"; - frag.appendChild(a); - if (links.bandcamp) { - const sup = document.createElement("sup"); - sup.className = "rsl-sup"; - const b = document.createElement("a"); - b.className = "rsl-bc"; - b.textContent = "bc"; - b.href = links.bandcamp; - b.target = "_blank"; - b.rel = "noopener noreferrer"; - b.title = "Search on Bandcamp"; - sup.appendChild(b); - frag.appendChild(sup); - } - return frag; + a.title = `Open on ${primary.platform === "spotify" ? "Spotify" : "Bandcamp"}`; + return a; } function wrapNode(node) { const text = node.nodeValue; const ms = matchesIn(text).filter((m) => { const links = resolved.get(m.name); - return links && links.spotify; + return links && ((links.primary && links.primary.url) || links.spotify); }); if (!ms.length) return; const frag = document.createDocumentFragment(); @@ -128,17 +115,19 @@ // ---- resolver client ------------------------------------------------------ const resolved = new Map(); // candidate string -> { spotify, bandcamp } | null + // The network call goes through the background script — a page-context fetch to + // http://localhost is blocked by reddit's CSP / mixed-content rules. async function resolve(names) { + log("resolving", names.length, "candidate(s) via background:", names.slice(0, 8)); try { - const r = await fetch(`${RESOLVER_URL}/resolve`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ candidates: names }), - }); - if (!r.ok) throw new Error(`http ${r.status}`); - return await r.json(); + const resp = await api.runtime.sendMessage({ type: "resolve", candidates: names }); + if (!resp) throw new Error("no response from background script"); + if (!resp.ok) throw new Error(resp.error || "resolve failed"); + const hits = Object.values(resp.data).filter(Boolean).length; + log("resolver returned", hits, "artist(s) of", names.length); + return resp.data; } catch (e) { - console.warn("[rsl] resolver unreachable:", e.message); + console.warn("[rsl] resolve failed (is the resolver running on :8787?):", e.message); return null; } } @@ -152,11 +141,14 @@ } async function scan() { - const nodes = collectTextNodes(getRoot()); + const root = getRoot(); + const nodes = collectTextNodes(root); + log("scan: root", root.tagName || root.nodeName, "| text nodes", nodes.length); if (!nodes.length) return; const seen = new Set(); for (const node of nodes) for (const m of matchesIn(node.nodeValue)) seen.add(m.name); + log("candidates found:", seen.size); const ask = [...seen].filter((c) => !resolved.has(c)); if (ask.length) { @@ -164,6 +156,10 @@ if (res) for (const c of ask) resolved.set(c, res[c] || null); // negative-cache misses } for (const node of nodes) if (node.isConnected) wrapNode(node); + + const total = document.querySelectorAll("a.rsl-link").length; + api.runtime.sendMessage({ type: "badge", count: total }).catch(() => {}); + log("scan done; links on page:", total); } // serialize scans; coalesce overlapping triggers @@ -187,6 +183,7 @@ const onThread = () => /\/comments\//.test(location.pathname); + log("content script loaded:", location.href, "| thread page:", onThread()); if (onThread()) schedule(); new MutationObserver(debounce(() => { if (onThread()) schedule(); }, 600)) .observe(document.documentElement, { childList: true, subtree: true }); diff --git a/extension/icon.svg b/extension/icon.svg new file mode 100644 index 0000000..c4c782e --- /dev/null +++ b/extension/icon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/extension/manifest.json b/extension/manifest.json index c059377..68366b7 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -13,6 +13,13 @@ "http://localhost:8787/*", "http://127.0.0.1:8787/*" ], + "background": { + "scripts": ["background.js"] + }, + "browser_action": { + "default_icon": "icon.svg", + "default_title": "Reddit → Spotify/Bandcamp linker (active on reddit threads)" + }, "content_scripts": [ { "matches": ["*://*.reddit.com/*"], diff --git a/extension/styles.css b/extension/styles.css index 54ac626..8314322 100644 --- a/extension/styles.css +++ b/extension/styles.css @@ -1,23 +1,16 @@ -/* Injected links — kept subtle so reddit comments stay readable. */ +/* Injected links — kept subtle so reddit comments stay readable. One link per artist, + coloured by platform: green = Spotify, teal = Bandcamp. */ .rsl-link { - text-decoration: underline dotted #1db954 !important; /* spotify green */ + text-decoration: underline dotted; text-underline-offset: 2px; cursor: pointer; } +.rsl-spotify { + text-decoration-color: #1db954 !important; /* spotify green */ +} +.rsl-bandcamp { + text-decoration-color: #629aa9 !important; /* bandcamp teal */ +} .rsl-link:hover { - text-decoration: underline solid #1db954 !important; -} -.rsl-sup { - font-size: 0.7em; - margin-left: 1px; - vertical-align: super; - line-height: 0; -} -.rsl-bc { - color: #629aa9 !important; /* bandcamp teal */ - text-decoration: none !important; - font-weight: 600; -} -.rsl-bc:hover { - text-decoration: underline !important; + text-decoration-style: solid !important; } diff --git a/service/lib.js b/service/lib.js index c7d9782..a36369f 100644 --- a/service/lib.js +++ b/service/lib.js @@ -19,12 +19,26 @@ export function pickArtist(name, items, { minScore = null } = {}) { ); } +// A "/search" url means we don't have a direct artist page (just a search fallback). +export const isSearchUrl = (url) => typeof url === "string" && url.includes("/search"); + +// Choose the single best link to show: prefer Spotify when the artist is actually ON +// Spotify (a direct, non-search url); else a direct Bandcamp; else the Spotify search. +export function primaryOf({ spotify, bandcamp }) { + if (spotify && !isSearchUrl(spotify)) return { platform: "spotify", url: spotify }; + if (bandcamp && !isSearchUrl(bandcamp)) return { platform: "bandcamp", url: bandcamp }; + return { platform: "spotify", url: spotify }; +} + // Link object for a confirmed Spotify hit (direct artist url when present, else search). -export const spotifyResult = (hit) => ({ - name: hit.name, - spotify: hit.external_urls?.spotify ?? spotifySearch(hit.name), - bandcamp: bandcampSearch(hit.name), -}); +export function spotifyResult(hit) { + const r = { + name: hit.name, + spotify: hit.external_urls?.spotify ?? spotifySearch(hit.name), + bandcamp: bandcampSearch(hit.name), + }; + return { ...r, primary: primaryOf(r) }; +} // Extract direct links from a MusicBrainz artist's url-relations (zero-cred: MB stores // official Bandcamp / streaming URLs). Either field may be null. @@ -37,9 +51,10 @@ export function relUrls(relations) { // Link object for a confirmed MusicBrainz hit: prefer the direct url-rel links, fall back // to search urls when MB has no relation for that platform. export function mbResult(hit, links) { - return { + const r = { name: hit.name, spotify: links?.spotify ?? spotifySearch(hit.name), bandcamp: links?.bandcamp ?? bandcampSearch(hit.name), }; + return { ...r, primary: primaryOf(r) }; } diff --git a/service/lib.test.js b/service/lib.test.js index 145f6e9..4efc247 100644 --- a/service/lib.test.js +++ b/service/lib.test.js @@ -1,7 +1,7 @@ // Unit tests for the resolver's pure logic (run with `node --test`). Zero-dep. import { test } from "node:test"; import assert from "node:assert/strict"; -import { norm, bandcampSearch, spotifySearch, pickArtist, spotifyResult, relUrls, mbResult } from "./lib.js"; +import { norm, bandcampSearch, spotifySearch, pickArtist, spotifyResult, relUrls, mbResult, primaryOf, isSearchUrl } from "./lib.js"; test("norm lowercases, trims, NFKC-normalizes", () => { assert.equal(norm(" Cerebral Bore "), "cerebral bore"); @@ -35,8 +35,28 @@ test("spotifyResult prefers the direct url, falls back to search", () => { assert.equal(direct.spotify, "https://open.spotify.com/artist/abc"); assert.match(direct.bandcamp, /^https:\/\/bandcamp\.com\/search\?q=Devourment/); + assert.deepEqual(direct.primary, { platform: "spotify", url: "https://open.spotify.com/artist/abc" }); + const noUrl = spotifyResult({ name: "Devourment" }); assert.equal(noUrl.spotify, "https://open.spotify.com/search/Devourment"); + assert.equal(noUrl.primary.platform, "spotify"); // search fallback still points at spotify +}); + +test("primaryOf / isSearchUrl: prefer direct spotify, else direct bandcamp, else spotify search", () => { + assert.equal(isSearchUrl("https://open.spotify.com/search/X"), true); + assert.equal(isSearchUrl("https://open.spotify.com/artist/abc"), false); + assert.deepEqual( + primaryOf({ spotify: "https://open.spotify.com/artist/abc", bandcamp: "https://bandcamp.com/search?q=X&item_type=b" }), + { platform: "spotify", url: "https://open.spotify.com/artist/abc" }, + ); + assert.deepEqual( + primaryOf({ spotify: "https://open.spotify.com/search/X", bandcamp: "https://x.bandcamp.com/" }), + { platform: "bandcamp", url: "https://x.bandcamp.com/" }, + ); + assert.deepEqual( + primaryOf({ spotify: "https://open.spotify.com/search/X", bandcamp: "https://bandcamp.com/search?q=X&item_type=b" }), + { platform: "spotify", url: "https://open.spotify.com/search/X" }, + ); }); test("relUrls picks direct bandcamp + spotify from MB relations, tolerates junk", () => { @@ -64,6 +84,7 @@ test("mbResult prefers direct links, falls back to search per-platform", () => { ); assert.equal(direct.bandcamp, "https://cattledecapitation.bandcamp.com/"); assert.equal(direct.spotify, "https://open.spotify.com/artist/x"); + assert.deepEqual(direct.primary, { platform: "spotify", url: "https://open.spotify.com/artist/x" }); const fallback = mbResult({ name: "Cerebral Bore" }, null); assert.equal(fallback.spotify, "https://open.spotify.com/search/Cerebral%20Bore");