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