# ----------------------------------------------------------------------------- # Immo-Ka — Agrégateur de maisons à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/sutton.py : Groupe Sutton Québec (suttonquebec.com) # Site rendu serveur : chaque page de résultats publie 10 propriétés en # JSON-LD schema.org (ItemList), avec prix, chambres, salles de bains, # superficie et adresse. Les photos suivent le motif immo.vrtx.co/pic450/{id}. # ----------------------------------------------------------------------------- from __future__ import annotations import html as _html import os import re from .base import BaseConnector from . import _detailutil as du from .jsonld import item_list_elements, sqm_to_sqft from ..schema import PropertyListing BASE = "https://www.suttonquebec.com" DETAIL_LIMIT = int(os.environ.get("IMMOKA_SUTTON_DETAIL_LIMIT", "400")) # toutes les photos de la fiche (la liste n'en montre que quelques-unes) _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)?[^A-Za-z0-9]*(.*?)' r'(?:Caract[ée]ristiques|Pi[èe]ce|Inclusions|Exclusions|Addenda|$)', re.I | re.S) _ROOM_ROW_RE = re.compile( r'([A-ZÀ-Ÿ][^\d|]{2,38}?)\s+(\d[\d.,]*\s*[xX]\s*\d[\d.,]*)\s*P?\s+' r'([A-Za-zÀ-ÿ/ -]{3,30}?)\s+(RC|SS|AU|\d[eE]?r?e?)\b') # Pages de résultats couvrant le résidentiel à vendre au Québec. LISTING_PAGES = [ "/fr/maisons-a-vendre.html", "/fr/condos-lofts-appartements-a-vendre.html", "/fr/chalets-a-vendre.html", "/fr/fermes-fermettes-a-vendre.html", ] MAX_PAGES = 60 # garde-fou (10 propriétés/page) IMG_RE = re.compile(r'https://immo\.vrtx\.co/pic\d+/(\d+)(?:-\d+)?\.(?:jpg|jpeg|png|webp)', re.I) NOINS_RE = re.compile(r'noInscription=(\d+)') # Sitemap : couverture EXHAUSTIVE (l'index /sitemap.cfm pointe vers des # sitemaps d'inscriptions paginés). typeInscription=1 = à vendre, =2 = à louer. SITEMAP_INDEX = f"{BASE}/sitemap.cfm" SITEMAP_MAX_PAGES = 40 # garde-fou (≈12 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) class SuttonConnector(BaseConnector): source_id = "sutton" request_delay = 0.5 def fetch(self) -> list[PropertyListing]: by_id: dict[str, PropertyListing] = {} # 1) couverture exhaustive via le sitemap (adresse/type depuis le slug) for slug, centris, url in self._sitemap_listings(): if centris in by_id: continue ptype, addr, city = _from_slug(slug) by_id[centris] = PropertyListing( source=self.source_id, external_id=centris, url=url, title=addr or _deslug(slug), address=addr, city=city, property_type=ptype, mls=centris, broker_name="Groupe Sutton Québec", ) listings = list(by_id.values()) # fiche détail : prix, chambres/sdb, galerie complète (~80 photos), # description, pièces, inclusions, caractéristiques — plafonné, cache # accumulé entre cycles (tout le parc finit enrichi au fil des syncs). du.enrich(self, listings, DETAIL_LIMIT, parse_sutton_detail, key="v4") for lst in listings: office = lst.details.pop("__agency", "") lst.agency = office or "Groupe Sutton Québec" return listings def _sitemap_listings(self): """Itère (slug, centris, url) de toutes les inscriptions À VENDRE.""" 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 != "1" or centris in seen: # 1 = à vendre uniquement continue seen.add(centris) url = (f"{BASE}/fr/inscription/{slug}.html" f"?noInscription={centris}&typeInscription=1") yield slug, centris, url def _fetch_section(self, path: str, out: dict) -> None: page = 1 while page <= MAX_PAGES: url = f"{BASE}{path}?page={page}" try: html = self.get(url).text except Exception: break elems = item_list_elements(html) if not elems: break images_by_id = self._images_by_id(html) new = 0 for el in elems: lst = self._to_listing(el, images_by_id) if lst and lst.uid not in out: out[lst.uid] = lst new += 1 # dernière page : moins d'items que la pleine page, ou rien de neuf if len(elems) < 10 or new == 0: break page += 1 @staticmethod def _images_by_id(html: str) -> dict[str, list[str]]: groups: dict[str, list[str]] = {} for m in IMG_RE.finditer(html): groups.setdefault(m.group(1), []) if m.group(0) not in groups[m.group(1)]: groups[m.group(1)].append(m.group(0)) return groups def _to_listing(self, el: dict, images_by_id: dict) -> PropertyListing | None: item = el.get("item", el) url = item.get("url") or el.get("url") or "" if not url: return None if url.startswith("fr/") or url.startswith("/fr/"): url = f"{BASE}/{url.lstrip('/')}" m = NOINS_RE.search(url) or NOINS_RE.search(el.get("url", "")) external_id = m.group(1) if m else url.rsplit("/", 1)[-1].split(".")[0] addr = el.get("address") or item.get("address") or {} offers = el.get("offers") or item.get("offers") or {} price = offers.get("price") try: price = float(price) if price is not None else None except (TypeError, ValueError): price = None area = None fs = item.get("floorSize") or {} if isinstance(fs, dict) and fs.get("value"): val = fs["value"] # unitCode MTK = m² ; sinon on suppose des pi². Convertit si plausible. if fs.get("unitCode") == "MTK" and float(val) < 400: area = sqm_to_sqft(val) else: try: area = round(float(val)) except (TypeError, ValueError): area = None return PropertyListing( source=self.source_id, external_id=str(external_id), url=url, title=(item.get("name") or el.get("name") or "").strip(), address=addr.get("streetAddress", ""), sector=addr.get("addressLocality", ""), city=addr.get("addressLocality", ""), region=addr.get("addressRegion", ""), property_type=item.get("@type", ""), price=price, price_label=(f"{price:,.0f} $".replace(",", " ") if price else ""), bedrooms=_as_int(item.get("numberOfBedrooms")), bathrooms=_as_int(item.get("numberOfBathroomsTotal")), area_sqft=area, images=images_by_id.get(str(external_id), []), broker_name="Groupe Sutton Québec", ) def _as_int(v): try: return int(float(v)) except (TypeError, ValueError): return None 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-pierrefonds-roxboro", "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", ) def _from_slug(slug: str) -> tuple[str, str, str]: """« maison-de-plain-pied-a-vendre-522-cedar-street-beaconsfield » -> (type, adresse, ville). Best-effort (l'adresse exacte vient de la fiche).""" m = re.search(r"^(.*?)-a-vendre-(.+)$", slug, re.I) if not m: return "", _deslug(slug), "" ptype = _deslug(m.group(1)) rest = m.group(2).lower() city = "" for hint in _CITY_HINTS: if rest.endswith("-" + hint) or rest == hint: city = _deslug(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 = _deslug(parts[1]), parts[0] return ptype, _deslug(rest), city def _merge(dst: PropertyListing, src: PropertyListing) -> None: """Complète dst (issu du sitemap) avec les champs riches de src (JSON-LD).""" for f in ("price", "bedrooms", "bathrooms", "area_sqft"): if getattr(dst, f, None) in (None, 0) and getattr(src, f, None) not in (None, 0): setattr(dst, f, getattr(src, f)) for f in ("address", "city", "sector", "region", "price_label", "property_type"): if not getattr(dst, f, "") and getattr(src, f, ""): setattr(dst, f, getattr(src, f)) if len(src.images) > len(dst.images): dst.images = src.images def parse_sutton_detail(html: str) -> dict: """Fiche Sutton : galerie complète, description, pièces (dimensions), inclusions.""" out: dict = {} # photos : normaliser toute taille -> pic450, dédoublonner par id-index seen, imgs = {}, [] for m in _DET_IMG_RE.finditer(html): key = m.group(1) if key not in seen: seen[key] = 1 imgs.append(f"https://immo.vrtx.co/pic450/{key}.jpg") if imgs: out["images"] = imgs text = re.sub(r"\s+", " ", _html.unescape(re.sub(r"<[^>]+>", " ", html))) # prix : premier montant du texte (le prix demandé précède évaluation/taxes) for mnt in re.findall(r"([\d][\d ]{3,})\s*\$", text): val = _as_int(mnt.replace(" ", "").replace("\xa0", "")) if val and val >= 20000: out["price"] = float(val) out["price_label"] = f"{val:,.0f} $".replace(",", " ") break mb = re.search(r"(\d+)\s*chambre", text, re.I) if mb: out["bedrooms"] = int(mb.group(1)) ms = re.search(r"(\d+)\s*salle[s]?\s*de\s*bain", text, re.I) if ms: out["bathrooms"] = int(ms.group(1)) md = _DESC_RE.search(text) if md: desc = md.group(1).strip() if len(desc) > 30: out["description"] = desc[:4000] # 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|Dimension)\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: out.setdefault("details", {})["pieces"] = rooms[:20] # inclusions 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["features"] = feats[:20] _det = du.centris_details(du.flatten(html)) if _det: out.setdefault("details", {}).update(_det) # sous-agence (bureau) : lien /agence-immobiliere/NNN-.html ma = re.search(r'/agence-immobiliere/\d+-([a-z0-9-]+)\.html', html, re.I) if ma: out.setdefault("details", {})["__agency"] = _deslug(ma.group(1)) return out