Repository Public
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 Resto-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 (nom, tier, extraction, acces_legal,9# cadence déclarée, statut) ;10# 2. introspection STATIQUE (ast) de restoka/connectors/*.py et11# restoka/inspections.py : classe, backend, endpoints, pagination,12# constantes de budget — sans exécuter les connecteurs ;13# 3. BD live data/restoka.db : volumétrie, complétude des champs par14# source (SQL), menus, dernier sync OK, cadence observée, erreurs.15# Usage : python3 scripts/gen_connector_docs.py (racine du projet)16# ==============================================================================17from __future__ import annotations1819import ast20import json21import re22import sqlite323import statistics24from datetime import datetime25from pathlib import Path2627ROOT = Path(__file__).resolve().parents[1]28CONN_DIR = ROOT / "restoka" / "connectors"29EXTRA_MODULES = [ROOT / "restoka" / "inspections.py"] # mapaq (enrichissement)30DOCS_DIR = ROOT / "docs" / "connecteurs"31DB_PATH = ROOT / "data" / "restoka.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", "resto-ka.com",38 "nominatim")3940FIELDS = [41 ("name", "Nom de l'établissement", "name IS NOT NULL AND name != ''"),42 ("chain", "Chaîne / bannière", "chain IS NOT NULL AND chain != ''"),43 ("cuisines", "Cuisines (JSON, taxonomie §6.1)",44 "cuisines IS NOT NULL AND length(cuisines) > 4"),45 ("establishment_type", "Type d'établissement",46 "establishment_type IS NOT NULL AND establishment_type != ''"),47 ("price_range", "Fourchette de prix",48 "price_range IS NOT NULL AND price_range != ''"),49 ("address", "Adresse civique", "address IS NOT NULL AND address != ''"),50 ("city", "Ville", "city IS NOT NULL AND city != ''"),51 ("region", "Région administrative", "region IS NOT NULL AND region != ''"),52 ("postal_code", "Code postal",53 "postal_code IS NOT NULL AND postal_code != ''"),54 ("lat", "GPS (lat/lng)", "lat IS NOT NULL AND lng IS NOT NULL"),55 ("phone", "Téléphone", "phone IS NOT NULL AND phone != ''"),56 ("website", "Site web", "website IS NOT NULL AND website != ''"),57 ("hours", "Horaires structurés (JSON)",58 "hours IS NOT NULL AND length(hours) > 4"),59 ("services", "Services (JSON)", "services IS NOT NULL AND length(services) > 4"),60 ("dietary_options", "Options alimentaires (JSON)",61 "dietary_options IS NOT NULL AND length(dietary_options) > 4"),62 ("images", "Photos (JSON)", "images IS NOT NULL AND length(images) > 4"),63 ("url", "URL de la fiche source", "url IS NOT NULL AND url != ''"),64]656667def esc(s: str, limit: int = 100) -> str:68 s = str(s).replace("\\", "\\\\").replace("|", "\\|")69 s = re.sub(r"\s+", " ", s).strip()70 return s[: limit - 1] + "…" if len(s) > limit else s717273def pct(n, d) -> str:74 return f"{100.0 * (n or 0) / d:.0f} %" if d else "—"757677def fmt_ts(ts) -> str:78 if not ts:79 return "—"80 return datetime.fromtimestamp(float(ts)).strftime("%Y-%m-%d %H:%M")818283def fmt_secs(sec: float) -> str:84 if sec < 5400:85 return f"≈ {sec / 60:.0f} min"86 if sec < 129600:87 return f"≈ {sec / 3600:.1f} h"88 return f"≈ {sec / 86400:.1f} j"899091def example_value(col: str, value) -> str:92 if value is None or value == "":93 return "—"94 if col == "images":95 try:96 imgs = json.loads(value)97 return esc(f"{len(imgs)} photo(s) — {imgs[0]}", 90) if imgs else "—"98 except (ValueError, TypeError):99 return esc(value, 90)100 return esc(value, 90)101102103# -- introspection statique ------------------------------------------------------104105def banner_description(text: str, filename: str) -> str:106 lines, started = [], False107 for raw in text.splitlines():108 if not raw.startswith("#"):109 if started:110 break111 continue112 body = raw.lstrip("#").strip()113 if set(body) <= {"-", "="}:114 continue115 if not started:116 if body.startswith("Desc:"):117 started = True118 lines.append(body[len("Desc:"):].strip())119 continue120 if re.match(r"^(Author|File):", body):121 break122 lines.append(body)123 return " ".join(l for l in lines if l).strip()124125126def detect_backends(text: str) -> tuple[list[str], str]:127 detailed, family = [], "direct"128 if re.search(r"self\.(get|post)\(|requests\.(get|post)\(", text):129 detailed.append("requests direct (session UA RestoKaBot, throttling poli)")130 if ".scrapfly(" in text:131 detailed.append("Scrapfly (asp + render_js — contournement anti-bot)")132 family = "Scrapfly"133 if "get_rendered(" in text:134 detailed.append("Firecrawl (HTML rendu, JavaScript exécuté)")135 family = "Firecrawl" if family == "direct" else family136 if not detailed:137 detailed.append("requests direct")138 return detailed, family139140141def detect_flavor(text: str) -> str:142 low = text.lower()143 if "graphql" in low:144 return "API GraphQL interne"145 if "overpass" in low:146 return "API Overpass (OpenStreetMap)"147 if "listecondamnation" in low or "donneesquebec" in low or "données québec" in low:148 return "jeu de données ouvert (CSV, Données Québec)"149 if "sitemap" in low:150 return "sitemap XML + pages HTML"151 if re.search(r"\.json\(\)", text) and re.search(r"api[./_]", low):152 return "API JSON"153 return "pages HTML (rendu serveur)"154155156def detect_pagination(text: str, constants: dict) -> str:157 hits, low = [], text.lower()158 if re.search(r"[?&]page=|[\"']page[\"']\s*[:=]|paged", low):159 hits.append("pagination par numéro de page")160 if re.search(r"[?&]offset=|[\"']offset[\"']", low):161 hits.append("pagination par offset")162 if "cursor" in low:163 hits.append("curseur de pagination")164 lists = [k for k, v in constants.items()165 if isinstance(v, (list, tuple)) and len(v) > 1]166 if lists:167 hits.append(f"itération sur {len(constants[lists[0]])} racines "168 f"(constante `{lists[0]}`)")169 if not hits:170 hits.append("réponse unique (pas de pagination)")171 return " ; ".join(hits)172173174def introspect_module(path: Path) -> list[dict]:175 text = path.read_text(encoding="utf-8")176 try:177 tree = ast.parse(text)178 except SyntaxError:179 return []180 constants: dict = {}181 for node in tree.body:182 if isinstance(node, ast.Assign) and len(node.targets) == 1 \183 and isinstance(node.targets[0], ast.Name) \184 and node.targets[0].id.isupper():185 try:186 constants[node.targets[0].id] = ast.literal_eval(node.value)187 except (ValueError, TypeError, SyntaxError):188 seg = ast.get_source_segment(text, node.value) or ""189 urls = URL_RE.findall(seg)190 constants[node.targets[0].id] = urls if len(urls) > 1 else \191 (urls[0] if urls else None)192 urls_all = []193 for u in URL_RE.findall(text):194 u = u.rstrip('".')195 host = u.split("//", 1)[-1].split("/", 1)[0]196 if "." not in host: # fragment de f-string, pas une vraie URL197 continue198 if not any(h in u for h in INFRA_HOSTS) and u not in urls_all:199 urls_all.append(u)200 endpoint = None201 for name in ("BASE", "BASE_URL", "API", "API_URL", "API_BASE", "GRAPHQL",202 "CSV_URL", "DATASET_URL", "ROOT", "URL"):203 v = constants.get(name)204 if isinstance(v, str) and v.startswith("http"):205 endpoint = v206 break207 if not endpoint and urls_all:208 endpoint = urls_all[0]209 budgets = {k: v for k, v in constants.items()210 if isinstance(v, (int, float)) and not isinstance(v, bool)211 and re.search(r"MAX|CAP|LIMIT|BUDGET|TTL|PER_PAGE|PAGES|DELAY|BATCH", k)}212 backends, family = detect_backends(text)213 rel = path.relative_to(ROOT)214 base = {215 "module": path.stem, "path": str(rel),216 "banner": banner_description(text, path.name),217 "endpoint": endpoint, "urls": urls_all[:5], "budgets": budgets,218 "backends": backends, "backend_family": family,219 "flavor": detect_flavor(text),220 "pagination": detect_pagination(text, constants),221 }222 entries = []223 for node in tree.body:224 if not isinstance(node, ast.ClassDef):225 continue226 bases = {getattr(b, "id", getattr(b, "attr", "")) for b in node.bases}227 if "BaseConnector" not in bases:228 continue229 attrs = {"request_delay": 0.6, "timeout": 30, "use_detail_cache": True,230 "source_id": ""}231 for sub in node.body:232 if isinstance(sub, ast.Assign) and len(sub.targets) == 1 \233 and isinstance(sub.targets[0], ast.Name):234 try:235 attrs[sub.targets[0].id] = ast.literal_eval(sub.value)236 except (ValueError, TypeError, SyntaxError):237 pass238 if attrs["source_id"]:239 entries.append({**base, "class": node.name,240 "class_doc": ast.get_docstring(node) or "",241 **attrs})242 if not entries and isinstance(constants.get("SOURCE_ID"), str):243 # module d'enrichissement sans classe (ex. inspections.py / mapaq)244 entries.append({**base, "class": "(module fonctionnel, pas de classe)",245 "class_doc": ast.get_docstring(tree) or "",246 "source_id": constants["SOURCE_ID"],247 "request_delay": None, "timeout": None,248 "use_detail_cache": False})249 return entries250251252# -- BD live ----------------------------------------------------------------------253254def db_stats(con: sqlite3.Connection, sid: str) -> dict:255 parts = ", ".join(256 f"sum(CASE WHEN active=1 AND {cond} THEN 1 ELSE 0 END) AS f_{col}"257 for col, _l, cond in FIELDS)258 agg = con.execute(259 f"SELECT count(*) AS total, coalesce(sum(active),0) AS act, "260 f"min(first_seen) AS first_seen, max(last_seen) AS last_seen, {parts} "261 f"FROM restaurants WHERE source=?", (sid,)).fetchone()262 sample = con.execute(263 "SELECT * FROM restaurants WHERE source=? AND active=1 "264 "ORDER BY last_seen DESC LIMIT 1", (sid,)).fetchone()265 menus = con.execute(266 "SELECT count(*) AS n, coalesce(sum(m.item_count),0) AS items, "267 "max(m.captured_at) AS fresh, count(DISTINCT m.price_context) AS ctx "268 "FROM menus m JOIN restaurants r ON r.uid = m.uid WHERE r.source=?",269 (sid,)).fetchone()270 inspections = None271 if sid == "mapaq":272 inspections = con.execute(273 "SELECT count(*) AS n, sum(CASE WHEN uid IS NOT NULL THEN 1 ELSE 0 "274 "END) AS matched, min(date_infraction) AS d0, "275 "max(date_infraction) AS d1, coalesce(sum(montant_amende),0) "276 "AS amendes "277 "FROM inspections").fetchone()278 runs = con.execute(279 "SELECT ts, ok, found, added, updated, removed, message FROM sync_log "280 "WHERE source=? ORDER BY ts DESC LIMIT 60", (sid,)).fetchall()281 ok_ts = sorted(r["ts"] for r in runs if r["ok"])282 cadence = None283 if len(ok_ts) >= 3:284 deltas = [b - a for a, b in zip(ok_ts, ok_ts[1:]) if b - a > 60]285 if deltas:286 cadence = statistics.median(deltas)287 return {"agg": agg, "sample": sample, "menus": menus,288 "inspections": inspections, "runs": runs, "cadence": cadence,289 "last_ok": next((r for r in runs if r["ok"]), None),290 "errors": [r for r in runs if not r["ok"]][:5],291 "err_count": sum(1 for r in runs if not r["ok"])}292293294# -- rendu ---------------------------------------------------------------------------295296def licence_lines(reg: dict, entry: dict | None) -> list[str]:297 out = [f"- **Cadre d'accès (registre, `acces_legal`)** : "298 f"{reg.get('acces_legal', '—')}"]299 blob = (reg.get("acces_legal") or "") + (reg.get("extraction") or "")300 low = blob.lower()301 if "odbl" in low or "openstreetmap" in low:302 out.append("- **Licence** : ODbL — « Données © contributeurs "303 "OpenStreetMap » ; attribution affichée sur les pages "304 "Sources et le pied de page de Resto-Ka.")305 elif "cc-by" in low or "données québec" in low or (306 entry and "donneesquebec" in " ".join(entry.get("urls", []))):307 out.append("- **Licence** : donnée ouverte CC-BY 4.0 (Données Québec) "308 "— mention de la source « MAPAQ / Données Québec » affichée.")309 else:310 out.append("- **Scraping / API** : User-Agent identifiable "311 "`RestoKaBot/1.0 (+https://www.resto-ka.com/bot; "312 "contact@spboucher.ai)`, throttling poli, aucun "313 "contournement d'accès ; les fiches pointent vers la "314 "source d'origine.")315 out.append("- Retrait sur demande : contact@spboucher.ai.")316 return out317318319def render_fiche(reg: dict, entry: dict | None, stats: dict | None,320 now: str) -> str:321 sid = reg["id"]322 name = reg.get("name", sid)323 status = reg.get("status", "—")324 etat = status.split("—")[0].split("(")[0].strip()325 out = [f"# {name} — connecteur `{sid}`", "",326 f"_Fiche générée automatiquement par "327 f"`scripts/gen_connector_docs.py` le {now} — ne pas éditer à la "328 f"main, régénérer._", ""]329 agg = stats["agg"] if stats else None330 vol = f" · Restos actifs : {agg['act']}/{agg['total']}" if agg and \331 agg["total"] else ""332 fam = entry["backend_family"] if entry else "—"333 out.append(f"**État : {etat}** · Palier (tier) : {reg.get('tier', '—')} · "334 f"Backend : {fam}{vol}")335 out.append("")336337 out.append("## Description de la source")338 out.append("")339 if entry and entry["banner"]:340 out.append(entry["banner"])341 out.append("")342 out.append(f"- **Plateforme** : {reg.get('platform', '—')} · **Site** : "343 f"{reg.get('url', '—')}")344 out.append(f"- **Extraction (registre)** : {reg.get('extraction', '—')}")345 out.append(f"- **Contexte de prix** : {reg.get('price_context') or '—'}")346 if entry:347 out.append(f"- **Module** : `{entry['path']}` — `{entry['class']}`")348 else:349 out.append("- **Module** : aucun (connecteur à écrire — voir statut)")350 if reg.get("integrations"):351 n = len(reg["integrations"])352 inact = sum(1 for i in reg["integrations"]353 if i.get("status") == "inactif")354 out.append(f"- **Intégrations recensées** : {n} clés GUID "355 f"({n - inact} actives, {inact} retirées) — voir "356 f"`data/sources.json` et `data/ueat-discovered.json`")357 out.append("")358359 out.append("## Accès")360 out.append("")361 if entry:362 out.append(f"- **Type d'accès** : {entry['flavor']}")363 out.append(f"- **Endpoint de base** : {entry['endpoint'] or '—'}")364 if entry["urls"]:365 out.append("- **URLs du module** : " + " · ".join(entry["urls"][:4]))366 out.append(f"- **Pagination** : {entry['pagination']}")367 out.append(f"- **Backend anti-bot / rendu** : "368 f"{' ; '.join(entry['backends'])}")369 if entry["request_delay"] is not None:370 out.append(f"- **Politesse** : {entry['request_delay']} s entre "371 f"requêtes, timeout {entry['timeout']} s, UA "372 f"`RestoKaBot/1.0 (+https://www.resto-ka.com/bot)`")373 auth = "clé API requise (voir statut)" if "clé" in status else \374 "aucune — accès public/anonyme"375 out.append(f"- **Authentification** : {auth}")376 else:377 out.append(f"- Connecteur non écrit — accès prévu (registre) : "378 f"{reg.get('extraction', '—')}")379 out.append("")380381 out.append("## Champs récupérés → schéma cible")382 out.append("")383 if sid == "mapaq":384 out.append("Source d'**enrichissement** : alimente la table "385 "`inspections` (exploitant, établissement, adresse, dates, "386 "amende, motif) puis croisement conservateur avec "387 "`restaurants.uid` (nom normalisé + ville/code postal, ou "388 "code postal + civique + similarité de nom — colonne "389 "`matched_by`). Pas de fiches restaurant propres.")390 out.append("")391 elif agg and agg["total"]:392 out.append(f"Le connecteur alimente la table `restaurants` (et `menus` "393 f"le cas échéant). Complétude mesurée en SQL sur les "394 f"{agg['act']} fiches actives ; exemple tiré d'une ligne "395 f"réelle de la BD.")396 out.append("")397 out.append("| Colonne `restaurants` | Contenu | Renseignée (actives) "398 "| Exemple réel |")399 out.append("|---|---|---|---|")400 sample = stats["sample"]401 for col, label, _c in FIELDS:402 ex = example_value(col, sample[col]) if sample is not None else "—"403 out.append(f"| `{col}` | {label} | {pct(agg[f'f_{col}'], agg['act'])} "404 f"| {ex} |")405 out.append("")406 m = stats["menus"]407 if m and m["n"]:408 out.append(f"**Menus** : {m['n']} menus rattachés "409 f"({m['items']} items, {m['ctx']} contexte(s) de prix, "410 f"dernière capture {esc(m['fresh'] or '—', 20)}) — table "411 f"`menus` (sections → items → options, prix CAD).")412 out.append("")413 else:414 out.append("Aucune fiche en BD pour cette source (connecteur en "415 "attente ou clé manquante) — schéma cible : table "416 "`restaurants` + `menus`.")417 out.append("")418419 out.append("## Fréquence & budget")420 out.append("")421 out.append(f"- **Cadence déclarée (registre)** : {reg.get('cadence', '—')}")422 if stats:423 cad = fmt_secs(stats["cadence"]) if stats["cadence"] else "—"424 out.append(f"- **Cadence observée** (médiane sync_log) : {cad}")425 lo = stats["last_ok"]426 if lo:427 out.append(f"- **Dernier passage OK** : {fmt_ts(lo['ts'])} — "428 f"{lo['found'] or 0} trouvées, +{lo['added'] or 0} / "429 f"~{lo['updated'] or 0} / -{lo['removed'] or 0}")430 if entry and entry["budgets"]:431 caps = ", ".join(f"`{k}` = {v}" for k, v in sorted(entry["budgets"].items()))432 out.append(f"- **Caps / budgets du module** : {caps}")433 if entry and entry.get("use_detail_cache"):434 out.append("- **Cache des payloads détail** : activé (table "435 "`detail_cache`)")436 out.append("")437438 out.append("## Volumétrie & complétude")439 out.append("")440 if agg and agg["total"]:441 out.append(f"- **Fiches en BD** : {agg['total']} au total, "442 f"**{agg['act']} actives**")443 out.append(f"- **Première ingestion** : {fmt_ts(agg['first_seen'])[:10]} "444 f"· **Dernière observation** : {fmt_ts(agg['last_seen'])[:10]}")445 out.append(f"- **Complétude clé (actives)** : GPS "446 f"{pct(agg['f_lat'], agg['act'])} · adresse "447 f"{pct(agg['f_address'], agg['act'])} · cuisines "448 f"{pct(agg['f_cuisines'], agg['act'])} · horaires "449 f"{pct(agg['f_hours'], agg['act'])} · site web "450 f"{pct(agg['f_website'], agg['act'])}")451 if stats and stats["inspections"] and stats["inspections"]["n"]:452 i = stats["inspections"]453 amendes = f"{i['amendes']:,.0f}".replace(",", " ")454 out.append(f"- **Inspections MAPAQ** : {i['n']} condamnations "455 f"({i['matched']} croisées avec un resto, amendes cumulées "456 f"{amendes} $), infractions de {i['d0']} à {i['d1']}")457 if not (agg and agg["total"]) and not (stats and stats["inspections"]):458 out.append("- Aucune donnée en BD pour cette source.")459 if stats:460 out.append(f"- **Runs journalisés (60 derniers)** : "461 f"{len(stats['runs'])}, dont {stats['err_count']} en erreur")462 out.append("")463464 out.append("## Erreurs connues & dépannage")465 out.append("")466 if stats and stats["errors"]:467 out.append("| Date | Message (sync_log) |")468 out.append("|---|---|")469 for r in stats["errors"]:470 out.append(f"| {fmt_ts(r['ts'])} | {esc(r['message'] or '', 160)} |")471 out.append("")472 else:473 out.append("Aucune erreur dans les 60 derniers runs journalisés.")474 out.append("")475 if etat != "actif":476 out.append(f"**Note du registre** : {status}")477 out.append("")478 out.append(f"Rejouer la source seule : `python3 run.py sync {sid}` · "479 f"vérifier `sync_log` (`SELECT * FROM sync_log WHERE "480 f"source='{sid}' ORDER BY ts DESC LIMIT 5;`).")481 out.append("")482483 out.append("## Licence, attribution & conditions")484 out.append("")485 out.extend(licence_lines(reg, entry))486 out.append("")487488 out.append("## Historique")489 out.append("")490 if agg and agg["first_seen"]:491 out.append(f"- {fmt_ts(agg['first_seen'])[:10]} — premières fiches de "492 f"la source ingérées dans la BD.")493 blob = " ".join(str(reg.get(k, "")) for k in ("status", "notes",494 "extraction", "acces_legal"))495 for d in sorted({m.group(0) for m in DATE_RE.finditer(blob)}):496 out.append(f"- {d} — date mentionnée au registre (voir `status`/notes).")497 out.append("- 2026-08-18 — vague d'enrichissement : standardisation de la "498 "documentation des connecteurs (fiche générée par "499 "`scripts/gen_connector_docs.py`).")500 out.append("")501 return "\n".join(out)502503504def main() -> None:505 now = datetime.now().strftime("%Y-%m-%d %H:%M")506 registry = json.loads(SOURCES_JSON.read_text(encoding="utf-8"))["sources"]507 con = sqlite3.connect(DB_PATH)508 con.row_factory = sqlite3.Row509510 by_sid: dict[str, dict] = {}511 for path in sorted(CONN_DIR.glob("*.py")) + EXTRA_MODULES:512 if path.stem in SKIP_MODULES or not path.exists():513 continue514 for e in introspect_module(path):515 by_sid[e["source_id"]] = e516517 DOCS_DIR.mkdir(parents=True, exist_ok=True)518 for old in DOCS_DIR.glob("*.md"):519 old.unlink()520521 rows = []522 for reg in registry:523 sid = reg["id"]524 entry = by_sid.get(sid)525 stats = db_stats(con, sid)526 (DOCS_DIR / f"{sid}.md").write_text(527 render_fiche(reg, entry, stats, now), encoding="utf-8")528 agg = stats["agg"]529 lo = stats["last_ok"]530 etat = reg.get("status", "—").split("—")[0].split("(")[0].strip()531 rows.append(532 f"| [`{sid}`]({sid}.md) | {esc(reg.get('name', sid), 40)} "533 f"| T{reg.get('tier', '—')} "534 f"| {esc(entry['flavor'] if entry else '—', 40)} "535 f"| {entry['backend_family'] if entry else '—'} "536 f"| {agg['act']}/{agg['total']} "537 f"| {pct(agg['f_lat'], agg['act'])} "538 f"| {pct(agg['f_hours'], agg['act'])} "539 f"| {pct(agg['f_cuisines'], agg['act'])} "540 f"| {esc(etat, 30)} | {fmt_ts(lo['ts']) if lo else '—'} |")541542 total_act = con.execute(543 "SELECT coalesce(sum(active),0) FROM restaurants").fetchone()[0]544 idx = [545 "# Resto-Ka — Index des connecteurs", "",546 f"_Généré automatiquement par `scripts/gen_connector_docs.py` le {now} "547 f"— ne pas éditer à la main, régénérer._", "",548 f"**{len(registry)} sources au registre** · **{total_act} restos "549 f"actifs** en BD.", "",550 "| Source | Nom | Tier | Type d'accès | Backend | Actifs/Total | GPS "551 "| Horaires | Cuisines | État | Dernier sync OK |",552 "|---|---|---|---|---|---|---|---|---|---|---|",553 ]554 idx.extend(rows)555 idx.append("")556 (DOCS_DIR / "INDEX.md").write_text("\n".join(idx), encoding="utf-8")557 con.close()558 print(f"[gen_connector_docs] {len(registry)} fiches + INDEX.md écrits dans "559 f"{DOCS_DIR}")560561562if __name__ == "__main__":563 main()564