2
stars
51
commits
JavaScript
primary language
Jul 23, 2026
updated
Re-exports Hacker News and Lobsters RSS feeds with extra content embedded for offline reading in a feed reader.
It's designed and tested primarily for NetNewsWire:
the embedded content is styled with inline CSS (NetNewsWire strips <style>
blocks but keeps inline styles) and comment threads use native <details> for
collapsing. Other readers should work, but may render the embedded content
differently.
| Feed | Contents |
|---|---|
/hn.xml | HN front page |
/lobsters.xml | Lobsters top stories (past week) |
Each source feed is queried once per request (the feed is rebuilt fresh every time — see Caching & freshness), and each external-link story produces two items, in the source's original order:
Article: <title> — the full article body extracted via reader mode
(comments, nav, and boilerplate stripped).Comments: <title> — the HN/Lobsters discussion rendered as clean,
threaded comments.Self/text posts (Ask HN, Lobsters discussions, etc.) point at their own
discussion page rather than an external article, so splitting them would be
redundant — the discussion view already renders the post's body above the
thread. These produce a single, unprefixed item (<title> with no
Article:/Comments: prefix) rendered as that discussion.
Article extraction uses Mozilla Readability (the engine behind Firefox Reader View) over a lightweight linkedom DOM, so only the main article remains.
Every request requires a shared secret as a query param:
https://your-host/hn.xml?secret=YOUR_SECRET
The secret is passed as a URL query parameter rather than an Authorization
header or HTTP Basic auth because many RSS readers don't let you attach custom
headers (or credentials) to a feed subscription — a query param is the lowest
common denominator that works in every reader. The usual trade-off applies:
query-string secrets can surface in server/proxy access logs, so treat the feed
URL itself as sensitive.
/health is exempt so uptime monitors can probe it.
Some article pages sit behind a proof-of-work / captcha interstitial (Anubis, Cloudflare "Just a moment", etc.). The feeds detect these and fall back, in order:
JINA_READER=0 to
opt out. Sends the article URL to Jina's servers.The feed is rebuilt from a fresh source fetch on every request, so the story list always reflects the current front page. Rebuilds stay cheap because each story's rendered content is cached and only re-rendered when stale:
ARTICLE_TTL_MS (24h).COMMENTS_TTL_MS
(30m), then re-rendered to pick up new replies. Item guids are stable, so a
refreshed thread updates in place in your reader instead of appearing as a
duplicate.STALE_TTL_MS
(24h) rather than degrading to the link-out card — a transient upstream error
means "slightly stale," not "unavailable."With Upstash/Vercel KV configured, this cache persists across serverless cold starts (and is namespaced per deploy, so a new deploy starts fresh); without it, an in-memory cache is used per instance.
Set via environment variables (on Vercel, project env vars; locally, .env —
see .env.example).
| Var | Default | Purpose |
|---|---|---|
API_SECRET | (required) | Shared secret for ?secret= |
ENTRY_TTL_MS | 86400000 (24h) | Default cached lifetime; also the fallback for ARTICLE_TTL_MS |
ARTICLE_TTL_MS | (= ENTRY_TTL_MS) | How long a rendered article stays cached (immutable, so long) |
COMMENTS_TTL_MS | 1800000 (30m) | How long a rendered discussion stays cached before re-rendering to pick up new comments |
FAIL_TTL_MS | 600000 (10m) | How long a failed render is remembered (and skipped) before being retried |
STALE_TTL_MS | 86400000 (24h) | How long the last good render is kept to serve on a failed re-render (stale-on-error) before falling back to link-out |
ENTRY_MAX | 500 | Max cached entries before LRU eviction |
FETCH_CONCURRENCY | 3 | Parallel page fetches per feed build |
JINA_READER | (on) | Set 0/false/off/no to disable the Jina fallback |
JINA_API_KEY | (none) | Optional; raises Jina's rate limit |
KV_REST_API_URL / KV_REST_API_TOKEN | (none) | Optional Upstash/Vercel KV for a persistent cache across cold starts |
npm install
API_SECRET=dev npm start # or: npm run start (reads .env)
Then open http://localhost:3000/?secret=dev for the feed index.
The app runs as a zero-config Vercel serverless function — no build step or
framework settings needed. vercel.json routes every request to api/index.js
(which exports the Express app) and sets the function timeout.
API_SECRET. Under Settings → Environment Variables, add
API_SECRET with a long random value. It's the only required variable; see
Configuration for the optional tunables.KV_REST_API_URL / KV_REST_API_TOKEN or Upstash's
UPSTASH_REDIS_REST_URL / UPSTASH_REDIS_REST_TOKEN, so no code changes are
needed. Without it the cache is in-memory only, which on serverless is wiped
on every cold start — so each build re-fetches every page from one IP and is
far more likely to be rate-limited. See Caching & freshness.Your feeds are then at https://<your-deployment>/hn.xml?secret=YOUR_SECRET (and
/lobsters.xml?secret=…); open https://<your-deployment>/?secret=… for the
index, then subscribe to the feed URLs in your reader.
51 commits
JavaScript
100.0%
2
stars
51
commits
JavaScript
primary language
Jul 23, 2026
updated
Re-exports Hacker News and Lobsters RSS feeds with extra content embedded for offline reading in a feed reader.
It's designed and tested primarily for NetNewsWire:
the embedded content is styled with inline CSS (NetNewsWire strips <style>
blocks but keeps inline styles) and comment threads use native <details> for
collapsing. Other readers should work, but may render the embedded content
differently.
| Feed | Contents |
|---|---|
/hn.xml | HN front page |
/lobsters.xml | Lobsters top stories (past week) |
Each source feed is queried once per request (the feed is rebuilt fresh every time — see Caching & freshness), and each external-link story produces two items, in the source's original order:
Article: <title> — the full article body extracted via reader mode
(comments, nav, and boilerplate stripped).Comments: <title> — the HN/Lobsters discussion rendered as clean,
threaded comments.Self/text posts (Ask HN, Lobsters discussions, etc.) point at their own
discussion page rather than an external article, so splitting them would be
redundant — the discussion view already renders the post's body above the
thread. These produce a single, unprefixed item (<title> with no
Article:/Comments: prefix) rendered as that discussion.
Article extraction uses Mozilla Readability (the engine behind Firefox Reader View) over a lightweight linkedom DOM, so only the main article remains.
Every request requires a shared secret as a query param:
https://your-host/hn.xml?secret=YOUR_SECRET
The secret is passed as a URL query parameter rather than an Authorization
header or HTTP Basic auth because many RSS readers don't let you attach custom
headers (or credentials) to a feed subscription — a query param is the lowest
common denominator that works in every reader. The usual trade-off applies:
query-string secrets can surface in server/proxy access logs, so treat the feed
URL itself as sensitive.
/health is exempt so uptime monitors can probe it.
Some article pages sit behind a proof-of-work / captcha interstitial (Anubis, Cloudflare "Just a moment", etc.). The feeds detect these and fall back, in order:
JINA_READER=0 to
opt out. Sends the article URL to Jina's servers.The feed is rebuilt from a fresh source fetch on every request, so the story list always reflects the current front page. Rebuilds stay cheap because each story's rendered content is cached and only re-rendered when stale:
ARTICLE_TTL_MS (24h).COMMENTS_TTL_MS
(30m), then re-rendered to pick up new replies. Item guids are stable, so a
refreshed thread updates in place in your reader instead of appearing as a
duplicate.STALE_TTL_MS
(24h) rather than degrading to the link-out card — a transient upstream error
means "slightly stale," not "unavailable."With Upstash/Vercel KV configured, this cache persists across serverless cold starts (and is namespaced per deploy, so a new deploy starts fresh); without it, an in-memory cache is used per instance.
Set via environment variables (on Vercel, project env vars; locally, .env —
see .env.example).
| Var | Default | Purpose |
|---|---|---|
API_SECRET | (required) | Shared secret for ?secret= |
ENTRY_TTL_MS | 86400000 (24h) | Default cached lifetime; also the fallback for ARTICLE_TTL_MS |
ARTICLE_TTL_MS | (= ENTRY_TTL_MS) | How long a rendered article stays cached (immutable, so long) |
COMMENTS_TTL_MS | 1800000 (30m) | How long a rendered discussion stays cached before re-rendering to pick up new comments |
FAIL_TTL_MS | 600000 (10m) | How long a failed render is remembered (and skipped) before being retried |
STALE_TTL_MS | 86400000 (24h) | How long the last good render is kept to serve on a failed re-render (stale-on-error) before falling back to link-out |
ENTRY_MAX | 500 | Max cached entries before LRU eviction |
FETCH_CONCURRENCY | 3 | Parallel page fetches per feed build |
JINA_READER | (on) | Set 0/false/off/no to disable the Jina fallback |
JINA_API_KEY | (none) | Optional; raises Jina's rate limit |
KV_REST_API_URL / KV_REST_API_TOKEN | (none) | Optional Upstash/Vercel KV for a persistent cache across cold starts |
npm install
API_SECRET=dev npm start # or: npm run start (reads .env)
Then open http://localhost:3000/?secret=dev for the feed index.
The app runs as a zero-config Vercel serverless function — no build step or
framework settings needed. vercel.json routes every request to api/index.js
(which exports the Express app) and sets the function timeout.
API_SECRET. Under Settings → Environment Variables, add
API_SECRET with a long random value. It's the only required variable; see
Configuration for the optional tunables.KV_REST_API_URL / KV_REST_API_TOKEN or Upstash's
UPSTASH_REDIS_REST_URL / UPSTASH_REDIS_REST_TOKEN, so no code changes are
needed. Without it the cache is in-memory only, which on serverless is wiped
on every cold start — so each build re-fetches every page from one IP and is
far more likely to be rate-limited. See Caching & freshness.Your feeds are then at https://<your-deployment>/hn.xml?secret=YOUR_SECRET (and
/lobsters.xml?secret=…); open https://<your-deployment>/?secret=… for the
index, then subscribe to the feed URLs in your reader.
51 commits
JavaScript
100.0%