SPB Git

spb/immo-ka Public

Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)

Python 66.4% TypeScript 19.9% CSS 13.2% HTML 0.5%
13.1 KB · 313 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/sutton.py : Groupe Sutton Québec (suttonquebec.com)5#   Site rendu serveur : chaque page de résultats publie 10 propriétés en6#   JSON-LD schema.org (ItemList), avec prix, chambres, salles de bains,7#   superficie et adresse. Les photos suivent le motif immo.vrtx.co/pic450/{id}.8# -----------------------------------------------------------------------------9from __future__ import annotations1011import html as _html12import os13import re1415from .base import BaseConnector16from . import _detailutil as du17from .jsonld import item_list_elements, sqm_to_sqft18from ..schema import PropertyListing1920BASE = "https://www.suttonquebec.com"21DETAIL_LIMIT = int(os.environ.get("IMMOKA_SUTTON_DETAIL_LIMIT", "400"))22# toutes les photos de la fiche (la liste n'en montre que quelques-unes)23_DET_IMG_RE = re.compile(r'https://immo\.vrtx\.co/pic\d+/(\d+(?:-\d+)?)\.(?:jpg|jpeg|png|webp)', re.I)24_DESC_RE = re.compile(r'Description de la propri[ée]t[ée](?:\s+[àa]\s+vendre)?[^A-Za-z0-9]*(.*?)'25                      r'(?:Caract[ée]ristiques|Pi[èe]ce|Inclusions|Exclusions|Addenda|$)', re.I | re.S)26_ROOM_ROW_RE = re.compile(27    r'([A-ZÀ-Ÿ][^\d|]{2,38}?)\s+(\d[\d.,]*\s*[xX]\s*\d[\d.,]*)\s*P?\s+'28    r'([A-Za-zÀ-ÿ/ -]{3,30}?)\s+(RC|SS|AU|\d[eE]?r?e?)\b')29# Pages de résultats couvrant le résidentiel à vendre au Québec.30LISTING_PAGES = [31    "/fr/maisons-a-vendre.html",32    "/fr/condos-lofts-appartements-a-vendre.html",33    "/fr/chalets-a-vendre.html",34    "/fr/fermes-fermettes-a-vendre.html",35]36MAX_PAGES = 60                         # garde-fou (10 propriétés/page)37IMG_RE = re.compile(r'https://immo\.vrtx\.co/pic\d+/(\d+)(?:-\d+)?\.(?:jpg|jpeg|png|webp)',38                    re.I)39NOINS_RE = re.compile(r'noInscription=(\d+)')4041# Sitemap : couverture EXHAUSTIVE (l'index /sitemap.cfm pointe vers des42# sitemaps d'inscriptions paginés). typeInscription=1 = à vendre, =2 = à louer.43SITEMAP_INDEX = f"{BASE}/sitemap.cfm"44SITEMAP_MAX_PAGES = 40                  # garde-fou (≈12 pages réelles × ~1000)45_SM_SUB_RE = re.compile(r'sitemap_inscription\.cfm\?[^<"\s]+', re.I)46_SM_LOC_RE = re.compile(47    r'https://www\.suttonquebec\.com/fr/inscription/([^<"?]+)\.html\?'48    r'noInscription=(\d+)&(?:amp;)?typeInscription=(\d)', re.I)495051class SuttonConnector(BaseConnector):52    source_id = "sutton"53    request_delay = 0.55455    def fetch(self) -> list[PropertyListing]:56        by_id: dict[str, PropertyListing] = {}57        # 1) couverture exhaustive via le sitemap (adresse/type depuis le slug)58        for slug, centris, url in self._sitemap_listings():59            if centris in by_id:60                continue61            ptype, addr, city = _from_slug(slug)62            by_id[centris] = PropertyListing(63                source=self.source_id, external_id=centris, url=url,64                title=addr or _deslug(slug), address=addr, city=city,65                property_type=ptype, mls=centris,66                broker_name="Groupe Sutton Québec",67            )68        listings = list(by_id.values())69        # fiche détail : prix, chambres/sdb, galerie complète (~80 photos),70        # description, pièces, inclusions, caractéristiques — plafonné, cache71        # accumulé entre cycles (tout le parc finit enrichi au fil des syncs).72        du.enrich(self, listings, DETAIL_LIMIT, parse_sutton_detail, key="v4")73        for lst in listings:74            office = lst.details.pop("__agency", "")75            lst.agency = office or "Groupe Sutton Québec"76        return listings7778    def _sitemap_listings(self):79        """Itère (slug, centris, url) de toutes les inscriptions À VENDRE."""80        try:81            index = self.get(SITEMAP_INDEX).text82        except Exception:83            return84        subs = []85        for s in _SM_SUB_RE.findall(index):86            s = s.replace("&amp;", "&")87            if s not in subs:88                subs.append(s)89        if not subs:                       # repli : pagination directe90            subs = [f"sitemap_inscription.cfm?page={p}&platine=0"91                    for p in range(1, 14)] + ["sitemap_inscription.cfm?platine=1"]92        seen = set()93        for sub in subs[:SITEMAP_MAX_PAGES]:94            try:95                xml = self.get(f"{BASE}/{sub}").text96            except Exception:97                continue98            for m in _SM_LOC_RE.finditer(xml):99                slug, centris, typ = m.group(1), m.group(2), m.group(3)100                if typ != "1" or centris in seen:      # 1 = à vendre uniquement101                    continue102                seen.add(centris)103                url = (f"{BASE}/fr/inscription/{slug}.html"104                       f"?noInscription={centris}&typeInscription=1")105                yield slug, centris, url106107    def _fetch_section(self, path: str, out: dict) -> None:108        page = 1109        while page <= MAX_PAGES:110            url = f"{BASE}{path}?page={page}"111            try:112                html = self.get(url).text113            except Exception:114                break115            elems = item_list_elements(html)116            if not elems:117                break118            images_by_id = self._images_by_id(html)119            new = 0120            for el in elems:121                lst = self._to_listing(el, images_by_id)122                if lst and lst.uid not in out:123                    out[lst.uid] = lst124                    new += 1125            # dernière page : moins d'items que la pleine page, ou rien de neuf126            if len(elems) < 10 or new == 0:127                break128            page += 1129130    @staticmethod131    def _images_by_id(html: str) -> dict[str, list[str]]:132        groups: dict[str, list[str]] = {}133        for m in IMG_RE.finditer(html):134            groups.setdefault(m.group(1), [])135            if m.group(0) not in groups[m.group(1)]:136                groups[m.group(1)].append(m.group(0))137        return groups138139    def _to_listing(self, el: dict, images_by_id: dict) -> PropertyListing | None:140        item = el.get("item", el)141        url = item.get("url") or el.get("url") or ""142        if not url:143            return None144        if url.startswith("fr/") or url.startswith("/fr/"):145            url = f"{BASE}/{url.lstrip('/')}"146        m = NOINS_RE.search(url) or NOINS_RE.search(el.get("url", ""))147        external_id = m.group(1) if m else url.rsplit("/", 1)[-1].split(".")[0]148149        addr = el.get("address") or item.get("address") or {}150        offers = el.get("offers") or item.get("offers") or {}151        price = offers.get("price")152        try:153            price = float(price) if price is not None else None154        except (TypeError, ValueError):155            price = None156157        area = None158        fs = item.get("floorSize") or {}159        if isinstance(fs, dict) and fs.get("value"):160            val = fs["value"]161            # unitCode MTK = m² ; sinon on suppose des pi². Convertit si plausible.162            if fs.get("unitCode") == "MTK" and float(val) < 400:163                area = sqm_to_sqft(val)164            else:165                try:166                    area = round(float(val))167                except (TypeError, ValueError):168                    area = None169170        return PropertyListing(171            source=self.source_id,172            external_id=str(external_id),173            url=url,174            title=(item.get("name") or el.get("name") or "").strip(),175            address=addr.get("streetAddress", ""),176            sector=addr.get("addressLocality", ""),177            city=addr.get("addressLocality", ""),178            region=addr.get("addressRegion", ""),179            property_type=item.get("@type", ""),180            price=price,181            price_label=(f"{price:,.0f} $".replace(",", " ") if price else ""),182            bedrooms=_as_int(item.get("numberOfBedrooms")),183            bathrooms=_as_int(item.get("numberOfBathroomsTotal")),184            area_sqft=area,185            images=images_by_id.get(str(external_id), []),186            broker_name="Groupe Sutton Québec",187        )188189190def _as_int(v):191    try:192        return int(float(v))193    except (TypeError, ValueError):194        return None195196197def _deslug(s: str) -> str:198    return re.sub(r"\s+", " ", s.replace("-", " ")).strip().title()199200201# villes/arrondissements composés fréquents dans les slugs Sutton (fin du slug)202_CITY_HINTS = (203    "montreal-le-plateau-mont-royal", "montreal-ville-marie", "montreal-rosemont-la-petite-patrie",204    "montreal-le-sud-ouest", "montreal-cote-des-neiges-notre-dame-de-grace", "montreal-ahuntsic-cartierville",205    "montreal-villeray-saint-michel-parc-extension", "montreal-mercier-hochelaga-maisonneuve",206    "montreal-riviere-des-prairies-pointe-aux-trembles", "montreal-verdun-ile-des-soeurs",207    "montreal-saint-laurent", "montreal-lasalle", "montreal-outremont", "montreal-anjou",208    "montreal-pierrefonds-roxboro", "trois-rivieres", "saint-jean-sur-richelieu",209    "salaberry-de-valleyfield", "mont-saint-hilaire", "notre-dame-de-l-ile-perrot",210    "vaudreuil-dorion", "sainte-adele", "sainte-therese", "saint-jerome", "levis", "quebec",211    "laval", "gatineau", "longueuil", "brossard", "sherbrooke", "granby", "magog",212)213214215def _from_slug(slug: str) -> tuple[str, str, str]:216    """« maison-de-plain-pied-a-vendre-522-cedar-street-beaconsfield »217    -> (type, adresse, ville). Best-effort (l'adresse exacte vient de la fiche)."""218    m = re.search(r"^(.*?)-a-vendre-(.+)$", slug, re.I)219    if not m:220        return "", _deslug(slug), ""221    ptype = _deslug(m.group(1))222    rest = m.group(2).lower()223    city = ""224    for hint in _CITY_HINTS:225        if rest.endswith("-" + hint) or rest == hint:226            city = _deslug(hint)227            rest = rest[: -len(hint)].rstrip("-")228            break229    else:230        # sinon : dernier token comme ville (approx ; corrigé par la fiche détail)231        parts = rest.rsplit("-", 1)232        if len(parts) == 2 and not parts[1].isdigit():233            city, rest = _deslug(parts[1]), parts[0]234    return ptype, _deslug(rest), city235236237def _merge(dst: PropertyListing, src: PropertyListing) -> None:238    """Complète dst (issu du sitemap) avec les champs riches de src (JSON-LD)."""239    for f in ("price", "bedrooms", "bathrooms", "area_sqft"):240        if getattr(dst, f, None) in (None, 0) and getattr(src, f, None) not in (None, 0):241            setattr(dst, f, getattr(src, f))242    for f in ("address", "city", "sector", "region", "price_label", "property_type"):243        if not getattr(dst, f, "") and getattr(src, f, ""):244            setattr(dst, f, getattr(src, f))245    if len(src.images) > len(dst.images):246        dst.images = src.images247248249def parse_sutton_detail(html: str) -> dict:250    """Fiche Sutton : galerie complète, description, pièces (dimensions), inclusions."""251    out: dict = {}252    # photos : normaliser toute taille -> pic450, dédoublonner par id-index253    seen, imgs = {}, []254    for m in _DET_IMG_RE.finditer(html):255        key = m.group(1)256        if key not in seen:257            seen[key] = 1258            imgs.append(f"https://immo.vrtx.co/pic450/{key}.jpg")259    if imgs:260        out["images"] = imgs261262    text = re.sub(r"\s+", " ", _html.unescape(re.sub(r"<[^>]+>", " ", html)))263264    # prix : premier montant du texte (le prix demandé précède évaluation/taxes)265    for mnt in re.findall(r"([\d][\d  ]{3,})\s*\$", text):266        val = _as_int(mnt.replace(" ", "").replace("\xa0", ""))267        if val and val >= 20000:268            out["price"] = float(val)269            out["price_label"] = f"{val:,.0f} $".replace(",", " ")270            break271    mb = re.search(r"(\d+)\s*chambre", text, re.I)272    if mb:273        out["bedrooms"] = int(mb.group(1))274    ms = re.search(r"(\d+)\s*salle[s]?\s*de\s*bain", text, re.I)275    if ms:276        out["bathrooms"] = int(ms.group(1))277278    md = _DESC_RE.search(text)279    if md:280        desc = md.group(1).strip()281        if len(desc) > 30:282            out["description"] = desc[:4000]283284    # pièces : « Nom  DimxDim P  Revêtement  Niveau »285    rooms = []286    for m in _ROOM_ROW_RE.finditer(text):287        nom = m.group(1).strip(" -")288        # retirer l'en-tête de tableau qui peut coller au 1er nom289        nom = re.sub(r"^.*?(?:Niveau|Rev[êe]tement|Dimension)\s+", "", nom).strip(" -")290        if not nom or nom.lower() in ("pièce", "piece"):291            continue292        rooms.append({"nom": nom[:40], "dimensions": m.group(2).replace(" ", ""),293                      "revetement": m.group(3).strip(), "niveau": m.group(4)})294    if rooms:295        out.setdefault("details", {})["pieces"] = rooms[:20]296297    # inclusions298    mi = re.search(r"Inclusions?\s*:?(.*?)(?:Exclusions|Addenda|Financ|$)", text, re.I | re.S)299    if mi:300        inc = mi.group(1).strip()301        feats = [s.strip() for s in re.split(r"[,;•]", inc) if 3 <= len(s.strip()) <= 90]302        if feats:303            out["features"] = feats[:20]304    _det = du.centris_details(du.flatten(html))305    if _det:306        out.setdefault("details", {}).update(_det)307308    # sous-agence (bureau) : lien /agence-immobiliere/NNN-<slug>.html309    ma = re.search(r'/agence-immobiliere/\d+-([a-z0-9-]+)\.html', html, re.I)310    if ma:311        out.setdefault("details", {})["__agency"] = _deslug(ma.group(1))312    return out313