SPB Git forge

spb/sorti-ka

Public

Toutes les sorties et tous les événements du Québec, un seul endroit — 7 connecteurs, fiches SSR, design Groupe KA.

58commits 1branches 0releases
13.7 MBsize
maindefault branch
17 days agolast push
HTML 82.9% Python 15.2% TypeScript 0.9% JavaScript 0.7%
26.9 KB · 583 lines python
Raw Blame History
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    ("end_time",    "Heure de fin (end_time)", "heure locale HH:MM (Québec)"),54    ("status",      "Statut",       "cancelled / postponed / soldout (NULL = prévu)"),55    ("is_free",     "Gratuité (is_free)", "1 gratuit / 0 payant / NULL inconnu"),56    ("price_min",   "Prix min",     "tarif minimal réel ($)"),57    ("price_label", "Libellé prix", "étiquette tarifaire affichable"),58    ("artists",     "Artistes",     "têtes d'affiche / lineup (JSON)"),59    ("organizer",   "Organisateur", "promoteur / organisme"),60    ("image",       "Image",        "visuel (og:image ou média source)"),61    ("website",     "Site web",     "site officiel de l'événement"),62]6364# Complétude « clé » suivie dans l'INDEX et la section Volumétrie (SQL, libellé).65KEY_METRICS = [66    ("lat IS NOT NULL AND lng IS NOT NULL",                         "GPS"),67    ("(is_free IS NOT NULL OR price_min IS NOT NULL OR (price_label IS NOT NULL AND price_label != ''))", "Prix"),68    ("start_time IS NOT NULL AND start_time != ''",                 "Heure (start_time)"),69    ("end_time IS NOT NULL AND end_time != ''",                     "Heure de fin"),70    ("status IS NOT NULL AND status != ''",                         "Statut (annulé/reporté/complet)"),71    ("artists IS NOT NULL AND artists NOT IN ('', '[]')",           "Artistes"),72    ("image IS NOT NULL AND image != ''",                           "Image"),73    ("venue IS NOT NULL AND venue != ''",                           "Salle (venue)"),74]7576# Attributions REQUISES par licence (rappelées mot à mot dans les fiches).77ATTRIBUTIONS = {78    "montreal": ("**Mention requise (CC BY 4.0)** : « Contient des données de "79                 "la **Ville de Montréal**, sous licence CC BY 4.0 "80                 "(donnees.montreal.ca). » L'attribution doit accompagner "81                 "toute réutilisation publique des événements de cette source."),82    "sitq":     ("**Mention requise (CC 4.0 — Données Québec)** : « Données du "83                 "**SIT Québec — Ministère du Tourisme**, diffusées sous "84                 "licence Creative Commons 4.0 via Données Québec. » "85                 "L'attribution au Ministère du Tourisme est obligatoire."),86    "laval":    ("**Mention requise (CC 4.0 — Données Québec)** : « Données de "87                 "la **Ville de Laval** (Calendrier des activités), diffusées "88                 "sous licence Creative Commons 4.0 via Données Québec. » "89                 "L'attribution à la Ville de Laval est obligatoire."),90}9192# Notes de dépannage spécifiques (complètent les erreurs sync_log live).93DEPANNAGE = {94    "montreal": ["Le WAF de donnees.montreal.ca renvoie **403** à tout "95                 "User-Agent contenant « bot » — le UA `SortiKa/1.0 (+URL; "96                 "courriel)` est volontairement sans ce mot. Un 403 ponctuel "97                 "est en général transitoire (WAF) ; vérifier le UA avant tout."],98    "atuvu":    ["Source derrière anti-bot/JS lourd → backend **Scrapfly** "99                 "requis : vérifier `SCRAPFLY_API_KEY` dans `.env` et le solde "100                 "de crédits Scrapfly si le connecteur ne rapporte plus rien."],101    "ticketmaster": ["Sans `TICKETMASTER_API_KEY` dans `.env`, le connecteur "102                     "tourne en mode REPLI (scraping des pages découverte "103                     "publiques) : pas de prix, pas de statut, images = plans "104                     "de salle. La clé Discovery (gratuite, inscription "105                     "developer.ticketmaster.com) débloque priceRanges, "106                     "dates.status.code (annulé/reporté), visuels 16:9 HD et "107                     "attractions → artistes. Quota API : 5 req/s (throttle "108                     "0,25 s côté connecteur)."],109    "bandsintown": ["L'API n'accepte que certains formats d'`app_id` : le "110                    "format widget `js_www.sorti-ka.com` est testé accepté "111                    "(2026-08) ; un app_id arbitraire simple est refusé."],112    "lepointdevente": ["L'enrichissement par fiches détail est incrémental "113                       "(cache `data/lepointdevente_cache.json`, plafond "114                       "`LPDV_DETAIL_MAX`/sync, re-visite 7 j) : la complétude "115                       "description/heure/prix monte au fil des cycles."],116    "lavitrine": ["Parcours par sitemap incrémental (`LV_MAX_FETCH` fiches/"117                  "sync) : après un reset de cache, plusieurs cycles sont "118                  "nécessaires pour recouvrir tout le catalogue."],119}120121WAVE_HISTORY = (122    "- **2026-08-18 — Vague 1 d'enrichissement** (commit `7d9d1b0`) : "123    "géocodage Nominatim des salles (`sortika/geocode.py`, cache disque, "124    "plafond `SORTIKA_GEOCODE_MAX`), fiches détail lepointdevente "125    "(description, heure, tarifs réels `/plugins/rates/<id>`), contacts "126    "Tourinsoft (SITQ), heure locale Montréal via JSON-LD.\n"127    "- **2026-08-18 — Vague 2 d'enrichissement** (commit `65d2d33`) : "128    "colonnes additives `events.start_time` (HH:MM locale QC) et "129    "`events.artists` (JSON) + migration auto ; heure captée partout où la "130    "source la publie (lepointdevente, evenko, Laval, Sherbrooke, Montréal, "131    "atuvu) ; artistes (headliners evenko, lineup Bandsintown, attractions "132    "Ticketmaster, patrons sûrs sur les titres) ; 3 nouveaux connecteurs "133    "(brossard, ticketmaster, bandsintown).\n"134    "- **2026-08-19 — Phase 2 (travail de moine)** : colonnes additives "135    "`end_time`, `status` (annulé/reporté/complet) et `quarantine` (règles "136    "anti-aberrations, non publié mais réintégrable) ; règle d'ARCHIVAGE des "137    "événements terminés à chaque cycle (jamais supprimés) ; dedup_key "138    "inclut désormais l'heure (les séances multiples d'un même jour ne sont "139    "plus écrasées à l'affichage) ; alerte « 0 événement futur / chute "140    "> 80 % » par source (data/future_counts.json + /api/sources) ; heure et "141    "description La Vitrine ; end_time/organisateur/statut Eventbrite ; "142    "og:description Atuvu ; premières parties + portes + tournée evenko ; "143    "offres billets Bandsintown ; taxonomie type-event + description "144    "intégrale Brossard ; image Longueuil ; géocodage par ADRESSE CIVIQUE "145    "prioritaire ; villes normalisées au Répertoire MAMH "146    "(data/villes_canon.json) ; laval consignée « morte-a-la-source »."147)148149150# ---------------------------------------------------------------------------151# Introspection statique d'un connecteur152# ---------------------------------------------------------------------------153154def introspect(path: Path) -> dict:155    text = path.read_text(encoding="utf-8")156    info: dict = {"file": path.name, "endpoints": [], "env": [], "header": [],157                  "pagination": "", "backend": "", "request_delay": None}158159    # En-tête documentaire (bloc de commentaires en tête de fichier).160    for line in text.splitlines():161        s = line.rstrip()162        if s.startswith("#"):163            body = s.lstrip("#").strip()164            if set(body) <= {"-"}:          # séparateurs -----165                continue166            info["header"].append(body)167        elif s.startswith(("from ", "import ")) or not s:168            if info["header"]:169                break170        else:171            break172    # Retire le tri-boilerplate commun (nom du projet, auteur, nom du fichier).173    info["header"] = [h for h in info["header"]174                      if not h.startswith(("Sorti-Ka —", "Auteur :"))]175176    # Constantes URL au niveau module (ast gère la concaténation implicite).177    try:178        tree = ast.parse(text)179        for node in tree.body:180            if isinstance(node, ast.Assign) and len(node.targets) == 1 \181                    and isinstance(node.targets[0], ast.Name):182                name = node.targets[0].id183                val = node.value184                if isinstance(val, ast.Constant) and isinstance(val.value, str) \185                        and val.value.startswith("http"):186                    info["endpoints"].append((name, val.value))187    except SyntaxError:188        pass189190    # Plafonds / clés env : VAR = int(os.environ.get("X", "def"))  # commentaire191    env_re = re.compile(192        r'(?:os\.environ|__import__\("os"\)\.environ)\.get\(\s*"([A-Z_]+)"\s*'193        r'(?:,\s*"([^"]*)")?\s*\)[^\n#]*(?:#\s*(.*))?')194    for m in env_re.finditer(text):195        var, default, comment = m.group(1), m.group(2), (m.group(3) or "").strip()196        info["env"].append((var, default if default is not None else "—", comment))197198    # Backend réseau.199    low = text.lower()200    if "scrapfly" in low:201        info["backend"] = "Scrapfly (anti-bot / rendu JS) via `base.get_scrapfly()`"202    else:203        info["backend"] = "GET direct `requests` (throttlé, UA `SortiKa/1.0` identifié)"204205    # Pagination (heuristique sur le texte + l'en-tête).206    hints = []207    if "offset" in low:208        hints.append("offset/limit")209    if re.search(r'page["\']?\s*[:=]|page=\{page\}|\?page=', text):210        hints.append("par numéro de page")211    if "sitemap" in low:212        hints.append("parcours de sitemap XML")213    if "per_page" in low:214        hints.append("per_page")215    info["pagination"] = ", ".join(dict.fromkeys(hints)) or "aucune (flux unique)"216217    m = re.search(r"request_delay\s*[:=]\s*(?:float\s*=\s*)?([\d.]+)", text)218    if m:219        info["request_delay"] = m.group(1)220221    return info222223224# ---------------------------------------------------------------------------225# Base live226# ---------------------------------------------------------------------------227228def db_stats(cur: sqlite3.Cursor, sid: str) -> dict:229    row = cur.execute(230        "SELECT COUNT(*), COALESCE(SUM(active),0) FROM events WHERE source=?",231        (sid,)).fetchone()232    total, active = row233    metrics = []234    for cond, label in KEY_METRICS:235        n = cur.execute(236            f"SELECT COUNT(*) FROM events WHERE source=? AND active=1 AND ({cond})",237            (sid,)).fetchone()[0]238        metrics.append((label, n))239    # Complétude par champ du schéma (sur les événements actifs).240    per_field = {}241    for col, _label, _desc in SCHEMA_FIELDS:242        if col == "lat":243            cond = "lat IS NOT NULL AND lng IS NOT NULL"244        elif col in ("categories", "artists"):245            cond = f"{col} IS NOT NULL AND {col} NOT IN ('', '[]')"246        elif col == "is_free":247            cond = "is_free IS NOT NULL"248        elif col == "price_min":249            cond = "price_min IS NOT NULL"250        else:251            cond = f"{col} IS NOT NULL AND CAST({col} AS TEXT) != ''"252        per_field[col] = cur.execute(253            f"SELECT COUNT(*) FROM events WHERE source=? AND active=1 AND ({cond})",254            (sid,)).fetchone()[0]255256    # Exemple réel : l'événement actif le plus « complet », le plus récent.257    score = " + ".join(f"(CASE WHEN {c} THEN 1 ELSE 0 END)" for c, _ in KEY_METRICS)258    ex = cur.execute(259        f"SELECT * FROM events WHERE source=? AND active=1 "260        f"ORDER BY ({score}) DESC, updated_at DESC LIMIT 1", (sid,)).fetchone()261    example = dict(ex) if ex else {}262263    # sync_log : dernier sync, compteurs, erreurs.264    last = cur.execute(265        "SELECT ts, fetched, added, updated, removed, error FROM sync_log "266        "WHERE source=? ORDER BY ts DESC LIMIT 1", (sid,)).fetchone()267    nsync = cur.execute("SELECT COUNT(*) FROM sync_log WHERE source=?",268                        (sid,)).fetchone()[0]269    errors = cur.execute(270        "SELECT error, COUNT(*), MAX(ts) FROM sync_log WHERE source=? AND "271        "error IS NOT NULL GROUP BY error ORDER BY MAX(ts) DESC LIMIT 5",272        (sid,)).fetchall()273    return {"total": total, "active": active, "metrics": metrics,274            "per_field": per_field, "example": example,275            "last_sync": dict(last) if last else None, "nsync": nsync,276            "errors": [dict(e) for e in errors] if errors else277                      []}278279280def fmt_ts(ts: float | None) -> str:281    if not ts:282        return "n/d"283    return datetime.fromtimestamp(ts, tz=timezone.utc).astimezone() \284                   .strftime("%Y-%m-%d %H:%M")285286287def pct(n: int, d: int) -> str:288    return f"{100.0 * n / d:.0f} %" if d else "—"289290291def md_escape(v) -> str:292    if v is None:293        return "∅"294    s = str(v).replace("\n", " ").replace("|", "\\|").strip()295    return (s[:96] + "…") if len(s) > 96 else (s or "∅")296297298def git_history(path: Path) -> list[str]:299    try:300        out = subprocess.run(301            ["git", "log", "--follow", "--date=short",302             "--format=%h %ad %s", "--", str(path.relative_to(ROOT))],303            cwd=ROOT, capture_output=True, text=True, timeout=30).stdout304        return out.strip().splitlines()[:12]305    except Exception:306        return []307308309# ---------------------------------------------------------------------------310# Rendu des fiches311# ---------------------------------------------------------------------------312313def render_fiche(src: dict, intro: dict | None, stats: dict | None) -> str:314    sid = src["id"]315    L: list[str] = []316    L.append(f"# {src['name']} (`{sid}`)")317    L.append("")318    L.append(f"> Fiche générée le {STAMP} par `scripts/gen_connector_docs.py` "319             f"— ne pas éditer à la main, relancer le script.")320    L.append("")321    L.append(f"**Statut : {src.get('statut', 'n/d')}** · Type : "322             f"`{src.get('type', 'n/d')}`" +323             (f" · Connecteur : `sortika/connectors/{intro['file']}`" if intro324              else " · Connecteur : *aucun (source sans connecteur runtime)*"))325    L.append("")326327    # 1. Description --------------------------------------------------------328    L.append("## Description")329    L.append("")330    L.append(f"- **Source** : [{src['name']}]({src['url']})")331    L.append(f"- **Couverture** : {src.get('couverture', 'n/d')}")332    L.append(f"- **Extraction** : {src.get('extraction', 'n/d')}")333    if src.get("note"):334        L.append(f"- **Note** : {src['note']}")335    L.append("")336337    # 2. Accès ---------------------------------------------------------------338    L.append("## Accès")339    L.append("")340    L.append(f"- **Type** : {src.get('type', 'n/d')}")341    if intro and intro["endpoints"]:342        for name, url in intro["endpoints"]:343            L.append(f"- **Endpoint** (`{name}`) : `{url}`")344    else:345        L.append(f"- **Endpoint** : voir URL source ({src['url']})")346    auth = "Aucune (accès public, identification honnête par User-Agent)"347    if sid == "ticketmaster":348        auth = "Clé API gratuite `TICKETMASTER_API_KEY` (developer.ticketmaster.com)"349    elif sid == "bandsintown":350        auth = "`app_id` d'identification (`BANDSINTOWN_APP_ID`, format widget accepté)"351    elif intro and "Scrapfly" in intro["backend"]:352        auth = "Aucune côté source ; `SCRAPFLY_API_KEY` requis pour le backend Scrapfly"353    L.append(f"- **Auth** : {auth}")354    if intro:355        L.append(f"- **Pagination** : {intro['pagination']}")356        L.append(f"- **Backend** : {intro['backend']}")357        if intro["request_delay"]:358            L.append(f"- **Throttle** : {intro['request_delay']} s entre requêtes")359        L.append(f"- **Accès légal** : {src.get('acces_legal', 'n/d')}")360        if intro["header"]:361            L.append("")362            L.append("<details><summary>En-tête documentaire du connecteur</summary>")363            L.append("")364            for h in intro["header"]:365                L.append(f"> {h}")366            L.append("")367            L.append("</details>")368    else:369        L.append(f"- **Accès légal** : {src.get('acces_legal', 'n/d')}")370    L.append("")371372    # 3. Champs récupérés → schéma -------------------------------------------373    L.append("## Champs récupérés → schéma")374    L.append("")375    if stats and stats["active"]:376        ex = stats["example"]377        L.append(f"Complétude mesurée sur les **{stats['active']} événements "378                 f"actifs** de la source ; exemples tirés d'un événement réel "379                 f"(`{md_escape(ex.get('uid'))}`).")380        L.append("")381        L.append("| Champ (`events.*`) | Description | Complétude | Exemple réel |")382        L.append("|---|---|---|---|")383        for col, label, desc in SCHEMA_FIELDS:384            n = stats["per_field"][col]385            if col == "lat":386                val = (f"{ex.get('lat')}, {ex.get('lng')}"387                       if ex.get("lat") is not None else None)388            else:389                val = ex.get(col)390            L.append(f"| `{col}` — {label} | {desc} | "391                     f"{pct(n, stats['active'])} | {md_escape(val)} |")392    else:393        L.append("*Aucun événement actif en base pour cette source — pas de "394                 "schéma live à montrer.*")395    L.append("")396397    # 4. Fréquence & budgets ---------------------------------------------------398    L.append("## Fréquence & budgets")399    L.append("")400    L.append(f"- **Cadence** : {src.get('cadence', 'n/d')}")401    if intro and intro["env"]:402        L.append("- **Budgets / plafonds (variables d'environnement)** :")403        L.append("")404        L.append("| Variable | Défaut | Rôle |")405        L.append("|---|---|---|")406        for var, default, comment in intro["env"]:407            L.append(f"| `{var}` | `{default}` | {comment or '—'} |")408    L.append("- **Budget global** : géocodage Nominatim plafonné à "409             "`SORTIKA_GEOCODE_MAX` (défaut 120) requêtes réseau/cycle, "410             "toutes sources confondues (`sortika/geocode.py`, cache disque).")411    L.append("")412413    # 5. Volumétrie & complétude live ------------------------------------------414    L.append("## Volumétrie & complétude live")415    L.append("")416    if stats:417        L.append(f"*(mesuré le {STAMP} dans `data/sortika.db`)*")418        L.append("")419        L.append(f"- **Événements en base** : {stats['total']} "420                 f"(dont **{stats['active']} actifs**)")421        for label, n in stats["metrics"]:422            L.append(f"- **{label}** : {n}/{stats['active']} actifs "423                     f"({pct(n, stats['active'])})")424        ls = stats["last_sync"]425        if ls:426            L.append(f"- **Dernier sync** : {fmt_ts(ls['ts'])} — "427                     f"fetched {ls['fetched']}, +{ls['added']} ajoutés, "428                     f"~{ls['updated']} mis à jour, -{ls['removed']} retirés"429                     + (f", **erreur : {md_escape(ls['error'])}**"430                        if ls["error"] else ""))431        L.append(f"- **Syncs enregistrés** (sync_log) : {stats['nsync']}")432    else:433        L.append("*Source sans connecteur runtime : aucune donnée en base.*")434    L.append("")435436    # 6. Erreurs & dépannage -----------------------------------------------------437    L.append("## Erreurs & dépannage")438    L.append("")439    if stats and stats["errors"]:440        L.append("Erreurs relevées dans `sync_log` :")441        L.append("")442        L.append("| Erreur | Occurrences | Dernière fois |")443        L.append("|---|---|---|")444        for e in stats["errors"]:445            err, cnt, ts = list(e.values())446            L.append(f"| {md_escape(err)} | {cnt} | {fmt_ts(ts)} |")447        L.append("")448    elif stats:449        L.append("Aucune erreur dans `sync_log` pour cette source. ✅")450        L.append("")451    for tip in DEPANNAGE.get(sid, []):452        L.append(f"- {tip}")453    if intro:454        L.append("- Rejouer un sync isolé : `python3 run.py sync --source "455                 f"{sid}` (ou via le watcher PM2) ; inspecter ensuite "456                 "`sync_log` : `sqlite3 data/sortika.db \"SELECT * FROM "457                 f"sync_log WHERE source='{sid}' ORDER BY ts DESC LIMIT 5;\"`")458    L.append("")459460    # 7. Licence / attribution ------------------------------------------------------461    L.append("## Licence / attribution")462    L.append("")463    L.append(f"- {src.get('acces_legal', 'n/d')}")464    if sid in ATTRIBUTIONS:465        L.append("")466        L.append(ATTRIBUTIONS[sid])467    L.append("")468469    # 8. Historique -------------------------------------------------------------------470    L.append("## Historique")471    L.append("")472    if src.get("statut") == "abandonnee":473        L.append(f"- **Source abandonnée** — {src.get('note', 'sans note')}")474    L.append(WAVE_HISTORY)475    if intro:476        hist = git_history(CONNECTORS_DIR / intro["file"])477        if hist:478            L.append("")479            L.append(f"Commits touchant `sortika/connectors/{intro['file']}` :")480            L.append("")481            for h in hist:482                L.append(f"- `{h.split(' ', 1)[0]}` {h.split(' ', 1)[1]}")483    L.append("")484    return "\n".join(L)485486487# ---------------------------------------------------------------------------488# INDEX489# ---------------------------------------------------------------------------490491def render_index(rows: list[dict]) -> str:492    L: list[str] = []493    L.append("# Sorti-Ka — Connecteurs de sources d'événements")494    L.append("")495    L.append(f"> Index généré le {STAMP} par `scripts/gen_connector_docs.py` "496             f"(rejouable : `python3 scripts/gen_connector_docs.py`). "497             f"Croise `data/sources.json`, l'introspection de "498             f"`sortika/connectors/*.py` et la base live `data/sortika.db`.")499    L.append("")500    actives = [r for r in rows if r["src"].get("statut") != "abandonnee"]501    abandon = [r for r in rows if r["src"].get("statut") == "abandonnee"]502    tot = sum(r["stats"]["total"] for r in rows if r["stats"])503    act = sum(r["stats"]["active"] for r in rows if r["stats"])504    L.append(f"**{len(rows)} sources au registre** — {len(actives)} "505             f"actives/prêtes, {len(abandon)} abandonnées · "506             f"**{tot} événements en base** (dont {act} actifs).")507    L.append("")508    L.append("⚠️ **Attributions requises** : Ville de Montréal (CC BY 4.0) ; "509             "SIT Québec / Ministère du Tourisme et Ville de Laval "510             "(CC 4.0 — Données Québec). Détails dans chaque fiche, section "511             "« Licence / attribution ».")512    L.append("")513    L.append("## Sources actives / prêtes")514    L.append("")515    L.append("| Source | Type | Statut | Actifs | GPS | Prix | Heure | "516             "Artistes | Image | Salle | Dernier sync |")517    L.append("|---|---|---|---|---|---|---|---|---|---|---|")518    for r in actives:519        s, st = r["src"], r["stats"]520        if st and st["active"]:521            cells = [pct(n, st["active"]) for _, n in st["metrics"]]522            last = fmt_ts(st["last_sync"]["ts"]) if st["last_sync"] else "n/d"523            L.append(f"| [{s['name']}]({s['id']}.md) | {s['type']} | "524                     f"{s['statut']} | {st['active']} | " + " | ".join(cells) +525                     f" | {last} |")526        else:527            last = (fmt_ts(st["last_sync"]["ts"])528                    if st and st["last_sync"] else "n/d")529            L.append(f"| [{s['name']}]({s['id']}.md) | {s['type']} | "530                     f"{s['statut']} | 0 | — | — | — | — | — | — | {last} |")531    L.append("")532    L.append("## Sources abandonnées")533    L.append("")534    L.append("| Source | Type | Raison |")535    L.append("|---|---|---|")536    for r in abandon:537        s = r["src"]538        L.append(f"| [{s['name']}]({s['id']}.md) | {s['type']} | "539                 f"{md_escape(s.get('note', 'n/d'))} |")540    L.append("")541    L.append("## Régénérer cette documentation")542    L.append("")543    L.append("```bash")544    L.append("cd ~/apps/sorti-ka && python3 scripts/gen_connector_docs.py")545    L.append("```")546    L.append("")547    return "\n".join(L)548549550# ---------------------------------------------------------------------------551552def main() -> int:553    sources = json.loads(SOURCES_JSON.read_text(encoding="utf-8"))["sources"]554    con = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True)555    con.row_factory = sqlite3.Row556    cur = con.cursor()557    OUT_DIR.mkdir(parents=True, exist_ok=True)558559    rows = []560    for src in sources:561        sid = src["id"]562        cpath = CONNECTORS_DIR / f"{sid.replace('-', '_')}.py"563        intro = introspect(cpath) if cpath.exists() else None564        has_data = cur.execute(565            "SELECT COUNT(*) FROM events WHERE source=?", (sid,)).fetchone()[0]566        has_log = cur.execute(567            "SELECT COUNT(*) FROM sync_log WHERE source=?", (sid,)).fetchone()[0]568        stats = db_stats(cur, sid) if (intro or has_data or has_log) else None569        fiche = render_fiche(src, intro, stats)570        (OUT_DIR / f"{sid}.md").write_text(fiche, encoding="utf-8")571        rows.append({"src": src, "intro": intro, "stats": stats})572        print(f"  fiche : docs/connecteurs/{sid}.md"573              + (f"  ({stats['active']} actifs)" if stats else "  (sans données)"))574575    (OUT_DIR / "INDEX.md").write_text(render_index(rows), encoding="utf-8")576    print(f"  index : docs/connecteurs/INDEX.md ({len(rows)} sources)")577    con.close()578    return 0579580581if __name__ == "__main__":582    sys.exit(main())583