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

VariableRequiredDescription
SECRET_KEYYesSession secret key, generated with python3 -c "import secrets; print(secrets.token_hex(32))"
BASE_URLYesFull public URL (e.g. https://links.example.com)
OIDC_CLIENT_IDYesOIDC application client ID
OIDC_CLIENT_SECRETYesOIDC application client secret
OIDC_ISSUERYesOIDC issuer URL (e.g. https://auth.example.com)
FRESHRSS_SYNC_INTERVALNoFreshRSS 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-For header 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

  1. Create an OIDC application in PocketID
  2. Callback URL: https://your-instance.example.com/auth/oidc/callback
  3. Set OIDC_CLIENT_ID, OIDC_CLIENT_SECRET and OIDC_ISSUER in .env

Authentik

  1. Create an OAuth2/OIDC Provider (type: Authorization Code + PKCE)
  2. Callback URL: https://your-instance.example.com/auth/oidc/callback
  3. OIDC_ISSUER = https://auth.example.com/application/o/<slug>/

Keycloak

  1. Create a Keycloak client, Access Type public, PKCE enabled
  2. 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.

PaletteLightDark
DefaultLightDark
NordNordNord Dark
DraculaDracula LightDracula
CatppuccinLatteMocha
GruvboxGruvbox LightGruvbox
SolarizedSolarizedSolarized Dark
Rosé PineDawnMoon

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

KeyAction
nNew 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.

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=1 to 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 unarchived runs 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}, the slug is 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.

ParameterTypeDescription
qstringFull-text search (title, description, note, URL, tags), accent-insensitive
tagstringFilter by tag
group_idintFilter by folder (includes subfolders)
pageintPage (default: 1)
per_pageintResults 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.

FieldTypeDescription
urlstringLink URL (required)
titlestringTitle (default: URL)
notestringMarkdown note
tagsarrayList of tags
folder_idintFolder ID (optional, ignored if invalid)
is_publicboolMake 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

CodeMeaning
401Missing or invalid API key
404Resource not found
422Invalid data
429Rate limit reached

FreshRSS integration

Excerpta can automatically sync starred articles from FreshRSS via the GReader API.

Configuration

  1. In FreshRSS, enable the GReader API: Settings → Authentication → API access
  2. In Excerpta: Settings → FreshRSS
FieldDescription
FreshRSS URLInstance URL (e.g. https://rss.example.com)
UsernameFreshRSS login username
API passwordGReader API password (can differ from the main password)
Target folderExcerpta folder where imported articles are filed
IntervalSync 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

  1. In Excerpta, go to Settings → Account
  2. 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

LayerTechnology
BackendPython 3.11+, FastAPI, SQLModel
DatabaseSQLite (WAL + FTS5)
TemplatesJinja2
JSAlpine.js (v3, served locally)
CSSVanilla CSS, theme variables
Reader extractionreadability-lxml + nh3 (HTML sanitization)
Testspytest (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-Token header
  • 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.