Dix-huit migrations, zéro CHECK. crawl_run.status, job_triggers.job_type et
band_locations.geocode_status étaient du texte libre dont les valeurs admises ne
vivaient que dans des commentaires. C'est le dénominateur commun de plusieurs
bugs de ce dépôt : un statut 'error' que plus personne n'écrivait mais qu'un
bouton d'admin ciblait encore, des lignes 'processing' qu'aucune requête ne
reprenait, un job_type réclamé par le mauvais daemon. Une faute de frappe
écrivait une ligne durablement invisible, sans jamais rien faire échouer.
Vérifié contre PostgreSQL : 'runnning' et le job_type 'geocode' sont désormais
rejetés, les valeurs légitimes passent. Les contraintes restent NOT VALID de
façon assumée — cette migration tourne au démarrage de chaque conteneur API, et
une seule ligne héritée hors domaine empêcherait l'API de démarrer. Le but est
d'arrêter la dérive, pas de réécrire le passé.
Retire geocode_queue, vestige du pipeline Nominatim que plus aucune ligne de
code ne référence.
Ajoute enfin une purge de rétention sur crawl_log, admin_audit_log et
admin_login_attempts, qui grossissaient sans fin — alors que la migration 015
avait explicitement conçu service_health pour éviter « une table qui grossit ».
Elle est appelée par migrate.js au démarrage, seul point d'accroche périodique
disponible, et volontairement tolérante : ce nettoyage ne doit jamais empêcher
l'API de démarrer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
isLockedOut comptait `username = $2 OR ip = $3` dans un seul total. Cinq échecs
avec le nom d'un admin, depuis n'importe quelle adresse, verrouillaient donc ce
compte un quart d'heure — à répéter indéfiniment, sans coût et sans connaître
le mot de passe. Il suffisait de connaître le nom d'utilisateur pour mettre
l'admin dehors en permanence.
Les deux compteurs deviennent distincts et volontairement asymétriques. L'IP
reste stricte à 5 : c'est elle qui freine la force brute, et s'auto-verrouiller
n'a aucun intérêt pour un attaquant. Le compteur par username subsiste contre
une attaque répartie, mais à 20 — il faut brûler 4 IP avant de commencer à
gêner le titulaire du compte. Vérifié contre PostgreSQL : l'admin légitime a
bien par_ip=0 quand un tiers martèle depuis ailleurs.
Ajoute par ailleurs `Vary: Origin` aux réponses CORS. L'en-tête Allow-Origin
dépend de l'origine demandée : sans Vary, un cache intermédiaire peut servir à
une origine la réponse mise en cache pour une autre — ce qui revient à
autoriser une origine qui ne l'est pas, ou à faire refuser une origine
légitime. Posé aussi quand l'origine est refusée, pour la même raison.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Corriger à la main les coordonnées d'un groupe écrit un band_locations avec
geocode_provider='admin' et confiance 1.0 : la donnée la plus autoritaire du
système, saisie par un humain, impossible à régénérer. Elle était pourtant la
moins protégée.
Le trigger de la migration 013 supprime toutes les band_locations d'un groupe
dès que son location_text change — override compris. locked_fields ne couvre
que les colonnes de `bands`, jamais les points : une correction disparaissait
donc silencieusement à la première modification du lieu, y compris quand ce
lieu venait d'être verrouillé par l'admin lui-même.
Trois routes de reset l'effaçaient également. Le code raisonnait explicitement
sur la protection des saisies manuelles pour choisir le statut écrit ('done' et
non 'manual', afin que reset-llm ne les emporte pas), mais ni reset-all ni
requeue-all(include_done) n'excluaient le provider : la protection tenait sur
un bouton et manquait sur les deux autres.
Le trigger épargne désormais les points 'admin', et les trois routes les
excluent. Trois tests d'intégration contre un vrai PostGIS couvrent ce trigger
et celui de la migration 016, qui n'en avaient aucun.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Le limiteur était keyé sur le Bearer fourni par l'appelant :
keyGenerator: (req) => authBearer(req) || req.ip
La clé était donc une valeur non authentifiée, entièrement choisie par
l'attaquant. Envoyer un jeton différent à chaque requête ouvrait un seau neuf à
chaque fois : sur 30 tentatives avec un jeton variable, 0 étaient bloquées, là
où 20 l'étaient avec un jeton fixe. Le plafond ne freinait donc rien — alors
qu'il est précisément là pour ralentir la recherche du jeton par force brute.
Chaque jeton inédit consommait en prime une entrée du cache LRU du limiteur,
évinçant les compteurs légitimes.
La clé redevient l'IP. Isoler par identité réelle est impossible à cette
couche : le limiteur s'exécute avant l'authentification.
Le test « limite par jeton, pas globalement » verrouillait explicitement le
comportement vulnérable. Il est remplacé, avec la raison de l'abandon.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Les deux sites étaient servis par nginx sans aucun en-tête de sécurité : le
helmet de l'API ne couvre que les réponses JSON, pas les pages HTML et JS.
L'admin, qui déclenche des actions destructrices, reçoit une CSP stricte —
script-src 'self' est tenable, son app.js n'utilisant aucun handler inline. Le
site public reçoit une CSP plus permissive sur script/style/img, pour ne pas
casser la carte, mais verrouille object-src, base-uri et frame-ancestors.
pgAdmin est retiré des deux compose sur décision explicite : une interface
d'administration de base exposée sur Internet, pour un usage ponctuel.
- Images de base épinglées (nginx:alpine → nginx:1.27-alpine).
- Utilisateur non-root pour l'api, le crawler et le geocoder.
- Le geocoder ne copie plus que src/ au lieu de tout le contexte.
- .dockerignore ajouté au crawler : son COPY src embarquait __pycache__.
- Keepalive vers l'upstream API (proxy_http_version 1.1 + Connection "").
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
activitySummary() injectait crawl_run.error en innerHTML sans échappement. Ce
message provient d'exceptions du crawler, qui contiennent du contenu scrapé sur
Metal Archives — donc influençable depuis l'extérieur.
api() n'avait aucun timeout : une API muette (pool DB saturé, upstream nginx
silencieux) laissait la vue bloquée sur « Chargement… » indéfiniment.
reset-errors vise désormais aussi les lignes coincées en 'processing', en
forçage manuel du mécanisme d'auto-réparation du worker.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Passe critique sur les zones non vérifiées.
BUG — la correction manuelle d'un lieu n'atteignait pas la moitié du site
bands.lat/lon/geom est une dénormalisation du lieu d'origine, maintenue par
sync_bands_primary() dans geocoder/worker.py. La saisie manuelle de
coordonnées (PATCH /admin/api/locations/:id, ajoutée récemment) écrivait
band_locations sans jamais la déclencher : la correction apparaissait sur la
carte — qui lit band_locations — mais jamais dans la liste, les statistiques
ni la heatmap, qui lisent bands. Le groupe restait affiché au mauvais endroit
indéfiniment.
La synchronisation est répliquée en SQL dans la même transaction, avec la même
règle de tri (step_order ASC, id ASC) que le worker Python.
BUG — course entre réplicas au démarrage (migrate.js)
Le script s'exécute au démarrage de CHAQUE conteneur API. Deux réplicas
démarrant ensemble lisaient tous deux schema_migrations vide et appliquaient
les mêmes fichiers en parallèle : au mieux une violation de clé primaire qui
faisait échouer le démarrage, au pire deux ALTER concurrents.
Verrou consultatif pg_advisory_lock, relâché explicitement. migrate.js
n'exécute plus au chargement s'il est importé (nécessaire pour le tester).
CODE MORT — redis
Signalé au tout début, jamais retiré : un conteneur redis + un volume
persistant dans les DEUX composes, sans une seule référence dans le code.
TESTS AJOUTÉS
- Intégration migrations (7 tests) : application sur base vierge, rejouabilité,
ordre lexicographique, relâchement du verrou, échec bruyant sur migration
invalide, et surtout DEUX MIGRATIONS SIMULTANÉES sur une base vierge —
le cas qui motivait le verrou.
- Tests par PROPRIÉTÉS (fast-check, 21 tests) : batterie qui manquait.
Les tests par l'exemple ne couvrent que les cas auxquels on a pensé.
L'invariant central : toute entrée arbitraire produit soit une valeur
normalisée valide, soit une ValidationError — jamais une autre exception,
jamais NaN. C'est ce qui garantit un 400 plutôt qu'un 500. Vérifie aussi
la cohérence offset = (page-1) × pageSize, le domaine des coordonnées,
et qu'aucun caractère de contrôle ne survit à la validation.
Tests : 601 JS + 61 intégration, 63 Python, 89 Playwright
Co-Authored-By: Claude <noreply@anthropic.com>
1. Suite d'intégration SQL (npm run test:integration:full)
Ferme le dernier angle mort : la SÉMANTIQUE du SQL. Le parseur de grammaire ne
voyait pas un nom de colonne inexistant, un type incompatible ou une fonction
PostGIS mal appelée — soit exactement la classe de bugs qui n'apparaissait
qu'en production.
Technique : chaque requête émise par l'application passe par `PREPARE`.
Postgres l'analyse et la planifie entièrement — colonnes, types, opérateurs
jsonb, fonctions PostGIS — SANS l'exécuter ni nécessiter de données. Rapide, et
ça couvre aussi les requêtes que le parseur JS ne sait pas lire :
jsonb_build_object, `jsonb - text[]`, `raw ? 'clé'`, make_interval, l'opérateur
spatial && et l'agrégation en grille des clusters.
Les migrations réelles sont appliquées dans l'ordre réel : une migration
invalide échoue ici, plus au redémarrage du conteneur en production.
54 tests. Exige Docker, donc HORS de `check` et du hook pre-push — la boucle
de développement reste à 6 s. Base jetable en tmpfs (fsync off).
Deux témoins vérifient que le détecteur n'est pas inopérant : une colonne
inexistante et un type incompatible doivent être rejetés.
2. Les boutons de crawl ne mentent plus
La production ne déploie pas le service crawler (docker-compose.yml ne le
contient pas). Les boutons « Crawl incrémental », « Enrichir » et « Crawl
complet » y créaient des demandes que personne ne consommait, avec un libellé
promettant une exécution « sous ~1 min ».
Ils s'appuient désormais sur le battement de cœur : pas de crawler vivant,
boutons désactivés et raison affichée. « Alimenter le géocodage » reste actif,
puisqu'il est traité par le geocoder.
Je n'ai PAS ajouté le crawler au compose de production : ce serait déclencher
du crawl depuis la prod, décision qui n'est pas la mienne.
Régression évitée au passage : re-rendre le bloc de traitements effaçait le
message de retour (« Demande #77 enregistrée »), puisque loadPilotage() est
rappelé après un clic réussi. L'état des boutons est donc mis à jour sans
reconstruire le DOM. Détecté par un test existant.
Correctif : la suite d'intégration était happée par le glob de vitest.config.js
et allongeait `check` de 6 à 22 s en exigeant Docker.
Tests : 580 JS + 54 intégration, 63 Python, 89 Playwright
Co-Authored-By: Claude <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>
La liste des mutants survivants est la carte de ce qui n'est pas vérifié. Une
passe dessus a montré que TOUS les filtres de /admin/api/bands étaient non
testés (sept mutants survivants par drapeau booléen) — précisément ceux dont
dépendent les filtres rapides du dashboard. Ils pouvaient être inopérants sans
que rien ne le signale.
Bugs trouvés et corrigés
1. crawler_pending détruit silencieusement (backend)
Le PATCH reconstruisait crawler_pending avec jsonb_build_object() sur les
seuls champs édités. Il gardait donc le conflit qu'on venait de trancher ET
supprimait ceux des champs non touchés : éditer le nom d'un groupe effaçait
ses conflits de genre, de pays et de localisation, sans trace.
Corrigé en suivant la convention de /resolve-conflict : retrait des clés
arbitrées (`crawler_pending - ARRAY[...]`).
2. Filtre genre non borné (backend)
q, location_q et themes_q tronquaient à 100 caractères ; genre non. Un motif
ILIKE de taille arbitraire partait vers Postgres, qui ne peut pas l'indexer.
country et status sont bornés au passage, par cohérence.
3. Chips inutilisables après une alerte (frontend)
Le paramètre de statut de l'URL était relu à CHAQUE rendu. Arrivé depuis une
alerte du Pilotage, cliquer un autre chip n'avait aucun effet : le filtre
revenait aussitôt à celui de l'URL. Le paramètre est désormais consommé
(replaceState, qui ne déclenche pas hashchange).
4. Minuteur d'auto-refresh orphelin (frontend)
Le minuteur était armé APRÈS le chargement des données. Quitter la vue
pendant celui-ci le laissait s'armer après le clearTimer() du routeur : le
Pilotage continuait d'interroger quatre endpoints toutes les 15 s depuis un
autre onglet, indéfiniment. Résolu par un jeton de rendu.
Commentaire dangereux corrigé
La saisie manuelle de coordonnées écrit geocode_status='done'. Le commentaire
annonçait 'manual', ce qui aurait conduit à une « correction » aux conséquences
invisibles : /api/clusters ne retient que ('done','country_only') — le point
n'apparaîtrait pas sur la carte — et /locations/reset-llm remet en file
('llm_needed','manual') — le bouton effacerait la saisie. Invariant verrouillé
par un test.
Test instable supprimé
Les interceptions Playwright lisaient la réponse après une possible navigation
(« Response has been disposed ») : un échec sur trois exécutions, sans rapport
avec ce qui était vérifié. Un test instable finit par être ignoré, ce qui est
pire qu'un test absent. Helper patchJson() ; stable sur 4 exécutions.
Tests ajoutés : 529 JS (+78), 43 Python, 82 Playwright (+27)
- bandFilters.test.js : les 3 drapeaux × 2 polarités × présence/absence,
bornes de q, tri, pagination, alignement des paramètres liés
- conflicts.test.js : effet du PATCH sur crawler_pending, casts par type,
allowlist, cohérence avec resolve-conflict
- tools.spec.js : tri, pagination, arbitrage de conflit, annulations,
filtres LLM, cycle de vie des vues
Les trois tests de régression ont été vérifiés NON VACUOUS : chaque bug
réintroduit les fait échouer.
Mutation : 64,00 % -> 68,29 % (seuil 55), obtenu en écrivant des tests et non
en réduisant le périmètre muté.
Co-Authored-By: Claude <noreply@anthropic.com>
Le panneau était découpé par table SQL, pas par question que se pose l'admin.
Conséquence : « je constate ici, j'agis dans un autre onglet », et aucun moyen
de voir ce qui coince réellement.
Redécoupage — un onglet = une question
- Pilotage : est-ce que ça tourne ? (fusionne l'ancien « Actions »)
- Groupes : trouver et corriger
- Localisations : qu'est-ce qui coince dans le géocodage ? ← NOUVEAU
- Journal : que s'est-il passé ?
- LLM & coûts : combien ça coûte ?
Pilotage : chaque chiffre problématique porte son action
- Bandeau de santé (groupes, géocodage, file, bloqués, coût) coloré par état
- Alertes actionnables : « 12 localisations en erreur » + [Examiner] +
[Tout remettre en file], au lieu d'un mur de boutons dans un autre onglet
- Déclencheurs de traitements inline, opérations destructives repliées
- L'ancien bloc « Éléments de genre » (des centaines de mots-clés jamais
consultés) est retiré
Localisations : la vue qui manquait totalement
L'admin ne disposait que d'actions EN MASSE sur band_locations et d'aucun moyen
de voir CE qui échouait. Débloquer un seul lieu supposait de relancer des
milliers d'appels Geoapify/Groq facturés.
- GET /admin/api/locations liste filtrable (statut, lieu, groupe, pays)
- POST /admin/api/locations/:id/requeue relance UNE ligne
- PATCH /admin/api/locations/:id saisie manuelle des coordonnées
- Diagnostic lisible sans clic : lieu brut, statut, essais, erreur réelle
Groupes : recherche d'abord
Une barre de recherche et des filtres rapides en chips remplacent les 8 champs
texte ; les filtres avancés sont repliés.
Accessibilité
- Lignes de tableau activables au clavier (role=button, tabindex, Entrée)
- Modales : Échap ferme, focus piégé, focus rendu à l'élément d'origine
- Contraste : --err (#c61a1a) échouait WCAG AA en texte sur fond sombre (3,4:1).
Les messages d'erreur étaient difficiles à lire. Ajout de --err-text /
--ok-text (~6,5:1) pour les usages en couleur de texte.
Détecté par les nouveaux tests axe sur les vues rendues.
- Statuts affichés en français au lieu des valeurs brutes de la base
Tests
- 36 tests API sur les trois nouveaux endpoints (allowlist de statuts,
bornes des coordonnées, audit, 404)
- 53 parcours Playwright sur la VRAIE app Fastify + faux pool, dans une
topologie identique à la production (statique servi + /admin/* proxifié)
- Tests axe sur les vues RENDUES : le test jsdom existant ne voyait que la
coquille vide du dashboard, tout étant construit en JavaScript
- e2e branché sur le hook pre-push, pas sur `check` : la boucle de
développement reste à 6 s, le push coûte 28 s
Correction trouvée par les tests
Le routeur ne séparait pas la query string du nom de vue :
« #/locations?status=error » ne correspondait à aucun alias et retombait sur
Pilotage — les liens des alertes ne fonctionnaient pas.
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>
L'admin mélangeait pipeline legacy et récent (onglet "Queue" pointait vers
geocode_queue mort → erreur "Cannot read properties of undefined (reading
'reduce')"), dispersait les logs (Monitor/Historique/Jobs) et les actions
(Géocodage/Jobs/Checkpoints) sur des onglets séparés sans lien entre eux.
Nouvelle nav (5 onglets) :
- Vue d'ensemble : stats bands + genre/pays (ex-Dashboard) + cartes live
auto-refresh 15s (crawl en cours, géocodage, jobs en attente, file
d'enrichissement) — fusion Dashboard+Monitor+Queue.
- Groupes : liste bands inchangée + filtre "Conflits seulement" (nouveau
has_conflict sur GET /admin/api/bands) ; le modal band affiche maintenant
une section "Conflits en attente" resolvable inline (garder ma valeur /
accepter Metal Archives) — remplace l'onglet Conflits séparé.
- Activité : liste chronologique unique (GET /admin/api/activity, fusion
crawl_run tous types + admin_audit_log), cliquable → dialog avec logs
complets du run (GET /admin/api/logs?run_id=) ou diff avant/après pour une
action admin. Remplace Crawl runs + Historique + Audit + la liste de jobs
de l'ex-onglet Jobs.
- Actions : toutes les commandes (crawl, géocodage, maintenance) sur une
seule page, groupées, chaque bouton vérifié contre son endpoint réel +
checkpoints en lecture seule. Remplace Jobs + les boutons de Géocodage.
- LLM : inchangé.
Backend
- endpoints morts supprimés (audit-log, conflicts liste, crawl-runs liste —
tous remplacés par /admin/api/activity ou le filtre has_conflict) ;
vérifié 1:1 qu'aucun appel front ne pointe vers un endpoint disparu et
qu'aucun endpoint restant n'est orphelin.
- GET /admin/api/bands : nouveau filtre has_conflict.
Co-Authored-By: Claude Opus 4.8 <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>
Durcissement fiabilité (suite audit).
Confiance & granularité
- worker: un résultat n'est accepté ('done') que si confiance >= GEOCODE_MIN_CONFIDENCE
(0.7) ET granularité non-grossière (rejette country/state/county/region). Sinon
on continue les fallbacks, puis -> llm_needed.
- fix majeur: sur cache hit la confiance était écrite 0.5 en dur (97% des lignes
faussées). Elle est désormais lue depuis geocode_cache (recalculée du raw).
- migration 012: colonnes confidence+granularity sur geocode_cache (recalcul des
entrées Geoapify depuis le raw), geocode_granularity sur band_locations.
Dedup (7x moins de travail)
- fast-path: un band_location dont le (lieu,pays) est déjà 'done' copie le
résultat sans appel API. Index fonctionnel lower(location_raw).
Ancien pipeline retiré
- geocode_queue n'est plus lu nulle part (endpoints /geocoding/reset-errors et
/requeue-all supprimés, /live et /geocoding et Monitor basculés sur
band_locations, panneau admin "ancien pipeline" retiré).
Boutons reset (onglet Géocodage, zone dangereuse)
- POST /admin/api/locations/reset-all: remet tout en queue + efface coords
- POST /admin/api/geocode-cache/purge-nominatim: purge le cache Nominatim
Divers
- groq_worker: coût calculé par modèle (70B vs 8B) au lieu du tarif 70B fixe
- GEOCODE_MIN_CONFIDENCE ajouté aux deux compose
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Objectif: pouvoir debugger la fiabilité des données depuis l'admin.
Parser
- fix split "and": _CITY_SPLIT ne coupe plus sur " and "/"&" (cassait
"Tyne and Wear", "Bosnia and Herzegovina", "Newcastle upon Tyne"). Garde
uniquement "/" et "\" (séparateurs canoniques Metal Archives).
Traçabilité LLM
- migration 011: llm_cache reçoit ma_id + location_raw + country (+ index) pour
relier chaque appel Groq au groupe déclencheur
- groq_worker: renseigne ces colonnes à l'insertion
Admin — lecture par groupe
- GET /admin/api/bands/:ma_id renvoie désormais aussi les band_locations (steps,
statut, provider, confiance, coords, essais, erreur, query) et les appels LLM
(modèle, extraction, tokens, coût, prompt/réponse)
- modal band: section "Provenance & géocodage" (crawl MA / géocodage / LLM +
champs verrouillés manuels)
Admin — stats & debug LLM
- GET /admin/api/geocoding: breakdown par provider (avg confiance) + par modèle
LLM (dont city=null); nouvelle carte "LLM null"
- nouveau GET /admin/api/llm + onglet "LLM": liste filtrable des appels Groq,
prompt/réponse dépliables, lien vers le groupe
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
La carte clusterisait depuis bands.geom (un seul point par groupe). Désormais
/api/clusters source band_locations : un groupe multi-périodes (ex. Thessaloniki
puis Boston) apparaît comme plusieurs points distincts.
- migration 010: colonne geom générée (Point,4326) + index GIST sur
band_locations (évite le seq-scan sur les requêtes bbox)
- /api/clusters: FROM band_locations bl JOIN bands b, bbox via geom && envelope,
filtres (pays/statut/genre/année) sur b.*, points issus de bl.lat/lon ;
la JSON des clusters porte location_raw + step_label
- web parseBandData: expose location_raw + step_label (base pour différencier
plus tard un groupe qui a déménagé d'un groupe resté sur place)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
bands.ma_id est BIGINT (crawler génère jusqu'à ~3.5e9) mais band_locations.ma_id
avait été créé en INTEGER (max 2.1e9). Tout INSERT pour un band à ma_id élevé
échouait en "integer out of range", erreur avalée silencieusement par l'enqueue.
62 239 bands (sur 96 143 localisables) n'entraient donc jamais dans le pipeline,
d'où 0 llm_needed / 0 erreur trompeurs.
- migration 008: ma_id BIGINT (installs neuves)
- migration 009: ALTER COLUMN ma_id TYPE BIGINT (DB existante)
- enqueue.py: compte les inserts échoués (failed=) au lieu de les avaler
- worker.py: sync_bands_primary prend le lieu d'ORIGINE (step 0) et non la
dernière localisation — garde les groupes européens en Europe
- admin app.js: coût LLM robuste au NaN (NUMERIC accepte la valeur spéciale NaN)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- enqueue.py devient un daemon qui poll job_triggers (type geocoder_enqueue)
toutes les 15s — plus de commande manuelle à lancer
- API : geocoder_enqueue ajouté aux job types autorisés
- Admin UI page Géocodage : bouton "Lancer l'enqueue" (vert)
- Admin UI Centre de commandes : même bouton dans la section Géocodeur
- docker-compose : geocoder-enqueue passe à restart: unless-stopped
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Sélecteur de langue : dropdown custom avec flag-icons CDN (fi fi-xx), fermeture click-outside, aria-expanded
- Admin Monitor : nouvelle vue live avec status temps réel (crawl runs, géocodeur, jobs en attente)
- Log stream incrémental : fetch uniquement les nouveaux logs via min_id, buffer 500 entrées, filtre level + recherche texte client-side, flash animation sur nouvelles lignes
- Boutons inline Annuler pour runs actifs et jobs en attente
- API : GET /admin/api/live (agrégé), POST crawl-runs/:id/cancel, POST job-triggers/:id/cancel, param min_id sur GET /logs
- Refresh configurable 3/5/10/30s, pause/reprendre, vider le buffer
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- i18n front-end (fr/en/de/es/it/pl/nl/ro/pt/cs/sv) via locales.js + data-i18n attrs
- Sélecteur de langue avec drapeaux dans la topbar, persisté en localStorage
- Mentions légales et FAQ générées dynamiquement par locale (buildLegal/buildFaq)
- Section "Remerciements" : Geoapify, OSM, Leaflet, Metal Archives, HellBlazer
- Admin : centre de commandes (crawler jobs, géocodeur, maintenance, live log tail 10s)
- Admin : boutons reset-errors / requeue-all géocodeur avec confirmation
- Admin : toutes les actions admin loguées dans crawl_log pour audit
- Suppression anciens artefacts (worker, infra/, apps/api/src/index.js)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Geocoder → Geoapify (bcb790b9007644d1a44ff2391118479c) :
- Remplace Nominatim par Geoapify dans apps/geocoder/src/worker.py
- Délai réduit à 0.22s (5 req/s vs 1 req/s Nominatim) → bien plus rapide
- Même logique de cache geocode_cache, même fallback backoff progressif
- Env vars : GEOAPIFY_API_KEY (obligatoire), GEOCODER_MIN_DELAY/JITTER
Fix crawler incremental_modified (Expecting value: line 1 column 1) :
- ma_http.get_json() retenait sans retry sur JSONDecodeError (corps vide =
session Chrome morte). Désormais : refresh session + retry, comme pour
les erreurs 403/429.
Admin dashboard :
- Bands : colonne Lieu, champ recherche lieu séparé (location_q), filtres
"Géocodé oui/non" (has_lat) et "Lieu vide/renseigné" (has_location)
- Queue : bouton "Annuler runs bloqués >30min" (POST /admin/api/crawl-runs/
cleanup), auto-refresh 15s, colonne Progression avec durée elapsed pour
les runs actifs
- Dashboard : toutes les listes pays/genre/statut sans limite (scroll interne)
- Progression live : crawler écrit les stats dans crawl_run toutes les 50
bands (update_crawl_run_progress), visible dans Queue en temps réel
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Nouveau service apps/admin (admin.metalfrom.eu / admin.dev.metalfrom.eu) :
- Frontend statique vanilla JS/CSS reprenant le design system du site
(login, dashboard stats, table bands éditable, queue d'enrichissement,
historique crawl_run, logs live, checkpoints, journal d'audit)
- nginx reverse-proxy /admin/api/* et /admin/auth/* vers le service api
interne (same-origin côté navigateur, pas de CORS cross-site nécessaire
pour le cookie de session)
apps/api :
- Nouvelle auth dédiée au dashboard, séparée du token BM_IMPORT_TOKEN
existant : login bcrypt + session JWT en cookie httpOnly/secure/
sameSite=strict, rate-limit + lockout après 5 échecs/15min, seeding
du compte admin via env vars (jamais de mot de passe en clair en DB
ou en git)
- Routes /admin/api/* : stats, queue (breakdown priorité identique au
crawler Python), bands (recherche/tri/pagination/édition + audit log),
crawl-runs, crawl-checkpoints, logs, audit-log
- trustProxy activé (Traefik + nginx en amont)
apps/crawler :
- log_event() écrit dans la nouvelle table crawl_log (run start/finish/
erreurs) pour que le dashboard affiche les logs sans exposer le socket
Docker (choix délibéré : pas de docker.sock monté, accès DB only)
migration 006_admin_dashboard.sql : admin_users, admin_login_attempts,
admin_audit_log, crawl_log
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- flaresolverr.py: refactor stateless (create_session/destroy_session/get)
L'ancienne approche warmup→cookies→requests était bloquée par Cloudflare
(JA3 TLS fingerprinting). Toutes les requêtes passent maintenant via une
session Chrome persistante FlareSolverr.
- ma_http.py: MASession utilise les sessions FS persistantes. Ajout de
_extract_json (html.unescape + extraction <pre>) et _build_url.
Gestion du refresh sur 403/429 avec recréation de session.
- db.py: ajout du champ data JSONB dans upsert_bands INSERT+ON CONFLICT.
Avant ce fix, l'URL n'était jamais stockée → get_bands_to_enrich retournait
toujours 0 résultats → enrichissement mort.
- scraper_band.py: schéma band_page aligné sur l'historique DB (lineup
structuré avec URLs artistes, discography_url, lyrical_themes, location,
info_raw). Ajout de "Themes" dans _pick pour couvrir les deux variantes
de clé HTML (MA utilise parfois "Themes", parfois "Lyrical themes").
- 005_fix_checkpoints_and_data.sql: renomme last_additions_check →
last_created_check (clé morte vs clé utilisée par le code). Backfill
enriched=true + colonnes top-level (formed_year, themes, status,
location_text) depuis band_page JSONB pour les 85k bands de l'ancien scraper.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
pg.Client ne peut pas être réutilisé après un connect() échoué.
Le retry créait le même client → "Client has already been connected"
dès l'attempt 2. On crée maintenant un nouveau client à chaque essai.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
migrate.js now retries up to 12 times with linear backoff (max 30s per attempt)
instead of crashing immediately — prevents container restart loops when the
standalone Coolify DB is temporarily unreachable at startup.
CORS_ORIGINS in docker-compose.dev.yml now uses ${CORS_ORIGINS:-https://dev.metalfrom.eu}
to match the prod pattern and allow Coolify UI override.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Ajoute geocoder-worker et worker sur le réseau coolify pour
résoudre le hostname DB (lgrep99...) qui n'est accessible que
depuis le réseau coolify
- CORS_ORIGINS env var optionnel pour configurer les origines
autorisées (utilisé par l'env dev)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>