diff --git a/extension/extract.js b/extension/extract.js index 62399a2..067d42f 100644 --- a/extension/extract.js +++ b/extension/extract.js @@ -23,11 +23,28 @@ // 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. + // 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; - 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; + // 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. @@ -89,24 +106,43 @@ // 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. - out.push({ name, index: gi[0], length: gi[1] - gi[0] }); + 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; - const raw = text.slice(gi[0], gi[1]); + // 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; - out.push({ name, index: gi[0], length: name.length }); + // 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; } diff --git a/extension/extract.test.cjs b/extension/extract.test.cjs index 66eb8b9..4dbf121 100644 --- a/extension/extract.test.cjs +++ b/extension/extract.test.cjs @@ -64,6 +64,43 @@ test("albumMatchesIn skips pure-number and too-short quotes", () => { assert.ok(!names.includes("X")); // length < 2 }); +test("albumMatchesIn: 'X LP/EP' branch is anchored, not the sentence prefix", () => { + // No left anchor used to yield "I think their new" (the prefix before " LP"). + const ms = albumMatchesIn("I think their new LP is amazing"); + assert.ok(!ms.some((m) => m.name === "I think their new")); + // Either nothing, or only a title-shaped run — never a sentence fragment. + for (const m of ms) assert.ok(/^["“'‘]?[A-Z]/.test(m.name)); +}); + +test("albumMatchesIn: 'the album X' guards a lowercase/verb left edge", () => { + // 'the album by Carcass' must not emit 'by Carcass' / 'by' as an album. + const a = albumMatchesIn("the album by Carcass").map((m) => m.name); + assert.ok(!a.includes("by Carcass")); + assert.ok(!a.includes("by")); + // 'the album is great…' must emit no album candidate at all. + const b = albumMatchesIn("the album is great but I forgot the name"); + assert.equal(b.length, 0); +}); + +test("albumMatchesIn: prose quotes are not album candidates", () => { + // A quote with sentence-ending punctuation mid-span, or a lone interjection, is not a title. + const a = albumMatchesIn('"We hold these truths to be self-evident."').map((m) => m.name); + assert.ok(!a.includes("We hold these truths to be self-evident.")); + assert.equal(albumMatchesIn('"lol"').length, 0); +}); + +test("albumMatchesIn: recall preserved — real titles still emit", () => { + assert.ok(albumMatchesIn("the album Reign in Blood").map((m) => m.name).includes("Reign in Blood")); + assert.ok(albumMatchesIn("Reign in Blood LP").map((m) => m.name).includes("Reign in Blood")); + assert.ok(albumMatchesIn('"Human Jerky"').map((m) => m.name).includes("Human Jerky")); +}); + +test("albumMatchesIn: tightened cues still map positions back to the text", () => { + for (const text of ["Reign in Blood LP rules", "the album by Carcass", '"Human Jerky" demo']) { + for (const m of albumMatchesIn(text)) assert.equal(text.substr(m.index, m.length), m.name); + } +}); + test("isSafeHttpUrl accepts http/https and rejects javascript/data/garbage/empty", () => { // accepted: the only schemes a resolver link may inject as an href assert.ok(isSafeHttpUrl("https://open.spotify.com/artist/abc"));