Recherche unifiée des thèses et mémoires des universités québécoises, avec vraie recherche par discipline — la fonctionnalité qui manque à Érudit.
🔎 Démo en ligne — xjodoin.github.io/theses-quebec
187 463 thèses et mémoires moissonnés depuis 16 dépôts institutionnels (Concordia, McGill, UdeM, Sherbrooke, Bishop's, Laval, Polytechnique, le réseau UQ, INRS, ÉTS, TÉLUQ), classés dans 74 disciplines canoniques (taxonomie alignée sur Érudit), indexés en plein texte avec facettes par université, type, année et discipline.
- Pourquoi ce projet
- Architecture
- Démarrage rapide
- Build statique pour GitHub Pages
- Moissonner les dépôts
- Classificateur LLM (Gemini 3 Flash)
- Structure du projet
- Configuration (.env)
- Déploiement
- Statut & limites
- Contribuer
- Licence
- Remerciements
Érudit indexe les thèses québécoises mais sa recherche par discipline ne fonctionne pas vraiment : taper « éducation » dans le filtre disciplinaire ne retourne pas de manière fiable les thèses en sciences de l'éducation, parce que chaque université renseigne ses métadonnées Dublin Core différemment (« sciences de l'éducation » vs « éducation » vs « didactique »).
Ce projet :
- Moissonne les métadonnées via OAI-PMH (le protocole standard que tous les dépôts EPrints / DSpace / Hyrax exposent).
- Normalise Dublin Core dans un schéma commun.
- Classe chaque thèse dans une discipline canonique (74 catégories, taxonomie alignée sur Érudit) via un classificateur règle-base + un classificateur LLM (Gemini 3 Flash) en batch pour le résidu.
- Indexe en SQLite FTS5 avec facettes.
- Expose une API REST + une interface web sobre (Tailwind, vanilla JS).
Résultat : 28,6 % de thèses non classées par les règles → 0,02 % après le batch LLM.
v0.6 ajoute la suggestion « Did you mean? » sur 0 résultat (Levenshtein sur le vocabulaire des facettes) et un graphique temporel par décennie dans la sidebar.
┌──────────────────────────────────┐
│ 16 dépôts institutionnels QC │
│ (EPrints · DSpace · Hyrax) │
└────────────────┬─────────────────┘
│ OAI-PMH 2.0 (oai_dc · dim · oai_etdms · uketd_dc)
│ + Playwright (McGill, Azure WAF)
▼
┌─────────────────────────┐
│ harvester/ │
│ ├ harvest.py │ multi-format DC
│ ├ parsers.py │ 4 parseurs (oai_dc/dim/etdms/uketd)
│ ├ normalize.py │ schéma unifié
│ ├ classify.py │ règles 3-pass (auth/primary/abstract)
│ └ llm_classify.py │ Gemini 3 Flash batch (résidu)
└────────────┬────────────┘
▼
┌─────────────────────────┐
│ data/theses.db │ SQLite + FTS5
│ (GitHub Releases, │ ~76 MB compressé
│ asset zstd) │ (~666 MB raw)
└────────────┬────────────┘
▼
┌──────────────────┴──────────────────┐
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ Variante FastAPI │ │ Variante statique│
│ api/app.py │ │ scripts/build.mjs│
│ /api/search … │ │ → dist/tqsearch/ │
└────────┬─────────┘ └────────┬─────────┘
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ web/common.js │ partagé → │ web/common.js │
│ + backends/fast… │ │ + backends/tq… │
└──────────────────┘ └──────────────────┘
| Composant | Pile | Pourquoi |
|---|---|---|
| Moissonneur | Python + requests + Playwright |
OAI-PMH partout sauf McGill (WAF Azure → vraie session navigateur) |
| Index serveur | SQLite FTS5 | Aucun service externe ; tokenizer unicode61 remove_diacritics |
| Index statique | tqsearch | BM25F statique par défaut, Pagefind disponible seulement pour les benchmarks |
| Classification | 3-pass règles + Gemini 3 Flash batch | Auth → règles primaires → règles+abstract → LLM pour le résidu |
| API | FastAPI | Auto-doc OpenAPI sur /docs |
| UI | Tailwind CDN, vanilla JS, ES modules | Aucun build, web/common.js partagé entre les deux variantes |
# 1. Cloner
git clone https://github.com/xjodoin/theses-quebec
cd theses-quebec
# 2. Installer
python3 -m venv .venv # Python ≥ 3.10
.venv/bin/pip install -r requirements.txt
npm install # pour npm run db:fetch
# 3. Récupérer la DB pré-moissonnée (zstd, ~75 Mo) depuis la dernière
# release GitHub. L'index FTS5 sera reconstruit automatiquement au
# premier `connect()` (quelques secondes).
npm run db:fetch
# 4. Lancer l'API + le frontend (servi sur la même origine)
.venv/bin/uvicorn api.app:app --host 127.0.0.1 --port 8000
# 5. Ouvrir
open http://127.0.0.1:8000/La DB pré-moissonnée n'est plus versionnée dans le repo — elle est publiée
comme release GitHub (asset zstd compressé, ~75 Mo). On évite ainsi de
saturer le quota Git LFS (1 Go gratuit), et le clone reste léger (~5 Mo).
Pour publier une nouvelle version après un harvest : npm run db:release.
Une seconde version 100 % statique est générée à chaque push. Le backend de production est le prototype tqsearch (index BM25F statique en shards). Pagefind n'est plus inclus dans le build Pages par défaut ; il sert seulement aux builds de benchmark. Recherche locale, sans serveur, hébergement gratuit sur GitHub Pages, scaling à 187 k records.
# Installer Node 20+ et les deps
npm install
# Builder dist/ pour Pages (lit data/theses.db, produit tqsearch seulement)
npm run build
# Builder dist/ avec Pagefind en plus, pour les comparaisons de benchmark
npm run build:bench
# Servir localement (http://localhost:5000)
npm run serveLes benchmarks de recherche sont documentés dans
docs/search-benchmarks.md. Ils couvrent la
latence navigateur et la qualité de ranking contre SQLite FTS5.
Le prototype tqsearch est décrit dans
docs/static-search-design.md.
Le déploiement Pages peut être déclenché directement par
.github/workflows/pages.yml, qui reconstruit
tqsearch depuis main. npm run deploy reste disponible pour publier un
artifact local via une release pages-*.
Après un moissonnage, relancer npm run deploy publie le site statique à jour.
| Caractéristique | Version FastAPI | Version statique (Pages) |
|---|---|---|
| Hébergement | VPS / PaaS | GitHub Pages (gratuit, CDN) |
| Coût mensuel | 0 – 5 $ | 0 $ |
| Recherche | SQLite FTS5 (server) | tqsearch BM25F (browser) |
| Latence requête | 10–50 ms (HTTP + SQL) | ~20–54 ms cold first paint sur les requêtes bench, 0–9 ms warm, puis affinage exact |
| Premier chargement | < 1 s | ~160 KB init search assets, chunks à la demande |
| Maintenance | Service à surveiller | Aucune |
| Re-utilisation des données | API JSON | tqsearch/ ouvert |
# Tout moissonner (~10–60 min selon la santé des serveurs OAI)
.venv/bin/python harvester/harvest.py
# Limiter à N records par source (utile pour tester)
.venv/bin/python harvester/harvest.py --max-per-source 200
# Cibler une ou plusieurs sources
.venv/bin/python harvester/harvest.py --only concordia udem laval
# McGill nécessite un harvester séparé (Azure WAF + Hyrax)
.venv/bin/python harvester/mcgill_harvest.py
.venv/bin/python harvester/mcgill_harvest.py --headed # debug, navigateur visibleSources actuellement configurées dans
harvester/sources.yaml :
| Université | Plateforme | Endpoint |
|---|---|---|
| Concordia (Spectrum) | EPrints | /cgi/oai2 |
| UdeM (Papyrus) | DSpace · Scholaris | /server/oai/request |
| Sherbrooke (Savoirs UdeS) | DSpace · Scholaris | /server/oai/request |
| Bishop's (IRis) | DSpace · Scholaris | /server/oai/request |
| UQAM (Archipel) | EPrints | /cgi/oai2 |
| UQTR (Cognitio) | EPrints | /cgi/oai2 |
| UQAC (Constellation) | EPrints | /cgi/oai2 |
| UQAR (Sémaphore) | EPrints | /cgi/oai2 |
| UQO (Dépôt institutionnel) | EPrints | /cgi/oai2 |
| UQAT (Depositum) | EPrints | /cgi/oai2 |
| INRS (EspaceINRS) | EPrints | /cgi/oai2 |
| ÉTS (Espace ETS) | EPrints | /cgi/oai2 |
| TÉLUQ (R-Libre) | EPrints | /cgi/oai2 |
| Polytechnique Montréal (PolyPublie) | EPrints | /cgi/oai2 (set types=thesis) |
| Laval (Corpus UL) | DSpace | /oai/request |
| McGill (eScholarship) | Hyrax / Blacklight | /catalog/oai (via Playwright) |
Pour ajouter une source, append au YAML — pas de code à toucher tant que c'est EPrints ou DSpace.
Le classificateur règle-base couvre ~71 % des cas. Pour le reste — titres
opaques sans abstract, sans dc:subject — on délègue à Gemini 3 Flash en
mode batch (réponse en quelques minutes à 1 h, ~50 % moins cher que le
synchrone).
# 1. Crée une clé sur https://aistudio.google.com/apikey
cp .env.example .env
$EDITOR .env # colle GEMINI_API_KEY=...
# 2. Test avec 10 records
.venv/bin/python harvester/llm_classify.py run --limit 10
# 3. Plein régime (1 400+ records)
.venv/bin/python harvester/llm_classify.py run
# Le run est resumable : si tu Ctrl-C, le batch continue côté Google.
# Reprends-le avec :
.venv/bin/python harvester/llm_classify.py poll BATCH_NAMECaractéristiques clés :
- Schéma JSON enforcé côté serveur :
responseSchemaavecenumstrict des 74 disciplines canoniques. La sortie est garantie d'être une étiquette valide. thinkingBudget: 0: Gemini 3 utilise des thinking tokens par défaut. Pour de la classification mono-label on n'en veut pas — sinon ils mangent le budget de réponse.- Mêmes étiquettes que le classificateur règle-base — les facettes restent cohérentes.
- Sub-commandes
preview/submit/poll/runpour découpler soumission et application.
theses-quebec/
├── api/
│ └── app.py FastAPI: /api/search /api/facets /api/sources
├── harvester/
│ ├── sources.yaml Config des dépôts à moissonner
│ ├── harvest.py Boucle OAI-PMH générique
│ ├── mcgill_harvest.py Harvester Playwright pour McGill (WAF Azure)
│ ├── normalize.py DC → schéma unifié, filtre thèse/mémoire
│ ├── classify.py Classificateur règle-base (~150 mots-clés)
│ ├── llm_classify.py Classificateur Gemini 3 Flash batch
│ └── db.py Schéma SQLite + FTS5 + triggers
├── web/
│ ├── common.js UI partagée (search loop, facettes, modal, citations)
│ ├── backends/
│ │ ├── fastapi.js Adapter pour /api/search
│ │ ├── tqsearch.js Adapter pour l'index statique BM25F
│ │ └── pagefind.js Adapter de benchmark pour le bundle Pagefind
│ ├── index.html Frontend pour la version FastAPI
│ └── static.html Frontend pour la version statique (Pages)
├── scripts/
│ ├── build.mjs SQLite → tqsearch → dist/
│ ├── build_tqsearch.mjs Builder BM25F statique file-backed
│ ├── serve.mjs Serveur local de prévisualisation
│ ├── fetch_db.mjs gh release download → zstd -d → data/theses.db
│ └── release_db.mjs slim FTS5 + zstd → gh release create db-YYYY-MM-DD
├── tests/ Suite pytest (75+ tests : classify, normalize, parsers, db)
├── data/
│ └── theses.db Base SQLite (récupérée via `npm run db:fetch`,
│ non versionnée — voir GitHub Releases)
├── .env.example Template pour les secrets
├── package.json Build pipeline statique
├── requirements.txt
├── LICENSE MIT
├── NOTICE Provenance des métadonnées moissonnées
└── README.md
Créer .env à la racine (déjà gitignoré) :
# Requis pour harvester/llm_classify.py
GEMINI_API_KEY=AIza...
# Optionnel — par défaut "gemini-flash-latest"
GEMINI_MODEL=gemini-flash-latestVoir .env.example pour le modèle.
Trois voies typiques, par ordre croissant de complexité :
- GitHub Pages (statique) — déjà en place.
npm run deployconstruit, publie une releasepages-*, puis déclenche Pages. Coût : 0 $. Voir Build statique pour GitHub Pages. - Fly.io free tier — Dockerfile +
fly.toml, volume persistant pour le SQLite, machine planifiée pour le harvest. Coût : 0–3 $/mois. Pas encore matérialisé — ouvre une issue si intéressé. - Self-hosted —
systemd+ Caddy (HTTPS auto) sur n'importe quel VPS Linux. Cron pour le harvest. ~5 fichiers de config, stable des années. Pas encore matérialisé.
- Suite pytest (75+ tests autour de
classify,normalize,parsers,db). Smoke-test classifier exécuté en CI à chaque push. - 2 dépôts intermittents (UQAM, TÉLUQ) — leurs serveurs OAI ne répondent pas toujours. Le harvester est résilient (retry + skip).
- Métadonnées datées — la dernière passe est figée dans
data/theses.db. Re-moissonner avant un travail sérieux. - Classification LLM = pas une vérité — elle est rapide, cohérente et
bonne, mais reste une heuristique. Pour la précision absolue, croiser
avec
thesis.degree.disciplinequand le dépôt l'expose. - Pas encore d'authoring de revues / abstracts depuis Érudit — c'est un agrégateur de dépôts institutionnels seulement.
Les contributions sont les bienvenues, surtout :
- Nouvelles sources : ajoute une entrée dans
sources.yaml. Si la source n'expose pas OAI-PMH, suis le patron demcgill_harvest.py. - Mots-clés disciplinaires :
harvester/classify.pyest trivial à étendre. Voir le commentaire en tête du fichier. - UI :
web/common.jsest partagé entre les deux variantes. Modifie-le pour faire évoluer les deux d'un coup. Aucun build. - Tests :
python3 -m pytest tests/ -q(Python 3.11+).
Workflow standard :
git checkout -b ma-feature
# ... commits ...
git push origin ma-feature
gh pr createMIT pour le code source. Voir NOTICE pour la
provenance des métadonnées moissonnées (qui restent la propriété de chaque
université).
- Maude Jodoin, qui a posé la question : « pourquoi je peux pas chercher les thèses en éducation ? ».
- ENAP pour la liste de référence des dépôts institutionnels québécois
(
espace.enap.ca/autre_depot.html). - Open Archives Initiative pour OAI-PMH, le protocole qui rend ce projet possible en moins de 1 000 lignes de Python.
- Les équipes des bibliothèques universitaires qui maintiennent ces dépôts en accès libre.
