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>
220 lines
8 KiB
Python
220 lines
8 KiB
Python
"""
|
|
Jobs de crawl. Chaque fonction est un job indépendant appelé par le scheduler.
|
|
"""
|
|
import logging
|
|
from datetime import UTC, datetime
|
|
|
|
from .config import (
|
|
BAND_MAX_DELAY,
|
|
BAND_MIN_DELAY,
|
|
COOLDOWN_EVERY,
|
|
COOLDOWN_MAX,
|
|
COOLDOWN_MIN,
|
|
MA_BASE,
|
|
)
|
|
from .db import (
|
|
RunCancelled,
|
|
finish_crawl_run,
|
|
get_bands_to_enrich,
|
|
get_checkpoint,
|
|
log_event,
|
|
raise_if_cancelled,
|
|
set_checkpoint,
|
|
start_crawl_run,
|
|
update_crawl_run_progress,
|
|
upsert_band_enriched,
|
|
upsert_bands,
|
|
)
|
|
from .europe_codes import EUROPE_COUNTRY_CODES
|
|
from .ma_http import MASession
|
|
from .polite import cooldown, sleep_range
|
|
from .scraper_band import page_hash, parse_band_page
|
|
|
|
_EU_SET = set(EUROPE_COUNTRY_CODES)
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
CHUNK = 300 # taille des batches d'upsert
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Crawl complet (tous les pays d'un coup)
|
|
# ------------------------------------------------------------------
|
|
|
|
def run_full_crawl(session: MASession, countries: list[str] = None):
|
|
countries = countries or EUROPE_COUNTRY_CODES
|
|
run_id = start_crawl_run("full_europe", countries)
|
|
stats = {"seen": 0, "new": 0, "updated": 0, "enriched": 0}
|
|
error = None
|
|
cancelled = False
|
|
log_event("info", f"full crawl started ({len(countries)} countries)", run_id=run_id)
|
|
|
|
try:
|
|
for cc in countries:
|
|
raise_if_cancelled(run_id)
|
|
log.info(f"[full] country={cc}")
|
|
buf = []
|
|
for band in session.fetch_country_bands(cc):
|
|
band["data"] = {"url": band.pop("url", None)}
|
|
buf.append(band)
|
|
if len(buf) >= CHUNK:
|
|
# Un crawl complet Europe dure des heures : le contrôle doit
|
|
# tomber à chaque lot, pas seulement entre deux pays.
|
|
raise_if_cancelled(run_id)
|
|
r = upsert_bands(buf)
|
|
stats["seen"] += len(buf)
|
|
stats["new"] += r["inserted"]
|
|
stats["updated"] += r["updated"]
|
|
buf = []
|
|
if buf:
|
|
r = upsert_bands(buf)
|
|
stats["seen"] += len(buf)
|
|
stats["new"] += r["inserted"]
|
|
stats["updated"] += r["updated"]
|
|
|
|
set_checkpoint("last_full_crawl_at", _now_iso())
|
|
log.info(f"[full] done: {stats}")
|
|
log_event("info", f"full crawl done: {stats}", run_id=run_id)
|
|
except RunCancelled:
|
|
cancelled = True
|
|
log.info(f"[full] annulé sur demande admin: {stats}")
|
|
log_event("warning", f"full crawl annulé par un admin: {stats}", run_id=run_id)
|
|
except Exception as e:
|
|
error = str(e)
|
|
log.error(f"[full] error: {e}", exc_info=True)
|
|
log_event("error", f"full crawl error: {e}", run_id=run_id)
|
|
finally:
|
|
finish_crawl_run(run_id, stats, error, cancelled=cancelled)
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Crawl incrémental (dernières additions / modifications)
|
|
# ------------------------------------------------------------------
|
|
|
|
def run_incremental(session: MASession, order_by: str):
|
|
"""
|
|
order_by : 'created' | 'modified'
|
|
S'arrête dès qu'on voit des entrées déjà connues (via le checkpoint).
|
|
"""
|
|
checkpoint_key = f"last_{order_by}_check"
|
|
since = get_checkpoint(checkpoint_key)
|
|
run_id = start_crawl_run(f"incremental_{order_by}")
|
|
stats = {"seen": 0, "new": 0, "updated": 0, "enriched": 0}
|
|
error = None
|
|
cancelled = False
|
|
latest_date = since
|
|
|
|
log.info(f"[incr/{order_by}] since={since}")
|
|
log_event("info", f"incremental/{order_by} started (since={since})", run_id=run_id)
|
|
try:
|
|
buf = []
|
|
for band in session.fetch_archive_bands(order_by, since_date=since):
|
|
date_str = band.pop("date_str", None)
|
|
if date_str and (latest_date is None or date_str > latest_date):
|
|
latest_date = date_str
|
|
|
|
if band.get("country") not in _EU_SET:
|
|
continue # on reste europe uniquement
|
|
|
|
band["data"] = {"url": band.pop("url", None)}
|
|
buf.append(band)
|
|
if len(buf) >= CHUNK:
|
|
raise_if_cancelled(run_id)
|
|
r = upsert_bands(buf)
|
|
stats["seen"] += len(buf)
|
|
stats["new"] += r["inserted"]
|
|
stats["updated"] += r["updated"]
|
|
buf = []
|
|
|
|
if buf:
|
|
r = upsert_bands(buf)
|
|
stats["seen"] += len(buf)
|
|
stats["new"] += r["inserted"]
|
|
stats["updated"] += r["updated"]
|
|
|
|
if latest_date:
|
|
set_checkpoint(checkpoint_key, latest_date)
|
|
log.info(f"[incr/{order_by}] done: {stats}")
|
|
log_event("info", f"incremental/{order_by} done: {stats}", run_id=run_id)
|
|
except RunCancelled:
|
|
cancelled = True
|
|
log.info(f"[incr/{order_by}] annulé sur demande admin: {stats}")
|
|
log_event("warning", f"incremental/{order_by} annulé par un admin: {stats}", run_id=run_id)
|
|
except Exception as e:
|
|
error = str(e)
|
|
log.error(f"[incr/{order_by}] error: {e}", exc_info=True)
|
|
log_event("error", f"incremental/{order_by} error: {e}", run_id=run_id)
|
|
finally:
|
|
finish_crawl_run(run_id, stats, error, cancelled=cancelled)
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Enrichissement des pages individuelles de bands
|
|
# ------------------------------------------------------------------
|
|
|
|
def run_enrich(session: MASession, limit: int = 100, country: str | None = None):
|
|
"""
|
|
Visite les pages individuelles des bands non encore enrichies.
|
|
Stocke themes, membres, label, dates MA, hash HTML.
|
|
"""
|
|
run_id = start_crawl_run("enrich", [country] if country else None)
|
|
stats = {"seen": 0, "new": 0, "updated": 0, "enriched": 0}
|
|
error = None
|
|
cancelled = False
|
|
|
|
try:
|
|
bands = get_bands_to_enrich(country=country, limit=limit)
|
|
log.info(f"[enrich] {len(bands)} bands to enrich")
|
|
log_event("info", f"enrich started: {len(bands)} bands queued", run_id=run_id)
|
|
|
|
for i, band in enumerate(bands):
|
|
# Volontairement AVANT le try/except interne : celui-ci fait
|
|
# `except Exception: continue` et avalerait RunCancelled.
|
|
raise_if_cancelled(run_id)
|
|
|
|
url = band.get("url")
|
|
if not url:
|
|
continue
|
|
if not url.startswith("http"):
|
|
url = MA_BASE + url
|
|
|
|
try:
|
|
html = session.get_html(url)
|
|
data = parse_band_page(html)
|
|
h = page_hash(html)
|
|
ok = upsert_band_enriched(band["ma_id"], data, h)
|
|
if ok:
|
|
stats["enriched"] += 1
|
|
stats["seen"] += 1
|
|
except Exception as e:
|
|
log.warning(f"[enrich] failed ma_id={band['ma_id']}: {e}")
|
|
log_event("warning", f"enrich failed: {e}", run_id=run_id, ma_id=band["ma_id"])
|
|
continue
|
|
|
|
sleep_range(BAND_MIN_DELAY, BAND_MAX_DELAY)
|
|
if COOLDOWN_EVERY and (i + 1) % COOLDOWN_EVERY == 0:
|
|
cooldown(COOLDOWN_MIN, COOLDOWN_MAX)
|
|
|
|
if (i + 1) % 10 == 0:
|
|
update_crawl_run_progress(run_id, stats)
|
|
|
|
log.info(f"[enrich] done: {stats}")
|
|
log_event("info", f"enrich done: {stats}", run_id=run_id)
|
|
except RunCancelled:
|
|
cancelled = True
|
|
log.info(f"[enrich] annulé sur demande admin: {stats}")
|
|
log_event("warning", f"enrich annulé par un admin: {stats}", run_id=run_id)
|
|
except Exception as e:
|
|
error = str(e)
|
|
log.error(f"[enrich] error: {e}", exc_info=True)
|
|
log_event("error", f"enrich error: {e}", run_id=run_id)
|
|
finally:
|
|
finish_crawl_run(run_id, stats, error, cancelled=cancelled)
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Helpers
|
|
# ------------------------------------------------------------------
|
|
|
|
def _now_iso() -> str:
|
|
return datetime.now(UTC).strftime("%Y-%m-%d")
|