// 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; } // 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 }; });