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%
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*(m²|mc|pi²|pi|ft)\s*\|\s*Superficie habitable', re.I)200_LOT_RE = re.compile(r'([\d ,]+)\s*(m²|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