# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/localys.py : connecteur Gestion Localys (gestionlocalys.com, # 240+ unités). Site Next.js rendu serveur : /fr/logements liste les # logements disponibles (cartes avec adresse, quartier, « 2 ch. · 1 sdb. » # et prix « 1 500 $ / mois »), chaque fiche /fr/logements/ fournit la # description (avec **DISPONIBLE …**, sections #### Inclusions/Exclusions), # les caractéristiques (« 2 ch. 1 sdb. 750 pi² ») et la galerie (photos # ws.jumptools.com encodées dans /_next/image?url=…). Une annonce par # logement ; external_id = slug ; fiches via le cache détail (clé = hash du # texte de la carte). # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import re import urllib.parse from bs4 import BeautifulSoup from ..schema import Listing, parse_price from .base import BaseConnector BASE = "https://www.gestionlocalys.com" LIST_URL = f"{BASE}/fr/logements" # « 2558 Rue De Cadillac, Montréal (Mercier/Hochelaga-Maisonneuve), QC, … » H1_RE = re.compile( r"^(?P.+?),\s*(?P[^,(]+?)\s*(?:\((?P[^)]+)\))?" r",\s*QC", re.S) PRICE_RE = re.compile(r"([\d\s ]+)\$\s*/\s*mois") CH_RE = re.compile(r"(\d+)\s*ch\.") SDB_RE = re.compile(r"([\d.]+)\s*sdb\.") SQFT_RE = re.compile(r"([\d\s ]+)\s*pi²") NEXT_IMG_RE = re.compile(r'/_next/image\?url=([^&"\']+)') DISPO_RE = re.compile(r"DISPONIBLE[^*\n]{0,40}", re.I) def _clean_md(t: str) -> str: """Retire les marqueurs markdown (** et ####) du texte rendu.""" return re.sub(r"\s+", " ", t.replace("**", " ").replace("####", " ") ).strip() class LocalysConnector(BaseConnector): source_id = "localys" request_delay = 0.7 def fetch(self) -> list[Listing]: html = self.get(LIST_URL).text soup = BeautifulSoup(html, "html.parser") # cartes : ancêtre du lien qui contient le prix cards: dict[str, str] = {} # slug -> texte de la carte for a in soup.find_all("a", href=True): if "/fr/logements/" not in a["href"]: continue slug = a["href"].rstrip("/").rsplit("/", 1)[-1] if slug in cards and cards[slug]: continue node, text = a, "" for _ in range(6): node = node.parent if node is None: break t = re.sub(r"\s+", " ", node.get_text(" ", strip=True)) if "$" in t: text = t break cards[slug] = text listings: list[Listing] = [] for slug, card_text in cards.items(): try: key = hashlib.sha1(card_text.encode("utf-8")).hexdigest() d = self.detail(slug, key, lambda s=slug: self._logement(s)) lst = self._listing(slug, card_text, d) if lst: listings.append(lst) except Exception: continue return listings def _logement(self, slug: str) -> dict: """Scrape la fiche : h1, description, caractéristiques, photos.""" out: dict = {"h1": "", "desc": "", "carac": "", "images": []} html = self.get(f"{LIST_URL}/{slug}").text soup = BeautifulSoup(html, "html.parser") for s in soup.find_all("script"): s.decompose() # écarter le payload RSC de Next.js if soup.h1: out["h1"] = soup.h1.get_text(" ", strip=True) text = re.sub(r"\s+", " ", soup.get_text(" ", strip=True)) i = text.find("/ mois") j = text.find("Caractéristiques") if i >= 0 and j > i: out["desc"] = text[i + len("/ mois"):j].strip()[:1500] if j >= 0: out["carac"] = text[j:j + 120] images = [] for enc in NEXT_IMG_RE.findall(html): u = urllib.parse.unquote(enc) if u.startswith("http") and "jumptools.com" in u \ and u not in images: images.append(u) out["images"] = images[:15] return out def _listing(self, slug: str, card_text: str, d: dict) -> Listing | None: # fiche morte : la source amont (jumptools) renvoie sa page 404 if "cannot be found" in d.get("desc", ""): return None h1 = d.get("h1") or "" street = city = sector = "" m = H1_RE.match(h1) if m: street = m.group("street").strip() city = m.group("city").strip() sector = (m.group("sector") or "").strip() address = ", ".join(x for x in (street, city) if x) price = None pm = PRICE_RE.search(card_text) or PRICE_RE.search(d.get("carac", "")) if pm: price = parse_price(pm.group(1).strip() + " $") bedrooms = bathrooms = None blob = f"{card_text} {d.get('carac', '')}" bm = CH_RE.search(blob) if bm: bedrooms = float(bm.group(1)) sm = SDB_RE.search(blob) if sm: try: bathrooms = float(sm.group(1)) except ValueError: pass area = None am = SQFT_RE.search(d.get("carac", "")) or SQFT_RE.search(card_text) if am: try: area = float(re.sub(r"[\s ]", "", am.group(1))) except ValueError: pass desc_raw = d.get("desc", "") availability = "" dm = DISPO_RE.search(desc_raw) if dm: availability = _clean_md(dm.group(0)).capitalize() return Listing( source=self.source_id, external_id=slug, url=f"{LIST_URL}/{slug}", title=h1.replace(", QC, Canada", "") or slug, address=address, sector=sector, city=city or "Montréal", unit_type="", bedrooms=bedrooms, bathrooms=bathrooms, price=price, price_label=f"{pm.group(1).strip()} $ / mois" if pm else "", availability=availability, area_sqft=area, description=_clean_md(desc_raw), images=d.get("images", []), )