Files
linker/extension/extract.js
T
paulandClaude Opus 4.8 a35dd95c88 fix: tighten album cue extraction so candidates are title-shaped, not sentence fragments (closes task/album-cue-precision)
Source: task/album-cue-precision (plan/steward-linker-design) — album cue regexes over-generated junk candidates (no left anchors), feeding noise to the resolver/LLM and risking fallback mis-links.

- X LP/EP branch: anchor the capture to a 1-4 capitalized/quoted title run (mirrors CAND_RE) so 'I think their new LP' no longer yields the sentence prefix.
- 'the album X' branch: require the name to start with a capital/quote and reject a leading verb/article (is|was|by|the|a|an) so 'the album by Carcass' / 'the album is great' yield no candidate.
- QUOTED_RE: reject prose quotes — a span with mid-string sentence punctuation (. ! ?), over ~6 words, or a lone stopword interjection ('lol') — so prose quotes never become album candidates.
- Preserve position mapping (trim a captured leading quote and shift the index) and dedupe identical spans across the two signals; recall of real titles ('Reign in Blood', 'Human Jerky') unchanged.

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

174 lines
9.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;
// 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 title-shaped run for the LP/EP branch: 1-4 tokens starting at a capital/quote, mirroring
// CAND_RE's connector set so multi-word titles ("Reign in Blood") survive. Bounded ({0,3}).
const TITLE_RUN = `["“']?${TOKEN}(?:[ ]+(?:${TOKEN}|of|the|and|&|in|on|to|for|a|an)){0,3}["”']?`;
const ALBUM_CUE_RE = new RegExp(
// "the album X" — capture must START with a capital or opening quote so a cue immediately
// followed by a verb/article ("the album is great", "the album by Carcass") captures nothing.
`\\b(?:the )?(?:album|record|ep|lp)\\s+(?:called\\s+|titled\\s+|named\\s+)?([A-Z"'“‘][A-Za-z0-9'.&\\- ]{0,79}?)(?=[.,;:!?)\\]"”']|\\s+(?:is|was|by|and|on|from|which|that)\\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",
]);
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;
}
// 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 and
// won't run long, and a lone interjection ("lol", "yeah") is a stopword, not a title.
if (/[.!?]/.test(name.slice(0, -1)) || words.length > 6) 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();
if (name.length < 2 || /^\d+$/.test(name)) 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 };
});