SPB Git forge

spb/crea-ka

Public

Créa·Ka — annuaire public cross-plateforme des créateurs de contenu québécois (crea-ka.com)

52commits 1branches 0releases
11.3 MBsize
maindefault branch
19 days agolast push
Python 73.6% HTML 13.2% TypeScript 6% JavaScript 4.5% CSS 1.7% Dockerfile 0.6%
23.8 KB · 559 lines python
Raw Blame History
1#!/usr/bin/env python32# ==============================================================================3# Author: Simon-Pierre Boucher <contact@spboucher.ai>4# File:   scripts/gen_connector_docs.py5# Desc:   Documentation STANDARDISÉE des connecteurs Créa-Ka — génère6#         docs/connecteurs/INDEX.md + une fiche docs/connecteurs/<id>.md par7#         source du registre, de façon 100 % programmatique et rejouable :8#           1. registre data/sources.json (famille, palier, accès, cadence,9#              signal d'identité, notes, statut) ;10#           2. introspection STATIQUE (ast) de creaka/connectors/*.py :11#              classe, kind (discovery/enrichment), backend, endpoints,12#              pagination, constantes de budget — sans exécuter le code ;13#           3. BD live data/creaka.db : créateurs découverts/enrichis par14#              source (doc JSON), complétude des champs, comptes par15#              plateforme, dernier sync, cadence observée, alertes récentes.16#         Usage : python3 scripts/gen_connector_docs.py   (racine du projet)17# ==============================================================================18from __future__ import annotations1920import ast21import json22import re23import sqlite324import statistics25from datetime import datetime, timezone26from pathlib import Path2728ROOT = Path(__file__).resolve().parents[1]29CONN_DIR = ROOT / "creaka" / "connectors"30DOCS_DIR = ROOT / "docs" / "connecteurs"31DB_PATH = ROOT / "data" / "creaka.db"32SOURCES_JSON = ROOT / "data" / "sources.json"3334SKIP_MODULES = {"__init__", "base"}35URL_RE = re.compile(r"https?://[^\s\"'\\)>,;]+")36DATE_RE = re.compile(r"\d{4}-\d{2}-\d{2}")37INFRA_HOSTS = ("api.firecrawl.dev", "api.scrapfly.io", "crea-ka.com")3839# plateformes de la table `accounts` alimentées par chaque source d'enrichissement40SOURCE_PLATFORMS = {41    "instagram-profil": ("instagram",),42    "tiktok-profil": ("tiktok",),43    "youtube": ("youtube",),44    "youtube-recherche": ("youtube",),45    "twitch": ("twitch",),46    "x-profil": ("x",),47    "balados-rss": ("podcast",),48    "balados-itunes": ("podcast",),49    "palmares-apple": ("podcast",),50    "podcastindex": ("podcast",),51    "onlyqueb": ("onlyfans",),52}5354# champs du doc créateur documentés (complétude par source de découverte)55DOC_FIELDS = [56    ("display_name", "Nom public"),57    ("bio", "Bio"),58    ("region", "Région"),59    ("city", "Ville"),60    ("niches", "Niches (liste)"),61    ("languages", "Langues (liste)"),62    ("creator_type", "Type de créateur"),63    ("primary_platform", "Plateforme principale"),64    ("platforms", "Comptes sociaux (liste)"),65    ("avatar_url", "Avatar"),66    ("total_reach", "Portée totale (abonnés cumulés)"),67    ("link_in_bio_url", "Lien-en-bio"),68]6970ACC_FIELDS = [71    ("handle", "Handle", "handle IS NOT NULL AND handle != ''"),72    ("url", "URL du profil", "url IS NOT NULL AND url != ''"),73    ("followers", "Abonnés", "followers IS NOT NULL"),74    ("verified", "Badge vérifié", "verified IS NOT NULL"),75    ("confidence", "Confiance d'identité", "confidence IS NOT NULL"),76    ("signal", "Signal d'identité", "signal IS NOT NULL AND signal != ''"),77    ("metrics", "Métriques détaillées (JSON)",78     "metrics IS NOT NULL AND length(metrics) > 4"),79    ("last_checked", "Dernière vérification",80     "last_checked IS NOT NULL AND last_checked != ''"),81]828384def esc(s, limit: int = 100) -> str:85    s = str(s).replace("\\", "\\\\").replace("|", "\\|")86    s = re.sub(r"\s+", " ", s).strip()87    return s[: limit - 1] + "…" if len(s) > limit else s888990def pct(n, d) -> str:91    return f"{100.0 * (n or 0) / d:.0f} %" if d else "—"929394def parse_iso(ts: str) -> float | None:95    try:96        return datetime.fromisoformat(ts.replace("Z", "+00:00")).timestamp()97    except (ValueError, AttributeError):98        return None99100101def fmt_secs(sec: float) -> str:102    if sec < 5400:103        return f"≈ {sec / 60:.0f} min"104    if sec < 129600:105        return f"≈ {sec / 3600:.1f} h"106    return f"≈ {sec / 86400:.1f} j"107108109def filled(v) -> bool:110    return bool(v)111112113# -- introspection statique ------------------------------------------------------114115def banner_description(text: str) -> str:116    lines, started = [], False117    for raw in text.splitlines():118        if not raw.startswith("#"):119            if started:120                break121            continue122        body = raw.lstrip("#").strip()123        if set(body) <= {"-", "="}:124            continue125        if not started:126            if body.startswith("Desc:"):127                started = True128                lines.append(body[len("Desc:"):].strip())129            continue130        if re.match(r"^(Author|File):", body):131            break132        lines.append(body)133    return " ".join(l for l in lines if l).strip()134135136def introspect_module(path: Path) -> list[dict]:137    text = path.read_text(encoding="utf-8")138    try:139        tree = ast.parse(text)140    except SyntaxError:141        return []142    constants: dict = {}143    for node in tree.body:144        if isinstance(node, ast.Assign) and len(node.targets) == 1 \145                and isinstance(node.targets[0], ast.Name) \146                and node.targets[0].id.isupper():147            try:148                constants[node.targets[0].id] = ast.literal_eval(node.value)149            except (ValueError, TypeError, SyntaxError):150                seg = ast.get_source_segment(text, node.value) or ""151                urls = URL_RE.findall(seg)152                constants[node.targets[0].id] = urls if len(urls) > 1 else \153                    (urls[0] if urls else None)154    urls_all = []155    for u in URL_RE.findall(text):156        u = u.rstrip('".')157        host = u.split("//", 1)[-1].split("/", 1)[0]158        if "." not in host:      # fragment de f-string, pas une vraie URL159            continue160        if not any(h in u for h in INFRA_HOSTS) and u not in urls_all:161            urls_all.append(u)162    endpoint = None163    for name in ("BASE", "BASE_URL", "API", "API_URL", "API_BASE", "SPARQL",164                 "ENDPOINT", "ROOT", "URL", "SEARCH_URL", "LOOKUP_URL"):165        v = constants.get(name)166        if isinstance(v, str) and v.startswith("http"):167            endpoint = v168            break169    if not endpoint and urls_all:170        endpoint = urls_all[0]171    budgets = {k: v for k, v in constants.items()172               if isinstance(v, (int, float)) and not isinstance(v, bool)173               and re.search(r"MAX|CAP|LIMIT|BUDGET|TTL|PER_PAGE|PAGES|DELAY|BATCH",174                             k)}175    detailed, family = [], "direct"176    if re.search(r"self\.(get|post)\(|requests\.(get|post)\(", text):177        detailed.append("requests direct (session UA CreaKaBot, throttling poli)")178    if ".scrapfly(" in text:179        detailed.append("Scrapfly (asp + render_js — contournement anti-bot)")180        family = "Scrapfly"181    if "get_rendered(" in text:182        detailed.append("Firecrawl (HTML rendu, JavaScript exécuté)")183        family = "Firecrawl" if family == "direct" else family184    if not detailed:185        detailed.append("requests direct")186    low = text.lower()187    if "sparql" in low:188        flavor = "API SPARQL publique (Wikidata)"189    elif "graphql" in low:190        flavor = "API GraphQL"191    elif "itunes.apple.com" in low:192        flavor = "API iTunes Search/Lookup (publique)"193    elif "rss" in low and "feedparser" in low or "<rss" in low:194        flavor = "flux RSS publics"195    elif "sitemap" in low:196        flavor = "sitemap XML + JSON-LD des pages profil (SSR)"197    elif "json-ld" in low or "jsonld" in low or "profilepage" in low:198        flavor = "JSON-LD des pages profil publiques"199    elif re.search(r"\.json\(\)", text) and re.search(r"api[./_]", low):200        flavor = "API JSON"201    elif "dataset" in low or "seed" in low and "csv" in low:202        flavor = "dataset local compilé (data/seed)"203    else:204        flavor = "pages HTML publiques"205    hits = []206    if re.search(r"[?&]page=|[\"']page[\"']\s*[:=]", low):207        hits.append("pagination par numéro de page")208    if re.search(r"[?&]offset=|[\"']offset[\"']", low):209        hits.append("pagination par offset")210    if "cursor" in low or "continuation" in low:211        hits.append("curseur / continuation")212    if not hits:213        hits.append("réponse unique (pas de pagination)")214    base = {"module": path.stem, "path": f"creaka/connectors/{path.stem}.py",215            "banner": banner_description(text), "endpoint": endpoint,216            "urls": urls_all[:5], "budgets": budgets, "backends": detailed,217            "backend_family": family, "flavor": flavor,218            "pagination": " ; ".join(hits)}219    entries = []220    for node in tree.body:221        if not isinstance(node, ast.ClassDef):222            continue223        bases = {getattr(b, "id", getattr(b, "attr", "")) for b in node.bases}224        if "BaseConnector" not in bases:225            continue226        attrs = {"request_delay": 0.8, "timeout": 30, "kind": "discovery",227                 "source_id": ""}228        for sub in node.body:229            if isinstance(sub, ast.Assign) and len(sub.targets) == 1 \230                    and isinstance(sub.targets[0], ast.Name):231                try:232                    attrs[sub.targets[0].id] = ast.literal_eval(sub.value)233                except (ValueError, TypeError, SyntaxError):234                    pass235        if attrs["source_id"]:236            entries.append({**base, "class": node.name,237                            "class_doc": ast.get_docstring(node) or "",238                            **attrs})239    return entries240241242# -- BD live ----------------------------------------------------------------------243244def load_creators(con) -> list[dict]:245    out = []246    for (doc,) in con.execute("SELECT doc FROM creators"):247        try:248            out.append(json.loads(doc))249        except ValueError:250            pass251    return out252253254def db_stats(con, sid: str, creators: list[dict]) -> dict:255    mine = [c for c in creators if c.get("source") == sid]256    touched = sum(1 for c in creators257                  if sid in (c.get("source_ids") or []) or c.get("source") == sid)258    doc_fill = {f: sum(1 for c in mine if filled(c.get(f)))259                for f, _l in DOC_FIELDS}260    sample = max(mine, key=lambda c: c.get("total_reach") or 0) if mine else None261262    acc = None263    acc_sample = None264    platforms = SOURCE_PLATFORMS.get(sid)265    if platforms:266        ph = ",".join("?" * len(platforms))267        parts = ", ".join(268            f"sum(CASE WHEN {cond} THEN 1 ELSE 0 END) AS f_{col}"269            for col, _l, cond in ACC_FIELDS)270        acc = con.execute(271            f"SELECT count(*) AS n, avg(confidence) AS conf, "272            f"max(last_checked) AS checked, {parts} FROM accounts "273            f"WHERE platform IN ({ph})", platforms).fetchone()274        acc_sample = con.execute(275            f"SELECT * FROM accounts WHERE platform IN ({ph}) AND followers IS "276            f"NOT NULL ORDER BY followers DESC LIMIT 1", platforms).fetchone()277278    runs = con.execute(279        "SELECT ts, creators, accounts, avg_confidence, added, updated, "280        "errors, seconds, alert FROM sync_log WHERE source=? "281        "ORDER BY ts DESC LIMIT 60", (sid,)).fetchall()282    ok_ts = sorted(t for t in (parse_iso(r["ts"]) for r in runs) if t)283    cadence = None284    if len(ok_ts) >= 3:285        deltas = [b - a for a, b in zip(ok_ts, ok_ts[1:]) if b - a > 60]286        if deltas:287            cadence = statistics.median(deltas)288    alerts = [r for r in runs if (r["errors"] or 0) > 0 or r["alert"]][:5]289    return {"mine": len(mine), "touched": touched, "doc_fill": doc_fill,290            "sample": sample, "acc": acc, "acc_sample": acc_sample,291            "platforms": platforms, "runs": runs, "cadence": cadence,292            "last": runs[0] if runs else None, "alerts": alerts}293294295# -- rendu ---------------------------------------------------------------------------296297def render_fiche(reg: dict, entry: dict | None, st: dict, now: str) -> str:298    sid = reg["id"]299    etat = reg.get("statut", "actif")300    fam = reg.get("famille", "—")301    out = [f"# `{sid}` — connecteur {fam} (palier {reg.get('palier', '—')})", "",302           f"_Fiche générée automatiquement par "303           f"`scripts/gen_connector_docs.py` le {now} — ne pas éditer à la "304           f"main, régénérer._", ""]305    kind = entry.get("kind") if entry else fam306    out.append(f"**État : {esc(etat, 80)}** · Famille : {fam} ({kind}) · "307               f"Backend : {entry['backend_family'] if entry else '—'} · "308               f"Créateurs découverts : {st['mine']} · touchés : "309               f"{st['touched']}")310    out.append("")311312    out.append("## Description de la source")313    out.append("")314    if entry and entry["banner"]:315        out.append(entry["banner"])316        out.append("")317    out.append(f"- **Notes (registre)** : {reg.get('notes', '—')}")318    out.append(f"- **Signal d'identité** : {reg.get('signal_identite', '—')} "319               f"(colonne `accounts.signal` / `confidence`)")320    if entry:321        out.append(f"- **Module** : `{entry['path']}` — classe "322                   f"`{entry['class']}` (`kind = \"{entry['kind']}\"`)")323    else:324        out.append("- **Module** : introuvable (vérifier le registre)")325    out.append("")326327    out.append("## Accès")328    out.append("")329    out.append(f"- **Accès (registre)** : {reg.get('acces', '—')}")330    if entry:331        out.append(f"- **Type d'accès (code)** : {entry['flavor']}")332        out.append(f"- **Endpoint de base** : {entry['endpoint'] or '—'}")333        if entry["urls"]:334            out.append("- **URLs du module** : " + " · ".join(entry["urls"][:4]))335        out.append(f"- **Pagination** : {entry['pagination']}")336        out.append(f"- **Backend anti-bot / rendu** : "337                   f"{' ; '.join(entry['backends'])}")338        out.append(f"- **Politesse** : {entry['request_delay']} s entre "339                   f"requêtes, timeout {entry['timeout']} s, UA "340                   f"`CreaKaBot/1.0 (+https://www.crea-ka.com/bot)`")341        needs_key = "clé" in (reg.get("acces") or "").lower() \342            or "clé" in str(etat).lower()343        auth = ("clé(s) API requise(s) — voir statut/registre" if needs_key344                else "aucune — contenu public / API anonyme")345        out.append(f"- **Authentification** : {auth}")346    out.append("")347348    out.append("## Champs récupérés → schéma cible")349    out.append("")350    if st["mine"]:351        out.append(f"Source de **découverte** : produit des fiches `Creator` "352                   f"(tables `creators` + `accounts`). Complétude mesurée sur "353                   f"les {st['mine']} créateurs découverts par cette source "354                   f"(champ `doc.source`) ; exemple tiré d'une fiche réelle.")355        out.append("")356        out.append("| Champ du doc créateur | Contenu | Renseigné | Exemple réel |")357        out.append("|---|---|---|---|")358        s = st["sample"] or {}359        for f, label in DOC_FIELDS:360            v = s.get(f)361            if f == "platforms" and v:362                ex = esc(", ".join(f"{p.get('platform')}:@{p.get('handle')}"363                                   for p in v[:3]), 90)364            elif isinstance(v, list):365                ex = esc(", ".join(map(str, v[:4])), 90)366            else:367                ex = esc(v, 90) if filled(v) else "—"368            out.append(f"| `{f}` | {label} | "369                       f"{pct(st['doc_fill'][f], st['mine'])} | {ex} |")370        out.append("")371    if st["acc"] is not None and st["acc"]["n"]:372        a = st["acc"]373        plats = ", ".join(st["platforms"])374        out.append(f"Cible d'**enrichissement** : colonnes de la table "375                   f"`accounts` pour la/les plateforme(s) `{plats}` "376                   f"({a['n']} comptes en BD, confiance moyenne "377                   f"{a['conf']:.2f}).")378        out.append("")379        out.append("| Colonne `accounts` | Contenu | Renseignée | Exemple réel |")380        out.append("|---|---|---|---|")381        sm = st["acc_sample"]382        for col, label, _c in ACC_FIELDS:383            ex = esc(sm[col], 90) if sm is not None and sm[col] not in (384                None, "") else "—"385            out.append(f"| `{col}` | {label} | {pct(a[f'f_{col}'], a['n'])} "386                       f"| {ex} |")387        out.append("")388    if not st["mine"] and (st["acc"] is None or not st["acc"]["n"]):389        out.append("Aucune donnée attribuable à cette source en BD pour "390                   "l'instant (clés manquantes ou source en attente) — schéma "391                   "cible : `creators` + `accounts`.")392        out.append("")393394    out.append("## Fréquence & budget")395    out.append("")396    out.append(f"- **Cadence déclarée (registre)** : {reg.get('cadence', '—')}")397    cad = fmt_secs(st["cadence"]) if st["cadence"] else "—"398    out.append(f"- **Cadence observée** (médiane sync_log) : {cad}")399    last = st["last"]400    if last:401        out.append(f"- **Dernier passage** : {esc(last['ts'], 20)} — "402                   f"{last['creators'] or 0} créateurs, {last['accounts'] or 0} "403                   f"comptes, +{last['added'] or 0} / ~{last['updated'] or 0}, "404                   f"{last['errors'] or 0} erreur(s), "405                   f"{(last['seconds'] or 0):.0f} s")406    if entry and entry["budgets"]:407        caps = ", ".join(f"`{k}` = {v}" for k, v in sorted(entry["budgets"].items()))408        out.append(f"- **Caps / budgets du module** : {caps}")409    if entry:410        out.append(f"- **Throttling** : {entry['request_delay']} s entre "411                   f"requêtes (`request_delay`)")412    out.append("")413414    out.append("## Volumétrie & complétude")415    out.append("")416    out.append(f"- **Créateurs découverts par la source** (`doc.source`) : "417               f"{st['mine']} · **fiches touchées** (`source_ids`) : "418               f"{st['touched']}")419    if st["mine"]:420        out.append(f"- **Complétude clé (découverts)** : niches "421                   f"{pct(st['doc_fill']['niches'], st['mine'])} · avatar "422                   f"{pct(st['doc_fill']['avatar_url'], st['mine'])} · portée "423                   f"{pct(st['doc_fill']['total_reach'], st['mine'])} · région "424                   f"{pct(st['doc_fill']['region'], st['mine'])}")425    if st["acc"] is not None and st["acc"]["n"]:426        a = st["acc"]427        out.append(f"- **Comptes `{', '.join(st['platforms'])}` en BD** : "428                   f"{a['n']} — abonnés {pct(a['f_followers'], a['n'])} · "429                   f"métriques {pct(a['f_metrics'], a['n'])} · dernière "430                   f"vérification {esc(a['checked'] or '—', 20)}")431    out.append(f"- **Runs journalisés (60 derniers)** : {len(st['runs'])}, "432               f"dont {sum(1 for r in st['runs'] if (r['errors'] or 0) > 0)} "433               f"avec erreurs")434    out.append("")435436    out.append("## Erreurs connues & dépannage")437    out.append("")438    if st["alerts"]:439        out.append("| Passage | Erreurs | Alerte (sync_log) |")440        out.append("|---|---|---|")441        for r in st["alerts"]:442            out.append(f"| {esc(r['ts'], 20)} | {r['errors'] or 0} | "443                       f"{esc(r['alert'] or '—', 160)} |")444        out.append("")445    else:446        out.append("Aucune erreur ni alerte dans les 60 derniers runs "447                   "journalisés.")448        out.append("")449    if str(etat) != "actif":450        out.append(f"**Statut du registre** : {etat}")451        out.append("")452    out.append(f"Rejouer la source seule : `python3 run.py sync {sid}` · "453               f"vérifier `sync_log` (`SELECT * FROM sync_log WHERE "454               f"source='{sid}' ORDER BY ts DESC LIMIT 5;`).")455    out.append("")456457    out.append("## Licence, attribution & conditions")458    out.append("")459    out.append(f"- **Cadre d'accès (registre)** : {reg.get('acces', '—')}")460    low = (reg.get("acces") or "").lower()461    if "wikidata" in low or "sparql" in low:462        out.append("- **Licence** : données Wikidata sous CC0 — réutilisation "463                   "libre, mention « Source : Wikidata » affichée par "464                   "courtoisie.")465    elif "dataset local" in low or "listes" in sid:466        out.append("- **Données compilées** de listes et palmarès médias "467                   "**publics** ; seuls des faits publics (nom, handle, "468                   "audience approximative) sont conservés.")469    elif "api" in low and ("publique" in low or "itunes" in low):470        out.append("- **API publique** utilisée selon ses conditions (pas de "471                   "clé détournée, throttling poli) ; données limitées aux "472                   "profils publics.")473    else:474        out.append("- **Profils publics uniquement** : UA identifiable "475                   "`CreaKaBot/1.0 (+https://www.crea-ka.com/bot; "476                   "contact@spboucher.ai)`, throttling poli, respect des "477                   "opt-out (`data/optout.json`) et du volet éthique "478                   "(`creaka/ethics.py` — mineurs exclus via `is_minor`).")479    out.append("- Retrait sur demande : contact@spboucher.ai (opt-out honoré "480               "à la prochaine ingestion).")481    out.append("")482483    out.append("## Historique")484    out.append("")485    blob = " ".join(str(reg.get(k, "")) for k in ("notes", "statut", "acces"))486    for d in sorted({m.group(0) for m in DATE_RE.finditer(blob)}):487        out.append(f"- {d} — date mentionnée au registre (voir notes/statut).")488    if st["runs"]:489        first = st["runs"][-1]["ts"]490        out.append(f"- {str(first)[:10]} — plus ancien passage journalisé dans "491                   f"`sync_log` (fenêtre des 60 derniers).")492    out.append("- 2026-08-18 — vague d'enrichissement : standardisation de la "493               "documentation des connecteurs (fiche générée par "494               "`scripts/gen_connector_docs.py`).")495    out.append("")496    return "\n".join(out)497498499def main() -> None:500    now = datetime.now().strftime("%Y-%m-%d %H:%M")501    registry = json.loads(SOURCES_JSON.read_text(encoding="utf-8"))["sources"]502    con = sqlite3.connect(DB_PATH)503    con.row_factory = sqlite3.Row504    creators = load_creators(con)505506    by_sid: dict[str, dict] = {}507    for path in sorted(CONN_DIR.glob("*.py")):508        if path.stem in SKIP_MODULES:509            continue510        for e in introspect_module(path):511            by_sid[e["source_id"]] = e512513    DOCS_DIR.mkdir(parents=True, exist_ok=True)514    for old in DOCS_DIR.glob("*.md"):515        old.unlink()516517    rows = []518    for reg in registry:519        sid = reg["id"]520        entry = by_sid.get(sid)521        st = db_stats(con, sid, creators)522        (DOCS_DIR / f"{sid}.md").write_text(523            render_fiche(reg, entry, st, now), encoding="utf-8")524        last = st["last"]525        acc = st["acc"]526        comptes = (f"{acc['n']} ({', '.join(st['platforms'])})"527                   if acc is not None and acc["n"] else "—")528        rows.append(529            f"| [`{sid}`]({sid}.md) | {reg.get('famille', '—')} "530            f"| P{reg.get('palier', '—')} "531            f"| {esc(entry['flavor'] if entry else '—', 42)} "532            f"| {entry['backend_family'] if entry else '—'} "533            f"| {st['mine']} | {comptes} "534            f"| {esc(str(reg.get('statut', 'actif')).split('—')[0], 26)} "535            f"| {esc(last['ts'], 17) if last else '—'} |")536537    n_creators = con.execute("SELECT count(*) FROM creators").fetchone()[0]538    n_accounts = con.execute("SELECT count(*) FROM accounts").fetchone()[0]539    idx = [540        "# Créa-Ka — Index des connecteurs", "",541        f"_Généré automatiquement par `scripts/gen_connector_docs.py` le {now} "542        f"— ne pas éditer à la main, régénérer._", "",543        f"**{len(registry)} sources au registre** · **{n_creators} créateurs** "544        f"et **{n_accounts} comptes** en BD.", "",545        "| Source | Famille | Palier | Type d'accès | Backend | Découverts "546        "| Comptes (plateforme) | État | Dernier passage |",547        "|---|---|---|---|---|---|---|---|---|",548    ]549    idx.extend(rows)550    idx.append("")551    (DOCS_DIR / "INDEX.md").write_text("\n".join(idx), encoding="utf-8")552    con.close()553    print(f"[gen_connector_docs] {len(registry)} fiches + INDEX.md écrits dans "554          f"{DOCS_DIR}")555556557if __name__ == "__main__":558    main()559