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%
4.8 KB · 139 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/m_immobilier.py : M Immobilier (mimmobilier.com)5#   Agence indépendante de prestige (Grand Montréal), inscriptions Centris.6#   La page /properties est rendue serveur : chaque carte « cardProperty »7#   porte l'URL (avec no Centris), la ville, l'adresse, chambres, salles de8#   bains, prix et les photos (/images/centris-slideshow/{id}-N-*.jpg).9# -----------------------------------------------------------------------------10from __future__ import annotations1112import html as _html13import os14import re1516from .base import BaseConnector17from . import _detailutil as du18from ..normalize import parse_price19from ..schema import PropertyListing2021BASE = "https://www.mimmobilier.com"22LISTING_URL = f"{BASE}/properties"23CARD_SPLIT = "cardProperty col-span-12"24HREF_RE = re.compile(r'href="(/properties/[^"]+/(\d+))"')25IMG_RE = re.compile(r'(/images/centris-slideshow/\d+-\d+-\d+\.(?:jpg|jpeg|png|webp))', re.I)26DETAIL_LIMIT = int(os.environ.get("IMMOKA_M_DETAIL_LIMIT", "300"))272829class MImmobilierConnector(BaseConnector):30    source_id = "m_immobilier"31    request_delay = 0.632    use_detail_cache = False3334    def fetch(self) -> list[PropertyListing]:35        html = self.get(LISTING_URL).text36        cards = html.split(CARD_SPLIT)[1:]37        out: dict[str, PropertyListing] = {}38        for card in cards:39            card = CARD_SPLIT + card[:6000]40            lst = self._parse_card(card)41            if lst and lst.uid not in out:42                out[lst.uid] = lst43        listings = list(out.values())44        # fiche détail : galerie complète (135 photos) + description + type45        du.enrich(self, listings, DETAIL_LIMIT, parse_m_detail, key="v2")46        return listings4748    def _parse_card(self, card: str) -> PropertyListing | None:49        m = HREF_RE.search(card)50        if not m:51            return None52        url = BASE + m.group(1)53        external_id = m.group(2)5455        images = []56        for im in IMG_RE.findall(card):57            full = BASE + im58            if full not in images:59                images.append(full)6061        # texte du carton, ligne par ligne62        text = _html.unescape(re.sub(r"<[^>]+>", "\n", card))63        lines = [l.strip() for l in text.splitlines() if l.strip()]6465        status_txt = lines[1] if len(lines) > 1 else ""66        # ville et adresse : les 2 lignes après le statut « À vendre »67        city = address = ""68        try:69            k = next(i for i, l in enumerate(lines)70                     if l.lower().startswith(("à vendre", "a vendre", "vendu")))71            status_txt = lines[k]72            city = lines[k + 1] if k + 1 < len(lines) else ""73            address = lines[k + 2] if k + 2 < len(lines) else ""74        except StopIteration:75            pass7677        price_label = _after(lines, "prix")78        if price_label and "/ m" in price_label.lower():79            return None                    # location, pas une vente80        price = parse_price(price_label or "")81        beds = _int(_after(lines, "chambres"))82        baths = _int(_after(lines, "salles de bains"))8384        vendu = "vendu" in status_txt.lower()85        return PropertyListing(86            source=self.source_id,87            external_id=external_id,88            url=url,89            title=f"{address}, {city}".strip(", "),90            address=address,91            city=city,92            property_type="",              # non exposé sur le carton93            price=price,94            price_label=price_label or "",95            bedrooms=beds,96            bathrooms=baths,97            mls=external_id,98            status="vendu" if vendu else "a-vendre",99            images=images,100            broker_name="M Immobilier",101        )102103104def parse_m_detail(html: str) -> dict:105    """Galerie Centris complète + description (JSON-LD) + caractéristiques."""106    out: dict = {}107    imgs = []108    seen = set()109    for im in IMG_RE.findall(html):110        full = BASE + im111        if full not in seen:112            seen.add(full)113            imgs.append(full)114    if imgs:115        out["images"] = imgs116    desc = du.ld_description(html)117    if desc:118        out["description"] = desc119    _det = du.centris_details(du.flatten(html))120    if _det:121        out.setdefault("details", {}).update(_det)122    return out123124125def _after(lines: list[str], label: str) -> str:126    lab = label.lower()127    for i, l in enumerate(lines):128        if l.lower() == lab and i + 1 < len(lines):129            return lines[i + 1]130    return ""131132133def _int(s: str):134    if not s:135        return None136    # « 4 + 1 » -> 5 (chambres principales + sous-sol)137    nums = [int(x) for x in re.findall(r"\d+", s)]138    return sum(nums) if nums else None139