// 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. // 2. An album cue phrase ("the album Scum", "Scum LP/EP") naming the next 1-4 words. // 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; const ALBUM_CUE_RE = /\b(?:the )?(?:album|record|ep|lp)\s+(?:called\s+|titled\s+|named\s+)?([A-Za-z0-9][A-Za-z0-9'’.&\- ]{0,79}?)(?=[.,;:!?)\]"”'’]|\s+(?:is|was|by|and|on|from|which|that)\b|$)|\b([A-Za-z0-9][A-Za-z0-9'’.&\- ]{0,79}?)\s+(?:LP|EP)\b/gd; // 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 = []; 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(); if (name.length < 2 || /^\d+$/.test(name)) continue; // index/length cover the inner text only (no quotes), so the wrapper links the title. out.push({ name, index: gi[0], length: 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; const raw = text.slice(gi[0], gi[1]); const name = raw.replace(TRAILING_PUNCT, "").trim(); if (name.length < 2 || /^\d+$/.test(name)) continue; out.push({ name, index: gi[0], length: 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; } return { matchesIn, cueMatchesIn, albumMatchesIn, allMatchesIn }; });