# -----------------------------------------------------------------------------
# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
# connectors/remax_quebec.py : RE/MAX Québec (remax-quebec.com)
# Le site interroge un index Meilisearch public (search-only key exposée dans
# la config de la page). L'index « inscriptions » couvre toute la province.
# Meilisearch plafonne à 1000 hits par requête (maxTotalHits) : on shard donc
# par région de tri d'acheminement postale (FSA, 3 premiers caractères) et on
# dédoublonne par numéro d'inscription. Couverture validée vs le total global.
# -----------------------------------------------------------------------------
from __future__ import annotations
import html as _html
import os
import re
from .base import BaseConnector
from ..normalize import parse_area_sqft, parse_price
from ..schema import PropertyListing
SEARCH_URL = "https://search.remax-quebec.com/indexes/inscriptions/search"
# Clé de recherche (search-only) exposée publiquement dans la config du site.
SEARCH_KEY = "b0b93998ab78573e8b937b528ad37d2ce3fbc97e07a9f2c909c4220db910c152"
SITE = "https://www.remax-quebec.com"
# Territoires postaux du Québec : préfixes G, H et J.
FSA_LETTERS = ("G", "H", "J")
SOLD_TOKENS = ("vendu", "sold", "loué", "loue", "rented")
# Enrichissement page détail (photos + specs) : plafonné par exécution pour
# garder chaque sync borné. Les fiches déjà en cache sont réutilisées gratis ;
# seules les nouvelles/modifiées consomment le budget. Sur plusieurs cycles,
# tout le parc finit enrichi. Surchargeable via IMMOKA_REMAX_DETAIL_LIMIT.
DETAIL_LIMIT = int(os.environ.get("IMMOKA_REMAX_DETAIL_LIMIT", "3000"))
class RemaxQuebecConnector(BaseConnector):
source_id = "remax_quebec"
request_delay = 0.15 # API JSON rapide ; on reste poli
use_detail_cache = True # cache BD des pages détail (photos/specs)
def fetch(self) -> list[PropertyListing]:
by_id: dict[int, dict] = {}
for fsa in self._fsa_candidates():
hits = self._search(fsa)
for h in hits:
nid = h.get("no_inscription")
if nid is not None:
by_id[nid] = h # dédoublonnage inter-shards
listings = []
for h in by_id.values():
lst = self._to_listing(h)
if lst is not None:
listings.append(lst)
self._enrich(listings)
return listings
# -- enrichissement (photos + specs via la page détail) -------------------
def _enrich(self, listings: list[PropertyListing]) -> None:
from .. import db
con = db.connect()
budget = DETAIL_LIMIT
for lst in listings:
key = f"v3|{lst.price_label or '-'}" # v3 = + agence/bureau ; refetch si prix change
cached = db.get_cached_detail(con, self.source_id, lst.external_id, key)
if cached is None:
if budget <= 0:
continue # enrichi à un prochain cycle
cached = self._scrape_detail(lst.url)
db.put_cached_detail(con, self.source_id, lst.external_id, key, cached)
budget -= 1
_apply_detail(lst, cached)
con.close()
def _scrape_detail(self, url: str) -> dict:
try:
html = self.get(url).text
except Exception:
return {}
return parse_remax_detail(html)
# -- sharding --------------------------------------------------------------
@staticmethod
def _fsa_candidates():
for letter in FSA_LETTERS:
for digit in "0123456789":
for last in "ABCDEFGHIJKLMNOPQRSTUVWXYZ":
yield f"{letter}{digit}{last}"
def _search(self, q: str) -> list[dict]:
try:
resp = self.post(
SEARCH_URL,
headers={"Authorization": f"Bearer {SEARCH_KEY}",
"Content-Type": "application/json"},
json={"q": q, "limit": 1000},
)
except Exception:
return []
data = resp.json()
return data.get("hits", []) if isinstance(data, dict) else []
# -- mapping ---------------------------------------------------------------
def _to_listing(self, h: dict) -> PropertyListing | None:
nid = h.get("no_inscription")
if nid is None:
return None
price_label = (h.get("display_price") or {}).get("fr", "") or ""
# exclure les propriétés vendues / retirées (l'index les conserve)
if any(tok in price_label.lower() for tok in SOLD_TOKENS):
return None
slug = (h.get("slug") or {}).get("fr", "") or ""
url = f"{SITE}/fr/proprietes/{slug}" if slug else f"{SITE}/fr"
full_addr = (h.get("full_address") or {}).get("fr", "") or ""
address, sector, city = _split_address(full_addr)
prop_type, region = _from_slug(slug)
return PropertyListing(
source=self.source_id,
external_id=str(nid),
url=url,
title=full_addr,
address=address,
sector=sector,
city=city,
region=region,
property_type=prop_type,
price=parse_price(price_label),
price_label=price_label,
broker_name="RE/MAX Québec",
)
# ---------------------------------------------------------------------------
# Analyse de l'adresse et du slug
# ---------------------------------------------------------------------------
_PAREN_RE = re.compile(r"\(([^)]*)\)")
_POSTAL_RE = re.compile(r"[GHJ]\d[A-Z]\s?\d[A-Z]\d", re.I)
def _split_address(full: str) -> tuple[str, str, str]:
"""« 16146 Rue Forsyth, Montréal (Rivière-des-Prairies) (X), H1A5S9 »
-> (adresse, secteur, ville)."""
if not full:
return "", "", ""
parts = [p.strip() for p in full.split(",")]
# retirer le code postal final
if parts and _POSTAL_RE.search(parts[-1]):
parts = parts[:-1]
address = parts[0] if parts else ""
city = sector = ""
if len(parts) >= 2:
muni = parts[1]
parens = _PAREN_RE.findall(muni)
city = _PAREN_RE.sub("", muni).strip()
if parens:
sector = parens[-1].strip()
# secteur supplémentaire dans les champs suivants (avant le postal)
for extra in parts[2:]:
e = _PAREN_RE.sub("", extra).strip() or extra.strip()
if e and not sector:
sector = e
return address, sector, city
_TYPE_SLUG = {
"maison": "Maison", "house": "Maison",
"condo": "Condo", "appartement": "Condo", "apartment": "Condo",
"plex": "Multiplex", "duplex": "Duplex", "triplex": "Triplex",
"terrain": "Terrain", "land": "Terrain",
"chalet": "Chalet", "cottage": "Chalet",
"fermette": "Fermette/Agricole", "ferme": "Fermette/Agricole",
"commercial": "Commercial", "commerciale": "Commercial",
}
# ---------------------------------------------------------------------------
# Page détail : photos + description + caractéristiques
# ---------------------------------------------------------------------------
_LD_RE = re.compile(r'', re.S | re.I)
# Les photos apparaissent dans plusieurs buckets de taille (www_full, _medium,
# _small…) selon le lazy-load ; on les capte toutes et on les normalise en
# pleine résolution, dédoublonnées par nom de fichier.
_IMG_RE = re.compile(
r'https://media\.remax-quebec\.com/img/www_[a-z]+/[^"\'\\ ]+\.(?:jpg|jpeg|png|webp)',
re.I)
_IMG_SIZE_RE = re.compile(r'/www_[a-z]+/', re.I)
# Dans la section caractéristiques, la valeur précède le libellé :
# « 2 (1 + 1) | Chambres », « 1 | Salle de bain », « 1975 | Année de construction »
_SPEC_RE = {
"bedrooms": re.compile(r'(\d+)(?:\s*\([^)]*\))?\s*\|\s*Chambre', re.I),
"bathrooms": re.compile(r'(\d+)\s*\|\s*Salle de bain', re.I),
"powder_rooms": re.compile(r"(\d+)\s*\|\s*Salle d'eau", re.I),
"year_built": re.compile(r'(\d{4})\s*\|\s*Ann[ée]+e de construction', re.I),
}
_AREA_RE = re.compile(r'([\d ,]+)\s*(m²|mc|pi²|pi|ft)\s*\|\s*Superficie habitable', re.I)
_LOT_RE = re.compile(r'([\d ,]+)\s*(m²|mc|pi²|pi|ft)\s*\|\s*Superficie du terrain', re.I)
_COORD_RE = re.compile(r'query=(-?\d+\.\d+)%2C\+?(-?\d+\.\d+)')
_ROOM_RE = re.compile(r'rooms-details-section__vertical-table[^>]*>(.*?)\s*', re.S)
_QSTAT_RE = re.compile(
r'quick-stat-text[^>]*>\s*]*>\s*(.*?)\s*\s*]*>\s*(.*?)\s*', re.S)
_INCL_RE = re.compile(r'inclusions-exclusions-section(.*?)(?:|realtor-section|financial-section)', re.S)
_PHONE_RE = re.compile(r'\d{3}[\s ]\d{3}-\d{4}')
# Ensemble de libellés Centris standard capturés dans le tableau « valeur | libellé ».
_DETAIL_LABELS = [
"Type de propriété", "Genre de propriété", "Style de bâtiment",
"Année de construction", "Nombre d'unités", "Superficie du bâtiment (au sol)",
"Superficie du terrain", "Superficie habitable", "Stationnement (total)",
"Système de chauffage", "Énergie pour le chauffage", "Fenêtres", "Type de fenestration",
"Toiture", "Revêtement", "Sous-sol", "Piscine", "Garage", "Zonage",
"Système d'égouts", "Approvisionnement en eau", "Déménagement",
"Taxes municipales", "Taxes scolaires", "Évaluation municipale (terrain)",
"Évaluation municipale (bâtiment)", "Cuisine", "Salle de bain / Salle d'eau",
]
def _flatten(html: str) -> str:
t = _html.unescape(re.sub(r'<[^>]+>', ' | ', html))
t = re.sub(r'[ \t\r\n]*\|[ \t\r\n|]*', ' | ', t)
return re.sub(r'[ \t]+', ' ', t)
def parse_remax_detail(html: str) -> dict:
"""Extrait TOUT le contenu d'une page détail RE/MAX : photos, description,
coordonnées, caractéristiques complètes, pièces (dimensions), inclusions,
taxes/évaluation, courtier. Objectif : rien perdre par rapport à la fiche source."""
import json
out: dict = {}
details: dict = {}
# description via JSON-LD RealEstateListing
for block in _LD_RE.findall(html):
try:
data = json.loads(block)
except ValueError:
continue
for node in (data if isinstance(data, list) else [data]):
if isinstance(node, dict) and node.get("@type") == "RealEstateListing" and node.get("description"):
out["description"] = _html.unescape(node["description"]).strip()
# photos : toutes tailles → pleine résolution, dédoublonnées par nom de fichier
seen, images = set(), []
for u in _IMG_RE.findall(html):
full = _IMG_SIZE_RE.sub("/www_full/", u)
fn = full.rsplit("/", 1)[-1]
if fn not in seen and "nophoto" not in full:
seen.add(fn)
images.append(full)
if images:
out["images"] = images
# coordonnées GPS (lien Google Maps)
m = _COORD_RE.search(html)
if m:
out["lat"], out["lng"] = float(m.group(1)), float(m.group(2))
# courtier + téléphone (le nom précède « Courtier immobilier »)
mb = re.search(r'>\s*([A-ZÀ-Ÿ][A-Za-zÀ-ÿ .\'-]{4,45})\s*[^<]*'
r'(?:<[^>]+>\s*)*Courtier immobilier', html)
if mb:
name = _html.unescape(re.sub(r'\s+', ' ', mb.group(1))).strip()
if name:
out["broker_name"] = name
mp = _PHONE_RE.search(html)
if mp:
out["broker_phone"] = mp.group(0)
# agence / bureau (« RE/MAX ALLIANCE INC. ») — pour les Sources par sous-agence
ma = re.search(r'RE/MAX[^<,|"\n]{1,40}\bINC\.?', html)
if ma:
out["agency"] = _html.unescape(re.sub(r'\s+', ' ', ma.group(0))).strip().title() \
.replace("Re/Max", "RE/MAX").replace("Inc.", "inc.")
text = _flatten(html)
# champs numériques principaux
for field, rx in _SPEC_RE.items():
m = rx.search(text)
if m:
out[field] = int(m.group(1))
m = _AREA_RE.search(text)
if m:
out["area_sqft"] = parse_area_sqft(f"{m.group(1)} {m.group(2)}")
m = _LOT_RE.search(text)
if m:
out["lot_sqft"] = parse_area_sqft(f"{m.group(1)} {m.group(2)}")
# tableau de caractéristiques complet (valeur | libellé) → details
for label in _DETAIL_LABELS:
m = re.search(r'([^|]{1,60})\s*\|\s*' + re.escape(label) + r'\b', text)
if m:
val = m.group(1).strip(" |")
if val and 1 <= len(val) <= 60 and val.lower() != label.lower():
details[label] = val
# pièces avec dimensions/niveau/revêtement
rooms = []
for blk in _ROOM_RE.findall(html):
rt = _html.unescape(re.sub(r'<[^>]+>', ' ', blk))
rt = re.sub(r'\s+', ' ', rt).strip()
name = re.split(r'Niveau\s*:', rt)[0].strip()
niveau = (re.search(r'Niveau\s*:\s*([^:]+?)(?:Dimensions|Revêtement|$)', rt) or [None, ""])
dim = re.search(r'Dimensions\s*:\s*([0-9\'".,X x×]+)', rt)
rev = re.search(r'Revêtement\s*:\s*([A-Za-zÀ-ÿ ,-]+?)(?:\s*Détails|\s*$)', rt)
if name:
rooms.append({
"nom": name[:40],
"niveau": (niveau[1].strip() if niveau else ""),
"dimensions": (dim.group(1).strip() if dim else ""),
"revetement": (rev.group(1).strip() if rev else ""),
})
if rooms:
details["pieces"] = rooms[:20]
# quick stats (Nb de pièces, superficie…)
for label, val in _QSTAT_RE.findall(html):
label = _html.unescape(re.sub(r'\s+', ' ', label)).strip()
val = _html.unescape(re.sub(r'\s+', ' ', val)).strip()
if label and val and label not in details:
details[label] = val
# inclusions / exclusions → features
mi = _INCL_RE.search(html)
feats = []
if mi:
blk = _html.unescape(re.sub(r'<[^>]+>', '\n', mi.group(1)))
for line in blk.split('\n'):
line = line.strip(" \t•-")
if 3 <= len(line) <= 120 and not line.lower().startswith(("inclusion", "exclusion")):
feats.append(line)
if feats:
out["features"] = feats[:25]
# superficie habitable depuis details si pas déjà captée
if out.get("area_sqft") is None and details.get("Superficie habitable"):
out["area_sqft"] = parse_area_sqft(details["Superficie habitable"])
if out.get("lot_sqft") is None and details.get("Superficie du terrain"):
out["lot_sqft"] = parse_area_sqft(details["Superficie du terrain"])
if details:
out["details"] = details
return out
def _apply_detail(lst: PropertyListing, d: dict) -> None:
if not d:
return
if d.get("images"):
lst.images = d["images"]
if d.get("features"):
lst.features = d["features"]
if d.get("details"):
lst.details.update(d["details"])
# le nom réel du courtier (page détail) remplace le placeholder « RE/MAX Québec »
if d.get("broker_name"):
lst.broker_name = d["broker_name"]
# agence/bureau -> sous-agence pour les Sources
if d.get("agency"):
lst.agency = d["agency"]
for f in ("description", "bedrooms", "bathrooms", "powder_rooms",
"year_built", "area_sqft", "lot_sqft", "lat", "lng",
"broker_phone"):
if d.get(f) is not None and getattr(lst, f, None) in (None, "", 0):
setattr(lst, f, d[f])
def _from_slug(slug: str) -> tuple[str, str]:
"""slug « maison-a-vendre-laurentides/1744-rue-carmen-val-david-9004907 »
-> (type de propriété, région)."""
if not slug:
return "", ""
head = slug.split("/", 1)[0] # maison-a-vendre-laurentides
prop_type = ""
for key, canon in _TYPE_SLUG.items():
if re.search(rf"\b{key}", head):
prop_type = canon
break
# région = ce qui suit « -a-vendre-/-for-sale- »
m = re.search(r"(?:a-vendre|for-sale)-(.+)$", head)
region = m.group(1).replace("-", " ").title() if m else ""
return prop_type, region