SPB Git forge

spb/rent-ka

Public
8commits 1branches 0releases
7.4 MBsize
maindefault branch
19 days agolast push
Python 68.8% TypeScript 18.6% CSS 8.7% JavaScript 3.3% HTML 0.6%
24.9 KB · 587 lines python
Raw Blame History
1#!/usr/bin/env python32# -----------------------------------------------------------------------------3# Rent-Ka — Agrégateur de logements à louer (province de Québec)4# Auteur : Simon-Pierre Boucher — contact@spboucher.ai5# scripts/gen_connector_docs.py : documentation STANDARDISÉE des connecteurs.6#7#   Génère docs/connecteurs/INDEX.md + une fiche docs/connecteurs/<source_id>.md8#   par connecteur, de façon 100 % programmatique et rejouable, en croisant :9#     1. le registre data/sources.json (nom, url, secteurs, statut, notes) ;10#     2. l'introspection STATIQUE (ast) du code de rentka/connectors/*.py :11#        classe, backend (direct / Firecrawl / Scrapfly), endpoint de base,12#        pagination, constantes de budget — sans exécuter les connecteurs ;13#     3. la BD live data/rentka.db : volumétrie, complétude des champs par14#        source (SQL), dernier sync OK, cadence observée, erreurs récentes.15#16#   Usage : python3 scripts/gen_connector_docs.py       (depuis la racine)17#   À relancer après tout changement de connecteur / registre / ingestion.18# -----------------------------------------------------------------------------19from __future__ import annotations2021import ast22import json23import re24import sqlite325import statistics26from datetime import datetime27from pathlib import Path2829ROOT = Path(__file__).resolve().parents[1]30CONN_DIR = ROOT / "rentka" / "connectors"31DOCS_DIR = ROOT / "docs" / "connecteurs"32DB_PATH = ROOT / "data" / "rentka.db"33SOURCES_JSON = ROOT / "data" / "sources.json"3435SKIP_MODULES = {"__init__", "base", "_detailutil"}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", "rent-ka.com",39               "nominatim", "overpass")4041# Colonnes de `listings` documentées dans la section « Champs récupérés » :42# (colonne, libellé, condition SQL de complétude sur les annonces actives)43FIELDS = [44    ("title", "Titre de l'annonce", "title IS NOT NULL AND title != ''"),45    ("address", "Adresse civique", "address IS NOT NULL AND address != ''"),46    ("city", "Ville (normalisée)", "city IS NOT NULL AND city != ''"),47    ("sector", "Secteur / quartier", "sector IS NOT NULL AND sector != ''"),48    ("unit_type", "Type d'unité (3½, 4½…)",49     "unit_type IS NOT NULL AND unit_type != ''"),50    ("price", "Loyer mensuel ($)", "price IS NOT NULL"),51    ("bedrooms", "Chambres", "bedrooms IS NOT NULL"),52    ("bathrooms", "Salles de bain", "bathrooms IS NOT NULL"),53    ("area_sqft", "Superficie (pi²)", "area_sqft IS NOT NULL"),54    ("availability_date", "Date de disponibilité",55     "availability_date IS NOT NULL AND availability_date != ''"),56    ("lat", "GPS (lat/lng)", "lat IS NOT NULL AND lng IS NOT NULL"),57    ("description", "Description", "description IS NOT NULL AND description != ''"),58    ("images", "Photos (JSON)", "images IS NOT NULL AND length(images) > 4"),59    ("amenities", "Commodités (JSON)",60     "amenities IS NOT NULL AND length(amenities) > 4"),61    ("details", "Détails additionnels (JSON)",62     "details IS NOT NULL AND length(details) > 4"),63    ("url", "URL de l'annonce source", "url IS NOT NULL AND url != ''"),64]656667# -- helpers de formatage ------------------------------------------------------6869def esc(s: str, limit: int = 100) -> str:70    """Échappe une valeur pour une cellule de tableau Markdown."""71    s = str(s).replace("\\", "\\\\").replace("|", "\\|")72    s = re.sub(r"\s+", " ", s).strip()73    return s[: limit - 1] + "…" if len(s) > limit else s747576def pct(n, d) -> str:77    if not d:78        return "—"79    return f"{100.0 * (n or 0) / d:.0f} %"808182def fmt_ts(ts) -> str:83    if not ts:84        return "—"85    return datetime.fromtimestamp(float(ts)).strftime("%Y-%m-%d %H:%M")868788def fmt_secs(sec: float) -> str:89    if sec < 5400:90        return f"≈ {sec / 60:.0f} min"91    if sec < 129600:92        return f"≈ {sec / 3600:.1f} h"93    return f"≈ {sec / 86400:.1f} j"949596def example_value(col: str, value) -> str:97    """Rend un exemple lisible tiré d'une ligne réelle de la BD."""98    if value is None or value == "":99        return "—"100    if col == "images":101        try:102            imgs = json.loads(value)103            if not imgs:104                return "—"105            return esc(f"{len(imgs)} photo(s) — {imgs[0]}", 90)106        except (ValueError, TypeError):107            return esc(value, 90)108    if col in ("amenities", "details"):109        return esc(value, 90)110    if col == "price":111        return f"{float(value):,.0f} $".replace(",", " ")112    if col in ("bedrooms", "bathrooms", "area_sqft"):113        v = float(value)114        return f"{v:g}"115    return esc(value, 90)116117118# -- introspection statique du code des connecteurs ----------------------------119120def banner_description(text: str, module: str) -> str:121    """Extrait la description du bandeau de commentaires en tête de module."""122    lines, started = [], False123    for raw in text.splitlines():124        if not raw.startswith("#"):125            if started or not raw.strip():126                break127            continue128        body = raw.lstrip("#").strip()129        if set(body) <= {"-", "="}:130            continue131        marker = f"connectors/{module}.py"132        if not started:133            if marker in body:134                started = True135                lines.append(body.split(":", 1)[-1].strip())136            continue137        lines.append(body)138    return " ".join(l for l in lines if l).strip()139140141def detect_backends(text: str) -> tuple[list[str], str]:142    """(liste détaillée, famille courte) des backends de fetch utilisés."""143    detailed, family = [], "direct"144    if re.search(r"self\.(get|post)\(", text):145        detailed.append("requests direct (session UA RentKaBot, throttling poli)")146    if ".scrapfly(" in text:147        detailed.append("Scrapfly (asp + render_js — contournement anti-bot)")148        family = "Scrapfly"149    if "get_rendered(" in text:150        detailed.append("Scrapfly render_js (HTML rendu, JavaScript exécuté"151                        " — ex-Firecrawl, migré 2026-08-27)")152        family = "Scrapfly" if family == "direct" else family153    if not detailed:154        detailed.append("requests direct")155    return detailed, family156157158def detect_flavor(text: str) -> str:159    low = text.lower()160    if "graphql" in low:161        return "API GraphQL interne"162    if "admin-ajax" in low:163        return "API admin-ajax (WordPress)"164    if "/wp-json" in low:165        return "API REST WordPress (wp-json)"166    if "sitemap" in low:167        return "sitemap XML + pages HTML"168    if re.search(r"\.json\(\)", text) and re.search(r"api[./_]", low):169        return "API JSON interne du site"170    return "pages HTML (rendu serveur)"171172173def detect_pagination(text: str, constants: dict) -> str:174    hits = []175    low = text.lower()176    if "js_scenario" in text:177        hits.append("défilement simulé (js_scenario Scrapfly)")178    if re.search(r"[?&]page=|[\"']page[\"']\s*[:=]|paged", low):179        hits.append("pagination par numéro de page")180    if re.search(r"[?&]offset=|[\"']offset[\"']", low):181        hits.append("pagination par offset")182    if "load_more" in low or "loadmore" in low:183        hits.append("bouton « charger plus » rejoué")184    roots = [k for k, v in constants.items()185             if isinstance(v, (list, tuple)) and len(v) > 1186             and k in ("ROOTS", "PAGES", "SECTIONS", "SECTEURS", "CITIES",187                       "REGIONS", "URLS", "LISTS")]188    if roots:189        n = len(constants[roots[0]])190        hits.append(f"multi-racines ({n} pages de départ : {roots[0]})")191    if not hits:192        hits.append("page de liste unique (pas de pagination)")193    return " ; ".join(hits)194195196def introspect_module(path: Path) -> list[dict]:197    """Retourne une entrée par classe connecteur du module (souvent 1)."""198    text = path.read_text(encoding="utf-8")199    module = path.stem200    try:201        tree = ast.parse(text)202    except SyntaxError:203        return []204205    constants: dict = {}206    for node in tree.body:207        if isinstance(node, ast.Assign) and len(node.targets) == 1 \208                and isinstance(node.targets[0], ast.Name) \209                and node.targets[0].id.isupper():210            try:211                constants[node.targets[0].id] = ast.literal_eval(node.value)212            except (ValueError, TypeError, SyntaxError):213                seg = ast.get_source_segment(text, node.value) or ""214                urls = URL_RE.findall(seg)215                constants[node.targets[0].id] = urls if len(urls) > 1 else \216                    (urls[0] if urls else None)217218    urls_all = []219    for u in URL_RE.findall(text):220        u = u.rstrip('".')221        host = u.split("//", 1)[-1].split("/", 1)[0]222        if "." not in host:      # fragment de f-string, pas une vraie URL223            continue224        if not any(h in u for h in INFRA_HOSTS) and u not in urls_all:225            urls_all.append(u)226227    endpoint = None228    for name in ("BASE", "BASE_URL", "API", "API_URL", "API_BASE", "ROOT",229                 "SITE", "URL", "LIST_URL", "HOST"):230        v = constants.get(name)231        if isinstance(v, str) and v.startswith("http"):232            endpoint = v233            break234    if not endpoint and urls_all:235        endpoint = urls_all[0]236237    budgets = {k: v for k, v in constants.items()238               if isinstance(v, (int, float)) and not isinstance(v, bool)239               and re.search(r"MAX|CAP|LIMIT|BUDGET|TTL|PER_PAGE|PAGES|DELAY", k)}240241    backends, family = detect_backends(text)242    entries = []243    for node in tree.body:244        if not isinstance(node, ast.ClassDef):245            continue246        bases = {getattr(b, "id", getattr(b, "attr", "")) for b in node.bases}247        if not (bases & {"BaseConnector"}) and not any(248                b.endswith("Connector") for b in bases if b):249            continue250        attrs = {"request_delay": 0.6, "timeout": 30, "use_detail_cache": True,251                 "disabled": False, "source_id": ""}252        for sub in node.body:253            if isinstance(sub, ast.Assign) and len(sub.targets) == 1 \254                    and isinstance(sub.targets[0], ast.Name):255                try:256                    attrs[sub.targets[0].id] = ast.literal_eval(sub.value)257                except (ValueError, TypeError, SyntaxError):258                    pass259        if not attrs.get("source_id"):260            continue261        entries.append({262            "module": module, "path": f"rentka/connectors/{module}.py",263            "class": node.name, "class_doc": ast.get_docstring(node) or "",264            "banner": banner_description(text, module),265            "source_id": attrs["source_id"], "disabled": attrs["disabled"],266            "request_delay": attrs["request_delay"], "timeout": attrs["timeout"],267            "use_detail_cache": attrs["use_detail_cache"],268            "endpoint": endpoint, "urls": urls_all[:4], "budgets": budgets,269            "backends": backends, "backend_family": family,270            "flavor": detect_flavor(text),271            "pagination": detect_pagination(text, constants),272        })273    return entries274275276# -- BD live -------------------------------------------------------------------277278def db_stats(con: sqlite3.Connection, sid: str) -> dict:279    parts = ", ".join(280        f"sum(CASE WHEN active=1 AND {cond} THEN 1 ELSE 0 END) AS f_{col}"281        for col, _label, cond in FIELDS)282    row = con.execute(283        f"SELECT count(*) AS total, coalesce(sum(active),0) AS act, "284        f"min(first_seen) AS first_seen, max(last_seen) AS last_seen, {parts} "285        f"FROM listings WHERE source=?", (sid,)).fetchone()286    sample = con.execute(287        "SELECT * FROM listings WHERE source=? AND active=1 "288        "ORDER BY last_seen DESC LIMIT 1", (sid,)).fetchone()289    if sample is None:290        sample = con.execute(291            "SELECT * FROM listings WHERE source=? ORDER BY last_seen DESC "292            "LIMIT 1", (sid,)).fetchone()293294    runs = con.execute(295        "SELECT ts, ok, found, added, updated, removed, message FROM sync_log "296        "WHERE source=? ORDER BY ts DESC LIMIT 60", (sid,)).fetchall()297    ok_ts = sorted(r["ts"] for r in runs if r["ok"])298    cadence = None299    if len(ok_ts) >= 3:300        deltas = [b - a for a, b in zip(ok_ts, ok_ts[1:]) if b - a > 60]301        if deltas:302            cadence = statistics.median(deltas)303    last_ok = next((r for r in runs if r["ok"]), None)304    errors = [r for r in runs if not r["ok"]][:5]305    return {"agg": row, "sample": sample, "runs": runs, "cadence": cadence,306            "last_ok": last_ok, "errors": errors,307            "err_count": sum(1 for r in runs if not r["ok"])}308309310# -- rendu des fiches ------------------------------------------------------------311312def short_status(entry: dict, reg: list[dict]) -> str:313    if entry["disabled"]:314        return "désactivé (disabled=True)"315    status = (reg[0].get("status") or "") if reg else ""316    if status.startswith("actif (dégradé)"):317        return "actif (dégradé)"318    for p in ("actif", "suspendu", "non connectable"):319        if status.startswith(p):320            return p321    return status.split("—")[0].strip() or "actif (hors registre)"322323324def history_lines(reg: list[dict], stats: dict) -> list[str]:325    lines = []326    if stats["agg"]["first_seen"]:327        lines.append(f"- {fmt_ts(stats['agg']['first_seen'])[:10]} — premières "328                     "annonces de la source ingérées dans la BD.")329    seen = set()330    for r in reg:331        blob = " ".join(str(r.get(k, "")) for k in ("status", "notes"))332        for m in DATE_RE.finditer(blob):333            d = m.group(0)334            if d in seen:335                continue336            seen.add(d)337            ctx = blob[max(0, m.start() - 90): m.end() + 90]338            ctx = re.sub(r"\s+", " ", ctx).strip()339            lines.append(f"- {d} — mention au registre : « …{ctx}… »")340    lines.append("- 2026-08-18 — vague d'enrichissement : standardisation de la "341                 "documentation des connecteurs (fiche générée par "342                 "`scripts/gen_connector_docs.py`).")343    return lines344345346def render_fiche(entry: dict, reg: list[dict], stats: dict, now: str) -> str:347    sid = entry["source_id"]348    agg = stats["agg"]349    main = reg[0] if reg else {}350    name = main.get("name") or sid351    region = main.get("region") or "—"352    etat = short_status(entry, reg)353354    out = [f"# {name} — connecteur `{sid}`", ""]355    out.append(f"_Fiche générée automatiquement par "356               f"`scripts/gen_connector_docs.py` le {now} — ne pas éditer à la "357               f"main, régénérer._")358    out.append("")359    out.append(f"**État : {etat}** · Région : {region} · Backend : "360               f"{entry['backend_family']} · Annonces actives : "361               f"{agg['act']}/{agg['total']}")362    out.append("")363364    # -- Description ------------------------------------------------------------365    out.append("## Description de la source")366    out.append("")367    desc = entry["banner"] or entry["class_doc"].splitlines()[0] if (368        entry["banner"] or entry["class_doc"]) else ""369    if desc:370        out.append(desc)371        out.append("")372    out.append(f"- **Site** : {main.get('url', '—')}")373    out.append(f"- **Page des annonces** : {main.get('listing_url', '—')}")374    if main.get("sectors"):375        out.append(f"- **Secteurs couverts** : {main['sectors']}")376    out.append(f"- **Module** : `{entry['path']}` — classe `{entry['class']}`")377    if len(reg) > 1:378        others = ", ".join(f"`{r['id']}` ({r.get('name', '')})" for r in reg[1:])379        out.append(f"- **Entrées additionnelles du registre couvertes** : {others}")380    out.append("")381382    # -- Accès --------------------------------------------------------------------383    out.append("## Accès")384    out.append("")385    out.append(f"- **Type d'accès** : {entry['flavor']}")386    out.append(f"- **Endpoint de base** : {entry['endpoint'] or '—'}")387    if entry["urls"]:388        out.append("- **URLs de départ (constantes du module)** : "389                   + " · ".join(entry["urls"]))390    out.append("- **Authentification** : aucune — contenu public"391               + (" (clé Scrapfly côté Rent-Ka)" if entry["backend_family"] ==392                  "Scrapfly" else ""))393    out.append(f"- **Pagination** : {entry['pagination']}")394    out.append(f"- **Backend anti-bot / rendu** : {' ; '.join(entry['backends'])}")395    out.append(f"- **Politesse** : {entry['request_delay']} s entre requêtes, "396               f"timeout {entry['timeout']} s, User-Agent identifiable "397               f"`RentKaBot/1.0 (+https://www.rent-ka.com/bot)`")398    out.append("")399400    # -- Champs -------------------------------------------------------------------401    out.append("## Champs récupérés → schéma cible")402    out.append("")403    out.append(f"Le connecteur ({entry['backend_family']}, {entry['flavor']}) "404               f"alimente les colonnes de la table `listings`. Complétude "405               f"mesurée en SQL sur les {agg['act']} annonces actives ; exemple "406               f"tiré d'une ligne réelle de la BD.")407    out.append("")408    out.append("| Colonne `listings` | Contenu | Renseignée (actives) | Exemple réel |")409    out.append("|---|---|---|---|")410    sample = stats["sample"]411    for col, label, _cond in FIELDS:412        ex = example_value(col, sample[col]) if sample is not None else "—"413        out.append(f"| `{col}` | {label} | {pct(agg[f'f_{col}'], agg['act'])} "414                   f"| {ex} |")415    out.append("")416417    # -- Fréquence & budget ---------------------------------------------------------418    out.append("## Fréquence & budget")419    out.append("")420    cad = fmt_secs(stats["cadence"]) if stats["cadence"] else "—"421    out.append(f"- **Cadence observée** (médiane des passages OK, sync_log) : {cad}")422    lo = stats["last_ok"]423    if lo:424        out.append(f"- **Dernier passage OK** : {fmt_ts(lo['ts'])} — "425                   f"{lo['found'] or 0} trouvées, +{lo['added'] or 0} / "426                   f"~{lo['updated'] or 0} / -{lo['removed'] or 0}")427    else:428        out.append("- **Dernier passage OK** : aucun dans les 60 derniers runs")429    out.append(f"- **Throttling** : {entry['request_delay']} s entre requêtes "430               f"(constante de classe `request_delay`)")431    out.append(f"- **Cache des pages détail (BD `detail_cache`)** : "432               f"{'activé' if entry['use_detail_cache'] else 'désactivé'} — "433               f"évite de re-visiter les fiches inchangées")434    if entry["budgets"]:435        caps = ", ".join(f"`{k}` = {v}" for k, v in sorted(entry["budgets"].items()))436        out.append(f"- **Caps / budgets du module** : {caps}")437    out.append("")438439    # -- Volumétrie -------------------------------------------------------------------440    out.append("## Volumétrie & complétude")441    out.append("")442    out.append(f"- **Annonces 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'])} · prix "448               f"{pct(agg['f_price'], agg['act'])} · photos "449               f"{pct(agg['f_images'], agg['act'])} · description "450               f"{pct(agg['f_description'], agg['act'])}")451    out.append(f"- **Runs journalisés (60 derniers)** : {len(stats['runs'])}, "452               f"dont {stats['err_count']} en erreur")453    out.append("")454455    # -- Erreurs ------------------------------------------------------------------------456    out.append("## Erreurs connues & dépannage")457    out.append("")458    if stats["errors"]:459        out.append("| Date | Message (sync_log) |")460        out.append("|---|---|")461        for r in stats["errors"]:462            out.append(f"| {fmt_ts(r['ts'])} | {esc(r['message'] or '', 160)} |")463        out.append("")464    else:465        out.append("Aucune erreur dans les 60 derniers runs journalisés.")466        out.append("")467    status = main.get("status", "")468    if status and not status.strip() == "actif":469        out.append(f"**Note du registre** : {status}")470        out.append("")471    out.append(f"Rejouer la source seule : `python3 run.py sync {sid}` · "472               f"vérifier ensuite `sync_log` (`SELECT * FROM sync_log WHERE "473               f"source='{sid}' ORDER BY ts DESC LIMIT 5;`).")474    out.append("")475476    # -- Licence ------------------------------------------------------------------------477    out.append("## Licence, attribution & conditions")478    out.append("")479    out.append("- Scraping poli d'annonces **publiques** (données factuelles : "480               "prix, adresse, disponibilité) publiées par le gestionnaire sur "481               "son propre site.")482    out.append("- User-Agent **identifiable** avec page d'information et "483               "contact : `RentKaBot/1.0 (+https://www.rent-ka.com/bot; "484               "contact@spboucher.ai)` ; throttling "485               f"{entry['request_delay']} s ; cache détail pour minimiser les "486               "requêtes.")487    out.append("- Chaque annonce affichée sur Rent-Ka **pointe vers l'annonce "488               "d'origine** (colonne `url`) — la source garde le trafic de "489               "conversion.")490    out.append("- Retrait sur demande : contact@spboucher.ai.")491    out.append("")492493    # -- Historique ----------------------------------------------------------------------494    out.append("## Historique")495    out.append("")496    out.extend(history_lines(reg, stats))497    out.append("")498    return "\n".join(out)499500501# -- programme principal -----------------------------------------------------------502503def main() -> None:504    now = datetime.now().strftime("%Y-%m-%d %H:%M")505    registry = json.loads(SOURCES_JSON.read_text(encoding="utf-8"))["sources"]506    by_connector: dict[str, list[dict]] = {}507    for r in registry:508        key = r.get("connector") or ""509        if key:510            by_connector.setdefault(key, []).append(r)511    # l'entrée dont id == connector d'abord (entrée « principale »)512    for key, lst in by_connector.items():513        lst.sort(key=lambda r: (r["id"] != key, r["id"]))514515    con = sqlite3.connect(DB_PATH)516    con.row_factory = sqlite3.Row517518    entries: list[dict] = []519    for path in sorted(CONN_DIR.glob("*.py")):520        if path.stem in SKIP_MODULES:521            continue522        entries.extend(introspect_module(path))523    entries.sort(key=lambda e: e["source_id"])524525    DOCS_DIR.mkdir(parents=True, exist_ok=True)526    for old in DOCS_DIR.glob("*.md"):527        old.unlink()528529    index_rows = []530    covered = set()531    tot_active = 0532    for entry in entries:533        sid = entry["source_id"]534        reg = by_connector.get(sid) or by_connector.get(entry["module"]) or []535        for r in reg:536            covered.add(r["id"])537        stats = db_stats(con, sid)538        (DOCS_DIR / f"{sid}.md").write_text(539            render_fiche(entry, reg, stats, now), encoding="utf-8")540        agg = stats["agg"]541        tot_active += agg["act"] or 0542        lo = stats["last_ok"]543        main_r = reg[0] if reg else {}544        index_rows.append(545            f"| [`{sid}`]({sid}.md) | {esc(main_r.get('name', sid), 40)} "546            f"| {esc(main_r.get('region', '—'), 20)} "547            f"| {esc(entry['flavor'], 34)} | {entry['backend_family']} "548            f"| {agg['act']}/{agg['total']} "549            f"| {pct(agg['f_lat'], agg['act'])} "550            f"| {pct(agg['f_price'], agg['act'])} "551            f"| {pct(agg['f_images'], agg['act'])} "552            f"| {esc(short_status(entry, reg), 26)} "553            f"| {fmt_ts(lo['ts']) if lo else '—'} |")554555    # INDEX.md ------------------------------------------------------------------556    n_act = sum(1 for e in entries if not e["disabled"])557    idx = [558        "# Rent-Ka — Index des connecteurs", "",559        f"_Généré automatiquement par `scripts/gen_connector_docs.py` le {now} "560        f"— ne pas éditer à la main, régénérer._", "",561        f"**{len(entries)} connecteurs documentés** ({n_act} activés, "562        f"{len(entries) - n_act} désactivés) · **{tot_active} annonces "563        f"actives** au total · registre : {len(registry)} entrées.", "",564        "| Connecteur | Nom | Région | Type d'accès | Backend | Actives/Total "565        "| GPS | Prix | Photos | État | Dernier sync OK |",566        "|---|---|---|---|---|---|---|---|---|---|---|",567    ]568    idx.extend(index_rows)569    leftovers = [r for r in registry if r["id"] not in covered]570    if leftovers:571        idx += ["", "## Entrées du registre sans module connecteur", "",572                "Sources recensées mais non connectées (voir le champ `status` "573                "du registre pour la raison détaillée) :", ""]574        for r in sorted(leftovers, key=lambda x: x["id"]):575            reason = esc((r.get("status") or "").split("—")[0], 60)576            idx.append(f"- `{r['id']}` — {esc(r.get('name', ''), 60)} "577                       f"({reason or '—'})")578    idx.append("")579    (DOCS_DIR / "INDEX.md").write_text("\n".join(idx), encoding="utf-8")580    con.close()581    print(f"[gen_connector_docs] {len(entries)} fiches + INDEX.md écrits dans "582          f"{DOCS_DIR}")583584585if __name__ == "__main__":586    main()587