Files
linker/extension/extract.js
T
paulandClaude Opus 4.8 5a3abf6c89 fix: batch large-thread candidates + stop negative-caching uncapped names (closes task/chunk-large-thread-candidates)
Source: task/chunk-large-thread-candidates (plan/steward-linker-quality) — content.js sent all candidates in one call, the server capped at 100, and names past the cap were permanently null-cached and never retried — silently breaking large threads.

- content.js: resolve the per-scan ask set in batches of BATCH (100, <= server MAX_CANDIDATES) via a new pure Extract.chunk helper, so every candidate is actually answered and none is dropped by the resolver's slice.
- content.js: cache only keys actually present in a response (hasOwnProperty); cap-overflow / partial-error / resolver-down names are left out of `resolved` and retried next scan instead of stuck at null.
- extract.js: add pure chunk(arr, size) on the RSLExtract global; extract.test.cjs covers exact-multiple, remainder, under-size, empty, and lossless in-order concatenation.

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

249 lines
15 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 WEAK
// cue (album|record|ep|lp) can't separate a stylized lowercase title from prose, so after it
// only a capital/digit-led run OR a SINGLE lowercase token is admitted (a multi-word
// lowercase run after a weak cue is rejected — it leaks prose). A multi-word lowercase title
// is admitted ONLY after a STRONG explicit cue ("album called good kid maad city"). 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 a 1-4-word title, not 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}["”']?`;
// DIGIT_RUN — a DIGIT-led run whose inner words may be lowercase ("36 chambers", "808s and
// heartbreak", "1989 deluxe"). The leading digit IS the precision signal a title was meant (a
// bare lowercase lead is not), so unlike TITLE_RUN this run absorbs trailing lowercase tokens —
// bounded to 1-4 words so it never grabs the sentence tail. Tried BEFORE TITLE_RUN in the
// alternation so a digit lead captures its full continuation, not just the bare number.
const DIGIT_RUN = `["“']?[0-9]${WORD}(?:[ ]+[A-Za-z0-9'.&\\-]+){0,3}["”']?`;
// LOWER_RUN — a fully lowercase-styled MULTI-word title (lead + inner words all lowercase/digit),
// bounded to 1-4 words. The weak cue (album|record|ep|lp) cannot tell a stylized lowercase title
// ("good kid maad city") from prose ("makes me cry") — the lowercase-prose space dwarfs the rare
// real case — so a multi-word lowercase run is admitted ONLY after a STRONG explicit cue
// (called|titled|named). After the weak cue, only a single lowercase token (LOWER_ONE) is allowed.
const LOWER_RUN = `[a-z0-9]${WORD}(?:[ ]+[a-z0-9]${WORD}){0,3}`;
// LOWER_ONE — a SINGLE lowercase token admitted after the WEAK cue ("the album untitled"). The
// negative lookahead `(?![ ]+[a-z])` requires it to be the WHOLE title: a second lowercase word
// means prose ("makes me cry", "changed everything"), which is rejected outright — the lever that
// closes the LOWER_RUN precision leak while keeping a one-word stylized title like "untitled".
const LOWER_ONE = `[a-z]${WORD}(?![ ]+[a-z])`;
const ALBUM_CUE_RE = new RegExp(
// STRONG cue: "album called/titled/named X" — the explicit naming verb licenses a multi-word
// lowercase title (LOWER_RUN), the ONLY place a bare lowercase run is admitted. Digit- and
// capital-led runs work here too. DIGIT_RUN precedes TITLE_RUN so a digit lead absorbs its
// lowercase continuation instead of [A-Z0-9] matching the bare number and stopping.
`\\b(?:the )?(?:album|record|ep|lp)\\s+(?:called|titled|named)\\s+(${DIGIT_RUN}|${TITLE_RUN}|${LOWER_RUN})\\b` +
// WEAK cue: "the album X" — a capital/digit-led title (DIGIT_RUN|TITLE_RUN) or at most a
// SINGLE lowercase token (LOWER_ONE). No multi-word lowercase run here: it would leak prose.
`|\\b(?:the )?(?:album|record|ep|lp)\\s+(${DIGIT_RUN}|${TITLE_RUN}|${LOWER_ONE})\\b` +
// "X LP/EP" — anchored title run (no left-anchor-less prefix grab); digit-led too.
`|\\b(${DIGIT_RUN}|${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) => {
// Position-mapping invariant: the wrapper links a candidate via text.substr(index, length),
// so the emitted span must reproduce the name exactly. Drop any candidate that violates it
// (a defensive guard over the quote/cue index-shift arithmetic) rather than mis-link the DOM.
if (text.substr(index, length) !== name) return;
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] || m.indices[3]; // strong-cue / weak-cue / "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;
}
// Split an array into consecutive sub-arrays of at most `size` elements (the last is the
// remainder). Pure + position-preserving: concatenating the chunks reproduces the input in order.
// The resolver client uses it to keep each /resolve request at or under the server's per-call cap.
function chunk(arr, size) {
const out = [];
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
return out;
}
// 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, chunk };
});