# ----------------------------------------------------------------------------- # 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 : cityLabel (accents corrects), sinon 1er segment du chemin seg = url.replace(BASE + "/", "").split("/") city = r.get("cityLabel") or (seg[0].replace("-", " ").title() if seg else "") images = [i["formattableImageUrl"].replace("%FORMAT%", "zoomedGallery") for i in r.get("images") or [] if i.get("formattableImageUrl")] details = {} ts = r.get("publicReleaseTimestamp") # date de mise en ligne (ms epoch) if ts: import datetime try: details["listed_at"] = datetime.datetime.fromtimestamp( ts / 1000.0, datetime.timezone.utc).strftime("%Y-%m-%d") except (ValueError, OSError, OverflowError): pass 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()], details=details, 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()) # v2 = TOUTES les boîtes de caractéristiques (l'ancien parseur ne lisait # que la 1re) + salles de bain/d'eau + superficies (dimensions du terrain) du.enrich(self, listings, DETAIL_LIMIT, _parse_lespac_detail, key="v2") return listings def _clean(s: str) -> str: return re.sub(r"\s+", " ", _html.unescape(re.sub(r"<[^>]+>", " ", s))).strip() def _area_pi2(value: str) -> float | None: """« 1 200 pi2 » / « 111 m2 » -> pi² (via la normalisation commune).""" from ..normalize import parse_area_sqft return parse_area_sqft(value) _DIMS_RE = re.compile(r"([\d\s]+(?:,\d+)?)\s*x\s*([\d\s]+(?:,\d+)?)\s*(pieds|m[èe]tres)", re.I) def _dims_pi2(value: str) -> float | None: """« 15,24 x 30,48 mètres » / « 50 x 100 pieds » -> superficie en pi².""" m = _DIMS_RE.search(value or "") if not m: return None try: a = float(m.group(1).replace(" ", "").replace(",", ".")) b = float(m.group(2).replace(" ", "").replace(",", ".")) except ValueError: return None if a <= 0 or b <= 0 or a > 100000 or b > 100000: return None s = a * b if m.group(3).lower().startswith("m"): s *= 10.7639 return round(s) if s >= 300 else None # < 300 pi² : dimensions suspectes 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 = [], {} # section « Caractéristiques » -> fin des boîtes : PLUSIEURS
# (types, équipements, description des pièces…) — on lit toutes les rangées #

Label Valeur jusqu'à la bannière suivante. i = html.find(">Caractéristiques

") if i >= 0: j = html.find("pub-middle-listing-detail", i) seg = html[i:j if j > i else i + 30000] for lm, vm in re.findall(r"

(.*?)\s*(.*?)", seg, re.S): label, value = _clean(lm), _clean(vm) if not label or not value or len(label) > 45 or len(value) > 120: continue if label not in details: features.append(f"{label} : {value}") details[label] = value low = label.lower() 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 low: mn = re.search(r"\d+", value) if mn: out["bedrooms"] = int(mn.group(0)) elif "salle" in low and "eau" in low and "bain" not in low: mn = re.search(r"\d+", value) if mn and int(mn.group(0)) > 0: out["powder_rooms"] = int(mn.group(0)) elif "salle" in low and "bain" in low: mn = re.search(r"\d+", value) if mn: out["bathrooms"] = int(mn.group(0)) elif "superficie" in low and "terrain" in low: v = _area_pi2(value) if v: out["lot_sqft"] = v elif "superficie" in low: v = _area_pi2(value) if v: out["area_sqft"] = v elif label == "Dimension du terrain": v = _dims_pi2(value) if v: out["lot_sqft"] = v 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