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
+44
View File
@@ -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
});