Documentation
Installation
Requirements: Docker and Docker Compose, and an OIDC provider (PocketID, Authentik, Keycloak, etc.).
Option A (pre-built image)
curl -O https://raw.githubusercontent.com/notarobot63/excerpta/main/docker-compose.prod.yml
curl -O https://raw.githubusercontent.com/notarobot63/excerpta/main/.env.example
cp .env.example .env
# edit .env
REGISTRY_IMAGE=ghcr.io/notarobot63/excerpta:latest \
docker compose -f docker-compose.prod.yml up -d
Option B (build locally)
git clone https://github.com/notarobot63/excerpta.git
cd excerpta
cp .env.example .env
# edit .env
docker compose up --build -d
Environment variables
| Variable | Required | Description |
|---|---|---|
SECRET_KEY | Yes | Session secret key, generated with python3 -c "import secrets; print(secrets.token_hex(32))" |
BASE_URL | Yes | Full public URL (e.g. https://links.example.com) |
OIDC_CLIENT_ID | Yes | OIDC application client ID |
OIDC_CLIENT_SECRET | Yes | OIDC application client secret |
OIDC_ISSUER | Yes | OIDC issuer URL (e.g. https://auth.example.com) |
FRESHRSS_SYNC_INTERVAL | No | FreshRSS sync interval in minutes (default: 30) |
Reverse proxy
Excerpta listens on port 8070 (configurable in the compose file). Caddy example:
links.example.com {
reverse_proxy localhost:8070
}
Nginx example:
server {
listen 443 ssl;
server_name links.example.com;
location / {
proxy_pass http://localhost:8070;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
The
X-Forwarded-Forheader is required for rate limiting to work correctly per client IP.
Updating
# pre-built image
REGISTRY_IMAGE=ghcr.io/notarobot63/excerpta:latest \
docker compose -f docker-compose.prod.yml pull && \
docker compose -f docker-compose.prod.yml up -d
# local build
git pull && docker compose up --build -d
Persistent data
Data (SQLite database) is stored in a named Docker volume, excerpta_data. To back it up:
docker run --rm \
-v excerpta_data:/data \
-v $(pwd):/backup \
alpine tar czf /backup/excerpta-backup.tar.gz /data
Configuration
OIDC / Authentication
Excerpta uses OIDC with the PKCE flow. Any compatible provider is supported.
PocketID
- Create an OIDC application in PocketID
- Callback URL:
https://your-instance.example.com/auth/oidc/callback - Set
OIDC_CLIENT_ID,OIDC_CLIENT_SECRETandOIDC_ISSUERin.env
Authentik
- Create an OAuth2/OIDC Provider (type: Authorization Code + PKCE)
- Callback URL:
https://your-instance.example.com/auth/oidc/callback OIDC_ISSUER=https://auth.example.com/application/o/<slug>/
Keycloak
- Create a Keycloak client, Access Type
public, PKCE enabled OIDC_ISSUER=https://keycloak.example.com/realms/<realm>
Themes
9 palettes available, each with a light and dark variant. The toggle lives in the sidebar and the choice is remembered in the browser.
| Palette | Light | Dark |
|---|---|---|
| Default | Light | Dark |
| Nord | Nord | Nord Dark |
| Dracula | Dracula Light | Dracula |
| Catppuccin | Latte | Mocha |
| Gruvbox | Gruvbox Light | Gruvbox |
| Solarized | Solarized | Solarized Dark |
| Rosé Pine | Dawn | Moon |
The full selector is in Settings → Appearance.
API key
The REST API key is generated automatically when the account is created. Visible and regenerable in Settings → Account. It is stored as an HMAC, the plaintext value is never kept server-side.
Bookmarklet
Available in Settings → Bookmarklet. Dragging it to the browser's bookmarks bar lets you save the current page in one click.
Import / export
- Import: bookmarks in Netscape HTML format (Firefox, Chrome, Safari), folders are recreated automatically
- Export:
Settings → Export, generates a Netscape HTML file with folders and tags
Keyboard shortcuts
| Key | Action |
|---|---|
n | New link |
/ | Focus search |
Shortcuts are disabled while focus is in an input field.
Duplicate detection
If you try to add a URL that's already in your collection (via the form or the bookmarklet), Excerpta automatically redirects to the existing link's edit page with a warning.
Administration
The admin panel (/admin/) is only accessible to accounts flagged is_admin = true in the database. It lets you list and disable users, view global statistics, and regenerate API keys.
Search
Search is real-time: results are filtered as you type (no reload), with a short debounce delay and a 2-character trigger threshold. Clearing the field shows all links again. Pagination also happens via AJAX and the URL stays shareable (the browser's back button works).
The full-text index (SQLite FTS5) covers titles, descriptions, notes, URLs and tags, and is accent-insensitive. Without JavaScript, the classic search form still works.
Results are ranked by relevance (weighted bm25): a term found in the title carries more weight than in tags, description, or URL. Searched terms are highlighted in displayed titles.
Reader view
Every link has a reader icon that opens a clean, readable version of the article:
- Main content extraction via Readability (the same algorithm as Firefox's reading mode), followed by HTML sanitization (anti-XSS)
- Focused presentation: narrow reading column, refined typography, images preserved, estimated reading time
- Adjustable font size (remembered) and light/dark theme inherited from the app
- Extraction happens on first open and is then cached; subsequent opens are instant. Adding
?refresh=1to the URL forces a new extraction - Pages that can't be extracted (paywall, purely JavaScript content) show a failure state with a link to the original
Archiving (Wayback Machine)
Excerpta archives links on the Internet Archive's Wayback Machine.
- Automatic on add: every new link is archived in the background (captured while the page is still alive). FreshRSS sync does not archive automatically, to avoid saturating Wayback's quotas
- Visible status per link on its card: in progress, archived (the icon points to the Wayback capture), or failed (button to retry)
- Bulk archiving:
Settings → Archive unarchivedruns a throttled background job over all links not yet archived
Wayback strongly limits anonymous archiving: on a large batch, some links may fail (HTTP 429). Retrying only targets the links that are still not archived.
Public page
Every user has a public page listing their public links:
- URL:
/u/{slug}, theslugis customizable in Settings → Public page - Associated RSS feed:
/u/{slug}/feed.xml - Only links marked public appear there; the page title is customizable
REST API v1
Authentication
All API requests require an API key in the header:
X-API-Key: <your-api-key>
The key is available in Settings → Account. Rate limit: 60 requests / minute per key.
GET /api/v1/me
Returns the authenticated user's profile.
curl https://your-instance.example.com/api/v1/me \
-H "X-API-Key: <key>"
GET /api/v1/links
Lists links, paginated, with optional search and filters.
| Parameter | Type | Description |
|---|---|---|
q | string | Full-text search (title, description, note, URL, tags), accent-insensitive |
tag | string | Filter by tag |
group_id | int | Filter by folder (includes subfolders) |
page | int | Page (default: 1) |
per_page | int | Results per page (default: 30, max: 100) |
curl "https://your-instance.example.com/api/v1/links?q=python&tag=dev&page=1" \
-H "X-API-Key: <key>"
POST /api/v1/links
Creates a new link.
| Field | Type | Description |
|---|---|---|
url | string | Link URL (required) |
title | string | Title (default: URL) |
note | string | Markdown note |
tags | array | List of tags |
folder_id | int | Folder ID (optional, ignored if invalid) |
is_public | bool | Make the link public right away (default: false) |
curl -X POST https://your-instance.example.com/api/v1/links \
-H "X-API-Key: <key>" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com", "title": "Example", "tags": ["dev", "tool"]}'
PATCH /api/v1/links/{id}
Updates an existing link (e.g. is_public).
curl -X PATCH https://your-instance.example.com/api/v1/links/42 \
-H "X-API-Key: <key>" \
-H "Content-Type: application/json" \
-d '{"is_public": true}'
DELETE /api/v1/links/{id}
Deletes a link. Returns 204 No Content.
GET /api/v1/tags · /api/v1/folders
Lists tags (with their associated link count) and the folder tree (with link counts).
POST /api/v1/freshrss/sync
Triggers a manual FreshRSS sync. Useful for an external cron.
GET /public/feed.xml
Public RSS feed, no authentication required. Returns the 100 most recent public links in RSS 2.0 format.
Error codes
| Code | Meaning |
|---|---|
| 401 | Missing or invalid API key |
| 404 | Resource not found |
| 422 | Invalid data |
| 429 | Rate limit reached |
FreshRSS integration
Excerpta can automatically sync starred articles from FreshRSS via the GReader API.
Configuration
- In FreshRSS, enable the GReader API: Settings → Authentication → API access
- In Excerpta: Settings → FreshRSS
| Field | Description |
|---|---|
| FreshRSS URL | Instance URL (e.g. https://rss.example.com) |
| Username | FreshRSS login username |
| API password | GReader API password (can differ from the main password) |
| Target folder | Excerpta folder where imported articles are filed |
| Interval | Sync frequency in minutes (default: 30) |
The GReader token is encrypted (Fernet) before storage.
How it works
On every sync, Excerpta fetches all starred articles and imports the ones not already present (deduplicated by URL). Imported articles are filed into the configured target folder, it's this folder, not a tag, that identifies them as coming from FreshRSS.
Automatic unstarring
Deleting a link imported from FreshRSS offers to unstar the article in FreshRSS at the same time via a checkbox (checked by default). Moving a link out of its FreshRSS folder (drag & drop or editing) also automatically unstars it.
Android app
The excerpta-android app lets you save links directly from the Android share menu.
Features
- Share a URL from any Android app
- Add a title, note and tags before sending
- API key authentication (stored locally)
Installation
Available at notarobot63/excerpta-android, with pre-built signed APK releases. Not on the Play Store yet.
Configuration
- In Excerpta, go to Settings → Account
- Scan the QR code with the Android app
The QR code encodes the instance URL and the API key. Also directly available via GET /settings/android-qr.png (with the API key as a header).
Manual configuration
If scanning isn't possible, enter manually in the app:
- Instance URL:
https://your-instance.example.com - API key: available in Settings → Account
Contributing
Stack
| Layer | Technology |
|---|---|
| Backend | Python 3.11+, FastAPI, SQLModel |
| Database | SQLite (WAL + FTS5) |
| Templates | Jinja2 |
| JS | Alpine.js (v3, served locally) |
| CSS | Vanilla CSS, theme variables |
| Reader extraction | readability-lxml + nh3 (HTML sanitization) |
| Tests | pytest (tests/, see requirements-dev.txt) |
Development environment
git clone https://github.com/notarobot63/excerpta.git
cd excerpta
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env
# edit .env
uvicorn app.main:app --reload
The app is available at http://localhost:8000.
Tests
pip install -r requirements-dev.txt
pytest -q
The GitLab CI runs pytest at the test stage, which blocks the build and deployment on failure.
Security
Before submitting a contribution:
- No secrets in plaintext in code or templates
- External URLs go through
_safe_url()(SSRF blacklist) - POST forms include the CSRF token (
{{ csrf_input(request) }}) - JSON requests use the
X-CSRF-Tokenheader - Any new authenticated route uses
Depends(get_current_user)
Reporting an issue
Open an issue on the repository with a precise description of the observed behavior and steps to reproduce it.