#!/usr/bin/env python3 # ----------------------------------------------------------------------------- # Sorti-Ka — Agrégateur de sorties & événements (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # scripts/gen_connector_docs.py : générateur REJOUABLE de la documentation # standardisée des connecteurs → docs/connecteurs/INDEX.md + une fiche par # source. Croise trois vérités : # 1. le registre data/sources.json (description, licence, cadence, statut) # 2. l'introspection statique des connecteurs sortika/connectors/*.py # (endpoints, pagination, backend, plafonds env, en-tête documentaire) # 3. la base LIVE data/sortika.db (volumétrie, complétude par champ, # exemples réels, dernier sync, erreurs sync_log) # + l'historique git de chaque connecteur. # # Usage : python3 scripts/gen_connector_docs.py # (aucune dépendance hors stdlib ; la BD est ouverte en lecture seule) # ----------------------------------------------------------------------------- from __future__ import annotations import ast import json import re import sqlite3 import subprocess import sys from datetime import datetime, timezone from pathlib import Path ROOT = Path(__file__).resolve().parents[1] SOURCES_JSON = ROOT / "data" / "sources.json" DB_PATH = ROOT / "data" / "sortika.db" CONNECTORS_DIR = ROOT / "sortika" / "connectors" OUT_DIR = ROOT / "docs" / "connecteurs" NOW = datetime.now().astimezone() STAMP = NOW.strftime("%Y-%m-%d %H:%M %Z") # Champs documentés dans le tableau « Champs récupérés → schéma » de chaque # fiche : (colonne SQL, libellé, description courte). SCHEMA_FIELDS = [ ("title", "Titre", "titre de l'événement"), ("description", "Description", "texte descriptif (source ou fiche détail)"), ("url", "URL", "lien vers la fiche originale / billetterie"), ("categories", "Catégories", "taxonomie canonique Sorti-Ka (JSON)"), ("venue", "Salle (venue)","nom du lieu"), ("address", "Adresse", "adresse civique"), ("city", "Ville", "municipalité"), ("region", "Région", "région administrative"), ("lat", "GPS (lat/lng)","coordonnées géographiques"), ("start_date", "Date début", "date locale AAAA-MM-JJ"), ("end_date", "Date fin", "date locale AAAA-MM-JJ"), ("start_time", "Heure (start_time)", "heure locale HH:MM (Québec)"), ("end_time", "Heure de fin (end_time)", "heure locale HH:MM (Québec)"), ("status", "Statut", "cancelled / postponed / soldout (NULL = prévu)"), ("is_free", "Gratuité (is_free)", "1 gratuit / 0 payant / NULL inconnu"), ("price_min", "Prix min", "tarif minimal réel ($)"), ("price_label", "Libellé prix", "étiquette tarifaire affichable"), ("artists", "Artistes", "têtes d'affiche / lineup (JSON)"), ("organizer", "Organisateur", "promoteur / organisme"), ("image", "Image", "visuel (og:image ou média source)"), ("website", "Site web", "site officiel de l'événement"), ] # Complétude « clé » suivie dans l'INDEX et la section Volumétrie (SQL, libellé). KEY_METRICS = [ ("lat IS NOT NULL AND lng IS NOT NULL", "GPS"), ("(is_free IS NOT NULL OR price_min IS NOT NULL OR (price_label IS NOT NULL AND price_label != ''))", "Prix"), ("start_time IS NOT NULL AND start_time != ''", "Heure (start_time)"), ("end_time IS NOT NULL AND end_time != ''", "Heure de fin"), ("status IS NOT NULL AND status != ''", "Statut (annulé/reporté/complet)"), ("artists IS NOT NULL AND artists NOT IN ('', '[]')", "Artistes"), ("image IS NOT NULL AND image != ''", "Image"), ("venue IS NOT NULL AND venue != ''", "Salle (venue)"), ] # Attributions REQUISES par licence (rappelées mot à mot dans les fiches). ATTRIBUTIONS = { "montreal": ("**Mention requise (CC BY 4.0)** : « Contient des données de " "la **Ville de Montréal**, sous licence CC BY 4.0 " "(donnees.montreal.ca). » L'attribution doit accompagner " "toute réutilisation publique des événements de cette source."), "sitq": ("**Mention requise (CC 4.0 — Données Québec)** : « Données du " "**SIT Québec — Ministère du Tourisme**, diffusées sous " "licence Creative Commons 4.0 via Données Québec. » " "L'attribution au Ministère du Tourisme est obligatoire."), "laval": ("**Mention requise (CC 4.0 — Données Québec)** : « Données de " "la **Ville de Laval** (Calendrier des activités), diffusées " "sous licence Creative Commons 4.0 via Données Québec. » " "L'attribution à la Ville de Laval est obligatoire."), } # Notes de dépannage spécifiques (complètent les erreurs sync_log live). DEPANNAGE = { "montreal": ["Le WAF de donnees.montreal.ca renvoie **403** à tout " "User-Agent contenant « bot » — le UA `SortiKa/1.0 (+URL; " "courriel)` est volontairement sans ce mot. Un 403 ponctuel " "est en général transitoire (WAF) ; vérifier le UA avant tout."], "atuvu": ["Source derrière anti-bot/JS lourd → backend **Scrapfly** " "requis : vérifier `SCRAPFLY_API_KEY` dans `.env` et le solde " "de crédits Scrapfly si le connecteur ne rapporte plus rien."], "ticketmaster": ["Sans `TICKETMASTER_API_KEY` dans `.env`, le connecteur " "tourne en mode REPLI (scraping des pages découverte " "publiques) : pas de prix, pas de statut, images = plans " "de salle. La clé Discovery (gratuite, inscription " "developer.ticketmaster.com) débloque priceRanges, " "dates.status.code (annulé/reporté), visuels 16:9 HD et " "attractions → artistes. Quota API : 5 req/s (throttle " "0,25 s côté connecteur)."], "bandsintown": ["L'API n'accepte que certains formats d'`app_id` : le " "format widget `js_www.sorti-ka.com` est testé accepté " "(2026-08) ; un app_id arbitraire simple est refusé."], "lepointdevente": ["L'enrichissement par fiches détail est incrémental " "(cache `data/lepointdevente_cache.json`, plafond " "`LPDV_DETAIL_MAX`/sync, re-visite 7 j) : la complétude " "description/heure/prix monte au fil des cycles."], "lavitrine": ["Parcours par sitemap incrémental (`LV_MAX_FETCH` fiches/" "sync) : après un reset de cache, plusieurs cycles sont " "nécessaires pour recouvrir tout le catalogue."], } WAVE_HISTORY = ( "- **2026-08-18 — Vague 1 d'enrichissement** (commit `7d9d1b0`) : " "géocodage Nominatim des salles (`sortika/geocode.py`, cache disque, " "plafond `SORTIKA_GEOCODE_MAX`), fiches détail lepointdevente " "(description, heure, tarifs réels `/plugins/rates/`), contacts " "Tourinsoft (SITQ), heure locale Montréal via JSON-LD.\n" "- **2026-08-18 — Vague 2 d'enrichissement** (commit `65d2d33`) : " "colonnes additives `events.start_time` (HH:MM locale QC) et " "`events.artists` (JSON) + migration auto ; heure captée partout où la " "source la publie (lepointdevente, evenko, Laval, Sherbrooke, Montréal, " "atuvu) ; artistes (headliners evenko, lineup Bandsintown, attractions " "Ticketmaster, patrons sûrs sur les titres) ; 3 nouveaux connecteurs " "(brossard, ticketmaster, bandsintown).\n" "- **2026-08-19 — Phase 2 (travail de moine)** : colonnes additives " "`end_time`, `status` (annulé/reporté/complet) et `quarantine` (règles " "anti-aberrations, non publié mais réintégrable) ; règle d'ARCHIVAGE des " "événements terminés à chaque cycle (jamais supprimés) ; dedup_key " "inclut désormais l'heure (les séances multiples d'un même jour ne sont " "plus écrasées à l'affichage) ; alerte « 0 événement futur / chute " "> 80 % » par source (data/future_counts.json + /api/sources) ; heure et " "description La Vitrine ; end_time/organisateur/statut Eventbrite ; " "og:description Atuvu ; premières parties + portes + tournée evenko ; " "offres billets Bandsintown ; taxonomie type-event + description " "intégrale Brossard ; image Longueuil ; géocodage par ADRESSE CIVIQUE " "prioritaire ; villes normalisées au Répertoire MAMH " "(data/villes_canon.json) ; laval consignée « morte-a-la-source »." ) # --------------------------------------------------------------------------- # Introspection statique d'un connecteur # --------------------------------------------------------------------------- def introspect(path: Path) -> dict: text = path.read_text(encoding="utf-8") info: dict = {"file": path.name, "endpoints": [], "env": [], "header": [], "pagination": "", "backend": "", "request_delay": None} # En-tête documentaire (bloc de commentaires en tête de fichier). for line in text.splitlines(): s = line.rstrip() if s.startswith("#"): body = s.lstrip("#").strip() if set(body) <= {"-"}: # séparateurs ----- continue info["header"].append(body) elif s.startswith(("from ", "import ")) or not s: if info["header"]: break else: break # Retire le tri-boilerplate commun (nom du projet, auteur, nom du fichier). info["header"] = [h for h in info["header"] if not h.startswith(("Sorti-Ka —", "Auteur :"))] # Constantes URL au niveau module (ast gère la concaténation implicite). try: tree = ast.parse(text) for node in tree.body: if isinstance(node, ast.Assign) and len(node.targets) == 1 \ and isinstance(node.targets[0], ast.Name): name = node.targets[0].id val = node.value if isinstance(val, ast.Constant) and isinstance(val.value, str) \ and val.value.startswith("http"): info["endpoints"].append((name, val.value)) except SyntaxError: pass # Plafonds / clés env : VAR = int(os.environ.get("X", "def")) # commentaire env_re = re.compile( r'(?:os\.environ|__import__\("os"\)\.environ)\.get\(\s*"([A-Z_]+)"\s*' r'(?:,\s*"([^"]*)")?\s*\)[^\n#]*(?:#\s*(.*))?') for m in env_re.finditer(text): var, default, comment = m.group(1), m.group(2), (m.group(3) or "").strip() info["env"].append((var, default if default is not None else "—", comment)) # Backend réseau. low = text.lower() if "scrapfly" in low: info["backend"] = "Scrapfly (anti-bot / rendu JS) via `base.get_scrapfly()`" else: info["backend"] = "GET direct `requests` (throttlé, UA `SortiKa/1.0` identifié)" # Pagination (heuristique sur le texte + l'en-tête). hints = [] if "offset" in low: hints.append("offset/limit") if re.search(r'page["\']?\s*[:=]|page=\{page\}|\?page=', text): hints.append("par numéro de page") if "sitemap" in low: hints.append("parcours de sitemap XML") if "per_page" in low: hints.append("per_page") info["pagination"] = ", ".join(dict.fromkeys(hints)) or "aucune (flux unique)" m = re.search(r"request_delay\s*[:=]\s*(?:float\s*=\s*)?([\d.]+)", text) if m: info["request_delay"] = m.group(1) return info # --------------------------------------------------------------------------- # Base live # --------------------------------------------------------------------------- def db_stats(cur: sqlite3.Cursor, sid: str) -> dict: row = cur.execute( "SELECT COUNT(*), COALESCE(SUM(active),0) FROM events WHERE source=?", (sid,)).fetchone() total, active = row metrics = [] for cond, label in KEY_METRICS: n = cur.execute( f"SELECT COUNT(*) FROM events WHERE source=? AND active=1 AND ({cond})", (sid,)).fetchone()[0] metrics.append((label, n)) # Complétude par champ du schéma (sur les événements actifs). per_field = {} for col, _label, _desc in SCHEMA_FIELDS: if col == "lat": cond = "lat IS NOT NULL AND lng IS NOT NULL" elif col in ("categories", "artists"): cond = f"{col} IS NOT NULL AND {col} NOT IN ('', '[]')" elif col == "is_free": cond = "is_free IS NOT NULL" elif col == "price_min": cond = "price_min IS NOT NULL" else: cond = f"{col} IS NOT NULL AND CAST({col} AS TEXT) != ''" per_field[col] = cur.execute( f"SELECT COUNT(*) FROM events WHERE source=? AND active=1 AND ({cond})", (sid,)).fetchone()[0] # Exemple réel : l'événement actif le plus « complet », le plus récent. score = " + ".join(f"(CASE WHEN {c} THEN 1 ELSE 0 END)" for c, _ in KEY_METRICS) ex = cur.execute( f"SELECT * FROM events WHERE source=? AND active=1 " f"ORDER BY ({score}) DESC, updated_at DESC LIMIT 1", (sid,)).fetchone() example = dict(ex) if ex else {} # sync_log : dernier sync, compteurs, erreurs. last = cur.execute( "SELECT ts, fetched, added, updated, removed, error FROM sync_log " "WHERE source=? ORDER BY ts DESC LIMIT 1", (sid,)).fetchone() nsync = cur.execute("SELECT COUNT(*) FROM sync_log WHERE source=?", (sid,)).fetchone()[0] errors = cur.execute( "SELECT error, COUNT(*), MAX(ts) FROM sync_log WHERE source=? AND " "error IS NOT NULL GROUP BY error ORDER BY MAX(ts) DESC LIMIT 5", (sid,)).fetchall() return {"total": total, "active": active, "metrics": metrics, "per_field": per_field, "example": example, "last_sync": dict(last) if last else None, "nsync": nsync, "errors": [dict(e) for e in errors] if errors else []} def fmt_ts(ts: float | None) -> str: if not ts: return "n/d" return datetime.fromtimestamp(ts, tz=timezone.utc).astimezone() \ .strftime("%Y-%m-%d %H:%M") def pct(n: int, d: int) -> str: return f"{100.0 * n / d:.0f} %" if d else "—" def md_escape(v) -> str: if v is None: return "∅" s = str(v).replace("\n", " ").replace("|", "\\|").strip() return (s[:96] + "…") if len(s) > 96 else (s or "∅") def git_history(path: Path) -> list[str]: try: out = subprocess.run( ["git", "log", "--follow", "--date=short", "--format=%h %ad %s", "--", str(path.relative_to(ROOT))], cwd=ROOT, capture_output=True, text=True, timeout=30).stdout return out.strip().splitlines()[:12] except Exception: return [] # --------------------------------------------------------------------------- # Rendu des fiches # --------------------------------------------------------------------------- def render_fiche(src: dict, intro: dict | None, stats: dict | None) -> str: sid = src["id"] L: list[str] = [] L.append(f"# {src['name']} (`{sid}`)") L.append("") L.append(f"> Fiche générée le {STAMP} par `scripts/gen_connector_docs.py` " f"— ne pas éditer à la main, relancer le script.") L.append("") L.append(f"**Statut : {src.get('statut', 'n/d')}** · Type : " f"`{src.get('type', 'n/d')}`" + (f" · Connecteur : `sortika/connectors/{intro['file']}`" if intro else " · Connecteur : *aucun (source sans connecteur runtime)*")) L.append("") # 1. Description -------------------------------------------------------- L.append("## Description") L.append("") L.append(f"- **Source** : [{src['name']}]({src['url']})") L.append(f"- **Couverture** : {src.get('couverture', 'n/d')}") L.append(f"- **Extraction** : {src.get('extraction', 'n/d')}") if src.get("note"): L.append(f"- **Note** : {src['note']}") L.append("") # 2. Accès --------------------------------------------------------------- L.append("## Accès") L.append("") L.append(f"- **Type** : {src.get('type', 'n/d')}") if intro and intro["endpoints"]: for name, url in intro["endpoints"]: L.append(f"- **Endpoint** (`{name}`) : `{url}`") else: L.append(f"- **Endpoint** : voir URL source ({src['url']})") auth = "Aucune (accès public, identification honnête par User-Agent)" if sid == "ticketmaster": auth = "Clé API gratuite `TICKETMASTER_API_KEY` (developer.ticketmaster.com)" elif sid == "bandsintown": auth = "`app_id` d'identification (`BANDSINTOWN_APP_ID`, format widget accepté)" elif intro and "Scrapfly" in intro["backend"]: auth = "Aucune côté source ; `SCRAPFLY_API_KEY` requis pour le backend Scrapfly" L.append(f"- **Auth** : {auth}") if intro: L.append(f"- **Pagination** : {intro['pagination']}") L.append(f"- **Backend** : {intro['backend']}") if intro["request_delay"]: L.append(f"- **Throttle** : {intro['request_delay']} s entre requêtes") L.append(f"- **Accès légal** : {src.get('acces_legal', 'n/d')}") if intro["header"]: L.append("") L.append("
En-tête documentaire du connecteur") L.append("") for h in intro["header"]: L.append(f"> {h}") L.append("") L.append("
") else: L.append(f"- **Accès légal** : {src.get('acces_legal', 'n/d')}") L.append("") # 3. Champs récupérés → schéma ------------------------------------------- L.append("## Champs récupérés → schéma") L.append("") if stats and stats["active"]: ex = stats["example"] L.append(f"Complétude mesurée sur les **{stats['active']} événements " f"actifs** de la source ; exemples tirés d'un événement réel " f"(`{md_escape(ex.get('uid'))}`).") L.append("") L.append("| Champ (`events.*`) | Description | Complétude | Exemple réel |") L.append("|---|---|---|---|") for col, label, desc in SCHEMA_FIELDS: n = stats["per_field"][col] if col == "lat": val = (f"{ex.get('lat')}, {ex.get('lng')}" if ex.get("lat") is not None else None) else: val = ex.get(col) L.append(f"| `{col}` — {label} | {desc} | " f"{pct(n, stats['active'])} | {md_escape(val)} |") else: L.append("*Aucun événement actif en base pour cette source — pas de " "schéma live à montrer.*") L.append("") # 4. Fréquence & budgets --------------------------------------------------- L.append("## Fréquence & budgets") L.append("") L.append(f"- **Cadence** : {src.get('cadence', 'n/d')}") if intro and intro["env"]: L.append("- **Budgets / plafonds (variables d'environnement)** :") L.append("") L.append("| Variable | Défaut | Rôle |") L.append("|---|---|---|") for var, default, comment in intro["env"]: L.append(f"| `{var}` | `{default}` | {comment or '—'} |") L.append("- **Budget global** : géocodage Nominatim plafonné à " "`SORTIKA_GEOCODE_MAX` (défaut 120) requêtes réseau/cycle, " "toutes sources confondues (`sortika/geocode.py`, cache disque).") L.append("") # 5. Volumétrie & complétude live ------------------------------------------ L.append("## Volumétrie & complétude live") L.append("") if stats: L.append(f"*(mesuré le {STAMP} dans `data/sortika.db`)*") L.append("") L.append(f"- **Événements en base** : {stats['total']} " f"(dont **{stats['active']} actifs**)") for label, n in stats["metrics"]: L.append(f"- **{label}** : {n}/{stats['active']} actifs " f"({pct(n, stats['active'])})") ls = stats["last_sync"] if ls: L.append(f"- **Dernier sync** : {fmt_ts(ls['ts'])} — " f"fetched {ls['fetched']}, +{ls['added']} ajoutés, " f"~{ls['updated']} mis à jour, -{ls['removed']} retirés" + (f", **erreur : {md_escape(ls['error'])}**" if ls["error"] else "")) L.append(f"- **Syncs enregistrés** (sync_log) : {stats['nsync']}") else: L.append("*Source sans connecteur runtime : aucune donnée en base.*") L.append("") # 6. Erreurs & dépannage ----------------------------------------------------- L.append("## Erreurs & dépannage") L.append("") if stats and stats["errors"]: L.append("Erreurs relevées dans `sync_log` :") L.append("") L.append("| Erreur | Occurrences | Dernière fois |") L.append("|---|---|---|") for e in stats["errors"]: err, cnt, ts = list(e.values()) L.append(f"| {md_escape(err)} | {cnt} | {fmt_ts(ts)} |") L.append("") elif stats: L.append("Aucune erreur dans `sync_log` pour cette source. ✅") L.append("") for tip in DEPANNAGE.get(sid, []): L.append(f"- {tip}") if intro: L.append("- Rejouer un sync isolé : `python3 run.py sync --source " f"{sid}` (ou via le watcher PM2) ; inspecter ensuite " "`sync_log` : `sqlite3 data/sortika.db \"SELECT * FROM " f"sync_log WHERE source='{sid}' ORDER BY ts DESC LIMIT 5;\"`") L.append("") # 7. Licence / attribution ------------------------------------------------------ L.append("## Licence / attribution") L.append("") L.append(f"- {src.get('acces_legal', 'n/d')}") if sid in ATTRIBUTIONS: L.append("") L.append(ATTRIBUTIONS[sid]) L.append("") # 8. Historique ------------------------------------------------------------------- L.append("## Historique") L.append("") if src.get("statut") == "abandonnee": L.append(f"- **Source abandonnée** — {src.get('note', 'sans note')}") L.append(WAVE_HISTORY) if intro: hist = git_history(CONNECTORS_DIR / intro["file"]) if hist: L.append("") L.append(f"Commits touchant `sortika/connectors/{intro['file']}` :") L.append("") for h in hist: L.append(f"- `{h.split(' ', 1)[0]}` {h.split(' ', 1)[1]}") L.append("") return "\n".join(L) # --------------------------------------------------------------------------- # INDEX # --------------------------------------------------------------------------- def render_index(rows: list[dict]) -> str: L: list[str] = [] L.append("# Sorti-Ka — Connecteurs de sources d'événements") L.append("") L.append(f"> Index généré le {STAMP} par `scripts/gen_connector_docs.py` " f"(rejouable : `python3 scripts/gen_connector_docs.py`). " f"Croise `data/sources.json`, l'introspection de " f"`sortika/connectors/*.py` et la base live `data/sortika.db`.") L.append("") actives = [r for r in rows if r["src"].get("statut") != "abandonnee"] abandon = [r for r in rows if r["src"].get("statut") == "abandonnee"] tot = sum(r["stats"]["total"] for r in rows if r["stats"]) act = sum(r["stats"]["active"] for r in rows if r["stats"]) L.append(f"**{len(rows)} sources au registre** — {len(actives)} " f"actives/prêtes, {len(abandon)} abandonnées · " f"**{tot} événements en base** (dont {act} actifs).") L.append("") L.append("⚠️ **Attributions requises** : Ville de Montréal (CC BY 4.0) ; " "SIT Québec / Ministère du Tourisme et Ville de Laval " "(CC 4.0 — Données Québec). Détails dans chaque fiche, section " "« Licence / attribution ».") L.append("") L.append("## Sources actives / prêtes") L.append("") L.append("| Source | Type | Statut | Actifs | GPS | Prix | Heure | " "Artistes | Image | Salle | Dernier sync |") L.append("|---|---|---|---|---|---|---|---|---|---|---|") for r in actives: s, st = r["src"], r["stats"] if st and st["active"]: cells = [pct(n, st["active"]) for _, n in st["metrics"]] last = fmt_ts(st["last_sync"]["ts"]) if st["last_sync"] else "n/d" L.append(f"| [{s['name']}]({s['id']}.md) | {s['type']} | " f"{s['statut']} | {st['active']} | " + " | ".join(cells) + f" | {last} |") else: last = (fmt_ts(st["last_sync"]["ts"]) if st and st["last_sync"] else "n/d") L.append(f"| [{s['name']}]({s['id']}.md) | {s['type']} | " f"{s['statut']} | 0 | — | — | — | — | — | — | {last} |") L.append("") L.append("## Sources abandonnées") L.append("") L.append("| Source | Type | Raison |") L.append("|---|---|---|") for r in abandon: s = r["src"] L.append(f"| [{s['name']}]({s['id']}.md) | {s['type']} | " f"{md_escape(s.get('note', 'n/d'))} |") L.append("") L.append("## Régénérer cette documentation") L.append("") L.append("```bash") L.append("cd ~/apps/sorti-ka && python3 scripts/gen_connector_docs.py") L.append("```") L.append("") return "\n".join(L) # --------------------------------------------------------------------------- def main() -> int: sources = json.loads(SOURCES_JSON.read_text(encoding="utf-8"))["sources"] con = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True) con.row_factory = sqlite3.Row cur = con.cursor() OUT_DIR.mkdir(parents=True, exist_ok=True) rows = [] for src in sources: sid = src["id"] cpath = CONNECTORS_DIR / f"{sid.replace('-', '_')}.py" intro = introspect(cpath) if cpath.exists() else None has_data = cur.execute( "SELECT COUNT(*) FROM events WHERE source=?", (sid,)).fetchone()[0] has_log = cur.execute( "SELECT COUNT(*) FROM sync_log WHERE source=?", (sid,)).fetchone()[0] stats = db_stats(cur, sid) if (intro or has_data or has_log) else None fiche = render_fiche(src, intro, stats) (OUT_DIR / f"{sid}.md").write_text(fiche, encoding="utf-8") rows.append({"src": src, "intro": intro, "stats": stats}) print(f" fiche : docs/connecteurs/{sid}.md" + (f" ({stats['active']} actifs)" if stats else " (sans données)")) (OUT_DIR / "INDEX.md").write_text(render_index(rows), encoding="utf-8") print(f" index : docs/connecteurs/INDEX.md ({len(rows)} sources)") con.close() return 0 if __name__ == "__main__": sys.exit(main())