# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/sutton.py : Groupe Sutton Québec (suttonquebec.com) — LOCATIONS # Couverture exhaustive via le sitemap : l'index /sitemap.cfm pointe vers des # sitemaps d'inscriptions paginés où chaque URL porte noInscription (numéro # Centris) et typeInscription (1 = à vendre, 2 = À LOUER — on ne garde que # les 2). Le slug donne type/adresse/ville approximatifs ; la fiche détail # fournit le loyer (« 1 950 $ / mois »), la galerie immo.vrtx.co, la # description, les pièces et l'adresse exacte (H1). Adapté du connecteur # « à vendre » d'Immo-Ka (agent-courtage/immoka). # ----------------------------------------------------------------------------- from __future__ import annotations import html as _html import os import re from ..normalize import parse_area_sqft, strip_accents from ..schema import Listing from .base import BaseConnector from . import _detailutil as du BASE = "https://www.suttonquebec.com" DETAIL_LIMIT = int(os.environ.get("LOUKA_SUTTON_DETAIL_LIMIT", "400")) # Sitemap : l'index /sitemap.cfm pointe vers des sitemaps d'inscriptions. SITEMAP_INDEX = f"{BASE}/sitemap.cfm" SITEMAP_MAX_PAGES = 40 # garde-fou (≈13 pages réelles × ~1000) _SM_SUB_RE = re.compile(r'sitemap_inscription\.cfm\?[^<"\s]+', re.I) _SM_LOC_RE = re.compile( r'https://www\.suttonquebec\.com/fr/inscription/([^<"?]+)\.html\?' r'noInscription=(\d+)&(?:amp;)?typeInscription=(\d)', re.I) # toutes les photos de la fiche (normalisées pic450, dédoublonnées id-index) _DET_IMG_RE = re.compile( r'https://immo\.vrtx\.co/pic\d+/(\d+(?:-\d+)?)\.(?:jpg|jpeg|png|webp)', re.I) _DESC_RE = re.compile( r'Description de la propri[ée]t[ée](?:\s+[àa]\s+(?:vendre|louer))?[^A-Za-z0-9]*(.*?)' r'(?:Caract[ée]ristiques|Pi[èe]ce\s+Dimension|Inclusions|Exclusions|Addenda|$)', re.I | re.S) # loyer : montant suivi de « / mois » / « par mois » (le prix demandé précède # les loyers des « prochaines propriétés » en bas de page) _RENT_RE = re.compile(r"(?]*>(.*?)", re.S) _BROKER_RE = re.compile( r'divResume courtier[^"]*">.*?class="h4">\s*([^<]{3,60}?)\s*

', re.S) _PHONE_RE = re.compile(r"\d{3}[-\s]\d{3}-\d{4}") _AGENCY_RE = re.compile(r'/agence-immobiliere/\d+-([a-z0-9-]+)\.html', re.I) _PAREN_RE = re.compile(r"\(([^)]*)\)") _POSTAL_RE = re.compile(r"[GHJ]\d[A-Z]\s?\d[A-Z]\d", re.I) # types de slug qui ne sont pas des logements (lou-ka = résidentiel locatif) _EXCLUDE_TYPE_RE = re.compile( r"commercial|bureau|industriel|terrain|entrep[ôo]t|local\b", re.I) # Sur les fiches LOCATION, le tableau des caractéristiques est en ordre # libellé | valeur : on privilégie cet ordre, du.centris_details en repli. _RENT_LABELS = [ "Type de propriété", "Genre de propriété", "Style de bâtiment", "Année de construction", "Aire habitable", "Superficie habitable", "Superficie du terrain", "Nombre de pièces", "Stationnement", "Garage", "Mode de chauffage", "Système de chauffage", "Énergie pour le chauffage", "Équipement disponible", "Zonage", "Approvisionnement en eau", "Système d'égouts", "Piscine", "Déménagement", "Date d'emménagement", "Disponibilité", "Bail", "Durée du bail", "Meublé", "Animaux", "Inclus dans le loyer", "Cuisine", ] class SuttonConnector(BaseConnector): source_id = "sutton" request_delay = 0.5 def fetch(self) -> list[Listing]: by_id: dict[str, Listing] = {} # 1) couverture exhaustive via le sitemap (type/adresse depuis le slug) for slug, centris, url in self._sitemap_listings(): if centris in by_id: continue ptype, addr, city = _from_slug(slug) if ptype is None: # commercial/bureau : pas un logement continue details = {"MLS": centris} if ptype: details["Type"] = ptype by_id[centris] = Listing( source=self.source_id, external_id=centris, url=url, title=addr or _deslug(slug), address=addr, city=city, unit_type="Maison" if ptype.startswith("Maison") else "", details=details, ) listings = list(by_id.values()) # fiche détail : loyer, chambres, galerie complète, description, # pièces, inclusions, adresse exacte — plafonné, cache accumulé entre # cycles (tout le parc finit enrichi au fil des syncs). self._purge_empty_details() du.enrich(self, listings, DETAIL_LIMIT, parse_sutton_detail, key="v1", fetch_html=self._fetch_detail_html) for lst in listings: # adresse exacte du H1 de la fiche : remplace l'approx. du slug for det_key, attr in (("__adresse", "address"), ("__ville", "city"), ("__secteur", "sector")): val = lst.details.pop(det_key, "") if val: if attr == "address": val = _expand_abbrev(val) setattr(lst, attr, val) if lst.address: lst.title = lst.address _unit_from_bedrooms(lst) return listings def _purge_empty_details(self) -> None: """Retire du cache les payloads vides « {} » (échecs réseau passés figés par enrich) : sans clé fraîche ces fiches ne seraient JAMAIS re-visitées alors que la page répond très bien aujourd'hui — c'était la cause des annonces sans prix/photos/description. Les fiches ainsi purgées repassent dans le budget DETAIL_LIMIT au prochain enrich.""" from .. import db con = db.connect() try: con.execute( "DELETE FROM detail_cache WHERE source=? AND" " (payload IS NULL OR payload='' OR payload='{}')", (self.source_id,)) con.commit() except Exception: pass finally: con.close() def _fetch_detail_html(self, url: str) -> str: """GET de la fiche avec UNE reprise : un échec ponctuel (timeout, hoquet serveur) serait sinon mis en cache comme payload vide.""" import time as _time try: return self.get(url).text except Exception: _time.sleep(2.0) return self.get(url).text def _sitemap_listings(self): """Itère (slug, centris, url) de toutes les inscriptions À LOUER.""" try: index = self.get(SITEMAP_INDEX).text except Exception: return subs = [] for s in _SM_SUB_RE.findall(index): s = s.replace("&", "&") if s not in subs: subs.append(s) if not subs: # repli : pagination directe subs = [f"sitemap_inscription.cfm?page={p}&platine=0" for p in range(1, 14)] + ["sitemap_inscription.cfm?platine=1"] seen = set() for sub in subs[:SITEMAP_MAX_PAGES]: try: xml = self.get(f"{BASE}/{sub}").text except Exception: continue for m in _SM_LOC_RE.finditer(xml): slug, centris, typ = m.group(1), m.group(2), m.group(3) if typ != "2" or centris in seen: # 2 = à louer uniquement continue seen.add(centris) url = (f"{BASE}/fr/inscription/{slug}.html" f"?noInscription={centris}&typeInscription=2") yield slug, centris, url def _unit_from_bedrooms(lst: Listing) -> None: """« Chambres » de la fiche détail -> unit_type « n chambres » (la normalisation lou-ka convertit en n+2½).""" if lst.unit_type: return m = re.match(r"\d+", str(lst.details.get("Chambres", ""))) if m: n = int(m.group(0)) lst.unit_type = "Studio" if n == 0 else f"{n} chambres" else: lst.unit_type = lst.details.get("Type", "") # Le H1 Sutton abrège les odonymes (« 3762 Rue St-Dominique », « Av. », # « Boul. ») : le géocodeur Adresses Québec les score 0 (vérifié) alors que # la forme en toutes lettres score 85+. On livre donc l'adresse canonique. _ABBREV = [ (re.compile(r"\bSt-"), "Saint-"), (re.compile(r"\bSte-"), "Sainte-"), (re.compile(r"\bAv\.\s"), "Avenue "), (re.compile(r"\bBoul\.\s"), "Boulevard "), (re.compile(r"\bCh\.\s"), "Chemin "), (re.compile(r"\bPl\.\s"), "Place "), (re.compile(r"\bCr\.\s"), "Croissant "), (re.compile(r"\bCrois\.\s"), "Croissant "), (re.compile(r"\bProm\.\s"), "Promenade "), (re.compile(r"\bTerr\.\s"), "Terrasse "), (re.compile(r"\bMt[ée]e\s"), "Montée "), (re.compile(r"\bRte\s"), "Route "), ] def _expand_abbrev(addr: str) -> str: for rx, rep in _ABBREV: addr = rx.sub(rep, addr) return addr def _deslug(s: str) -> str: return re.sub(r"\s+", " ", s.replace("-", " ")).strip().title() # villes/arrondissements composés fréquents dans les slugs Sutton (fin du slug) _CITY_HINTS = ( "montreal-le-plateau-mont-royal", "montreal-ville-marie", "montreal-rosemont-la-petite-patrie", "montreal-le-sud-ouest", "montreal-cote-des-neiges-notre-dame-de-grace", "montreal-ahuntsic-cartierville", "montreal-villeray-saint-michel-parc-extension", "montreal-mercier-hochelaga-maisonneuve", "montreal-riviere-des-prairies-pointe-aux-trembles", "montreal-verdun-ile-des-soeurs", "montreal-saint-laurent", "montreal-lasalle", "montreal-outremont", "montreal-anjou", "montreal-lachine", "montreal-pierrefonds-roxboro", "montreal-saint-leonard", "montreal-montreal-nord", "montreal-l-ile-bizard-sainte-genevieve", "longueuil-le-vieux-longueuil", "longueuil-saint-hubert", "longueuil-greenfield-park", "laval-chomedey", "laval-sainte-dorothee", "laval-sainte-rose", "laval-duvernay", "laval-fabreville", "laval-vimont", "laval-auteuil", "laval-laval-des-rapides", "laval-pont-viau", "laval-laval-ouest", "laval-saint-francois", "laval-saint-vincent-de-paul", "quebec-la-cite-limoilou", "quebec-sainte-foy-sillery-cap-rouge", "quebec-charlesbourg", "quebec-beauport", "quebec-les-rivieres", "quebec-la-haute-saint-charles", "sherbrooke-les-nations", "sherbrooke-fleurimont", "saguenay-chicoutimi", "saguenay-jonquiere", "saguenay-la-baie", "trois-rivieres", "saint-jean-sur-richelieu", "salaberry-de-valleyfield", "mont-saint-hilaire", "notre-dame-de-l-ile-perrot", "vaudreuil-dorion", "sainte-adele", "sainte-therese", "saint-jerome", "levis", "quebec", "laval", "gatineau", "longueuil", "brossard", "sherbrooke", "granby", "magog", "sorel-tracy", "sainte-catherine", "saint-constant", "chateauguay", "coteau-du-lac", "saint-zotique", "sainte-anne-de-bellevue", "sainte-anne-des-lacs", "pointe-claire", "dorval", "beaconsfield", "kirkland", "dollard-des-ormeaux", "cote-saint-luc", "mont-royal", "westmount", "saint-lambert", "boucherville", "sainte-julie", "varennes", "saint-bruno-de-montarville", "candiac", "la-prairie", "chambly", "repentigny", "terrebonne", "mascouche", "blainville", "boisbriand", "rosemere", "mirabel", "deux-montagnes", "pincourt", ) # accents perdus dans les slugs (villes fréquentes) _CITY_ACCENTS = { "montreal": "Montréal", "quebec": "Québec", "levis": "Lévis", "trois-rivieres": "Trois-Rivières", "saint-jerome": "Saint-Jérôme", "sainte-adele": "Sainte-Adèle", "sainte-therese": "Sainte-Thérèse", "chateauguay": "Châteauguay", "rosemere": "Rosemère", "sainte-julie": "Sainte-Julie", } # arrondissement/secteur préfixé par sa grande ville dans le slug _BOROUGH_PREFIXES = { "montreal-": "Montréal", "longueuil-": "Longueuil", "laval-": "Laval", "quebec-": "Québec", "sherbrooke-": "Sherbrooke", "saguenay-": "Saguenay", "levis-": "Lévis", } def _hint_city(hint: str) -> str: """Ville lisible depuis un indice de slug ; les arrondissements composés « montreal-… » deviennent simplement « Montréal » (le secteur exact vient de la fiche détail).""" for prefix, city in _BOROUGH_PREFIXES.items(): if hint.startswith(prefix): return city return _CITY_ACCENTS.get(hint, _deslug(hint)) def _from_slug(slug: str) -> tuple[str | None, str, str]: """« appartement-a-louer-3762-rue-st-dominique-montreal-le-plateau-mont-royal » -> (type, adresse, ville). Best-effort : l'adresse exacte vient de la fiche. Type None = pas un logement (espace commercial, bureau…).""" # quelques slugs de location portent le libellé anglais, voire « for-sale » # par coquille : typeInscription=2 (sitemap) fait foi, pas le slug. m = re.search(r"^(.*?)-(?:a-louer|for-rent|a-vendre|for-sale)-(.+)$", slug, re.I) if not m: return "", _deslug(slug), "" if _EXCLUDE_TYPE_RE.search(m.group(1)): return None, "", "" ptype = _deslug(m.group(1)) rest = m.group(2).lower() city = "" for hint in _CITY_HINTS: if rest.endswith("-" + hint) or rest == hint: city = _hint_city(hint) rest = rest[: -len(hint)].rstrip("-") break else: # sinon : dernier token comme ville (approx ; corrigé par la fiche détail) parts = rest.rsplit("-", 1) if len(parts) == 2 and not parts[1].isdigit(): city, rest = _hint_city(parts[1]), parts[0] return ptype, _deslug(rest), city # régions administratives : parfois entre parenthèses dans le H1 après le # secteur — « Longueuil (Le Vieux-Longueuil) ( Montérégie ) » — à distinguer _REGIONS = { "monteregie", "estrie", "laurentides", "lanaudiere", "outaouais", "mauricie", "bas-saint-laurent", "gaspesie", "gaspesie--iles-de-la-madeleine", "abitibi-temiscamingue", "cote-nord", "saguenay--lac-saint-jean", "centre-du-quebec", "chaudiere-appalaches", "capitale-nationale", "nord-du-quebec", } _APT_RE = re.compile(r"(?i)^(?:app|apt|suite|local|unit[ée]|#)") def _split_h1(full: str) -> tuple[str, str, str, str]: """« 4781 Rue St-Ambroise, app. 201, Montréal (Le Sud-Ouest), H4C2E8 » -> (adresse, ville, secteur, région).""" parts = [p.strip() for p in full.split(",") if p.strip()] if parts and _POSTAL_RE.fullmatch(parts[-1].replace(" ", "")): parts = parts[:-1] address = parts[0] if parts else "" rest = parts[1:] # « app. 201 » collé après la rue : fait partie de l'adresse while rest and _APT_RE.match(rest[0]): address += ", " + rest.pop(0) city = sector = region = "" if rest: muni = rest[0] city = _PAREN_RE.sub("", muni).strip() for p in (p.strip() for p in _PAREN_RE.findall(muni)): if strip_accents(p.lower()).replace(" ", "-") in _REGIONS: region = p elif p: sector = p return address, city, sector, region def parse_sutton_detail(html: str) -> dict: """Fiche Sutton location : loyer, galerie complète, description, chambres, pièces (dimensions), inclusions, caractéristiques, agence, adresse exacte.""" out: dict = {} details: dict = {} # photos : normaliser toute taille -> pic450, dédoublonner par id-index seen, imgs = set(), [] for m in _DET_IMG_RE.finditer(html): key = m.group(1) if key not in seen: seen.add(key) imgs.append(f"https://immo.vrtx.co/pic450/{key}.jpg") if imgs: out["images"] = imgs # adresse exacte (H1 : « 3762 Rue St-Dominique, Montréal (…), H2W0A2 ») mh = _H1_RE.search(html) if mh: h1 = re.sub(r"\s+", " ", _html.unescape(re.sub(r"<[^>]+>", " ", mh.group(1)))).strip() address, city, sector, region = _split_h1(h1) if address and re.match(r"\d", address): details["__adresse"] = address if city: details["__ville"] = city if sector: details["__secteur"] = sector if region: details["Région"] = region text = re.sub(r"\s+", " ", _html.unescape(re.sub(r"<[^>]+>", " ", html))) # loyer : premier « N NNN $ / mois » plausible (100–20 000 $) for m in _RENT_RE.finditer(text): try: val = int(re.sub(r"\s", "", m.group(1))) except ValueError: continue if 100 <= val <= 20000: out["price"] = float(val) out["price_label"] = f"{val:,.0f} $/mois".replace(",", " ") break mb = re.search(r"(\d+)\s*chambres?\b(?!\s*[àa]\s*coucher)", text, re.I) if mb: details["Chambres"] = mb.group(1) ms = re.search(r"(\d+)\s*salles?\s*de\s*bain", text, re.I) if ms: details["Salles de bain"] = ms.group(1) md = _DESC_RE.search(text) if md: desc = md.group(1).strip() if len(desc) > 30: out["description"] = desc[:4000] # aire habitable : « 76.9 MC (828 pi²) » -> pi² ma = _AREA_RE.search(text) if ma: unit = ma.group(2).lower() raw = f"{ma.group(1)} {'m²' if unit in ('mc', 'm2', 'm²') else 'pi²'}" area = parse_area_sqft(raw) if area: out["area_sqft"] = area # pièces : « Nom DimxDim P Revêtement Niveau » rooms = [] for m in _ROOM_ROW_RE.finditer(text): nom = m.group(1).strip(" -") # retirer l'en-tête de tableau qui peut coller au 1er nom nom = re.sub(r"^.*(?:Niveau|Rev[êe]tement|Dimensions?)\s+", "", nom).strip(" -") if not nom or nom.lower() in ("pièce", "piece"): continue rooms.append({"nom": nom[:40], "dimensions": m.group(2).replace(" ", ""), "revetement": m.group(3).strip(), "niveau": m.group(4)}) if rooms: details["pieces"] = rooms[:20] # inclusions -> commodités affichables mi = re.search(r"Inclusions?\s*:?(.*?)(?:Exclusions|Addenda|Financ|$)", text, re.I | re.S) if mi: inc = mi.group(1).strip() feats = [s.strip() for s in re.split(r"[,;•]", inc) if 3 <= len(s.strip()) <= 90] if feats: out["amenities"] = feats[:20] # caractéristiques : repli valeur|libellé (du), écrasé par libellé|valeur flat = du.flatten(html) details.update(du.centris_details(flat)) details.update(_labels_first(flat, _RENT_LABELS)) _drop_section_values(details) # « Cuisine » happé depuis le tableau des pièces (dimensions) : retirer if re.search(r"\d\s*[xX]\s*\d", str(details.get("Cuisine", ""))): details.pop("Cuisine", None) # « 76.9 MC (828 pi » : l'exposant ² coupe la valeur à l'aplatissement if str(details.get("Aire habitable", "")).endswith("pi"): details["Aire habitable"] += "²)" # date d'emménagement / déménagement -> disponibilité — garde-fou : la # valeur doit ressembler à une date/mention de dispo, pas à un bout de # phrase happé du texte libre (« …parties de la ville sont pratiques ») for k in ("Date d'emménagement", "Déménagement", "Disponibilité"): val = str(details.get(k) or "").strip() if val and re.search(r"(?i)\d|imm[ée]diat|maintenant|libre|now" r"|n[ée]gociable|signature|entente", val): out["availability"] = val break if val and k == "Disponibilité" and not re.search(r"\d", val): details.pop(k, None) # valeur happée : on la retire aussi # chambres -> type d'unité (rempli si la fiche liste n'en avait pas) if details.get("Chambres"): try: n = int(details["Chambres"]) out["unit_type"] = "Studio" if n == 0 else f"{n} chambres" except ValueError: pass # courtier + sous-agence (bureau) : carte « divResume courtier » mc = _BROKER_RE.search(html) if mc: details["Courtier"] = _html.unescape(mc.group(1)).strip() mp = _PHONE_RE.search(html[mc.start():mc.start() + 1500]) if mp: details["Téléphone"] = mp.group(0) mo = _AGENCY_RE.search(html) if mo: details["Agence"] = _deslug(mo.group(1)) if details: out["details"] = details return out # une « valeur » qui est en fait un titre de section = extraction décalée _SECTION_VAL_RE = re.compile( r"^(?:Particularit[ée]s|Caract[ée]ristiques|D[ée]tails des|Inclusions" r"|Exclusions|Addenda|Publicit[ée])", re.I) def _labels_first(text: str, labels: list[str]) -> dict: """Extraction « libellé | valeur » (ordre des fiches location Sutton).""" out: dict = {} for label in labels: boundary = r"\b" if label[-1].isalnum() else "" m = re.search(re.escape(label) + boundary + r"\s*\|\s*([^|]{1,55})", text) if m: val = m.group(1).strip(" |") if (val and 1 <= len(val) <= 55 and val.lower() != label.lower() and val not in labels and not _SECTION_VAL_RE.match(val)): out[label] = val return out def _drop_section_values(details: dict) -> None: """Purge les valeurs qui sont des titres de section (repli valeur|libellé de du.centris_details décalé sur les fiches en ordre libellé|valeur).""" for k in [k for k, v in details.items() if isinstance(v, str) and _SECTION_VAL_RE.match(v)]: del details[k]