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/sutton.py : Groupe Sutton Québec (suttonquebec.com) — LOCATIONS5# Couverture exhaustive via le sitemap : l'index /sitemap.cfm pointe vers des6# sitemaps d'inscriptions paginés où chaque URL porte noInscription (numéro7# Centris) et typeInscription (1 = à vendre, 2 = À LOUER — on ne garde que8# les 2). Le slug donne type/adresse/ville approximatifs ; la fiche détail9# fournit le loyer (« 1 950 $ / mois »), la galerie immo.vrtx.co, la10# description, les pièces et l'adresse exacte (H1). Adapté du connecteur11# « à vendre » d'Immo-Ka (agent-courtage/immoka).12# -----------------------------------------------------------------------------13from __future__ import annotations1415import html as _html16import os17import re1819from ..normalize import parse_area_sqft, strip_accents20from ..schema import Listing21from .base import BaseConnector2223from . import _detailutil as du2425BASE = "https://www.suttonquebec.com"26DETAIL_LIMIT = int(os.environ.get("LOUKA_SUTTON_DETAIL_LIMIT", "400"))2728# Sitemap : l'index /sitemap.cfm pointe vers des sitemaps d'inscriptions.29SITEMAP_INDEX = f"{BASE}/sitemap.cfm"30SITEMAP_MAX_PAGES = 40 # garde-fou (≈13 pages réelles × ~1000)31_SM_SUB_RE = re.compile(r'sitemap_inscription\.cfm\?[^<"\s]+', re.I)32_SM_LOC_RE = re.compile(33 r'https://www\.suttonquebec\.com/fr/inscription/([^<"?]+)\.html\?'34 r'noInscription=(\d+)&(?:amp;)?typeInscription=(\d)', re.I)3536# toutes les photos de la fiche (normalisées pic450, dédoublonnées id-index)37_DET_IMG_RE = re.compile(38 r'https://immo\.vrtx\.co/pic\d+/(\d+(?:-\d+)?)\.(?:jpg|jpeg|png|webp)', re.I)39_DESC_RE = re.compile(40 r'Description de la propri[ée]t[ée](?:\s+[àa]\s+(?:vendre|louer))?[^A-Za-z0-9]*(.*?)'41 r'(?:Caract[ée]ristiques|Pi[èe]ce\s+Dimension|Inclusions|Exclusions|Addenda|$)',42 re.I | re.S)43# loyer : montant suivi de « / mois » / « par mois » (le prix demandé précède44# les loyers des « prochaines propriétés » en bas de page)45_RENT_RE = re.compile(r"(?<!\d)(\d{1,2}\s\d{3}|\d{3,5})\s*\$\s*(?:/\s*|par\s+)mois",46 re.I)47_ROOM_ROW_RE = re.compile(48 r'([A-ZÀ-Ÿ][^\d|]{2,38}?)\s+(\d[\d.,]*\s*[xX]\s*\d[\d.,]*)\s*P?\s+'49 r'([A-Za-zÀ-ÿ/ -]{3,30}?)\s+(RC|SS|AU|\d[eE]?r?e?)\b')50_AREA_RE = re.compile(r"Aire habitable\s*:?\s*([\d.,]+)\s*(mc|m2|m²|pc|pi)", re.I)51_H1_RE = re.compile(r"<h1[^>]*>(.*?)</h1>", re.S)52_BROKER_RE = re.compile(53 r'divResume courtier[^"]*">.*?class="h4">\s*([^<]{3,60}?)\s*</p>', re.S)54_PHONE_RE = re.compile(r"\d{3}[-\s]\d{3}-\d{4}")55_AGENCY_RE = re.compile(r'/agence-immobiliere/\d+-([a-z0-9-]+)\.html', re.I)56_PAREN_RE = re.compile(r"\(([^)]*)\)")57_POSTAL_RE = re.compile(r"[GHJ]\d[A-Z]\s?\d[A-Z]\d", re.I)5859# types de slug qui ne sont pas des logements (lou-ka = résidentiel locatif)60_EXCLUDE_TYPE_RE = re.compile(61 r"commercial|bureau|industriel|terrain|entrep[ôo]t|local\b", re.I)6263# Sur les fiches LOCATION, le tableau des caractéristiques est en ordre64# libellé | valeur : on privilégie cet ordre, du.centris_details en repli.65_RENT_LABELS = [66 "Type de propriété", "Genre de propriété", "Style de bâtiment",67 "Année de construction", "Aire habitable", "Superficie habitable",68 "Superficie du terrain", "Nombre de pièces", "Stationnement",69 "Garage", "Mode de chauffage", "Système de chauffage",70 "Énergie pour le chauffage", "Équipement disponible", "Zonage",71 "Approvisionnement en eau", "Système d'égouts", "Piscine",72 "Déménagement", "Date d'emménagement", "Disponibilité", "Bail",73 "Durée du bail", "Meublé", "Animaux", "Inclus dans le loyer", "Cuisine",74]757677class SuttonConnector(BaseConnector):78 source_id = "sutton"79 request_delay = 0.58081 def fetch(self) -> list[Listing]:82 by_id: dict[str, Listing] = {}83 # 1) couverture exhaustive via le sitemap (type/adresse depuis le slug)84 for slug, centris, url in self._sitemap_listings():85 if centris in by_id:86 continue87 ptype, addr, city = _from_slug(slug)88 if ptype is None: # commercial/bureau : pas un logement89 continue90 details = {"MLS": centris}91 if ptype:92 details["Type"] = ptype93 by_id[centris] = Listing(94 source=self.source_id, external_id=centris, url=url,95 title=addr or _deslug(slug), address=addr, city=city,96 unit_type="Maison" if ptype.startswith("Maison") else "",97 details=details,98 )99 listings = list(by_id.values())100 # fiche détail : loyer, chambres, galerie complète, description,101 # pièces, inclusions, adresse exacte — plafonné, cache accumulé entre102 # cycles (tout le parc finit enrichi au fil des syncs).103 self._purge_empty_details()104 du.enrich(self, listings, DETAIL_LIMIT, parse_sutton_detail, key="v1",105 fetch_html=self._fetch_detail_html)106 for lst in listings:107 # adresse exacte du H1 de la fiche : remplace l'approx. du slug108 for det_key, attr in (("__adresse", "address"), ("__ville", "city"),109 ("__secteur", "sector")):110 val = lst.details.pop(det_key, "")111 if val:112 if attr == "address":113 val = _expand_abbrev(val)114 setattr(lst, attr, val)115 if lst.address:116 lst.title = lst.address117 _unit_from_bedrooms(lst)118 return listings119120 def _purge_empty_details(self) -> None:121 """Retire du cache les payloads vides « {} » (échecs réseau passés122 figés par enrich) : sans clé fraîche ces fiches ne seraient JAMAIS123 re-visitées alors que la page répond très bien aujourd'hui — c'était124 la cause des annonces sans prix/photos/description. Les fiches ainsi125 purgées repassent dans le budget DETAIL_LIMIT au prochain enrich."""126 from .. import db127 con = db.connect()128 try:129 con.execute(130 "DELETE FROM detail_cache WHERE source=? AND"131 " (payload IS NULL OR payload='' OR payload='{}')",132 (self.source_id,))133 con.commit()134 except Exception:135 pass136 finally:137 con.close()138139 def _fetch_detail_html(self, url: str) -> str:140 """GET de la fiche avec UNE reprise : un échec ponctuel (timeout,141 hoquet serveur) serait sinon mis en cache comme payload vide."""142 import time as _time143 try:144 return self.get(url).text145 except Exception:146 _time.sleep(2.0)147 return self.get(url).text148149 def _sitemap_listings(self):150 """Itère (slug, centris, url) de toutes les inscriptions À LOUER."""151 try:152 index = self.get(SITEMAP_INDEX).text153 except Exception:154 return155 subs = []156 for s in _SM_SUB_RE.findall(index):157 s = s.replace("&", "&")158 if s not in subs:159 subs.append(s)160 if not subs: # repli : pagination directe161 subs = [f"sitemap_inscription.cfm?page={p}&platine=0"162 for p in range(1, 14)] + ["sitemap_inscription.cfm?platine=1"]163 seen = set()164 for sub in subs[:SITEMAP_MAX_PAGES]:165 try:166 xml = self.get(f"{BASE}/{sub}").text167 except Exception:168 continue169 for m in _SM_LOC_RE.finditer(xml):170 slug, centris, typ = m.group(1), m.group(2), m.group(3)171 if typ != "2" or centris in seen: # 2 = à louer uniquement172 continue173 seen.add(centris)174 url = (f"{BASE}/fr/inscription/{slug}.html"175 f"?noInscription={centris}&typeInscription=2")176 yield slug, centris, url177178179def _unit_from_bedrooms(lst: Listing) -> None:180 """« Chambres » de la fiche détail -> unit_type « n chambres »181 (la normalisation lou-ka convertit en n+2½)."""182 if lst.unit_type:183 return184 m = re.match(r"\d+", str(lst.details.get("Chambres", "")))185 if m:186 n = int(m.group(0))187 lst.unit_type = "Studio" if n == 0 else f"{n} chambres"188 else:189 lst.unit_type = lst.details.get("Type", "")190191192# Le H1 Sutton abrège les odonymes (« 3762 Rue St-Dominique », « Av. »,193# « Boul. ») : le géocodeur Adresses Québec les score 0 (vérifié) alors que194# la forme en toutes lettres score 85+. On livre donc l'adresse canonique.195_ABBREV = [196 (re.compile(r"\bSt-"), "Saint-"),197 (re.compile(r"\bSte-"), "Sainte-"),198 (re.compile(r"\bAv\.\s"), "Avenue "),199 (re.compile(r"\bBoul\.\s"), "Boulevard "),200 (re.compile(r"\bCh\.\s"), "Chemin "),201 (re.compile(r"\bPl\.\s"), "Place "),202 (re.compile(r"\bCr\.\s"), "Croissant "),203 (re.compile(r"\bCrois\.\s"), "Croissant "),204 (re.compile(r"\bProm\.\s"), "Promenade "),205 (re.compile(r"\bTerr\.\s"), "Terrasse "),206 (re.compile(r"\bMt[ée]e\s"), "Montée "),207 (re.compile(r"\bRte\s"), "Route "),208]209210211def _expand_abbrev(addr: str) -> str:212 for rx, rep in _ABBREV:213 addr = rx.sub(rep, addr)214 return addr215216217def _deslug(s: str) -> str:218 return re.sub(r"\s+", " ", s.replace("-", " ")).strip().title()219220221# villes/arrondissements composés fréquents dans les slugs Sutton (fin du slug)222_CITY_HINTS = (223 "montreal-le-plateau-mont-royal", "montreal-ville-marie", "montreal-rosemont-la-petite-patrie",224 "montreal-le-sud-ouest", "montreal-cote-des-neiges-notre-dame-de-grace", "montreal-ahuntsic-cartierville",225 "montreal-villeray-saint-michel-parc-extension", "montreal-mercier-hochelaga-maisonneuve",226 "montreal-riviere-des-prairies-pointe-aux-trembles", "montreal-verdun-ile-des-soeurs",227 "montreal-saint-laurent", "montreal-lasalle", "montreal-outremont", "montreal-anjou",228 "montreal-lachine", "montreal-pierrefonds-roxboro", "montreal-saint-leonard",229 "montreal-montreal-nord", "montreal-l-ile-bizard-sainte-genevieve",230 "longueuil-le-vieux-longueuil", "longueuil-saint-hubert", "longueuil-greenfield-park",231 "laval-chomedey", "laval-sainte-dorothee", "laval-sainte-rose", "laval-duvernay",232 "laval-fabreville", "laval-vimont", "laval-auteuil", "laval-laval-des-rapides",233 "laval-pont-viau", "laval-laval-ouest", "laval-saint-francois", "laval-saint-vincent-de-paul",234 "quebec-la-cite-limoilou", "quebec-sainte-foy-sillery-cap-rouge", "quebec-charlesbourg",235 "quebec-beauport", "quebec-les-rivieres", "quebec-la-haute-saint-charles",236 "sherbrooke-les-nations", "sherbrooke-fleurimont", "saguenay-chicoutimi",237 "saguenay-jonquiere", "saguenay-la-baie", "trois-rivieres", "saint-jean-sur-richelieu",238 "salaberry-de-valleyfield", "mont-saint-hilaire", "notre-dame-de-l-ile-perrot",239 "vaudreuil-dorion", "sainte-adele", "sainte-therese", "saint-jerome", "levis", "quebec",240 "laval", "gatineau", "longueuil", "brossard", "sherbrooke", "granby", "magog",241 "sorel-tracy", "sainte-catherine", "saint-constant", "chateauguay", "coteau-du-lac",242 "saint-zotique", "sainte-anne-de-bellevue", "sainte-anne-des-lacs", "pointe-claire",243 "dorval", "beaconsfield", "kirkland", "dollard-des-ormeaux", "cote-saint-luc",244 "mont-royal", "westmount", "saint-lambert", "boucherville", "sainte-julie", "varennes",245 "saint-bruno-de-montarville", "candiac", "la-prairie", "chambly", "repentigny",246 "terrebonne", "mascouche", "blainville", "boisbriand", "rosemere", "mirabel",247 "deux-montagnes", "pincourt",248)249# accents perdus dans les slugs (villes fréquentes)250_CITY_ACCENTS = {251 "montreal": "Montréal", "quebec": "Québec", "levis": "Lévis",252 "trois-rivieres": "Trois-Rivières", "saint-jerome": "Saint-Jérôme",253 "sainte-adele": "Sainte-Adèle", "sainte-therese": "Sainte-Thérèse",254 "chateauguay": "Châteauguay", "rosemere": "Rosemère", "sainte-julie": "Sainte-Julie",255}256# arrondissement/secteur préfixé par sa grande ville dans le slug257_BOROUGH_PREFIXES = {258 "montreal-": "Montréal", "longueuil-": "Longueuil", "laval-": "Laval",259 "quebec-": "Québec", "sherbrooke-": "Sherbrooke", "saguenay-": "Saguenay",260 "levis-": "Lévis",261}262263264def _hint_city(hint: str) -> str:265 """Ville lisible depuis un indice de slug ; les arrondissements composés266 « montreal-… » deviennent simplement « Montréal » (le secteur exact vient267 de la fiche détail)."""268 for prefix, city in _BOROUGH_PREFIXES.items():269 if hint.startswith(prefix):270 return city271 return _CITY_ACCENTS.get(hint, _deslug(hint))272273274def _from_slug(slug: str) -> tuple[str | None, str, str]:275 """« appartement-a-louer-3762-rue-st-dominique-montreal-le-plateau-mont-royal »276 -> (type, adresse, ville). Best-effort : l'adresse exacte vient de la fiche.277 Type None = pas un logement (espace commercial, bureau…)."""278 # quelques slugs de location portent le libellé anglais, voire « for-sale »279 # par coquille : typeInscription=2 (sitemap) fait foi, pas le slug.280 m = re.search(r"^(.*?)-(?:a-louer|for-rent|a-vendre|for-sale)-(.+)$", slug, re.I)281 if not m:282 return "", _deslug(slug), ""283 if _EXCLUDE_TYPE_RE.search(m.group(1)):284 return None, "", ""285 ptype = _deslug(m.group(1))286 rest = m.group(2).lower()287 city = ""288 for hint in _CITY_HINTS:289 if rest.endswith("-" + hint) or rest == hint:290 city = _hint_city(hint)291 rest = rest[: -len(hint)].rstrip("-")292 break293 else:294 # sinon : dernier token comme ville (approx ; corrigé par la fiche détail)295 parts = rest.rsplit("-", 1)296 if len(parts) == 2 and not parts[1].isdigit():297 city, rest = _hint_city(parts[1]), parts[0]298 return ptype, _deslug(rest), city299300301# régions administratives : parfois entre parenthèses dans le H1 après le302# secteur — « Longueuil (Le Vieux-Longueuil) ( Montérégie ) » — à distinguer303_REGIONS = {304 "monteregie", "estrie", "laurentides", "lanaudiere", "outaouais",305 "mauricie", "bas-saint-laurent", "gaspesie", "gaspesie--iles-de-la-madeleine",306 "abitibi-temiscamingue", "cote-nord", "saguenay--lac-saint-jean",307 "centre-du-quebec", "chaudiere-appalaches", "capitale-nationale",308 "nord-du-quebec",309}310_APT_RE = re.compile(r"(?i)^(?:app|apt|suite|local|unit[ée]|#)")311312313def _split_h1(full: str) -> tuple[str, str, str, str]:314 """« 4781 Rue St-Ambroise, app. 201, Montréal (Le Sud-Ouest), H4C2E8 »315 -> (adresse, ville, secteur, région)."""316 parts = [p.strip() for p in full.split(",") if p.strip()]317 if parts and _POSTAL_RE.fullmatch(parts[-1].replace(" ", "")):318 parts = parts[:-1]319 address = parts[0] if parts else ""320 rest = parts[1:]321 # « app. 201 » collé après la rue : fait partie de l'adresse322 while rest and _APT_RE.match(rest[0]):323 address += ", " + rest.pop(0)324 city = sector = region = ""325 if rest:326 muni = rest[0]327 city = _PAREN_RE.sub("", muni).strip()328 for p in (p.strip() for p in _PAREN_RE.findall(muni)):329 if strip_accents(p.lower()).replace(" ", "-") in _REGIONS:330 region = p331 elif p:332 sector = p333 return address, city, sector, region334335336def parse_sutton_detail(html: str) -> dict:337 """Fiche Sutton location : loyer, galerie complète, description, chambres,338 pièces (dimensions), inclusions, caractéristiques, agence, adresse exacte."""339 out: dict = {}340 details: dict = {}341342 # photos : normaliser toute taille -> pic450, dédoublonner par id-index343 seen, imgs = set(), []344 for m in _DET_IMG_RE.finditer(html):345 key = m.group(1)346 if key not in seen:347 seen.add(key)348 imgs.append(f"https://immo.vrtx.co/pic450/{key}.jpg")349 if imgs:350 out["images"] = imgs351352 # adresse exacte (H1 : « 3762 Rue St-Dominique, Montréal (…), H2W0A2 »)353 mh = _H1_RE.search(html)354 if mh:355 h1 = re.sub(r"\s+", " ", _html.unescape(re.sub(r"<[^>]+>", " ", mh.group(1)))).strip()356 address, city, sector, region = _split_h1(h1)357 if address and re.match(r"\d", address):358 details["__adresse"] = address359 if city:360 details["__ville"] = city361 if sector:362 details["__secteur"] = sector363 if region:364 details["Région"] = region365366 text = re.sub(r"\s+", " ", _html.unescape(re.sub(r"<[^>]+>", " ", html)))367368 # loyer : premier « N NNN $ / mois » plausible (100–20 000 $)369 for m in _RENT_RE.finditer(text):370 try:371 val = int(re.sub(r"\s", "", m.group(1)))372 except ValueError:373 continue374 if 100 <= val <= 20000:375 out["price"] = float(val)376 out["price_label"] = f"{val:,.0f} $/mois".replace(",", " ")377 break378379 mb = re.search(r"(\d+)\s*chambres?\b(?!\s*[àa]\s*coucher)", text, re.I)380 if mb:381 details["Chambres"] = mb.group(1)382 ms = re.search(r"(\d+)\s*salles?\s*de\s*bain", text, re.I)383 if ms:384 details["Salles de bain"] = ms.group(1)385386 md = _DESC_RE.search(text)387 if md:388 desc = md.group(1).strip()389 if len(desc) > 30:390 out["description"] = desc[:4000]391392 # aire habitable : « 76.9 MC (828 pi²) » -> pi²393 ma = _AREA_RE.search(text)394 if ma:395 unit = ma.group(2).lower()396 raw = f"{ma.group(1)} {'m²' if unit in ('mc', 'm2', 'm²') else 'pi²'}"397 area = parse_area_sqft(raw)398 if area:399 out["area_sqft"] = area400401 # pièces : « Nom DimxDim P Revêtement Niveau »402 rooms = []403 for m in _ROOM_ROW_RE.finditer(text):404 nom = m.group(1).strip(" -")405 # retirer l'en-tête de tableau qui peut coller au 1er nom406 nom = re.sub(r"^.*(?:Niveau|Rev[êe]tement|Dimensions?)\s+", "", nom).strip(" -")407 if not nom or nom.lower() in ("pièce", "piece"):408 continue409 rooms.append({"nom": nom[:40], "dimensions": m.group(2).replace(" ", ""),410 "revetement": m.group(3).strip(), "niveau": m.group(4)})411 if rooms:412 details["pieces"] = rooms[:20]413414 # inclusions -> commodités affichables415 mi = re.search(r"Inclusions?\s*:?(.*?)(?:Exclusions|Addenda|Financ|$)", text, re.I | re.S)416 if mi:417 inc = mi.group(1).strip()418 feats = [s.strip() for s in re.split(r"[,;•]", inc) if 3 <= len(s.strip()) <= 90]419 if feats:420 out["amenities"] = feats[:20]421422 # caractéristiques : repli valeur|libellé (du), écrasé par libellé|valeur423 flat = du.flatten(html)424 details.update(du.centris_details(flat))425 details.update(_labels_first(flat, _RENT_LABELS))426 _drop_section_values(details)427 # « Cuisine » happé depuis le tableau des pièces (dimensions) : retirer428 if re.search(r"\d\s*[xX]\s*\d", str(details.get("Cuisine", ""))):429 details.pop("Cuisine", None)430 # « 76.9 MC (828 pi » : l'exposant ² coupe la valeur à l'aplatissement431 if str(details.get("Aire habitable", "")).endswith("pi"):432 details["Aire habitable"] += "²)"433434 # date d'emménagement / déménagement -> disponibilité — garde-fou : la435 # valeur doit ressembler à une date/mention de dispo, pas à un bout de436 # phrase happé du texte libre (« …parties de la ville sont pratiques »)437 for k in ("Date d'emménagement", "Déménagement", "Disponibilité"):438 val = str(details.get(k) or "").strip()439 if val and re.search(r"(?i)\d|imm[ée]diat|maintenant|libre|now"440 r"|n[ée]gociable|signature|entente", val):441 out["availability"] = val442 break443 if val and k == "Disponibilité" and not re.search(r"\d", val):444 details.pop(k, None) # valeur happée : on la retire aussi445446 # chambres -> type d'unité (rempli si la fiche liste n'en avait pas)447 if details.get("Chambres"):448 try:449 n = int(details["Chambres"])450 out["unit_type"] = "Studio" if n == 0 else f"{n} chambres"451 except ValueError:452 pass453454 # courtier + sous-agence (bureau) : carte « divResume courtier »455 mc = _BROKER_RE.search(html)456 if mc:457 details["Courtier"] = _html.unescape(mc.group(1)).strip()458 mp = _PHONE_RE.search(html[mc.start():mc.start() + 1500])459 if mp:460 details["Téléphone"] = mp.group(0)461 mo = _AGENCY_RE.search(html)462 if mo:463 details["Agence"] = _deslug(mo.group(1))464465 if details:466 out["details"] = details467 return out468469470# une « valeur » qui est en fait un titre de section = extraction décalée471_SECTION_VAL_RE = re.compile(472 r"^(?:Particularit[ée]s|Caract[ée]ristiques|D[ée]tails des|Inclusions"473 r"|Exclusions|Addenda|Publicit[ée])", re.I)474475476def _labels_first(text: str, labels: list[str]) -> dict:477 """Extraction « libellé | valeur » (ordre des fiches location Sutton)."""478 out: dict = {}479 for label in labels:480 boundary = r"\b" if label[-1].isalnum() else ""481 m = re.search(re.escape(label) + boundary + r"\s*\|\s*([^|]{1,55})", text)482 if m:483 val = m.group(1).strip(" |")484 if (val and 1 <= len(val) <= 55 and val.lower() != label.lower()485 and val not in labels and not _SECTION_VAL_RE.match(val)):486 out[label] = val487 return out488489490def _drop_section_values(details: dict) -> None:491 """Purge les valeurs qui sont des titres de section (repli valeur|libellé492 de du.centris_details décalé sur les fiches en ordre libellé|valeur)."""493 for k in [k for k, v in details.items()494 if isinstance(v, str) and _SECTION_VAL_RE.match(v)]:495 del details[k]496