# ----------------------------------------------------------------------------- # Immo-Ka — Agrégateur de maisons à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/remax_quebec.py : RE/MAX Québec (remax-quebec.com) # Le site interroge un index Meilisearch public (search-only key exposée dans # la config de la page). L'index « inscriptions » couvre toute la province. # Meilisearch plafonne à 1000 hits par requête (maxTotalHits) : on shard donc # par région de tri d'acheminement postale (FSA, 3 premiers caractères) et on # dédoublonne par numéro d'inscription. Couverture validée vs le total global. # ----------------------------------------------------------------------------- from __future__ import annotations import html as _html import os import re from .base import BaseConnector from ..normalize import parse_area_sqft, parse_price from ..schema import PropertyListing SEARCH_URL = "https://search.remax-quebec.com/indexes/inscriptions/search" # Clé de recherche (search-only) exposée publiquement dans la config du site. SEARCH_KEY = "b0b93998ab78573e8b937b528ad37d2ce3fbc97e07a9f2c909c4220db910c152" SITE = "https://www.remax-quebec.com" # Territoires postaux du Québec : préfixes G, H et J. FSA_LETTERS = ("G", "H", "J") SOLD_TOKENS = ("vendu", "sold", "loué", "loue", "rented") # Enrichissement page détail (photos + specs) : plafonné par exécution pour # garder chaque sync borné. Les fiches déjà en cache sont réutilisées gratis ; # seules les nouvelles/modifiées consomment le budget. Sur plusieurs cycles, # tout le parc finit enrichi. Surchargeable via IMMOKA_REMAX_DETAIL_LIMIT. DETAIL_LIMIT = int(os.environ.get("IMMOKA_REMAX_DETAIL_LIMIT", "3000")) class RemaxQuebecConnector(BaseConnector): source_id = "remax_quebec" request_delay = 0.15 # API JSON rapide ; on reste poli use_detail_cache = True # cache BD des pages détail (photos/specs) def fetch(self) -> list[PropertyListing]: by_id: dict[int, dict] = {} for fsa in self._fsa_candidates(): hits = self._search(fsa) for h in hits: nid = h.get("no_inscription") if nid is not None: by_id[nid] = h # dédoublonnage inter-shards listings = [] for h in by_id.values(): lst = self._to_listing(h) if lst is not None: listings.append(lst) self._enrich(listings) return listings # -- enrichissement (photos + specs via la page détail) ------------------- def _enrich(self, listings: list[PropertyListing]) -> None: from .. import db con = db.connect() budget = DETAIL_LIMIT for lst in listings: key = f"v3|{lst.price_label or '-'}" # v3 = + agence/bureau ; refetch si prix change cached = db.get_cached_detail(con, self.source_id, lst.external_id, key) if cached is None: if budget <= 0: continue # enrichi à un prochain cycle cached = self._scrape_detail(lst.url) db.put_cached_detail(con, self.source_id, lst.external_id, key, cached) budget -= 1 _apply_detail(lst, cached) con.close() def _scrape_detail(self, url: str) -> dict: try: html = self.get(url).text except Exception: return {} return parse_remax_detail(html) # -- sharding -------------------------------------------------------------- @staticmethod def _fsa_candidates(): for letter in FSA_LETTERS: for digit in "0123456789": for last in "ABCDEFGHIJKLMNOPQRSTUVWXYZ": yield f"{letter}{digit}{last}" def _search(self, q: str) -> list[dict]: try: resp = self.post( SEARCH_URL, headers={"Authorization": f"Bearer {SEARCH_KEY}", "Content-Type": "application/json"}, json={"q": q, "limit": 1000}, ) except Exception: return [] data = resp.json() return data.get("hits", []) if isinstance(data, dict) else [] # -- mapping --------------------------------------------------------------- def _to_listing(self, h: dict) -> PropertyListing | None: nid = h.get("no_inscription") if nid is None: return None price_label = (h.get("display_price") or {}).get("fr", "") or "" # exclure les propriétés vendues / retirées (l'index les conserve) if any(tok in price_label.lower() for tok in SOLD_TOKENS): return None slug = (h.get("slug") or {}).get("fr", "") or "" url = f"{SITE}/fr/proprietes/{slug}" if slug else f"{SITE}/fr" full_addr = (h.get("full_address") or {}).get("fr", "") or "" address, sector, city = _split_address(full_addr) prop_type, region = _from_slug(slug) return PropertyListing( source=self.source_id, external_id=str(nid), url=url, title=full_addr, address=address, sector=sector, city=city, region=region, property_type=prop_type, price=parse_price(price_label), price_label=price_label, broker_name="RE/MAX Québec", ) # --------------------------------------------------------------------------- # Analyse de l'adresse et du slug # --------------------------------------------------------------------------- _PAREN_RE = re.compile(r"\(([^)]*)\)") _POSTAL_RE = re.compile(r"[GHJ]\d[A-Z]\s?\d[A-Z]\d", re.I) def _split_address(full: str) -> tuple[str, str, str]: """« 16146 Rue Forsyth, Montréal (Rivière-des-Prairies) (X), H1A5S9 » -> (adresse, secteur, ville).""" if not full: return "", "", "" parts = [p.strip() for p in full.split(",")] # retirer le code postal final if parts and _POSTAL_RE.search(parts[-1]): parts = parts[:-1] address = parts[0] if parts else "" city = sector = "" if len(parts) >= 2: muni = parts[1] parens = _PAREN_RE.findall(muni) city = _PAREN_RE.sub("", muni).strip() if parens: sector = parens[-1].strip() # secteur supplémentaire dans les champs suivants (avant le postal) for extra in parts[2:]: e = _PAREN_RE.sub("", extra).strip() or extra.strip() if e and not sector: sector = e return address, sector, city _TYPE_SLUG = { "maison": "Maison", "house": "Maison", "condo": "Condo", "appartement": "Condo", "apartment": "Condo", "plex": "Multiplex", "duplex": "Duplex", "triplex": "Triplex", "terrain": "Terrain", "land": "Terrain", "chalet": "Chalet", "cottage": "Chalet", "fermette": "Fermette/Agricole", "ferme": "Fermette/Agricole", "commercial": "Commercial", "commerciale": "Commercial", } # --------------------------------------------------------------------------- # Page détail : photos + description + caractéristiques # --------------------------------------------------------------------------- _LD_RE = re.compile(r']+application/ld\+json[^>]*>(.*?)', re.S | re.I) # Les photos apparaissent dans plusieurs buckets de taille (www_full, _medium, # _small…) selon le lazy-load ; on les capte toutes et on les normalise en # pleine résolution, dédoublonnées par nom de fichier. _IMG_RE = re.compile( r'https://media\.remax-quebec\.com/img/www_[a-z]+/[^"\'\\ ]+\.(?:jpg|jpeg|png|webp)', re.I) _IMG_SIZE_RE = re.compile(r'/www_[a-z]+/', re.I) # Dans la section caractéristiques, la valeur précède le libellé : # « 2 (1 + 1) | Chambres », « 1 | Salle de bain », « 1975 | Année de construction » _SPEC_RE = { "bedrooms": re.compile(r'(\d+)(?:\s*\([^)]*\))?\s*\|\s*Chambre', re.I), "bathrooms": re.compile(r'(\d+)\s*\|\s*Salle de bain', re.I), "powder_rooms": re.compile(r"(\d+)\s*\|\s*Salle d'eau", re.I), "year_built": re.compile(r'(\d{4})\s*\|\s*Ann[ée]+e de construction', re.I), } _AREA_RE = re.compile(r'([\d ,]+)\s*(m²|mc|pi²|pi|ft)\s*\|\s*Superficie habitable', re.I) _LOT_RE = re.compile(r'([\d ,]+)\s*(m²|mc|pi²|pi|ft)\s*\|\s*Superficie du terrain', re.I) _COORD_RE = re.compile(r'query=(-?\d+\.\d+)%2C\+?(-?\d+\.\d+)') _ROOM_RE = re.compile(r'rooms-details-section__vertical-table[^>]*>(.*?)\s*', re.S) _QSTAT_RE = re.compile( r'quick-stat-text[^>]*>\s*]*>\s*(.*?)\s*\s*]*>\s*(.*?)\s*', re.S) _INCL_RE = re.compile(r'inclusions-exclusions-section(.*?)(?:|realtor-section|financial-section)', re.S) _PHONE_RE = re.compile(r'\d{3}[\s ]\d{3}-\d{4}') # Ensemble de libellés Centris standard capturés dans le tableau « valeur | libellé ». _DETAIL_LABELS = [ "Type de propriété", "Genre de propriété", "Style de bâtiment", "Année de construction", "Nombre d'unités", "Superficie du bâtiment (au sol)", "Superficie du terrain", "Superficie habitable", "Stationnement (total)", "Système de chauffage", "Énergie pour le chauffage", "Fenêtres", "Type de fenestration", "Toiture", "Revêtement", "Sous-sol", "Piscine", "Garage", "Zonage", "Système d'égouts", "Approvisionnement en eau", "Déménagement", "Taxes municipales", "Taxes scolaires", "Évaluation municipale (terrain)", "Évaluation municipale (bâtiment)", "Cuisine", "Salle de bain / Salle d'eau", ] def _flatten(html: str) -> str: t = _html.unescape(re.sub(r'<[^>]+>', ' | ', html)) t = re.sub(r'[ \t\r\n]*\|[ \t\r\n|]*', ' | ', t) return re.sub(r'[ \t]+', ' ', t) def parse_remax_detail(html: str) -> dict: """Extrait TOUT le contenu d'une page détail RE/MAX : photos, description, coordonnées, caractéristiques complètes, pièces (dimensions), inclusions, taxes/évaluation, courtier. Objectif : rien perdre par rapport à la fiche source.""" import json out: dict = {} details: dict = {} # description via JSON-LD RealEstateListing for block in _LD_RE.findall(html): try: data = json.loads(block) except ValueError: continue for node in (data if isinstance(data, list) else [data]): if isinstance(node, dict) and node.get("@type") == "RealEstateListing" and node.get("description"): out["description"] = _html.unescape(node["description"]).strip() # photos : toutes tailles → pleine résolution, dédoublonnées par nom de fichier seen, images = set(), [] for u in _IMG_RE.findall(html): full = _IMG_SIZE_RE.sub("/www_full/", u) fn = full.rsplit("/", 1)[-1] if fn not in seen and "nophoto" not in full: seen.add(fn) images.append(full) if images: out["images"] = images # coordonnées GPS (lien Google Maps) m = _COORD_RE.search(html) if m: out["lat"], out["lng"] = float(m.group(1)), float(m.group(2)) # courtier + téléphone (le nom précède « Courtier immobilier ») mb = re.search(r'>\s*([A-ZÀ-Ÿ][A-Za-zÀ-ÿ .\'-]{4,45})\s*[^<]*' r'(?:<[^>]+>\s*)*Courtier immobilier', html) if mb: name = _html.unescape(re.sub(r'\s+', ' ', mb.group(1))).strip() if name: out["broker_name"] = name mp = _PHONE_RE.search(html) if mp: out["broker_phone"] = mp.group(0) # agence / bureau (« RE/MAX ALLIANCE INC. ») — pour les Sources par sous-agence ma = re.search(r'RE/MAX[^<,|"\n]{1,40}\bINC\.?', html) if ma: out["agency"] = _html.unescape(re.sub(r'\s+', ' ', ma.group(0))).strip().title() \ .replace("Re/Max", "RE/MAX").replace("Inc.", "inc.") text = _flatten(html) # champs numériques principaux for field, rx in _SPEC_RE.items(): m = rx.search(text) if m: out[field] = int(m.group(1)) m = _AREA_RE.search(text) if m: out["area_sqft"] = parse_area_sqft(f"{m.group(1)} {m.group(2)}") m = _LOT_RE.search(text) if m: out["lot_sqft"] = parse_area_sqft(f"{m.group(1)} {m.group(2)}") # tableau de caractéristiques complet (valeur | libellé) → details for label in _DETAIL_LABELS: m = re.search(r'([^|]{1,60})\s*\|\s*' + re.escape(label) + r'\b', text) if m: val = m.group(1).strip(" |") if val and 1 <= len(val) <= 60 and val.lower() != label.lower(): details[label] = val # pièces avec dimensions/niveau/revêtement rooms = [] for blk in _ROOM_RE.findall(html): rt = _html.unescape(re.sub(r'<[^>]+>', ' ', blk)) rt = re.sub(r'\s+', ' ', rt).strip() name = re.split(r'Niveau\s*:', rt)[0].strip() niveau = (re.search(r'Niveau\s*:\s*([^:]+?)(?:Dimensions|Revêtement|$)', rt) or [None, ""]) dim = re.search(r'Dimensions\s*:\s*([0-9\'".,X x×]+)', rt) rev = re.search(r'Revêtement\s*:\s*([A-Za-zÀ-ÿ ,-]+?)(?:\s*Détails|\s*$)', rt) if name: rooms.append({ "nom": name[:40], "niveau": (niveau[1].strip() if niveau else ""), "dimensions": (dim.group(1).strip() if dim else ""), "revetement": (rev.group(1).strip() if rev else ""), }) if rooms: details["pieces"] = rooms[:20] # quick stats (Nb de pièces, superficie…) for label, val in _QSTAT_RE.findall(html): label = _html.unescape(re.sub(r'\s+', ' ', label)).strip() val = _html.unescape(re.sub(r'\s+', ' ', val)).strip() if label and val and label not in details: details[label] = val # inclusions / exclusions → features mi = _INCL_RE.search(html) feats = [] if mi: blk = _html.unescape(re.sub(r'<[^>]+>', '\n', mi.group(1))) for line in blk.split('\n'): line = line.strip(" \t•-") if 3 <= len(line) <= 120 and not line.lower().startswith(("inclusion", "exclusion")): feats.append(line) if feats: out["features"] = feats[:25] # superficie habitable depuis details si pas déjà captée if out.get("area_sqft") is None and details.get("Superficie habitable"): out["area_sqft"] = parse_area_sqft(details["Superficie habitable"]) if out.get("lot_sqft") is None and details.get("Superficie du terrain"): out["lot_sqft"] = parse_area_sqft(details["Superficie du terrain"]) if details: out["details"] = details return out def _apply_detail(lst: PropertyListing, d: dict) -> None: if not d: return if d.get("images"): lst.images = d["images"] if d.get("features"): lst.features = d["features"] if d.get("details"): lst.details.update(d["details"]) # le nom réel du courtier (page détail) remplace le placeholder « RE/MAX Québec » if d.get("broker_name"): lst.broker_name = d["broker_name"] # agence/bureau -> sous-agence pour les Sources if d.get("agency"): lst.agency = d["agency"] for f in ("description", "bedrooms", "bathrooms", "powder_rooms", "year_built", "area_sqft", "lot_sqft", "lat", "lng", "broker_phone"): if d.get(f) is not None and getattr(lst, f, None) in (None, "", 0): setattr(lst, f, d[f]) def _from_slug(slug: str) -> tuple[str, str]: """slug « maison-a-vendre-laurentides/1744-rue-carmen-val-david-9004907 » -> (type de propriété, région).""" if not slug: return "", "" head = slug.split("/", 1)[0] # maison-a-vendre-laurentides prop_type = "" for key, canon in _TYPE_SLUG.items(): if re.search(rf"\b{key}", head): prop_type = canon break # région = ce qui suit « -a-vendre-/-for-sale- » m = re.search(r"(?:a-vendre|for-sale)-(.+)$", head) region = m.group(1).replace("-", " ").title() if m else "" return prop_type, region