- apps/crawler/ : service Python complet, remplace les scripts locaux - FlareSolverr pour bypasser Cloudflare (cookies CF → session requests) - Crawl incrémental : /archives/band-list/by/created et /by/modified - Crawl complet Europe : pagination AJAX /browse/ajax-country/ - Enrichissement : pages individuelles de bands (themes, membres, label, hash) - Écriture directe en DB (upserts bulk, idempotents) - Scheduler intégré (schedule library) : incrémental 4h, enrich 2h, full le 1er du mois - Tracking via crawl_run et crawl_checkpoint (migration 004) - docker-compose.dev.yml : flaresolverr + crawler ajoutés Full crawl désactivé en dev (CRAWLER_SCHED_FULL_DAY=0) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
233 lines
7.9 KiB
Python
233 lines
7.9 KiB
Python
"""
|
|
Écriture directe en DB. Toutes les opérations passent par des upserts idempotents.
|
|
"""
|
|
import logging
|
|
from contextlib import contextmanager
|
|
from datetime import datetime, timezone
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
import psycopg2
|
|
import psycopg2.extras
|
|
|
|
from .config import DATABASE_URL
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
@contextmanager
|
|
def get_conn():
|
|
conn = psycopg2.connect(DATABASE_URL)
|
|
try:
|
|
yield conn
|
|
conn.commit()
|
|
except Exception:
|
|
conn.rollback()
|
|
raise
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def now_utc() -> datetime:
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Bands
|
|
# ------------------------------------------------------------------
|
|
|
|
def upsert_bands(bands: List[Dict[str, Any]]) -> Dict[str, int]:
|
|
"""
|
|
Upsert en bulk. Préserve les champs existants si la nouvelle valeur est None.
|
|
Retourne {"inserted": N, "updated": N}.
|
|
"""
|
|
if not bands:
|
|
return {"inserted": 0, "updated": 0}
|
|
|
|
ts = now_utc()
|
|
rows = []
|
|
for b in bands:
|
|
rows.append((
|
|
b["ma_id"],
|
|
b.get("name") or "",
|
|
b.get("country"),
|
|
b.get("location_text"),
|
|
b.get("status"),
|
|
b.get("genre"),
|
|
b.get("formed_year"),
|
|
b.get("themes"),
|
|
b.get("enriched", False),
|
|
b.get("crawled_at"),
|
|
b.get("crawled_hash"),
|
|
b.get("ma_created_at"),
|
|
b.get("ma_modified_at"),
|
|
ts, # first_seen_at (ignoré si déjà set)
|
|
ts, # created_at (ignoré si déjà set)
|
|
))
|
|
|
|
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)
|
|
VALUES %s
|
|
ON CONFLICT (ma_id) DO UPDATE SET
|
|
name = COALESCE(EXCLUDED.name, bands.name),
|
|
country = COALESCE(EXCLUDED.country, bands.country),
|
|
location_text = COALESCE(EXCLUDED.location_text, bands.location_text),
|
|
status = COALESCE(EXCLUDED.status, bands.status),
|
|
genre = COALESCE(EXCLUDED.genre, bands.genre),
|
|
formed_year = COALESCE(EXCLUDED.formed_year, bands.formed_year),
|
|
themes = COALESCE(EXCLUDED.themes, bands.themes),
|
|
enriched = GREATEST(EXCLUDED.enriched, bands.enriched),
|
|
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)
|
|
RETURNING (xmax = 0) AS was_inserted
|
|
"""
|
|
|
|
with get_conn() as conn:
|
|
with conn.cursor() as cur:
|
|
results = psycopg2.extras.execute_values(cur, sql, rows, fetch=True)
|
|
inserted = sum(1 for r in results if r[0])
|
|
updated = len(results) - inserted
|
|
|
|
log.info(f"[db] upsert_bands: {inserted} inserted, {updated} updated")
|
|
return {"inserted": inserted, "updated": updated}
|
|
|
|
|
|
def upsert_band_enriched(ma_id: int, data: Dict[str, Any], html_hash: str) -> bool:
|
|
"""Met à jour les champs d'enrichissement d'un band."""
|
|
sql = """
|
|
UPDATE bands SET
|
|
status = COALESCE($2, status),
|
|
genre = COALESCE($3, genre),
|
|
themes = COALESCE($4, themes),
|
|
formed_year = COALESCE($5, formed_year),
|
|
data = data || $6::jsonb,
|
|
enriched = true,
|
|
crawled_at = $7,
|
|
crawled_hash = $8,
|
|
ma_created_at = COALESCE($9, ma_created_at),
|
|
ma_modified_at= COALESCE($10, ma_modified_at)
|
|
WHERE ma_id = $1
|
|
"""
|
|
ts = now_utc()
|
|
with get_conn() as conn:
|
|
with conn.cursor() as cur:
|
|
import json
|
|
cur.execute(sql, (
|
|
ma_id,
|
|
data.get("status"),
|
|
data.get("genre"),
|
|
data.get("themes"),
|
|
_parse_year(data.get("formed_in")),
|
|
json.dumps({"band_page": data, "enriched_at": ts.isoformat()}),
|
|
ts,
|
|
html_hash,
|
|
data.get("ma_created_at"),
|
|
data.get("ma_modified_at"),
|
|
))
|
|
return cur.rowcount > 0
|
|
|
|
|
|
def get_bands_to_enrich(country: Optional[str] = None, limit: int = 50) -> List[Dict]:
|
|
"""Bands qui n'ont jamais été enrichies (data->'band_page' IS NULL)."""
|
|
sql = """
|
|
SELECT ma_id, data->>'url' AS url, name, country
|
|
FROM bands
|
|
WHERE data->'band_page' IS NULL
|
|
AND data->>'url' IS NOT NULL
|
|
"""
|
|
params = []
|
|
if country:
|
|
sql += " AND country = %s"
|
|
params.append(country)
|
|
sql += " ORDER BY first_seen_at ASC NULLS LAST LIMIT %s"
|
|
params.append(limit)
|
|
|
|
with get_conn() as conn:
|
|
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
|
cur.execute(sql, params)
|
|
return [dict(r) for r in cur.fetchall()]
|
|
|
|
|
|
def get_bands_stale(hours: int = 24 * 30, limit: int = 100) -> List[Dict]:
|
|
"""Bands enrichies il y a plus de N heures (pour re-crawl périodique)."""
|
|
sql = """
|
|
SELECT ma_id, data->>'url' AS url, name, country
|
|
FROM bands
|
|
WHERE enriched = true
|
|
AND (crawled_at IS NULL OR crawled_at < now() - make_interval(hours => %s))
|
|
ORDER BY crawled_at ASC NULLS FIRST
|
|
LIMIT %s
|
|
"""
|
|
with get_conn() as conn:
|
|
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
|
cur.execute(sql, (hours, limit))
|
|
return [dict(r) for r in cur.fetchall()]
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Crawl run tracking
|
|
# ------------------------------------------------------------------
|
|
|
|
def start_crawl_run(run_type: str, countries: Optional[List[str]] = None) -> int:
|
|
sql = "INSERT INTO crawl_run (run_type, countries) VALUES (%s, %s) RETURNING id"
|
|
with get_conn() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(sql, (run_type, countries))
|
|
return cur.fetchone()[0]
|
|
|
|
|
|
def finish_crawl_run(run_id: int, stats: Dict[str, int], error: Optional[str] = None):
|
|
status = "error" if error else "done"
|
|
sql = """
|
|
UPDATE crawl_run SET
|
|
status = %s, finished_at = now(),
|
|
bands_seen = %s, bands_new = %s, bands_updated = %s, bands_enriched = %s,
|
|
error = %s
|
|
WHERE id = %s
|
|
"""
|
|
with get_conn() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(sql, (
|
|
status,
|
|
stats.get("seen", 0), stats.get("new", 0),
|
|
stats.get("updated", 0), stats.get("enriched", 0),
|
|
error, run_id,
|
|
))
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Checkpoints
|
|
# ------------------------------------------------------------------
|
|
|
|
def get_checkpoint(key: str) -> Optional[str]:
|
|
with get_conn() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute("SELECT value FROM crawl_checkpoint WHERE key = %s", (key,))
|
|
row = cur.fetchone()
|
|
return row[0] if row else None
|
|
|
|
|
|
def set_checkpoint(key: str, value: str):
|
|
sql = """
|
|
INSERT INTO crawl_checkpoint (key, value, updated_at)
|
|
VALUES (%s, %s, now())
|
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()
|
|
"""
|
|
with get_conn() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(sql, (key, value))
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Helpers
|
|
# ------------------------------------------------------------------
|
|
|
|
def _parse_year(s: Optional[str]) -> Optional[int]:
|
|
if not s:
|
|
return None
|
|
m = __import__("re").search(r"\b(1[89]\d\d|20\d\d)\b", str(s))
|
|
return int(m.group(1)) if m else None
|