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