A personal newspaper that polls news in the background, curates and translates the front-page stories to Norwegian with an LLM, and serves a finished broadsheet on the web — so the data is always ready and nothing is processed on the fly while you read.
Inspired by openpaper, but flipped around: openpaper runs the whole pipeline live in Claude Code every time. Here the pipeline is an always-on background service, and reading is instant.
BACKGROUND (scheduler, every POLL_MINUTES) WEB (instant)
ingest → content → curate → content* → translate → build → front page / article / more
(RSS/ (full text (LLM (ensures (LLM → (stores
API/ for new against full text Norwegian, edition
PW) stories) profile) on front) incl. body) in DB)
- ingest — fetches from the sources in
sources.yaml. Three fetcher types like openpaper: RSS (feedparser), API (httpx), Playwright (Chromium for feedless JS pages). Deduplicates on URL hash. - content — fetches the full body text for new stories: static extraction (httpx + trafilatura) first, Playwright fallback for JS-heavy pages. Capped at the
CONTENT_FETCH_LIMITnewest per run; front-page stories are guaranteed full text after curation. - curate — the LLM ranks fresh stories against your editorial profile (
PREFERENCES) and picks the front page. Without an LLM key: the newest stories. - translate — translates only the curated stories to Norwegian Bokmål, including the entire body text. Cached per article (
translated_at), so never any duplicate work. - build — freezes an
Edition. The front page always shows the latest edition.
The web routes read finished data:
GET /— the front page (latest edition)GET /article/{id}— single articleGET /more— more stories (pagination over the corpus, instant)POST /refresh— the "fetch new now" button: triggers the pipeline in the background
cd avisa
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env # add OPENROUTER_API_KEY if you want curation + translation
uvicorn app.main:app --reloadOpen http://localhost:8000. The first time, an edition is built in the background — reload the page after a few seconds.
Feel free to use the control script instead of remembering commands:
./avisa setup # interactive wizard: title, profile, sources
./avisa start # start in the background
./avisa stop # stop
./avisa logs # follow the logEverything works without an LLM: the app falls back to the newest stories and skips translation (keeping the original text), so you see the whole flow right away.
On localhost you don't need OpenRouter. With LLM_PROVIDER=auto (default), the app
uses your logged-in claude CLI session for curation and translation if no
OpenRouter key is set. That gives you intelligent curation + Norwegian translation
for free via your Claude Code subscription. On a server (without claude),
OpenRouter is used.
cp .env.example .env # fill in the key + optionally models/profile
docker compose up -d --buildThe SQLite database lives in a named volume (avisa-data), so the data survives
restarts and redeploys. For HTTPS, put a reverse proxy (Caddy/nginx) in front, or
expose it with a Cloudflare Tunnel (no open ports) — see below.
Any always-on box you already have — a NAS, a mini-PC, a Raspberry Pi, an old
laptop — can host this for free via a Cloudflare Tunnel: no open ports, no
reverse proxy, home IP never exposed. The stack is two containers (avisa +
cloudflared); see docker-compose.truenas.yml. deploy/TRUENAS.md is a worked
example for TrueNAS SCALE (deployed through Dockge), but the same compose runs
on any Docker host.
Every push to main builds and pushes a fresh image to GHCR
(.github/workflows/build.yml). Since the box is behind NAT, CI can't push to it —
the stack bundles Watchtower, which polls GHCR
and recreates avisa when :latest changes, so a git push reaches the box on its
own. Drop the watchtower service and pull manually (Dockge, or docker compose pull && up -d) if you'd rather approve each update.
Note: PREFERENCES, PAPER_TITLE, FRONT_PAGE_SIZE and POLL_MINUTES are only
initial values. If you change them in the settings (web/wizard), they are saved
to the DB and override the env.
| Variable | Description |
|---|---|
ADMIN_PASSWORD |
Protects the admin surfaces. Empty = no login (everything open). |
SESSION_SECRET |
Cookie signing (optional; falls back to ADMIN_PASSWORD). |
LLM_PROVIDER |
auto (recommended) / openrouter / claude_cli / none. |
OPENROUTER_API_KEY |
OpenRouter key. Empty + auto = use the local Claude session. |
CURATE_MODEL / TRANSLATE_MODEL |
OpenRouter model ID, e.g. anthropic/claude-haiku-4.5. |
CLAUDE_MODEL |
Model for the local claude CLI (empty = CLI default). Set to haiku for speed. |
TRANSLATE_CONCURRENCY |
Number of translation batch calls at once (default 4). |
TRANSLATE_BATCH_MAX / TRANSLATE_BATCH_CHARS |
How many articles / characters are packed per call. |
POLL_MINUTES |
Initial value: how often polling runs. |
FRONT_PAGE_SIZE |
Initial value: number of stories in the paper. |
CONTENT_FETCH_LIMIT |
Max new stories that get full text fetched per run. |
USE_PLAYWRIGHT |
Browser fallback for JS-heavy pages (true/false). |
PREFERENCES |
Initial value: editorial profile that drives curation. |
PAPER_TITLE |
Initial value: title of the paper. |
Four ways, all of which save to the DB (overriding the env):
- Talk to the configurator (requires an LLM) — the free-text field at the top of
/settings. Write "add aftenposten.no and e24.no, remove Hacker News, more about climate, name the paper Kveldsposten". The LLM interprets it into concrete actions (add/remove/ disable sources, adjust profile, title, size, poll interval) and carries them out, and rebuilds the paper if anything relevant changed. - Wizard —
./avisa setupasks about title, profile and sources. - Settings page —
⚙ Settingson the front page (/settings): edit the profile, title, front-page size and poll interval, and add / disable / delete sources. A changed interval takes effect immediately. - Feedback — the "To the editor" field at the bottom of the front page. Write e.g. "more climate, fewer opinion pieces"; the LLM adjusts the profile and the paper is rebuilt. Without an LLM, the feedback is added as a note in the profile.
You don't need to know the feed URL, type or selector. Paste a name or a URL ("nrk.no", "aftenposten.no") into the settings or the wizard, and then:
- the app finds the RSS feed automatically (reads
<link>tags + common paths), - lets the LLM pick the best feed, name and section the source — and even guess a known feed URL when the site doesn't declare one (e.g. BBC), and
- for feedless JS pages: renders the page with Playwright, lets the LLM suggest a
CSS selector for the article links, and validates it by running it before
the source is saved (
kind: playwright).
Without an LLM, the deterministic RSS discovery is used on its own.
When the paper is being built, the front page shows what's happening in real time —
which step (fetching, full text, curating, translating, building), a count (e.g.
"Translating 5/12") and elapsed time. The first build shows a dedicated panel that
updates itself and loads the paper when it's ready. The progress is exposed at
GET /status (JSON).
The front page has a section menu (sticky) that jumps to Domestic/Foreign/ Technology etc., images on top, secondary and section stories where the source has them, and the article pages show source, date, reading time and previous/next navigation within the edition — so you can read your way through the paper like a newspaper.
Reading (the front page, articles, "more stories") is always open. The admin surfaces
(/settings, sources, feedback, the refresh button) require a login when
ADMIN_PASSWORD is set — then an attempt without a valid cookie is bounced to /login.
If ADMIN_PASSWORD is empty, everything is open (fine locally / behind a VPN). The
cookie is a signed HMAC token (stdlib, no extra dependencies, no server state).
Avisa is intentionally single-tenant: one paper, one editorial profile, one set of
sources, one shared admin password. There is no user/account model — Source, Article,
Edition and Setting are global, and the background pipeline builds one edition for
the whole instance. This matches the purpose: a personal morning paper.
Multi-user is deliberately not supported. True multi-tenancy would mean accounts +
password hashing, a user_id on every table with per-user query scoping, and per-user
curation/translation/build — which also multiplies the (paid) LLM cost roughly linearly
per user. That's a different product than a personal newspaper.
If you want to share it with a few people, run one instance per person instead of adding multi-tenancy — it's far simpler and fully isolated:
# Per person: own database + own settings, passed as env vars
# (pydantic-settings reads real env vars in addition to .env). A distinct
# DATABASE_URL gives a fully separate paper, sources and preferences.
OPENROUTER_API_KEY=sk-or-alice ADMIN_PASSWORD=alice-secret \
DATABASE_URL=sqlite:///./alice.db \
uvicorn app.main:app --port 8001The cleanest isolation is one directory (or container) per person, each with its own
.env and avisa.db. With Docker, give each person their own compose project (-p alice)
and data volume. Each instance keeps its own paper, preferences and login.
Edit sources.yaml and delete avisa.db (or the volume) to re-seed. Types:
rss— any RSS/Atom feeds.api— JSON API (currently Hacker News / Algolia).playwright— feedless JS pages. Requiresconfig.link_selector(CSS selector for the article links). See the example at the bottom ofsources.yaml.
Whatever the type, the full body text is fetched per story in the content phase.
- Translation does not re-run automatically if you add a key
afterwards: reset
translated_aton the relevant rows to re-translate. - Full-text cap: only the
CONTENT_FETCH_LIMITnewest new stories get full text fetched per run (the front-page stories are guaranteed regardless). The rest are picked up in subsequent runs. - Auth is a single shared password cookie with no expiry/rotation — enough for one user behind a proxy/VPN, not multi-user management.
/morepaginates the whole corpus in memory; perfectly fine at this level, but should become SQL pagination as the database grows.- Locally requires Python 3.13 (not 3.14 — SQLModel incompatibility). Docker uses Playwright's official base image and is unaffected.
See app/ for the code — each pipeline step is one small file under app/pipeline/.