Documentation

Installation

Prérequis : Docker et Docker Compose, ainsi qu'un fournisseur OIDC (PocketID, Authentik, Keycloak, etc.).

Option A (image pré-construite)

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
# éditer .env
REGISTRY_IMAGE=ghcr.io/notarobot63/excerpta:latest \
  docker compose -f docker-compose.prod.yml up -d

Option B (build local)

git clone https://github.com/notarobot63/excerpta.git
cd excerpta
cp .env.example .env
# éditer .env
docker compose up --build -d

Variables d'environnement

VariableObligatoireDescription
SECRET_KEYOuiClé secrète des sessions, générée avec python3 -c "import secrets; print(secrets.token_hex(32))"
BASE_URLOuiURL publique complète (ex. https://liens.exemple.com)
OIDC_CLIENT_IDOuiClient ID de l'application OIDC
OIDC_CLIENT_SECRETOuiClient secret de l'application OIDC
OIDC_ISSUEROuiURL de l'issuer OIDC (ex. https://auth.exemple.com)
FRESHRSS_SYNC_INTERVALNonIntervalle de synchronisation FreshRSS en minutes (défaut : 30)

Reverse proxy

Excerpta écoute sur le port 8070 (configurable dans le compose). Exemple Caddy :

liens.exemple.com {
    reverse_proxy localhost:8070
}

Exemple Nginx :

server {
    listen 443 ssl;
    server_name liens.exemple.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;
    }
}

Le header X-Forwarded-For est nécessaire pour que la limitation de débit fonctionne correctement par IP client.

Mise à jour

# image pré-construite
REGISTRY_IMAGE=ghcr.io/notarobot63/excerpta:latest \
  docker compose -f docker-compose.prod.yml pull && \
  docker compose -f docker-compose.prod.yml up -d

# build local
git pull && docker compose up --build -d

Données persistantes

Les données (base SQLite) sont stockées dans un volume Docker nommé excerpta_data. Pour sauvegarder :

docker run --rm \
  -v excerpta_data:/data \
  -v $(pwd):/backup \
  alpine tar czf /backup/excerpta-backup.tar.gz /data

Configuration

OIDC / Authentification

Excerpta utilise OIDC avec le flux PKCE. N'importe quel fournisseur compatible est supporté.

PocketID

  1. Créer une application OIDC dans PocketID
  2. URL de callback : https://votre-instance.exemple.com/auth/oidc/callback
  3. Renseigner OIDC_CLIENT_ID, OIDC_CLIENT_SECRET et OIDC_ISSUER dans .env

Authentik

  1. Créer un Provider OAuth2/OIDC (type : Authorization Code + PKCE)
  2. URL de callback : https://votre-instance.exemple.com/auth/oidc/callback
  3. OIDC_ISSUER = https://auth.exemple.com/application/o/<slug>/

Keycloak

  1. Créer un client Keycloak, Access Type public, PKCE activé
  2. OIDC_ISSUER = https://keycloak.exemple.com/realms/<realm>

Thèmes

9 palettes disponibles, chacune en variante claire et sombre. Le réglage se fait depuis la barre latérale et reste mémorisé dans le navigateur.

PaletteClairSombre
Par défautLightDark
NordNordNord Dark
DraculaDracula LightDracula
CatppuccinLatteMocha
GruvboxGruvbox LightGruvbox
SolarizedSolarizedSolarized Dark
Rosé PineDawnMoon

Le sélecteur complet est dans Paramètres → Apparence.

Clé API

La clé de l'API REST est générée automatiquement à la création du compte. Visible et régénérable dans Paramètres → Compte. Elle est stockée sous forme de HMAC, la valeur en clair n'est jamais conservée côté serveur.

Bookmarklet

Disponible dans Paramètres → Bookmarklet. Le glisser dans la barre de favoris du navigateur permet d'enregistrer la page courante en un clic.

Import / export

  • Import : liens au format Netscape HTML (Firefox, Chrome, Safari), les dossiers sont recréés automatiquement
  • Export : Paramètres → Export : génère un fichier Netscape HTML avec dossiers et tags

Raccourcis clavier

ToucheAction
nNouveau lien
/Focus sur la recherche

Les raccourcis sont désactivés quand le focus est dans un champ de saisie.

Détection de doublons

Si vous tentez d'ajouter une URL déjà présente dans votre collection (via le formulaire ou le bookmarklet), Excerpta redirige automatiquement vers la page d'édition du lien existant avec un avertissement.

Administration

Le panneau d'administration (/admin/) n'est accessible qu'aux comptes marqués is_admin = true en base. Il permet de lister et désactiver des utilisateurs, consulter des statistiques globales, et régénérer des clés API.

Recherche

La recherche est en temps réel : les résultats se filtrent pendant la frappe (sans rechargement), avec un léger délai de temporisation et un seuil de déclenchement à 2 caractères. Vider le champ réaffiche tous les liens. La pagination se fait aussi en AJAX et l'URL reste partageable (le bouton retour du navigateur fonctionne).

L'index plein texte (SQLite FTS5) couvre titres, descriptions, notes, URLs et tags, et est insensible aux accents. Sans JavaScript, le formulaire de recherche classique fonctionne toujours.

Les résultats sont classés par pertinence (bm25 pondéré) : un terme trouvé dans le titre pèse plus que dans les tags, la description ou l'URL. Les termes recherchés sont surlignés dans les titres affichés.

Vue lecteur

Chaque lien dispose d'une icône lecteur qui ouvre une version épurée et lisible de l'article :

  • Extraction du contenu principal via Readability (le même algorithme que le mode lecture de Firefox), suivie d'une sanitisation HTML (anti-XSS)
  • Présentation focalisée : colonne de lecture étroite, typographie soignée, images conservées, temps de lecture estimé
  • Taille de police ajustable (mémorisée) et thème clair/sombre hérité de l'application
  • L'extraction se fait à la première ouverture puis est mise en cache ; les ouvertures suivantes sont instantanées. Ajouter ?refresh=1 à l'URL force une nouvelle extraction
  • Les pages non extractibles (paywall, contenu purement JavaScript) affichent un état d'échec avec un lien vers l'original

Archivage (Wayback Machine)

Excerpta archive les liens sur la Wayback Machine d'Internet Archive.

  • Automatique à l'ajout : chaque nouveau lien est archivé en tâche de fond (capturé pendant que la page est encore vivante). La synchronisation FreshRSS n'archive pas automatiquement, pour éviter de saturer les quotas de Wayback
  • Statut visible par lien sur sa carte : en cours, archivé (l'icône pointe vers la capture Wayback), ou échoué (bouton pour réessayer)
  • Archivage en masse : Paramètres → Archiver les liens non archivés lance une tâche de fond throttlée sur tous les liens pas encore archivés

Wayback limite fortement l'archivage anonyme : sur un gros lot, certains liens peuvent échouer (HTTP 429). Relancer ne cible que les liens encore non archivés.

Page publique

Chaque utilisateur dispose d'une page publique listant ses liens publics :

  • URL : /u/{slug}, le slug est personnalisable dans Paramètres → Page publique (voir Paramètres)
  • Flux RSS associé : /u/{slug}/feed.xml
  • Seuls les liens marqués publics y apparaissent ; le titre de la page est personnalisable

API REST v1

Authentification

Toutes les requêtes à l'API nécessitent une clé API dans l'en-tête :

X-API-Key: <votre-clé-api>

La clé est disponible dans Paramètres → Compte. Limite de débit : 60 requêtes / minute par clé.

GET /api/v1/me

Retourne le profil de l'utilisateur authentifié.

curl https://votre-instance.exemple.com/api/v1/me \
  -H "X-API-Key: <clé>"

GET /api/v1/links

Liste les liens, paginés, avec recherche et filtres optionnels.

ParamètreTypeDescription
qstringRecherche plein texte (titre, description, note, URL, tags), insensible aux accents
tagstringFiltrer par tag
group_idintFiltrer par dossier (inclut les sous-dossiers)
pageintPage (défaut : 1)
per_pageintRésultats par page (défaut : 30, max : 100)
curl "https://votre-instance.exemple.com/api/v1/links?q=python&tag=dev&page=1" \
  -H "X-API-Key: <clé>"

POST /api/v1/links

Crée un nouveau lien.

ChampTypeDescription
urlstringURL du lien (obligatoire)
titlestringTitre (défaut : URL)
notestringNote markdown
tagsarrayListe de tags
folder_idintID de dossier (optionnel, ignoré si invalide)
is_publicboolRendre le lien public immédiatement (défaut : false)
curl -X POST https://votre-instance.exemple.com/api/v1/links \
  -H "X-API-Key: <clé>" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://exemple.com", "title": "Exemple", "tags": ["dev", "tool"]}'

PATCH /api/v1/links/{id}

Met à jour un lien existant (par exemple is_public).

curl -X PATCH https://votre-instance.exemple.com/api/v1/links/42 \
  -H "X-API-Key: <clé>" \
  -H "Content-Type: application/json" \
  -d '{"is_public": true}'

DELETE /api/v1/links/{id}

Supprime un lien. Retourne 204 No Content.

GET /api/v1/tags · /api/v1/folders

Liste les tags (avec leur nombre de liens associés) et l'arborescence des dossiers (avec leur nombre de liens).

POST /api/v1/freshrss/sync

Déclenche une synchronisation FreshRSS manuelle. Utile pour un cron externe.

GET /public/feed.xml

Flux RSS public, sans authentification. Retourne les 100 liens publics les plus récents au format RSS 2.0.

Codes d'erreur

CodeSignification
401Clé API manquante ou invalide
404Ressource non trouvée
422Données invalides
429Limite de débit atteinte

Intégration FreshRSS

Excerpta peut synchroniser automatiquement les articles étoilés de FreshRSS via l'API GReader.

Configuration

  1. Dans FreshRSS, activer l'API GReader : Paramètres → Authentification → Accès API
  2. Dans Excerpta : Paramètres → FreshRSS
ChampDescription
URL FreshRSSURL de l'instance (ex. https://rss.exemple.com)
IdentifiantNom d'utilisateur FreshRSS
Mot de passe APIMot de passe API GReader (peut différer du mot de passe principal)
Dossier cibleDossier Excerpta où sont classés les articles importés
IntervalleFréquence de synchronisation en minutes (défaut : 30)

Le jeton GReader est chiffré (Fernet) avant stockage.

Fonctionnement

À chaque synchronisation, Excerpta récupère tous les articles étoilés et importe ceux pas encore présents (dédoublonnés par URL). Les articles importés sont classés dans le dossier cible configuré : c'est ce dossier, pas un tag, qui les identifie comme venant de FreshRSS.

Désétoilage automatique

Supprimer un lien importé depuis FreshRSS propose de désétoiler l'article dans FreshRSS en même temps, via une case à cocher (cochée par défaut). Déplacer un lien hors de son dossier FreshRSS (glisser-déposer ou édition) le désétoile aussi automatiquement.

Application Android

L'application excerpta-android permet d'enregistrer des liens directement depuis le menu de partage d'Android.

Fonctionnalités

  • Partager une URL depuis n'importe quelle application Android
  • Ajouter un titre, une note et des tags avant l'envoi
  • Authentification par clé API (stockée localement)

Installation

Disponible sur notarobot63/excerpta-android, avec des APK signés pré-construits en releases. Pas encore sur le Play Store.

Configuration

  1. Dans Excerpta, aller dans Paramètres → Compte
  2. Scanner le QR code avec l'application Android

Le QR code encode l'URL de l'instance et la clé API. Il est aussi directement disponible via GET /settings/android-qr.png (avec la clé API en en-tête).

Configuration manuelle

Si le scan n'est pas possible, saisir manuellement dans l'application :

  • URL de l'instance : https://votre-instance.exemple.com
  • Clé API : disponible dans Paramètres → Compte

Contribuer

Stack

CoucheTechnologie
BackendPython 3.11+, FastAPI, SQLModel
Base de donnéesSQLite (WAL + FTS5)
TemplatesJinja2
JSAlpine.js (v3, servi localement)
CSSCSS natif, variables de thème
Extraction lecteurreadability-lxml + nh3 (sanitisation HTML)
Testspytest (tests/, voir requirements-dev.txt)

Environnement de développement

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
# éditer .env
uvicorn app.main:app --reload

L'application est disponible sur http://localhost:8000.

Tests

pip install -r requirements-dev.txt
pytest -q

La CI GitLab exécute pytest à l'étape test, qui bloque le build et le déploiement en cas d'échec.

Sécurité

Avant de soumettre une contribution :

  • Aucun secret en clair dans le code ou les templates
  • Les URLs externes passent par _safe_url() (liste noire SSRF)
  • Les formulaires POST incluent le token CSRF ({{ csrf_input(request) }})
  • Les requêtes JSON utilisent l'en-tête X-CSRF-Token
  • Toute nouvelle route authentifiée utilise Depends(get_current_user)

Signaler un problème

Ouvrir une issue sur le dépôt avec une description précise du comportement observé et les étapes pour le reproduire.