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:
parent
3230ba8d9a
commit
dc242d1390
5 changed files with 238 additions and 150 deletions
47
apps/api/migrations/005_fix_checkpoints_and_data.sql
Normal file
47
apps/api/migrations/005_fix_checkpoints_and_data.sql
Normal 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;
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
"""
|
||||
Écriture directe en DB. Toutes les opérations passent par des upserts idempotents.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from contextlib import contextmanager
|
||||
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"),
|
||||
ts, # first_seen_at (ignoré si déjà set)
|
||||
ts, # created_at (ignoré si déjà set)
|
||||
psycopg2.extras.Json(b.get("data") or {}),
|
||||
))
|
||||
|
||||
sql = """
|
||||
INSERT INTO bands
|
||||
(ma_id, name, country, location_text, status, genre, formed_year,
|
||||
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
|
||||
ON CONFLICT (ma_id) DO UPDATE SET
|
||||
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_hash = COALESCE(EXCLUDED.crawled_hash, bands.crawled_hash),
|
||||
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
|
||||
"""
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,17 @@
|
|||
"""
|
||||
Client FlareSolverr pour bypasser Cloudflare.
|
||||
Client FlareSolverr — interface stateless.
|
||||
|
||||
Workflow :
|
||||
1. fs = FlareSolverr(url)
|
||||
2. fs.warmup(ma_url) → solve le challenge CF, stocke les cookies
|
||||
3. session = fs.build_session() → requests.Session avec les cookies CF
|
||||
4. Toutes les requêtes suivantes passent par session (requests standard, rapide)
|
||||
5. Si 403 : appeler fs.warmup() à nouveau pour rafraîchir les cookies
|
||||
2. session_id = fs.create_session()
|
||||
3. fs.get(warmup_url, session_id) → résout le challenge CF
|
||||
4. status, body = fs.get(ajax_url, session_id) → réutilise le même Chrome
|
||||
5. fs.destroy_session(session_id) → libère la ressource Chrome
|
||||
"""
|
||||
import logging
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import requests
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -23,42 +24,6 @@ class FlareSolverr:
|
|||
def __init__(self, base_url: str, timeout_ms: int = 90_000):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
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:
|
||||
try:
|
||||
|
|
@ -67,19 +32,47 @@ class FlareSolverr:
|
|||
except Exception:
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal
|
||||
# ------------------------------------------------------------------
|
||||
def create_session(self) -> str:
|
||||
"""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:
|
||||
payload = {"cmd": cmd, "url": url, "maxTimeout": self.timeout_ms}
|
||||
def destroy_session(self, session_id: str):
|
||||
"""Libère la session Chrome."""
|
||||
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()
|
||||
data = r.json()
|
||||
except Exception as e:
|
||||
raise FlareSolverrError(f"FlareSolverr request failed: {e}") from e
|
||||
|
||||
if data.get("status") != "ok":
|
||||
raise FlareSolverrError(f"FlareSolverr error: {data.get('message', data)}")
|
||||
return data
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
Si un appel retourne 403/429, on rafraîchit automatiquement les cookies.
|
||||
Cloudflare bloque les requêtes `requests` directes (fingerprint TLS/JA3).
|
||||
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 re
|
||||
import time
|
||||
from html import unescape as html_unescape
|
||||
from typing import Any, Dict, Optional
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import requests
|
||||
|
||||
from .config import MA_BASE, FS_TIMEOUT_MS, AJAX_PAGE_SIZE, LIST_MIN_DELAY, LIST_MAX_DELAY
|
||||
from .config import MA_BASE, AJAX_PAGE_SIZE, LIST_MIN_DELAY, LIST_MAX_DELAY
|
||||
from .flaresolverr import FlareSolverr, FlareSolverrError
|
||||
from .polite import sleep_range
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_WARMUP_URL = f"{MA_BASE}/lists/FR"
|
||||
_SESSION_TTL = 3600 * 4 # refresh session toutes les 4h
|
||||
|
||||
|
||||
class MASession:
|
||||
def __init__(self, fs: FlareSolverr):
|
||||
self.fs = fs
|
||||
self._session: Optional[requests.Session] = None
|
||||
self._cookie_age = 0.0
|
||||
self._session_id: Optional[str] = None
|
||||
self._session_age = 0.0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Session management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def ensure_session(self, force_refresh: bool = False):
|
||||
age = time.time() - self._cookie_age
|
||||
if self._session is None or force_refresh or age > 3600 * 4: # refresh every 4h
|
||||
log.info("[ma_http] acquiring Cloudflare cookies via FlareSolverr...")
|
||||
self.fs.warmup(_WARMUP_URL)
|
||||
self._session = self.fs.build_session()
|
||||
self._cookie_age = time.time()
|
||||
log.info("[ma_http] session ready")
|
||||
age = time.time() - self._session_age
|
||||
if self._session_id is None or force_refresh or age > _SESSION_TTL:
|
||||
if self._session_id:
|
||||
self.fs.destroy_session(self._session_id)
|
||||
self._session_id = None
|
||||
log.info("[ma_http] creating FlareSolverr persistent session...")
|
||||
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:
|
||||
"""GET JSON depuis un endpoint AJAX MA."""
|
||||
self.ensure_session()
|
||||
full_url = _build_url(url, params)
|
||||
for attempt in range(retries + 1):
|
||||
try:
|
||||
r = self._session.get(url, params=params, timeout=30)
|
||||
if r.status_code in (403, 429, 503) and attempt < retries:
|
||||
log.warning(f"[ma_http] {r.status_code} on {url}, refreshing cookies...")
|
||||
status, body = self.fs.get(full_url, self._session_id)
|
||||
if status in (403, 429, 503) and attempt < retries:
|
||||
log.warning(f"[ma_http] {status} on {url}, refreshing session...")
|
||||
self.ensure_session(force_refresh=True)
|
||||
sleep_range(LIST_MIN_DELAY * 2, LIST_MAX_DELAY * 2)
|
||||
continue
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except requests.exceptions.JSONDecodeError:
|
||||
if status != 200:
|
||||
raise RuntimeError(f"HTTP {status} on {full_url}")
|
||||
try:
|
||||
return _extract_json(body)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
log.warning(f"[ma_http] non-JSON response from {url}")
|
||||
raise
|
||||
raise RuntimeError(f"Failed after {retries} retries: {url}")
|
||||
|
||||
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()
|
||||
for attempt in range(retries + 1):
|
||||
try:
|
||||
r = self._session.get(url, timeout=30)
|
||||
if r.status_code in (403, 429, 503) and attempt < retries:
|
||||
log.warning(f"[ma_http] {r.status_code} on {url}, refreshing...")
|
||||
status, body = self.fs.get(url, self._session_id)
|
||||
if status in (403, 429, 503) and attempt < retries:
|
||||
log.warning(f"[ma_http] {status} on {url}, refreshing session...")
|
||||
self.ensure_session(force_refresh=True)
|
||||
sleep_range(LIST_MIN_DELAY * 2, LIST_MAX_DELAY * 2)
|
||||
continue
|
||||
r.raise_for_status()
|
||||
return r.text
|
||||
except Exception:
|
||||
if status != 200:
|
||||
if attempt < retries:
|
||||
sleep_range(3, 6)
|
||||
continue
|
||||
raise
|
||||
raise RuntimeError(f"HTTP {status} on {url}")
|
||||
return body
|
||||
raise RuntimeError(f"Failed after {retries} retries: {url}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -154,7 +163,6 @@ class MASession:
|
|||
parsed = _parse_archive_row(row, order_by)
|
||||
if not parsed:
|
||||
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:
|
||||
stop = True
|
||||
break
|
||||
|
|
@ -172,13 +180,29 @@ class MASession:
|
|||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Row parsers
|
||||
# Helpers internes
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
import re
|
||||
from typing import Optional as Opt
|
||||
def _build_url(base: str, params: Dict = None) -> str:
|
||||
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):
|
||||
"""Extrait href et texte d'un fragment HTML '<a href="...">text</a>'."""
|
||||
m = re.search(r'href=["\']([^"\']+)["\']', html_str or "")
|
||||
|
|
@ -187,7 +211,7 @@ def _extract_link(html_str: str):
|
|||
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 "")
|
||||
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()
|
||||
|
||||
|
||||
def _parse_list_row(row, country_code: str) -> Opt[Dict]:
|
||||
"""
|
||||
Ligne du listing pays : [name_html, genre, location, status]
|
||||
"""
|
||||
def _parse_list_row(row, country_code: str) -> Optional[Dict]:
|
||||
"""Ligne du listing pays : [name_html, genre, location, status]"""
|
||||
if len(row) < 3:
|
||||
return None
|
||||
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]:
|
||||
"""
|
||||
Ligne des archives : [name_html, country_html, genre, date_str]
|
||||
"""
|
||||
def _parse_archive_row(row, order_by: str) -> Optional[Dict]:
|
||||
"""Ligne des archives : [name_html, country_html, genre, date_str]"""
|
||||
if len(row) < 4:
|
||||
return None
|
||||
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:
|
||||
return None
|
||||
|
||||
country_href, country_code = _extract_link(row[1])
|
||||
# country_href est genre /lists/FR → extraire le code
|
||||
country_href, _ = _extract_link(row[1])
|
||||
m = re.search(r"/lists/([A-Z]{1,3})", country_href or "")
|
||||
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,
|
||||
"genre": _clean(row[2]),
|
||||
"date_str": _clean(row[3]),
|
||||
"date_type": order_by, # 'created' ou 'modified'
|
||||
"date_type": order_by,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
"""
|
||||
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 re
|
||||
|
|
@ -7,85 +10,96 @@ from typing import Any, Dict, List, Optional
|
|||
|
||||
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]:
|
||||
soup = BeautifulSoup(html, "lxml")
|
||||
out: Dict[str, Any] = {}
|
||||
|
||||
title = soup.find("title")
|
||||
out["title"] = _text(title)
|
||||
|
||||
h1 = (
|
||||
soup.find("h1", class_=re.compile(r"band_name", re.I))
|
||||
or soup.find("h1")
|
||||
)
|
||||
h1 = soup.find("h1", class_=re.compile(r"band_name", re.I)) or soup.find("h1")
|
||||
out["name"] = _text(h1)
|
||||
|
||||
# Bloc #band_stats : dt/dd pairs
|
||||
stats = soup.find("div", id="band_stats") or soup.find("div", id="band_info") or soup
|
||||
# --- band_stats : dt/dd pairs ---
|
||||
stats_div = soup.find("div", id="band_stats") or soup.find("div", id="band_info") or soup
|
||||
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"):
|
||||
dd = dt.find_next_sibling("dd")
|
||||
k = _text(dt).rstrip(":")
|
||||
v = _text(dd)
|
||||
v = _text(dd) if dd else None
|
||||
if k and 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["formed_in"] = _pick(kv, "Formed in", "Formed")
|
||||
out["genre"] = _pick(kv, "Genre")
|
||||
out["themes"] = _pick(kv, "Lyrical themes", "Lyrical Themes")
|
||||
out["label"] = _pick(kv, "Current label", "Label")
|
||||
out["formed_in"] = _pick(kv, "Formed in", "Formed")
|
||||
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)
|
||||
audit = soup.find(id="auditTrail") or soup.find("div", class_=re.compile(r"audit", re.I))
|
||||
if audit:
|
||||
trail_text = _text(audit)
|
||||
m_added = re.search(r"Added on:\s*([^\n,]+)", trail_text, re.I)
|
||||
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")]
|
||||
# --- Lineup (grouped by section, with artist URLs) ---
|
||||
lineup: Dict[str, List[Dict]] = {"current": [], "past": [], "live": [], "session": []}
|
||||
for table in soup.find_all("table", class_="lineupTable"):
|
||||
section_key = _lineup_section(table)
|
||||
bucket = lineup.setdefault(section_key, [])
|
||||
for tr in table.find_all("tr")[1:]:
|
||||
tds = tr.find_all(["td", "th"])
|
||||
if not tds:
|
||||
continue
|
||||
cells = [_text(td) for td in tds]
|
||||
row = dict(zip(headers, cells)) if headers and len(headers) == len(cells) else {"cells": cells}
|
||||
if section:
|
||||
row["section"] = section
|
||||
members.append(row)
|
||||
if members:
|
||||
out["members"] = members
|
||||
a = tds[0].find("a", href=re.compile(r"/artists/"))
|
||||
if not a:
|
||||
continue
|
||||
role = _text(tds[1]) if len(tds) > 1 else None
|
||||
bucket.append({
|
||||
"name": _text(a),
|
||||
"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")
|
||||
if links_div:
|
||||
out["links"] = [
|
||||
{"text": _text(a), "url": a["href"]}
|
||||
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
|
||||
|
||||
|
||||
def page_hash(html: str) -> str:
|
||||
"""MD5 du HTML pour détecter les changements."""
|
||||
return hashlib.md5(html.encode()).hexdigest()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _text(el) -> str:
|
||||
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:
|
||||
return kv[k]
|
||||
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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue