Repository Public
1#!/usr/bin/env python32# -----------------------------------------------------------------------------3# Sorti-Ka — Agrégateur de sorties & événements (province de Québec)4# Auteur : Simon-Pierre Boucher — contact@spboucher.ai5# scripts/gen_connector_docs.py : générateur REJOUABLE de la documentation6# standardisée des connecteurs → docs/connecteurs/INDEX.md + une fiche par7# source. Croise trois vérités :8# 1. le registre data/sources.json (description, licence, cadence, statut)9# 2. l'introspection statique des connecteurs sortika/connectors/*.py10# (endpoints, pagination, backend, plafonds env, en-tête documentaire)11# 3. la base LIVE data/sortika.db (volumétrie, complétude par champ,12# exemples réels, dernier sync, erreurs sync_log)13# + l'historique git de chaque connecteur.14#15# Usage : python3 scripts/gen_connector_docs.py16# (aucune dépendance hors stdlib ; la BD est ouverte en lecture seule)17# -----------------------------------------------------------------------------18from __future__ import annotations1920import ast21import json22import re23import sqlite324import subprocess25import sys26from datetime import datetime, timezone27from pathlib import Path2829ROOT = Path(__file__).resolve().parents[1]30SOURCES_JSON = ROOT / "data" / "sources.json"31DB_PATH = ROOT / "data" / "sortika.db"32CONNECTORS_DIR = ROOT / "sortika" / "connectors"33OUT_DIR = ROOT / "docs" / "connecteurs"3435NOW = datetime.now().astimezone()36STAMP = NOW.strftime("%Y-%m-%d %H:%M %Z")3738# Champs documentés dans le tableau « Champs récupérés → schéma » de chaque39# fiche : (colonne SQL, libellé, description courte).40SCHEMA_FIELDS = [41 ("title", "Titre", "titre de l'événement"),42 ("description", "Description", "texte descriptif (source ou fiche détail)"),43 ("url", "URL", "lien vers la fiche originale / billetterie"),44 ("categories", "Catégories", "taxonomie canonique Sorti-Ka (JSON)"),45 ("venue", "Salle (venue)","nom du lieu"),46 ("address", "Adresse", "adresse civique"),47 ("city", "Ville", "municipalité"),48 ("region", "Région", "région administrative"),49 ("lat", "GPS (lat/lng)","coordonnées géographiques"),50 ("start_date", "Date début", "date locale AAAA-MM-JJ"),51 ("end_date", "Date fin", "date locale AAAA-MM-JJ"),52 ("start_time", "Heure (start_time)", "heure locale HH:MM (Québec)"),53 ("is_free", "Gratuité (is_free)", "1 gratuit / 0 payant / NULL inconnu"),54 ("price_min", "Prix min", "tarif minimal réel ($)"),55 ("price_label", "Libellé prix", "étiquette tarifaire affichable"),56 ("artists", "Artistes", "têtes d'affiche / lineup (JSON)"),57 ("organizer", "Organisateur", "promoteur / organisme"),58 ("image", "Image", "visuel (og:image ou média source)"),59 ("website", "Site web", "site officiel de l'événement"),60]6162# Complétude « clé » suivie dans l'INDEX et la section Volumétrie (SQL, libellé).63KEY_METRICS = [64 ("lat IS NOT NULL AND lng IS NOT NULL", "GPS"),65 ("(is_free IS NOT NULL OR price_min IS NOT NULL OR (price_label IS NOT NULL AND price_label != ''))", "Prix"),66 ("start_time IS NOT NULL AND start_time != ''", "Heure (start_time)"),67 ("artists IS NOT NULL AND artists NOT IN ('', '[]')", "Artistes"),68 ("image IS NOT NULL AND image != ''", "Image"),69 ("venue IS NOT NULL AND venue != ''", "Salle (venue)"),70]7172# Attributions REQUISES par licence (rappelées mot à mot dans les fiches).73ATTRIBUTIONS = {74 "montreal": ("**Mention requise (CC BY 4.0)** : « Contient des données de "75 "la **Ville de Montréal**, sous licence CC BY 4.0 "76 "(donnees.montreal.ca). » L'attribution doit accompagner "77 "toute réutilisation publique des événements de cette source."),78 "sitq": ("**Mention requise (CC 4.0 — Données Québec)** : « Données du "79 "**SIT Québec — Ministère du Tourisme**, diffusées sous "80 "licence Creative Commons 4.0 via Données Québec. » "81 "L'attribution au Ministère du Tourisme est obligatoire."),82 "laval": ("**Mention requise (CC 4.0 — Données Québec)** : « Données de "83 "la **Ville de Laval** (Calendrier des activités), diffusées "84 "sous licence Creative Commons 4.0 via Données Québec. » "85 "L'attribution à la Ville de Laval est obligatoire."),86}8788# Notes de dépannage spécifiques (complètent les erreurs sync_log live).89DEPANNAGE = {90 "montreal": ["Le WAF de donnees.montreal.ca renvoie **403** à tout "91 "User-Agent contenant « bot » — le UA `SortiKa/1.0 (+URL; "92 "courriel)` est volontairement sans ce mot. Un 403 ponctuel "93 "est en général transitoire (WAF) ; vérifier le UA avant tout."],94 "atuvu": ["Source derrière anti-bot/JS lourd → backend **Scrapfly** "95 "requis : vérifier `SCRAPFLY_API_KEY` dans `.env` et le solde "96 "de crédits Scrapfly si le connecteur ne rapporte plus rien."],97 "ticketmaster": ["Sans `TICKETMASTER_API_KEY` dans `.env`, le connecteur "98 "s'annonce « clé manquante » et ne retourne rien (statut "99 "« prêt — clé requise »). Quota API : 5 req/s (throttle "100 "0,25 s côté connecteur)."],101 "bandsintown": ["L'API n'accepte que certains formats d'`app_id` : le "102 "format widget `js_www.sorti-ka.com` est testé accepté "103 "(2026-08) ; un app_id arbitraire simple est refusé."],104 "lepointdevente": ["L'enrichissement par fiches détail est incrémental "105 "(cache `data/lepointdevente_cache.json`, plafond "106 "`LPDV_DETAIL_MAX`/sync, re-visite 7 j) : la complétude "107 "description/heure/prix monte au fil des cycles."],108 "lavitrine": ["Parcours par sitemap incrémental (`LV_MAX_FETCH` fiches/"109 "sync) : après un reset de cache, plusieurs cycles sont "110 "nécessaires pour recouvrir tout le catalogue."],111}112113WAVE_HISTORY = (114 "- **2026-08-18 — Vague 1 d'enrichissement** (commit `7d9d1b0`) : "115 "géocodage Nominatim des salles (`sortika/geocode.py`, cache disque, "116 "plafond `SORTIKA_GEOCODE_MAX`), fiches détail lepointdevente "117 "(description, heure, tarifs réels `/plugins/rates/<id>`), contacts "118 "Tourinsoft (SITQ), heure locale Montréal via JSON-LD.\n"119 "- **2026-08-18 — Vague 2 d'enrichissement** (commit `65d2d33`) : "120 "colonnes additives `events.start_time` (HH:MM locale QC) et "121 "`events.artists` (JSON) + migration auto ; heure captée partout où la "122 "source la publie (lepointdevente, evenko, Laval, Sherbrooke, Montréal, "123 "atuvu) ; artistes (headliners evenko, lineup Bandsintown, attractions "124 "Ticketmaster, patrons sûrs sur les titres) ; 3 nouveaux connecteurs "125 "(brossard, ticketmaster, bandsintown)."126)127128129# ---------------------------------------------------------------------------130# Introspection statique d'un connecteur131# ---------------------------------------------------------------------------132133def introspect(path: Path) -> dict:134 text = path.read_text(encoding="utf-8")135 info: dict = {"file": path.name, "endpoints": [], "env": [], "header": [],136 "pagination": "", "backend": "", "request_delay": None}137138 # En-tête documentaire (bloc de commentaires en tête de fichier).139 for line in text.splitlines():140 s = line.rstrip()141 if s.startswith("#"):142 body = s.lstrip("#").strip()143 if set(body) <= {"-"}: # séparateurs -----144 continue145 info["header"].append(body)146 elif s.startswith(("from ", "import ")) or not s:147 if info["header"]:148 break149 else:150 break151 # Retire le tri-boilerplate commun (nom du projet, auteur, nom du fichier).152 info["header"] = [h for h in info["header"]153 if not h.startswith(("Sorti-Ka —", "Auteur :"))]154155 # Constantes URL au niveau module (ast gère la concaténation implicite).156 try:157 tree = ast.parse(text)158 for node in tree.body:159 if isinstance(node, ast.Assign) and len(node.targets) == 1 \160 and isinstance(node.targets[0], ast.Name):161 name = node.targets[0].id162 val = node.value163 if isinstance(val, ast.Constant) and isinstance(val.value, str) \164 and val.value.startswith("http"):165 info["endpoints"].append((name, val.value))166 except SyntaxError:167 pass168169 # Plafonds / clés env : VAR = int(os.environ.get("X", "def")) # commentaire170 env_re = re.compile(171 r'(?:os\.environ|__import__\("os"\)\.environ)\.get\(\s*"([A-Z_]+)"\s*'172 r'(?:,\s*"([^"]*)")?\s*\)[^\n#]*(?:#\s*(.*))?')173 for m in env_re.finditer(text):174 var, default, comment = m.group(1), m.group(2), (m.group(3) or "").strip()175 info["env"].append((var, default if default is not None else "—", comment))176177 # Backend réseau.178 low = text.lower()179 if "scrapfly" in low:180 info["backend"] = "Scrapfly (anti-bot / rendu JS) via `base.get_scrapfly()`"181 else:182 info["backend"] = "GET direct `requests` (throttlé, UA `SortiKa/1.0` identifié)"183184 # Pagination (heuristique sur le texte + l'en-tête).185 hints = []186 if "offset" in low:187 hints.append("offset/limit")188 if re.search(r'page["\']?\s*[:=]|page=\{page\}|\?page=', text):189 hints.append("par numéro de page")190 if "sitemap" in low:191 hints.append("parcours de sitemap XML")192 if "per_page" in low:193 hints.append("per_page")194 info["pagination"] = ", ".join(dict.fromkeys(hints)) or "aucune (flux unique)"195196 m = re.search(r"request_delay\s*[:=]\s*(?:float\s*=\s*)?([\d.]+)", text)197 if m:198 info["request_delay"] = m.group(1)199200 return info201202203# ---------------------------------------------------------------------------204# Base live205# ---------------------------------------------------------------------------206207def db_stats(cur: sqlite3.Cursor, sid: str) -> dict:208 row = cur.execute(209 "SELECT COUNT(*), COALESCE(SUM(active),0) FROM events WHERE source=?",210 (sid,)).fetchone()211 total, active = row212 metrics = []213 for cond, label in KEY_METRICS:214 n = cur.execute(215 f"SELECT COUNT(*) FROM events WHERE source=? AND active=1 AND ({cond})",216 (sid,)).fetchone()[0]217 metrics.append((label, n))218 # Complétude par champ du schéma (sur les événements actifs).219 per_field = {}220 for col, _label, _desc in SCHEMA_FIELDS:221 if col == "lat":222 cond = "lat IS NOT NULL AND lng IS NOT NULL"223 elif col in ("categories", "artists"):224 cond = f"{col} IS NOT NULL AND {col} NOT IN ('', '[]')"225 elif col == "is_free":226 cond = "is_free IS NOT NULL"227 elif col == "price_min":228 cond = "price_min IS NOT NULL"229 else:230 cond = f"{col} IS NOT NULL AND CAST({col} AS TEXT) != ''"231 per_field[col] = cur.execute(232 f"SELECT COUNT(*) FROM events WHERE source=? AND active=1 AND ({cond})",233 (sid,)).fetchone()[0]234235 # Exemple réel : l'événement actif le plus « complet », le plus récent.236 score = " + ".join(f"(CASE WHEN {c} THEN 1 ELSE 0 END)" for c, _ in KEY_METRICS)237 ex = cur.execute(238 f"SELECT * FROM events WHERE source=? AND active=1 "239 f"ORDER BY ({score}) DESC, updated_at DESC LIMIT 1", (sid,)).fetchone()240 example = dict(ex) if ex else {}241242 # sync_log : dernier sync, compteurs, erreurs.243 last = cur.execute(244 "SELECT ts, fetched, added, updated, removed, error FROM sync_log "245 "WHERE source=? ORDER BY ts DESC LIMIT 1", (sid,)).fetchone()246 nsync = cur.execute("SELECT COUNT(*) FROM sync_log WHERE source=?",247 (sid,)).fetchone()[0]248 errors = cur.execute(249 "SELECT error, COUNT(*), MAX(ts) FROM sync_log WHERE source=? AND "250 "error IS NOT NULL GROUP BY error ORDER BY MAX(ts) DESC LIMIT 5",251 (sid,)).fetchall()252 return {"total": total, "active": active, "metrics": metrics,253 "per_field": per_field, "example": example,254 "last_sync": dict(last) if last else None, "nsync": nsync,255 "errors": [dict(e) for e in errors] if errors else256 []}257258259def fmt_ts(ts: float | None) -> str:260 if not ts:261 return "n/d"262 return datetime.fromtimestamp(ts, tz=timezone.utc).astimezone() \263 .strftime("%Y-%m-%d %H:%M")264265266def pct(n: int, d: int) -> str:267 return f"{100.0 * n / d:.0f} %" if d else "—"268269270def md_escape(v) -> str:271 if v is None:272 return "∅"273 s = str(v).replace("\n", " ").replace("|", "\\|").strip()274 return (s[:96] + "…") if len(s) > 96 else (s or "∅")275276277def git_history(path: Path) -> list[str]:278 try:279 out = subprocess.run(280 ["git", "log", "--follow", "--date=short",281 "--format=%h %ad %s", "--", str(path.relative_to(ROOT))],282 cwd=ROOT, capture_output=True, text=True, timeout=30).stdout283 return out.strip().splitlines()[:12]284 except Exception:285 return []286287288# ---------------------------------------------------------------------------289# Rendu des fiches290# ---------------------------------------------------------------------------291292def render_fiche(src: dict, intro: dict | None, stats: dict | None) -> str:293 sid = src["id"]294 L: list[str] = []295 L.append(f"# {src['name']} (`{sid}`)")296 L.append("")297 L.append(f"> Fiche générée le {STAMP} par `scripts/gen_connector_docs.py` "298 f"— ne pas éditer à la main, relancer le script.")299 L.append("")300 L.append(f"**Statut : {src.get('statut', 'n/d')}** · Type : "301 f"`{src.get('type', 'n/d')}`" +302 (f" · Connecteur : `sortika/connectors/{intro['file']}`" if intro303 else " · Connecteur : *aucun (source sans connecteur runtime)*"))304 L.append("")305306 # 1. Description --------------------------------------------------------307 L.append("## Description")308 L.append("")309 L.append(f"- **Source** : [{src['name']}]({src['url']})")310 L.append(f"- **Couverture** : {src.get('couverture', 'n/d')}")311 L.append(f"- **Extraction** : {src.get('extraction', 'n/d')}")312 if src.get("note"):313 L.append(f"- **Note** : {src['note']}")314 L.append("")315316 # 2. Accès ---------------------------------------------------------------317 L.append("## Accès")318 L.append("")319 L.append(f"- **Type** : {src.get('type', 'n/d')}")320 if intro and intro["endpoints"]:321 for name, url in intro["endpoints"]:322 L.append(f"- **Endpoint** (`{name}`) : `{url}`")323 else:324 L.append(f"- **Endpoint** : voir URL source ({src['url']})")325 auth = "Aucune (accès public, identification honnête par User-Agent)"326 if sid == "ticketmaster":327 auth = "Clé API gratuite `TICKETMASTER_API_KEY` (developer.ticketmaster.com)"328 elif sid == "bandsintown":329 auth = "`app_id` d'identification (`BANDSINTOWN_APP_ID`, format widget accepté)"330 elif intro and "Scrapfly" in intro["backend"]:331 auth = "Aucune côté source ; `SCRAPFLY_API_KEY` requis pour le backend Scrapfly"332 L.append(f"- **Auth** : {auth}")333 if intro:334 L.append(f"- **Pagination** : {intro['pagination']}")335 L.append(f"- **Backend** : {intro['backend']}")336 if intro["request_delay"]:337 L.append(f"- **Throttle** : {intro['request_delay']} s entre requêtes")338 L.append(f"- **Accès légal** : {src.get('acces_legal', 'n/d')}")339 if intro["header"]:340 L.append("")341 L.append("<details><summary>En-tête documentaire du connecteur</summary>")342 L.append("")343 for h in intro["header"]:344 L.append(f"> {h}")345 L.append("")346 L.append("</details>")347 else:348 L.append(f"- **Accès légal** : {src.get('acces_legal', 'n/d')}")349 L.append("")350351 # 3. Champs récupérés → schéma -------------------------------------------352 L.append("## Champs récupérés → schéma")353 L.append("")354 if stats and stats["active"]:355 ex = stats["example"]356 L.append(f"Complétude mesurée sur les **{stats['active']} événements "357 f"actifs** de la source ; exemples tirés d'un événement réel "358 f"(`{md_escape(ex.get('uid'))}`).")359 L.append("")360 L.append("| Champ (`events.*`) | Description | Complétude | Exemple réel |")361 L.append("|---|---|---|---|")362 for col, label, desc in SCHEMA_FIELDS:363 n = stats["per_field"][col]364 if col == "lat":365 val = (f"{ex.get('lat')}, {ex.get('lng')}"366 if ex.get("lat") is not None else None)367 else:368 val = ex.get(col)369 L.append(f"| `{col}` — {label} | {desc} | "370 f"{pct(n, stats['active'])} | {md_escape(val)} |")371 else:372 L.append("*Aucun événement actif en base pour cette source — pas de "373 "schéma live à montrer.*")374 L.append("")375376 # 4. Fréquence & budgets ---------------------------------------------------377 L.append("## Fréquence & budgets")378 L.append("")379 L.append(f"- **Cadence** : {src.get('cadence', 'n/d')}")380 if intro and intro["env"]:381 L.append("- **Budgets / plafonds (variables d'environnement)** :")382 L.append("")383 L.append("| Variable | Défaut | Rôle |")384 L.append("|---|---|---|")385 for var, default, comment in intro["env"]:386 L.append(f"| `{var}` | `{default}` | {comment or '—'} |")387 L.append("- **Budget global** : géocodage Nominatim plafonné à "388 "`SORTIKA_GEOCODE_MAX` (défaut 120) requêtes réseau/cycle, "389 "toutes sources confondues (`sortika/geocode.py`, cache disque).")390 L.append("")391392 # 5. Volumétrie & complétude live ------------------------------------------393 L.append("## Volumétrie & complétude live")394 L.append("")395 if stats:396 L.append(f"*(mesuré le {STAMP} dans `data/sortika.db`)*")397 L.append("")398 L.append(f"- **Événements en base** : {stats['total']} "399 f"(dont **{stats['active']} actifs**)")400 for label, n in stats["metrics"]:401 L.append(f"- **{label}** : {n}/{stats['active']} actifs "402 f"({pct(n, stats['active'])})")403 ls = stats["last_sync"]404 if ls:405 L.append(f"- **Dernier sync** : {fmt_ts(ls['ts'])} — "406 f"fetched {ls['fetched']}, +{ls['added']} ajoutés, "407 f"~{ls['updated']} mis à jour, -{ls['removed']} retirés"408 + (f", **erreur : {md_escape(ls['error'])}**"409 if ls["error"] else ""))410 L.append(f"- **Syncs enregistrés** (sync_log) : {stats['nsync']}")411 else:412 L.append("*Source sans connecteur runtime : aucune donnée en base.*")413 L.append("")414415 # 6. Erreurs & dépannage -----------------------------------------------------416 L.append("## Erreurs & dépannage")417 L.append("")418 if stats and stats["errors"]:419 L.append("Erreurs relevées dans `sync_log` :")420 L.append("")421 L.append("| Erreur | Occurrences | Dernière fois |")422 L.append("|---|---|---|")423 for e in stats["errors"]:424 err, cnt, ts = list(e.values())425 L.append(f"| {md_escape(err)} | {cnt} | {fmt_ts(ts)} |")426 L.append("")427 elif stats:428 L.append("Aucune erreur dans `sync_log` pour cette source. ✅")429 L.append("")430 for tip in DEPANNAGE.get(sid, []):431 L.append(f"- {tip}")432 if intro:433 L.append("- Rejouer un sync isolé : `python3 run.py sync --source "434 f"{sid}` (ou via le watcher PM2) ; inspecter ensuite "435 "`sync_log` : `sqlite3 data/sortika.db \"SELECT * FROM "436 f"sync_log WHERE source='{sid}' ORDER BY ts DESC LIMIT 5;\"`")437 L.append("")438439 # 7. Licence / attribution ------------------------------------------------------440 L.append("## Licence / attribution")441 L.append("")442 L.append(f"- {src.get('acces_legal', 'n/d')}")443 if sid in ATTRIBUTIONS:444 L.append("")445 L.append(ATTRIBUTIONS[sid])446 L.append("")447448 # 8. Historique -------------------------------------------------------------------449 L.append("## Historique")450 L.append("")451 if src.get("statut") == "abandonnee":452 L.append(f"- **Source abandonnée** — {src.get('note', 'sans note')}")453 L.append(WAVE_HISTORY)454 if intro:455 hist = git_history(CONNECTORS_DIR / intro["file"])456 if hist:457 L.append("")458 L.append(f"Commits touchant `sortika/connectors/{intro['file']}` :")459 L.append("")460 for h in hist:461 L.append(f"- `{h.split(' ', 1)[0]}` {h.split(' ', 1)[1]}")462 L.append("")463 return "\n".join(L)464465466# ---------------------------------------------------------------------------467# INDEX468# ---------------------------------------------------------------------------469470def render_index(rows: list[dict]) -> str:471 L: list[str] = []472 L.append("# Sorti-Ka — Connecteurs de sources d'événements")473 L.append("")474 L.append(f"> Index généré le {STAMP} par `scripts/gen_connector_docs.py` "475 f"(rejouable : `python3 scripts/gen_connector_docs.py`). "476 f"Croise `data/sources.json`, l'introspection de "477 f"`sortika/connectors/*.py` et la base live `data/sortika.db`.")478 L.append("")479 actives = [r for r in rows if r["src"].get("statut") != "abandonnee"]480 abandon = [r for r in rows if r["src"].get("statut") == "abandonnee"]481 tot = sum(r["stats"]["total"] for r in rows if r["stats"])482 act = sum(r["stats"]["active"] for r in rows if r["stats"])483 L.append(f"**{len(rows)} sources au registre** — {len(actives)} "484 f"actives/prêtes, {len(abandon)} abandonnées · "485 f"**{tot} événements en base** (dont {act} actifs).")486 L.append("")487 L.append("⚠️ **Attributions requises** : Ville de Montréal (CC BY 4.0) ; "488 "SIT Québec / Ministère du Tourisme et Ville de Laval "489 "(CC 4.0 — Données Québec). Détails dans chaque fiche, section "490 "« Licence / attribution ».")491 L.append("")492 L.append("## Sources actives / prêtes")493 L.append("")494 L.append("| Source | Type | Statut | Actifs | GPS | Prix | Heure | "495 "Artistes | Image | Salle | Dernier sync |")496 L.append("|---|---|---|---|---|---|---|---|---|---|---|")497 for r in actives:498 s, st = r["src"], r["stats"]499 if st and st["active"]:500 cells = [pct(n, st["active"]) for _, n in st["metrics"]]501 last = fmt_ts(st["last_sync"]["ts"]) if st["last_sync"] else "n/d"502 L.append(f"| [{s['name']}]({s['id']}.md) | {s['type']} | "503 f"{s['statut']} | {st['active']} | " + " | ".join(cells) +504 f" | {last} |")505 else:506 last = (fmt_ts(st["last_sync"]["ts"])507 if st and st["last_sync"] else "n/d")508 L.append(f"| [{s['name']}]({s['id']}.md) | {s['type']} | "509 f"{s['statut']} | 0 | — | — | — | — | — | — | {last} |")510 L.append("")511 L.append("## Sources abandonnées")512 L.append("")513 L.append("| Source | Type | Raison |")514 L.append("|---|---|---|")515 for r in abandon:516 s = r["src"]517 L.append(f"| [{s['name']}]({s['id']}.md) | {s['type']} | "518 f"{md_escape(s.get('note', 'n/d'))} |")519 L.append("")520 L.append("## Régénérer cette documentation")521 L.append("")522 L.append("```bash")523 L.append("cd ~/apps/sorti-ka && python3 scripts/gen_connector_docs.py")524 L.append("```")525 L.append("")526 return "\n".join(L)527528529# ---------------------------------------------------------------------------530531def main() -> int:532 sources = json.loads(SOURCES_JSON.read_text(encoding="utf-8"))["sources"]533 con = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True)534 con.row_factory = sqlite3.Row535 cur = con.cursor()536 OUT_DIR.mkdir(parents=True, exist_ok=True)537538 rows = []539 for src in sources:540 sid = src["id"]541 cpath = CONNECTORS_DIR / f"{sid.replace('-', '_')}.py"542 intro = introspect(cpath) if cpath.exists() else None543 has_data = cur.execute(544 "SELECT COUNT(*) FROM events WHERE source=?", (sid,)).fetchone()[0]545 has_log = cur.execute(546 "SELECT COUNT(*) FROM sync_log WHERE source=?", (sid,)).fetchone()[0]547 stats = db_stats(cur, sid) if (intro or has_data or has_log) else None548 fiche = render_fiche(src, intro, stats)549 (OUT_DIR / f"{sid}.md").write_text(fiche, encoding="utf-8")550 rows.append({"src": src, "intro": intro, "stats": stats})551 print(f" fiche : docs/connecteurs/{sid}.md"552 + (f" ({stats['active']} actifs)" if stats else " (sans données)"))553554 (OUT_DIR / "INDEX.md").write_text(render_index(rows), encoding="utf-8")555 print(f" index : docs/connecteurs/INDEX.md ({len(rows)} sources)")556 con.close()557 return 0558559560if __name__ == "__main__":561 sys.exit(main())562