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>
217 lines
12 KiB
JavaScript
217 lines
12 KiB
JavaScript
// 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 };
|
||
});
|