Neural Tech Daily
ai-tutorials

Build a Personalized News Digest with Claude and RSS: End-to-End Python Tutorial

Build a Python pipeline that polls RSS feeds, filters items by Claude against your interests, renders a Markdown digest, emails via Resend, and runs on cron.

Updated ~13 min read
Share
GitHub social card for kurtmckee/feedparser — the Python RSS and Atom parser used to poll feeds in this tutorial

Image: kurtmckee/feedparser on GitHub, used for editorial coverage of the Python RSS parser taught in this tutorial.

What you’ll build

A small Python project that polls a list of RSS feeds (Hacker News front page, arXiv cs.AI new submissions, a couple of vendor blogs), asks Claude to score each item against a short list of your interests, renders the kept items into a Markdown digest, and emails it to you via Resend. The whole pipeline runs from a single cron job once a day and fits in roughly 150 lines of Python.

The recommendation here is structural: per the feedparser documentation, a single feedparser.parse(url) call normalises RSS 2.0 and Atom into the same Python object, 1 so you can mix arXiv (Atom) and Hacker News mirrors (RSS 2.0) without per-source branching. Per the Anthropic Messages API reference, a single Claude call can take a batch of titles plus your interest list and return a JSON verdict for each item, 2 which keeps cost predictable at one request per cron run rather than one per feed item. Per the Resend Python SDK quickstart, resend.Emails.send(...) takes a dict with from, to, subject, and html, 3 so the last step is a four-line function.

The differentiator over a plain “show me everything” RSS reader is the Claude filter. Your stated interests sit in a config file, and the model triages a noisy 200-item firehose down to the 10 to 20 items you actually want to read, with a one-line “why this matters” note attached to each kept item.

Prerequisites

You’ll need:

  • Python 3.9 or newer.
  • An Anthropic API key. Per the Anthropic pricing page, Claude Sonnet 4.6 is billed at $3 per million input tokens and $15 per million output tokens 4 — a daily digest run over 200 RSS items typically costs well under one cent per day, but check the pricing page for current rates before scaling.
  • A Resend account and a verified sending domain. Per the Resend pricing page, the free tier covers 100 emails per day and 3,000 per month, 5 which is more than enough for a personal digest.
  • A machine that can run cron (a Linux VPS, a Mac, or a small VM are all fine).

Set both API keys as environment variables so the SDKs pick them up automatically:

export ANTHROPIC_API_KEY="sk-ant-..."
export RESEND_API_KEY="re_..."

Per the Anthropic Python SDK PyPI page, instantiating Anthropic() with no arguments reads ANTHROPIC_API_KEY from the environment; 6 the Resend SDK reads RESEND_API_KEY the same way per its quickstart. 3

Step 1: Install the dependencies

Create a virtual environment, then install feedparser, the Anthropic SDK, and the Resend SDK:

python -m venv .venv
source .venv/bin/activate
pip install feedparser anthropic resend

feedparser is pure Python and has no system dependencies per its documentation introduction. 7 The Anthropic and Resend SDKs are both standard pip install packages.

GitHub social card for anthropics/anthropic-sdk-python — the official Python SDK used to call Claude's Messages API in this tutorial

Image: anthropics/anthropic-sdk-python on GitHub, used for editorial coverage of the official Python client.

Step 2: Define the feed list and your interests

Two pieces of config live at the top of the file. The first is the list of feeds. The second is a short prose description of what you want to read.

Hacker News exposes a stable RSS surface via hnrss.github.io per its documentation, 8 and arXiv publishes a per-category Atom feed per the arXiv RSS help page. 9 Create digest.py:

FEEDS = [
    "https://hnrss.org/frontpage",
    "https://export.arxiv.org/rss/cs.AI",
    "https://openai.com/blog/rss.xml",
    "https://www.anthropic.com/news/rss.xml",
]

INTERESTS = """
I work on LLM application infrastructure. Surface items about:
- new model releases or major version bumps from frontier labs
- prompt caching, structured output, and tool use techniques
- production reliability patterns for LLM apps (eval, observability, cost)
- arXiv papers on agent architectures or retrieval-augmented generation

Skip:
- general crypto, web3, and trading content
- consumer hardware launches unrelated to AI accelerators
- pure ML theory papers with no implementation angle
"""

Two details earn their place. The interest list reads as a brief written for a human assistant; per the Anthropic Messages API reference, the model treats the system prompt as steering context, 2 so writing it as prose rather than a rigid taxonomy gives Claude room to apply judgement on edge cases. The feed list keeps Atom and RSS sources side by side because feedparser normalises both into the same entries shape per the common-elements reference. 10

Step 3: Poll the feeds

The first function pulls every feed and flattens the result into a list of dicts. Add to digest.py:

import feedparser
from datetime import datetime, timezone, timedelta

def fetch_items(feeds: list[str], hours: int = 24) -> list[dict]:
    """Pull all feeds; return entries published in the last `hours` hours."""
    cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
    items = []
    for feed_url in feeds:
        parsed = feedparser.parse(feed_url)
        source = parsed.feed.get("title", feed_url)
        for entry in parsed.entries:
            published = entry.get("published_parsed") or entry.get("updated_parsed")
            if published is None:
                continue
            published_dt = datetime(*published[:6], tzinfo=timezone.utc)
            if published_dt < cutoff:
                continue
            items.append({
                "source": source,
                "title": entry.get("title", "(no title)"),
                "link": entry.get("link", ""),
                "summary": entry.get("summary", "")[:500],
                "published": published_dt.isoformat(),
            })
    return items

Three points worth narrating. feedparser.parse(url) is a synchronous network call per the feedparser introduction, 7 so a list of 10 feeds takes roughly 5 to 15 seconds end-to-end; that’s fine for a once-a-day cron and avoids the operational cost of an async stack. The cutoff filter keeps the digest scoped to the last 24 hours, which keeps the prompt small and the model focused. The summary[:500] truncation caps the per-item payload before it ever reaches Claude — arXiv summaries can run several thousand characters, and 500 is enough for a relevance judgement.

GitHub social card for arXiv/arxiv-base — the codebase backing the arXiv Atom feed referenced as a source in this tutorial

Image: arXiv/arxiv-base on GitHub, used for editorial coverage of the arXiv platform whose Atom feed this tutorial polls.

Step 4: Score items with Claude

Now batch the items into a single Messages-API call. Per the Anthropic Messages API reference, the model accepts a system prompt plus a user prompt and returns text content, 2 so the right shape is: system prompt carries the interest list, user prompt carries a numbered list of titles plus summaries, response is a JSON array of verdicts.

Add to digest.py:

import json
from anthropic import Anthropic

client = Anthropic()

SCORING_SYSTEM_PROMPT = f"""
You are a personal news filter. The reader's interests are:
{INTERESTS}

For each numbered item in the user's message, return a JSON array
where each element has:
- "index": the item number
- "keep": true or false
- "reason": a one-sentence note explaining the verdict

Return ONLY the JSON array, with no markdown fences or extra text.
"""

def score_items(items: list[dict]) -> list[dict]:
    """Ask Claude which items match the reader's interests."""
    if not items:
        return []
    numbered = "\n\n".join(
        f"{i}. [{item['source']}] {item['title']}\n{item['summary']}"
        for i, item in enumerate(items)
    )
    message = client.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=4096,
        system=SCORING_SYSTEM_PROMPT,
        messages=[{"role": "user", "content": numbered}],
    )
    verdicts = json.loads(message.content[0].text)
    kept = []
    for verdict in verdicts:
        if verdict.get("keep"):
            item = items[verdict["index"]]
            item["reason"] = verdict.get("reason", "")
            kept.append(item)
    return kept

A few choices worth narrating. claude-sonnet-4-6 is the canonical Sonnet model ID per the Anthropic models overview; 11 Sonnet is the right register for a triage task because it’s faster and cheaper than Opus while still strong on structured output. max_tokens=4096 covers a verdict list for several hundred items — each verdict is roughly 30 to 50 tokens. The system prompt does the heavy lifting; the user message is just the data. Per the Messages API reference, that split keeps the steering context out of the user-turn token budget. 2

The prompt is explicit about no markdown fences; Claude sometimes wraps JSON in triple backticks and that wrapper trips json.loads. If you find a more defensive parse is needed for your feeds, wrap the call in try/except json.JSONDecodeError and log the raw response.

GitHub social card for resend/resend-python — the official Resend Python SDK used to send the digest email in this tutorial

Image: resend/resend-python on GitHub, used for editorial coverage of the official Resend Python client.

Step 5: Render the Markdown digest

Once Claude returns the kept items, render them to Markdown grouped by source. Add to digest.py:

from collections import defaultdict

def render_digest(kept: list[dict]) -> str:
    """Render kept items as a Markdown digest grouped by source."""
    if not kept:
        return "# Daily digest\n\nNo items matched your interests today.\n"
    by_source = defaultdict(list)
    for item in kept:
        by_source[item["source"]].append(item)
    today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
    lines = [f"# Daily digest — {today}", ""]
    for source, source_items in by_source.items():
        lines.append(f"## {source}")
        lines.append("")
        for item in source_items:
            lines.append(f"- [{item['title']}]({item['link']})")
            if item.get("reason"):
                lines.append(f"  - _{item['reason']}_")
        lines.append("")
    return "\n".join(lines)

The structure is deliberately plain: an H1 for the date, an H2 per source, a bullet per kept item with the title as a link and the “reason” note italicised underneath. Markdown is the right intermediate format because most modern email clients render it cleanly when you pass it through a Markdown-to-HTML step, and a plain-text variant degrades gracefully for clients that don’t.

Step 6: Email via Resend

Per the Resend send-email API reference, the JSON body takes from, to, subject, and either html or text. 12 The Python SDK wraps this as resend.Emails.send(params) per its quickstart. 3

Add to digest.py:

import os
import resend
from markdown import markdown  # pip install markdown

resend.api_key = os.environ["RESEND_API_KEY"]

def send_digest(markdown_body: str, recipient: str, sender: str) -> str:
    """Convert Markdown to HTML and send via Resend. Return the message id."""
    html_body = markdown(markdown_body)
    today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
    params = {
        "from": sender,
        "to": [recipient],
        "subject": f"Daily digest — {today}",
        "html": html_body,
    }
    response = resend.Emails.send(params)
    return response["id"]

Two details. The from address must use a domain you’ve verified in the Resend dashboard per the Resend quickstart; 3 sending from an unverified domain returns an error. The markdown library (pip install markdown) is a small dependency that converts the Markdown intermediate to inline HTML; an alternative is markdown2 or any equivalent renderer.

GitHub social card for Python-Markdown/markdown — the Python Markdown-to-HTML library used to render the digest body before email send

Image: Python-Markdown/markdown on GitHub, used for editorial coverage of the Markdown rendering library.

Step 7: Wire the end-to-end run

The main block ties the four steps together:

if __name__ == "__main__":
    items = fetch_items(FEEDS, hours=24)
    kept = score_items(items)
    digest = render_digest(kept)
    message_id = send_digest(
        markdown_body=digest,
        recipient="you@example.com",
        sender="digest@yourdomain.com",
    )
    print(f"Sent digest with {len(kept)} items, message id {message_id}")

Run it once by hand to confirm the end-to-end flow works:

python digest.py

You should see a one-line summary in the terminal and the digest land in your inbox within a few seconds.

Step 8: Deploy on cron

Per the GNU cron crontab(5) manual, a crontab line takes five time fields followed by the command, 13 so a “run every day at 08:00” entry looks like this. Edit your crontab with crontab -e and add:

0 8 * * * cd /home/you/digest && /home/you/digest/.venv/bin/python digest.py >> /home/you/digest/digest.log 2>&1

Three things keep this honest. The cd step is necessary because cron runs with a minimal working directory; without it, any relative paths in your config break. The absolute path to the venv’s Python binary is necessary because cron’s PATH does not include your shell’s activated venv. The >> ... 2>&1 redirect captures both stdout and stderr to a log file so a silent failure leaves a paper trail.

Cron does not inherit your interactive shell’s environment per the crontab(5) manual, 13 so set the API keys at the top of the crontab file:

ANTHROPIC_API_KEY=sk-ant-...
RESEND_API_KEY=re_...

0 8 * * * cd /home/you/digest && /home/you/digest/.venv/bin/python digest.py >> /home/you/digest/digest.log 2>&1

Hardening checklist

A daily script is one thing; a digest you trust to run unattended needs a few more guards.

  • Retry the Claude call on transient errors. The Anthropic Python SDK raises typed exceptions for rate-limit and server errors per its PyPI documentation; 6 wrap client.messages.create(...) in a small backoff loop so a one-off 5xx doesn’t lose a day’s digest.
  • Validate the JSON shape. If Claude drifts on schema (returns a top-level object instead of an array, or omits the keep field), catch the parse error and fall back to a “review everything” digest rather than dropping items silently.
  • Pin the model ID. claude-sonnet-4-6 is a pinned snapshot per the Anthropic models overview; 11 pinning rather than relying on an evergreen alias keeps the triage behaviour stable across SDK upgrades.
  • Deduplicate across runs. Store the previous run’s item links in a small SQLite file and skip any link you’ve already surfaced. RSS feeds frequently re-publish items with updated timestamps, and dedup keeps the digest from repeating yesterday’s headlines.
  • Alert on empty digests. If the kept-item count is zero for three consecutive days, something is probably broken (a feed went 404, the interest prompt is too strict, the API key expired); email yourself a “digest is empty” warning so the failure is loud.
  • Log token cost. The Messages API response includes a usage field with input and output token counts per the API reference; 2 log it per run so a runaway prompt change shows up before the monthly bill does.

Where to take it next

A few natural extensions:

  • Multi-recipient routing. Pass a list of (recipient, interests) pairs and run the scoring step per recipient; the fetch step runs once and the scoring fans out.
  • Per-source weighting. Add a priority field to each feed (arXiv higher than HN, vendor blogs lower) and pass it into the system prompt so Claude breaks ties on relevance.
  • Long-form summaries. Drop in a second Claude call that takes the kept items and writes a one-paragraph “themes of the day” preamble before the bullet list.
  • Web archive. Mirror the Markdown digest to a static site so old digests are searchable; Markdown is already the right intermediate.

The full source for the walkthrough is the seven code blocks above, in order. Drop them into a single digest.py, set the two API keys, edit one crontab line, and the end-to-end pipeline polls your feeds, triages the noise, renders the keepers, and emails you the digest once a day.

How this article was made: an autonomous AI pipeline researched, drafted, fact-checked, and reviewed this piece, aggregating publicly-available information from the sources consulted below. AI (artificial intelligence) can make mistakes, so please cross-check the consulted sources before acting on anything here. Neural Tech Daily is not liable for decisions or outcomes based on this article.

Sources consulted

Cited Sources

  1. 1. feedparser — Documentation home (RSS 2.0 and Atom normalisation) (accessed )
  2. 2. Anthropic — Messages API reference (system prompt, max_tokens, usage field) (accessed )
  3. 3. Resend — Python SDK quickstart (resend.Emails.send and RESEND_API_KEY) (accessed )
  4. 4. Anthropic — API pricing (Claude Sonnet 4.6 input and output token rates) (accessed )
  5. 5. Resend — Pricing (free tier: 100 emails/day, 3,000/month) (accessed )
  6. 6. Anthropic — Python SDK (anthropic) on PyPI (ANTHROPIC_API_KEY environment variable, typed exceptions) (accessed )
  7. 7. feedparser — Introduction and basic parsing (feedparser.parse synchronous network call) (accessed )
  8. 8. Hacker News — RSS feeds (hnrss.github.io front-page feed URL) (accessed )
  9. 9. arXiv — RSS and Atom feeds (per-category cs.AI feed) (accessed )
  10. 10. feedparser — Common RSS elements (entries shape across RSS and Atom) (accessed )
  11. 11. Anthropic — Models overview (claude-sonnet-4-6 canonical model ID) (accessed )
  12. 12. Resend — API reference: send email (from, to, subject, html parameters) (accessed )
  13. 13. GNU cron — crontab(5) manual (five time fields, environment inheritance) (accessed )

Anonymous · no cookies set

Report a problem with this article

Articles are produced by an autonomous AI pipeline; mistakes do happen. Tell us what's wrong and the editorial review will revisit the claim.

Category

Found this useful? Share it.