# ----------------------------------------------------------------------------- # Immo-Ka — Agrégateur de maisons à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/engel_volkers.py : Engel & Völkers Montréal / Québec (luxe) # Le site local immobilier.evquebec.com publie un property-sitemap.xml # exhaustif (~1240 fiches, /propriete/{no-centris}/). Chaque fiche a un # JSON-LD RealEstateListing (prix, adresse, description, n° Centris). La # galerie est servie séquentiellement sur media.evquebec.com/listings/{id}/NN.webp. # ----------------------------------------------------------------------------- from __future__ import annotations import html as _html import os import re from .base import BaseConnector from . import _detailutil as du from ..schema import PropertyListing SITEMAP = "https://immobilier.evquebec.com/property-sitemap.xml" MEDIA = "https://media.evquebec.com/listings" DETAIL_LIMIT = int(os.environ.get("IMMOKA_EV_DETAIL_LIMIT", "400")) GALLERY_MAX = 60 _PROP_RE = re.compile(r'https://immobilier\.evquebec\.com/propriete/(\d{5,})/') _NAME_RE = re.compile(r'^(.*?)\s+à vendre\b\s*-?\s*(.*)$', re.I) class EngelVolkersConnector(BaseConnector): source_id = "engel_volkers" request_delay = 0.4 use_detail_cache = True def fetch(self) -> list[PropertyListing]: try: xml = self.get(SITEMAP).text except Exception: return [] ids, seen = [], set() for m in _PROP_RE.finditer(xml): # ne garder que les URLs FR if m.group(1) not in seen: seen.add(m.group(1)) ids.append(m.group(1)) listings = [PropertyListing(source=self.source_id, external_id=cid, url=f"https://immobilier.evquebec.com/propriete/{cid}/", mls=cid, broker_name="Engel & Völkers Québec") for cid in ids] self._enrich(listings) return listings def _enrich(self, listings: list[PropertyListing]) -> None: from .. import db con = db.connect() budget = DETAIL_LIMIT try: for lst in listings: d = db.get_cached_detail(con, self.source_id, lst.external_id, "v1") if d is None: if budget <= 0: continue try: d = self._parse(self.get(lst.url).text, lst.external_id) except Exception: d = {} db.put_cached_detail(con, self.source_id, lst.external_id, "v1", d) budget -= 1 _apply(lst, d) finally: con.close() def _parse(self, html: str, centris: str) -> dict: out: dict = {} for n in du.ld_nodes(html): t = n.get("@type") types = t if isinstance(t, list) else [t] if "RealEstateListing" in types: if n.get("description"): out["description"] = _html.unescape(str(n["description"])).strip() offer = n.get("offers") or {} try: p = float(offer.get("price")) if p >= 10_000: out["price"] = p except (TypeError, ValueError): pass name = _html.unescape(n.get("name", "")).strip() if name: out.update(_parse_name(name)) break # bedrooms / bathrooms depuis le texte text = re.sub(r"\s+", " ", _html.unescape(re.sub(r"<[^>]+>", " ", html))) mb = re.search(r"(\d+)\s*chambre", text, re.I) if mb: out["bedrooms"] = int(mb.group(1)) ms = re.search(r"(\d+)\s*salle[s]?\s*de\s*bain", text, re.I) if ms: out["bathrooms"] = int(ms.group(1)) out["images"] = self._gallery(centris) return out def _gallery(self, centris: str) -> list[str]: """Galerie séquentielle NN.webp — on s'arrête au premier trou (HEAD rapide).""" urls = [] for i in range(1, GALLERY_MAX + 1): u = f"{MEDIA}/{centris}/{i:02d}.webp" try: r = self.session.head(u, timeout=8) except Exception: break if r.status_code != 200: break urls.append(u) return urls def _parse_name(name: str) -> dict: """« Condo à vendre - Centre-ville - 1288 Av. …, Montréal (Ville-Marie), Montréal ».""" out: dict = {} m = _NAME_RE.search(name) if not m: return out out["property_type"] = m.group(1).strip() rest = m.group(2).strip() segs = [s.strip() for s in rest.split(" - ") if s.strip()] loc = segs[-1] if segs else rest # municipalité = segment contenant « ( » ; adresse = ce qui précède commas = [c.strip() for c in loc.split(",")] muni_idx = next((i for i, c in enumerate(commas) if "(" in c), None) if muni_idx is not None: muni = commas[muni_idx] ps = re.search(r"\(([^)]+)\)", muni) out["city"] = re.sub(r"\s*\([^)]*\)", "", muni).strip() if ps: out["sector"] = ps.group(1).strip() addr = ", ".join(commas[:muni_idx]).strip() if addr: out["address"] = addr elif len(commas) >= 2: # pas d'arrondissement : dernier segment = municipalité, le reste = adresse out["city"] = commas[-1] out["address"] = ", ".join(commas[:-1]).strip() else: out["address"] = loc if len(segs) > 1 and not out.get("sector"): out["sector"] = segs[0] return out def _apply(lst: PropertyListing, d: dict) -> None: if not d: return for f in ("address", "city", "sector", "property_type", "description"): if d.get(f) and not getattr(lst, f, ""): setattr(lst, f, d[f]) for f in ("price", "bedrooms", "bathrooms"): if d.get(f) is not None and getattr(lst, f, None) in (None, 0): setattr(lst, f, d[f]) if d.get("images") and len(d["images"]) > len(lst.images): lst.images = d["images"]