feat: cue-phrase candidate extraction + testable extract.js (task/smarter-candidate-extraction)

Catch artist names written in lowercase right after a cue phrase ("check out devourment",
"by cattle decapitation", "fan of ...") — the Title-Case/ALL-CAPS pass missed those.

- extension/extract.js (new): pure extraction (matchesIn + new cueMatchesIn + allMatchesIn),
  UMD so it loads as a content script AND require()s in node for tests. The cue pass uses the
  regex `d` flag to map matches back to positions for in-place wrapping; a small cue-stoplist
  drops "the/this/new/..." right after the cue.
- extension/content.js: extraction now comes from globalThis.RSLExtract.allMatchesIn
  (Title-Case + cue union), keeping the existing position-based wrapping.
- extension/manifest.json: load extract.js before content.js.
- extension/extract.test.cjs (new) + scripts/check.sh: 6 node:test cases for the extraction
  logic; check.sh now runs the extension tests too (16 total, green).
- README: updated the detection limitation note.

Deferred (design item 2): merging names split across inline formatting (cross-node) — more
involved; left as a follow-up.

Verified: scripts/check.sh green (16 tests). In-browser behavior is logic-verified only (no
headless Firefox); additive to the pending background-fix re-test — the [rsl] logs distinguish
"candidates found" from "resolve failed".

Source: task/smarter-candidate-extraction (objective/linker-improvements).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
paul
2026-06-17 06:36:28 +00:00
co-authored by Claude Opus 4.8
parent 607a4ab45e
commit c2618ce0ea
6 changed files with 144 additions and 44 deletions
+6 -39
View File
@@ -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 <x>") 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));