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%
7.4 KB · 157 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/royal_lepage.py : Royal LePage (royallepage.ca) — Québec5#6#   Le portail rend ses résultats côté serveur, mais /fr/search/homes/{page}/7#   sans géo renvoie le Canada entier. La recherche par ville EST filtrable :8#   /fr/search/homes/{page}/?search_str=...&prov_code=QC&city_name=...&lat=..&9#   lng=..&search_type=city renvoie 100 % de propriétés du Québec, classées par10#   proximité (46/page). Chaque recherche plafonne à ~1 250 résultats ; on11#   SHARD donc par points d'ancrage répartis dans la province et on dédoublonne12#   par numéro MLS (= n° Centris, dans l'URL /.../mls{no}/). Aucune dépendance13#   Firecrawl : requêtes directes.14# -----------------------------------------------------------------------------15from __future__ import annotations1617import html as _html18import os19import re2021from .base import BaseConnector22from . import _detailutil as du23from ..normalize import parse_price24from ..schema import PropertyListing2526BASE = "https://www.royallepage.ca/fr/search/homes"27MAX_PAGES = 30                        # une recherche plafonne à ~27 pages (~1250)28# Le nom du BUREAU (« Royal LePage Humania »…) n'est pas sur la carte : il vient29# de la fiche détail. Enrichissement plafonné + cache (agency se remplit au fil30# des cycles). = affichage des Sources par sous-agence.31DETAIL_LIMIT = int(os.environ.get("IMMOKA_RLP_DETAIL_LIMIT", "500"))32_OFFICE_RE = re.compile(r'agent-info__brokerage".*?<a[^>]*>\s*([^<,\n]+?)\s*(?:,|\n|</a>)',33                        re.S | re.I)3435# Points d'ancrage couvrant le Québec (nom, lat, lng). L'union des recherches36# + dédoublonnage MLS couvre la province.37ANCHORS = [38    ("Montréal", 45.5089, -73.5542), ("Québec", 46.8139, -71.2080),39    ("Gatineau", 45.4765, -75.7013), ("Sherbrooke", 45.4040, -71.8929),40    ("Trois-Rivières", 46.3432, -72.5432), ("Saguenay", 48.4280, -71.0680),41    ("Laval", 45.6066, -73.7124), ("Longueuil", 45.5312, -73.5185),42    ("Drummondville", 45.8833, -72.4833), ("Saint-Jérôme", 45.7803, -74.0038),43    ("Granby", 45.4001, -72.7300), ("Rimouski", 48.4489, -68.5236),44    ("Rouyn-Noranda", 48.2360, -79.0230), ("Val-d'Or", 48.0975, -77.7972),45    ("Gaspé", 48.8330, -64.4870), ("Sept-Îles", 50.2001, -66.3821),46    ("Baie-Comeau", 49.2166, -68.1487), ("Joliette", 46.0167, -73.4500),47    ("Saint-Hyacinthe", 45.6300, -72.9569), ("Sorel-Tracy", 46.0500, -73.1200),48    ("Victoriaville", 46.0533, -71.9667), ("Mont-Laurier", 46.5500, -75.5000),49    ("Alma", 48.5500, -71.6500), ("Matane", 48.8500, -67.5300),50    ("Saint-Sauveur", 45.8939, -74.2051), ("Baie-Saint-Paul", 47.4400, -70.5000),51    ("Salaberry-de-Valleyfield", 45.2500, -74.1300), ("Thetford Mines", 46.1000, -71.3000),52]5354_CARD_RE = re.compile(r'card--listing-card js-listing js-property-details(.*?)'55                      r'(?=card--listing-card js-listing js-property-details|</main>|$)', re.S)56_URL_RE = re.compile(r'/fr/property/quebec/[^"\']+?/mls[a-z0-9]+/', re.I)57_MLS_RE = re.compile(r'/mls([a-z0-9]+)/', re.I)58_KEY_RE = re.compile(r'data-rlp-key="(-?\d+\.\d+)\.(-?\d+\.\d+)"')59_PHOTO_RE = re.compile(r'(//rlp\.jumplisting\.com/photos/[^"\']+)')60_CAC_RE = re.compile(r'(\d+)\s*CAC', re.I)61_SDB_RE = re.compile(r'(\d+)(?:\+\d+)?\s*SDB', re.I)62_PRICE_RE = re.compile(r'([\d\s  ]{4,})\s*\|?\s*\$')63_TYPE_RE = re.compile(r'\|\s*(Maison|Condo|Appartement|Duplex|Triplex|Plex|Terrain|'64                      r'Chalet|Fermette|Ferme|Loft|Terre|Maison de ville|Jumelé|'65                      r'Quadruplex|Quintuplex|Commercial)\s*\|', re.I)666768class RoyalLepageConnector(BaseConnector):69    source_id = "royal_lepage"70    request_delay = 0.47172    def fetch(self) -> list[PropertyListing]:73        by_id: dict[str, PropertyListing] = {}74        for name, lat, lng in ANCHORS:75            self._search_anchor(name, lat, lng, by_id)76        listings = list(by_id.values())77        # bureau (agency) via la fiche détail — plafonné, cache accumulé78        du.enrich(self, listings, DETAIL_LIMIT, _parse_rlp_office, key="office1")79        for lst in listings:80            office = lst.details.pop("__agency", "")81            if office:82                lst.agency = office83        return listings8485    def _search_anchor(self, name: str, lat: float, lng: float, out: dict) -> None:86        empty_streak = 087        for page in range(1, MAX_PAGES + 1):88            params = {89                "search_str": f"{name}, QC", "prov_code": "QC",90                "city_name": name, "lat": lat, "lng": lng, "search_type": "city",91            }92            try:93                html = self.get(f"{BASE}/{page}/", params=params,94                                headers={"X-Requested-With": "XMLHttpRequest"}).text95            except Exception:96                break97            new = 098            for lst in self._parse_cards(html):99                if lst.external_id not in out:100                    out[lst.external_id] = lst101                    new += 1102            if new == 0:103                empty_streak += 1104                if empty_streak >= 2:   # cap serveur atteint (page répétée)105                    break106            else:107                empty_streak = 0108109    def _parse_cards(self, html: str) -> list[PropertyListing]:110        out = []111        for seg in _CARD_RE.findall(html):112            murl = _URL_RE.search(seg)113            if not murl:114                continue115            url = "https://www.royallepage.ca" + murl.group(0)116            mls = _MLS_RE.search(url)117            ext = mls.group(1) if mls else url118            # /fr/property/quebec/{ville}/{adresse}/{id}/mls{no}/119            parts = murl.group(0).strip("/").split("/")120            city = parts[3].replace("-", " ").title() if len(parts) > 3 else ""121            address = parts[4].replace("-", " ").title() if len(parts) > 4 else ""122            flat = _html.unescape(re.sub(r"<[^>]+>", " | ", seg))123            flat = re.sub(r"(\s*\|\s*)+", " | ", re.sub(r"\s+", " ", flat))124            beds = _CAC_RE.search(flat)125            baths = _SDB_RE.search(flat)126            price = _PRICE_RE.search(flat)127            typ = _TYPE_RE.search(flat)128            key = _KEY_RE.search(seg)129            photo = _PHOTO_RE.search(seg)130            images = []131            if photo:132                images = ["https:" + photo.group(1).replace("_0_med", "_0_lg")]133            out.append(PropertyListing(134                source=self.source_id, external_id=str(ext), url=url,135                title=address, address=address, city=city, region="Québec",136                property_type=(typ.group(1) if typ else ""),137                price=(parse_price(price.group(1)) if price else None),138                price_label=(price.group(0).strip() if price else ""),139                bedrooms=int(beds.group(1)) if beds else None,140                bathrooms=int(baths.group(1)) if baths else None,141                mls=(mls.group(1) if mls else ""),142                images=images,143                lat=float(key.group(1)) if key else None,144                lng=float(key.group(2)) if key else None,145                broker_name="Royal LePage",146            ))147        return out148149150def _parse_rlp_office(html: str) -> dict:151    """Extrait le bureau/brokerage de la fiche détail Royal LePage."""152    m = _OFFICE_RE.search(html)153    if not m:154        return {}155    office = _html.unescape(m.group(1)).strip()156    return {"details": {"__agency": office}} if office else {}157