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.
|
É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
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue