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:
@@ -124,10 +124,10 @@ let the content script reach it).
|
|||||||
|
|
||||||
## Limitations (MVP)
|
## Limitations (MVP)
|
||||||
|
|
||||||
- **Detection is capitalization-based.** Names written in lowercase, or split
|
- **Detection is mostly capitalization-based**, with a cue-phrase pass that also catches
|
||||||
across formatting (e.g. **bold** in the middle of a name), can be missed. The
|
lowercase names right after "check out / by / fan of / recommend …". Names split across
|
||||||
resolver's exact-name match keeps false *positives* low; the trade-off is some
|
inline formatting (e.g. **bold** mid-name) can still be missed. The resolver's
|
||||||
false *negatives*.
|
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
|
- **Links are direct when MusicBrainz has the artist's `url-relations`** (official
|
||||||
Bandcamp / Spotify pages); otherwise they fall back to a search URL. (Each
|
Bandcamp / Spotify pages); otherwise they fall back to a search URL. (Each
|
||||||
confirmed artist costs one extra throttled MusicBrainz lookup, cached after.)
|
confirmed artist costs one extra throttled MusicBrainz lookup, cached after.)
|
||||||
|
|||||||
+6
-39
@@ -11,43 +11,10 @@
|
|||||||
const api = globalThis.browser ?? globalThis.chrome;
|
const api = globalThis.browser ?? globalThis.chrome;
|
||||||
const log = (...a) => console.log("[rsl]", ...a);
|
const log = (...a) => console.log("[rsl]", ...a);
|
||||||
|
|
||||||
// ---- candidate extraction -------------------------------------------------
|
// Candidate extraction is pure + lives in extract.js (loaded first per manifest order;
|
||||||
// A token is a Capitalized or ALL-CAPS word; a candidate is 1-4 tokens joined by
|
// defines globalThis.RSLExtract) so it can be unit-tested in node. allMatchesIn(text)
|
||||||
// spaces, with a few lowercase connectors allowed inside ("Signs of the Swarm").
|
// returns {name,index,length} for Title-Case runs AND cue-phrase ("check out <x>") names.
|
||||||
const TOKEN = "[A-Z][A-Za-z0-9'’.&\\-]*";
|
const Extract = globalThis.RSLExtract;
|
||||||
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 ----------------------------------------------------------
|
// ---- DOM walking ----------------------------------------------------------
|
||||||
const SKIP_TAGS = new Set([
|
const SKIP_TAGS = new Set([
|
||||||
@@ -95,7 +62,7 @@
|
|||||||
|
|
||||||
function wrapNode(node) {
|
function wrapNode(node) {
|
||||||
const text = node.nodeValue;
|
const text = node.nodeValue;
|
||||||
const ms = matchesIn(text).filter((m) => {
|
const ms = Extract.allMatchesIn(text).filter((m) => {
|
||||||
const links = resolved.get(m.name);
|
const links = resolved.get(m.name);
|
||||||
return links && ((links.primary && links.primary.url) || links.spotify);
|
return links && ((links.primary && links.primary.url) || links.spotify);
|
||||||
});
|
});
|
||||||
@@ -147,7 +114,7 @@
|
|||||||
if (!nodes.length) return;
|
if (!nodes.length) return;
|
||||||
|
|
||||||
const seen = new Set();
|
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);
|
log("candidates found:", seen.size);
|
||||||
|
|
||||||
const ask = [...seen].filter((c) => !resolved.has(c));
|
const ask = [...seen].filter((c) => !resolved.has(c));
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
});
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
"content_scripts": [
|
"content_scripts": [
|
||||||
{
|
{
|
||||||
"matches": ["*://*.reddit.com/*"],
|
"matches": ["*://*.reddit.com/*"],
|
||||||
"js": ["content.js"],
|
"js": ["extract.js", "content.js"],
|
||||||
"css": ["styles.css"],
|
"css": ["styles.css"],
|
||||||
"run_at": "document_idle"
|
"run_at": "document_idle"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,5 +16,6 @@ echo " ok extension/manifest.json + service/package.json"
|
|||||||
|
|
||||||
echo "== node --test (unit) =="
|
echo "== node --test (unit) =="
|
||||||
( cd service && node --test )
|
( cd service && node --test )
|
||||||
|
( cd extension && node --test )
|
||||||
|
|
||||||
echo "ALL CHECKS PASSED"
|
echo "ALL CHECKS PASSED"
|
||||||
|
|||||||
Reference in New Issue
Block a user