SPB Git forge

spb/immo-ka

Public

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

112commits 1branches 0releases
125.4 MBsize
maindefault branch
13 days agolast push
Python 47.5% HTML 27.9% TypeScript 15.5% CSS 7.2% JavaScript 2%
7.9 KB · 200 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/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