Files
linker/extension/extract.js
T
paulandClaude Opus 4.8 c2618ce0ea 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>
2026-06-17 06:36:28 +00:00

89 lines
4.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.
// 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 };
});