Python 67%
TypeScript 18.2%
CSS 14.4%
1#!/usr/bin/env python32# -----------------------------------------------------------------------------3# Immo-Ka — Agrégateur de maisons à vendre (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 par connecteur nommé8# (data/sources.json) + UNE fiche par famille dynamique (remax_ag_*,9# via_ag_*, c21_ag_*, source.immo), en croisant trois sources de vérité :10# 1. les registres JSON : data/sources.json + data/*_agencies.json11# 2. l'introspection code : immoka/connectors/* (registre CONNECTORS,12# en-têtes de modules, budgets env IMMOKA_*_DETAIL_LIMIT, délais)13# 3. la base vivante : data/immoka.db (listings, sync_log)14#15# REJOUABLE : ré-exécuter le script régénère tout docs/connecteurs/.16# Usage : python3 scripts/gen_connector_docs.py17# -----------------------------------------------------------------------------18from __future__ import annotations1920import datetime21import json22import re23import sqlite324import sys25from pathlib import Path2627ROOT = Path(__file__).resolve().parent.parent28sys.path.insert(0, str(ROOT))2930DATA = ROOT / "data"31OUT = ROOT / "docs" / "connecteurs"32DB = DATA / "immoka.db"3334from immoka.connectors import CONNECTORS # noqa: E402 (auto-découverte)3536NOW = datetime.datetime.now().strftime("%Y-%m-%d %H:%M")37GEN_NOTE = ("*Généré le {} par `scripts/gen_connector_docs.py` — fichier "38 "produit automatiquement, ne pas éditer à la main.*".format(NOW))3940# --- mécanismes clés à mentionner dans les fiches concernées ------------------41MECANISMES: dict[str, list[str]] = {42 "remax_quebec": [43 "**Clé Meilisearch auto** : le site expose une clé *search-only* dans sa "44 "config publique ; le connecteur interroge directement l'index "45 "`inscriptions` (plafond 1 000 hits/requête → shard par FSA G/H/J + "46 "dédoublonnage par n° d'inscription, couverture validée vs total global).",47 "**Cache détail v4** : la fiche détail est mise en cache sous la clé "48 "`v4|<prix>` — tableau « Libellé | Valeur » + Détails financiers "49 "(taxes municipales/scolaires, évaluations municipales) ; re-téléchargée "50 "si le prix change.",51 ],52 "barnes_quebec": [53 "**Clé Algolia auto-extraite** : App ID / admin key / préfixe d'index "54 "lus dans les PHPVars du plugin `barnes-algolia` (wp_localize_script), "55 "avec cache et repli sur les derniers identifiants connus — la clé a "56 "déjà été rotée (403 le 2026-08-15) et le connecteur s'auto-répare.",57 "Algolia ne contient pas les photos : elles sont récupérées sur la "58 "fiche détail.",59 ],60 "remax_ag": [61 "**Backup du flux central** : plan B si la clé Meilisearch de "62 "remax-quebec.com est rotée/bloquée. Énumération par sitemap "63 "(1 requête → tout le parc) sur les plateformes « nos-proprietes » et "64 "« centris-other ».",65 "**Dédup Centris** : le n° Centris (dans l'URL) sert de clé de "66 "déduplication contre le flux central — les doublons sont masqués "67 "(`dup_hidden=1`), aucun double-comptage.",68 ],69}7071TRANSVERSES = """## Mécanismes transverses (tous connecteurs)7273- **Enrichissements détail budgétés** : chaque connecteur qui visite les pages74 détail plafonne le nombre de fiches par exécution via une variable75 d'environnement `IMMOKA_*_DETAIL_LIMIT` (défauts dans le tableau des fiches).76 Les fiches déjà en cache BD (`detail_cache`) sont réutilisées gratuitement ;77 sur plusieurs cycles, tout le parc finit enrichi.78- **Dédup Centris + adresse** (`immoka/db.py`) : les doublons inter-sources79 (même n° Centris via les sous-agences, ou même adresse normalisée) sont80 marqués `dup_hidden=1` — {dup_total} annonces masquées actuellement — et la81 lecture filtre en `AND dup_hidden=0`.82- **Jointure rôle d'évaluation** (`immoka/vraiprix_local.py`) : appariement83 local FTS par adresse contre `data/vraiprix.db` (3,7 M unités d'évaluation).84 Remplit lat/lng manquants, l'estimation Vrai-Prix (P10-P90 + lien) et les85 champs du rôle (année de construction, superficies, évaluations). Impact :86 **year_built rempli à {yb_pct:.0f} %** des annonces visibles, dont87 {yb_role_pct:.0f} % proviennent du rôle (`year_built_source: role`).88- **Historique — vagues du 2026-08-18** :89 - `7911d58` Vague 1 : clé Algolia auto-extraite (Barnes), taxes/éval RE/MAX90 (cache détail v4), specs Via Capitale, Kijiji étendu.91 - `e9bc9d7` Vague 2 : jointure au rôle d'évaluation, fiches92 Sutton/Royal LePage/LesPAC enrichies, days_on_market, dédup par adresse.93"""949596# --- helpers ------------------------------------------------------------------97def module_header(path: Path) -> str:98 """Bloc de commentaires d'en-tête (#) ou docstring d'un module."""99 try:100 text = path.read_text(encoding="utf-8")101 except OSError:102 return ""103 lines = []104 for line in text.splitlines():105 if line.startswith("#!"):106 continue107 if line.startswith("#"):108 s = line.lstrip("#").rstrip()109 if s.startswith(" "):110 s = s[1:]111 if set(s) <= {"-", " "}:112 continue113 lines.append(s)114 elif lines:115 break116 elif line.strip():117 break118 if not lines:119 m = re.match(r'\s*(?:"""|\'\'\')(.*?)(?:"""|\'\'\')', text, re.S)120 if m:121 lines = [ln.rstrip() for ln in m.group(1).strip().splitlines()]122 return "\n".join("> " + (ln or "") for ln in lines)123124125def env_budgets(path: Path) -> list[tuple[str, str]]:126 try:127 src = path.read_text(encoding="utf-8")128 except OSError:129 return []130 return re.findall(r'os\.environ\.get\(\s*"(IMMOKA_[A-Z0-9_]+)"\s*,\s*"([^"]*)"', src)131132133def fmt_ts(ts) -> str:134 if not ts:135 return "—"136 return datetime.datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M")137138139def pct(part, whole) -> str:140 if not whole:141 return "—"142 return f"{100.0 * (part or 0) / whole:.1f} %"143144145def nfr(n) -> str:146 return f"{n:,}".replace(",", " ") if isinstance(n, int) else str(n)147148149# --- collecte BD ----------------------------------------------------------------150def db_stats() -> tuple[dict, dict]:151 con = sqlite3.connect(f"file:{DB}?mode=ro", uri=True)152 con.row_factory = sqlite3.Row153 V = "active=1 AND dup_hidden=0"154 rows = con.execute(f"""155 SELECT source,156 COUNT(*) AS total,157 SUM(active=1) AS actives,158 SUM({V}) AS vis,159 SUM(active=1 AND dup_hidden=1) AS dups,160 SUM({V} AND lat IS NOT NULL AND lng IS NOT NULL) AS gps,161 SUM({V} AND images IS NOT NULL AND images NOT IN ('','[]')) AS photos,162 SUM({V} AND price IS NOT NULL) AS prix,163 SUM({V} AND bedrooms IS NOT NULL) AS cac,164 SUM({V} AND bathrooms IS NOT NULL) AS sdb,165 SUM({V} AND year_built IS NOT NULL) AS annee,166 SUM({V} AND details LIKE '%year_built_source%') AS annee_role,167 SUM({V} AND (details LIKE '%Taxes municipales%'168 OR details LIKE '%Taxes scolaires%'169 OR details LIKE '%valuation municipale%')) AS taxes,170 SUM({V} AND vraiprix IS NOT NULL) AS vp171 FROM listings GROUP BY source""").fetchall()172 stats = {r["source"]: dict(r) for r in rows}173174 syncs: dict[str, list] = {}175 for r in con.execute("SELECT source, ts, found, added, updated, removed,"176 " ok, message FROM sync_log ORDER BY ts"):177 syncs.setdefault(r["source"], []).append(dict(r))178 con.close()179 return stats, syncs180181182def agg(stats: dict, ids: list[str]) -> dict:183 keys = ("total", "actives", "vis", "dups", "gps", "photos", "prix", "cac",184 "sdb", "annee", "annee_role", "taxes", "vp")185 out = {k: 0 for k in keys}186 for i in ids:187 s = stats.get(i)188 if s:189 for k in keys:190 out[k] += s[k] or 0191 return out192193194def completude_md(s: dict) -> str:195 v = s["vis"] or 0196 rows = [197 ("Annonces en base (toutes)", nfr(s["total"])),198 ("Actives", nfr(s["actives"] or 0)),199 ("Visibles (après dédup)", nfr(v)),200 ("Masquées par la dédup", nfr(s["dups"] or 0)),201 ("GPS (lat/lng)", pct(s["gps"], v)),202 ("Photos", pct(s["photos"], v)),203 ("Prix", pct(s["prix"], v)),204 ("CAC (chambres)", pct(s["cac"], v)),205 ("SDB (salles de bains)", pct(s["sdb"], v)),206 ("Année de construction", pct(s["annee"], v)207 + (f" (dont rôle : {pct(s['annee_role'], s['annee'])})" if s["annee"] else "")),208 ("Taxes / évaluation municipale", pct(s["taxes"], v)),209 ("Estimation Vrai-Prix (rôle)", pct(s["vp"], v)),210 ]211 md = "| Indicateur | Valeur |\n|---|---|\n"212 md += "\n".join(f"| {k} | {val} |" for k, val in rows)213 return md214215216def sync_md(syncs: dict, sid: str) -> str:217 hist = syncs.get(sid) or []218 if not hist:219 return "Aucune entrée `sync_log` pour cette source."220 last = hist[-1]221 recent = hist[-10:]222 errs = [h for h in recent if not h["ok"]]223 md = (f"- **Dernier sync** : {fmt_ts(last['ts'])} — trouvé {nfr(last['found'] or 0)}, "224 f"+{last['added'] or 0} / ~{last['updated'] or 0} / -{last['removed'] or 0} — "225 f"{'OK' if last['ok'] else 'ÉCHEC'}"226 + (f" ({last['message']})" if last["message"] and last["message"] != "ok" else ""))227 md += f"\n- **Erreurs récentes** : {len(errs)} sur les {len(recent)} derniers syncs"228 if errs:229 e = errs[-1]230 md += f" — dernière : {fmt_ts(e['ts'])} « {e['message']} »"231 return md232233234def mecanique_md(cls) -> str:235 if cls is None:236 return "_Pas de classe connecteur dédiée (voir la note du registre)._"237 mod = sys.modules[cls.__module__]238 path = Path(mod.__file__)239 md = f"- Module : `immoka/connectors/{path.name}` — classe `{cls.__name__}`\n"240 md += f"- Délai entre requêtes : {getattr(cls, 'request_delay', '—')} s ; "241 md += ("cache détail BD : oui" if getattr(cls, "use_detail_cache", False)242 else "cache détail BD : non")243 budgets = env_budgets(path)244 if budgets:245 md += "\n- Budgets env (enrichissement détail plafonné/exécution) : "246 md += ", ".join(f"`{k}` (défaut {v})" for k, v in budgets)247 hdr = module_header(path)248 if hdr:249 md += "\n\n**En-tête du module :**\n\n" + hdr250 return md251252253# --- familles dynamiques ---------------------------------------------------------254FAMILLES = [255 dict(slug="famille-remax-ag", prefix="remax_ag_", registry="remax_agencies.json",256 module="remax_agences",257 titre="Famille RE/MAX sous-agences (`remax_ag_*`)",258 desc="Un connecteur par sous-agence RE/MAX (sites propres, indépendants du "259 "portail central), généré dynamiquement depuis `data/remax_agencies.json`."),260 dict(slug="famille-via-ag", prefix="via_ag_", registry="via_capitale_agencies.json",261 module="via_capitale_agences",262 titre="Famille Via Capitale agences (`via_ag_*`)",263 desc="Connecteurs par bannière Via Capitale, générés depuis "264 "`data/via_capitale_agencies.json` (comptes API source.immo par agence)."),265 dict(slug="famille-c21-ag", prefix="c21_ag_", registry="century21_agencies.json",266 module="century21_agences",267 titre="Famille Century 21 (`c21_ag_*`)",268 desc="Connecteurs par compte Century 21, générés depuis "269 "`data/century21_agencies.json` (plateforme source.immo)."),270 dict(slug="famille-source-immo", prefix=None, registry="source_immo_agencies.json",271 module="agences_source_immo",272 titre="Famille source.immo (agences indépendantes)",273 desc="Agences indépendantes servies par la plateforme source.immo "274 "(comptes/API dans `data/source_immo_agencies.json`)."),275]276277278def famille_members(fam: dict) -> list[str]:279 """source_ids des membres instanciés (registre CONNECTORS)."""280 return sorted(sid for sid, cls in CONNECTORS.items()281 if cls.__module__.endswith("." + fam["module"]))282283284def write_famille(fam: dict, stats: dict, syncs: dict, named_ids: set) -> None:285 members = famille_members(fam)286 reg_path = DATA / fam["registry"]287 registry = json.loads(reg_path.read_text(encoding="utf-8"))288 mod_path = ROOT / "immoka" / "connectors" / (fam["module"] + ".py")289290 md = [f"# {fam['titre']}", "", GEN_NOTE, "", fam["desc"], ""]291 md += [f"- Registre : `data/{fam['registry']}` — **{len(registry)} entrées**",292 f"- Connecteurs instanciés : **{len(members)}**",293 f"- Module : `immoka/connectors/{fam['module']}.py`", ""]294 hdr = module_header(mod_path)295 if hdr:296 md += ["**En-tête du module :**", "", hdr, ""]297 for key, notes in MECANISMES.items():298 if fam["slug"].startswith("famille-remax") and key == "remax_ag":299 md += ["## Mécanismes clés", ""]300 md += [f"- {n}" for n in notes] + [""]301 budgets = env_budgets(mod_path)302 if budgets:303 md += ["Budgets env : " + ", ".join(f"`{k}` (défaut {v})" for k, v in budgets), ""]304305 md += ["## Membres et statuts", "",306 "| source_id | Domaine | Plateforme | Visibles | Actives | Masquées dédup | Dernier sync | OK |",307 "|---|---|---|---|---|---|---|---|"]308 for sid in members:309 cls = CONNECTORS[sid]310 s = stats.get(sid) or {}311 hist = syncs.get(sid) or []312 last = hist[-1] if hist else None313 dom = getattr(cls, "domain", "") or getattr(cls, "site", "") or "—"314 plat = getattr(cls, "platform", "") or "source.immo"315 link = f"[`{sid}`]({sid}.md)" if sid in named_ids else f"`{sid}`"316 md.append("| {} | {} | {} | {} | {} | {} | {} | {} |".format(317 link, dom, plat, nfr(s.get("vis", 0) or 0), nfr(s.get("actives", 0) or 0),318 nfr(s.get("dups", 0) or 0), fmt_ts(last["ts"]) if last else "—",319 ("✅" if last["ok"] else "❌") if last else "—"))320 # entrées de registre sans classe instanciée321 inst_doms = {getattr(CONNECTORS[sid], "domain", "").replace("https://", "")322 .replace("http://", "").strip("/") for sid in members}323 orphans = []324 for entry in registry:325 dom = (entry.get("domain") or "").replace("https://", "").replace("http://", "").strip("/")326 if dom and dom not in inst_doms and ("www." + dom) not in inst_doms \327 and dom.removeprefix("www.") not in {d.removeprefix("www.") for d in inst_doms}:328 orphans.append(dom)329 if orphans:330 md += ["", f"Entrées du registre sans connecteur instancié ({len(orphans)}) : "331 + ", ".join(f"`{d}`" for d in orphans)]332333 a = agg(stats, members)334 md += ["", "## Volumétrie & complétude agrégées (famille, BD live)", "",335 completude_md(a), ""]336 (OUT / f"{fam['slug']}.md").write_text("\n".join(md) + "\n", encoding="utf-8")337338339# --- fiches nommées ---------------------------------------------------------------340def write_named(entry: dict, stats: dict, syncs: dict) -> None:341 sid = entry["id"]342 cls = CONNECTORS.get(entry.get("connector") or sid) or CONNECTORS.get(sid)343 md = [f"# {entry.get('name', sid)} (`{sid}`)", "", GEN_NOTE, "",344 "## Registre (`data/sources.json`)", ""]345 for k, label in (("url", "Site"), ("listing_url", "Recherche"),346 ("coverage", "Couverture"), ("type", "Type"),347 ("platform", "Plateforme"), ("role", "Rôle"),348 ("status", "Statut")):349 if entry.get(k):350 md.append(f"- **{label}** : {entry[k]}")351 note = entry.get("note") or entry.get("notes")352 if note:353 md += ["", f"> {note}"]354 md += ["", "## Mécanique (introspection du code)", "", mecanique_md(cls), ""]355 if sid in MECANISMES or (entry.get("connector") or "") in MECANISMES:356 md += ["## Mécanismes clés", ""]357 md += [f"- {n}" for n in MECANISMES.get(sid) or MECANISMES[entry["connector"]]]358 md += [""]359 s = stats.get(sid)360 md += ["## Volumétrie & complétude (BD live)", ""]361 if s:362 md += [completude_md(s), ""]363 else:364 md += ["_Aucune annonce en base pour cette source (voir la note du "365 "registre : couverture assurée ailleurs ou connecteur inactif)._", ""]366 md += ["## Synchronisation", "", sync_md(syncs, sid), "",367 "Voir aussi les [mécanismes transverses](INDEX.md#mécanismes-transverses-tous-connecteurs) "368 "(budgets détail, dédup Centris+adresse, jointure rôle vraiprix.db)."]369 (OUT / f"{sid}.md").write_text("\n".join(md) + "\n", encoding="utf-8")370371372# --- INDEX ------------------------------------------------------------------------373def write_index(sources: list, stats: dict, syncs: dict) -> None:374 tot = agg(stats, list(stats.keys()))375 md = ["# Immo-Ka — Connecteurs (documentation standardisée)", "", GEN_NOTE, "",376 f"**{len(CONNECTORS)} connecteurs** enregistrés (auto-découverte "377 f"`immoka/connectors/__init__.py`) : {len(sources)} connecteurs nommés "378 f"(`data/sources.json`) + 4 familles dynamiques (RE/MAX sous-agences, "379 f"Via Capitale, Century 21, source.immo).", "",380 f"Base vivante `data/immoka.db` : {nfr(tot['total'])} annonces, "381 f"{nfr(tot['actives'])} actives, {nfr(tot['vis'])} visibles après dédup "382 f"({nfr(tot['dups'])} masquées).", "",383 TRANSVERSES.format(dup_total=nfr(tot["dups"]),384 yb_pct=100.0 * tot["annee"] / max(tot["vis"], 1),385 yb_role_pct=100.0 * tot["annee_role"] / max(tot["annee"], 1)),386 "## Familles dynamiques", "",387 "| Famille | Fiche | Membres | Visibles |", "|---|---|---|---|"]388 for fam in FAMILLES:389 members = famille_members(fam)390 a = agg(stats, members)391 md.append(f"| {fam['titre'].split('(')[0].strip()} | "392 f"[{fam['slug']}]({fam['slug']}.md) | {len(members)} | {nfr(a['vis'])} |")393 md += ["", "## Connecteurs nommés", "",394 "| id | Nom | Type | Statut | Visibles | Année (dont rôle) | Dernier sync | OK |",395 "|---|---|---|---|---|---|---|---|"]396 for e in sorted(sources, key=lambda x: -(stats.get(x["id"], {}).get("vis") or 0)):397 sid = e["id"]398 s = stats.get(sid) or {}399 hist = syncs.get(sid) or []400 last = hist[-1] if hist else None401 annee = pct(s.get("annee"), s.get("vis")) if s else "—"402 md.append("| [`{}`]({}.md) | {} | {} | {} | {} | {} | {} | {} |".format(403 sid, sid, e.get("name", ""), e.get("type", "—"), e.get("status", "—"),404 nfr(s.get("vis", 0) or 0), annee,405 fmt_ts(last["ts"]) if last else "—",406 ("✅" if last["ok"] else "❌") if last else "—"))407 (OUT / "INDEX.md").write_text("\n".join(md) + "\n", encoding="utf-8")408409410def main() -> None:411 OUT.mkdir(parents=True, exist_ok=True)412 for old in OUT.glob("*.md"):413 old.unlink()414 sources = json.loads((DATA / "sources.json").read_text(encoding="utf-8"))["sources"]415 stats, syncs = db_stats()416 named_ids = {e["id"] for e in sources}417 for e in sources:418 write_named(e, stats, syncs)419 for fam in FAMILLES:420 write_famille(fam, stats, syncs, named_ids)421 write_index(sources, stats, syncs)422 print(f"[gen_connector_docs] {len(sources)} fiches nommées + {len(FAMILLES)} "423 f"familles + INDEX → {OUT}")424425426if __name__ == "__main__":427 main()428