fix(crawler): FlareSolverr persistent sessions + DB data field + scraper schema

- 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>
This commit is contained in:
Nicolas Fryder 2026-06-30 20:29:33 +02:00
parent 3230ba8d9a
commit dc242d1390
5 changed files with 238 additions and 150 deletions

View file

@ -0,0 +1,47 @@
-- 005: corriger les checkpoints + backfill enriched depuis les données de l'ancien scraper
-- 1. Renommer la clé checkpoint morte 'last_additions_check' → 'last_created_check'
-- (le code utilise last_{order_by}_check donc 'last_created_check', pas 'last_additions_check')
INSERT INTO crawl_checkpoint (key, value)
VALUES ('last_created_check', NULL)
ON CONFLICT (key) DO NOTHING;
DELETE FROM crawl_checkpoint WHERE key = 'last_additions_check';
-- 2. Backfill depuis les données de l'ancien scraper :
-- 85 829 bands ont data->'band_page' rempli mais enriched=false et colonnes top-level vides.
-- On remplit les colonnes à partir du JSONB existant.
UPDATE bands SET
enriched = true,
status = COALESCE(
NULLIF(trim(status), ''),
data->'band_page'->>'status'
),
genre = COALESCE(
NULLIF(trim(genre), ''),
data->'band_page'->>'genre',
data->'band_page'->'info_raw'->>'genre'
),
themes = COALESCE(
NULLIF(trim(themes), ''),
NULLIF(data->'band_page'->>'lyrical_themes', 'N/A'),
NULLIF(data->'band_page'->'info_raw'->>'themes', 'N/A')
),
formed_year = COALESCE(
formed_year,
CASE
WHEN (data->'band_page'->>'formed_in') ~ '^\d{4}$'
THEN (data->'band_page'->>'formed_in')::int
WHEN (data->'band_page'->>'formed_in') IS NOT NULL
THEN (regexp_match(data->'band_page'->>'formed_in', '\d{4}'))[1]::int
WHEN (data->'band_page'->'info_raw'->>'formed in') IS NOT NULL
THEN (regexp_match(data->'band_page'->'info_raw'->>'formed in', '\d{4}'))[1]::int
ELSE NULL
END
),
location_text = COALESCE(
NULLIF(trim(location_text), ''),
data->'band_page'->>'location',
data->'band_page'->'info_raw'->>'location'
)
WHERE data->'band_page' IS NOT NULL;

View file

@ -1,6 +1,7 @@
""" """
Écriture directe en DB. Toutes les opérations passent par des upserts idempotents. Écriture directe en DB. Toutes les opérations passent par des upserts idempotents.
""" """
import json
import logging import logging
from contextlib import contextmanager from contextlib import contextmanager
from datetime import datetime, timezone from datetime import datetime, timezone
@ -62,13 +63,14 @@ def upsert_bands(bands: List[Dict[str, Any]]) -> Dict[str, int]:
b.get("ma_modified_at"), b.get("ma_modified_at"),
ts, # first_seen_at (ignoré si déjà set) ts, # first_seen_at (ignoré si déjà set)
ts, # created_at (ignoré si déjà set) ts, # created_at (ignoré si déjà set)
psycopg2.extras.Json(b.get("data") or {}),
)) ))
sql = """ sql = """
INSERT INTO bands INSERT INTO bands
(ma_id, name, country, location_text, status, genre, formed_year, (ma_id, name, country, location_text, status, genre, formed_year,
themes, enriched, crawled_at, crawled_hash, ma_created_at, ma_modified_at, themes, enriched, crawled_at, crawled_hash, ma_created_at, ma_modified_at,
first_seen_at, created_at) first_seen_at, created_at, data)
VALUES %s VALUES %s
ON CONFLICT (ma_id) DO UPDATE SET ON CONFLICT (ma_id) DO UPDATE SET
name = COALESCE(EXCLUDED.name, bands.name), name = COALESCE(EXCLUDED.name, bands.name),
@ -82,7 +84,8 @@ def upsert_bands(bands: List[Dict[str, Any]]) -> Dict[str, int]:
crawled_at = COALESCE(EXCLUDED.crawled_at, bands.crawled_at), crawled_at = COALESCE(EXCLUDED.crawled_at, bands.crawled_at),
crawled_hash = COALESCE(EXCLUDED.crawled_hash, bands.crawled_hash), crawled_hash = COALESCE(EXCLUDED.crawled_hash, bands.crawled_hash),
ma_created_at = COALESCE(EXCLUDED.ma_created_at, bands.ma_created_at), ma_created_at = COALESCE(EXCLUDED.ma_created_at, bands.ma_created_at),
ma_modified_at= COALESCE(EXCLUDED.ma_modified_at, bands.ma_modified_at) ma_modified_at= COALESCE(EXCLUDED.ma_modified_at, bands.ma_modified_at),
data = bands.data || EXCLUDED.data
RETURNING (xmax = 0) AS was_inserted RETURNING (xmax = 0) AS was_inserted
""" """

View file

@ -1,16 +1,17 @@
""" """
Client FlareSolverr pour bypasser Cloudflare. Client FlareSolverr interface stateless.
Workflow : Workflow :
1. fs = FlareSolverr(url) 1. fs = FlareSolverr(url)
2. fs.warmup(ma_url) solve le challenge CF, stocke les cookies 2. session_id = fs.create_session()
3. session = fs.build_session() requests.Session avec les cookies CF 3. fs.get(warmup_url, session_id) résout le challenge CF
4. Toutes les requêtes suivantes passent par session (requests standard, rapide) 4. status, body = fs.get(ajax_url, session_id) réutilise le même Chrome
5. Si 403 : appeler fs.warmup() à nouveau pour rafraîchir les cookies 5. fs.destroy_session(session_id) libère la ressource Chrome
""" """
import logging import logging
from typing import Optional, Tuple
import requests import requests
from typing import Dict, List, Optional
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -23,42 +24,6 @@ class FlareSolverr:
def __init__(self, base_url: str, timeout_ms: int = 90_000): def __init__(self, base_url: str, timeout_ms: int = 90_000):
self.base_url = base_url.rstrip("/") self.base_url = base_url.rstrip("/")
self.timeout_ms = timeout_ms self.timeout_ms = timeout_ms
self._cookies: List[Dict] = []
self._user_agent: Optional[str] = None
# ------------------------------------------------------------------
# Public
# ------------------------------------------------------------------
def warmup(self, url: str):
"""Charge une page MA pour résoudre le challenge CF et stocker les cookies."""
log.info(f"[fs] warmup on {url}")
resp = self._request("request.get", url)
solution = resp.get("solution", {})
self._cookies = solution.get("cookies", [])
self._user_agent = solution.get("userAgent")
log.info(f"[fs] warmup ok, got {len(self._cookies)} cookies, ua={self._user_agent[:40] if self._user_agent else None}")
def build_session(self) -> requests.Session:
"""Retourne un requests.Session avec les cookies et user-agent CF."""
if not self._cookies:
raise FlareSolverrError("No cookies — call warmup() first")
s = requests.Session()
s.headers.update({
"User-Agent": self._user_agent or "Mozilla/5.0",
"Accept-Language": "en-US,en;q=0.9",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Referer": "https://www.metal-archives.com/",
})
for c in self._cookies:
s.cookies.set(c["name"], c["value"], domain=c.get("domain", ""), path=c.get("path", "/"))
return s
def get_raw(self, url: str) -> str:
"""Fait une requête via FlareSolverr et retourne le HTML rendu."""
resp = self._request("request.get", url)
return resp.get("solution", {}).get("response", "")
def healthy(self) -> bool: def healthy(self) -> bool:
try: try:
@ -67,19 +32,47 @@ class FlareSolverr:
except Exception: except Exception:
return False return False
# ------------------------------------------------------------------ def create_session(self) -> str:
# Internal """Ouvre une session Chrome persistante dans FlareSolverr, retourne le session_id."""
# ------------------------------------------------------------------ data = self._call({"cmd": "sessions.create"})
session_id = data["session"]
log.info(f"[fs] created session {session_id}")
return session_id
def _request(self, cmd: str, url: str) -> Dict: def destroy_session(self, session_id: str):
payload = {"cmd": cmd, "url": url, "maxTimeout": self.timeout_ms} """Libère la session Chrome."""
try: try:
r = requests.post(f"{self.base_url}/v1", json=payload, timeout=self.timeout_ms / 1000 + 10) self._call({"cmd": "sessions.destroy", "session": session_id})
log.info(f"[fs] destroyed session {session_id}")
except Exception as e:
log.warning(f"[fs] destroy session {session_id} failed: {e}")
def get(self, url: str, session_id: Optional[str] = None) -> Tuple[int, str]:
"""
GET via FlareSolverr. Retourne (http_status_code, response_body).
Si session_id fourni, réutilise le Chrome existant (pas de re-warmup).
"""
payload: dict = {"cmd": "request.get", "url": url, "maxTimeout": self.timeout_ms}
if session_id:
payload["session"] = session_id
data = self._call(payload)
sol = data.get("solution", {})
status = sol.get("status", 0)
body = sol.get("response", "")
log.debug(f"[fs] GET {url}{status}")
return status, body
def _call(self, payload: dict) -> dict:
try:
r = requests.post(
f"{self.base_url}/v1",
json=payload,
timeout=self.timeout_ms / 1000 + 10,
)
r.raise_for_status() r.raise_for_status()
data = r.json() data = r.json()
except Exception as e: except Exception as e:
raise FlareSolverrError(f"FlareSolverr request failed: {e}") from e raise FlareSolverrError(f"FlareSolverr request failed: {e}") from e
if data.get("status") != "ok": if data.get("status") != "ok":
raise FlareSolverrError(f"FlareSolverr error: {data.get('message', data)}") raise FlareSolverrError(f"FlareSolverr error: {data.get('message', data)}")
return data return data

View file

@ -1,79 +1,88 @@
""" """
Session HTTP vers Metal Archives. Session HTTP vers Metal Archives via FlareSolverr persistent sessions.
Combine FlareSolverr (pour les cookies CF) et requests (pour les appels AJAX rapides). Cloudflare bloque les requêtes `requests` directes (fingerprint TLS/JA3).
Si un appel retourne 403/429, on rafraîchit automatiquement les cookies. Toutes les requêtes passent par FlareSolverr qui utilise un Chrome headless réel.
Une session Chrome est créée une fois et réutilisée pour toute la durée du crawl
(refresh automatique toutes les 4h ou sur 403/429).
""" """
import json
import logging import logging
import re
import time import time
from html import unescape as html_unescape
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from urllib.parse import urlencode
import requests from .config import MA_BASE, AJAX_PAGE_SIZE, LIST_MIN_DELAY, LIST_MAX_DELAY
from .config import MA_BASE, FS_TIMEOUT_MS, AJAX_PAGE_SIZE, LIST_MIN_DELAY, LIST_MAX_DELAY
from .flaresolverr import FlareSolverr, FlareSolverrError from .flaresolverr import FlareSolverr, FlareSolverrError
from .polite import sleep_range from .polite import sleep_range
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
_WARMUP_URL = f"{MA_BASE}/lists/FR" _WARMUP_URL = f"{MA_BASE}/lists/FR"
_SESSION_TTL = 3600 * 4 # refresh session toutes les 4h
class MASession: class MASession:
def __init__(self, fs: FlareSolverr): def __init__(self, fs: FlareSolverr):
self.fs = fs self.fs = fs
self._session: Optional[requests.Session] = None self._session_id: Optional[str] = None
self._cookie_age = 0.0 self._session_age = 0.0
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Session management # Session management
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def ensure_session(self, force_refresh: bool = False): def ensure_session(self, force_refresh: bool = False):
age = time.time() - self._cookie_age age = time.time() - self._session_age
if self._session is None or force_refresh or age > 3600 * 4: # refresh every 4h if self._session_id is None or force_refresh or age > _SESSION_TTL:
log.info("[ma_http] acquiring Cloudflare cookies via FlareSolverr...") if self._session_id:
self.fs.warmup(_WARMUP_URL) self.fs.destroy_session(self._session_id)
self._session = self.fs.build_session() self._session_id = None
self._cookie_age = time.time() log.info("[ma_http] creating FlareSolverr persistent session...")
log.info("[ma_http] session ready") self._session_id = self.fs.create_session()
log.info(f"[ma_http] warming up CF cookies on {_WARMUP_URL}")
status, _ = self.fs.get(_WARMUP_URL, self._session_id)
log.info(f"[ma_http] warmup done (status={status}), session ready")
self._session_age = time.time()
def get_json(self, url: str, params: Dict = None, retries: int = 2) -> Any: def get_json(self, url: str, params: Dict = None, retries: int = 2) -> Any:
"""GET JSON depuis un endpoint AJAX MA.""" """GET JSON depuis un endpoint AJAX MA."""
self.ensure_session() self.ensure_session()
full_url = _build_url(url, params)
for attempt in range(retries + 1): for attempt in range(retries + 1):
try: status, body = self.fs.get(full_url, self._session_id)
r = self._session.get(url, params=params, timeout=30) if status in (403, 429, 503) and attempt < retries:
if r.status_code in (403, 429, 503) and attempt < retries: log.warning(f"[ma_http] {status} on {url}, refreshing session...")
log.warning(f"[ma_http] {r.status_code} on {url}, refreshing cookies...")
self.ensure_session(force_refresh=True) self.ensure_session(force_refresh=True)
sleep_range(LIST_MIN_DELAY * 2, LIST_MAX_DELAY * 2) sleep_range(LIST_MIN_DELAY * 2, LIST_MAX_DELAY * 2)
continue continue
r.raise_for_status() if status != 200:
return r.json() raise RuntimeError(f"HTTP {status} on {full_url}")
except requests.exceptions.JSONDecodeError: try:
return _extract_json(body)
except (json.JSONDecodeError, ValueError):
log.warning(f"[ma_http] non-JSON response from {url}") log.warning(f"[ma_http] non-JSON response from {url}")
raise raise
raise RuntimeError(f"Failed after {retries} retries: {url}") raise RuntimeError(f"Failed after {retries} retries: {url}")
def get_html(self, url: str, retries: int = 2) -> str: def get_html(self, url: str, retries: int = 2) -> str:
"""GET HTML brut (pages band, etc.).""" """GET HTML rendu (pages band, etc.)."""
self.ensure_session() self.ensure_session()
for attempt in range(retries + 1): for attempt in range(retries + 1):
try: status, body = self.fs.get(url, self._session_id)
r = self._session.get(url, timeout=30) if status in (403, 429, 503) and attempt < retries:
if r.status_code in (403, 429, 503) and attempt < retries: log.warning(f"[ma_http] {status} on {url}, refreshing session...")
log.warning(f"[ma_http] {r.status_code} on {url}, refreshing...")
self.ensure_session(force_refresh=True) self.ensure_session(force_refresh=True)
sleep_range(LIST_MIN_DELAY * 2, LIST_MAX_DELAY * 2) sleep_range(LIST_MIN_DELAY * 2, LIST_MAX_DELAY * 2)
continue continue
r.raise_for_status() if status != 200:
return r.text
except Exception:
if attempt < retries: if attempt < retries:
sleep_range(3, 6) sleep_range(3, 6)
continue continue
raise raise RuntimeError(f"HTTP {status} on {url}")
return body
raise RuntimeError(f"Failed after {retries} retries: {url}") raise RuntimeError(f"Failed after {retries} retries: {url}")
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -154,7 +163,6 @@ class MASession:
parsed = _parse_archive_row(row, order_by) parsed = _parse_archive_row(row, order_by)
if not parsed: if not parsed:
continue continue
# Si on a déjà vu jusqu'à cette date, on s'arrête
if since_date and parsed.get("date_str") and parsed["date_str"] <= since_date: if since_date and parsed.get("date_str") and parsed["date_str"] <= since_date:
stop = True stop = True
break break
@ -172,13 +180,29 @@ class MASession:
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Row parsers # Helpers internes
# ------------------------------------------------------------------ # ------------------------------------------------------------------
import re def _build_url(base: str, params: Dict = None) -> str:
from typing import Optional as Opt if not params:
return base
return f"{base}?{urlencode(params)}"
def _extract_json(body: str) -> Any:
"""
FlareSolverr wraps les réponses JSON dans <pre> avec HTML-encoding des <>.
On extrait le contenu, on unescape les entités HTML, puis on parse le JSON.
"""
m = re.search(r"<pre[^>]*>([\s\S]*?)</pre>", body)
text = html_unescape(m.group(1)) if m else body
return json.loads(text)
# ------------------------------------------------------------------
# Row parsers
# ------------------------------------------------------------------
def _extract_link(html_str: str): def _extract_link(html_str: str):
"""Extrait href et texte d'un fragment HTML '<a href="...">text</a>'.""" """Extrait href et texte d'un fragment HTML '<a href="...">text</a>'."""
m = re.search(r'href=["\']([^"\']+)["\']', html_str or "") m = re.search(r'href=["\']([^"\']+)["\']', html_str or "")
@ -187,7 +211,7 @@ def _extract_link(html_str: str):
return href, text return href, text
def _extract_ma_id(url: str) -> Opt[int]: def _extract_ma_id(url: str) -> Optional[int]:
m = re.search(r"/bands/[^/]+/(\d+)", url or "") m = re.search(r"/bands/[^/]+/(\d+)", url or "")
return int(m.group(1)) if m else None return int(m.group(1)) if m else None
@ -196,10 +220,8 @@ def _clean(s) -> str:
return re.sub(r"<[^>]+>", "", str(s or "")).strip() return re.sub(r"<[^>]+>", "", str(s or "")).strip()
def _parse_list_row(row, country_code: str) -> Opt[Dict]: def _parse_list_row(row, country_code: str) -> Optional[Dict]:
""" """Ligne du listing pays : [name_html, genre, location, status]"""
Ligne du listing pays : [name_html, genre, location, status]
"""
if len(row) < 3: if len(row) < 3:
return None return None
href, name = _extract_link(row[0]) href, name = _extract_link(row[0])
@ -217,10 +239,8 @@ def _parse_list_row(row, country_code: str) -> Opt[Dict]:
} }
def _parse_archive_row(row, order_by: str) -> Opt[Dict]: def _parse_archive_row(row, order_by: str) -> Optional[Dict]:
""" """Ligne des archives : [name_html, country_html, genre, date_str]"""
Ligne des archives : [name_html, country_html, genre, date_str]
"""
if len(row) < 4: if len(row) < 4:
return None return None
href, name = _extract_link(row[0]) href, name = _extract_link(row[0])
@ -228,8 +248,7 @@ def _parse_archive_row(row, order_by: str) -> Opt[Dict]:
if not ma_id or not name: if not ma_id or not name:
return None return None
country_href, country_code = _extract_link(row[1]) country_href, _ = _extract_link(row[1])
# country_href est genre /lists/FR → extraire le code
m = re.search(r"/lists/([A-Z]{1,3})", country_href or "") m = re.search(r"/lists/([A-Z]{1,3})", country_href or "")
cc = m.group(1) if m else _clean(row[1]) cc = m.group(1) if m else _clean(row[1])
@ -240,5 +259,5 @@ def _parse_archive_row(row, order_by: str) -> Opt[Dict]:
"country": cc, "country": cc,
"genre": _clean(row[2]), "genre": _clean(row[2]),
"date_str": _clean(row[3]), "date_str": _clean(row[3]),
"date_type": order_by, # 'created' ou 'modified' "date_type": order_by,
} }

View file

@ -1,5 +1,8 @@
""" """
Parser d'une page individuelle de band Metal Archives. Parser d'une page individuelle de band Metal Archives.
Produit un dict compatible avec upsert_band_enriched() ET cohérent avec
le schéma band_page historique stocké en DB (lineup structuré, discography_url, etc.)
""" """
import hashlib import hashlib
import re import re
@ -7,85 +10,96 @@ from typing import Any, Dict, List, Optional
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
# Section header → clé lineup
_LINEUP_SECTIONS = {
"current": "current",
"past": "past",
"live": "live",
"session": "session",
}
def parse_band_page(html: str) -> Dict[str, Any]: def parse_band_page(html: str) -> Dict[str, Any]:
soup = BeautifulSoup(html, "lxml") soup = BeautifulSoup(html, "lxml")
out: Dict[str, Any] = {} out: Dict[str, Any] = {}
title = soup.find("title") h1 = soup.find("h1", class_=re.compile(r"band_name", re.I)) or soup.find("h1")
out["title"] = _text(title)
h1 = (
soup.find("h1", class_=re.compile(r"band_name", re.I))
or soup.find("h1")
)
out["name"] = _text(h1) out["name"] = _text(h1)
# Bloc #band_stats : dt/dd pairs # --- band_stats : dt/dd pairs ---
stats = soup.find("div", id="band_stats") or soup.find("div", id="band_info") or soup stats_div = soup.find("div", id="band_stats") or soup.find("div", id="band_info") or soup
kv: Dict[str, str] = {} kv: Dict[str, str] = {}
for dl in stats.find_all("dl"): for dl in stats_div.find_all("dl"):
for dt in dl.find_all("dt"): for dt in dl.find_all("dt"):
dd = dt.find_next_sibling("dd") dd = dt.find_next_sibling("dd")
k = _text(dt).rstrip(":") k = _text(dt).rstrip(":")
v = _text(dd) v = _text(dd) if dd else None
if k and v: if k and v:
kv[k] = v kv[k] = v
out["info_raw"] = {k.lower(): v for k, v in kv.items()}
out["info"] = kv
out["status"] = _pick(kv, "Status") out["status"] = _pick(kv, "Status")
out["formed_in"] = _pick(kv, "Formed in", "Formed")
out["genre"] = _pick(kv, "Genre") out["genre"] = _pick(kv, "Genre")
out["themes"] = _pick(kv, "Lyrical themes", "Lyrical Themes") out["formed_in"] = _pick(kv, "Formed in", "Formed")
out["label"] = _pick(kv, "Current label", "Label")
out["years_active"] = _pick(kv, "Years active") out["years_active"] = _pick(kv, "Years active")
out["location"] = _pick(kv, "Location")
# MA uses either "Lyrical themes", "Lyrical Themes", or simply "Themes"
out["lyrical_themes"] = _pick(kv, "Lyrical themes", "Lyrical Themes", "Themes")
# Alias for upsert_band_enriched which reads data.get("themes")
out["themes"] = out["lyrical_themes"]
out["label"] = _pick(kv, "Current label", "Last label", "Label")
# Dates "Added on" / "Last modified" (souvent dans #auditTrail ou en bas de page) # --- Lineup (grouped by section, with artist URLs) ---
audit = soup.find(id="auditTrail") or soup.find("div", class_=re.compile(r"audit", re.I)) lineup: Dict[str, List[Dict]] = {"current": [], "past": [], "live": [], "session": []}
if audit: for table in soup.find_all("table", class_="lineupTable"):
trail_text = _text(audit) section_key = _lineup_section(table)
m_added = re.search(r"Added on:\s*([^\n,]+)", trail_text, re.I) bucket = lineup.setdefault(section_key, [])
m_modified = re.search(r"Last modified on:\s*([^\n,]+)", trail_text, re.I)
out["ma_created_at"] = m_added.group(1).strip() if m_added else None
out["ma_modified_at"] = m_modified.group(1).strip() if m_modified else None
# Membres
members: List[Dict] = []
for table in soup.find_all("table"):
cls = " ".join(table.get("class") or [])
if "lineupTable" not in cls:
continue
section_el = table.find_previous(["h2", "h3"])
section = _text(section_el) if section_el else None
headers = [_text(th) for th in table.find_all("th")]
for tr in table.find_all("tr")[1:]: for tr in table.find_all("tr")[1:]:
tds = tr.find_all(["td", "th"]) tds = tr.find_all(["td", "th"])
if not tds: if not tds:
continue continue
cells = [_text(td) for td in tds] a = tds[0].find("a", href=re.compile(r"/artists/"))
row = dict(zip(headers, cells)) if headers and len(headers) == len(cells) else {"cells": cells} if not a:
if section: continue
row["section"] = section role = _text(tds[1]) if len(tds) > 1 else None
members.append(row) bucket.append({
if members: "name": _text(a),
out["members"] = members "url": a.get("href", ""),
"role": role,
})
out["lineup"] = lineup
# Liens externes # --- Discography URL ---
disc = soup.find("a", href=re.compile(r"/band/discography/"))
out["discography_url"] = disc["href"] if disc else None
# --- Liens externes ---
links_div = soup.find("div", id="band_links") links_div = soup.find("div", id="band_links")
if links_div:
out["links"] = [ out["links"] = [
{"text": _text(a), "url": a["href"]} {"text": _text(a), "url": a["href"]}
for a in links_div.find_all("a", href=True) for a in links_div.find_all("a", href=True)
] ] if links_div else []
# --- Dates audit trail ---
audit = soup.find(id="auditTrail") or soup.find("div", class_=re.compile(r"audit", re.I))
if audit:
trail = _text(audit)
m_added = re.search(r"Added on:\s*(\S+\s+\S+)", trail, re.I)
m_modified = re.search(r"Last modified on:\s*(\S+\s+\S+)", trail, re.I)
out["ma_created_at"] = m_added.group(1).strip() if m_added else None
out["ma_modified_at"] = m_modified.group(1).strip() if m_modified else None
return out return out
def page_hash(html: str) -> str: def page_hash(html: str) -> str:
"""MD5 du HTML pour détecter les changements."""
return hashlib.md5(html.encode()).hexdigest() return hashlib.md5(html.encode()).hexdigest()
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _text(el) -> str: def _text(el) -> str:
return (el.get_text(" ", strip=True) if el else "").strip() return (el.get_text(" ", strip=True) if el else "").strip()
@ -95,3 +109,15 @@ def _pick(kv: Dict, *keys: str) -> Optional[str]:
if k in kv: if k in kv:
return kv[k] return kv[k]
return None return None
def _lineup_section(table) -> str:
"""Déduit la section lineup depuis le h2/h3 qui précède la table."""
el = table.find_previous(["h2", "h3"])
if not el:
return "current"
text = _text(el).lower()
for kw, key in _LINEUP_SECTIONS.items():
if kw in text:
return key
return "current"