SPB Git

Repository Public

gen_connector_docs.py 24.6 KB 564 lines python

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
Files 26b24f7e6ec3938f34d02423f72c047e92c07a55
Menu

Theme