SPB Git forge

spb/resto-ka

Public

Resto·Ka — tous les restaurants du Québec, menus complets et prix réels (famille ·Ka)

52commits 1branches 0releases
11.6 MBsize
maindefault branch
19 days agolast push
Python 69.3% TypeScript 16.7% CSS 7.9% JavaScript 4.7% HTML 1.4%
24.7 KB · 565 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 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