Python 68.8%
TypeScript 18.6%
CSS 8.7%
JavaScript 3.3%
HTML 0.6%
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