Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)
Python 47.5%
HTML 27.9%
TypeScript 15.5%
CSS 7.2%
JavaScript 2%
1# -----------------------------------------------------------------------------2# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/exp_quebec.py : eXp Realty Québec (expquebec.com) — toute la province.5#6# L'API cliente realestate.marketingwebsites.ca/api.php est verrouillée par7# whitelist IP (impasse, même via proxy). MAIS le serveur WordPress (autorisé8# sur l'API) rend l'inventaire COMPLET côté serveur sur9# /fr/properties/?pages={N} (9 fiches/page, param « pages » uniquement).10# Chaque carte porte data-href /fr/properties/mls/{MLS}, data-price ($US),11# data-address (rue) et data-pic (property-images/{MLS}/). Le n° MLS = n° Centris.12#13# FICHE DÉTAIL /fr/properties/mls/{MLS}/ : server-rendered elle aussi14# (requêtes directes, aucun anti-bot) et TRÈS riche — galerie complète en15# pleine résolution (~1728px, ancres data-lightbox="gallery-item"), remarque16# du courtier + addendum, inclusions/exclusions, icônes (chambres/sdb/17# superficie MC|PC/année), tables th/td par section (BÂTIMENT, ÉVALUATION,18# DÉPENSES, CARACTÉRISTIQUES, DÉTAILS DE PIÈCE), GPS exact dans l'embed19# Street View, courtier inscripteur (photo alt + tel: du bloc latéral).20# Enrichissement via _detailutil.enrich (cache BD + plafond21# IMMOKA_EXP_DETAIL_LIMIT, ~400/cycle).22#23# La VILLE vient de la fiche détail (en-tête h2) ; repli sur l'endpoint SSR24# /wp-content/themes/canvas/load/map-property.php?mls={MLS}&lang=fr pour les25# fiches au-delà du budget détail (cache « city » déjà peuplé).26#27# ⚠ PLAFOND : le SSR reflète le limit=1000 de l'API sous-jacente → au plus28# ~1000 fiches (pages 1..~114, crawl vérifié). C'est l'inventaire pratique29# complet ; si eXp QC dépasse un jour 1000 inscriptions, segmenter par région30# (params MW_region / MW_q du formulaire), chaque segment ayant son propre cap.31#32# source_id « exp_ag_qc » : infixe _ag_ = dédup Centris (db.refresh_dedup) —33# les fiches co-listées avec une bannière couverte sont masquées.34# -----------------------------------------------------------------------------35from __future__ import annotations3637import html as _html38import os39import re4041from .base import BaseConnector42from . import _detailutil as du43from ..normalize import parse_price44from ..schema import PropertyListing4546SITE = "https://expquebec.com"47LISTING = SITE + "/fr/properties/"48MAP_PROP = SITE + "/wp-content/themes/canvas/load/map-property.php"49DETAIL_LIMIT = int(os.environ.get("IMMOKA_EXP_DETAIL_LIMIT", "400"))50CITY_LIMIT = int(os.environ.get("IMMOKA_EXP_CITY_LIMIT", "1200"))51AGENCY = "eXp Agence immobilière"5253_CARD_SPLIT = re.compile(r'data-href="/fr/properties/mls/(\d{6,9})"')54_PRICE_RE = re.compile(r'data-price="([^"]*)"', re.I)55_ADDR_RE = re.compile(r'data-address="([^"]*)"', re.I)56_PIC_RE = re.compile(r'data-pic="([^"]*)"', re.I)5758# ---- fiche détail -----------------------------------------------------------59_GALLERY_RE = re.compile(r'<a href="(https?://[^"]+)"[^>]*data-lightbox="gallery-item"')60_H2_RE = re.compile(r'<small>MLS\s*#\s*\d+</small><br>\s*(.*?)</h2>', re.S)61_ICON_RE = re.compile(r'<i class="icon-realestate-(bed|bathtub|plan|calendar)">'62 r'</i>\s*([^<]+)')63_REMARK_RE = re.compile(r'<h4>Remarque du courtier</h4>\s*<p[^>]*>(.*?)</p>', re.S)64_ADDENDUM_RE = re.compile(r'<h4>Addendum</h4>\s*<p[^>]*>(.*?)</p>', re.S)65_INCL_RE = re.compile(r'<h4>INCLUSION</h4>\s*<p[^>]*>(.*?)</p>', re.S)66_EXCL_RE = re.compile(r'<h4>EXCLUSION</h4>\s*<p[^>]*>(.*?)</p>', re.S)67_TABLE_RE = re.compile(r'<table class="table">(.*?)</table>', re.S)68_SECTION_RE = re.compile(r'>\s*(BÂTIMENT|ÉVALUATION|DÉPENSES|CARACTÉRISTIQUES)\b')69_PAIR_RE = re.compile(r'<th class="prop-table"[^>]*>\s*(?:<[^>]+>\s*)*([^<]+?)\s*</th>'70 r'\s*<td[^>]*>\s*([^<]*?)\s*</td>', re.S)71_ROOM_RE = re.compile(r'<td data-title="Chambre">([^<]*)</td>\s*'72 r'<td data-title="Dimensions">([^<]*)</td>\s*'73 r'<td data-title="Niveau">([^<]*)</td>\s*'74 r'<td data-title="Planchers">([^<]*)</td>', re.S)75_SV_RE = re.compile(r'streetview\?key=[^&"\']*&location=(-?\d{1,2}\.\d+),(-?\d{2,3}\.\d+)')76_AGENT_RE = re.compile(r'agent-images/[^"]+"\s+alt="([^"]+)"')77_TEL_RE = re.compile(r'href="tel:(\d{7,15})"')78_SMS_RE = re.compile(r'data-phone="\+?1?(\d{10})"')79_OCCUP_RE = re.compile(r"Date d'occupation\s*:\s*([\d-]{8,10})")80_OPEN_RE = re.compile(r'visite libre<br>\s*(.*?)</h4>', re.S | re.I)81_TOUR_RE = re.compile(r'https?://(?:my\.)?(?:matterport\.com|youtu\.be|'82 r'vimeo\.com)/[^"\'<> ]+', re.I)83_AREA_RE = re.compile(r'([\d,]+(?:\.\d+)?)\s*(MC|PC)\b', re.I)84_POSTAL_RE = re.compile(r'^[A-Z]\d[A-Z]\s*\d[A-Z]\d$')8586# lignes du texte-source repliées en dur à ~60 colonnes : on recolle les87# retours qui coupent une phrase (pas de ponctuation finale, suite en minuscule)88_WRAP_RE = re.compile(r'(?<![.!?:])\n(?=[a-zà-ÿœ])')899091def _parse_us_price(label: str):92 label = (label or "").strip()93 if not label:94 return None, ""95 price = parse_price(label.replace("$", "").replace(",", "")) if label.startswith("$") \96 else parse_price(label)97 return price, label9899100def _txt(block: str) -> str:101 """Bloc HTML -> texte propre (les <br> deviennent des sauts de ligne,102 les retours en dur du flux Centris sont recollés)."""103 block = re.sub(r'<br\s*/?>', '\n@@BR@@\n', block, flags=re.I)104 block = re.sub(r'<[^>]+>', ' ', block)105 block = _html.unescape(block).replace('\r', '')106 block = re.sub(r'[ \t]+', ' ', block)107 block = re.sub(r' ?\n ?', '\n', block).strip()108 block = _WRAP_RE.sub(' ', block) # déplie les phrases coupées109 block = block.replace('\n@@BR@@\n', '\n').replace('@@BR@@', '\n')110 return re.sub(r'\n{3,}', '\n\n', block).strip()111112113def _area_sqft(val: str) -> float | None:114 """« 91.4 MC » (m²) / « 2378.82 PC » (pi²) -> pi²."""115 m = _AREA_RE.search(val or "")116 if not m:117 return None118 try:119 v = float(m.group(1).replace(",", ""))120 except ValueError:121 return None122 if v <= 0:123 return None124 return round(v * 10.7639) if m.group(2).upper() == "MC" else round(v)125126127def _fmt_area(val: str) -> str:128 """« 91.4 MC » -> « 91.4 m² » ; « 2378.82 PC » -> « 2378.82 pi² »."""129 m = _AREA_RE.search(val or "")130 if not m:131 return (val or "").strip()132 unit = "m²" if m.group(2).upper() == "MC" else "pi²"133 return f"{m.group(1)} {unit}"134135136def _parse_exp_detail(html: str) -> dict:137 """Fiche détail expquebec.com (server-rendered) -> payload riche."""138 out: dict = {}139 details: dict = {}140 features: list[str] = []141142 # -- galerie complète, pleine résolution, ordre d'origine (dédoublonnée :143 # le carrousel duplique les ancres pour le loop)144 images, seen = [], set()145 for u in _GALLERY_RE.findall(html):146 u = _html.unescape(u).strip()147 if u and u not in seen:148 seen.add(u)149 images.append(u)150 if images:151 out["images"] = images152153 # -- en-tête h2 : adresse <br/> Ville (secteur), Arrondissement X, CP154 m = _H2_RE.search(html)155 if m:156 head = _html.unescape(re.sub(r'<[^>]+>', '\n', m.group(1)))157 lines = [ln.strip() for ln in head.split('\n') if ln.strip()]158 if lines:159 out["address"] = lines[0]160 rest = " ".join(lines[1:])161 parts = [p.strip() for p in rest.split(",") if p.strip()]162 if parts:163 cm = re.match(r'^(.*?)\s*\((.+)\)\s*$', parts[0])164 if cm:165 out["city"], out["sector"] = cm.group(1).strip(), cm.group(2).strip()166 else:167 out["city"] = parts[0]168 for p in parts[1:]:169 if _POSTAL_RE.match(p):170 details["Code postal"] = p171 elif p.startswith("Arrondissement ") and not out.get("sector"):172 arr = p[len("Arrondissement "):].strip()173 if arr and arr != "Ville" and not arr.startswith("Noms de rues"):174 out["sector"] = arr175176 # -- icônes : chambres / salles de bains / superficie / année177 for icon, val in _ICON_RE.findall(html):178 val = val.strip()179 if not val:180 continue181 if icon == "bed" and val.isdigit():182 out["bedrooms"] = int(val)183 elif icon == "bathtub" and val.isdigit():184 out["bathrooms"] = int(val)185 elif icon == "plan":186 sqft = _area_sqft(val)187 if sqft:188 out["area_sqft"] = sqft189 details["Superficie habitable"] = _fmt_area(val)190 elif icon == "calendar" and re.fullmatch(r'(1[6-9]|20)\d{2}', val):191 out["year_built"] = int(val)192193 # -- description : l'addendum (long) bat la remarque du courtier194 remark = _REMARK_RE.search(html)195 addendum = _ADDENDUM_RE.search(html)196 texts = [_txt(x.group(1)) for x in (addendum, remark) if x]197 if texts:198 out["description"] = max(texts, key=len)199 for rx, key in ((_INCL_RE, "Inclusions"), (_EXCL_RE, "Exclusions")):200 m = rx.search(html)201 if m:202 v = _txt(m.group(1))203 if v:204 details[key] = v205206 # -- tables th/td par section (dupliquées mobile/desktop -> dict dédoublonne)207 for tbl in _TABLE_RE.findall(html):208 sec = _SECTION_RE.search(tbl)209 if not sec:210 continue211 section = sec.group(1)212 for label, val in _PAIR_RE.findall(tbl):213 label = _html.unescape(label).strip().rstrip(":")214 val = re.sub(r'\s+', ' ', _html.unescape(val)).strip()215 if not val or val in ("0", "-"):216 continue217 if section == "BÂTIMENT":218 if label == "Type":219 out["property_type"] = val220 details["Type de propriété"] = val221 elif label == "Style":222 details["Style de bâtiment"] = val223 elif label == "Dimensions":224 details["Dimensions du bâtiment"] = val225 elif label == "Dimension terrain":226 details["Superficie du terrain"] = _fmt_area(val) \227 if _AREA_RE.search(val) else val228 lot = _area_sqft(val)229 if lot:230 out["lot_sqft"] = lot231 else: # Nombre d'étages, Année de construction…232 details[label] = val233 elif section == "ÉVALUATION":234 if label == "Année":235 details["Évaluation municipale (année)"] = val236 elif label in ("Terrain", "Bâtiment", "Total"):237 details[f"Évaluation municipale ({label.lower()})"] = val238 elif section == "DÉPENSES":239 details[re.sub(r'\s*\(\d{4}\)\s*', '', label)] = val240 else: # CARACTÉRISTIQUES241 details[label] = val242 if label in ("Particularités", "Équipement disponible"):243 features.extend(f.strip() for f in val.split(",") if f.strip())244245 # -- pièces (clé spéciale `pieces` rendue par le frontend) + salles d'eau246 rooms, powder = [], 0247 for nom, dims, niveau, sol in _ROOM_RE.findall(html):248 nom = _html.unescape(nom).strip()249 if not nom:250 continue251 room = {"nom": nom}252 for k, v in (("dimensions", dims), ("niveau", niveau), ("revetement", sol)):253 v = _html.unescape(v).strip()254 if v:255 room[k] = v256 rooms.append(room)257 if nom.lower() == "salle d'eau":258 powder += 1259 if rooms:260 details["pieces"] = rooms261 if powder:262 out["powder_rooms"] = powder263264 # -- GPS exact (embed Street View), courtier, extras265 m = _SV_RE.search(html)266 if m:267 out["lat"], out["lng"] = float(m.group(1)), float(m.group(2))268 m = _AGENT_RE.search(html)269 if m:270 out["broker_name"] = _html.unescape(m.group(1)).strip()271 blk = html[m.end():m.end() + 3000] # bloc latéral du courtier272 tel = _TEL_RE.search(blk) or _SMS_RE.search(blk)273 if tel:274 out["broker_phone"] = tel.group(1)275 m = _OCCUP_RE.search(html)276 if m:277 details["Date d'occupation"] = m.group(1)278 m = _OPEN_RE.search(html)279 if m:280 v = re.sub(r'\s+', ' ', _html.unescape(re.sub(r'<[^>]+>', ' ', m.group(1)))).strip(" |")281 if v:282 details["Visite libre"] = v283 m = _TOUR_RE.search(html)284 if m:285 details["Visite virtuelle"] = m.group(0)286287 if details:288 out["details"] = details289 if features:290 out["features"] = features291 return out292293294class ExpQuebecConnector(BaseConnector):295 source_id = "exp_ag_qc"296 request_delay = 0.4297 max_pages = 200 # garde-fou (9/page) ; stop après 3 pages vides298299 def fetch(self) -> list[PropertyListing]:300 by_id: dict[str, PropertyListing] = {}301 empty = 0302 for page in range(1, self.max_pages + 1):303 url = LISTING if page == 1 else f"{LISTING}?pages={page}"304 try:305 html = self.get(url).text306 except Exception:307 break308 parts = _CARD_SPLIT.split(html)309 # parts = [pre, mls1, blk1, mls2, blk2, …]310 found = 0311 for i in range(1, len(parts) - 1, 2):312 mls, blk = parts[i], parts[i + 1][:1500]313 if mls in by_id:314 continue315 lst = self._card(mls, blk)316 if lst:317 by_id[mls] = lst318 found += 1319 empty = empty + 1 if found == 0 else 0320 if empty >= 3:321 break322 listings = list(by_id.values())323 # fiche détail complète : galerie pleine résolution, description,324 # tables BÂTIMENT/ÉVALUATION/DÉPENSES/CARACTÉRISTIQUES, pièces, GPS,325 # courtier + téléphone. v1 = premier parse complet.326 du.enrich(self, listings, DETAIL_LIMIT, _parse_exp_detail, key="v1")327 # repli ville (fiches au-delà du budget détail) : endpoint SSR léger,328 # cache « city » déjà peuplé pour l'inventaire existant329 self._enrich_cities([l for l in listings if not l.city])330 return listings331332 def _card(self, mls: str, blk: str) -> PropertyListing | None:333 pm = _PRICE_RE.search(blk)334 am = _ADDR_RE.search(blk)335 pic = _PIC_RE.search(blk)336 price, price_label = _parse_us_price(pm.group(1) if pm else "")337 addr = _html.unescape(am.group(1)).strip() if am else ""338 img = _html.unescape(pic.group(1)).strip() if pic else \339 f"https://realestate.marketingwebsites.ca/property-images/{mls}/{mls}-01.jpg"340 return PropertyListing(341 source=self.source_id,342 external_id=mls,343 url=f"{SITE}/fr/properties/mls/{mls}/",344 title=addr or "Propriété à vendre",345 address=addr,346 price=price,347 price_label=price_label,348 mls=mls,349 images=[img] if img else [],350 agency=AGENCY,351 broker_name=AGENCY,352 )353354 def _enrich_cities(self, listings: list[PropertyListing]) -> None:355 """Complète la ville via map-property.php, avec cache BD + plafond.356 La ville est le dernier segment texte de la carte-fiche (après le prix357 et l'adresse).358359 ⚠ detail_cache = UNE ligne par fiche : on lit le payload quelle que360 soit sa clé (« city » historique OU « v1 » du parse détail, qui inclut361 la ville) et on n'écrit une ligne « city » que s'il n'y en a AUCUNE —362 jamais par-dessus un payload détail complet."""363 if CITY_LIMIT <= 0:364 return365 from .. import db366 con = db.connect()367 budget = CITY_LIMIT368 try:369 for lst in listings:370 cached = db.get_stale_detail(con, self.source_id, lst.external_id)371 if cached is None:372 if budget <= 0:373 continue374 fetched = self._fetch_city(lst.external_id)375 db.put_cached_detail(con, self.source_id, lst.external_id,376 "city", fetched)377 budget -= 1378 cached = fetched379 if cached.get("city") and not lst.city:380 lst.city = cached["city"]381 finally:382 con.close()383384 def _fetch_city(self, mls: str) -> dict:385 try:386 html = self.get(f"{MAP_PROP}?mls={mls}&lang=fr").text387 except Exception:388 return {}389 # segments texte : retirer prix ($), adresse (déjà connue) → dernier = ville390 segs = [s.strip() for s in re.split(r'<[^>]+>', html) if s.strip()]391 segs = [_html.unescape(s) for s in segs if "$" not in s and "-->" not in s]392 # la ville est le dernier segment alphabétique non numérique393 for s in reversed(segs):394 if re.search(r'[A-Za-zÀ-ÿ]{3,}', s) and not re.match(r'^\d', s):395 # éviter de reprendre l'adresse (commence souvent par un n° civique)396 return {"city": s}397 return {}398