Work in progress. This README covers features and design intent. For setup, see docs/deployment.md.
Lectio is a self-hosted feed reader focused on fast reading triage, rich content handling, and automation. It runs well on a personal VPS with full multi-user support, and is built to keep feed reading fast, keyboard-friendly, and workflow-oriented.
A self-hosted RSS reader with a triage-first interface that adapts from a three-pane desktop layout to narrower tablet and phone workflows. Built on Python + FastAPI + the reader library, with a plain-HTML/JS frontend — no build step, no bundler, no framework.
The design priority is speed of triage: quickly marking things read, surfacing what matters, and staying out of the way.
| Dark mode | Light mode |
|---|---|
![]() |
![]() |
| Read Mode (e-ink) | Saved articles |
|---|---|
![]() |
![]() |
More shots — the phone layout, Save Article, settings, automation, feed properties, tags, history and admin — are on the Screenshots wiki page. All of them are generated from synthetic demo data, never a real library.
The full tour — every feature, organized by area — is on the Features wiki page. The short version:
- Triage first. Three-pane reader, keyboard nav everywhere, context menus, bulk mark-as-read/unread that updates in place, per-view remembered sort, and a layout that collapses to two panes on a tablet and one on a phone. Checkbox multi-select on the post list drives bulk tag/star/playlist/move actions — see the Features wiki page for the detail.
- Picks up where you left off. Close the tab, press Back once too often, or swipe the app away — reopening Lectio returns you to the same article, scrolled to the same place. It also installs to your home screen as a standalone app.
- Built for a phone, not just shrunk onto one. One pane at a time, with Back walking the view stack — article → feed → folder — and then toggling the folder drawer instead of closing the tab out from under you.
- Tagging a post keeps it forever. A tag triggers a full offline capture — page, readability text, every image, and any files the post links to — so a kept article survives the site going down. Stars are the to-do pile; tags are the keep pile.
- Read-it-later built in. Save any page (menu, bookmarklet,
/api/save, or the browser extension) with no feed needed, then read it in Read Mode, an e-ink-friendly reading app at/readthat works offline — including Archive, Delete and mark-read, which queue and drain when you reconnect. - Content that actually renders. Feed sanitization is Lectio's own, so embeds survive: sandboxed players from a curated host allowlist, inline SVG and MathML, recovered YouTube embeds, podcast audio (even when it lives in a separate host feed), inline LaTeX math, and images kept at the author's layout and a sane size.
- Filter this view. Narrow the open list by text (
zelda "tears of" !sponsored), video duration, or tag chips:tag:games,tag:!games,tag:gam*;tagged:matches tags you applied. Press Enter to pull in matches from further down the view as you scroll; it pairs with Select All for quick bulk triage. - Automation. Highlight, mark-as-read, tag-filter, deduplicate, and send-to-destination rules (Instapaper, Pinterest, Reddit, Quire, email, YouTube playlists, webhooks) at any scope, with dry-run and run history. An optional auto-read age keeps a quiet site's first update from flooding the unread list. See Features details.
- Feed management that expects the real web. Resilient auto-discovery, Page Feeds for feedless sites, dev.to and DeviantArt adapters (adding an artist Watches them; on a Watch-feed byline, the name filters to that artist and the ✕ Unwatches), conditional GET, per-feed and per-domain backoff, GUID-churn suppression, feed compare, duplicate scanning, unsubscribe that keeps your curation, and an optional outbound proxy with FlareSolverr/last-resort-backend escalation for feeds behind an anti-bot challenge. A Utilities tab groups the clean-up tools, including one that removes duplicate and unwatchable videos from your YouTube playlists.
- Fix a post in place. Edit its date, title or URL; clean up its body with an Aardvark-style element remover; re-fetch its content — undoably, with an Internet Archive fallback — one post, a multi-selection, or a whole feed or folder at a time. Teaser-only feeds can fetch the full article automatically as new posts arrive, and feeds whose RSS lacks real tags can pick up the publisher's own article topics as tag suggestions — each switched on per folder or per feed.
- Yours to run. Isolated per-user databases with shared content caches, Google Reader / Fever / Miniflux v1 APIs, WebSub push, Takeout-style export/import, and no build step: plain HTML and JS, no bundler, no framework.
| Layer | What it does |
|---|---|
main.py |
FastAPI routes, Jinja2 templates, all request handling |
services/ |
Feed refresh, lead images, email, starred archive, YouTube, reader API wrapper |
reader library |
Feed fetching, parsing, storage, ETag/conditional requests |
| Per-user SQLite DBs | Feed/entry store, app state (prefs, rules, read history), starred archive; layout in tenancy |
Dates, saved-article recovery, per-feed file capture, suggested tags, comic episode expansion, transparent-image handling, the refresh watchdog and the page-weight work are all described on the Features wiki page — they are behaviour, not architecture, and this file is meant to stay short. Design rationale for any of it lives in ARCHITECTURE.md.
- Backend: Python 3.15, FastAPI, uvicorn
- Feed library: reader (handles HTTP, parsing, ETags, scheduling)
- Frontend: Vanilla JS, Jinja2 templates, no build step
- Database: SQLite (WAL mode), per user
- Deployment: Docker + docker-compose, Traefik reverse proxy
Enable the lint hook once per clone:
git config core.hooksPath .githooksIt runs scripts/lint_changed.py over the lines you staged (ruff, ~40ms) so a
lint problem is fixed in the commit rather than after a CI round-trip. CI runs
the same script across the whole PR. Both are scoped to changed lines rather
than whole files, because the repo carries a backlog of existing findings — new
code is held to the rules while the backlog shrinks file by file. git commit --no-verify bypasses it.
- Tests —
make test(oruv run pytest). - Rebuilding the container —
make rebuild; the image bakes the source in, so a rebuild is what makes a commit live.
CI, dependency audit, CodeQL, scratch cleanup and backups: docs/development.md.
Active personal use. Not yet documented for general deployment. The codebase moves fast — APIs, DB schema, and config format may change without notice.
Issues and PRs welcome, but this is primarily a personal project.



