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%
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