Skip to content

A feed catalogue, and a poller that can hold one - #2

Merged
ralyodio merged 2 commits into
masterfrom
feat/feed-corpus
Aug 16, 2026
Merged

A feed catalogue, and a poller that can hold one#2
ralyodio merged 2 commits into
masterfrom
feat/feed-corpus

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Imports the Kagi small-web OPML — 47,223 feeds — and adds a daemon that keeps them current without being a resource hog.

Why not feeds.json

FeedManager loads every feed (and up to 100 articles each) into one JSON file it reads whole on construction and rewrites whole on every change. Right for a few dozen hand-picked feeds; for a catalogue it is quadratic work against a file growing past a gigabyte, ending in an OOM crash.

Store is now SQLite via node:sqlite — built into Node 24, so no native module to compile (this box has no build toolchain, so better-sqlite3 was not an option).

Measured: full 47,223-feed import in 1.7s at 122 MB peak RSS.

The daemon

Eight feeds in flight, 2s between batches. Five things make almost every poll free:

  • Conditional GET — ETag/Last-Modified per feed, so unchanged feeds cost a 304 and no parsing. Servers that ignore validators get the same path via content hash. Verified live: forced re-poll returned 176 unchanged of 188.
  • Adaptive intervals — publishing feeds checked sooner, quiet ones back off to a day.
  • Exponential backoff, then deactivation — dead domains are the biggest waste in a catalogue this size.
  • Bounded everything — one feed per host per batch, request timeout, download size cap.
  • Idle sleep when nothing is due.

The database is the queue (next_fetch_at is the cursor) — no Redis, no job server, and killing it mid-batch loses nothing.

Live run: 25 batches → 188 feeds polled, 3,418 articles, ~134 feeds/min. That is one full sweep per 6h interval.

Podcasts

feeds harvest-podcasts uses the iTunes Search API — the same keyless source media-streamer uses, and the one directory that returns a real feedUrl. No bulk export exists, so it is assembled from many paced searches: 2,642 unique feeds from the first 29 queries. Resumable; Ctrl-C keeps what it found.

Shaped for what comes next

Feeds are global rows and subscriptions keys a feed to an account, so a follow/unfollow product adds rows rather than needing the catalogue re-modelled. accounts.auth_kind leaves room for email or CoinPay. Both tables are empty and unused — this CLI is still single-user.

Commands

rssamp feeds import-opml --file ~/smallweb.opml
rssamp feeds harvest-podcasts [--all]
rssamp feeds stats
rssamp feeds recent --kind podcast
rssamp daemon start [--batch 8] [--pause 2]

Tests

38 new tests, all passing. Full suite is 192 passing / 11 failing — all 11 pre-existing in config-manager and feed-scheduler, neither of which this touches. (Note: the full suite does not terminate cleanly on its own, also pre-existing — feed-scheduler leaves real cron timers running.)

🤖 Generated with Claude Code

Imports the Kagi small-web OPML — 47,223 feeds — and adds a daemon that
keeps them current without being a resource hog.

**Why not feeds.json.** `FeedManager` loads every feed, and up to a
hundred articles per feed, into one JSON file that it reads whole on
construction and rewrites whole on every change. That is right for the
few dozen feeds a person hand-picks. For a catalogue it is quadratic
work against a file growing past a gigabyte, and it ends in an
out-of-memory crash. The store is now SQLite via `node:sqlite`, built
into Node 24, so there is no native module to compile.

Measured: the full 47,223-feed import takes 1.7s at 122 MB peak RSS.

**The daemon.** Eight feeds in flight, two seconds between batches, and
five things that make almost every poll free:

  - Conditional GET. Every feed stores its ETag and Last-Modified, so an
    unchanged feed costs a 304 and no parsing. Servers that ignore
    validators get the same path via a content hash. Verified live: a
    forced re-poll came back 176 unchanged out of 188.
  - Adaptive intervals. Publishing feeds are checked sooner, quiet ones
    back off to a day.
  - Exponential backoff, then deactivation. Dead domains are the largest
    source of waste in a catalogue this size.
  - One feed per host per batch, a request timeout, and a download cap.
  - Idle sleep when nothing is due.

The database is the queue — `next_fetch_at` is the cursor — so there is
no Redis, and killing the daemon mid-batch loses nothing.

**Podcasts.** `feeds harvest-podcasts` builds a podcast catalogue from
the iTunes Search API, the same keyless source `media-streamer` uses and
the one directory that hands back a real `feedUrl`. There is no bulk
export, so it is assembled from many paced searches — 2,642 unique feeds
from the first 29 queries. Resumable: results are written as they
arrive, and Ctrl-C keeps them.

**Shaped for what comes next.** Feeds are global rows and `subscriptions`
keys a feed to an account, so a follow/unfollow product adds rows rather
than needing the catalogue re-modelled. `accounts.auth_kind` leaves room
for email or CoinPay. Both tables are empty and unused for now; this CLI
is still single-user.

Live run: 25 batches polled 188 feeds and collected 3,418 articles at
~134 feeds/min, which is one full sweep of the catalogue per 6h interval.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

9 finding(s)

HIGH/CRITICAL: 8 | MEDIUM: 1

Severity Rule Location
HIGH secret-generic-api-key docs/CONFIGURATION.md:360
HIGH secret-generic-credential docs/CONFIGURATION.md:361
HIGH secret-generic-credential docs/CONFIGURATION.md:362
HIGH secret-generic-credential docs/CONFIGURATION.md:363
HIGH secret-generic-credential docs/CONFIGURATION.md:367
HIGH secret-generic-api-key docs/CONFIGURATION.md:368
HIGH secret-generic-credential docs/CONFIGURATION.md:368
HIGH tls-verification-disabled docs/TROUBLESHOOTING.md:221
MEDIUM manifest-install-lifecycle-script package.json:37

Snippets are redacted; ThreatCrush never prints matched credential material.

The 340 hand-collected `opml` feeds are the ones worth keeping — the
33k `smallweb` rows are a re-importable public catalogue. Exporting as
OPML server-side keeps tens of thousands of rows out of an agent's
context, which is how brisk imported them in the first place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ralyodio
ralyodio marked this pull request as ready for review August 16, 2026 10:53
@ralyodio
ralyodio merged commit 3da8f85 into master Aug 16, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant