SPB Git forge

spb/immo-ka

Public

Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)

112commits 1branches 0releases
125.4 MBsize
maindefault branch
13 days agolast push
Python 47.5% HTML 27.9% TypeScript 15.5% CSS 7.2% JavaScript 2%
17.1 KB · 398 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/exp_quebec.py : eXp Realty Québec (expquebec.com) — toute la province.5#6#   L'API cliente realestate.marketingwebsites.ca/api.php est verrouillée par7#   whitelist IP (impasse, même via proxy). MAIS le serveur WordPress (autorisé8#   sur l'API) rend l'inventaire COMPLET côté serveur sur9#   /fr/properties/?pages={N} (9 fiches/page, param « pages » uniquement).10#   Chaque carte porte data-href /fr/properties/mls/{MLS}, data-price ($US),11#   data-address (rue) et data-pic (property-images/{MLS}/). Le n° MLS = n° Centris.12#13#   FICHE DÉTAIL /fr/properties/mls/{MLS}/ : server-rendered elle aussi14#   (requêtes directes, aucun anti-bot) et TRÈS riche — galerie complète en15#   pleine résolution (~1728px, ancres data-lightbox="gallery-item"), remarque16#   du courtier + addendum, inclusions/exclusions, icônes (chambres/sdb/17#   superficie MC|PC/année), tables th/td par section (BÂTIMENT, ÉVALUATION,18#   DÉPENSES, CARACTÉRISTIQUES, DÉTAILS DE PIÈCE), GPS exact dans l'embed19#   Street View, courtier inscripteur (photo alt + tel: du bloc latéral).20#   Enrichissement via _detailutil.enrich (cache BD + plafond21#   IMMOKA_EXP_DETAIL_LIMIT, ~400/cycle).22#23#   La VILLE vient de la fiche détail (en-tête h2) ; repli sur l'endpoint SSR24#   /wp-content/themes/canvas/load/map-property.php?mls={MLS}&lang=fr pour les25#   fiches au-delà du budget détail (cache « city » déjà peuplé).26#27#   ⚠ PLAFOND : le SSR reflète le limit=1000 de l'API sous-jacente → au plus28#   ~1000 fiches (pages 1..~114, crawl vérifié). C'est l'inventaire pratique29#   complet ; si eXp QC dépasse un jour 1000 inscriptions, segmenter par région30#   (params MW_region / MW_q du formulaire), chaque segment ayant son propre cap.31#32#   source_id « exp_ag_qc » : infixe _ag_ = dédup Centris (db.refresh_dedup) —33#   les fiches co-listées avec une bannière couverte sont masquées.34# -----------------------------------------------------------------------------35from __future__ import annotations3637import html as _html38import os39import re4041from .base import BaseConnector42from . import _detailutil as du43from ..normalize import parse_price44from ..schema import PropertyListing4546SITE = "https://expquebec.com"47LISTING = SITE + "/fr/properties/"48MAP_PROP = SITE + "/wp-content/themes/canvas/load/map-property.php"49DETAIL_LIMIT = int(os.environ.get("IMMOKA_EXP_DETAIL_LIMIT", "400"))50CITY_LIMIT = int(os.environ.get("IMMOKA_EXP_CITY_LIMIT", "1200"))51AGENCY = "eXp Agence immobilière"5253_CARD_SPLIT = re.compile(r'data-href="/fr/properties/mls/(\d{6,9})"')54_PRICE_RE = re.compile(r'data-price="([^"]*)"', re.I)55_ADDR_RE = re.compile(r'data-address="([^"]*)"', re.I)56_PIC_RE = re.compile(r'data-pic="([^"]*)"', re.I)5758# ---- fiche détail -----------------------------------------------------------59_GALLERY_RE = re.compile(r'<a href="(https?://[^"]+)"[^>]*data-lightbox="gallery-item"')60_H2_RE = re.compile(r'<small>MLS\s*#\s*\d+</small><br>\s*(.*?)</h2>', re.S)61_ICON_RE = re.compile(r'<i class="icon-realestate-(bed|bathtub|plan|calendar)">'62                      r'</i>\s*([^<]+)')63_REMARK_RE = re.compile(r'<h4>Remarque du courtier</h4>\s*<p[^>]*>(.*?)</p>', re.S)64_ADDENDUM_RE = re.compile(r'<h4>Addendum</h4>\s*<p[^>]*>(.*?)</p>', re.S)65_INCL_RE = re.compile(r'<h4>INCLUSION</h4>\s*<p[^>]*>(.*?)</p>', re.S)66_EXCL_RE = re.compile(r'<h4>EXCLUSION</h4>\s*<p[^>]*>(.*?)</p>', re.S)67_TABLE_RE = re.compile(r'<table class="table">(.*?)</table>', re.S)68_SECTION_RE = re.compile(r'>\s*(BÂTIMENT|ÉVALUATION|DÉPENSES|CARACTÉRISTIQUES)\b')69_PAIR_RE = re.compile(r'<th class="prop-table"[^>]*>\s*(?:<[^>]+>\s*)*([^<]+?)\s*</th>'70                      r'\s*<td[^>]*>\s*([^<]*?)\s*</td>', re.S)71_ROOM_RE = re.compile(r'<td data-title="Chambre">([^<]*)</td>\s*'72                      r'<td data-title="Dimensions">([^<]*)</td>\s*'73                      r'<td data-title="Niveau">([^<]*)</td>\s*'74                      r'<td data-title="Planchers">([^<]*)</td>', re.S)75_SV_RE = re.compile(r'streetview\?key=[^&"\']*&location=(-?\d{1,2}\.\d+),(-?\d{2,3}\.\d+)')76_AGENT_RE = re.compile(r'agent-images/[^"]+"\s+alt="([^"]+)"')77_TEL_RE = re.compile(r'href="tel:(\d{7,15})"')78_SMS_RE = re.compile(r'data-phone="\+?1?(\d{10})"')79_OCCUP_RE = re.compile(r"Date d'occupation\s*:\s*([\d-]{8,10})")80_OPEN_RE = re.compile(r'visite libre<br>\s*(.*?)</h4>', re.S | re.I)81_TOUR_RE = re.compile(r'https?://(?:my\.)?(?:matterport\.com|youtu\.be|'82                      r'vimeo\.com)/[^"\'<> ]+', re.I)83_AREA_RE = re.compile(r'([\d,]+(?:\.\d+)?)\s*(MC|PC)\b', re.I)84_POSTAL_RE = re.compile(r'^[A-Z]\d[A-Z]\s*\d[A-Z]\d$')8586# lignes du texte-source repliées en dur à ~60 colonnes : on recolle les87# retours qui coupent une phrase (pas de ponctuation finale, suite en minuscule)88_WRAP_RE = re.compile(r'(?<![.!?:])\n(?=[a-zà-ÿœ])')899091def _parse_us_price(label: str):92    label = (label or "").strip()93    if not label:94        return None, ""95    price = parse_price(label.replace("$", "").replace(",", "")) if label.startswith("$") \96        else parse_price(label)97    return price, label9899100def _txt(block: str) -> str:101    """Bloc HTML -> texte propre (les <br> deviennent des sauts de ligne,102    les retours en dur du flux Centris sont recollés)."""103    block = re.sub(r'<br\s*/?>', '\n@@BR@@\n', block, flags=re.I)104    block = re.sub(r'<[^>]+>', ' ', block)105    block = _html.unescape(block).replace('\r', '')106    block = re.sub(r'[ \t]+', ' ', block)107    block = re.sub(r' ?\n ?', '\n', block).strip()108    block = _WRAP_RE.sub(' ', block)             # déplie les phrases coupées109    block = block.replace('\n@@BR@@\n', '\n').replace('@@BR@@', '\n')110    return re.sub(r'\n{3,}', '\n\n', block).strip()111112113def _area_sqft(val: str) -> float | None:114    """« 91.4 MC » (m²) / « 2378.82 PC » (pi²) -> pi²."""115    m = _AREA_RE.search(val or "")116    if not m:117        return None118    try:119        v = float(m.group(1).replace(",", ""))120    except ValueError:121        return None122    if v <= 0:123        return None124    return round(v * 10.7639) if m.group(2).upper() == "MC" else round(v)125126127def _fmt_area(val: str) -> str:128    """« 91.4 MC » -> « 91.4 m² » ; « 2378.82 PC » -> « 2378.82 pi² »."""129    m = _AREA_RE.search(val or "")130    if not m:131        return (val or "").strip()132    unit = "m²" if m.group(2).upper() == "MC" else "pi²"133    return f"{m.group(1)} {unit}"134135136def _parse_exp_detail(html: str) -> dict:137    """Fiche détail expquebec.com (server-rendered) -> payload riche."""138    out: dict = {}139    details: dict = {}140    features: list[str] = []141142    # -- galerie complète, pleine résolution, ordre d'origine (dédoublonnée :143    #    le carrousel duplique les ancres pour le loop)144    images, seen = [], set()145    for u in _GALLERY_RE.findall(html):146        u = _html.unescape(u).strip()147        if u and u not in seen:148            seen.add(u)149            images.append(u)150    if images:151        out["images"] = images152153    # -- en-tête h2 : adresse <br/> Ville (secteur), Arrondissement X, CP154    m = _H2_RE.search(html)155    if m:156        head = _html.unescape(re.sub(r'<[^>]+>', '\n', m.group(1)))157        lines = [ln.strip() for ln in head.split('\n') if ln.strip()]158        if lines:159            out["address"] = lines[0]160            rest = " ".join(lines[1:])161            parts = [p.strip() for p in rest.split(",") if p.strip()]162            if parts:163                cm = re.match(r'^(.*?)\s*\((.+)\)\s*$', parts[0])164                if cm:165                    out["city"], out["sector"] = cm.group(1).strip(), cm.group(2).strip()166                else:167                    out["city"] = parts[0]168                for p in parts[1:]:169                    if _POSTAL_RE.match(p):170                        details["Code postal"] = p171                    elif p.startswith("Arrondissement ") and not out.get("sector"):172                        arr = p[len("Arrondissement "):].strip()173                        if arr and arr != "Ville" and not arr.startswith("Noms de rues"):174                            out["sector"] = arr175176    # -- icônes : chambres / salles de bains / superficie / année177    for icon, val in _ICON_RE.findall(html):178        val = val.strip()179        if not val:180            continue181        if icon == "bed" and val.isdigit():182            out["bedrooms"] = int(val)183        elif icon == "bathtub" and val.isdigit():184            out["bathrooms"] = int(val)185        elif icon == "plan":186            sqft = _area_sqft(val)187            if sqft:188                out["area_sqft"] = sqft189                details["Superficie habitable"] = _fmt_area(val)190        elif icon == "calendar" and re.fullmatch(r'(1[6-9]|20)\d{2}', val):191            out["year_built"] = int(val)192193    # -- description : l'addendum (long) bat la remarque du courtier194    remark = _REMARK_RE.search(html)195    addendum = _ADDENDUM_RE.search(html)196    texts = [_txt(x.group(1)) for x in (addendum, remark) if x]197    if texts:198        out["description"] = max(texts, key=len)199    for rx, key in ((_INCL_RE, "Inclusions"), (_EXCL_RE, "Exclusions")):200        m = rx.search(html)201        if m:202            v = _txt(m.group(1))203            if v:204                details[key] = v205206    # -- tables th/td par section (dupliquées mobile/desktop -> dict dédoublonne)207    for tbl in _TABLE_RE.findall(html):208        sec = _SECTION_RE.search(tbl)209        if not sec:210            continue211        section = sec.group(1)212        for label, val in _PAIR_RE.findall(tbl):213            label = _html.unescape(label).strip().rstrip(":")214            val = re.sub(r'\s+', ' ', _html.unescape(val)).strip()215            if not val or val in ("0", "-"):216                continue217            if section == "BÂTIMENT":218                if label == "Type":219                    out["property_type"] = val220                    details["Type de propriété"] = val221                elif label == "Style":222                    details["Style de bâtiment"] = val223                elif label == "Dimensions":224                    details["Dimensions du bâtiment"] = val225                elif label == "Dimension terrain":226                    details["Superficie du terrain"] = _fmt_area(val) \227                        if _AREA_RE.search(val) else val228                    lot = _area_sqft(val)229                    if lot:230                        out["lot_sqft"] = lot231                else:                       # Nombre d'étages, Année de construction…232                    details[label] = val233            elif section == "ÉVALUATION":234                if label == "Année":235                    details["Évaluation municipale (année)"] = val236                elif label in ("Terrain", "Bâtiment", "Total"):237                    details[f"Évaluation municipale ({label.lower()})"] = val238            elif section == "DÉPENSES":239                details[re.sub(r'\s*\(\d{4}\)\s*', '', label)] = val240            else:                           # CARACTÉRISTIQUES241                details[label] = val242                if label in ("Particularités", "Équipement disponible"):243                    features.extend(f.strip() for f in val.split(",") if f.strip())244245    # -- pièces (clé spéciale `pieces` rendue par le frontend) + salles d'eau246    rooms, powder = [], 0247    for nom, dims, niveau, sol in _ROOM_RE.findall(html):248        nom = _html.unescape(nom).strip()249        if not nom:250            continue251        room = {"nom": nom}252        for k, v in (("dimensions", dims), ("niveau", niveau), ("revetement", sol)):253            v = _html.unescape(v).strip()254            if v:255                room[k] = v256        rooms.append(room)257        if nom.lower() == "salle d'eau":258            powder += 1259    if rooms:260        details["pieces"] = rooms261    if powder:262        out["powder_rooms"] = powder263264    # -- GPS exact (embed Street View), courtier, extras265    m = _SV_RE.search(html)266    if m:267        out["lat"], out["lng"] = float(m.group(1)), float(m.group(2))268    m = _AGENT_RE.search(html)269    if m:270        out["broker_name"] = _html.unescape(m.group(1)).strip()271        blk = html[m.end():m.end() + 3000]      # bloc latéral du courtier272        tel = _TEL_RE.search(blk) or _SMS_RE.search(blk)273        if tel:274            out["broker_phone"] = tel.group(1)275    m = _OCCUP_RE.search(html)276    if m:277        details["Date d'occupation"] = m.group(1)278    m = _OPEN_RE.search(html)279    if m:280        v = re.sub(r'\s+', ' ', _html.unescape(re.sub(r'<[^>]+>', ' ', m.group(1)))).strip(" |")281        if v:282            details["Visite libre"] = v283    m = _TOUR_RE.search(html)284    if m:285        details["Visite virtuelle"] = m.group(0)286287    if details:288        out["details"] = details289    if features:290        out["features"] = features291    return out292293294class ExpQuebecConnector(BaseConnector):295    source_id = "exp_ag_qc"296    request_delay = 0.4297    max_pages = 200          # garde-fou (9/page) ; stop après 3 pages vides298299    def fetch(self) -> list[PropertyListing]:300        by_id: dict[str, PropertyListing] = {}301        empty = 0302        for page in range(1, self.max_pages + 1):303            url = LISTING if page == 1 else f"{LISTING}?pages={page}"304            try:305                html = self.get(url).text306            except Exception:307                break308            parts = _CARD_SPLIT.split(html)309            # parts = [pre, mls1, blk1, mls2, blk2, …]310            found = 0311            for i in range(1, len(parts) - 1, 2):312                mls, blk = parts[i], parts[i + 1][:1500]313                if mls in by_id:314                    continue315                lst = self._card(mls, blk)316                if lst:317                    by_id[mls] = lst318                    found += 1319            empty = empty + 1 if found == 0 else 0320            if empty >= 3:321                break322        listings = list(by_id.values())323        # fiche détail complète : galerie pleine résolution, description,324        # tables BÂTIMENT/ÉVALUATION/DÉPENSES/CARACTÉRISTIQUES, pièces, GPS,325        # courtier + téléphone. v1 = premier parse complet.326        du.enrich(self, listings, DETAIL_LIMIT, _parse_exp_detail, key="v1")327        # repli ville (fiches au-delà du budget détail) : endpoint SSR léger,328        # cache « city » déjà peuplé pour l'inventaire existant329        self._enrich_cities([l for l in listings if not l.city])330        return listings331332    def _card(self, mls: str, blk: str) -> PropertyListing | None:333        pm = _PRICE_RE.search(blk)334        am = _ADDR_RE.search(blk)335        pic = _PIC_RE.search(blk)336        price, price_label = _parse_us_price(pm.group(1) if pm else "")337        addr = _html.unescape(am.group(1)).strip() if am else ""338        img = _html.unescape(pic.group(1)).strip() if pic else \339            f"https://realestate.marketingwebsites.ca/property-images/{mls}/{mls}-01.jpg"340        return PropertyListing(341            source=self.source_id,342            external_id=mls,343            url=f"{SITE}/fr/properties/mls/{mls}/",344            title=addr or "Propriété à vendre",345            address=addr,346            price=price,347            price_label=price_label,348            mls=mls,349            images=[img] if img else [],350            agency=AGENCY,351            broker_name=AGENCY,352        )353354    def _enrich_cities(self, listings: list[PropertyListing]) -> None:355        """Complète la ville via map-property.php, avec cache BD + plafond.356        La ville est le dernier segment texte de la carte-fiche (après le prix357        et l'adresse).358359        ⚠ detail_cache = UNE ligne par fiche : on lit le payload quelle que360        soit sa clé (« city » historique OU « v1 » du parse détail, qui inclut361        la ville) et on n'écrit une ligne « city » que s'il n'y en a AUCUNE —362        jamais par-dessus un payload détail complet."""363        if CITY_LIMIT <= 0:364            return365        from .. import db366        con = db.connect()367        budget = CITY_LIMIT368        try:369            for lst in listings:370                cached = db.get_stale_detail(con, self.source_id, lst.external_id)371                if cached is None:372                    if budget <= 0:373                        continue374                    fetched = self._fetch_city(lst.external_id)375                    db.put_cached_detail(con, self.source_id, lst.external_id,376                                         "city", fetched)377                    budget -= 1378                    cached = fetched379                if cached.get("city") and not lst.city:380                    lst.city = cached["city"]381        finally:382            con.close()383384    def _fetch_city(self, mls: str) -> dict:385        try:386            html = self.get(f"{MAP_PROP}?mls={mls}&lang=fr").text387        except Exception:388            return {}389        # segments texte : retirer prix ($), adresse (déjà connue) → dernier = ville390        segs = [s.strip() for s in re.split(r'<[^>]+>', html) if s.strip()]391        segs = [_html.unescape(s) for s in segs if "$" not in s and "-->" not in s]392        # la ville est le dernier segment alphabétique non numérique393        for s in reversed(segs):394            if re.search(r'[A-Za-zÀ-ÿ]{3,}', s) and not re.match(r'^\d', s):395                # éviter de reprendre l'adresse (commence souvent par un n° civique)396                return {"city": s}397        return {}398