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>
Les deux modales (groupes sans coordonnées, mentions légales / FAQ) portent
role="dialog" et aria-modal="true" — ce qui affirme aux lecteurs d'écran que
tout le reste de la page est inerte. C'était faux : aucune gestion du focus.
Concrètement, à l'ouverture le focus restait derrière le voile, la tabulation
ressortait sur des commandes invisibles, et la fermeture ne rendait pas le
focus à son point de départ. Un utilisateur au clavier se retrouvait perdu
dans une page dont on venait de lui dire qu'elle était inaccessible.
Le dashboard admin faisait déjà tout cela correctement ; on porte le même
traitement, factorisé en openBackdrop / closeBackdrop puisque les modales du
site public sont des éléments statiques que l'on bascule, et non créés à la
volée comme côté admin.
Non couvert par un test : le site public n'a aucun harnais de bout en bout —
les 89 parcours Playwright ne visent que l'admin.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Le viewport est élargi de 20 % pour précharger les bords, sans aucun bornage.
Au-delà d'un certain dézoom, la bbox sortait des limites géographiques :
zoom 2 (monde) -> -164,-88.4,144,110.4
/api/clusters rejette en 400 toute coordonnée hors [-90,90] / [-180,180].
Vérifié contre la vraie validation : les zooms 3 et 4 passent, les zooms 1 et 2
renvoient « Coordinates out of range ». L'utilisateur qui dézoomait ne voyait
donc plus aucun point, sans le moindre message.
Leaflet renvoie par ailleurs des longitudes au-delà de ±180 quand la vue
chevauche plusieurs copies du monde ; le bornage seul produirait alors un
intervalle vide, que l'API rejette aussi. On retombe dans ce cas sur la bande
complète, que la vue couvre de toute façon.
Le calcul part dans pure.js, conformément à la convention du fichier, avec des
cas limites et une propriété fast-check : aucune vue Leaflet imaginable ne doit
produire une bbox que l'API refuse. Vérifié que cette propriété échoue bien sur
l'ancienne implémentation (contre-exemple : east = 180.00000000000003).
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>
build_fallback_queries cherchait le code pays comme SOUS-CHAÎNE du lieu :
country_code.upper() in location_raw.upper()
Or "DE" est contenu dans "DRESDEN", "FR" dans "FRESNES", "NO" dans "NOTODDEN",
"ES" dans "TORRES". Le pays était donc considéré comme déjà présent, et la
variante « ville, pays » se retrouvait reléguée APRÈS la ville nue.
Le worker s'arrête au PREMIER résultat fiable : il interrogeait donc Geoapify
sans aucun contexte pays, précisément sur les noms ambigus — il existe un
Dresden dans l'Ohio. Sur un échantillon de 25 villes européennes réelles, 11
partaient sans désambiguïsation.
Le code est désormais cherché comme mot entier. Le test existant n'attrapait
rien parce qu'il vérifiait la PRÉSENCE du pays quelque part dans la liste
(`any(...)`), jamais sa priorité : les nouveaux cas verrouillent l'ordre.
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>
Les cinq bibliothèques chargées depuis unpkg et jsDelivr (Leaflet,
MarkerCluster, leaflet.heat, noUiSlider, flag-icons) n'avaient aucun contrôle
d'intégrité : un CDN compromis exécutait du code arbitraire sur le site public.
Les neuf hashes ont été recalculés depuis les URL versionnées.
GoatCounter reste sans SRI : son script n'est pas versionné.
apiGet() reçoit un timeout : sans lui, une API qui ne répond jamais laissait
l'interface et le rechargement du viewport bloqués indéfiniment.
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>
La clé Geoapify part en query string. Toute exception requests — timeout,
raise_for_status, ConnectionError — embarque l'URL complète, donc la clé. Ce
message était écrit tel quel dans band_locations.geocode_error, que la vue
Localisations de l'admin affiche. Les exceptions sont remplacées par une erreur
sans URL, avec un masquage en filet.
Le groq-worker ne rattrapait que RuntimeError. Or requests lève des exceptions
héritant d'OSError : un 500 Groq ou un timeout s'échappait de la boucle et
tuait le process, que restart: unless-stopped relançait pour remourir aussitôt.
Les trois daemons faisaient `SELECT … FOR UPDATE SKIP LOCKED` avec
autocommit = True. Chaque instruction étant sa propre transaction, le verrou
tombait immédiatement : la clause ne protégeait rien. Sans conséquence avec une
réplique unique, mais toute mise à l'échelle aurait produit des appels API
payants en double. La réservation tient désormais en un seul UPDATE … RETURNING.
Autres correctifs :
- Une ligne passée en 'processing' puis abandonnée (redeploy, OOM) y restait
pour toujours : la file ne sélectionne que 'queued' et aucune route admin ne
visait ce statut. Récupération au démarrage et pendant les temps morts.
- is_reliable() acceptait n'importe quel résultat pour un lieu « pays seul »,
y compris sans confiance. Or une telle ligne n'arrive au worker que si le
centroïde a échoué — précisément quand il ne faut pas faire confiance.
- resolve_iso2() renvoyait n'importe quel code à deux lettres, "XX" compris.
- Alias non standards fréquents chez MA résolus (UK, Great Britain, Holland,
Czechia). COUNTRY_NAMES étant dérivé par inversion, où le dernier libellé
gagne, 'NL' est explicitement réimposé à « Netherlands ».
- max_tokens Groq relevé de 80 à 120 : la réponse JSON était parfois tronquée
à la source, ce que le repli de parsing ne peut pas réparer.
- Arrêt propre sur SIGTERM/SIGINT et reconnexion DB de l'enqueue.
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>
BUG — _parse_json perdait les réponses contenant un objet imbriqué
Le repli utilisé quand le modèle enrobe son JSON de prose s'appuyait sur
`\{[^}]+\}`, qui s'arrête au PREMIER `}`. Dès que la réponse contenait un objet
imbriqué (ex. {"city": "Oslo", "meta": {"confidence": 0.9}}), la capture était
tronquée donc invalide : _parse_json renvoyait None, le lieu comptait comme
« aucune ville trouvée », consommait un essai LLM et finissait par basculer en
'manual'. Aucune erreur levée — défaut silencieux, sur un appel facturé.
Le re.DOTALL passé en argument était de surcroît inopérant, `[^}]` matchant
déjà les retours à la ligne : signe que l'intention était `.`.
Corrigé en `\{.*\}` glouton, du premier `{` au dernier `}`.
TESTS (23) — helpers purs de groq_worker, jusqu'ici sans couverture
- _parse_json : JSON propre, enrobé de prose, imbriqué, multiligne, et tous
les cas inexploitables — qui doivent rendre None sans jamais lever, sous
peine d'interrompre le worker.
- _input_hash : c'est la clé du cache LLM, donc ce qui décide de rappeler Groq
ou non. Vérifié qu'elle normalise casse et espaces (sinon on repaie des
appels déjà faits) et qu'elle distingue modèle, lieu et pays.
- _cost et PRICING : un modèle absent de PRICING serait facturé au tarif de
repli sans qu'on le remarque, faussant le coût affiché dans l'admin.
- _apply_clean_query : appelée pour de vrai avec un curseur factice plutôt que
de réécrire la concaténation dans le test.
conftest reproduit le sys.path du conteneur (src/ ET racine de l'app) :
worker.py et groq_worker.py importent `parser` sans préfixe parce qu'ils sont
lancés en `python src/worker.py`.
Vérification écartée : `COUNTRY_NAMES.get(code, iso2)` retombe sur le code brut
pour un ISO2 inconnu (« Oslo, ZZ »). Mon test l'avait pris pour un bug ; c'est
volontaire — mieux vaut un code qu'aucun contexte pays. Documenté.
Deux tests tautologiques écrits puis supprimés avant commit : ils comparaient
deux f-strings identiques et ne vérifiaient rien.
Co-Authored-By: Claude <noreply@anthropic.com>
BUG trouvé par un test de propriété
parse_location_text("0/0") produisait deux lignes identiques. band_locations
impose UNIQUE (ma_id, step_order, location_raw) : le doublon était absorbé par
le ON CONFLICT DO NOTHING de l'enqueue, mais il faussait les compteurs et n'a
aucun sens métier — une étape ne se déroule pas deux fois au même endroit.
Déduplication par dict.fromkeys (ordre préservé) + tests de régression.
Contre-exemple minimal trouvé en 150 tirages, aucun test par l'exemple ne
l'aurait deviné.
SCRAPER HTML (42 tests) — module le plus exposé du crawler, aucun test
jusqu'ici. C'est lui qui extrait genre, statut, thèmes, line-up et dates. Si
Metal Archives change son HTML, il renvoie des champs vides et le crawler
enregistre des fiches creuses SANS lever d'erreur : la panne invisible, donc
la plus coûteuse.
Couvre les trois orthographes de « Lyrical themes » observées chez MA, les
variantes de « label » et « formed in », le regroupement du line-up par
section, l'audit trail, et surtout la dégradation : une page vide ou
malformée ne doit jamais lever et doit rendre une structure complète.
COUCHE RÉSEAU (57 tests) — ma_http et flaresolverr, sans une seule requête
réelle. Ce qui est testé, c'est la logique AUTOUR du réseau : quand réessayer,
quand abandonner, comment extraire les données d'une réponse enveloppée par
FlareSolverr.
Enjeu concret : ces bornes conditionnent le nombre de requêtes envoyées à
Metal Archives — une boucle de retry mal bornée nous ferait bannir. Vérifié
que retries=2 donne exactement 3 tentatives, qu'un 500 n'est PAS réessayé
(contrairement à 403/429/503, qui justifient une session Chrome neuve), et
que les appels de préchauffage Cloudflare sont comptés à part.
PROPRIÉTÉS GÉNÉRALISÉES
- Python (Hypothesis) : parseur de localisations, requêtes de repli,
centroïdes. Invariants — jamais d'exception, structure toujours complète,
aucun lieu vide, aucun doublon, nombre de requêtes facturées borné.
- Frontend (fast-check) : échappement HTML (une faille XSS, pas un défaut
cosmétique — tout le rendu passe par innerHTML), filtrage tri-état, tri
sans effet de bord ni perte d'éléments, clés de coordonnées.
max_examples fixé à 150 côté Python : ces tests étaient devenus le chemin
critique de `check` (11 s). 150 tirages suffisaient à trouver « 0/0 ».
Tests : 621 JS + 61 intégration, 162 Python, 89 Playwright. check à 9 s.
Co-Authored-By: Claude <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>
Bind activity row click handlers to the rendered row object instead of refinding it by id and type.
Also guard activitySummary against missing rows to avoid runtime crashes in the admin activity modal.
Support admin views from both hash-based URLs and path-based URLs.
Add compatibility aliases such as activities -> activity to avoid falling back to the dashboard overview.
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>
is_reliable exige strictement > MIN_CONFIDENCE : une confiance de 0.7 pile
(ou moins) n'est plus acceptée et part vers le LLM.
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>
Magyar, Dansk, Suomi, Slovenčina, Hrvatski, Slovenščina, Lietuvių, Latviešu,
Eesti, Norsk bokmål. 21 langues au total.
- locales.js: traductions complètes des ~50 clés (faq, legal inclus)
- index.html: boutons dropdown avec flag-icons (sl→si, et→ee, nb→no, da→dk)
- app.js: SUPPORTED_LANGS, LANG_FLAGS, LANG_NAMES
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>
- Options du select : noms natifs (Français, English, Deutsch…) à la place des emojis flags
- detectLang() itère navigator.languages[] pour trouver la première langue supportée
- Fallback sur "en" au lieu de "fr" si aucune langue connue
- Fallback t() sur "en" au lieu de "fr"
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>
Root cause du 404 intermittent : les stacks prod et dev déclarent tous les
deux un service nommé "api" attaché au même réseau externe coolify. Docker
DNS répond aléatoirement avec l'IP du conteneur prod OU dev pour le nom
"api" — quand admin/nginx tombait sur le mauvais conteneur (inaccessible
depuis cet environnement), ça donnait "connect() failed: Connection
refused" puis 404 côté client. Le resolver dynamique (commit précédent)
ne corrige pas cette collision, il la rend juste plus visible/intermittente.
Fix : alias réseau unique par environnement (bm-api-internal en prod,
dev-api-internal en dev) sur le service api, et apps/admin/Dockerfile
prend un ARG API_UPSTREAM substitué par sed dans nginx.conf au build,
pointant vers l'alias correspondant.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
nginx résolvait "api" une seule fois au démarrage et gardait l'IP en
cache indéfiniment (comportement par défaut pour un proxy_pass avec
hostname statique). Après un redeploy Coolify du service api (nouveau
conteneur = nouvelle IP), nginx continuait à taper sur l'ancienne IP
morte -> "connect() failed (111: Connection refused)" -> 404 côté
client. Fix : resolver 127.0.0.11 (DNS Docker embarqué) + proxy_pass
via variable pour forcer une résolution à chaque requête.
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>
Bug latent : la fonction n'était jamais appelée avant car get_bands_to_enrich
retournait 0. Exposé dès le premier run réel avec la nouvelle priority queue.
Co-Authored-By: Claude Sonnet 4.6 <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>
L'endpoint /archives/ajax-band-list retourne 6 colonnes :
[date, band_link, country_link, genre, time, user]
et non 4 comme supposé initialement. Le band_link est à row[1],
country à row[2], genre à row[3].
Les dates MA n'incluent pas l'année (ex: "June 1") donc date_str=None :
la logique d'arrêt incrémental sur date est désactivée, on re-fetch
les ~800 entrées récentes à chaque run (idempotent, coût négligeable).
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>
- 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>