This is the modular version of a DIY news digest. Instead of one long script, you get five short files with clean seams: swap the LLM provider with one environment variable, test the fetcher without spending API tokens, replace SMTP with an API-based sender later without touching anything else. If you want the single-file version first, read the ChatGPT API tutorial; this one is for people who know they will still be running this in a year.
The stack: feedparser for fetching, either the OpenAI or Anthropic SDK for summarization, smtplib for delivery, and a systemd timer on a cheap VPS for scheduling. Total dependency count: three packages.
Project layout
mkdir -p ~/digest && cd ~/digest
python -m venv .venv
source .venv/bin/activate
pip install feedparser openai anthropic
# You will create these files:
# config.py - feeds, limits, provider selection
# fetch.py - RSS fetching + dedupe state
# summarize.py - pluggable LLM call
# send.py - SMTP delivery
# main.py - glueconfig.py - one place for every knob
Everything you might want to change lives here. Secrets stay in environment variables; config.py only reads them. The one required decision is LLM_PROVIDER, which defaults to openai.
"""Central configuration. Secrets come from environment variables."""
import os
from pathlib import Path
FEEDS = [
"https://hnrss.org/frontpage",
"https://feeds.arstechnica.com/arstechnica/index",
"https://www.theverge.com/rss/index.xml",
]
MAX_ARTICLES = 12
SNIPPET_CHARS = 400
# "openai" or "anthropic"
LLM_PROVIDER = os.environ.get("LLM_PROVIDER", "openai")
OPENAI_MODEL = "gpt-4o-mini"
ANTHROPIC_MODEL = "claude-haiku-4-5"
STATE_FILE = Path(__file__).with_name("state.json")
SMTP_HOST = os.environ.get("SMTP_HOST", "smtp.gmail.com")
SMTP_PORT = int(os.environ.get("SMTP_PORT", "587"))
SMTP_USER = os.environ.get("SMTP_USER", "")
SMTP_PASS = os.environ.get("SMTP_PASS", "")
MAIL_FROM = os.environ.get("MAIL_FROM", SMTP_USER)
MAIL_TO = os.environ.get("MAIL_TO", SMTP_USER)fetch.py - feeds in, unseen articles out
The fetcher returns plain dictionaries and knows nothing about LLMs or email. Dedupe state is a JSON file holding the set of URLs already sent, capped so it cannot grow forever. URLs are normalized by stripping query strings, because several major feeds rotate tracking parameters and would otherwise slip past a naive comparison.
"""Fetch RSS feeds and return articles not yet sent."""
import json
import sys
from urllib.parse import urlsplit, urlunsplit
import feedparser
import config
def _normalize(url: str) -> str:
parts = urlsplit(url)
return urlunsplit((parts.scheme, parts.netloc, parts.path, "", ""))
def load_state() -> set:
if config.STATE_FILE.exists():
data = json.loads(config.STATE_FILE.read_text(encoding="utf-8"))
return set(data.get("seen", []))
return set()
def save_state(seen: set) -> None:
payload = {"seen": sorted(seen)[-5000:]}
config.STATE_FILE.write_text(json.dumps(payload), encoding="utf-8")
def fetch_new(seen: set) -> list:
articles = []
for feed_url in config.FEEDS:
parsed = feedparser.parse(feed_url)
if parsed.bozo and not parsed.entries:
print(f"warning: unparseable feed {feed_url}", file=sys.stderr)
continue
for entry in parsed.entries:
raw_url = entry.get("link", "")
if not raw_url:
continue
url = _normalize(raw_url)
if url in seen:
continue
snippet = " ".join(entry.get("summary", "").split())
articles.append(
{
"title": entry.get("title", "(no title)"),
"source": parsed.feed.get("title", feed_url),
"url": url,
"snippet": snippet[: config.SNIPPET_CHARS],
}
)
return articles[: config.MAX_ARTICLES]summarize.py - pluggable OpenAI or Anthropic
One public function, summarize(articles), and two private provider functions behind it. The SDK imports happen inside the provider functions, so you can deploy with only one of the two packages installed if you prefer. Note the Anthropic API requires max_tokens explicitly; the OpenAI chat API does not.
"""Turn a list of articles into a digest via the configured LLM."""
import config
PROMPT = """You write a personal daily news digest.
Articles are listed below (title, source, URL, snippet).
Rules:
- Open with one sentence summarizing the day.
- Then one section per story worth reading, at most 8 sections:
bold headline, 2-3 factual sentences, then the URL on its own line.
- Drop duplicates and thin content. No hype, no filler.
- Output HTML fragments only (h3, p, a). No html or body tags.
Articles:
{articles}
"""
def _listing(articles: list) -> str:
return "\n\n".join(
f"Title: {a['title']}\nSource: {a['source']}\n"
f"URL: {a['url']}\nSnippet: {a['snippet']}"
for a in articles
)
def _openai_digest(prompt: str) -> str:
from openai import OpenAI
client = OpenAI()
response = client.chat.completions.create(
model=config.OPENAI_MODEL,
messages=[{"role": "user", "content": prompt}],
temperature=0.4,
)
return response.choices[0].message.content
def _anthropic_digest(prompt: str) -> str:
import anthropic
client = anthropic.Anthropic()
response = client.messages.create(
model=config.ANTHROPIC_MODEL,
max_tokens=2000,
messages=[{"role": "user", "content": prompt}],
)
return response.content[0].text
def summarize(articles: list) -> str:
prompt = PROMPT.format(articles=_listing(articles))
if config.LLM_PROVIDER == "anthropic":
return _anthropic_digest(prompt)
return _openai_digest(prompt)Switching providers is now a deployment decision, not a code change: set LLM_PROVIDER=anthropic and ANTHROPIC_API_KEY, restart nothing, done. claude-haiku-4-5 and gpt-4o-mini sit in the same price-performance band for this job; run each for a week and keep whichever digest you prefer reading.
send.py and main.py
"""Deliver the digest over SMTP."""
import smtplib
from datetime import date
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
import config
def send(digest_html: str) -> None:
today = date.today().strftime("%A, %d %B %Y")
msg = MIMEMultipart("alternative")
msg["Subject"] = f"News digest - {today}"
msg["From"] = config.MAIL_FROM
msg["To"] = config.MAIL_TO
body = (
"<div style='font-family:Georgia,serif;max-width:600px;"
"margin:0 auto;line-height:1.5'>"
f"<h2>Daily digest</h2><p>{today}</p>{digest_html}</div>"
)
msg.attach(MIMEText("Open in an HTML mail client.", "plain"))
msg.attach(MIMEText(body, "html"))
with smtplib.SMTP(config.SMTP_HOST, config.SMTP_PORT, timeout=30) as s:
s.starttls()
s.login(config.SMTP_USER, config.SMTP_PASS)
s.send_message(msg)"""Glue: fetch -> summarize -> send -> persist state."""
import fetch
import send
import summarize
def main() -> None:
seen = fetch.load_state()
articles = fetch.fetch_new(seen)
if not articles:
print("Nothing new, not sending.")
return
digest = summarize.summarize(articles)
send.send(digest)
for a in articles:
seen.add(a["url"])
fetch.save_state(seen)
print(f"Sent {len(articles)} articles.")
if __name__ == "__main__":
main()The state file is written only after a successful send, so a crash mid-run means the same articles are retried next time rather than lost. That ordering is the single most important line of main.py.
Deploy on a 5 USD VPS with a systemd timer
Any bottom-tier VPS (Hetzner, DigitalOcean, whatever) runs this comfortably - it needs Python and about 30 seconds of CPU a day. systemd timers beat cron here for three concrete reasons: journalctl gives you searchable logs for free, Persistent=true fires a missed run after a reboot, and EnvironmentFile handles secrets cleanly.
[Unit]
Description=Daily news digest
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=digest
WorkingDirectory=/home/digest/digest
EnvironmentFile=/home/digest/digest/.env
ExecStart=/home/digest/digest/.venv/bin/python main.py[Unit]
Description=Run the news digest every morning
[Timer]
OnCalendar=*-*-* 06:30:00
Persistent=true
RandomizedDelaySec=120
[Install]
WantedBy=timers.targetsudo systemctl daemon-reload
sudo systemctl enable --now digest.timer
# Run once immediately to test:
sudo systemctl start digest.service
# Check the logs:
journalctl -u digest.service -n 50The EnvironmentFile format is KEY=value lines without the export keyword. If you reuse a shell-style .env with export statements, systemd will fail to parse it - this is the most common deployment stumble.
No VPS? The same code runs on GitHub Actions on a schedule - set the secrets in the repo, pip install in the workflow, run main.py, and commit state.json back (or cache it) since runners are wiped between jobs. Actions schedules can fire 5-15 minutes late, which does not matter for a newsletter.
What this really costs
| Item | Reality |
|---|---|
| Setup time | 4-8 hours including VPS provisioning, systemd unit debugging and the inevitable SMTP authentication detour. |
| Monthly running cost | VPS 4-6 USD. LLM around 0.05-1.50 USD per month at one daily issue on gpt-4o-mini or claude-haiku-4-5. GitHub Actions path: 0 USD infrastructure. |
| Email deliverability | Fine when mailing yourself via Gmail SMTP. Your own domain needs SPF, DKIM and DMARC configured, and a fresh domain still starts with poor sender reputation. |
| Ongoing maintenance | 1-3 hours per month: dead feeds, OS security updates on the VPS, SDK breaking changes (both OpenAI and Anthropic ship them), occasional prompt re-tuning. |
The modular layout front-loads a little extra work and pays it back the first time anything changes: a provider price hike, a feed you want to preprocess differently, a decision to move from SMTP to an email API. Each of those becomes a one-file change instead of surgery on a monolith.
Be honest with yourself about the maintenance line. The code will not rot fast, but the world around it does: feeds move, models get renamed, TLS requirements tighten. If an hour a month of tinkering sounds like fun, this is a great little system to own. If it sounds like a chore, that feeling is data too.