# -----------------------------------------------------------------------------
# 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 {}