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_agences.py : BACKUP RE/MAX — un connecteur par sous-agence5#6# Les ~54 bannières RE/MAX du Québec (RE/MAX 1er Choix, du Cartier, 3000,7# Platine…) ont chacune leur SITE PROPRE, indépendant du portail central8# remax-quebec.com. La majorité (42) partagent la même plateforme Centris9# (« /fr/nos-proprietes/{ville}/{adresse}/{no-centris} », rendu serveur,10# sitemap.xml exhaustif). C'est un plan B si la clé Meilisearch centrale11# (voir remax_quebec.py) est rotée ou bloquée.12#13# Une CLASSE PAR AGENCE est générée dynamiquement depuis14# data/remax_agencies.json (source_id « remax_ag_<slug> »). L'énumération15# se fait via le sitemap (1 requête → toutes les propriétés) ; le numéro16# Centris (dans l'URL) sert de clé de déduplication contre le flux central17# (voir la fusion dédupliquée dans immoka/web.py) — aucun double-comptage.18# -----------------------------------------------------------------------------19from __future__ import annotations2021import json22import os23import re24from pathlib import Path2526from .base import BaseConnector27from ..schema import PropertyListing2829REGISTRY = Path(__file__).resolve().parent.parent.parent / "data" / "remax_agencies.json"3031# Enrichissement page détail (prix/chambres/photos) : coûteux (1 requête par32# propriété). Désactivé par défaut : le flux central est déjà enrichi et ces33# fiches sont dédoublonnées contre lui. Activable par agence si le central tombe.34DETAIL_LIMIT = int(os.environ.get("IMMOKA_AGENCE_DETAIL_LIMIT", "0"))3536_PROP_RE = re.compile(r'/fr/nos-proprietes/([a-z0-9-]+)/([^<"/]+)/(\d+)', re.I)37# 2e plateforme Centris (« centris-other ») : sitemap_inscriptions.xml, URL38# /fr/proprietes/{type-ville-secteur}/{adresse}-{no-centris}39_PROP_RE2 = re.compile(r'/fr/proprietes/([a-z0-9-]+)/([a-z0-9-]+?)-(\d{6,})(?:/)?(?=["<]|$)', re.I)40# préfixes de type de propriété (les plus longs d'abord) pour découper le 1er segment41_TYPE_PREFIXES = [42 "batisse-commerciale-bureau", "chalet-et-maison-de-campagne",43 "maison-a-paliers-multiples", "maison-de-plain-pied", "maison-a-etages",44 "propriete-a-revenus", "local-commercial", "fond-de-commerce",45 "terre-agricole", "maison-mobile", "quadruplex", "quintuplex",46 "appartement", "condominium", "fermette", "triplex", "duplex", "chalet",47 "terrain", "maison", "condo", "commerce", "bureau", "industriel", "ferme",48 "garage", "loft", "terre",49]50_IMG_RE = re.compile(51 r'https://media\.remax-quebec\.com/img/(?:fiche|www_full|www_large|v4thumbs)/[^"\'\\ ]+\.(?:jpg|jpeg|png|webp)',52 re.I)53_IMG_SIZE_RE = re.compile(r'/img/(?:fiche|www_[a-z]+|v4thumbs)/', re.I)545556class RemaxAgenceConnector(BaseConnector):57 """Base des connecteurs de sous-agences RE/MAX (plateforme « nos-proprietes »).5859 Les sous-classes (générées plus bas) définissent `source_id`, `domain`60 et `agency_name`.61 """6263 domain: str = ""64 agency_name: str = ""65 platform: str = "nos-proprietes" # ou "centris-other"66 request_delay = 0.467 use_detail_cache = True6869 def fetch(self) -> list[PropertyListing]:70 urls = self._sitemap_property_urls()71 by_id: dict[str, PropertyListing] = {}72 for url in urls:73 lst = self._to_listing(url)74 if lst is not None:75 by_id[lst.external_id] = lst76 if DETAIL_LIMIT:77 self._enrich(list(by_id.values()))78 return list(by_id.values())7980 def _sitemap_url(self) -> str:81 return ("sitemap_inscriptions.xml" if self.platform == "centris-other"82 else "sitemap.xml")8384 def _sitemap_property_urls(self) -> list[str]:85 sm = self._sitemap_url()86 xml = ""87 for host in (self.domain, f"www.{self.domain}"):88 try:89 xml = self.get(f"https://{host}/{sm}").text90 if xml:91 break92 except Exception:93 continue94 if not xml:95 return []96 rx = _PROP_RE2 if self.platform == "centris-other" else _PROP_RE97 seen, out = set(), []98 for m in rx.finditer(xml):99 centris = m.groups()[-1]100 if centris not in seen:101 seen.add(centris)102 out.append(m.group(0))103 return out104105 def _to_listing(self, path: str) -> PropertyListing | None:106 if self.platform == "centris-other":107 return self._to_listing_centris(path)108 m = _PROP_RE.search(path)109 if not m:110 return None111 city_slug, addr_slug, centris = m.group(1), m.group(2), m.group(3)112 url = f"https://{self.domain}{path if path.startswith('/') else '/' + path}"113 city, sector = _split_city(city_slug)114 return PropertyListing(115 source=self.source_id, external_id=centris, url=url,116 title=_deslug(addr_slug), address=_deslug(addr_slug),117 city=city, sector=sector, mls=centris, broker_name=self.agency_name,118 )119120 def _to_listing_centris(self, url: str) -> PropertyListing | None:121 m = _PROP_RE2.search(url)122 if not m:123 return None124 seg1, addr_slug, centris = m.group(1), m.group(2), m.group(3)125 ptype, rest = _split_type(seg1) # type + « ville-secteur »126 city, sector = _split_city(rest)127 return PropertyListing(128 source=self.source_id, external_id=centris,129 url=url if url.startswith("http") else f"https://{self.domain}{url}",130 title=_deslug(addr_slug), address=_deslug(addr_slug),131 property_type=ptype, city=city, sector=sector,132 mls=centris, broker_name=self.agency_name,133 )134135 # -- enrichissement optionnel (prix/chambres/photos) ----------------------136 def _enrich(self, listings: list[PropertyListing]) -> None:137 from .. import db138 con = db.connect()139 budget = DETAIL_LIMIT140 for lst in listings:141 cached = db.get_cached_detail(con, self.source_id, lst.external_id, "d")142 if cached is None:143 if budget <= 0:144 continue145 cached = self._scrape_detail(lst.url)146 db.put_cached_detail(con, self.source_id, lst.external_id, "d", cached)147 budget -= 1148 _apply(lst, cached)149 con.close()150151 def _scrape_detail(self, url: str) -> dict:152 try:153 html = self.get(url).text154 except Exception:155 return {}156 out: dict = {}157 seen, imgs = set(), []158 for u in _IMG_RE.findall(html):159 full = _IMG_SIZE_RE.sub("/img/www_full/", u)160 fn = full.rsplit("/", 1)[-1]161 if fn not in seen and "nophoto" not in full:162 seen.add(fn)163 imgs.append(full)164 if imgs:165 out["images"] = imgs166 import html as _h167 t = _h.unescape(re.sub(r"<[^>]+>", " ", html))168 t = re.sub(r"\s+", " ", t)169 mp = re.search(r"([\d ]{4,})\s*\$", t)170 if mp:171 out["price_label"] = mp.group(0).strip()172 mb = re.search(r"(\d+)\s*chambre", t, re.I)173 if mb:174 out["bedrooms"] = int(mb.group(1))175 ms = re.search(r"(\d+)\s*salle[s]?\s*de\s*bain", t, re.I)176 if ms:177 out["bathrooms"] = int(ms.group(1))178 return out179180181def _apply(lst: PropertyListing, d: dict) -> None:182 if d.get("images"):183 lst.images = d["images"]184 if d.get("price_label") and not lst.price_label:185 lst.price_label = d["price_label"]186 for f in ("bedrooms", "bathrooms"):187 if d.get(f) is not None and getattr(lst, f, None) is None:188 setattr(lst, f, d[f])189190191def _deslug(s: str) -> str:192 return re.sub(r"\s+", " ", s.replace("-", " ")).strip().title()193194195def _split_type(seg1: str) -> tuple[str, str]:196 """« propriete-a-revenus-laval-laval-des-rapides » -> (type, « laval-... »)."""197 for pref in _TYPE_PREFIXES:198 if seg1.startswith(pref):199 return _deslug(pref), seg1[len(pref):].lstrip("-")200 # pas de type reconnu : tout est considéré comme localisation201 return "", seg1202203204# quelques villes composées fréquentes dans les slugs de la plateforme205def _split_city(slug: str) -> tuple[str, str]:206 parts = slug.split("-")207 city = parts[0].title() if parts else ""208 fixes = {"Quebec": "Québec", "Levis": "Lévis", "Montreal": "Montréal",209 "Trois": "Trois-Rivières", "St": "Saint"}210 city = fixes.get(city, city)211 sector = _deslug("-".join(parts[1:])) if len(parts) > 1 else ""212 return city, sector213214215# ---------------------------------------------------------------------------216# Génération d'une classe par sous-agence (plateforme « nos-proprietes »)217# ---------------------------------------------------------------------------218219def _slugify_id(banner: str) -> str:220 return "remax_ag_" + re.sub(r"[^a-z0-9]+", "_", banner.replace("remax-", "")).strip("_")221222223def _load_agencies() -> list[dict]:224 try:225 return json.loads(REGISTRY.read_text(encoding="utf-8"))226 except Exception:227 return []228229230# Crée dynamiquement RemaxAg<...> pour chaque agence dotée d'un site propre,231# sur l'une des deux plateformes Centris reconnues (nos-proprietes / centris-other).232for _ag in _load_agencies():233 _plat = _ag.get("platform")234 if _plat not in ("nos-proprietes", "centris-other") or not _ag.get("domain"):235 continue236 _dom = re.sub(r"^https?://", "", _ag["domain"]).rstrip("/")237 _sid = _slugify_id(_ag["banner"])238 _name = "RE/MAX " + _ag["banner"].replace("remax-", "").replace("-", " ").title()239 globals()[_sid.upper()] = type(240 "".join(p.title() for p in _sid.split("_")),241 (RemaxAgenceConnector,),242 {"source_id": _sid, "domain": _dom, "agency_name": _name, "platform": _plat},243 )244