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%
9.0 KB · 220 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/_detailutil.py : utilitaires partagés d'enrichissement « page détail »5#   Mutualise ce que chaque connecteur d'agence répète pour capter TOUTES les6#   infos de la fiche source (comme remax_quebec.py) : description JSON-LD,7#   coordonnées, aplatissement HTML, application au PropertyListing.8# -----------------------------------------------------------------------------9from __future__ import annotations1011import html as _html12import json13import re1415from ..schema import PropertyListing1617_LD_RE = re.compile(r'<script[^>]+application/ld\+json[^>]*>(.*?)</script>', re.S | re.I)18_COORD_RE = re.compile(r'(?:google\.[^"\']*?[?&](?:q|query|ll|center)=|maps/@)'19                       r'(-?\d{1,2}\.\d+)[ ,%+A-Za-z]+?(-?\d{2,3}\.\d+)')20_LD_PROP_TYPES = {"RealEstateListing", "Residence", "SingleFamilyResidence",21                  "House", "Apartment", "Product", "Offer", "Place", "Accommodation"}222324def ld_nodes(html: str):25    """Itère les objets JSON-LD (aplatis depuis @graph)."""26    for block in _LD_RE.findall(html):27        try:28            # strict=False : tolère les \n/\t bruts dans les chaînes (Yoamo…)29            data = json.loads(block, strict=False)30        except ValueError:31            continue32        graph = data.get("@graph", [data]) if isinstance(data, dict) else data33        for node in (graph if isinstance(graph, list) else [graph]):34            if isinstance(node, dict):35                yield node363738def ld_description(html: str) -> str:39    """Description depuis un nœud JSON-LD de type propriété (le plus long trouvé)."""40    best = ""41    for n in ld_nodes(html):42        t = n.get("@type")43        types = t if isinstance(t, list) else [t]44        if any(x in _LD_PROP_TYPES for x in types) and n.get("description"):45            d = _html.unescape(str(n["description"])).strip()46            if len(d) > len(best):47                best = d48    return best495051def gmaps_coords(html: str) -> tuple[float, float] | None:52    m = _COORD_RE.search(html)53    if not m:54        return None55    try:56        lat, lng = float(m.group(1)), float(m.group(2))57    except ValueError:58        return None59    if 44.5 <= lat <= 63.0 and -80.0 <= lng <= -56.0:60        return lat, lng61    return None626364def flatten(html: str) -> str:65    """HTML -> texte « valeur | libellé » pour extraire les tableaux Centris."""66    t = _html.unescape(re.sub(r"<[^>]+>", " | ", html))67    t = re.sub(r"[ \t\r\n]*\|[ \t\r\n|]*", " | ", t)68    return re.sub(r"[ \t]+", " ", t)697071# libellés Centris standard cherchés dans « valeur | libellé » OU « libellé | valeur »72_LABELS = [73    "Type de propriété", "Genre de propriété", "Style de bâtiment",74    "Année de construction", "Superficie habitable", "Superficie du terrain",75    "Superficie du bâtiment (au sol)", "Nombre de pièces", "Nombre d'unités",76    "Stationnement (total)", "Stationnement", "Garage", "Système de chauffage",77    "Énergie pour le chauffage", "Type de fenestration", "Fenêtres", "Toiture",78    "Revêtement", "Sous-sol", "Piscine", "Zonage", "Système d'égouts",79    "Approvisionnement en eau", "Déménagement", "Taxes municipales",80    "Taxes scolaires", "Évaluation municipale (terrain)",81    "Évaluation municipale (bâtiment)", "Cuisine",82]838485# en-têtes de section des fiches (jamais des valeurs valides)86_SECTION_HEADERS = {87    "bâtiment et intérieur", "particularités du bâtiment",88    "particularités du terrain", "particularités du site", "caractéristiques",89    "caractéristiques de la propriété", "détails financiers", "taxes et coûts",90    "taxes\xa0et\xa0coûts", "détails des pièces", "détails des rénovations",91    "inclusions et exclusions", "dimensions", "addenda", "frais mensuels",92    "évaluations", "équipement disponible", "dans les environs",93    "niveau", "revêtement", "détails", "taxes", "total", "pièce", "couvre-sol",94}95_LABELS_LOWER = {lb.lower() for lb in _LABELS}969798def _plausible(label: str, val: str) -> bool:99    """Garde-fou : rejette les valeurs qui sont d'autres libellés/en-têtes de100    section (l'aplatissement « | » rend les deux ordres ambigus) et exige un101    format minimal pour les champs monétaires/numériques."""102    low = val.lower().strip(" :")103    if not val or len(val) > 55 or low == label.lower():104        return False105    if low in _LABELS_LOWER or low in _SECTION_HEADERS:106        return False107    if label.startswith(("Taxes", "Évaluation")) and "$" not in val:108        return False109    if label == "Année de construction" and not re.search(r"\b(1[6-9]|20)\d{2}\b", val):110        return False111    if label.startswith("Superficie") and not re.search(r"\d", val):112        return False113    return True114115116def centris_details(text: str) -> dict:117    """Extrait les caractéristiques Centris d'un texte aplati.118119    Les fiches Centris modernes rendent « Libellé | Valeur » (le libellé120    précède la valeur) — c'est l'ordre essayé en premier ; l'ancien ordre121    « Valeur | Libellé » reste en repli. Un garde-fou (_plausible) évite de122    capter l'en-tête de section ou le libellé voisin comme valeur."""123    out: dict = {}124    for label in _LABELS:125        lab = re.escape(label)126        for m in (re.search(lab + r"\s*(?:\(\d{4}\))?\s*\|\s*([^|]{1,55})", text),127                  re.search(r"([^|]{1,55})\s*\|\s*" + lab + r"\b", text)):128            if m:129                val = re.sub(r"\s+", " ", m.group(1)).strip(" |")130                if _plausible(label, val):131                    out[label] = val132                    break133    return out134135136_INT_RE = re.compile(r"\d+")137138139def _int(v):140    if v is None:141        return None142    if isinstance(v, (int, float)):143        return int(v) or None144    m = _INT_RE.search(str(v))145    return int(m.group()) if m else None146147148def enrich(connector, listings, limit, parse_fn, key="v1", fetch_html=None):149    """Enrichit `listings` via leur page détail, avec cache BD + plafond `limit`.150151    - `parse_fn(html) -> dict` : extrait les champs riches d'une page détail.152    - `key` : versionne le cache (changer pour forcer un rafraîchissement).153    - `fetch_html(url) -> str` : par défaut connector.get(url).text ; passer154      connector.get_rendered pour les sites derrière Firecrawl/anti-bot.155    """156    if limit <= 0:157        return158    from .. import db159    fetch_html = fetch_html or (lambda u: connector.get(u).text)160    con = db.connect()161    budget = limit162    try:163        for lst in listings:164            cached = db.get_cached_detail(con, connector.source_id, lst.external_id, key)165            if cached is None:166                stale = db.get_stale_detail(con, connector.source_id, lst.external_id)167                if budget <= 0:168                    # budget épuisé : payload périmé (ancienne clé) plutôt que169                    # rien — la fiche garde ses photos/détails en attendant170                    # son re-parse à un prochain cycle171                    if stale:172                        apply_detail(lst, stale)173                    continue174                try:175                    cached = parse_fn(fetch_html(lst.url))176                except Exception:177                    cached = {}178                if not cached and stale:179                    # échec transitoire (challenge, timeout) : on garde l'ancien180                    # payload et on ne l'écrase pas — nouvel essai au prochain cycle181                    apply_detail(lst, stale)182                    budget -= 1183                    continue184                db.put_cached_detail(con, connector.source_id, lst.external_id, key, cached)185                budget -= 1186            apply_detail(lst, cached)187    finally:188        con.close()189190191def apply_detail(lst: PropertyListing, d: dict) -> None:192    """Applique un payload détail au PropertyListing sans écraser les valeurs déjà193    présentes (sauf images : on garde la plus grande galerie)."""194    if not d:195        return196    imgs = d.get("images")197    if imgs and len(imgs) > len(lst.images):198        lst.images = imgs199    if d.get("features"):200        # fusionne en dédoublonnant201        seen = {f.lower() for f in lst.features}202        for f in d["features"]:203            if f.lower() not in seen:204                lst.features.append(f)205                seen.add(f.lower())206    if d.get("details"):207        lst.details.update(d["details"])208    if d.get("broker_name"):209        lst.broker_name = d["broker_name"]210    # description : on garde la plus riche (la fiche détail bat le résumé liste)211    if d.get("description") and len(d["description"]) > len(lst.description or ""):212        lst.description = d["description"]213    for f in ("price_label", "address", "city", "sector", "property_type"):214        if d.get(f) and not getattr(lst, f, ""):215            setattr(lst, f, d[f])216    for f in ("bedrooms", "bathrooms", "powder_rooms", "year_built",217              "area_sqft", "lot_sqft", "lat", "lng", "broker_phone", "price"):218        if d.get(f) is not None and getattr(lst, f, None) in (None, "", 0):219            setattr(lst, f, d[f])220