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%
17.5 KB · 391 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# House-Ka — Agrégateur de maisons à vendre (Canada hors Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/realtypress.py : connecteur GÉNÉRIQUE RealtyPress (Canada)5#   RealtyPress = plugin WordPress branché sur le flux CREA DDF ; ~35 sites6#   d'agences/équipes ontariennes confirmés (recensement 2026-08-27, voir7#   docs/ontario-agences-connecteurs.md). Chaque site expose l'IDX/DDF complet8#   de son board (OREB, ITSO, KAREA…) en HTML server-rendered, sans anti-bot :9#   un seul parseur couvre quasi toute la province.10#11#   - liste : archive /listing/page/N/?posts_per_page=100 (100 cartes/page ;12#     ⚠ ?posts_per_page directement sur /listing = 301/vide) ; cartes13#     class="rps-property-result" (ruban For sale/For rent, prix, adresse,14#     ville, caractéristiques) ;15#   - fiche : mur CREA « I Accept The Terms » contourné par le cookie16#     `disclaimer=accepted` ; tableaux <strong>Label</strong>/valeur (MLS®17#     Number, Property Type, Bedrooms…), description « … (id:NNNNN) »,18#     lat/lng JSON-LD, photos ddfcdn.realtor.ca ;19#   - external_id = ddf<id> (préfixe : jamais de collision avec les n° Centris20#     QC) ; sources avec infixe _ag_ → la dédup par external_id masque les21#     doublons inter-sites (le même bien DDF publié sur plusieurs sites).22#   Sites générés depuis data/canada_agencies.json (un source_id par site).23# -----------------------------------------------------------------------------24from __future__ import annotations2526import html as _html27import json28import os29import re30import urllib.parse31from pathlib import Path3233from .base import BaseConnector34from . import _detailutil as du35from ..schema import PropertyListing3637REGISTRY = Path(__file__).resolve().parent.parent.parent / "data" / "canada_agencies.json"38DETAIL_LIMIT = int(os.environ.get("IMMOKA_RP_DETAIL_LIMIT",39                                  os.environ.get("IMMOKA_DETAIL_LIMIT", "150")))4041_CARD_RE = re.compile(r'<div class="rps-property-result">')42# fiche = 1er lien de la carte finissant par -<id DDF>/ ; le chemin varie selon43# le site (/listing/, /listings/, /all-regional-listings/…)44_LINK_RE = re.compile(r'href="(https?://[^"]+?-(\d{6,10})/?)"')45_RIBBON_RE = re.compile(r'rps-ribbon[^>]*>\s*([^<]+?)\s*<')46_PRICE_RE = re.compile(r'rps-price[^>]*>\s*\$\s*([\d,]+)')47_H4_RE = re.compile(r"<h4>\s*(.*?)\s*</h4>", re.S)48# avec ou sans <strong> selon le thème du site49_CITY_RE = re.compile(r'city-province-postalcode[^>]*>\s*(?:<strong>\s*)?([^<]+?)\s*<', re.S)50_FEAT_RE = re.compile(r'rps-result-feature-label[^>]*>\s*([^<]+?)\s*<')51_CARD_BROKER_RE = re.compile(r'text-muted[^>]*>\s*<small>\s*([^<]+?)\s*(?:<br|</small>)', re.S)52_DDFIMG_RE = re.compile(r'https://ddfcdn\.realtor\.ca/[^")\'\s\\]+')53_ROW_RE = re.compile(r"<td[^>]*>\s*<strong>([^<]{2,45})</strong>\s*</td>\s*"54                     r"<td[^>]*>(.*?)</td>", re.S)55_DESC_RE = re.compile(r'<!--\s*Description\s*-->\s*<p[^>]*>(.*?)</p>', re.S)56_DESC_RE2 = re.compile(r'<p itemprop="description"[^>]*>(.*?)</p>', re.S)57# provinces couvertes (House-Ka = Canada HORS Québec — le Québec vit sur immo-ka)58_PROVINCES = ("Ontario", "British Columbia", "Alberta", "Saskatchewan",59              "Manitoba", "New Brunswick", "Nova Scotia",60              "Prince Edward Island", "Newfoundland and Labrador",61              "Newfoundland & Labrador", "Yukon", "Northwest Territories",62              "Nunavut")63_PROV_ALT = "|".join(_PROVINCES)64_QC_RE = re.compile(r"\bQu[ée]bec\b", re.I)65# ville + province depuis <title> « 3383 Romeo Street, Greater Sudbury (Valley East), Ontario … »66_TITLE_CITY_RE = re.compile(r",\s*([^,<>|]{2,60}),\s*(" + _PROV_ALT + r")\b")67_PRICING_RE = re.compile(r'rps-pricing[^>]*>\s*\$\s*([\d,]+)')68_ID_TAIL_RE = re.compile(r"\s*\(id:\d{4,9}\)\s*$")69_TAG_RE = re.compile(r"<[^>]+>")70_NUM_RE = re.compile(r"[\d,]+(?:\.\d+)?")71_YEAR_RE = re.compile(r"\b(1[6-9]\d{2}|20\d{2})\b")72# territoire couvert (Canada au complet) — même boîte que schema.finalize()73_BBOX = (41.6, 83.2, -141.1, -52.5)747576def _num(s: str) -> float | None:77    m = _NUM_RE.search(s or "")78    if not m:79        return None80    try:81        return float(m.group(0).replace(",", ""))82    except ValueError:83        return None848586class _RealtyPress(BaseConnector):87    """Connecteur générique de site RealtyPress (voir data/canada_agencies.json)."""8889    agency_name = ""90    site_url = ""91    province = "Ontario"     # région par défaut des fiches (registre : "province")92    archive = "listing"      # chemin de l'archive (revelrealty: "listings",93                             # codygroup: "all-regional-listings")94    max_pages = 150          # 100 cartes/page → jusqu'à 15 000 fiches par site95    use_pp = True            # False (registre "page_size": 10) : pagination96                             # NATIVE 10/page — pour les sites qui plafonnent97                             # l'offset de posts_per_page (ex. hanlonrealty98                             # ~3 500 items) mais paginent à fond en natif99    request_delay = 0.6100101    def fetch(self) -> list[PropertyListing]:102        # mur CREA des fiches détail : le cookie suffit (posé pour tout domaine,103        # les redirections www/apex restent couvertes)104        self.session.cookies.set("disclaimer", "accepted")105        by_id: dict[str, PropertyListing] = {}106        base = self.site_url.rstrip("/")107        dry = 0108        pp = "?posts_per_page=100" if self.use_pp else ""109        for page in range(1, self.max_pages + 1):110            # page 1 : archive nue — sur certains sites (denisedunnrealtor…)111            # /page/1/ répond 200 SANS cartes au lieu de rediriger112            url = (f"{base}/{self.archive}/{pp}" if page == 1113                   else f"{base}/{self.archive}/page/{page}/{pp}")114            try:115                body = self.get(url).text116            except Exception:117                if page > 1:118                    break119                # page 1 : une erreur réseau transitoire ne doit pas devenir un120                # sync « réussi » à 0 fiche (rlpheartland/grapevine 2026-08-29 :121                # fetch en échec → found=0 en 0,2 s, ok=1) — passer par les122                # reprises ci-dessous, qui lèvent si ça persiste123                body = ""124            if page == 1 and not _CARD_RE.search(body):125                # page 1 vide par intermittence (hanlonrealty sert parfois un126                # gabarit sans cartes) : réessayer, puis variantes d'URL — un127                # échec ici ferait retirer TOUTES les fiches de la source128                import time as _t129                alts = [(self.archive, url),130                        (self.archive, f"{base}/{self.archive}/?posts_per_page=100"),131                        (self.archive, f"{base}/{self.archive}/page/2/{pp}")]132                # refonte de site : l'archive change parfois de chemin SANS133                # redirection (greybruce 2026-08 : /listing/ → 404 avec widget134                # « 12 vedettes », archive réelle déplacée sur /listings/) —135                # essayer les autres chemins connus du parc RealtyPress et136                # basculer self.archive pour les pages suivantes. Seuil de137                # cartes pour ne pas confondre un widget vedette (~12 cartes)138                # avec une vraie page d'archive (100/page, 10 en natif).139                min_cards = 20 if self.use_pp else 5140                alts += [(a, f"{base}/{a}/{pp}")141                         for a in ("listings", "listing", "all-regional-listings")142                         if a != self.archive]143                orig_archive, accepted = self.archive, False144                for arch, alt in alts:145                    _t.sleep(2.0)146                    try:147                        body = self.get(alt).text148                    except Exception:149                        continue150                    n = len(_CARD_RE.findall(body))151                    if n and (arch == orig_archive or n >= min_cards):152                        self.archive = arch153                        accepted = True154                        break155                if not accepted:156                    raise RuntimeError(157                        f"{self.source_id}: archive sans cartes après reprises "158                        "(page vide intermittente ?) — sync abandonné pour "159                        "protéger l'inventaire")160            cards = self._cards(body)161            if not cards and page > 1:162                # page vide INTERMITTENTE en cours de pagination (greybruce163                # 2026-09-01 : sync tronqué à 2 071/7 600 — le site sert164                # parfois un gabarit 200 sans cartes, que l'escalade anti-bot165                # de get() ne voit pas) : retenter avant de conclure à la fin166                # d'archive. Une vraie fin d'archive ne coûte que 2 requêtes167                # de plus.168                import time as _t169                for _ in range(2):170                    _t.sleep(2.0)171                    try:172                        body = self.get(url).text173                    except Exception:174                        continue175                    cards = self._cards(body)176                    if cards:177                        break178            if not cards:179                break180            before = len(by_id)181            for card in cards:182                self._parse_card(card, by_id)183            dry = dry + 1 if len(by_id) == before else 0184            if dry >= 2:185                break186        listings = list(by_id.values())187        du.enrich(self, listings, DETAIL_LIMIT, parse_rp_detail, key="v1")188        for lst in listings:189            # n° MLS du board (fiche détail) — utile à la dédup inter-plateformes190            if not lst.mls and lst.details.get("MLS® Number"):191                lst.mls = str(lst.details["MLS® Number"])192            if not lst.title:193                lst.title = ", ".join(filter(None, (lst.address, lst.city))) \194                    or "Propriété à vendre"195        return listings196197    def _cards(self, body: str) -> list[str]:198        marks = list(_CARD_RE.finditer(body))199        return [body[m.start():(marks[i + 1].start() if i + 1 < len(marks)200                                else m.start() + 6000)]201                for i, m in enumerate(marks)]202203    def _parse_card(self, card: str, by_id: dict) -> None:204        ml = _LINK_RE.search(card)205        if not ml:206            return207        url, ddf = ml.group(1), ml.group(2)208        eid = f"ddf{ddf}"209        if eid in by_id:210            return211        mr = _RIBBON_RE.search(card)212        ribbon = (mr.group(1) if mr else "").strip().lower()213        if "rent" in ribbon or "lease" in ribbon:214            return                      # locations : hors périmètre215        lst = PropertyListing(source=self.source_id, external_id=eid, url=url,216                              region=self.province, agency=self.agency_name,217                              broker_name=self.agency_name)218        ma = _H4_RE.search(card)219        if ma:220            lst.address = _html.unescape(_TAG_RE.sub(" ", ma.group(1))).strip()221        mc = _CITY_RE.search(card)222        if mc:223            raw = _html.unescape(mc.group(1)).strip().rstrip(",")224            # fiches québécoises (Gatineau… dans les pools DDF frontaliers) :225            # hors périmètre House-Ka — elles vivent sur immo-ka226            if _QC_RE.search(raw):227                return228            for prov in _PROVINCES:229                if prov.lower() in raw.lower():230                    # forme canonique unique (« & » → « and »)231                    lst.region = prov.replace(" & ", " and ")232                    raw = re.sub(r",?\s*" + re.escape(prov) + r"\b.*$", "",233                                 raw, flags=re.I)234                    break235            lst.city = raw.split("(")[0].strip()236        mp = _PRICE_RE.search(card)237        if mp:238            lst.price = _num(mp.group(1))239            lst.price_label = f"{mp.group(1)} $"240        for feat in _FEAT_RE.findall(card):241            f = _html.unescape(feat).strip()242            low = f.lower()243            n = _num(f)244            if not n:245                continue246            if "bedroom" in low:247                lst.bedrooms = int(n)248            elif "bathroom" in low:249                lst.bathrooms = int(n)250            elif "sqft" in low or "sq ft" in low or "ft" in low:251                lst.area_sqft = n       # plage « 1,100 - 1,500 ft² » : borne basse252        mbk = _CARD_BROKER_RE.search(card)253        if mbk:254            lst.broker_name = _html.unescape(mbk.group(1)).strip()[:120]255        mi = _DDFIMG_RE.search(card)256        if mi:257            lst.images = [mi.group(0)]258        by_id[lst.external_id] = lst259260261def parse_rp_detail(html: str) -> dict:262    """Fiche RealtyPress : tableaux DDF, description, GPS, galerie, courtier."""263    out: dict = {}264    details: dict = {}265266    for lab, val in _ROW_RE.findall(html):267        label = _html.unescape(lab).strip().rstrip(":")268        value = re.sub(r"\s+", " ", _html.unescape(_TAG_RE.sub(" ", val))).strip()269        if label and value and len(value) <= 300:270            details.setdefault(label, value)271272    def dv(*labels: str) -> str:273        for lb in labels:274            if details.get(lb):275                return details[lb]276        return ""277278    b = _num(dv("Bedrooms Total", "Bedrooms", "Bedrooms Above Ground"))279    if b is not None and 0 < b <= 30:280        out["bedrooms"] = int(b)281    b = _num(dv("Bathroom Total", "Bathrooms"))282    if b is not None and 0 < b <= 30:283        out["bathrooms"] = int(b)284    b = _num(dv("Half Bath Total"))285    if b is not None and 0 < b <= 10:286        out["powder_rooms"] = int(b)287    my = _YEAR_RE.search(dv("Constructed Date", "Construction Year", "Age"))288    if my:289        out["year_built"] = int(my.group(1))290    si = dv("Size Interior")291    if si and "sqft" in si.lower().replace(" ", ""):292        a = _num(si)                    # « 7,901 Sqft » / « 1200 - 1399 sqft »293        if a and a >= 100:294            out["area_sqft"] = a295    pt = dv("Property Type", "Building Type", "Type")296    if pt:297        out["property_type"] = pt       # anglais DDF — normalisé par finalize()298    sec = dv("Neigbourhood", "Neighbourhood", "Community Name")299    if sec:300        out["sector"] = sec301302    mp = _PRICING_RE.search(html)303    if mp:304        out["price"] = _num(mp.group(1))305        out["price_label"] = f"{mp.group(1)} $"306307    md = _DESC_RE.search(html) or _DESC_RE2.search(html)308    if md:309        desc = _html.unescape(_TAG_RE.sub(" ", md.group(1)))310        desc = re.sub(r"\s+", " ", desc).strip()311        out["description"] = _ID_TAIL_RE.sub("", desc)[:6000]312313    mt = re.search(r"<title>(.*?)</title>", html, re.S)314    if mt:315        title_txt = _html.unescape(mt.group(1))316        # (fiches québécoises : déjà filtrées au niveau des cartes de liste)317        mc = _TITLE_CITY_RE.search(title_txt)318        if mc and not _QC_RE.search(title_txt):319            # « Greater Sudbury (Valley East) » : le secteur part dans sector320            city = mc.group(1).split("(")[0].strip()321            if city and not any(c.isdigit() for c in city):322                out["city"] = city323                msec = re.search(r"\(([^)]{2,45})\)", mc.group(1))324                if msec and "sector" not in out:325                    out["sector"] = msec.group(1).strip()326327    for n in du.ld_nodes(html):328        t = n.get("@type")329        types = set(t if isinstance(t, list) else [t])330        geo = n.get("geo") or {}331        if isinstance(geo, dict) and "lat" not in out:332            try:333                lat, lng = float(geo["latitude"]), float(geo["longitude"])334                if _BBOX[0] <= lat <= _BBOX[1] and _BBOX[2] <= lng <= _BBOX[3]:335                    out["lat"], out["lng"] = lat, lng336            except (KeyError, TypeError, ValueError):337                pass338        if types & {"RealEstateAgent", "Organization"}:339            name = str(n.get("name") or "").strip()340            if name and "broker_name" not in out:341                out["broker_name"] = name[:120]342            tel = str(n.get("telephone") or "").strip()343            if tel and "broker_phone" not in out:344                out["broker_phone"] = tel[:40]345    if "lat" not in out:346        m = re.search(r'"latitude"\s*:\s*"?(-?\d{1,2}\.\d{3,})"?\s*,\s*'347                      r'"longitude"\s*:\s*"?(-?\d{2,3}\.\d{3,})"?', html)348        if m:349            lat, lng = float(m.group(1)), float(m.group(2))350            if _BBOX[0] <= lat <= _BBOX[1] and _BBOX[2] <= lng <= _BBOX[3]:351                out["lat"], out["lng"] = lat, lng352353    gal = [u for u in dict.fromkeys(_DDFIMG_RE.findall(html))354           if "/listings/" in u.lower()]355    if gal:356        out["images"] = gal[:60]357358    if details:359        out["details"] = details360    return out361362363def _load() -> list[dict]:364    try:365        return json.loads(REGISTRY.read_text(encoding="utf-8"))366    except Exception:367        return []368369370# House-Ka : les connecteurs RealtyPress sont le cœur du site — toujours371# enregistrés (pas de gate IMMOKA_ONTARIO, contrairement à Immo-Ka).372373# Génère une classe par site du registre.374for _ag in _load():375    if not all(_ag.get(k) for k in ("id", "site")):376        continue377    _sid = _ag["id"]378    globals()[f"REALTYPRESS_{_sid.upper()}"] = type(379        "RealtyPress" + "".join(p.title() for p in _sid.split("_")),380        (_RealtyPress,),381        {382            "source_id": _sid,383            "site_url": _ag["site"],384            "agency_name": _ag.get("name", _sid),385            "province": _ag.get("province", "Ontario"),386            "archive": _ag.get("archive", "listing"),387            "use_pp": int(_ag.get("page_size", 100)) >= 100,388            "max_pages": int(_ag.get("max_pages", 150)),389        },390    )391