Le trigger bands_set_geom (migration 002) faisait `NEW.updated_at := now()`
sans condition. Or upsert_bands exécute un `ON CONFLICT DO UPDATE` sans clause
WHERE : Postgres déclenche donc le trigger pour chaque ligne vue, même quand
aucune valeur ne change. get_bands_to_enrich filtrant sur
`updated_at > crawled_at + 1 min`, la table entière redevenait « à enrichir »
après chaque crawl — le crawler repartait indéfiniment chercher des pages
inchangées sur Metal Archives.
La migration 016 ne bumpe plus updated_at que sur changement réel de la ligne.
La 017 introduit alors le signal manquant : quand MA modifie un groupe sur un
champ visible seulement sur sa page (line-up, albums), le listing ne montre
aucun diff. Le crawler capture donc l'horodatage "modified" affiché par MA
lui-même et lève enrich_pending quand ce texte change — une fois par
modification, sans boucle.
Autres correctifs :
- claim_job_trigger() réclamait n'importe quel trigger en attente. Le crawler
raflait donc les 'geocoder_enqueue', qu'il ne sait pas exécuter, et les
refermait en « job_type inconnu » — selon lequel des deux daemons
interrogeait la table en premier. Borné aux CRAWLER_JOB_TYPES.
- run_enrich sautait la politesse après un échec, via un `continue` placé
avant le sleep. Un échec de get_html étant le plus souvent un 403/429,
c'était le pire moment pour enchaîner sans délai.
- FlareSolverrError n'était rattrapée nulle part : un blip réseau ou un crash
du Chrome headless faisait perdre le run entier.
- La boucle du scheduler n'isolait aucune exception : une panne DB transitoire
tuait le process, que restart: unless-stopped relançait en crash-loop.
- recover_stuck_runs() referme au démarrage les runs laissés en 'running' par
une instance tuée brutalement.
- full_crawl publie sa progression et respire entre deux pays.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Deux angles morts fermés.
1. Syntaxe SQL sans conteneur (apps/api/test/sqlSyntax.test.js)
Le faux pool vérifiait la FORME du SQL mais ne l'exécutait jamais : une requête
syntaxiquement invalide passait tous les tests et n'échouait qu'en production —
c'est précisément ce qui s'était produit avec crawler_pending.
Chaque requête réellement émise par les 28 routes est désormais parsée avec la
grammaire PostgreSQL (node-sql-parser), y compris les SET dynamiques du PATCH
et les casts par type de resolve-conflict. 48 tests, aucun conteneur.
Limite déclarée explicitement : la sémantique n'est pas validée, et deux
requêtes bâties sur jsonb_build_object ne sont pas parsables — le test échoue
si une route cesse d'avoir la moindre requête vérifiable, pour éviter qu'il
passe au vert à vide.
2. Supervision des services de fond (migration 015)
Le crawler et les workers ne sont pas exposés par Traefik : aucune sonde HTTP
ne peut les atteindre. Un crawler dont FlareSolverr était injoignable, ou un
worker à court de quota Geoapify, restait muet — le seul symptôme était
l'absence de données nouvelles, qu'il fallait remarquer soi-même.
Chaque service écrit un battement de cœur horaire dans service_health :
- crawler : base, FlareSolverr joignable, dernier run terminé < 12 h
- geocoder : base, clé Geoapify présente, API joignable, progression < 6 h
Une ligne par service, écrasée à chaque contrôle. L'API calcule `stale` en SQL
(> 2 h sans écriture) : un service arrêté cesse d'écrire, et son dernier
contrôle réussi le ferait sinon passer pour sain indéfiniment.
Le Pilotage affiche une carte « Services » et remonte chaque service dégradé ou
silencieux en alerte actionnable.
Règle appliquée aux sondes : aucune ne peut interrompre le service qu'elle
surveille. Toute exception devient un échec de sonde, l'écriture du résultat et
la journalisation échouent en silence. Un contrôle de santé qui fait tomber le
crawler serait pire que pas de contrôle.
Tests : 580 JS (+51), 63 Python (+20), 85 Playwright (+3)
Co-Authored-By: Claude <noreply@anthropic.com>
Le dépôt n'avait aucun test, aucun linter, aucune vérification de types.
Outillage
- ESLint 9 (flat config) sur api + les deux frontends, Ruff sur le Python
- tsc --checkJs sur l'API (pas de TypeScript, juste la vérification)
- Vitest : 401 tests JS ; pytest : 43 tests Python
- Tests de mutation (Stryker), deux profils : logique pure et API complète
- Hook pre-push `npm run check` (~17 s) — le déploiement Coolify est sur webhook,
c'est donc la seule porte de qualité avant la mise en ligne
- Workflow Forgejo Actions prêt (inerte tant qu'aucun runner n'est enregistré)
Sécurité
- Injection SQL authentifiée dans resolve-conflict : `field` était interpolé
dans le SET sans allowlist
- timingSafeEqual levait sur un jeton multi-octets (500 au lieu de 401)
- setErrorHandler écrasait tous les 4xx en 500
- .env.example : ADMIN_JWT_SECRET et ADMIN_SEED_* n'étaient documentés nulle part
alors que leur absence casse toute connexion admin
Annulation réelle des crawl_run (migration 014)
- L'API posait status='error' sans que le crawler en sache rien : le process
continuait, et son UPDATE final ne matchait plus (run réussi affiché en erreur)
- Protocole coopératif : drapeau cancel_requested lu à chaque lot, le crawler
écrit lui-même status='cancelled'
Cohérence géographique (migration 014)
- Le trigger 013 supprimait les band_locations sans purger le point dénormalisé
- L'édition admin de lat/lon n'atteignait jamais band_locations : la carte
ignorait la correction. Override step_order = -1, dans une transaction
Corrections
- limit/offset NaN → 500 au lieu de 400
- OPTIONS sans `return reply` (Fastify poursuivait le cycle de vie)
- listen() sans catch, cast ::text en dur sur les colonnes numériques
- /admin/api/logs ne renvoyait pas sa pagination
- a11y : sélecteur de langue annoncé comme liste vide (role=option manquant)
Nettoyage
- apps/web/quizz-site supprimé (sans rapport avec le projet)
- Code mort : openModal(), LANG_NAMES, double import, variables inutilisées
- .dockerignore ajoutés ; node_modules racine n'était pas gitignoré
Co-Authored-By: Claude <noreply@anthropic.com>
Étape 0 de la refonte pipeline crawl/géocodage.
Auto-requeue du géocodage
- migration 013: trigger trg_bands_geocode_dirty (AFTER UPDATE OF location_text
ON bands) supprime les band_locations d'un band dès que sa localisation
change réellement (IS DISTINCT FROM) — plus de points fantômes après un
déménagement détecté par le crawler incrémental.
- enqueue.py: en plus du déclenchement manuel, un scan automatique tourne
toutes les ENQUEUE_AUTO_INTERVAL_MIN minutes (défaut 60) et rattrape les
bands rendus "dirty" par le trigger (idempotent, ON CONFLICT DO NOTHING —
pas de canal de notification supplémentaire nécessaire). Chaque exécution
(manuelle ou auto) crée une ligne crawl_run + logs rattachés, en miroir de
crawler/src/db.py, pour apparaître dans l'activité admin à venir.
Crawl complet en calcul glissant
- remplace CRAWLER_SCHED_FULL_DAY (jour fixe du mois) par
CRAWLER_FULL_CRAWL_INTERVAL_DAYS (défaut 60) basé sur le checkpoint
last_full_crawl_at — robuste au calendrier et aux redémarrages (vérifié
aussi au démarrage, pas seulement le tick quotidien 03:00 UTC).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- BAND_MIN_DELAY: 2.5→1.5s, BAND_MAX_DELAY: 5.0→2.5s (avg 2s/band)
- ENRICH_LIMIT: 200→500 (configurable via CRAWLER_ENRICH_LIMIT)
- get_bands_to_enrich: remplace la queue FIFO simple par une file de
priorité à 4 niveaux :
1. Nouveaux bands (band_page absent)
2. Modifiés depuis dernier enrichissement (updated_at > crawled_at + 1min)
3. Héritage ancien scraper (crawled_at IS NULL, band_page présent)
4. Stale (crawled_at < now() - 30 days)
- Suppression de get_bands_stale() (logique absorbée par la queue)
Objectif : ~17 jours pour réenrichir les 103k bands
(6000 bands/jour à raison de 500/run × 12 runs/24h)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- apps/crawler/ : service Python complet, remplace les scripts locaux
- FlareSolverr pour bypasser Cloudflare (cookies CF → session requests)
- Crawl incrémental : /archives/band-list/by/created et /by/modified
- Crawl complet Europe : pagination AJAX /browse/ajax-country/
- Enrichissement : pages individuelles de bands (themes, membres, label, hash)
- Écriture directe en DB (upserts bulk, idempotents)
- Scheduler intégré (schedule library) : incrémental 4h, enrich 2h, full le 1er du mois
- Tracking via crawl_run et crawl_checkpoint (migration 004)
- docker-compose.dev.yml : flaresolverr + crawler ajoutés
Full crawl désactivé en dev (CRAWLER_SCHED_FULL_DAY=0)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>