Files
linker/extension/content.js
T
paulandClaude Opus 4.8 779eba6cb1 feat: reddit -> spotify/bandcamp artist linker MVP (extension + resolver service)
Firefox MV2 content script that detects artist/band names in reddit comment
threads and rewrites them inline into Spotify + Bandcamp links, backed by a tiny
zero-dependency Node resolver that confirms each candidate against the Spotify Web
API (direct links) or MusicBrainz (zero-setup, search links). Includes build +
install instructions in the README.

Source: project/reddit-spotify-linker (user 2026-06-12) + user message 2026-06-14
"Full vibe code, small service, minimal code."

Closes task/resolver-service, closes task/firefox-extension, closes task/docs-and-publish
(plan/mvp-build, objective/ship-mvp-linker).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 21:33:33 +00:00

194 lines
7.2 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.
// Reddit -> Spotify/Bandcamp linker (content script).
//
// On a reddit thread page it: pulls candidate artist names out of comment text,
// asks the local resolver service which ones are real artists, and rewrites those
// occurrences in place into links. The resolver does the hard "is this an artist"
// part; this script only handles extraction + DOM rewriting.
(() => {
"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";
// ---- candidate extraction -------------------------------------------------
// A token is a Capitalized or ALL-CAPS word; a candidate is 1-4 tokens joined by
// spaces, with a few lowercase connectors allowed inside ("Signs of the Swarm").
const TOKEN = "[A-Z][A-Za-z0-9'.&\\-]*";
const CAND_RE = new RegExp(`${TOKEN}(?:[ ]+(?:${TOKEN}|of|the|and|&|in|on|to|for|a|an)){0,3}`, "g");
const CONNECTORS = new Set(["of", "the", "and", "&", "in", "on", "to", "for", "a", "an"]);
// Common capitalized words that are almost never the artist being recommended.
// Deliberately small: the resolver's exact-name match is the real filter, so we
// avoid stoplisting plausible one-word band names (Yes, Love, Tool, ...).
const STOP = new Set([
"I", "A", "An", "The", "And", "But", "Or", "Nor", "So", "Yet", "For", "If", "As",
"At", "By", "In", "On", "To", "Of", "Up", "Off", "Out", "It", "He", "She", "We",
"They", "You", "Me", "Him", "Her", "Us", "Them", "My", "Your", "His", "Its", "Our",
"Their", "This", "That", "These", "Those", "Is", "Are", "Was", "Were", "Be", "Been",
"Am", "Do", "Does", "Did", "Has", "Have", "Had", "Will", "Would", "Can", "Could",
"Should", "Not", "No", "Yeah", "Ok", "Okay", "Lol", "Imo", "Imho", "Tbh", "Edit",
"Reddit", "Spotify", "Bandcamp", "Youtube", "Google", "Apple", "Album", "Albums",
"Band", "Bands", "Song", "Songs", "Track", "Tracks", "Ep", "Lp", "Cd", "Op", "Vol",
]);
function matchesIn(text) {
const out = [];
CAND_RE.lastIndex = 0;
let m;
while ((m = CAND_RE.exec(text))) {
let str = m[0].replace(/[.,;:!?'")\]]+$/u, ""); // trim trailing punctuation
let parts = str.split(/\s+/);
while (parts.length && CONNECTORS.has(parts[parts.length - 1].toLowerCase())) parts.pop();
str = parts.join(" ");
if (!str) continue;
const single = parts.length === 1;
if (single && (STOP.has(str) || str.length < 2 || /^\d+$/.test(str))) continue;
out.push({ name: str, index: m.index, length: str.length }); // only trailing trimmed -> index unchanged
}
return out;
}
// ---- DOM walking ----------------------------------------------------------
const SKIP_TAGS = new Set([
"A", "SCRIPT", "STYLE", "CODE", "PRE", "TEXTAREA", "INPUT", "BUTTON", "TIME", "NOSCRIPT", "SVG", "SELECT",
]);
function inSkippable(node) {
for (let el = node.parentElement; el; el = el.parentElement) {
if (SKIP_TAGS.has(el.tagName)) return true;
if (el.classList && el.classList.contains("rsl-link")) return true;
if (el.isContentEditable) return true;
}
return false;
}
function collectTextNodes(root) {
const nodes = [];
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
const v = node.nodeValue;
if (!v || v.length > 5000 || !/[A-Za-z]/.test(v)) return NodeFilter.FILTER_REJECT;
if (inSkippable(node)) return NodeFilter.FILTER_REJECT;
return NodeFilter.FILTER_ACCEPT;
},
});
let n;
while ((n = walker.nextNode())) nodes.push(n);
return nodes;
}
// ---- link building / rewriting --------------------------------------------
function makeLink(label, links) {
const frag = document.createDocumentFragment();
const a = document.createElement("a");
a.className = "rsl-link";
a.textContent = label;
a.href = links.spotify;
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;
}
function wrapNode(node) {
const text = node.nodeValue;
const ms = matchesIn(text).filter((m) => {
const links = resolved.get(m.name);
return links && links.spotify;
});
if (!ms.length) return;
const frag = document.createDocumentFragment();
let last = 0;
for (const m of ms) {
if (m.index < last) continue; // skip overlaps
if (m.index > last) frag.appendChild(document.createTextNode(text.slice(last, m.index)));
frag.appendChild(makeLink(text.substr(m.index, m.length), resolved.get(m.name)));
last = m.index + m.length;
}
if (last < text.length) frag.appendChild(document.createTextNode(text.slice(last)));
node.parentNode && node.parentNode.replaceChild(frag, node);
}
// ---- resolver client ------------------------------------------------------
const resolved = new Map(); // candidate string -> { spotify, bandcamp } | null
async function resolve(names) {
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();
} catch (e) {
console.warn("[rsl] resolver unreachable:", e.message);
return null;
}
}
// ---- scan orchestration ---------------------------------------------------
function getRoot() {
return (
document.querySelector("main, .content[role='main'], shreddit-comment-tree, .commentarea") ||
document.body
);
}
async function scan() {
const nodes = collectTextNodes(getRoot());
if (!nodes.length) return;
const seen = new Set();
for (const node of nodes) for (const m of matchesIn(node.nodeValue)) seen.add(m.name);
const ask = [...seen].filter((c) => !resolved.has(c));
if (ask.length) {
const res = await resolve(ask);
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);
}
// serialize scans; coalesce overlapping triggers
let running = false;
let pending = false;
function schedule() {
if (running) { pending = true; return; }
running = true;
scan()
.catch((e) => console.warn("[rsl]", e))
.finally(() => {
running = false;
if (pending) { pending = false; setTimeout(schedule, 300); }
});
}
function debounce(fn, ms) {
let t;
return () => { clearTimeout(t); t = setTimeout(fn, ms); };
}
const onThread = () => /\/comments\//.test(location.pathname);
if (onThread()) schedule();
new MutationObserver(debounce(() => { if (onThread()) schedule(); }, 600))
.observe(document.documentElement, { childList: true, subtree: true });
})();