Files
linker/extension/extract.js
T
paulandClaude Opus 4.8 16d8f74997 fix: rebalance album cue recall — admit number/lowercase-led + abbreviation-period titles, bound 'the album X', strip curly quote (closes task/album-cue-recall-rebalance)
Source: task/album-cue-recall-rebalance (plan/steward-linker-design) — the a35dd95 precision tightening over-corrected, dropping real number/lowercase-led and abbreviation-period album titles.

- Loosen the album-cue lead token to admit a DIGIT (and, after an explicit
  "the album X" cue, a lowercase-styled run), so number-led ("the album 1989",
  "1989 LP", "36 Chambers") and lowercase-styled ("good kid maad city") titles
  survive; ALBUM_LEAD_STOP still rejects an is/was/by/the/a/an lead.
- Narrow QUOTED_RE's mid-span reject to SENTENCE-ENDING punctuation only (a .!?
  before whitespace/EOL, abbreviation periods exempted), so "M.A.A.D City",
  "Mr. Bungle", "Sgt. Pepper's" survive while prose quotes still reject; raise
  the word cap 6 → 8.
- Add the curly quotes ”“ (U+201D/U+201C) to TRAILING_PUNCT so an LP/EP run that
  grabbed a trailing curly quote ("Reek of Putrefaction”") is trimmed, with the
  span index/length kept so text.substr(index,length)===name still holds.
- Bound the "the album X" branch to the same 1-4-token run as the LP/EP branch,
  so "the album Money Store dropped" yields "Money Store", not the sentence tail.
- Tests assert BOTH directions: recovered recall AND retained precision, plus the
  curly-quote-trim position-mapping invariant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 06:33:46 +00:00

217 lines
12 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;
// Albums are noisier free-text than artists, so only TWO precise signals generate album
// candidates — the Spotify exact-album match (and/or LLM) is still the precision filter:
// 1. A quoted string ("Reek of Putrefaction" or 'Scum') — a strong "this is a title" cue,
// but only when it reads like a title (short, no sentence-ending punctuation mid-span),
// so prose quotes ("We hold these truths…", "lol") don't become candidates.
// 2. An album cue phrase ("the album Scum", "Scum LP/EP") naming a title-shaped run: the
// capture starts at a capitalized/quoted token (so a cue followed by a verb/article —
// "the album by Carcass", "the album is great" — yields no candidate) and the LP/EP
// branch is anchored to 1-4 capitalized words instead of grabbing the sentence prefix.
// Group 1 of each holds the candidate; the `d` flag gives its position for in-place wrapping.
const QUOTED_RE = /["“]([^"”]{2,80})["”]|[']([^']{2,80})[']/gd;
// A single word inside a title run (Title-Case/ALL-CAPS — or, for a digit-led run, a number).
const WORD = "[A-Za-z0-9'.&\\-]*";
// TITLE_RUN — a Title-Case-or-digit-led run of 1-4 tokens, mirroring CAND_RE's connector set so
// multi-word titles ("Reign in Blood") survive and bounded ({0,3}). Inner words are Title-Case
// tokens or connectors only, so trailing lowercase prose ("Money Store dropped" → "Money Store")
// is excluded. The lead also admits a DIGIT, so number-led titles ("1989 LP", "36 Chambers")
// survive; it does NOT admit a bare lowercase lead (keeps "their new LP" from leaking "new").
const TITLE_RUN = `["“']?[A-Z0-9]${WORD}(?:[ ]+(?:${TOKEN}|of|the|and|&|in|on|to|for|a|an)){0,3}["”']?`;
// LOWER_RUN — a fully lowercase-styled title (lead + inner words all lowercase/digit), bounded to
// 1-4 words, for stylized titles after an explicit album cue ("good kid maad city", "untitled").
// A verb/article lead ("the album by Carcass", "the album is great") is captured here but then
// dropped by ALBUM_LEAD_STOP below, so this run never weakens the precision guard.
const LOWER_RUN = `[a-z0-9]${WORD}(?:[ ]+[a-z0-9]${WORD}){0,3}`;
const ALBUM_CUE_RE = new RegExp(
// "the album X" — bounded to a 1-4-token run (TITLE_RUN for a capital/digit-led title, LOWER_RUN
// for a lowercase-styled one), so "the album Money Store dropped" yields "Money Store", not the
// sentence tail. A verb/article lead is dropped by ALBUM_LEAD_STOP below.
`\\b(?:the )?(?:album|record|ep|lp)\\s+(?:called\\s+|titled\\s+|named\\s+)?(${TITLE_RUN}|${LOWER_RUN})\\b` +
// "X LP/EP" — anchored title run (no left-anchor-less prefix grab).
`|\\b(${TITLE_RUN})\\s+(?:LP|EP)\\b`,
"gd"
);
// First word of an album-cue capture that, despite a leading capital, is not a title.
const ALBUM_LEAD_STOP = new Set(["is", "was", "by", "the", "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.
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",
]);
// Trailing punctuation/quotes to strip off a captured run. Includes the curly close/open quotes
// (”“, U+201D/U+201C) so a title run that grabbed a trailing curly quote ("Reek of Putrefaction”")
// is trimmed symmetrically with the leading-quote trim; the span's index/length are adjusted to keep
// text.substr(index, length) === name.
const TRAILING_PUNCT = /[.,;:!?'"”“)\]]+$/u;
// True when `s` carries SENTENCE-ENDING punctuation (a clause/sentence break), so it reads as
// prose, not a title. `!`/`?` followed by whitespace/end always count. A `.`+whitespace/end counts
// UNLESS it is an abbreviation period — a 1-3-letter alpha token directly before it ("Mr.", "Sgt.",
// "St.", initials) — which a title legitimately carries ("Mr. Bungle", "Sgt. Pepper's"). All regexes
// are linear (no nesting/backtracking) and run only over the short quoted span.
function isSentenceEnding(s) {
if (/[!?](\s|$)/.test(s)) return true; // ! / ? — never an abbreviation
let i = s.indexOf(".");
while (i !== -1) {
const after = s[i + 1];
if (after === undefined || /\s/.test(after)) {
// a period at end-of-string or before whitespace: sentence-ending unless it abbreviates a
// short alpha token (e.g. "Mr"/"Sgt"/"St"/"A") immediately to its left.
const m = s.slice(0, i).match(/([A-Za-z]+)$/);
if (!m || m[1].length > 3) return true;
}
i = s.indexOf(".", i + 1);
}
return false;
}
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;
}
// Album candidates from the two precise signals (quoted strings + album cue phrases).
// Each match is { name, index, length } like the others; the server's exact Spotify
// album match is what actually confirms it (this only over-generates the candidates).
function albumMatchesIn(text) {
const out = [];
const seen = new Set(); // dedupe identical (index,length) spans across the two signals
const push = (name, index, length) => {
const key = index + ":" + length;
if (seen.has(key)) return;
seen.add(key);
out.push({ name, index, length });
};
QUOTED_RE.lastIndex = 0;
let m;
while ((m = QUOTED_RE.exec(text))) {
const gi = m.indices[1] || m.indices[2]; // double- or single-quoted group
if (!gi) continue;
const name = text.slice(gi[0], gi[1]).trim();
const words = name.split(/\s+/);
if (name.length < 2 || /^\d+$/.test(name)) continue;
// Reject prose quotes: a title doesn't carry SENTENCE-ENDING punctuation mid-span (a `.!?`
// FOLLOWED by whitespace or end-of-string — "We hold these truths…self-evident.") and won't
// run long. An internal abbreviation period is NOT sentence-ending, so it survives — both the
// run-on-letter kind ("M.A.A.D City") and the short-token kind ("Mr. Bungle", "Sgt. Pepper's"),
// where a 1-3-char alpha token + period reads as an abbreviation, not a clause end. A lone
// interjection ("lol") is a stopword, not a title.
if (isSentenceEnding(name) || words.length > 8) continue;
if (words.length === 1 && STOP.has(name[0].toUpperCase() + name.slice(1).toLowerCase())) continue;
// index/length cover the inner text only (no quotes), so the wrapper links the title.
push(name, gi[0], gi[1] - gi[0]);
}
ALBUM_CUE_RE.lastIndex = 0;
while ((m = ALBUM_CUE_RE.exec(text))) {
const gi = m.indices[1] || m.indices[2]; // "album X" group or "X LP/EP" group
if (!gi) continue;
// Trim a leading opening-quote the title run may have captured, shifting the index so
// the emitted span still maps back to the source text; then strip trailing punct/quotes.
let start = gi[0];
let raw = text.slice(start, gi[1]);
const lead = raw.match(/^["“']+/);
if (lead) { start += lead[0].length; raw = raw.slice(lead[0].length); }
const name = raw.replace(TRAILING_PUNCT, "").trim();
// Keep `length < 2`, but admit a pure-number title here (unlike the bare-quote branch): an
// explicit album cue ("the album 1989", "1989 LP") is the precision signal that a number is a
// title, not noise — so number-led albums survive.
if (name.length < 2) continue;
// A capital-led capture that is actually a verb/article ("Is great", "By Carcass") is no title.
if (ALBUM_LEAD_STOP.has(name.split(/\s+/)[0].toLowerCase())) continue;
push(name, start, name.length);
}
return out;
}
// Union of Title-Case + cue + album 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), albumMatchesIn(text));
all.sort((a, b) => a.index - b.index || b.length - a.length);
return all;
}
// True only for http/https URLs — reject javascript:, data:, garbage, empty.
// The resolver's url flows into an injected link's href, so the content script
// re-validates the scheme client-side before assigning it (defense in depth; the
// resolver already validates server-side, but a swapped/buggy one must not inject
// a javascript:/data: href into reddit's DOM).
function isSafeHttpUrl(u) {
try {
const p = new URL(u).protocol;
return p === "http:" || p === "https:";
} catch {
return false;
}
}
return { matchesIn, cueMatchesIn, albumMatchesIn, allMatchesIn, isSafeHttpUrl };
});