diff --git a/README.md b/README.md index f056855..b0194c1 100644 --- a/README.md +++ b/README.md @@ -124,10 +124,10 @@ let the content script reach it). ## Limitations (MVP) -- **Detection is capitalization-based.** Names written in lowercase, or split - across formatting (e.g. **bold** in the middle of a name), can be missed. The - resolver's exact-name match keeps false *positives* low; the trade-off is some - false *negatives*. +- **Detection is mostly capitalization-based**, with a cue-phrase pass that also catches + lowercase names right after "check out / by / fan of / recommend …". Names split across + inline formatting (e.g. **bold** mid-name) can still be missed. The resolver's + exact-name match keeps false *positives* low; the trade-off is some false *negatives*. - **Links are direct when MusicBrainz has the artist's `url-relations`** (official Bandcamp / Spotify pages); otherwise they fall back to a search URL. (Each confirmed artist costs one extra throttled MusicBrainz lookup, cached after.) diff --git a/extension/content.js b/extension/content.js index 41ab4cf..5b4d235 100644 --- a/extension/content.js +++ b/extension/content.js @@ -11,43 +11,10 @@ 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 - // 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; - } + // Candidate extraction is pure + lives in extract.js (loaded first per manifest order; + // defines globalThis.RSLExtract) so it can be unit-tested in node. allMatchesIn(text) + // returns {name,index,length} for Title-Case runs AND cue-phrase ("check out ") names. + const Extract = globalThis.RSLExtract; // ---- DOM walking ---------------------------------------------------------- const SKIP_TAGS = new Set([ @@ -95,7 +62,7 @@ function wrapNode(node) { const text = node.nodeValue; - const ms = matchesIn(text).filter((m) => { + const ms = Extract.allMatchesIn(text).filter((m) => { const links = resolved.get(m.name); return links && ((links.primary && links.primary.url) || links.spotify); }); @@ -147,7 +114,7 @@ if (!nodes.length) return; const seen = new Set(); - for (const node of nodes) for (const m of matchesIn(node.nodeValue)) seen.add(m.name); + for (const node of nodes) for (const m of Extract.allMatchesIn(node.nodeValue)) seen.add(m.name); log("candidates found:", seen.size); const ask = [...seen].filter((c) => !resolved.has(c)); diff --git a/extension/extract.js b/extension/extract.js new file mode 100644 index 0000000..ff1d678 --- /dev/null +++ b/extension/extract.js @@ -0,0 +1,88 @@ +// Pure candidate extraction from comment text. Loaded as a content script (defines +// globalThis.RSLExtract; runs before content.js per manifest order) AND require()-able in +// node for unit tests (extract.test.cjs). No DOM access here — strings in, matches out. +// +// Each match is { name, index, length } so the content script can wrap it in place. +(function (root, factory) { + const api = factory(); + root.RSLExtract = api; + if (typeof module !== "undefined" && module.exports) module.exports = api; // node (cjs) +})(typeof globalThis !== "undefined" ? globalThis : this, function () { + "use strict"; + + // A token is a Capitalized / ALL-CAPS word; a Title-Case candidate is 1-4 tokens 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"]); + + // Cue phrases that often precede an artist name written in lowercase + // ("check out devourment", "by cattle decapitation"). Group 1 = the next 1-2 words; + // the `d` flag gives us the group's position so the match can be wrapped in place. + const CUE_RE = /\b(?:by|check ?out|listen(?: to)?|recommend(?:ed|ations?)?|fan of|into|loves?|playing)\s+([A-Za-z][A-Za-z0-9'’.&-]*(?:\s+[A-Za-z0-9][A-Za-z0-9'’.&-]*)?)/gid; + + // Common capitalized words that are almost never the artist being recommended. + // Deliberately small: the resolver's exact-name match is the real filter. + 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", + ]); + + // Lowercase words unlikely to be the artist immediately after a cue phrase. + const CUE_STOP = new Set([ + "the", "this", "that", "a", "an", "it", "them", "him", "her", "us", "new", "more", + "some", "any", "good", "best", "my", "your", "his", "their", "out", "to", "one", "all", + ]); + + const TRAILING_PUNCT = /[.,;:!?’'")\]]+$/u; + + function matchesIn(text) { + const out = []; + CAND_RE.lastIndex = 0; + let m; + while ((m = CAND_RE.exec(text))) { + let str = m[0].replace(TRAILING_PUNCT, ""); + const 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 + } + return out; + } + + function cueMatchesIn(text) { + const out = []; + CUE_RE.lastIndex = 0; + let m; + while ((m = CUE_RE.exec(text))) { + const gi = m.indices && m.indices[1]; + if (!gi) continue; + const phrase = text.slice(gi[0], gi[1]).replace(TRAILING_PUNCT, ""); + if (!phrase) continue; + const first = phrase.split(/\s+/)[0]; + if (first.length < 2 || CUE_STOP.has(first.toLowerCase()) || /^\d+$/.test(first)) continue; + out.push({ name: first, index: gi[0], length: first.length }); // 1-word + if (phrase.length > first.length) out.push({ name: phrase, index: gi[0], length: phrase.length }); // 1-2 word + } + return out; + } + + // Union of Title-Case + cue matches, sorted for the in-place wrapper + // (ascending index, longer match first so the wrapper's overlap guard prefers it). + function allMatchesIn(text) { + const all = matchesIn(text).concat(cueMatchesIn(text)); + all.sort((a, b) => a.index - b.index || b.length - a.length); + return all; + } + + return { matchesIn, cueMatchesIn, allMatchesIn }; +}); diff --git a/extension/extract.test.cjs b/extension/extract.test.cjs new file mode 100644 index 0000000..0ca24fa --- /dev/null +++ b/extension/extract.test.cjs @@ -0,0 +1,44 @@ +// Unit tests for the pure candidate extraction (run with `node --test`). Zero-dep. +// CJS so it can require() extract.js, which is a UMD script (also a content script). +const { test } = require("node:test"); +const assert = require("node:assert/strict"); +const { matchesIn, cueMatchesIn, allMatchesIn } = require("./extract.js"); + +test("matchesIn finds Title-Case / ALL-CAPS runs, skips stopwords", () => { + // Note: "and" is a connector, so "X and Y" greedily joins into one run (by design, + // for names like "Iron and Wine") — keep the two names in separate clauses here. + const names = matchesIn("Cattle Decapitation is great; DEVOURMENT rules. I agree").map((m) => m.name); + assert.ok(names.includes("Cattle Decapitation")); + assert.ok(names.includes("DEVOURMENT")); + assert.ok(!names.includes("I")); +}); + +test("matchesIn reports positions that map back to the text", () => { + const text = "Check out Devourment now"; + const m = matchesIn(text).find((x) => x.name === "Devourment"); + assert.equal(text.substr(m.index, m.length), "Devourment"); +}); + +test("cueMatchesIn catches lowercase names after a cue phrase", () => { + const names = cueMatchesIn("you should check out devourment honestly").map((m) => m.name); + assert.ok(names.includes("devourment")); +}); + +test("cueMatchesIn positions map back to the text", () => { + const text = "i'm a big fan of cattle decapitation"; // "fan of" cue + for (const m of cueMatchesIn(text)) assert.equal(text.substr(m.index, m.length), m.name); +}); + +test("cueMatchesIn skips lowercase stopwords right after the cue", () => { + const names = cueMatchesIn("check out the new album").map((m) => m.name); + assert.ok(!names.includes("the")); +}); + +test("allMatchesIn merges cue + Title-Case and sorts ascending by index", () => { + const text = "Check out devourment and Cattle Decapitation"; + const ms = allMatchesIn(text); + for (let i = 1; i < ms.length; i++) assert.ok(ms[i].index >= ms[i - 1].index); + const names = ms.map((m) => m.name); + assert.ok(names.includes("devourment")); // cue (lowercase) + assert.ok(names.includes("Cattle Decapitation")); // title-case +}); diff --git a/extension/manifest.json b/extension/manifest.json index 68366b7..028d77d 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -23,7 +23,7 @@ "content_scripts": [ { "matches": ["*://*.reddit.com/*"], - "js": ["content.js"], + "js": ["extract.js", "content.js"], "css": ["styles.css"], "run_at": "document_idle" } diff --git a/scripts/check.sh b/scripts/check.sh index 021b675..e509548 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -16,5 +16,6 @@ echo " ok extension/manifest.json + service/package.json" echo "== node --test (unit) ==" ( cd service && node --test ) +( cd extension && node --test ) echo "ALL CHECKS PASSED"