#!/usr/bin/env python3 # ----------------------------------------------------------------------------- # Immo-Ka — Agrégateur de maisons à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # scripts/gen_connector_docs.py : documentation STANDARDISÉE des connecteurs. # # Génère docs/connecteurs/INDEX.md + une fiche par connecteur nommé # (data/sources.json) + UNE fiche par famille dynamique (remax_ag_*, # via_ag_*, c21_ag_*, source.immo), en croisant trois sources de vérité : # 1. les registres JSON : data/sources.json + data/*_agencies.json # 2. l'introspection code : immoka/connectors/* (registre CONNECTORS, # en-têtes de modules, budgets env IMMOKA_*_DETAIL_LIMIT, délais) # 3. la base vivante : data/immoka.db (listings, sync_log) # # REJOUABLE : ré-exécuter le script régénère tout docs/connecteurs/. # Usage : python3 scripts/gen_connector_docs.py # ----------------------------------------------------------------------------- from __future__ import annotations import datetime import json import re import sqlite3 import sys from pathlib import Path ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT)) DATA = ROOT / "data" OUT = ROOT / "docs" / "connecteurs" DB = DATA / "immoka.db" from immoka.connectors import CONNECTORS # noqa: E402 (auto-découverte) NOW = datetime.datetime.now().strftime("%Y-%m-%d %H:%M") GEN_NOTE = ("*Généré le {} par `scripts/gen_connector_docs.py` — fichier " "produit automatiquement, ne pas éditer à la main.*".format(NOW)) # --- mécanismes clés à mentionner dans les fiches concernées ------------------ MECANISMES: dict[str, list[str]] = { "remax_quebec": [ "**Clé Meilisearch auto** : le site expose une clé *search-only* dans sa " "config publique ; le connecteur interroge directement l'index " "`inscriptions` (plafond 1 000 hits/requête → shard par FSA G/H/J + " "dédoublonnage par n° d'inscription, couverture validée vs total global).", "**Cache détail v4** : la fiche détail est mise en cache sous la clé " "`v4|` — tableau « Libellé | Valeur » + Détails financiers " "(taxes municipales/scolaires, évaluations municipales) ; re-téléchargée " "si le prix change.", ], "barnes_quebec": [ "**Clé Algolia auto-extraite** : App ID / admin key / préfixe d'index " "lus dans les PHPVars du plugin `barnes-algolia` (wp_localize_script), " "avec cache et repli sur les derniers identifiants connus — la clé a " "déjà été rotée (403 le 2026-08-15) et le connecteur s'auto-répare.", "Algolia ne contient pas les photos : elles sont récupérées sur la " "fiche détail.", ], "remax_ag": [ "**Backup du flux central** : plan B si la clé Meilisearch de " "remax-quebec.com est rotée/bloquée. Énumération par sitemap " "(1 requête → tout le parc) sur les plateformes « nos-proprietes » et " "« centris-other ».", "**Dédup Centris** : le n° Centris (dans l'URL) sert de clé de " "déduplication contre le flux central — les doublons sont masqués " "(`dup_hidden=1`), aucun double-comptage.", ], } TRANSVERSES = """## Mécanismes transverses (tous connecteurs) - **Enrichissements détail budgétés** : chaque connecteur qui visite les pages détail plafonne le nombre de fiches par exécution via une variable d'environnement `IMMOKA_*_DETAIL_LIMIT` (défauts dans le tableau des fiches). Les fiches déjà en cache BD (`detail_cache`) sont réutilisées gratuitement ; sur plusieurs cycles, tout le parc finit enrichi. - **Dédup Centris + adresse** (`immoka/db.py`) : les doublons inter-sources (même n° Centris via les sous-agences, ou même adresse normalisée) sont marqués `dup_hidden=1` — {dup_total} annonces masquées actuellement — et la lecture filtre en `AND dup_hidden=0`. - **Jointure rôle d'évaluation** (`immoka/vraiprix_local.py`) : appariement local FTS par adresse contre `data/vraiprix.db` (3,7 M unités d'évaluation). Remplit lat/lng manquants, l'estimation Vrai-Prix (P10-P90 + lien) et les champs du rôle (année de construction, superficies, évaluations). Impact : **year_built rempli à {yb_pct:.0f} %** des annonces visibles, dont {yb_role_pct:.0f} % proviennent du rôle (`year_built_source: role`). - **Historique — vagues du 2026-08-18** : - `7911d58` Vague 1 : clé Algolia auto-extraite (Barnes), taxes/éval RE/MAX (cache détail v4), specs Via Capitale, Kijiji étendu. - `e9bc9d7` Vague 2 : jointure au rôle d'évaluation, fiches Sutton/Royal LePage/LesPAC enrichies, days_on_market, dédup par adresse. """ # --- helpers ------------------------------------------------------------------ def module_header(path: Path) -> str: """Bloc de commentaires d'en-tête (#) ou docstring d'un module.""" try: text = path.read_text(encoding="utf-8") except OSError: return "" lines = [] for line in text.splitlines(): if line.startswith("#!"): continue if line.startswith("#"): s = line.lstrip("#").rstrip() if s.startswith(" "): s = s[1:] if set(s) <= {"-", " "}: continue lines.append(s) elif lines: break elif line.strip(): break if not lines: m = re.match(r'\s*(?:"""|\'\'\')(.*?)(?:"""|\'\'\')', text, re.S) if m: lines = [ln.rstrip() for ln in m.group(1).strip().splitlines()] return "\n".join("> " + (ln or "") for ln in lines) def env_budgets(path: Path) -> list[tuple[str, str]]: try: src = path.read_text(encoding="utf-8") except OSError: return [] return re.findall(r'os\.environ\.get\(\s*"(IMMOKA_[A-Z0-9_]+)"\s*,\s*"([^"]*)"', src) def fmt_ts(ts) -> str: if not ts: return "—" return datetime.datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M") def pct(part, whole) -> str: if not whole: return "—" return f"{100.0 * (part or 0) / whole:.1f} %" def nfr(n) -> str: return f"{n:,}".replace(",", " ") if isinstance(n, int) else str(n) # --- collecte BD ---------------------------------------------------------------- def db_stats() -> tuple[dict, dict]: con = sqlite3.connect(f"file:{DB}?mode=ro", uri=True) con.row_factory = sqlite3.Row V = "active=1 AND dup_hidden=0" rows = con.execute(f""" SELECT source, COUNT(*) AS total, SUM(active=1) AS actives, SUM({V}) AS vis, SUM(active=1 AND dup_hidden=1) AS dups, SUM({V} AND lat IS NOT NULL AND lng IS NOT NULL) AS gps, SUM({V} AND images IS NOT NULL AND images NOT IN ('','[]')) AS photos, SUM({V} AND price IS NOT NULL) AS prix, SUM({V} AND bedrooms IS NOT NULL) AS cac, SUM({V} AND bathrooms IS NOT NULL) AS sdb, SUM({V} AND year_built IS NOT NULL) AS annee, SUM({V} AND details LIKE '%year_built_source%') AS annee_role, SUM({V} AND (details LIKE '%Taxes municipales%' OR details LIKE '%Taxes scolaires%' OR details LIKE '%valuation municipale%')) AS taxes, SUM({V} AND vraiprix IS NOT NULL) AS vp FROM listings GROUP BY source""").fetchall() stats = {r["source"]: dict(r) for r in rows} syncs: dict[str, list] = {} for r in con.execute("SELECT source, ts, found, added, updated, removed," " ok, message FROM sync_log ORDER BY ts"): syncs.setdefault(r["source"], []).append(dict(r)) con.close() return stats, syncs def agg(stats: dict, ids: list[str]) -> dict: keys = ("total", "actives", "vis", "dups", "gps", "photos", "prix", "cac", "sdb", "annee", "annee_role", "taxes", "vp") out = {k: 0 for k in keys} for i in ids: s = stats.get(i) if s: for k in keys: out[k] += s[k] or 0 return out def completude_md(s: dict) -> str: v = s["vis"] or 0 rows = [ ("Annonces en base (toutes)", nfr(s["total"])), ("Actives", nfr(s["actives"] or 0)), ("Visibles (après dédup)", nfr(v)), ("Masquées par la dédup", nfr(s["dups"] or 0)), ("GPS (lat/lng)", pct(s["gps"], v)), ("Photos", pct(s["photos"], v)), ("Prix", pct(s["prix"], v)), ("CAC (chambres)", pct(s["cac"], v)), ("SDB (salles de bains)", pct(s["sdb"], v)), ("Année de construction", pct(s["annee"], v) + (f" (dont rôle : {pct(s['annee_role'], s['annee'])})" if s["annee"] else "")), ("Taxes / évaluation municipale", pct(s["taxes"], v)), ("Estimation Vrai-Prix (rôle)", pct(s["vp"], v)), ] md = "| Indicateur | Valeur |\n|---|---|\n" md += "\n".join(f"| {k} | {val} |" for k, val in rows) return md def sync_md(syncs: dict, sid: str) -> str: hist = syncs.get(sid) or [] if not hist: return "Aucune entrée `sync_log` pour cette source." last = hist[-1] recent = hist[-10:] errs = [h for h in recent if not h["ok"]] md = (f"- **Dernier sync** : {fmt_ts(last['ts'])} — trouvé {nfr(last['found'] or 0)}, " f"+{last['added'] or 0} / ~{last['updated'] or 0} / -{last['removed'] or 0} — " f"{'OK' if last['ok'] else 'ÉCHEC'}" + (f" ({last['message']})" if last["message"] and last["message"] != "ok" else "")) md += f"\n- **Erreurs récentes** : {len(errs)} sur les {len(recent)} derniers syncs" if errs: e = errs[-1] md += f" — dernière : {fmt_ts(e['ts'])} « {e['message']} »" return md def mecanique_md(cls) -> str: if cls is None: return "_Pas de classe connecteur dédiée (voir la note du registre)._" mod = sys.modules[cls.__module__] path = Path(mod.__file__) md = f"- Module : `immoka/connectors/{path.name}` — classe `{cls.__name__}`\n" md += f"- Délai entre requêtes : {getattr(cls, 'request_delay', '—')} s ; " md += ("cache détail BD : oui" if getattr(cls, "use_detail_cache", False) else "cache détail BD : non") budgets = env_budgets(path) if budgets: md += "\n- Budgets env (enrichissement détail plafonné/exécution) : " md += ", ".join(f"`{k}` (défaut {v})" for k, v in budgets) hdr = module_header(path) if hdr: md += "\n\n**En-tête du module :**\n\n" + hdr return md # --- familles dynamiques --------------------------------------------------------- FAMILLES = [ dict(slug="famille-remax-ag", prefix="remax_ag_", registry="remax_agencies.json", module="remax_agences", titre="Famille RE/MAX sous-agences (`remax_ag_*`)", desc="Un connecteur par sous-agence RE/MAX (sites propres, indépendants du " "portail central), généré dynamiquement depuis `data/remax_agencies.json`."), dict(slug="famille-via-ag", prefix="via_ag_", registry="via_capitale_agencies.json", module="via_capitale_agences", titre="Famille Via Capitale agences (`via_ag_*`)", desc="Connecteurs par bannière Via Capitale, générés depuis " "`data/via_capitale_agencies.json` (comptes API source.immo par agence)."), dict(slug="famille-c21-ag", prefix="c21_ag_", registry="century21_agencies.json", module="century21_agences", titre="Famille Century 21 (`c21_ag_*`)", desc="Connecteurs par compte Century 21, générés depuis " "`data/century21_agencies.json` (plateforme source.immo)."), dict(slug="famille-source-immo", prefix=None, registry="source_immo_agencies.json", module="agences_source_immo", titre="Famille source.immo (agences indépendantes)", desc="Agences indépendantes servies par la plateforme source.immo " "(comptes/API dans `data/source_immo_agencies.json`)."), ] def famille_members(fam: dict) -> list[str]: """source_ids des membres instanciés (registre CONNECTORS).""" return sorted(sid for sid, cls in CONNECTORS.items() if cls.__module__.endswith("." + fam["module"])) def write_famille(fam: dict, stats: dict, syncs: dict, named_ids: set) -> None: members = famille_members(fam) reg_path = DATA / fam["registry"] registry = json.loads(reg_path.read_text(encoding="utf-8")) mod_path = ROOT / "immoka" / "connectors" / (fam["module"] + ".py") md = [f"# {fam['titre']}", "", GEN_NOTE, "", fam["desc"], ""] md += [f"- Registre : `data/{fam['registry']}` — **{len(registry)} entrées**", f"- Connecteurs instanciés : **{len(members)}**", f"- Module : `immoka/connectors/{fam['module']}.py`", ""] hdr = module_header(mod_path) if hdr: md += ["**En-tête du module :**", "", hdr, ""] for key, notes in MECANISMES.items(): if fam["slug"].startswith("famille-remax") and key == "remax_ag": md += ["## Mécanismes clés", ""] md += [f"- {n}" for n in notes] + [""] budgets = env_budgets(mod_path) if budgets: md += ["Budgets env : " + ", ".join(f"`{k}` (défaut {v})" for k, v in budgets), ""] md += ["## Membres et statuts", "", "| source_id | Domaine | Plateforme | Visibles | Actives | Masquées dédup | Dernier sync | OK |", "|---|---|---|---|---|---|---|---|"] for sid in members: cls = CONNECTORS[sid] s = stats.get(sid) or {} hist = syncs.get(sid) or [] last = hist[-1] if hist else None dom = getattr(cls, "domain", "") or getattr(cls, "site", "") or "—" plat = getattr(cls, "platform", "") or "source.immo" link = f"[`{sid}`]({sid}.md)" if sid in named_ids else f"`{sid}`" md.append("| {} | {} | {} | {} | {} | {} | {} | {} |".format( link, dom, plat, nfr(s.get("vis", 0) or 0), nfr(s.get("actives", 0) or 0), nfr(s.get("dups", 0) or 0), fmt_ts(last["ts"]) if last else "—", ("✅" if last["ok"] else "❌") if last else "—")) # entrées de registre sans classe instanciée inst_doms = {getattr(CONNECTORS[sid], "domain", "").replace("https://", "") .replace("http://", "").strip("/") for sid in members} orphans = [] for entry in registry: dom = (entry.get("domain") or "").replace("https://", "").replace("http://", "").strip("/") if dom and dom not in inst_doms and ("www." + dom) not in inst_doms \ and dom.removeprefix("www.") not in {d.removeprefix("www.") for d in inst_doms}: orphans.append(dom) if orphans: md += ["", f"Entrées du registre sans connecteur instancié ({len(orphans)}) : " + ", ".join(f"`{d}`" for d in orphans)] a = agg(stats, members) md += ["", "## Volumétrie & complétude agrégées (famille, BD live)", "", completude_md(a), ""] (OUT / f"{fam['slug']}.md").write_text("\n".join(md) + "\n", encoding="utf-8") # --- fiches nommées --------------------------------------------------------------- def write_named(entry: dict, stats: dict, syncs: dict) -> None: sid = entry["id"] cls = CONNECTORS.get(entry.get("connector") or sid) or CONNECTORS.get(sid) md = [f"# {entry.get('name', sid)} (`{sid}`)", "", GEN_NOTE, "", "## Registre (`data/sources.json`)", ""] for k, label in (("url", "Site"), ("listing_url", "Recherche"), ("coverage", "Couverture"), ("type", "Type"), ("platform", "Plateforme"), ("role", "Rôle"), ("status", "Statut")): if entry.get(k): md.append(f"- **{label}** : {entry[k]}") note = entry.get("note") or entry.get("notes") if note: md += ["", f"> {note}"] md += ["", "## Mécanique (introspection du code)", "", mecanique_md(cls), ""] if sid in MECANISMES or (entry.get("connector") or "") in MECANISMES: md += ["## Mécanismes clés", ""] md += [f"- {n}" for n in MECANISMES.get(sid) or MECANISMES[entry["connector"]]] md += [""] s = stats.get(sid) md += ["## Volumétrie & complétude (BD live)", ""] if s: md += [completude_md(s), ""] else: md += ["_Aucune annonce en base pour cette source (voir la note du " "registre : couverture assurée ailleurs ou connecteur inactif)._", ""] md += ["## Synchronisation", "", sync_md(syncs, sid), "", "Voir aussi les [mécanismes transverses](INDEX.md#mécanismes-transverses-tous-connecteurs) " "(budgets détail, dédup Centris+adresse, jointure rôle vraiprix.db)."] (OUT / f"{sid}.md").write_text("\n".join(md) + "\n", encoding="utf-8") # --- INDEX ------------------------------------------------------------------------ def write_index(sources: list, stats: dict, syncs: dict) -> None: tot = agg(stats, list(stats.keys())) md = ["# Immo-Ka — Connecteurs (documentation standardisée)", "", GEN_NOTE, "", f"**{len(CONNECTORS)} connecteurs** enregistrés (auto-découverte " f"`immoka/connectors/__init__.py`) : {len(sources)} connecteurs nommés " f"(`data/sources.json`) + 4 familles dynamiques (RE/MAX sous-agences, " f"Via Capitale, Century 21, source.immo).", "", f"Base vivante `data/immoka.db` : {nfr(tot['total'])} annonces, " f"{nfr(tot['actives'])} actives, {nfr(tot['vis'])} visibles après dédup " f"({nfr(tot['dups'])} masquées).", "", TRANSVERSES.format(dup_total=nfr(tot["dups"]), yb_pct=100.0 * tot["annee"] / max(tot["vis"], 1), yb_role_pct=100.0 * tot["annee_role"] / max(tot["annee"], 1)), "## Familles dynamiques", "", "| Famille | Fiche | Membres | Visibles |", "|---|---|---|---|"] for fam in FAMILLES: members = famille_members(fam) a = agg(stats, members) md.append(f"| {fam['titre'].split('(')[0].strip()} | " f"[{fam['slug']}]({fam['slug']}.md) | {len(members)} | {nfr(a['vis'])} |") md += ["", "## Connecteurs nommés", "", "| id | Nom | Type | Statut | Visibles | Année (dont rôle) | Dernier sync | OK |", "|---|---|---|---|---|---|---|---|"] for e in sorted(sources, key=lambda x: -(stats.get(x["id"], {}).get("vis") or 0)): sid = e["id"] s = stats.get(sid) or {} hist = syncs.get(sid) or [] last = hist[-1] if hist else None annee = pct(s.get("annee"), s.get("vis")) if s else "—" md.append("| [`{}`]({}.md) | {} | {} | {} | {} | {} | {} | {} |".format( sid, sid, e.get("name", ""), e.get("type", "—"), e.get("status", "—"), nfr(s.get("vis", 0) or 0), annee, fmt_ts(last["ts"]) if last else "—", ("✅" if last["ok"] else "❌") if last else "—")) (OUT / "INDEX.md").write_text("\n".join(md) + "\n", encoding="utf-8") def main() -> None: OUT.mkdir(parents=True, exist_ok=True) for old in OUT.glob("*.md"): old.unlink() sources = json.loads((DATA / "sources.json").read_text(encoding="utf-8"))["sources"] stats, syncs = db_stats() named_ids = {e["id"] for e in sources} for e in sources: write_named(e, stats, syncs) for fam in FAMILLES: write_famille(fam, stats, syncs, named_ids) write_index(sources, stats, syncs) print(f"[gen_connector_docs] {len(sources)} fiches nommées + {len(FAMILLES)} " f"familles + INDEX → {OUT}") if __name__ == "__main__": main()