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%
6.2 KB · 149 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/expert_immobilier_pm.py : L'Expert Immobilier P.M. (Laval, QC)5#   Vrai site : expertimmobilierpm.com (WordPress + plugin mw-properties2, la6#   même plateforme marketingwebsites.ca que KW Urbain). NB : l'ancienne URL du7#   registre (lexpertimmobilier.com) était une agence de Casablanca — corrigée.8#   La page /proprietes/ rend les fiches côté serveur (n° Centris dans l'URL) ;9#   la fiche détail fournit la galerie complète (marketingwebsites) + description.10# -----------------------------------------------------------------------------11from __future__ import annotations1213import html as _html14import os15import re16from urllib.parse import unquote_plus1718from .base import BaseConnector19from . import _detailutil as du20from ..normalize import parse_price21from ..schema import PropertyListing2223BASE = "https://expertimmobilierpm.com"24LISTINGS = f"{BASE}/proprietes/"25DETAIL_LIMIT = int(os.environ.get("IMMOKA_EPM_DETAIL_LIMIT", "300"))2627# lien fiche : /properties/{Adresse+Avec+Plus}/{no-centris}28_LINK_RE = re.compile(r'/properties/([^"/]+)/(\d{6,})')29_IMG_RE = re.compile(30    r'https://realestate\.marketingwebsites\.ca/property-images/\d+/[^"\'\\ ]+\.(?:jpg|jpeg|png|webp)',31    re.I)32_META_DESC_RE = re.compile(r'<meta\s+name="description"\s+content="([^"]{40,})"', re.I)3334_TYPE_MAP = {35    "plain-pied": "Maison", "à étages": "Maison", "a etages": "Maison",36    "à paliers": "Maison", "mobile": "Maison mobile", "cottage": "Maison",37    "appartement": "Condo", "condo": "Condo", "copropriété": "Condo", "loft": "Condo",38    "duplex": "Duplex", "triplex": "Triplex", "quadruplex": "Multiplex",39    "quintuplex": "Multiplex", "plex": "Multiplex", "revenus": "Multiplex",40    "terrain": "Terrain", "fermette": "Fermette/Agricole", "ferme": "Fermette/Agricole",41    "chalet": "Chalet", "commercial": "Commercial", "commerciale": "Commercial",42}434445def _norm_type(txt: str) -> str:46    k = txt.lower()47    for key, canon in _TYPE_MAP.items():48        if key in k:49            return canon50    return ""515253class ExpertImmobilierPmConnector(BaseConnector):54    source_id = "expert_immobilier_pm"55    request_delay = 0.65657    def fetch(self) -> list[PropertyListing]:58        try:59            html = self.get(LISTINGS).text60        except Exception:61            return []62        # premières positions de chaque n° Centris (les liens titre+image se63        # répètent : on garde la 1re occurrence de chaque fiche, dans l'ordre)64        firsts: list[tuple[str, str, int]] = []   # (addr_slug, centris, index)65        seen: set[str] = set()66        for m in _LINK_RE.finditer(html):67            c = m.group(2)68            if c not in seen:69                seen.add(c)70                firsts.append((m.group(1), c, m.start()))71        by_id: dict[str, PropertyListing] = {}72        for i, (addr_slug, centris, idx) in enumerate(firsts):73            end = firsts[i + 1][2] if i + 1 < len(firsts) else len(html)74            by_id[centris] = self._to_listing(addr_slug, centris, html[idx:end])75        listings = list(by_id.values())76        du.enrich(self, listings, DETAIL_LIMIT, parse_epm_detail, key="v2")77        return listings7879    def _to_listing(self, addr_slug: str, centris: str, block: str) -> PropertyListing:80        address = unquote_plus(addr_slug).strip()81        flat = _html.unescape(re.sub(r"<[^>]+>", " | ", block))82        flat = re.sub(r"\s*\|\s*", " | ", flat)83        flat = re.sub(r"[ \t]+", " ", flat)84        # tout est dans la carte : type | # centris | adresse | ville | N Chambres85        #   | N Bains | S MC Habitable | $prix86        mt = re.search(r"([A-Za-zÀ-ÿ'’ -]{4,40})\s*(?:\|\s*)+#\s*" + centris, flat)87        ptype = _norm_type(mt.group(1)) if mt else ""88        city = ""89        mc = re.search(re.escape(address) + r"\s*(?:\|\s*)+([A-Za-zÀ-ÿ'()/. -]{3,45}?)\s*\|",90                       flat)91        if mc:92            city = mc.group(1).strip(" |")93        beds = re.search(r"\|\s*(\d{1,2})\s*\|\s*Chambres?", flat)94        baths = re.search(r"\|\s*(\d{1,2})\s*\|\s*Bains?", flat)95        area = re.search(r"([\d.,]+)\s*MC\s*\|\s*Habitable", flat, re.I)96        area_sqft = None97        if area:98            try:99                area_sqft = round(float(area.group(1).replace(",", ".")) * 10.7639)100            except ValueError:101                pass102        mprice = re.search(r"\$\s*([\d  ,]{4,})", flat)103        price = parse_price(mprice.group(0)) if mprice else None104        thumb = _IMG_RE.search(block)105        return PropertyListing(106            source=self.source_id,107            external_id=centris,108            url=f"{BASE}/properties/{addr_slug}/{centris}",109            title=address,110            address=address,111            city=city,112            region="",113            property_type=ptype,114            price=price,115            price_label=(mprice.group(0).strip() if mprice else ""),116            bedrooms=int(beds.group(1)) if beds else None,117            bathrooms=int(baths.group(1)) if baths else None,118            area_sqft=area_sqft,119            mls=centris,120            images=[thumb.group(0)] if thumb else [],121            broker_name="L'Expert Immobilier P.M.",122        )123124125def parse_epm_detail(html: str) -> dict:126    """Fiche détail : galerie complète (marketingwebsites), description, ch/sdb, prix."""127    out: dict = {}128    seen, imgs = set(), []129    for u in _IMG_RE.findall(html):130        base = re.sub(r"/thumbs-\d+/", "/", u)   # préférer la pleine résolution131        if base not in seen:132            seen.add(base)133            imgs.append(base)134    if imgs:135        out["images"] = imgs136    md = _META_DESC_RE.search(html)137    if md:138        out["description"] = _html.unescape(md.group(1)).strip()139    # prix : uniquement un montant plausible suivi de $ (chambres/sdb viennent140    # de la carte, plus fiables que le texte aplati de la fiche)141    t = re.sub(r"\s+", " ", _html.unescape(re.sub(r"<[^>]+>", " ", html)))142    for m in re.finditer(r"([\d][\d  ]{4,})\s*\$", t):143        p = parse_price(m.group(0))144        if p and p >= 20000:145            out["price"] = p146            out["price_label"] = m.group(0).strip()147            break148    return out149