# ----------------------------------------------------------------------------- # Immo-Ka — Agrégateur de maisons à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/remax_agences.py : BACKUP RE/MAX — un connecteur par sous-agence # # Les ~54 bannières RE/MAX du Québec (RE/MAX 1er Choix, du Cartier, 3000, # Platine…) ont chacune leur SITE PROPRE, indépendant du portail central # remax-quebec.com. La majorité (42) partagent la même plateforme Centris # (« /fr/nos-proprietes/{ville}/{adresse}/{no-centris} », rendu serveur, # sitemap.xml exhaustif). C'est un plan B si la clé Meilisearch centrale # (voir remax_quebec.py) est rotée ou bloquée. # # Une CLASSE PAR AGENCE est générée dynamiquement depuis # data/remax_agencies.json (source_id « remax_ag_ »). L'énumération # se fait via le sitemap (1 requête → toutes les propriétés) ; le numéro # Centris (dans l'URL) sert de clé de déduplication contre le flux central # (voir la fusion dédupliquée dans immoka/web.py) — aucun double-comptage. # ----------------------------------------------------------------------------- from __future__ import annotations import json import os import re from pathlib import Path from .base import BaseConnector from ..schema import PropertyListing REGISTRY = Path(__file__).resolve().parent.parent.parent / "data" / "remax_agencies.json" # Enrichissement page détail (prix/chambres/photos) : coûteux (1 requête par # propriété). Désactivé par défaut : le flux central est déjà enrichi et ces # fiches sont dédoublonnées contre lui. Activable par agence si le central tombe. DETAIL_LIMIT = int(os.environ.get("IMMOKA_AGENCE_DETAIL_LIMIT", "0")) _PROP_RE = re.compile(r'/fr/nos-proprietes/([a-z0-9-]+)/([^<"/]+)/(\d+)', re.I) # 2e plateforme Centris (« centris-other ») : sitemap_inscriptions.xml, URL # /fr/proprietes/{type-ville-secteur}/{adresse}-{no-centris} _PROP_RE2 = re.compile(r'/fr/proprietes/([a-z0-9-]+)/([a-z0-9-]+?)-(\d{6,})(?:/)?(?=["<]|$)', re.I) # préfixes de type de propriété (les plus longs d'abord) pour découper le 1er segment _TYPE_PREFIXES = [ "batisse-commerciale-bureau", "chalet-et-maison-de-campagne", "maison-a-paliers-multiples", "maison-de-plain-pied", "maison-a-etages", "propriete-a-revenus", "local-commercial", "fond-de-commerce", "terre-agricole", "maison-mobile", "quadruplex", "quintuplex", "appartement", "condominium", "fermette", "triplex", "duplex", "chalet", "terrain", "maison", "condo", "commerce", "bureau", "industriel", "ferme", "garage", "loft", "terre", ] _IMG_RE = re.compile( r'https://media\.remax-quebec\.com/img/(?:fiche|www_full|www_large|v4thumbs)/[^"\'\\ ]+\.(?:jpg|jpeg|png|webp)', re.I) _IMG_SIZE_RE = re.compile(r'/img/(?:fiche|www_[a-z]+|v4thumbs)/', re.I) class RemaxAgenceConnector(BaseConnector): """Base des connecteurs de sous-agences RE/MAX (plateforme « nos-proprietes »). Les sous-classes (générées plus bas) définissent `source_id`, `domain` et `agency_name`. """ domain: str = "" agency_name: str = "" platform: str = "nos-proprietes" # ou "centris-other" request_delay = 0.4 use_detail_cache = True def fetch(self) -> list[PropertyListing]: urls = self._sitemap_property_urls() by_id: dict[str, PropertyListing] = {} for url in urls: lst = self._to_listing(url) if lst is not None: by_id[lst.external_id] = lst if DETAIL_LIMIT: self._enrich(list(by_id.values())) return list(by_id.values()) def _sitemap_url(self) -> str: return ("sitemap_inscriptions.xml" if self.platform == "centris-other" else "sitemap.xml") def _sitemap_property_urls(self) -> list[str]: sm = self._sitemap_url() xml = "" for host in (self.domain, f"www.{self.domain}"): try: xml = self.get(f"https://{host}/{sm}").text if xml: break except Exception: continue if not xml: return [] rx = _PROP_RE2 if self.platform == "centris-other" else _PROP_RE seen, out = set(), [] for m in rx.finditer(xml): centris = m.groups()[-1] if centris not in seen: seen.add(centris) out.append(m.group(0)) return out def _to_listing(self, path: str) -> PropertyListing | None: if self.platform == "centris-other": return self._to_listing_centris(path) m = _PROP_RE.search(path) if not m: return None city_slug, addr_slug, centris = m.group(1), m.group(2), m.group(3) url = f"https://{self.domain}{path if path.startswith('/') else '/' + path}" city, sector = _split_city(city_slug) return PropertyListing( source=self.source_id, external_id=centris, url=url, title=_deslug(addr_slug), address=_deslug(addr_slug), city=city, sector=sector, mls=centris, broker_name=self.agency_name, ) def _to_listing_centris(self, url: str) -> PropertyListing | None: m = _PROP_RE2.search(url) if not m: return None seg1, addr_slug, centris = m.group(1), m.group(2), m.group(3) ptype, rest = _split_type(seg1) # type + « ville-secteur » city, sector = _split_city(rest) return PropertyListing( source=self.source_id, external_id=centris, url=url if url.startswith("http") else f"https://{self.domain}{url}", title=_deslug(addr_slug), address=_deslug(addr_slug), property_type=ptype, city=city, sector=sector, mls=centris, broker_name=self.agency_name, ) # -- enrichissement optionnel (prix/chambres/photos) ---------------------- def _enrich(self, listings: list[PropertyListing]) -> None: from .. import db con = db.connect() budget = DETAIL_LIMIT for lst in listings: cached = db.get_cached_detail(con, self.source_id, lst.external_id, "d") if cached is None: if budget <= 0: continue cached = self._scrape_detail(lst.url) db.put_cached_detail(con, self.source_id, lst.external_id, "d", cached) budget -= 1 _apply(lst, cached) con.close() def _scrape_detail(self, url: str) -> dict: try: html = self.get(url).text except Exception: return {} out: dict = {} seen, imgs = set(), [] for u in _IMG_RE.findall(html): full = _IMG_SIZE_RE.sub("/img/www_full/", u) fn = full.rsplit("/", 1)[-1] if fn not in seen and "nophoto" not in full: seen.add(fn) imgs.append(full) if imgs: out["images"] = imgs import html as _h t = _h.unescape(re.sub(r"<[^>]+>", " ", html)) t = re.sub(r"\s+", " ", t) mp = re.search(r"([\d ]{4,})\s*\$", t) if mp: out["price_label"] = mp.group(0).strip() mb = re.search(r"(\d+)\s*chambre", t, re.I) if mb: out["bedrooms"] = int(mb.group(1)) ms = re.search(r"(\d+)\s*salle[s]?\s*de\s*bain", t, re.I) if ms: out["bathrooms"] = int(ms.group(1)) return out def _apply(lst: PropertyListing, d: dict) -> None: if d.get("images"): lst.images = d["images"] if d.get("price_label") and not lst.price_label: lst.price_label = d["price_label"] for f in ("bedrooms", "bathrooms"): if d.get(f) is not None and getattr(lst, f, None) is None: setattr(lst, f, d[f]) def _deslug(s: str) -> str: return re.sub(r"\s+", " ", s.replace("-", " ")).strip().title() def _split_type(seg1: str) -> tuple[str, str]: """« propriete-a-revenus-laval-laval-des-rapides » -> (type, « laval-... »).""" for pref in _TYPE_PREFIXES: if seg1.startswith(pref): return _deslug(pref), seg1[len(pref):].lstrip("-") # pas de type reconnu : tout est considéré comme localisation return "", seg1 # quelques villes composées fréquentes dans les slugs de la plateforme def _split_city(slug: str) -> tuple[str, str]: parts = slug.split("-") city = parts[0].title() if parts else "" fixes = {"Quebec": "Québec", "Levis": "Lévis", "Montreal": "Montréal", "Trois": "Trois-Rivières", "St": "Saint"} city = fixes.get(city, city) sector = _deslug("-".join(parts[1:])) if len(parts) > 1 else "" return city, sector # --------------------------------------------------------------------------- # Génération d'une classe par sous-agence (plateforme « nos-proprietes ») # --------------------------------------------------------------------------- def _slugify_id(banner: str) -> str: return "remax_ag_" + re.sub(r"[^a-z0-9]+", "_", banner.replace("remax-", "")).strip("_") def _load_agencies() -> list[dict]: try: return json.loads(REGISTRY.read_text(encoding="utf-8")) except Exception: return [] # Crée dynamiquement RemaxAg<...> pour chaque agence dotée d'un site propre, # sur l'une des deux plateformes Centris reconnues (nos-proprietes / centris-other). for _ag in _load_agencies(): _plat = _ag.get("platform") if _plat not in ("nos-proprietes", "centris-other") or not _ag.get("domain"): continue _dom = re.sub(r"^https?://", "", _ag["domain"]).rstrip("/") _sid = _slugify_id(_ag["banner"]) _name = "RE/MAX " + _ag["banner"].replace("remax-", "").replace("-", " ").title() globals()[_sid.upper()] = type( "".join(p.title() for p in _sid.split("_")), (RemaxAgenceConnector,), {"source_id": _sid, "domain": _dom, "agency_name": _name, "platform": _plat}, )