# ----------------------------------------------------------------------------- # Immo-Ka — Agrégateur de maisons à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/expert_immobilier_pm.py : L'Expert Immobilier P.M. (Laval, QC) # Vrai site : expertimmobilierpm.com (WordPress + plugin mw-properties2, la # même plateforme marketingwebsites.ca que KW Urbain). NB : l'ancienne URL du # registre (lexpertimmobilier.com) était une agence de Casablanca — corrigée. # La page /proprietes/ rend les fiches côté serveur (n° Centris dans l'URL) ; # la fiche détail fournit la galerie complète (marketingwebsites) + description. # ----------------------------------------------------------------------------- from __future__ import annotations import html as _html import os import re from urllib.parse import unquote_plus from .base import BaseConnector from . import _detailutil as du from ..normalize import parse_price from ..schema import PropertyListing BASE = "https://expertimmobilierpm.com" LISTINGS = f"{BASE}/proprietes/" DETAIL_LIMIT = int(os.environ.get("IMMOKA_EPM_DETAIL_LIMIT", "300")) # lien fiche : /properties/{Adresse+Avec+Plus}/{no-centris} _LINK_RE = re.compile(r'/properties/([^"/]+)/(\d{6,})') _IMG_RE = re.compile( r'https://realestate\.marketingwebsites\.ca/property-images/\d+/[^"\'\\ ]+\.(?:jpg|jpeg|png|webp)', re.I) _META_DESC_RE = re.compile(r' str: k = txt.lower() for key, canon in _TYPE_MAP.items(): if key in k: return canon return "" class ExpertImmobilierPmConnector(BaseConnector): source_id = "expert_immobilier_pm" request_delay = 0.6 def fetch(self) -> list[PropertyListing]: try: html = self.get(LISTINGS).text except Exception: return [] # premières positions de chaque n° Centris (les liens titre+image se # répètent : on garde la 1re occurrence de chaque fiche, dans l'ordre) firsts: list[tuple[str, str, int]] = [] # (addr_slug, centris, index) seen: set[str] = set() for m in _LINK_RE.finditer(html): c = m.group(2) if c not in seen: seen.add(c) firsts.append((m.group(1), c, m.start())) by_id: dict[str, PropertyListing] = {} for i, (addr_slug, centris, idx) in enumerate(firsts): end = firsts[i + 1][2] if i + 1 < len(firsts) else len(html) by_id[centris] = self._to_listing(addr_slug, centris, html[idx:end]) listings = list(by_id.values()) du.enrich(self, listings, DETAIL_LIMIT, parse_epm_detail, key="v2") return listings def _to_listing(self, addr_slug: str, centris: str, block: str) -> PropertyListing: address = unquote_plus(addr_slug).strip() flat = _html.unescape(re.sub(r"<[^>]+>", " | ", block)) flat = re.sub(r"\s*\|\s*", " | ", flat) flat = re.sub(r"[ \t]+", " ", flat) # tout est dans la carte : type | # centris | adresse | ville | N Chambres # | N Bains | S MC Habitable | $prix mt = re.search(r"([A-Za-zÀ-ÿ'’ -]{4,40})\s*(?:\|\s*)+#\s*" + centris, flat) ptype = _norm_type(mt.group(1)) if mt else "" city = "" mc = re.search(re.escape(address) + r"\s*(?:\|\s*)+([A-Za-zÀ-ÿ'()/. -]{3,45}?)\s*\|", flat) if mc: city = mc.group(1).strip(" |") beds = re.search(r"\|\s*(\d{1,2})\s*\|\s*Chambres?", flat) baths = re.search(r"\|\s*(\d{1,2})\s*\|\s*Bains?", flat) area = re.search(r"([\d.,]+)\s*MC\s*\|\s*Habitable", flat, re.I) area_sqft = None if area: try: area_sqft = round(float(area.group(1).replace(",", ".")) * 10.7639) except ValueError: pass mprice = re.search(r"\$\s*([\d ,]{4,})", flat) price = parse_price(mprice.group(0)) if mprice else None thumb = _IMG_RE.search(block) return PropertyListing( source=self.source_id, external_id=centris, url=f"{BASE}/properties/{addr_slug}/{centris}", title=address, address=address, city=city, region="", property_type=ptype, price=price, price_label=(mprice.group(0).strip() if mprice else ""), bedrooms=int(beds.group(1)) if beds else None, bathrooms=int(baths.group(1)) if baths else None, area_sqft=area_sqft, mls=centris, images=[thumb.group(0)] if thumb else [], broker_name="L'Expert Immobilier P.M.", ) def parse_epm_detail(html: str) -> dict: """Fiche détail : galerie complète (marketingwebsites), description, ch/sdb, prix.""" out: dict = {} seen, imgs = set(), [] for u in _IMG_RE.findall(html): base = re.sub(r"/thumbs-\d+/", "/", u) # préférer la pleine résolution if base not in seen: seen.add(base) imgs.append(base) if imgs: out["images"] = imgs md = _META_DESC_RE.search(html) if md: out["description"] = _html.unescape(md.group(1)).strip() # prix : uniquement un montant plausible suivi de $ (chambres/sdb viennent # de la carte, plus fiables que le texte aplati de la fiche) t = re.sub(r"\s+", " ", _html.unescape(re.sub(r"<[^>]+>", " ", html))) for m in re.finditer(r"([\d][\d ]{4,})\s*\$", t): p = parse_price(m.group(0)) if p and p >= 20000: out["price"] = p out["price_label"] = m.group(0).strip() break return out