# ----------------------------------------------------------------------------- # Immo-Ka — Agrégateur de maisons à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/immeubles_stuart.py : Les Immeubles Stuart (immeublesstuart.ca) — # agence indépendante de la Rive-Sud de Montréal (Saint-Lambert, Brossard, # Longueuil…), ~50 inscriptions en vente. # # Site WordPress propulsé par la plateforme « Ma Clé » (photos sur # maclebrokers-photos.s3.amazonaws.com). Liste /proprietes/ rendue serveur, # une seule page : cartes
(data-ville, # data-prix, adresse, MLS Centris, chambres/salles de bain). On ne garde que # les URLs « -a-vendre » (les « -a-louer » sont des locations). # # Fiche détail : JSON-LD RealEstateListing très riche avec extensions mc:* # (caractéristiques, taxes, évaluations, inclusions/exclusions, addenda, # agent nom/téléphone) + galerie S3 ({mls}{n}.jpg — exclure photo_membre). # Pas de GPS sur la fiche (code postal seulement). Enrichissement du.enrich. # # Agence première main (pas d'infixe _ag_) : ses fiches battent les copies # portails au même n° Centris dans la dédup. # ----------------------------------------------------------------------------- from __future__ import annotations import html as _html import json import os import re from .base import BaseConnector from . import _detailutil as du from ..normalize import parse_price from ..schema import PropertyListing SITE = "https://immeublesstuart.ca" LIST_URL = SITE + "/proprietes/" AGENCY = "Les Immeubles Stuart" DETAIL_LIMIT = int(os.environ.get("IMMOKA_STUART_DETAIL_LIMIT", "120")) _CARD_SPLIT = re.compile(r'
([^<]+)<') _META_RE = re.compile(r'
  • \s*(\d+)\s+(chambre|salle)', re.I) _IMG_RE = re.compile(r'src="(https://maclebrokers-photos\.s3\.amazonaws\.com/[^"]+)"') _S3_RE = re.compile(r'https://maclebrokers-photos\.s3\.amazonaws\.com/(?:large_)?photo/[^"\'\s]+\.jpe?g', re.I) class ImmeublesStuartConnector(BaseConnector): source_id = "stuart" request_delay = 0.4 def fetch(self) -> list[PropertyListing]: html = self.get(LIST_URL).text by_id: dict[str, PropertyListing] = {} for blk in _CARD_SPLIT.split(html)[1:]: blk = blk[:4000] hm = _HREF_RE.search(blk) if not hm or "-a-vendre" not in hm.group(1): continue mls = hm.group(2) city = _html.unescape((_VILLE_RE.search(blk) or [None, ""])[1]).strip() am = _ADRESSE_RE.search(blk) address = _html.unescape(am.group(1)).strip() if am else "" pm = _PRIX_RE.search(blk) price = int(pm.group(1)) if pm else None beds = baths = None for n, kind in _META_RE.findall(blk): if kind.lower() == "chambre": beds = int(n) else: baths = int(n) im = _IMG_RE.search(blk) by_id.setdefault(mls, PropertyListing( source=self.source_id, external_id=mls, url=hm.group(1), title=f"Propriété à vendre — {address or city}".strip(" —"), address=address, city=city, price=price, price_label=(f"{price:,} $".replace(",", " ") if price else ""), bedrooms=beds, bathrooms=baths, mls=mls, images=[im.group(1)] if im else [], agency=AGENCY, broker_name=AGENCY, )) listings = list(by_id.values()) du.enrich(self, listings, DETAIL_LIMIT, _parse_detail, key="v1") for lst in listings: # titre enrichi une fois le type connu if lst.property_type and lst.title.startswith("Propriété à vendre"): lst.title = f"{lst.property_type} à vendre — {lst.address or lst.city}".strip(" —") return listings def _parse_detail(html: str) -> dict: """Fiche Ma Clé : JSON-LD RealEstateListing + extensions mc:* + galerie S3.""" node = None for n in du.ld_nodes(html): if n.get("@type") == "RealEstateListing": node = n break if node is None: return {} out: dict = {} details: dict = {} features: list[str] = [] desc = _html.unescape(str(node.get("description") or "")).strip() addenda = _html.unescape(str(node.get("mc:addenda") or "")).strip() if addenda and addenda not in desc: desc = (desc + "\n\n" + addenda).strip() if desc: out["description"] = desc addr = node.get("address") or {} if isinstance(addr, dict): if addr.get("streetAddress"): out["address"] = str(addr["streetAddress"]).strip() if addr.get("addressLocality"): out["city"] = str(addr["addressLocality"]).strip() if addr.get("postalCode"): details["Code postal"] = str(addr["postalCode"]).strip() offers = node.get("offers") or {} if isinstance(offers, dict) and offers.get("price"): try: out["price"] = float(offers["price"]) except (TypeError, ValueError): pass if node.get("yearBuilt"): ym = re.search(r"\b(1[6-9]\d{2}|20\d{2})\b", str(node["yearBuilt"])) if ym: out["year_built"] = int(ym.group(1)) details["Année de construction"] = ym.group(1) lot = node.get("lotSize") or {} if isinstance(lot, dict) and lot.get("value"): try: sq = float(lot["value"]) if str(lot.get("unitCode") or "FTK").upper() == "MTK": sq *= 10.7639 out["lot_sqft"] = round(sq, 1) except (TypeError, ValueError): pass if node.get("mc:type"): out["property_type"] = str(node["mc:type"]).strip() details["Type de propriété"] = out["property_type"] car = node.get("mc:caracteristiques") if isinstance(car, dict): for k, v in car.items(): k, v = str(k).strip(), str(v).strip() if k and v: details[k] = v features.append(f"{k} : {v}" if len(v) < 40 else k) for src, dst in (("mc:inclusions", "Inclusions"), ("mc:exclusions", "Exclusions"), ("mc:eval_batiment", "Évaluation municipale (bâtiment)"), ("mc:eval_terrain", "Évaluation municipale (terrain)"), ("mc:eval_annee", "Évaluation municipale (année)")): v = node.get(src) if v not in (None, ""): v = str(v).strip() if dst.startswith("Évaluation") and v.isdigit(): v = f"{int(v):,} $".replace(",", " ") details[dst] = v taxes = node.get("mc:taxes") if isinstance(taxes, list) and taxes: details["Taxes"] = " ; ".join(str(t).strip() for t in taxes if t) agent = node.get("agent") or {} if isinstance(agent, dict): if agent.get("name"): out["broker_name"] = str(agent["name"]).strip() tel = str(agent.get("telephone") or "").strip() if re.fullmatch(r"\d{10}", tel): tel = f"{tel[:3]} {tel[3:6]}-{tel[6:]}" if tel: out["broker_phone"] = tel # galerie S3 : {mls}{n}.jpg (large_photo + photo), photo_membre = portraits mls = str(node.get("mc:no_inscription") or "") imgs, seen = [], set() for u in _S3_RE.findall(html): name = u.rsplit("/", 1)[-1] if mls and not name.startswith(mls): continue if name not in seen: seen.add(name) imgs.append(u) if imgs: out["images"] = imgs if details: out["details"] = details if features: out["features"] = features return out