SPB Git

spb/immo-ka Public

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

Python 66.4% TypeScript 19.9% CSS 13.2% HTML 0.5%
15.9 KB · 386 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/remax_quebec.py : RE/MAX Québec (remax-quebec.com)5#   Le site interroge un index Meilisearch public (search-only key exposée dans6#   la config de la page). L'index « inscriptions » couvre toute la province.7#   Meilisearch plafonne à 1000 hits par requête (maxTotalHits) : on shard donc8#   par région de tri d'acheminement postale (FSA, 3 premiers caractères) et on9#   dédoublonne par numéro d'inscription. Couverture validée vs le total global.10# -----------------------------------------------------------------------------11from __future__ import annotations1213import html as _html14import os15import re1617from .base import BaseConnector18from ..normalize import parse_area_sqft, parse_price19from ..schema import PropertyListing2021SEARCH_URL = "https://search.remax-quebec.com/indexes/inscriptions/search"22# Clé de recherche (search-only) exposée publiquement dans la config du site.23SEARCH_KEY = "b0b93998ab78573e8b937b528ad37d2ce3fbc97e07a9f2c909c4220db910c152"24SITE = "https://www.remax-quebec.com"2526# Territoires postaux du Québec : préfixes G, H et J.27FSA_LETTERS = ("G", "H", "J")28SOLD_TOKENS = ("vendu", "sold", "loué", "loue", "rented")2930# Enrichissement page détail (photos + specs) : plafonné par exécution pour31# garder chaque sync borné. Les fiches déjà en cache sont réutilisées gratis ;32# seules les nouvelles/modifiées consomment le budget. Sur plusieurs cycles,33# tout le parc finit enrichi. Surchargeable via IMMOKA_REMAX_DETAIL_LIMIT.34DETAIL_LIMIT = int(os.environ.get("IMMOKA_REMAX_DETAIL_LIMIT", "3000"))353637class RemaxQuebecConnector(BaseConnector):38    source_id = "remax_quebec"39    request_delay = 0.15          # API JSON rapide ; on reste poli40    use_detail_cache = True       # cache BD des pages détail (photos/specs)4142    def fetch(self) -> list[PropertyListing]:43        by_id: dict[int, dict] = {}44        for fsa in self._fsa_candidates():45            hits = self._search(fsa)46            for h in hits:47                nid = h.get("no_inscription")48                if nid is not None:49                    by_id[nid] = h        # dédoublonnage inter-shards50        listings = []51        for h in by_id.values():52            lst = self._to_listing(h)53            if lst is not None:54                listings.append(lst)55        self._enrich(listings)56        return listings5758    # -- enrichissement (photos + specs via la page détail) -------------------59    def _enrich(self, listings: list[PropertyListing]) -> None:60        from .. import db61        con = db.connect()62        budget = DETAIL_LIMIT63        for lst in listings:64            key = f"v3|{lst.price_label or '-'}"   # v3 = + agence/bureau ; refetch si prix change65            cached = db.get_cached_detail(con, self.source_id, lst.external_id, key)66            if cached is None:67                if budget <= 0:68                    continue                  # enrichi à un prochain cycle69                cached = self._scrape_detail(lst.url)70                db.put_cached_detail(con, self.source_id, lst.external_id, key, cached)71                budget -= 172            _apply_detail(lst, cached)73        con.close()7475    def _scrape_detail(self, url: str) -> dict:76        try:77            html = self.get(url).text78        except Exception:79            return {}80        return parse_remax_detail(html)8182    # -- sharding --------------------------------------------------------------83    @staticmethod84    def _fsa_candidates():85        for letter in FSA_LETTERS:86            for digit in "0123456789":87                for last in "ABCDEFGHIJKLMNOPQRSTUVWXYZ":88                    yield f"{letter}{digit}{last}"8990    def _search(self, q: str) -> list[dict]:91        try:92            resp = self.post(93                SEARCH_URL,94                headers={"Authorization": f"Bearer {SEARCH_KEY}",95                         "Content-Type": "application/json"},96                json={"q": q, "limit": 1000},97            )98        except Exception:99            return []100        data = resp.json()101        return data.get("hits", []) if isinstance(data, dict) else []102103    # -- mapping ---------------------------------------------------------------104    def _to_listing(self, h: dict) -> PropertyListing | None:105        nid = h.get("no_inscription")106        if nid is None:107            return None108        price_label = (h.get("display_price") or {}).get("fr", "") or ""109        # exclure les propriétés vendues / retirées (l'index les conserve)110        if any(tok in price_label.lower() for tok in SOLD_TOKENS):111            return None112113        slug = (h.get("slug") or {}).get("fr", "") or ""114        url = f"{SITE}/fr/proprietes/{slug}" if slug else f"{SITE}/fr"115        full_addr = (h.get("full_address") or {}).get("fr", "") or ""116        address, sector, city = _split_address(full_addr)117        prop_type, region = _from_slug(slug)118119        return PropertyListing(120            source=self.source_id,121            external_id=str(nid),122            url=url,123            title=full_addr,124            address=address,125            sector=sector,126            city=city,127            region=region,128            property_type=prop_type,129            price=parse_price(price_label),130            price_label=price_label,131            broker_name="RE/MAX Québec",132        )133134135# ---------------------------------------------------------------------------136# Analyse de l'adresse et du slug137# ---------------------------------------------------------------------------138139_PAREN_RE = re.compile(r"\(([^)]*)\)")140_POSTAL_RE = re.compile(r"[GHJ]\d[A-Z]\s?\d[A-Z]\d", re.I)141142143def _split_address(full: str) -> tuple[str, str, str]:144    """« 16146 Rue Forsyth, Montréal (Rivière-des-Prairies) (X), H1A5S9 »145    -> (adresse, secteur, ville)."""146    if not full:147        return "", "", ""148    parts = [p.strip() for p in full.split(",")]149    # retirer le code postal final150    if parts and _POSTAL_RE.search(parts[-1]):151        parts = parts[:-1]152    address = parts[0] if parts else ""153    city = sector = ""154    if len(parts) >= 2:155        muni = parts[1]156        parens = _PAREN_RE.findall(muni)157        city = _PAREN_RE.sub("", muni).strip()158        if parens:159            sector = parens[-1].strip()160        # secteur supplémentaire dans les champs suivants (avant le postal)161        for extra in parts[2:]:162            e = _PAREN_RE.sub("", extra).strip() or extra.strip()163            if e and not sector:164                sector = e165    return address, sector, city166167168_TYPE_SLUG = {169    "maison": "Maison", "house": "Maison",170    "condo": "Condo", "appartement": "Condo", "apartment": "Condo",171    "plex": "Multiplex", "duplex": "Duplex", "triplex": "Triplex",172    "terrain": "Terrain", "land": "Terrain",173    "chalet": "Chalet", "cottage": "Chalet",174    "fermette": "Fermette/Agricole", "ferme": "Fermette/Agricole",175    "commercial": "Commercial", "commerciale": "Commercial",176}177178179# ---------------------------------------------------------------------------180# Page détail : photos + description + caractéristiques181# ---------------------------------------------------------------------------182183_LD_RE = re.compile(r'<script[^>]+application/ld\+json[^>]*>(.*?)</script>', re.S | re.I)184# Les photos apparaissent dans plusieurs buckets de taille (www_full, _medium,185# _small…) selon le lazy-load ; on les capte toutes et on les normalise en186# pleine résolution, dédoublonnées par nom de fichier.187_IMG_RE = re.compile(188    r'https://media\.remax-quebec\.com/img/www_[a-z]+/[^"\'\\ ]+\.(?:jpg|jpeg|png|webp)',189    re.I)190_IMG_SIZE_RE = re.compile(r'/www_[a-z]+/', re.I)191# Dans la section caractéristiques, la valeur précède le libellé :192#   « 2 (1 + 1) | Chambres », « 1 | Salle de bain », « 1975 | Année de construction »193_SPEC_RE = {194    "bedrooms": re.compile(r'(\d+)(?:\s*\([^)]*\))?\s*\|\s*Chambre', re.I),195    "bathrooms": re.compile(r'(\d+)\s*\|\s*Salle de bain', re.I),196    "powder_rooms": re.compile(r"(\d+)\s*\|\s*Salle d'eau", re.I),197    "year_built": re.compile(r'(\d{4})\s*\|\s*Ann[ée]+e de construction', re.I),198}199_AREA_RE = re.compile(r'([\d  ,]+)\s*(|mc|pi²|pi|ft)\s*\|\s*Superficie habitable', re.I)200_LOT_RE = re.compile(r'([\d  ,]+)\s*(|mc|pi²|pi|ft)\s*\|\s*Superficie du terrain', re.I)201_COORD_RE = re.compile(r'query=(-?\d+\.\d+)%2C\+?(-?\d+\.\d+)')202_ROOM_RE = re.compile(r'rooms-details-section__vertical-table[^>]*>(.*?)</div>\s*</div>', re.S)203_QSTAT_RE = re.compile(204    r'quick-stat-text[^>]*>\s*<span[^>]*>\s*(.*?)\s*</span>\s*<span[^>]*>\s*(.*?)\s*</span>', re.S)205_INCL_RE = re.compile(r'inclusions-exclusions-section(.*?)(?:</section>|realtor-section|financial-section)', re.S)206_PHONE_RE = re.compile(r'\d{3}[\s ]\d{3}-\d{4}')207208# Ensemble de libellés Centris standard capturés dans le tableau « valeur | libellé ».209_DETAIL_LABELS = [210    "Type de propriété", "Genre de propriété", "Style de bâtiment",211    "Année de construction", "Nombre d'unités", "Superficie du bâtiment (au sol)",212    "Superficie du terrain", "Superficie habitable", "Stationnement (total)",213    "Système de chauffage", "Énergie pour le chauffage", "Fenêtres", "Type de fenestration",214    "Toiture", "Revêtement", "Sous-sol", "Piscine", "Garage", "Zonage",215    "Système d'égouts", "Approvisionnement en eau", "Déménagement",216    "Taxes municipales", "Taxes scolaires", "Évaluation municipale (terrain)",217    "Évaluation municipale (bâtiment)", "Cuisine", "Salle de bain / Salle d'eau",218]219220221def _flatten(html: str) -> str:222    t = _html.unescape(re.sub(r'<[^>]+>', ' | ', html))223    t = re.sub(r'[ \t\r\n]*\|[ \t\r\n|]*', ' | ', t)224    return re.sub(r'[ \t]+', ' ', t)225226227def parse_remax_detail(html: str) -> dict:228    """Extrait TOUT le contenu d'une page détail RE/MAX : photos, description,229    coordonnées, caractéristiques complètes, pièces (dimensions), inclusions,230    taxes/évaluation, courtier. Objectif : rien perdre par rapport à la fiche source."""231    import json232    out: dict = {}233    details: dict = {}234235    # description via JSON-LD RealEstateListing236    for block in _LD_RE.findall(html):237        try:238            data = json.loads(block)239        except ValueError:240            continue241        for node in (data if isinstance(data, list) else [data]):242            if isinstance(node, dict) and node.get("@type") == "RealEstateListing" and node.get("description"):243                out["description"] = _html.unescape(node["description"]).strip()244245    # photos : toutes tailles → pleine résolution, dédoublonnées par nom de fichier246    seen, images = set(), []247    for u in _IMG_RE.findall(html):248        full = _IMG_SIZE_RE.sub("/www_full/", u)249        fn = full.rsplit("/", 1)[-1]250        if fn not in seen and "nophoto" not in full:251            seen.add(fn)252            images.append(full)253    if images:254        out["images"] = images255256    # coordonnées GPS (lien Google Maps)257    m = _COORD_RE.search(html)258    if m:259        out["lat"], out["lng"] = float(m.group(1)), float(m.group(2))260261    # courtier + téléphone (le nom précède « Courtier immobilier »)262    mb = re.search(r'>\s*([A-ZÀ-Ÿ][A-Za-zÀ-ÿ .\'-]{4,45})\s*</span>[^<]*'263                   r'(?:<[^>]+>\s*)*Courtier immobilier', html)264    if mb:265        name = _html.unescape(re.sub(r'\s+', ' ', mb.group(1))).strip()266        if name:267            out["broker_name"] = name268    mp = _PHONE_RE.search(html)269    if mp:270        out["broker_phone"] = mp.group(0)271272    # agence / bureau (« RE/MAX ALLIANCE INC. ») — pour les Sources par sous-agence273    ma = re.search(r'RE/MAX[^<,|"\n]{1,40}\bINC\.?', html)274    if ma:275        out["agency"] = _html.unescape(re.sub(r'\s+', ' ', ma.group(0))).strip().title() \276            .replace("Re/Max", "RE/MAX").replace("Inc.", "inc.")277278    text = _flatten(html)279280    # champs numériques principaux281    for field, rx in _SPEC_RE.items():282        m = rx.search(text)283        if m:284            out[field] = int(m.group(1))285    m = _AREA_RE.search(text)286    if m:287        out["area_sqft"] = parse_area_sqft(f"{m.group(1)} {m.group(2)}")288    m = _LOT_RE.search(text)289    if m:290        out["lot_sqft"] = parse_area_sqft(f"{m.group(1)} {m.group(2)}")291292    # tableau de caractéristiques complet (valeur | libellé) → details293    for label in _DETAIL_LABELS:294        m = re.search(r'([^|]{1,60})\s*\|\s*' + re.escape(label) + r'\b', text)295        if m:296            val = m.group(1).strip(" |")297            if val and 1 <= len(val) <= 60 and val.lower() != label.lower():298                details[label] = val299300    # pièces avec dimensions/niveau/revêtement301    rooms = []302    for blk in _ROOM_RE.findall(html):303        rt = _html.unescape(re.sub(r'<[^>]+>', ' ', blk))304        rt = re.sub(r'\s+', ' ', rt).strip()305        name = re.split(r'Niveau\s*:', rt)[0].strip()306        niveau = (re.search(r'Niveau\s*:\s*([^:]+?)(?:Dimensions|Revêtement|$)', rt) or [None, ""])307        dim = re.search(r'Dimensions\s*:\s*([0-9\'".,X x×]+)', rt)308        rev = re.search(r'Revêtement\s*:\s*([A-Za-zÀ-ÿ ,-]+?)(?:\s*Détails|\s*$)', rt)309        if name:310            rooms.append({311                "nom": name[:40],312                "niveau": (niveau[1].strip() if niveau else ""),313                "dimensions": (dim.group(1).strip() if dim else ""),314                "revetement": (rev.group(1).strip() if rev else ""),315            })316    if rooms:317        details["pieces"] = rooms[:20]318319    # quick stats (Nb de pièces, superficie…)320    for label, val in _QSTAT_RE.findall(html):321        label = _html.unescape(re.sub(r'\s+', ' ', label)).strip()322        val = _html.unescape(re.sub(r'\s+', ' ', val)).strip()323        if label and val and label not in details:324            details[label] = val325326    # inclusions / exclusions → features327    mi = _INCL_RE.search(html)328    feats = []329    if mi:330        blk = _html.unescape(re.sub(r'<[^>]+>', '\n', mi.group(1)))331        for line in blk.split('\n'):332            line = line.strip(" \t•-")333            if 3 <= len(line) <= 120 and not line.lower().startswith(("inclusion", "exclusion")):334                feats.append(line)335    if feats:336        out["features"] = feats[:25]337338    # superficie habitable depuis details si pas déjà captée339    if out.get("area_sqft") is None and details.get("Superficie habitable"):340        out["area_sqft"] = parse_area_sqft(details["Superficie habitable"])341    if out.get("lot_sqft") is None and details.get("Superficie du terrain"):342        out["lot_sqft"] = parse_area_sqft(details["Superficie du terrain"])343344    if details:345        out["details"] = details346    return out347348349def _apply_detail(lst: PropertyListing, d: dict) -> None:350    if not d:351        return352    if d.get("images"):353        lst.images = d["images"]354    if d.get("features"):355        lst.features = d["features"]356    if d.get("details"):357        lst.details.update(d["details"])358    # le nom réel du courtier (page détail) remplace le placeholder « RE/MAX Québec »359    if d.get("broker_name"):360        lst.broker_name = d["broker_name"]361    # agence/bureau -> sous-agence pour les Sources362    if d.get("agency"):363        lst.agency = d["agency"]364    for f in ("description", "bedrooms", "bathrooms", "powder_rooms",365              "year_built", "area_sqft", "lot_sqft", "lat", "lng",366              "broker_phone"):367        if d.get(f) is not None and getattr(lst, f, None) in (None, "", 0):368            setattr(lst, f, d[f])369370371def _from_slug(slug: str) -> tuple[str, str]:372    """slug « maison-a-vendre-laurentides/1744-rue-carmen-val-david-9004907 »373    -> (type de propriété, région)."""374    if not slug:375        return "", ""376    head = slug.split("/", 1)[0]           # maison-a-vendre-laurentides377    prop_type = ""378    for key, canon in _TYPE_SLUG.items():379        if re.search(rf"\b{key}", head):380            prop_type = canon381            break382    # région = ce qui suit « -a-vendre-/-for-sale- »383    m = re.search(r"(?:a-vendre|for-sale)-(.+)$", head)384    region = m.group(1).replace("-", " ").title() if m else ""385    return prop_type, region386