# ----------------------------------------------------------------------------- # Immo-Ka — Agrégateur de maisons à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/exp_quebec.py : eXp Realty Québec (expquebec.com) — toute la province. # # L'API cliente realestate.marketingwebsites.ca/api.php est verrouillée par # whitelist IP (impasse, même via proxy). MAIS le serveur WordPress (autorisé # sur l'API) rend l'inventaire COMPLET côté serveur sur # /fr/properties/?pages={N} (9 fiches/page, param « pages » uniquement). # Chaque carte porte data-href /fr/properties/mls/{MLS}, data-price ($US), # data-address (rue) et data-pic (property-images/{MLS}/). Le n° MLS = n° Centris. # # FICHE DÉTAIL /fr/properties/mls/{MLS}/ : server-rendered elle aussi # (requêtes directes, aucun anti-bot) et TRÈS riche — galerie complète en # pleine résolution (~1728px, ancres data-lightbox="gallery-item"), remarque # du courtier + addendum, inclusions/exclusions, icônes (chambres/sdb/ # superficie MC|PC/année), tables th/td par section (BÂTIMENT, ÉVALUATION, # DÉPENSES, CARACTÉRISTIQUES, DÉTAILS DE PIÈCE), GPS exact dans l'embed # Street View, courtier inscripteur (photo alt + tel: du bloc latéral). # Enrichissement via _detailutil.enrich (cache BD + plafond # IMMOKA_EXP_DETAIL_LIMIT, ~400/cycle). # # La VILLE vient de la fiche détail (en-tête h2) ; repli sur l'endpoint SSR # /wp-content/themes/canvas/load/map-property.php?mls={MLS}&lang=fr pour les # fiches au-delà du budget détail (cache « city » déjà peuplé). # # ⚠ PLAFOND : le SSR reflète le limit=1000 de l'API sous-jacente → au plus # ~1000 fiches (pages 1..~114, crawl vérifié). C'est l'inventaire pratique # complet ; si eXp QC dépasse un jour 1000 inscriptions, segmenter par région # (params MW_region / MW_q du formulaire), chaque segment ayant son propre cap. # # source_id « exp_ag_qc » : infixe _ag_ = dédup Centris (db.refresh_dedup) — # les fiches co-listées avec une bannière couverte sont masquées. # ----------------------------------------------------------------------------- from __future__ import annotations import html as _html import os import re from .base import BaseConnector from . import _detailutil as du from ..normalize import parse_price from ..schema import PropertyListing SITE = "https://expquebec.com" LISTING = SITE + "/fr/properties/" MAP_PROP = SITE + "/wp-content/themes/canvas/load/map-property.php" DETAIL_LIMIT = int(os.environ.get("IMMOKA_EXP_DETAIL_LIMIT", "400")) CITY_LIMIT = int(os.environ.get("IMMOKA_EXP_CITY_LIMIT", "1200")) AGENCY = "eXp Agence immobilière" _CARD_SPLIT = re.compile(r'data-href="/fr/properties/mls/(\d{6,9})"') _PRICE_RE = re.compile(r'data-price="([^"]*)"', re.I) _ADDR_RE = re.compile(r'data-address="([^"]*)"', re.I) _PIC_RE = re.compile(r'data-pic="([^"]*)"', re.I) # ---- fiche détail ----------------------------------------------------------- _GALLERY_RE = re.compile(r']*data-lightbox="gallery-item"') _H2_RE = re.compile(r'MLS\s*#\s*\d+
\s*(.*?)', re.S) _ICON_RE = re.compile(r'' r'\s*([^<]+)') _REMARK_RE = re.compile(r'

Remarque du courtier

\s*]*>(.*?)

', re.S) _ADDENDUM_RE = re.compile(r'

Addendum

\s*]*>(.*?)

', re.S) _INCL_RE = re.compile(r'

INCLUSION

\s*]*>(.*?)

', re.S) _EXCL_RE = re.compile(r'

EXCLUSION

\s*]*>(.*?)

', re.S) _TABLE_RE = re.compile(r'(.*?)
', re.S) _SECTION_RE = re.compile(r'>\s*(BÂTIMENT|ÉVALUATION|DÉPENSES|CARACTÉRISTIQUES)\b') _PAIR_RE = re.compile(r']*>\s*(?:<[^>]+>\s*)*([^<]+?)\s*' r'\s*]*>\s*([^<]*?)\s*', re.S) _ROOM_RE = re.compile(r'([^<]*)\s*' r'([^<]*)\s*' r'([^<]*)\s*' r'([^<]*)', re.S) _SV_RE = re.compile(r'streetview\?key=[^&"\']*&location=(-?\d{1,2}\.\d+),(-?\d{2,3}\.\d+)') _AGENT_RE = re.compile(r'agent-images/[^"]+"\s+alt="([^"]+)"') _TEL_RE = re.compile(r'href="tel:(\d{7,15})"') _SMS_RE = re.compile(r'data-phone="\+?1?(\d{10})"') _OCCUP_RE = re.compile(r"Date d'occupation\s*:\s*([\d-]{8,10})") _OPEN_RE = re.compile(r'visite libre
\s*(.*?)', re.S | re.I) _TOUR_RE = re.compile(r'https?://(?:my\.)?(?:matterport\.com|youtu\.be|' r'vimeo\.com)/[^"\'<> ]+', re.I) _AREA_RE = re.compile(r'([\d,]+(?:\.\d+)?)\s*(MC|PC)\b', re.I) _POSTAL_RE = re.compile(r'^[A-Z]\d[A-Z]\s*\d[A-Z]\d$') # lignes du texte-source repliées en dur à ~60 colonnes : on recolle les # retours qui coupent une phrase (pas de ponctuation finale, suite en minuscule) _WRAP_RE = re.compile(r'(? str: """Bloc HTML -> texte propre (les
deviennent des sauts de ligne, les retours en dur du flux Centris sont recollés).""" block = re.sub(r'', '\n@@BR@@\n', block, flags=re.I) block = re.sub(r'<[^>]+>', ' ', block) block = _html.unescape(block).replace('\r', '') block = re.sub(r'[ \t]+', ' ', block) block = re.sub(r' ?\n ?', '\n', block).strip() block = _WRAP_RE.sub(' ', block) # déplie les phrases coupées block = block.replace('\n@@BR@@\n', '\n').replace('@@BR@@', '\n') return re.sub(r'\n{3,}', '\n\n', block).strip() def _area_sqft(val: str) -> float | None: """« 91.4 MC » (m²) / « 2378.82 PC » (pi²) -> pi².""" m = _AREA_RE.search(val or "") if not m: return None try: v = float(m.group(1).replace(",", "")) except ValueError: return None if v <= 0: return None return round(v * 10.7639) if m.group(2).upper() == "MC" else round(v) def _fmt_area(val: str) -> str: """« 91.4 MC » -> « 91.4 m² » ; « 2378.82 PC » -> « 2378.82 pi² ».""" m = _AREA_RE.search(val or "") if not m: return (val or "").strip() unit = "m²" if m.group(2).upper() == "MC" else "pi²" return f"{m.group(1)} {unit}" def _parse_exp_detail(html: str) -> dict: """Fiche détail expquebec.com (server-rendered) -> payload riche.""" out: dict = {} details: dict = {} features: list[str] = [] # -- galerie complète, pleine résolution, ordre d'origine (dédoublonnée : # le carrousel duplique les ancres pour le loop) images, seen = [], set() for u in _GALLERY_RE.findall(html): u = _html.unescape(u).strip() if u and u not in seen: seen.add(u) images.append(u) if images: out["images"] = images # -- en-tête h2 : adresse
Ville (secteur), Arrondissement X, CP m = _H2_RE.search(html) if m: head = _html.unescape(re.sub(r'<[^>]+>', '\n', m.group(1))) lines = [ln.strip() for ln in head.split('\n') if ln.strip()] if lines: out["address"] = lines[0] rest = " ".join(lines[1:]) parts = [p.strip() for p in rest.split(",") if p.strip()] if parts: cm = re.match(r'^(.*?)\s*\((.+)\)\s*$', parts[0]) if cm: out["city"], out["sector"] = cm.group(1).strip(), cm.group(2).strip() else: out["city"] = parts[0] for p in parts[1:]: if _POSTAL_RE.match(p): details["Code postal"] = p elif p.startswith("Arrondissement ") and not out.get("sector"): arr = p[len("Arrondissement "):].strip() if arr and arr != "Ville" and not arr.startswith("Noms de rues"): out["sector"] = arr # -- icônes : chambres / salles de bains / superficie / année for icon, val in _ICON_RE.findall(html): val = val.strip() if not val: continue if icon == "bed" and val.isdigit(): out["bedrooms"] = int(val) elif icon == "bathtub" and val.isdigit(): out["bathrooms"] = int(val) elif icon == "plan": sqft = _area_sqft(val) if sqft: out["area_sqft"] = sqft details["Superficie habitable"] = _fmt_area(val) elif icon == "calendar" and re.fullmatch(r'(1[6-9]|20)\d{2}', val): out["year_built"] = int(val) # -- description : l'addendum (long) bat la remarque du courtier remark = _REMARK_RE.search(html) addendum = _ADDENDUM_RE.search(html) texts = [_txt(x.group(1)) for x in (addendum, remark) if x] if texts: out["description"] = max(texts, key=len) for rx, key in ((_INCL_RE, "Inclusions"), (_EXCL_RE, "Exclusions")): m = rx.search(html) if m: v = _txt(m.group(1)) if v: details[key] = v # -- tables th/td par section (dupliquées mobile/desktop -> dict dédoublonne) for tbl in _TABLE_RE.findall(html): sec = _SECTION_RE.search(tbl) if not sec: continue section = sec.group(1) for label, val in _PAIR_RE.findall(tbl): label = _html.unescape(label).strip().rstrip(":") val = re.sub(r'\s+', ' ', _html.unescape(val)).strip() if not val or val in ("0", "-"): continue if section == "BÂTIMENT": if label == "Type": out["property_type"] = val details["Type de propriété"] = val elif label == "Style": details["Style de bâtiment"] = val elif label == "Dimensions": details["Dimensions du bâtiment"] = val elif label == "Dimension terrain": details["Superficie du terrain"] = _fmt_area(val) \ if _AREA_RE.search(val) else val lot = _area_sqft(val) if lot: out["lot_sqft"] = lot else: # Nombre d'étages, Année de construction… details[label] = val elif section == "ÉVALUATION": if label == "Année": details["Évaluation municipale (année)"] = val elif label in ("Terrain", "Bâtiment", "Total"): details[f"Évaluation municipale ({label.lower()})"] = val elif section == "DÉPENSES": details[re.sub(r'\s*\(\d{4}\)\s*', '', label)] = val else: # CARACTÉRISTIQUES details[label] = val if label in ("Particularités", "Équipement disponible"): features.extend(f.strip() for f in val.split(",") if f.strip()) # -- pièces (clé spéciale `pieces` rendue par le frontend) + salles d'eau rooms, powder = [], 0 for nom, dims, niveau, sol in _ROOM_RE.findall(html): nom = _html.unescape(nom).strip() if not nom: continue room = {"nom": nom} for k, v in (("dimensions", dims), ("niveau", niveau), ("revetement", sol)): v = _html.unescape(v).strip() if v: room[k] = v rooms.append(room) if nom.lower() == "salle d'eau": powder += 1 if rooms: details["pieces"] = rooms if powder: out["powder_rooms"] = powder # -- GPS exact (embed Street View), courtier, extras m = _SV_RE.search(html) if m: out["lat"], out["lng"] = float(m.group(1)), float(m.group(2)) m = _AGENT_RE.search(html) if m: out["broker_name"] = _html.unescape(m.group(1)).strip() blk = html[m.end():m.end() + 3000] # bloc latéral du courtier tel = _TEL_RE.search(blk) or _SMS_RE.search(blk) if tel: out["broker_phone"] = tel.group(1) m = _OCCUP_RE.search(html) if m: details["Date d'occupation"] = m.group(1) m = _OPEN_RE.search(html) if m: v = re.sub(r'\s+', ' ', _html.unescape(re.sub(r'<[^>]+>', ' ', m.group(1)))).strip(" |") if v: details["Visite libre"] = v m = _TOUR_RE.search(html) if m: details["Visite virtuelle"] = m.group(0) if details: out["details"] = details if features: out["features"] = features return out class ExpQuebecConnector(BaseConnector): source_id = "exp_ag_qc" request_delay = 0.4 max_pages = 200 # garde-fou (9/page) ; stop après 3 pages vides def fetch(self) -> list[PropertyListing]: by_id: dict[str, PropertyListing] = {} empty = 0 for page in range(1, self.max_pages + 1): url = LISTING if page == 1 else f"{LISTING}?pages={page}" try: html = self.get(url).text except Exception: break parts = _CARD_SPLIT.split(html) # parts = [pre, mls1, blk1, mls2, blk2, …] found = 0 for i in range(1, len(parts) - 1, 2): mls, blk = parts[i], parts[i + 1][:1500] if mls in by_id: continue lst = self._card(mls, blk) if lst: by_id[mls] = lst found += 1 empty = empty + 1 if found == 0 else 0 if empty >= 3: break listings = list(by_id.values()) # fiche détail complète : galerie pleine résolution, description, # tables BÂTIMENT/ÉVALUATION/DÉPENSES/CARACTÉRISTIQUES, pièces, GPS, # courtier + téléphone. v1 = premier parse complet. du.enrich(self, listings, DETAIL_LIMIT, _parse_exp_detail, key="v1") # repli ville (fiches au-delà du budget détail) : endpoint SSR léger, # cache « city » déjà peuplé pour l'inventaire existant self._enrich_cities([l for l in listings if not l.city]) return listings def _card(self, mls: str, blk: str) -> PropertyListing | None: pm = _PRICE_RE.search(blk) am = _ADDR_RE.search(blk) pic = _PIC_RE.search(blk) price, price_label = _parse_us_price(pm.group(1) if pm else "") addr = _html.unescape(am.group(1)).strip() if am else "" img = _html.unescape(pic.group(1)).strip() if pic else \ f"https://realestate.marketingwebsites.ca/property-images/{mls}/{mls}-01.jpg" return PropertyListing( source=self.source_id, external_id=mls, url=f"{SITE}/fr/properties/mls/{mls}/", title=addr or "Propriété à vendre", address=addr, price=price, price_label=price_label, mls=mls, images=[img] if img else [], agency=AGENCY, broker_name=AGENCY, ) def _enrich_cities(self, listings: list[PropertyListing]) -> None: """Complète la ville via map-property.php, avec cache BD + plafond. La ville est le dernier segment texte de la carte-fiche (après le prix et l'adresse). ⚠ detail_cache = UNE ligne par fiche : on lit le payload quelle que soit sa clé (« city » historique OU « v1 » du parse détail, qui inclut la ville) et on n'écrit une ligne « city » que s'il n'y en a AUCUNE — jamais par-dessus un payload détail complet.""" if CITY_LIMIT <= 0: return from .. import db con = db.connect() budget = CITY_LIMIT try: for lst in listings: cached = db.get_stale_detail(con, self.source_id, lst.external_id) if cached is None: if budget <= 0: continue fetched = self._fetch_city(lst.external_id) db.put_cached_detail(con, self.source_id, lst.external_id, "city", fetched) budget -= 1 cached = fetched if cached.get("city") and not lst.city: lst.city = cached["city"] finally: con.close() def _fetch_city(self, mls: str) -> dict: try: html = self.get(f"{MAP_PROP}?mls={mls}&lang=fr").text except Exception: return {} # segments texte : retirer prix ($), adresse (déjà connue) → dernier = ville segs = [s.strip() for s in re.split(r'<[^>]+>', html) if s.strip()] segs = [_html.unescape(s) for s in segs if "$" not in s and "-->" not in s] # la ville est le dernier segment alphabétique non numérique for s in reversed(segs): if re.search(r'[A-Za-zÀ-ÿ]{3,}', s) and not re.match(r'^\d', s): # éviter de reprendre l'adresse (commence souvent par un n° civique) return {"city": s} return {}