# ----------------------------------------------------------------------------- # Immo-Ka — Agrégateur de maisons à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/lespac.py : LesPAC (lespac.com) — petites annonces du Québec # UNIQUEMENT l'immobilier ACHAT-VENTE (pas la location, pas les entreprises) : # b37 résidentiel · b38 terrains · b39 commercial-industriel · # b40 chalets · b41 fermes · b42 immeubles à revenus # Les pages « /quebec/… _b{cat}k{page}R2.jsa » (toute la province) embarquent # `var searchResponse = {…}` côté serveur : 20 annonces/page + totalPages. # ----------------------------------------------------------------------------- from __future__ import annotations import html as _html import json import os import re from ..schema import PropertyListing from .base import BaseConnector from . import _detailutil as du BASE = "https://www.lespac.com" DETAIL_LIMIT = int(os.environ.get("IMMOKA_LESPAC_DETAIL_LIMIT", "400")) CATEGORIES = [ (37, "immobilier-achat-vente-residentiel", "Maison"), (38, "immobilier-achat-vente-terrains", "Terrain"), (39, "immobilier-achat-vente-commercial-industriel", "Commercial"), (40, "immobilier-achat-vente-chalets", "Chalet"), (41, "immobilier-achat-vente-fermes", "Fermette"), (42, "immobilier-achat-vente-immeubles-a-revenus", "Immeuble à revenus"), ] _RE_RESP = re.compile(r"var searchResponse = (\{.*?\});\s*[\r\n]", re.S) class LesPacConnector(BaseConnector): source_id = "lespac" request_delay = 0.9 def _search_page(self, slug: str, cat: int, page: int) -> dict | None: url = f"{BASE}/quebec/{slug}_b{cat}k{page}R2.jsa" m = _RE_RESP.search(self.get(url).text) return json.loads(m.group(1)) if m else None def _to_listing(self, r: dict, ptype: str) -> PropertyListing | None: lid = str(r.get("listingPublicId") or "") url = (r.get("listingDisplayUrl") or "").split("?")[0] if not lid or not url: return None # caractéristiques structurées (« Type de propriété », « Chambres »…) chars = {c.get("label", ""): str(c.get("value", "")) for c in r.get("characteristics") or [] if c.get("label")} def as_int(label): m = re.search(r"\d+", chars.get(label, "")) return int(m.group(0)) if m else None # ville = 1er segment du chemin de l'annonce seg = url.replace(BASE + "/", "").split("/") city = seg[0].replace("-", " ").title() if seg else "" images = [i["formattableImageUrl"].replace("%FORMAT%", "zoomedGallery") for i in r.get("images") or [] if i.get("formattableImageUrl")] return PropertyListing( source=self.source_id, external_id=lid, url=url, title=r.get("title") or "", city=city, property_type=chars.get("Type de propriété") or ptype, price=r.get("price"), price_label=r.get("priceLabel") or "", bedrooms=as_int("Chambres"), bathrooms=as_int("Salles de bain") or as_int("Salle de bain"), year_built=as_int("Année de construction"), description=(r.get("description") or "")[:2000], features=[f"{k} : {v}" for k, v in chars.items()], images=images, broker_name=r.get("advertiserName") or "LesPAC (particuliers)", agency="LesPAC Québec", ) def fetch(self) -> list[PropertyListing]: out: dict[str, PropertyListing] = {} for cat, slug, ptype in CATEGORIES: page, total_pages = 1, 1 while page <= total_pages: try: d = self._search_page(slug, cat, page) except Exception: break if not d: break total_pages = min(int(d.get("totalPages") or 1), 400) for r in d.get("searchResults") or []: lst = self._to_listing(r, ptype) if lst is not None: out.setdefault(lst.uid, lst) page += 1 listings = list(out.values()) du.enrich(self, listings, DETAIL_LIMIT, _parse_lespac_detail, key="v1") return listings def _clean(s: str) -> str: return re.sub(r"\s+", " ", _html.unescape(re.sub(r"<[^>]+>", " ", s))).strip() def _parse_lespac_detail(html: str) -> dict: """Fiche LesPAC : description complète, adresse civique, caractéristiques (boîte « Caractéristiques » :

LabelValeur

) et galerie pleine taille (binary/basephoto).""" out: dict = {} md = re.search(r'class="description"[^>]*>(.*?)', html, re.S | re.I) if md: desc = _clean(md.group(1)) if desc: out["description"] = desc[:6000] features, details = [], {} mbox = re.search(r'>Caractéristiques

\s*
(.*?)
', html, re.S) if mbox: for lm, vm in re.findall(r"

(.*?)\s*(.*?)", mbox.group(1), re.S): label, value = _clean(lm), _clean(vm) if not label or not value: continue features.append(f"{label} : {value}") details[label] = value if label == "Adresse" and re.match(r"\s*\d", value): out["address"] = value elif label == "Année": my = re.search(r"(18|19|20)\d{2}", value) if my: out["year_built"] = int(my.group(0)) elif label == "Type de propriété": out["property_type"] = value elif "chambre" in label.lower(): mn = re.search(r"\d+", value) if mn: out["bedrooms"] = int(mn.group(0)) elif "salle" in label.lower() and "bain" in label.lower(): mn = re.search(r"\d+", value) if mn: out["bathrooms"] = int(mn.group(0)) if features: out["features"] = features if details: out["details"] = details imgs, seen = [], set() for u in re.findall(r'https://cdn\.lespac\.com/binary/basephoto/\d+\.jpg', html): if u not in seen: seen.add(u) imgs.append(u) if imgs: out["images"] = imgs return out