Files
linker/extension/extract.js
T
paulandClaude Opus 4.8 2b4babf955 feat: detect & link album mentions, not just artists (closes task/album-detection)
Source: task/album-detection (objective/linker-improvements) — project brief asks for artist OR album linking; MVP linked artists only.

- service/lib.js: pickAlbum (the exact-name gate, named for the album path) + linkResult now carries kind:"artist"|"album" (additive; defaults to artist so older clients keep working).
- service/resolver.js: one Spotify type=artist,album search per candidate, exact-match against either; returns {url,kind}; cache key includes the kind hint. resolveOne/resolveAll thread the LLM kind hint through.
- service/llm.js: parseClassification now returns Map(name -> "artist"|"album") so album-kind candidates route to the album path (.has() still works like the old Set).
- extension/extract.js: albumMatchesIn over two precise signals (quoted strings + album cue phrases "the album X", "X LP/EP"), folded into allMatchesIn.
- extension/content.js + styles.css: album links render italicised (.rsl-album) with an album tooltip; artist behavior unchanged.
- Tests: new pure-logic coverage in lib.test.js / llm.test.js / extract.test.cjs (album shaping, Map-kind routing, album candidate extraction). scripts/check.sh green.
- README.md + AGENTS.md: document album linking.

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

124 lines
6.3 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.
// 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 };
});