# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/logisma.py : connecteur Logisma (logisma.ca) # Page de recherche /appartements-a-louer/ (boucle Elementor) : une carte # par unité disponible. Pages détail pour disponibilité, description, # promotion, liste « Détails : » (inclusions/animaux/fumeur), téléphone # (lien tel:) et toutes les images. L'API REST WP (/wp-json/wp/v2/location) # existe mais n'expose ni contenu ni ACF -> HTML seulement. # La section commerciale/industrielle est exclue. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import re from bs4 import BeautifulSoup from ..schema import Listing, infer_city, normalize_unit_type, parse_price from .base import BaseConnector BASE = "https://logisma.ca" LIST_URL = f"{BASE}/appartements-a-louer/" DETAIL_RE = re.compile(r"/appartements-a-louer/([a-z0-9\-]+)/?$") IMG_RE = re.compile( r"https://logisma\.ca/wp-content/uploads/[^\"'\\\s\)]+" r"\.(?:jpg|jpeg|png|webp)", re.I) IMG_NOISE_RE = re.compile(r"logo|favicon|icon|-\d+x\d+\.", re.I) class _DetailBudget(Exception): """Plafond de vraies requêtes de pages détail atteint pour cette synchro.""" class LogismaConnector(BaseConnector): source_id = "logisma" request_delay = 0.6 max_details = 60 # plafond de VRAIES requêtes détail par synchro def __init__(self) -> None: super().__init__() self._detail_fetches = 0 def fetch(self) -> list[Listing]: html = self.get(LIST_URL).text soup = BeautifulSoup(html, "html.parser") listings: dict[str, Listing] = {} # Cartes :

# 3 1/2 Québec, Ste-Foy
3 1/2 – 3003 avenue d'Entremont #5 for h3 in soup.select("h3.containsUrl[attr-url]"): try: lst = self._parse_card(h3) except Exception: continue if lst and lst.external_id not in listings: listings[lst.external_id] = lst # Pages détail (avec cache BD) : clé = contenu de la carte, versionnée for lst in listings.values(): key = "v3:" + hashlib.sha1(" | ".join( [lst.title, lst.price_label, lst.sector, lst.address]) .encode("utf-8")).hexdigest() try: payload = self.detail(lst.external_id, key, lambda u=lst.url: self._fetch_detail(u)) except _DetailBudget: continue except Exception: continue self._apply_detail(lst, payload) return list(listings.values()) def _parse_card(self, h3) -> Listing | None: url = h3.get("attr-url", "").split("?")[0] m = DETAIL_RE.search(url) if not m: return None slug = m.group(1) b = h3.find("b") unit_raw = b.get_text(" ", strip=True) if b else "" lines = [t.strip() for t in h3.get_text("\n", strip=True).split("\n") if t.strip()] # lines ~ ['3 1/2', 'Québec, Ste-Foy', '3 1/2 – 3003 avenue …, Ste-Foy'] sector = "" title = lines[-1] if lines else slug # Ligne ville/secteur : « Québec, Ste-Foy » (exclure hors Québec/Lévis) for ln in lines: mm = re.match(r"^([A-Za-zÀ-ÿ\-'’ ]+)\s*,\s*([A-Za-zÀ-ÿ\-'’ ]+)$", ln) if mm and "–" not in ln: city_raw = mm.group(1).strip().lower() if city_raw not in ("québec", "quebec", "lévis", "levis"): return None # hors agglomération Québec/Lévis sector = mm.group(2).strip() break # Adresse : après le tiret du titre (« 3 1/2 – 3003 avenue … #5, Ste-Foy ») address = "" if "–" in title or " - " in title: rest = re.split(r"–|\s-\s", title, maxsplit=1)[-1].strip() segs = [s.strip() for s in rest.split(",")] if len(segs) > 1 and not re.search(r"\d", segs[-1]): segs = segs[:-1] # retirer le secteur final address = ", ".join(s for s in segs if s) # Prix : dans le conteneur de la carte (élément frère du titre) price_label = "" container = h3.find_parent(class_=re.compile(r"add-link-container")) or \ h3.find_parent("div", attrs={"data-e-type": "container"}) if container: mp = re.search(r"\d[\d\s ]*\$", container.get_text(" ", strip=True)) if mp: price_label = mp.group(0).strip() return Listing( source=self.source_id, external_id=slug, url=url, title=title, address=address, sector=sector, city=infer_city(sector), unit_type=normalize_unit_type(unit_raw), price=parse_price(price_label), price_label=price_label, ) def _fetch_detail(self, url: str) -> dict: if self._detail_fetches >= self.max_details: raise _DetailBudget(url) self._detail_fetches += 1 resp = self.get(url) # lien mort : le site redirige vers la page liste -> garder la carte if resp.url.rstrip("/") != url.rstrip("/"): return {} html = resp.text soup = BeautifulSoup(html, "html.parser") body = soup.get_text(" ", strip=True) out: dict = {} # Disponibilité : « Disponible le 1er juillet 2026 » m = re.search(r"[Dd]isponible\b(?!s)\s*(?:le|dès|en|maintenant|" r"immédiatement|1er)[^.<,$]{0,50}", body) if m: avail = re.sub(r"\s+", " ", m.group(0)).strip() avail = re.split(r"\s+(?:UN MOIS|Description|Électro|Offre|Vous)", avail)[0] out["availability"] = avail.strip() # Prix de secours si absent de la carte (appliqué seulement si besoin) mp = re.search(r"(\d[\d\s ]*\$)\s*par mois", body) if mp: out["price_label"] = mp.group(1).strip() # Liste « Détails : » (items
  • du widget Elementor — certaines pages # n'utilisent pas de puces « • » : extraction structurelle d'abord) amen: list[str] = [] m = None mi = re.search(r"D[ée]tails\s*:?\s*", html) if mi: # le