Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/remax_quebec.py : RE/MAX Québec (remax-quebec.com) — LOCATIONS5# Le site interroge un index Meilisearch public (search-only key exposée dans6# la config de la page). L'index « inscriptions » couvre toute la province et7# mélange ventes et locations : on ne garde QUE les slugs « -a-louer » /8# « -for-rent » (et on écarte les Loué/Vendu que l'index conserve).9# Meilisearch plafonne à 1000 hits par requête (maxTotalHits) : on shard donc10# par région de tri d'acheminement postale (FSA, 3 premiers caractères) et on11# dédoublonne par numéro d'inscription. Adapté du connecteur « à vendre »12# d'Immo-Ka (agent-courtage/immoka).13# -----------------------------------------------------------------------------14from __future__ import annotations1516import html as _html17import os18import re1920from bs4 import BeautifulSoup2122from ..normalize import parse_area_sqft, parse_price23from ..schema import Listing24from .base import BaseConnector2526from . import _detailutil as du2728SEARCH_URL = "https://search.remax-quebec.com/indexes/inscriptions/search"29# Clé de recherche (search-only) exposée publiquement dans la config du site.30SEARCH_KEY = "b0b93998ab78573e8b937b528ad37d2ce3fbc97e07a9f2c909c4220db910c152"31SITE = "https://www.remax-quebec.com"3233# Territoires postaux du Québec : préfixes G, H et J.34FSA_LETTERS = ("G", "H", "J")35# annonces conclues que l'index conserve encore quelque temps36GONE_TOKENS = ("loué", "loue", "rented", "vendu", "sold")3738# Enrichissement page détail (photos + description + specs) : plafonné par39# exécution pour garder chaque sync borné. Les fiches en cache sont réutilisées40# gratis ; le parc se complète sur plusieurs cycles.41DETAIL_LIMIT = int(os.environ.get("LOUKA_REMAX_DETAIL_LIMIT", "400"))42# Réparation ciblée : re-visite des fiches déjà en cache SANS description43# (payload d'avant le repli « Addenda », ou échec réseau passé) — le44# marqueur desc_checked évite de re-visiter les fiches dont l'absence de45# description est confirmée à la source.46REPAIR_LIMIT = int(os.environ.get("LOUKA_REMAX_REPAIR_LIMIT", "400"))4748# slug « condo-a-louer-montreal/... » -> type affichable. Les appartements /49# condos / plex restent sans unit_type figé : la fiche détail fournit le nombre50# de chambres (converti en n+2½ par la normalisation lou-ka).51_TYPE_SLUG = {52 "maison": "Maison", "house": "Maison",53 "chalet": "Chalet", "cottage": "Chalet",54 "loft": "Loft",55 "condo": "Condo", "appartement": "Appartement", "apartment": "Appartement",56 "plex": "Multiplex", "duplex": "Duplex", "triplex": "Triplex",57}58# ce qui n'est pas un logement (lou-ka = résidentiel locatif uniquement)59_EXCLUDE_SLUG = ("commercial", "commerciale", "terrain", "land", "industriel",60 "bureau", "ferme", "fermette")61_RENT_SLUG_RE = re.compile(r"-(?:a-louer|for-rent)\b")626364class RemaxQuebecConnector(BaseConnector):65 source_id = "remax_quebec"66 request_delay = 0.4 # partagé API de recherche / pages détail6768 def fetch(self) -> list[Listing]:69 by_id: dict[int, dict] = {}70 for fsa in self._fsa_candidates():71 for h in self._search(fsa):72 nid = h.get("no_inscription")73 if nid is not None:74 by_id[nid] = h # dédoublonnage inter-shards75 listings = []76 for h in by_id.values():77 lst = self._to_listing(h)78 if lst is not None:79 listings.append(lst)80 du.enrich(self, listings, DETAIL_LIMIT, parse_remax_detail, key="v1")81 self._repair_missing_desc(listings)82 for lst in listings:83 _unit_from_bedrooms(lst)84 return listings8586 def _repair_missing_desc(self, listings: list[Listing]) -> None:87 """Re-visite les fiches en cache restées sans description : payloads88 d'avant le repli « Addenda » du parseur, ou payloads vides (échec89 réseau passé). Le nouveau parseur pose desc_checked=True, donc une90 fiche réellement sans addenda n'est re-visitée qu'une seule fois.91 Le reste du cache (fiches déjà riches) n'est pas invalidé."""92 from .. import db93 budget = REPAIR_LIMIT94 if budget <= 0:95 return96 con = db.connect()97 try:98 for lst in listings:99 if budget <= 0:100 break101 if lst.description:102 continue103 cached = db.get_cached_detail(con, self.source_id,104 lst.external_id, "v1")105 if cached is None: # jamais visitée : du.enrich s'en charge106 continue107 if cached.get("description") or cached.get("desc_checked"):108 continue109 budget -= 1110 try:111 payload = parse_remax_detail(self.get(lst.url).text)112 except Exception:113 continue # erreur réseau : on réessaiera114 db.put_cached_detail(con, self.source_id, lst.external_id,115 "v1", payload)116 du.apply_detail(lst, payload)117 finally:118 con.close()119120 # -- sharding --------------------------------------------------------------121 @staticmethod122 def _fsa_candidates():123 for letter in FSA_LETTERS:124 for digit in "0123456789":125 for last in "ABCDEFGHIJKLMNOPQRSTUVWXYZ":126 yield f"{letter}{digit}{last}"127128 def _search(self, q: str) -> list[dict]:129 try:130 resp = self.post(131 SEARCH_URL,132 headers={"Authorization": f"Bearer {SEARCH_KEY}",133 "Content-Type": "application/json"},134 json={"q": q, "limit": 1000},135 )136 except Exception:137 return []138 data = resp.json()139 return data.get("hits", []) if isinstance(data, dict) else []140141 # -- mapping ---------------------------------------------------------------142 def _to_listing(self, h: dict) -> Listing | None:143 nid = h.get("no_inscription")144 if nid is None:145 return None146 slug = (h.get("slug") or {}).get("fr", "") or ""147 slug_en = (h.get("slug") or {}).get("en", "") or ""148 # UNIQUEMENT les locations (l'index mélange ventes et locations) ;149 # l'URL de la fiche exige le slug français150 if not slug or not (_RENT_SLUG_RE.search(slug) or _RENT_SLUG_RE.search(slug_en)):151 return None152 price_label = (h.get("display_price") or {}).get("fr", "") or ""153 # exclure les logements déjà loués / retirés (l'index les conserve)154 if any(tok in price_label.lower() for tok in GONE_TOKENS):155 return None156 prop_type, region = _from_slug(slug)157 if prop_type is None: # commercial/terrain : pas un logement158 return None159160 url = f"{SITE}/fr/proprietes/{slug}"161 full_addr = (h.get("full_address") or {}).get("fr", "") or ""162 address, sector, city = _split_address(full_addr)163164 details: dict = {"MLS": str(nid)}165 if prop_type:166 details["Type"] = prop_type167 if region:168 details["Région"] = region169 return Listing(170 source=self.source_id,171 external_id=str(nid),172 url=url,173 title=address or full_addr,174 address=address,175 sector=sector,176 city=city,177 # appartement/condo/plex : la fiche détail donne les chambres178 unit_type=prop_type if prop_type in ("Maison", "Chalet", "Loft") else "",179 price=parse_price(price_label), # « 2 690$ par mois » -> 2690.0180 price_label=price_label,181 details=details,182 )183184185def _unit_from_bedrooms(lst: Listing) -> None:186 """Condo/appartement : « Chambres » de la fiche détail -> unit_type187 « n chambres » (la normalisation lou-ka convertit en n+2½)."""188 if lst.unit_type not in ("", "Condo", "Appartement"):189 return190 m = re.match(r"\d+", str(lst.details.get("Chambres", "")))191 if not m:192 if not lst.unit_type:193 lst.unit_type = lst.details.get("Type", "")194 return195 n = int(m.group(0))196 lst.unit_type = "Studio" if n == 0 else f"{n} chambres"197198199# ---------------------------------------------------------------------------200# Analyse de l'adresse et du slug201# ---------------------------------------------------------------------------202203_PAREN_RE = re.compile(r"\(([^)]*)\)")204_POSTAL_RE = re.compile(r"[GHJ]\d[A-Z]\s?\d[A-Z]\d", re.I)205206207def _split_address(full: str) -> tuple[str, str, str]:208 """« 9561 Boul. Perras, Montréal (Rivière-des-Prairies/…) (RDP), H1E4C4 »209 -> (adresse, secteur, ville)."""210 if not full:211 return "", "", ""212 parts = [p.strip() for p in full.split(",")]213 # retirer le code postal final214 if parts and _POSTAL_RE.search(parts[-1]):215 parts = parts[:-1]216 address = parts[0] if parts else ""217 city = sector = ""218 if len(parts) >= 2:219 muni = parts[1]220 parens = _PAREN_RE.findall(muni)221 city = _PAREN_RE.sub("", muni).strip()222 if parens:223 sector = parens[-1].strip()224 # secteur supplémentaire dans les champs suivants (avant le postal)225 for extra in parts[2:]:226 e = _PAREN_RE.sub("", extra).strip() or extra.strip()227 if e and not sector:228 sector = e229 return address, sector, city230231232def _from_slug(slug: str) -> tuple[str | None, str]:233 """slug « condo-a-louer-montreal/9561-boul-perras-…-20357732 »234 -> (type affichable, région). Type None = pas un logement (commercial…)."""235 if not slug:236 return "", ""237 head = slug.split("/", 1)[0] # condo-a-louer-montreal238 if any(re.search(rf"\b{x}", head) for x in _EXCLUDE_SLUG):239 return None, ""240 prop_type = ""241 for key, canon in _TYPE_SLUG.items():242 if re.search(rf"\b{key}", head):243 prop_type = canon244 break245 # région = ce qui suit « -a-louer-/-for-rent- »246 m = re.search(r"(?:a-louer|for-rent)-(.+)$", head)247 region = m.group(1).replace("-", " ").title() if m else ""248 return prop_type, region249250251# ---------------------------------------------------------------------------252# Page détail : photos + description + caractéristiques + courtier253# ---------------------------------------------------------------------------254255# Les photos apparaissent dans plusieurs buckets de taille (www_full, _medium,256# _small…) selon le lazy-load ; on les capte toutes et on les normalise en257# pleine résolution, dédoublonnées par nom de fichier.258_IMG_RE = re.compile(259 r'https://media\.remax-quebec\.com/img/www_[a-z]+/[^"\'\\ ]+\.(?:jpg|jpeg|png|webp)',260 re.I)261_IMG_SIZE_RE = re.compile(r"/www_[a-z]+/", re.I)262_COORD_RE = re.compile(r"query=(-?\d+\.\d+)%2C\+?(-?\d+\.\d+)")263# en tête de fiche, la valeur précède le libellé : « 2 | Chambres », « 1 | Salle de bain »264_BED_RE = re.compile(r"(\d+)(?:\s*\([^)]*\))?\s*\|\s*Chambres?\b", re.I)265_BATH_RE = re.compile(r"(\d+)\s*\|\s*Salles? de bain", re.I)266_WATER_RE = re.compile(r"(\d+)\s*\|\s*Salles? d'eau", re.I)267_QSTAT_RE = re.compile(268 r"quick-stat-text[^>]*>\s*<span[^>]*>\s*(.*?)\s*</span>\s*<span[^>]*>\s*(.*?)\s*</span>", re.S)269_INCL_RE = re.compile(270 r"inclusions-exclusions-section(.*?)(?:</section>|realtor-section|financial-section)", re.S)271_ROOM_RE = re.compile(r"rooms-details-section__vertical-table[^>]*>(.*?)</div>\s*</div>", re.S)272273# Sur les fiches LOCATION, le tableau « Particularités » est en ordre274# libellé | valeur (l'inverse des fiches vente) : on privilégie donc cet ordre275# et du.centris_details ne sert que de repli.276_RENT_LABELS = [277 "Type de propriété", "Genre de propriété", "Style de bâtiment",278 "Année de construction", "Superficie habitable", "Superficie du terrain",279 "Nombre de pièces", "Nombre d'unités", "Stationnement (total)", "Garage",280 "Mode de chauffage", "Système de chauffage", "Énergie pour le chauffage",281 "Approvisionnement en eau", "Système d'égouts", "Piscine", "Déménagement",282 "Date d'emménagement", "Disponibilité", "Bail", "Durée du bail", "Meublé",283 "Animaux", "Inclus dans le loyer", "Cuisine",284]285286287# une « valeur » qui est en fait un titre de section = extraction décalée288_SECTION_VAL_RE = re.compile(289 r"^(?:Particularit[ée]s|Caract[ée]ristiques|Inclusions|Exclusions|Addenda)",290 re.I)291292293def _labels_first(text: str, labels: list[str]) -> dict:294 """Extraction « libellé | valeur » (ordre des fiches location RE/MAX)."""295 out: dict = {}296 for label in labels:297 boundary = r"\b" if label[-1].isalnum() else ""298 m = re.search(re.escape(label) + boundary + r"\s*\|\s*([^|]{1,55})", text)299 if m:300 val = m.group(1).strip(" |")301 if (val and 1 <= len(val) <= 55 and val.lower() != label.lower()302 and val not in labels and not _SECTION_VAL_RE.match(val)):303 out[label] = val304 return out305306307def _drop_section_values(details: dict) -> None:308 """Purge les valeurs qui sont des titres de section (repli valeur|libellé309 de du.centris_details décalé sur les fiches en ordre libellé|valeur)."""310 for k in [k for k, v in details.items()311 if isinstance(v, str) and _SECTION_VAL_RE.match(v)]:312 del details[k]313314315def _ld_agents(html: str):316 """Nœuds RealEstateAgent (dans l'ordre du document), même imbriqués."""317 for n in du.ld_nodes(html):318 queue = [n]319 while queue:320 cur = queue.pop(0)321 if isinstance(cur, dict):322 if cur.get("@type") == "RealEstateAgent" and cur.get("name"):323 yield cur324 queue.extend(cur.values())325 elif isinstance(cur, list):326 queue.extend(cur)327328329def _addenda_description(html: str) -> str:330 """Repli quand le JSON-LD n'a pas de description : le texte du courtier331 vit alors dans les accordéons « Description » / « Addenda » de la fiche."""332 soup = BeautifulSoup(html, "html.parser")333 parts: list[str] = []334 for cls in ("description-section", "addenda-section"):335 for node in soup.select(f"div.{cls} .accordion__content"):336 txt = _html.unescape(node.get_text("\n", strip=True))337 txt = re.sub(r"[ \t]+", " ", txt)338 txt = re.sub(r"\n{3,}", "\n\n", txt).strip()339 if len(txt) >= 40 and txt not in parts:340 parts.append(txt)341 return "\n\n".join(parts)342343344def parse_remax_detail(html: str) -> dict:345 """Fiche RE/MAX location : description JSON-LD (repli : accordéons346 Description/Addenda), galerie pleine résolution, GPS, caractéristiques347 Centris, pièces, inclusions, courtier/agence."""348 # desc_checked : marqueur de cache — la présence de description a été349 # vérifiée AVEC le repli Addenda (évite les re-visites infinies quand la350 # fiche n'a réellement aucun texte)351 out: dict = {"desc_checked": True}352 details: dict = {}353354 # description via JSON-LD RealEstateListing, sinon accordéon Addenda355 desc = du.ld_description(html) or _addenda_description(html)356 if desc:357 out["description"] = desc[:6000]358359 # photos : toutes tailles -> pleine résolution, dédoublonnées par fichier360 seen, images = set(), []361 for u in _IMG_RE.findall(html):362 full = _IMG_SIZE_RE.sub("/www_full/", u)363 fn = full.rsplit("/", 1)[-1]364 if fn not in seen and "nophoto" not in full:365 seen.add(fn)366 images.append(full)367 if images:368 out["images"] = images369370 # coordonnées GPS (lien Google Maps)371 m = _COORD_RE.search(html)372 if m:373 lat, lng = float(m.group(1)), float(m.group(2))374 if 44.5 <= lat <= 63.0 and -80.0 <= lng <= -56.0:375 out["lat"], out["lng"] = lat, lng376377 # courtier + agence via JSON-LD RealEstateAgent (souvent imbriqué)378 for n in _ld_agents(html):379 details.setdefault("Courtier", str(n["name"]).strip().title())380 if n.get("telephone"):381 details.setdefault("Téléphone", str(n["telephone"]).strip())382 org = n.get("parentOrganization") or {}383 org_name = org.get("name", "") if isinstance(org, dict) else ""384 if org_name:385 agence = str(org_name).strip().title() \386 .replace("Re/Max", "RE/MAX").replace("Inc.", "inc.")387 details.setdefault("Agence", agence)388389 text = du.flatten(html)390391 # chambres / salles de bain (en tête : « 2 | Chambres », « 1 | Salle de bain »)392 m = _BED_RE.search(text)393 if m:394 details["Chambres"] = m.group(1)395 m = _BATH_RE.search(text)396 if m:397 details["Salles de bain"] = m.group(1)398 m = _WATER_RE.search(text)399 if m:400 details["Salles d'eau"] = m.group(1)401402 # caractéristiques : repli valeur|libellé (du), écrasé par libellé|valeur403 details.update(du.centris_details(text))404 details.update(_labels_first(text, _RENT_LABELS))405 _drop_section_values(details)406 # « Cuisine » happé depuis le tableau des pièces (dimensions/niveau) : retirer407 if re.search(r"\d\s*[xX]\s*\d|Niveau|^\d+$", str(details.get("Cuisine", ""))):408 details.pop("Cuisine", None)409410 # quick stats (Nb de pièces, Superficie habitable…)411 for label, val in _QSTAT_RE.findall(html):412 label = _html.unescape(re.sub(r"\s+", " ", label)).strip()413 val = _html.unescape(re.sub(r"\s+", " ", val)).strip()414 if label and val and label not in details:415 details[label] = val416417 # pièces avec dimensions/niveau/revêtement418 rooms = []419 for blk in _ROOM_RE.findall(html):420 rt = _html.unescape(re.sub(r"<[^>]+>", " ", blk))421 rt = re.sub(r"\s+", " ", rt).strip()422 name = re.split(r"Niveau\s*:", rt)[0].strip()423 niveau = re.search(r"Niveau\s*:\s*([^:]+?)(?:Dimensions|Revêtement|$)", rt)424 dim = re.search(r"Dimensions\s*:\s*([0-9\'\".,X x×]+)", rt)425 rev = re.search(r"Revêtement\s*:\s*([A-Za-zÀ-ÿ ,-]+?)(?:\s*Détails|\s*$)", rt)426 if name:427 rooms.append({428 "nom": name[:40],429 "niveau": (niveau.group(1).strip() if niveau else ""),430 "dimensions": (dim.group(1).strip() if dim else ""),431 "revetement": (rev.group(1).strip() if rev else ""),432 })433 if rooms:434 details["pieces"] = rooms[:20]435436 # inclusions / exclusions -> commodités affichables437 mi = _INCL_RE.search(html)438 feats = []439 if mi:440 blk = _html.unescape(re.sub(r"<[^>]+>", "\n", mi.group(1)))441 for line in blk.split("\n"):442 line = line.strip(" \t•-")443 if 3 <= len(line) <= 120 and not line.lower().startswith(444 ("inclusion", "exclusion")):445 feats.append(line)446 if feats:447 out["amenities"] = feats[:25]448449 # superficie habitable (quick stat ou tableau) -> pi²450 if details.get("Superficie habitable"):451 out["area_sqft"] = parse_area_sqft(details["Superficie habitable"])452 # date d'emménagement / déménagement -> disponibilité453 for k in ("Date d'emménagement", "Déménagement", "Disponibilité"):454 if details.get(k):455 out["availability"] = details[k]456 break457 # chambres -> type d'unité (les fiches liste condo/appartement sont vides)458 if details.get("Chambres"):459 try:460 n = int(details["Chambres"])461 out["unit_type"] = "Studio" if n == 0 else f"{n} chambres"462 except ValueError:463 pass464465 if details:466 out["details"] = details467 return out468