SPB Git forge

spb/house-ka

Public
18commits 1branches 0releases
1.9 MBsize
maindefault branch
19 days agolast push
Python 67% TypeScript 18.2% CSS 14.4%
19.6 KB · 428 lines python
Raw Blame History
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