Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)
Python 47.5%
HTML 27.9%
TypeScript 15.5%
CSS 7.2%
JavaScript 2%
1# -----------------------------------------------------------------------------2# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/immeubles_stuart.py : Les Immeubles Stuart (immeublesstuart.ca) —5# agence indépendante de la Rive-Sud de Montréal (Saint-Lambert, Brossard,6# Longueuil…), ~50 inscriptions en vente.7#8# Site WordPress propulsé par la plateforme « Ma Clé » (photos sur9# maclebrokers-photos.s3.amazonaws.com). Liste /proprietes/ rendue serveur,10# une seule page : cartes <article class="propriete-card"> (data-ville,11# data-prix, adresse, MLS Centris, chambres/salles de bain). On ne garde que12# les URLs « -a-vendre » (les « -a-louer » sont des locations).13#14# Fiche détail : JSON-LD RealEstateListing très riche avec extensions mc:*15# (caractéristiques, taxes, évaluations, inclusions/exclusions, addenda,16# agent nom/téléphone) + galerie S3 ({mls}{n}.jpg — exclure photo_membre).17# Pas de GPS sur la fiche (code postal seulement). Enrichissement du.enrich.18#19# Agence première main (pas d'infixe _ag_) : ses fiches battent les copies20# portails au même n° Centris dans la dédup.21# -----------------------------------------------------------------------------22from __future__ import annotations2324import html as _html25import json26import os27import re2829from .base import BaseConnector30from . import _detailutil as du31from ..normalize import parse_price32from ..schema import PropertyListing3334SITE = "https://immeublesstuart.ca"35LIST_URL = SITE + "/proprietes/"36AGENCY = "Les Immeubles Stuart"37DETAIL_LIMIT = int(os.environ.get("IMMOKA_STUART_DETAIL_LIMIT", "120"))3839_CARD_SPLIT = re.compile(r'<article class="propriete-card')40_HREF_RE = re.compile(r'href="(https?://immeublesstuart\.ca/proprietes/[^"]*--(\d{6,10})/?)"')41_VILLE_RE = re.compile(r'data-ville="([^"]*)"')42_PRIX_RE = re.compile(r'data-prix="(\d+)"')43_ADRESSE_RE = re.compile(r'propriete-card-adresse">([^<]+)<')44_META_RE = re.compile(r'<li>\s*(\d+)\s+(chambre|salle)', re.I)45_IMG_RE = re.compile(r'src="(https://maclebrokers-photos\.s3\.amazonaws\.com/[^"]+)"')46_S3_RE = re.compile(r'https://maclebrokers-photos\.s3\.amazonaws\.com/(?:large_)?photo/[^"\'\s]+\.jpe?g', re.I)474849class ImmeublesStuartConnector(BaseConnector):50 source_id = "stuart"51 request_delay = 0.45253 def fetch(self) -> list[PropertyListing]:54 html = self.get(LIST_URL).text55 by_id: dict[str, PropertyListing] = {}56 for blk in _CARD_SPLIT.split(html)[1:]:57 blk = blk[:4000]58 hm = _HREF_RE.search(blk)59 if not hm or "-a-vendre" not in hm.group(1):60 continue61 mls = hm.group(2)62 city = _html.unescape((_VILLE_RE.search(blk) or [None, ""])[1]).strip()63 am = _ADRESSE_RE.search(blk)64 address = _html.unescape(am.group(1)).strip() if am else ""65 pm = _PRIX_RE.search(blk)66 price = int(pm.group(1)) if pm else None67 beds = baths = None68 for n, kind in _META_RE.findall(blk):69 if kind.lower() == "chambre":70 beds = int(n)71 else:72 baths = int(n)73 im = _IMG_RE.search(blk)74 by_id.setdefault(mls, PropertyListing(75 source=self.source_id, external_id=mls,76 url=hm.group(1),77 title=f"Propriété à vendre — {address or city}".strip(" —"),78 address=address, city=city,79 price=price,80 price_label=(f"{price:,} $".replace(",", " ") if price else ""),81 bedrooms=beds, bathrooms=baths, mls=mls,82 images=[im.group(1)] if im else [],83 agency=AGENCY, broker_name=AGENCY,84 ))85 listings = list(by_id.values())86 du.enrich(self, listings, DETAIL_LIMIT, _parse_detail, key="v1")87 for lst in listings: # titre enrichi une fois le type connu88 if lst.property_type and lst.title.startswith("Propriété à vendre"):89 lst.title = f"{lst.property_type} à vendre — {lst.address or lst.city}".strip(" —")90 return listings919293def _parse_detail(html: str) -> dict:94 """Fiche Ma Clé : JSON-LD RealEstateListing + extensions mc:* + galerie S3."""95 node = None96 for n in du.ld_nodes(html):97 if n.get("@type") == "RealEstateListing":98 node = n99 break100 if node is None:101 return {}102 out: dict = {}103 details: dict = {}104 features: list[str] = []105106 desc = _html.unescape(str(node.get("description") or "")).strip()107 addenda = _html.unescape(str(node.get("mc:addenda") or "")).strip()108 if addenda and addenda not in desc:109 desc = (desc + "\n\n" + addenda).strip()110 if desc:111 out["description"] = desc112113 addr = node.get("address") or {}114 if isinstance(addr, dict):115 if addr.get("streetAddress"):116 out["address"] = str(addr["streetAddress"]).strip()117 if addr.get("addressLocality"):118 out["city"] = str(addr["addressLocality"]).strip()119 if addr.get("postalCode"):120 details["Code postal"] = str(addr["postalCode"]).strip()121122 offers = node.get("offers") or {}123 if isinstance(offers, dict) and offers.get("price"):124 try:125 out["price"] = float(offers["price"])126 except (TypeError, ValueError):127 pass128129 if node.get("yearBuilt"):130 ym = re.search(r"\b(1[6-9]\d{2}|20\d{2})\b", str(node["yearBuilt"]))131 if ym:132 out["year_built"] = int(ym.group(1))133 details["Année de construction"] = ym.group(1)134135 lot = node.get("lotSize") or {}136 if isinstance(lot, dict) and lot.get("value"):137 try:138 sq = float(lot["value"])139 if str(lot.get("unitCode") or "FTK").upper() == "MTK":140 sq *= 10.7639141 out["lot_sqft"] = round(sq, 1)142 except (TypeError, ValueError):143 pass144145 if node.get("mc:type"):146 out["property_type"] = str(node["mc:type"]).strip()147 details["Type de propriété"] = out["property_type"]148149 car = node.get("mc:caracteristiques")150 if isinstance(car, dict):151 for k, v in car.items():152 k, v = str(k).strip(), str(v).strip()153 if k and v:154 details[k] = v155 features.append(f"{k} : {v}" if len(v) < 40 else k)156157 for src, dst in (("mc:inclusions", "Inclusions"),158 ("mc:exclusions", "Exclusions"),159 ("mc:eval_batiment", "Évaluation municipale (bâtiment)"),160 ("mc:eval_terrain", "Évaluation municipale (terrain)"),161 ("mc:eval_annee", "Évaluation municipale (année)")):162 v = node.get(src)163 if v not in (None, ""):164 v = str(v).strip()165 if dst.startswith("Évaluation") and v.isdigit():166 v = f"{int(v):,} $".replace(",", " ")167 details[dst] = v168 taxes = node.get("mc:taxes")169 if isinstance(taxes, list) and taxes:170 details["Taxes"] = " ; ".join(str(t).strip() for t in taxes if t)171172 agent = node.get("agent") or {}173 if isinstance(agent, dict):174 if agent.get("name"):175 out["broker_name"] = str(agent["name"]).strip()176 tel = str(agent.get("telephone") or "").strip()177 if re.fullmatch(r"\d{10}", tel):178 tel = f"{tel[:3]} {tel[3:6]}-{tel[6:]}"179 if tel:180 out["broker_phone"] = tel181182 # galerie S3 : {mls}{n}.jpg (large_photo + photo), photo_membre = portraits183 mls = str(node.get("mc:no_inscription") or "")184 imgs, seen = [], set()185 for u in _S3_RE.findall(html):186 name = u.rsplit("/", 1)[-1]187 if mls and not name.startswith(mls):188 continue189 if name not in seen:190 seen.add(name)191 imgs.append(u)192 if imgs:193 out["images"] = imgs194195 if details:196 out["details"] = details197 if features:198 out["features"] = features199 return out200