# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (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 LOCATION résidentielle : # b457 logements · b458 chambres & colocation · b460 résidences pour aînés # Les pages « /montreal/…_b{cat}g17567k{page}R2.jsa » embarquent # `var searchResponse = {…}` côté serveur : 20-24 annonces/page + totalPages. # Le site force une ville d'ancrage (g17567 = Montréal) mais le jeu de # résultats couvre TOUTE la province, simplement trié par distance — vérifié : # l'ancre Québec (g15398) donne le même totalPages. Adapté du connecteur # « achat-vente » d'Immo-Ka (agent-courtage/immoka). # ----------------------------------------------------------------------------- from __future__ import annotations import html as _html import json import os import re from ..schema import Listing from .base import BaseConnector from . import _detailutil as du BASE = "https://www.lespac.com" ANCHOR = "montreal" # ville d'ancrage (tri par distance) ANCHOR_GEO = "g17567" DETAIL_LIMIT = int(os.environ.get("LOUKA_LESPAC_DETAIL_LIMIT", "400")) CATEGORIES = [ (457, "immobilier-location-logements", ""), (458, "immobilier-location-colocataires", "Chambre"), (460, "immobilier-location-residences-pour-aines", ""), ] _RE_RESP = re.compile(r"var searchResponse = (\{.*?\});\s*[\r\n]", re.S) _RE_DEMI = re.compile(r"(\d+)\s*(?:½|1/2)") def _clean(s: str) -> str: return re.sub(r"\s+", " ", _html.unescape(re.sub(r"<[^>]+>", " ", s))).strip() 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}/{ANCHOR}/{slug}_b{cat}{ANCHOR_GEO}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, unit_default: str) -> Listing | None: lid = str(r.get("listingPublicId") or "") url = (r.get("listingDisplayUrl") or "").split("?")[0] if not lid or not url: return None title = r.get("title") or "" unit_type = unit_default if not unit_type: m = _RE_DEMI.search(title) if m: unit_type = f"{m.group(1)}½" # prix : LesPAC affiche « Par mois » / « Par semaine » dans priceNote note = (r.get("priceNote") or "").strip() price, price_label = None, "" if r.get("price") is not None and ("mois" in note.lower() or not note): price = float(r["price"]) price_label = f"{r.get('priceLabel') or ''} par mois".strip() details: dict = {} if note and "mois" not in note.lower(): details["Fréquence du loyer"] = note # ex. « Par semaine » track = r.get("searchPageTrackingInfo") or {} region = ((track.get("listing-region-code") or {}).get("value") or "") if region: details["Région"] = region # « Montréal / Centre-Sud / Centre-Ville » → ville + secteur ; # « Autres Provinces » est souvent une erreur de classement LesPAC : # on laisse vide, la vraie ville viendra de l'adresse en fiche détail city_label = r.get("cityLabel") or "" seg = [s.strip() for s in city_label.split("/") if s.strip()] city = seg[0] if seg else "" sector = " / ".join(seg[1:]) if len(seg) > 1 else "" if city.lower() == "autres provinces": city = "" images = [i["formattableImageUrl"].replace("%FORMAT%", "zoomedGallery") for i in r.get("images") or [] if i.get("formattableImageUrl")] return Listing( source=self.source_id, external_id=lid, url=url, title=title, city=city, sector=sector, unit_type=unit_type, price=price, price_label=price_label, description=(r.get("description") or "")[:2000], details=details, images=images, ) def fetch(self) -> list[Listing]: out: dict[str, Listing] = {} for cat, slug, unit_default 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) fresh = 0 for r in d.get("searchResults") or []: lst = self._to_listing(r, unit_default) if lst is not None and lst.uid not in out: out[lst.uid] = lst fresh += 1 if fresh == 0 and page > 1: # fin réelle malgré totalPages break page += 1 listings = list(out.values()) du.enrich(self, listings, DETAIL_LIMIT, _parse_lespac_detail, key="v1") return listings def _parse_lespac_detail(html: str) -> dict: """Fiche LesPAC : description complète, adresse civique, caractéristiques (boîte « Caractéristiques » :

LabelValeur

, valeurs parfois enrobées de liens) et galerie pleine taille (basephoto).""" out: dict = {} md = re.search(r'
.*?

Description

\s*

(.*?)

', html, re.S | re.I) if md: desc = _clean(md.group(1)) if desc: out["description"] = desc[:6000] amenities, 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 amenities.append(f"{label} : {value}") details[label] = value if label == "Adresse": # « 439 Rue Bellevue, Municipalité de Saint-Donat, QC, Canada » parts = [p.strip() for p in value.split(",") if p.strip()] if parts and re.match(r"\s*\d", parts[0]): out["address"] = parts[0] if len(parts) >= 3 and parts[-2].upper() == "QC": ville = re.sub(r"^(?:Municipalité|Ville|Paroisse|Canton)" r"(?:\s+de\s+|\s+d[e']\s*)?", "", parts[-3]).strip() if ville: out["city"] = ville elif label == "Nombre de pièces": out["unit_type"] = value # ex. « 4 1/2 pièces » if amenities: out["amenities"] = amenities 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