A daily email of the top 5 Hacker News front-page stories, each with a summary of the linked page and a summary of what the top comments are arguing about.
Source is the official HN Firebase API — keyless and unauthenticated. Two cron jobs:
| File | Schedule | Job |
|---|---|---|
collect.ts | hourly, :07 | Record what's on the front page |
main.ts | 07:00 UTC | Pick the top 5 and email them |
topstories.json is a live snapshot, so a single daily poll would miss anything that
peaked overnight and fell off. The collector records every front-page story hourly into
hn-digest:pool, keeping high-water marks for score and comment count. The digest then
picks the highest peak score of the past 36 hours — a genuine top-of-day, not
whatever happened to be up at 07:00.
pick()takes the 5 highest-peak-score pool entries not already emailed.- Each story is refetched for its live score and its
kidslist.kidsis in HN's own rank order — comments carry no score in the API, so this is the only signal for "top comment". Algolia'schildrenis chronological andhnrss.org/item?id=is reverse-chronological, so neither can rank. - Replies are fetched too — 10 top-level comments, each with up to 3 replies (~25-35 comments per story). This is not optional polish. Reply count tracks contention: on the Nvidia/Hugging Face thread the three most-replied top comments were "what are they buying exactly?" (11 replies), "what is the business model?" (12) and a prediction about banning quantized models (19). Top-level alone made the digest report questions HN asked instead of what HN concluded — the answers were all one level down. The prompt marks replies as replies so they aren't read as independent opinions.
article.tsfetches the linked page and extracts text. Failures (paywalls, JS-only pages, video, PDFs) leave the summary leaning on the comments, and it says so. Ask/Show HN self-posts use the post body instead.summarize.tsmakes one Claude call per story. No comments means no discussion section rather than an invented one. The discussion comes back as a framing line plus labelled bullets, parsed intoDiscussion { lead, points[] }— the shape follows the thread, so a genuine disagreement gets opposing bullets while a pile of corrections doesn't get forced into a fake two-sided split.parseDiscussionfalls back to keeping the prose as a single unlabelled point if the model returns no bullets.render.tsbuilds the HTML + plain-text email, sent viastd/email.- The seen-set is written after the send succeeds, and only for stories that made it into the email. Anything that failed is retried on the next run. A blob read failure aborts the run rather than re-sending everything.
| Key | Required | Purpose |
|---|---|---|
ANTHROPIC_API_KEY | yes | Claude API |
DIGEST_TO | no | Recipient. Defaults to the val owner's address. |
DIGEST_DRY_RUN | no | 1 logs the email instead of sending it. |
Five Claude calls a day on claude-sonnet-5 — roughly $2/month.
claude-haiku-4-5 was measured against Sonnet on identical input for the same five
stories and lost on specifics: it missed the Microduck's motor count, loop rate and
licence, missed three of the six argument threads on the Cloudflare post, and got the
Reachy Mini licence wrong. Haiku costs about $1/month less. Not worth it here, but the
model argument to summarize() makes the comparison easy to re-run.
The collector makes ~31 HN API calls an hour; the digest makes ~5 × 31. All free.
Knobs at the top of main.ts: DIGEST_SIZE, MIN_SCORE, MAX_AGE_HOURS,
TOP_COMMENTS, REPLIES_PER_COMMENT, ARTICLE_MAX_CHARS. WATCH_TOP and POOL_TTL_DAYS are in
collect.ts. Prompt and tone live in SYSTEM in summarize.ts.