hn-digest

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:

FileScheduleJob
collect.tshourly, :07Record what's on the front page
main.ts07:00 UTCPick the top 5 and email them

Why two jobs

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.

How the digest run works

  1. pick() takes the 5 highest-peak-score pool entries not already emailed.
  2. Each story is refetched for its live score and its kids list. kids is in HN's own rank order — comments carry no score in the API, so this is the only signal for "top comment". Algolia's children is chronological and hnrss.org/item?id= is reverse-chronological, so neither can rank.
  3. 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.
  4. article.ts fetches 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.
  5. summarize.ts makes 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 into Discussion { 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. parseDiscussion falls back to keeping the prose as a single unlabelled point if the model returns no bullets.
  6. render.ts builds the HTML + plain-text email, sent via std/email.
  7. 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.

Env vars

KeyRequiredPurpose
ANTHROPIC_API_KEYyesClaude API
DIGEST_TOnoRecipient. Defaults to the val owner's address.
DIGEST_DRY_RUNno1 logs the email instead of sending it.

Cost

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.

Tuning

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.