Source: task/odesli-direct-cross-platform-links (plan/improvement-backlog) — bandcamp was only a Google search; Odesli gives direct cross-platform links for Spotify-resolved names.
- service/odesli.js: makeOdesli({fetch,baseUrl}) -> getLinks(spotifyUrl); pure parseOdesli core; bounded AbortSignal.timeout; non-200/timeout/garbage -> {} (graceful).
- resolver.js: opt-in ODESLI / ODESLI_URL flag (default OFF). When ON, enrich each Spotify-resolved result with additive bandcamp/appleMusic/youtube fields (isHttpUrl-validated), cached per Spotify url. OFF -> no call, byte-identical behavior. /health + startup log surface the flag.
- extension: a tiny additive "bc" link to a direct Bandcamp page when result.bandcamp is present (reuses isSafeHttpUrl); primary artist/album/spotify/google rendering unchanged. styles.css: bandcamp-teal secondary link.
- odesli.test.js + odesli.fixture.json: parseOdesli unit-tested against a real captured Odesli response (Blood Incantation album -> direct bandcamp; absent apple/youtube omitted; junk -> {}); getLinks 200/non-200/throw/parse-error paths.
- docs: .env.example, README, AGENTS.md note the opt-in flag.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
116 lines
7.8 KiB
Markdown
116 lines
7.8 KiB
Markdown
# Agent rules for the `linker` repo (reddit → spotify/bandcamp)
|
|
|
|
A Firefox extension + a tiny Node resolver that turn artist/band names in reddit
|
|
comments into Spotify/Bandcamp links. This file is the repo-local doctrine; the work
|
|
is tracked in the **towl** work-item store under project **`reddit-spotify-linker`**.
|
|
|
|
## Stack & layout
|
|
|
|
- `service/` — zero-dependency Node resolver (Node 18+, built-in `fetch`/`http`).
|
|
`resolver.js` = the thin HTTP server that wires the real Spotify search + ollama
|
|
classify into resolve-core; `resolve-core.js` = the pure resolve orchestration
|
|
(`resolveAll`/`resolveOne`/`pickDirect`/`cacheKey`, taking injected search/classify
|
|
fns — unit-testable without network); `lib.js` = pure, side-effect-free logic (the
|
|
exact-name match precision lever + link shaping) so it's unit-testable; `llm.js` =
|
|
pure ollama prompt/parse helpers (the optional local-LLM gate, HTTP-only — no
|
|
in-process model runtime); `lib.test.js` + `llm.test.js` + `resolver.test.js` =
|
|
`node:test`. The only music API is Spotify (MusicBrainz removed). The resolver confirms **both artists
|
|
and albums**: one `type=artist,album` Spotify search per candidate, exact
|
|
case-insensitive name match against either; each result carries
|
|
`kind: "artist" | "album"`. `odesli.js` = the optional Odesli (song.link) enrichment client
|
|
(injected `fetch`, pure `parseOdesli` core, HTTP-only — no new deps); `odesli.test.js` +
|
|
`odesli.fixture.json` (a captured real response) cover it.
|
|
- `extension/` — Firefox **MV2** content script (`content.js` + `extract.js` +
|
|
`manifest.json` + `styles.css`). No build step. `extract.js` is the pure candidate
|
|
extractor (Title-Case runs + cue phrases for artists; quoted strings + album cue
|
|
phrases for albums) — node-testable via `extract.test.cjs`. Album links render
|
|
italicised (`.rsl-album`).
|
|
- **shreddit shadow DOM:** `collectTextNodes` is shadow-piercing. A TreeWalker can't
|
|
cross a shadow boundary, so it walks the chosen `root`'s light DOM AND recursively
|
|
descends into the **open** shadow roots of elements within that subtree. New reddit's
|
|
`shreddit-*` components may encapsulate comment bodies in a shadow root; this reaches
|
|
them whether reddit slots (light DOM) or encapsulates (shadow DOM). Scope is two-layer:
|
|
`getRoot` tries the narrow comment containers first (`shreddit-comment-tree`, then old
|
|
reddit's `.commentarea`) before the broad `main`/`body`, so on a new-reddit comment page
|
|
the root is the comment tree and the walk stays within it; and when `getRoot` falls back
|
|
to a broad root (no comment tree present) the recursion descends only into comment-component
|
|
shadow hosts (`tagName` starting `SHREDDIT-COMMENT`), so reddit's chrome shadow roots (nav,
|
|
sidebar, composer) are never walked. (A single union `querySelector` could not do this — it
|
|
returns the first match in document order, and `<main>` is an ancestor of the comment tree,
|
|
so it would always win.) It is a no-op on old reddit (no shadow roots). Slotted light-DOM
|
|
nodes are not double-collected (a shadow walker sees the shadow tree's own nodes, not a
|
|
`<slot>`'s assigned light nodes; a Set de-dupes belt-and-braces). **Closed** shadow roots
|
|
are unreachable from a content script — a documented limitation.
|
|
- `scripts/check.sh` — the local check gate.
|
|
|
|
## How work lands (IMPORTANT — this is NOT the towl repo)
|
|
|
|
`linker` is hosted at **git.yapplesauce.com/paul/linker**, a **plain repo**: there is
|
|
**no PR pipeline and no branch protection**. You **push to `main` directly** (via the
|
|
user's PAT). Do **not** use `towl-orchestrate` / worktree+PR here.
|
|
|
|
1. Make a small, reversible change.
|
|
2. Run the gate: `bash scripts/check.sh` (must print `ALL CHECKS PASSED`).
|
|
3. Commit citing the towl ref (e.g. `(task/odesli-direct-links)`), push `main`.
|
|
4. Stamp the SHA on the towl item: `stamp_commit { ref, sha, repo: "linker" }`.
|
|
5. Close the towl item.
|
|
|
|
**NEVER touch the `towl` or `stickball` repos from work scoped to this project.**
|
|
|
|
## Testing (zero-dep)
|
|
|
|
`node --test` over `service/*.test.js` exercises `lib.js` (norm + the exact-name match
|
|
+ link builders), `llm.js` (the classifier prompt/parse), and `resolve-core.js` (the
|
|
album/artist orchestration — kindHint tie-break, kind-aware cache key, allowFallback
|
|
gating — driven with injected fake search/classify fns, no network). Keep pure logic in
|
|
`lib.js`/`resolve-core.js` (not `resolver.js`, which has the server side effect) so it stays
|
|
testable without opening a port. `extension/extract.test.cjs` covers the pure extractor
|
|
(artist/cue/album candidates) and must be extended with each detection change. **Add/extend a
|
|
test with every logic change.** The extension's in-browser behavior is **not** headlessly
|
|
testable in this environment — validate `content.js` with `node --check` and a
|
|
documented manual check on a real reddit thread (`about:debugging` → load temporary
|
|
add-on).
|
|
|
|
## Code stewards (towl plans under objective/linker-stewardship)
|
|
|
|
Standing, language-appropriate stewards (adapted from towl's SCOUT→FILE doctrine; run
|
|
them like any other steward — e.g. via a `/loop`). Each files findings into towl and
|
|
fixes small things directly.
|
|
|
|
- **quality** (`plan/steward-linker-quality`) — correctness + regressions; **owns and
|
|
maintains `scripts/check.sh` and the `node:test` suite**.
|
|
- **design** (`plan/steward-linker-design`) — detection precision/recall, the
|
|
injected-link UX, README accuracy, reddit-DOM drift.
|
|
- **security** (`plan/steward-linker-security`) — DOM injection (build nodes via
|
|
`createElement`/`textContent`, **never `innerHTML`**; http(s) hrefs only), minimal
|
|
extension permissions, bounded external API calls (no SSRF), secrets never
|
|
logged/committed (`.env` is gitignored).
|
|
|
|
## Standing decisions
|
|
|
|
- Firefox **MV2** stays — Mozilla has no deprecation timeline; do not migrate to MV3
|
|
speculatively.
|
|
- **Optional local LLM is the recommended better-matching path** (human override,
|
|
feedback 2026-06-18 — supersedes the old "no ML/NER" rule). The resolver may call
|
|
ollama over HTTP (`OLLAMA_URL` enables it, `OLLAMA_MODEL` defaults `qwen2.5:3b`)
|
|
to classify which candidates are real artists/albums. It stays **zero-NPM-dep**:
|
|
HTTP only, no in-process model runtime. The heuristic + Spotify exact-match is the
|
|
zero-dep default when no LLM is configured.
|
|
- **Optional Odesli (song.link) enrichment** is opt-in, default OFF (`ODESLI=1`, or `ODESLI_URL`
|
|
to override the endpoint — mirrors the ollama opt-in). When ON, an **album** result that already
|
|
carries a DIRECT Spotify url is enriched with the album's direct Bandcamp (+ Apple/YouTube) links
|
|
as ADDITIVE fields (`bandcamp`/`appleMusic`/`youtube`) — the existing `name`/`kind`/`spotify`/
|
|
`google`/`primary` shape is unchanged, so off the behavior is byte-identical and an older
|
|
extension keeps rendering. The gate is `shouldEnrich` (pure, unit-tested): **album-kind results
|
|
only** — Odesli rejects ARTIST urls (405 UNSUPPORTED_URL), so artist results are skipped entirely
|
|
(no wasted round-trip on the common case). Only the Spotify-creds path produces a url to enrich;
|
|
the no-creds Google fallback is untouched. Bounded (`AbortSignal.timeout`) + cached per Spotify
|
|
url + graceful (Odesli down/garbage keeps the current result). The input is our own validated
|
|
Spotify album url against the fixed host (no SSRF).
|
|
- Detection precision lever (zero-dep default): a candidate links **only** when
|
|
Spotify returns an **artist or album** whose name matches it case-insensitively. A
|
|
confirmed name links to its Spotify artist/album page if available, else a
|
|
"<name> bandcamp" Google fallback. Album candidates are over-generated from precise
|
|
signals only (quoted strings + album cue phrases) because album free-text is noisier
|
|
than artist names — the Spotify exact-match (and/or LLM `kind=album`) is the filter.
|