# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/logisquebec.py : LogisQuébec (logisquebec.com) — portail locatif # québécois (Réglisse Média). ~9 800 fiches location, province entière. # Découverte par les sitemaps (99,7 % du parc vérifié vs compteurs des pages # liste) : URLs de fiche « {type}-a-louer-{ville}-l###### ». Chaque fiche # (SSR, Cloudflare tolérant) expose un JSON-LD complet : SingleFamilyResidence # (adresse + code postal + GPS + téléphone) et Product (nom, description, # offers.price, sku = id). Galerie lightGallery, disponibilité et # caractéristiques actives (span .caracteristique-texte) dans le HTML. # Fiche expirée -> HTTP 410 Gone (retrait immédiat). Cache détail avec TTL # (les prix changent) via _detailutil.TtlDetailCache. # Superficie : la fiche n'a AUCUN champ structuré (ni JSON-LD floorSize, ni # caractéristique dédiée — vérifié 2026-08-18) ; seule l'extraction texte de # finalize() (description/commodités) s'applique, ~26 % du parc en donne une. # ----------------------------------------------------------------------------- from __future__ import annotations import os import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type from .base import BaseConnector from . import _detailutil as du BASE = "https://www.logisquebec.com" # type d'URL -> (type d'unité par défaut, court terme ?) ; les slugs # commerciaux (local-commercial, bureau, entrepot-industriel…) sont exclus TYPES = { "appartement": ("", False), "condo": ("", False), "maison": ("Maison", False), "studio": ("Studio", False), "loft": ("Loft", False), "chambre-et-colocation": ("Chambre", False), "chalet": ("Chalet", False), "appartement-condo-court-terme": ("", True), "maison-court-terme": ("Maison", True), "chambre-court-terme": ("Chambre", True), } _FICHE_RE = re.compile( r"^https://www\.logisquebec\.com/(" + "|".join(map(re.escape, TYPES)) + r")-a-louer-([a-z0-9_-]+)-l(\d+)$") _LOC_RE = re.compile(r"(.*?)") _PHOTO_RE = re.compile(r"https://i\.logisquebec\.com/i-a-louer/\d+/\d+/[^'\"]+") _DISPO_RE = re.compile(r"Disponible à partir de\s*:?\s*\|\s*([^|]{1,60})") # Dimensionnement du budget détail : parc ~6 000 fiches, TTL 7 j, watch ~10 # passes/jour -> ~90 rafraîchissements/passe suffisent à tenir le TTL. 400 # laisse 4x de marge (poussées de nouvelles fiches) en ramenant le cycle de # ~19 min à ~5 min. Les fiches JAMAIS VUES sont servies en premier (voir # fetch) : aucune perte de volume, le parc se complète au même rythme. DETAIL_LIMIT = int(os.environ.get("LOUKA_LOGISQUEBEC_DETAIL_LIMIT", "400")) TTL_DAYS = float(os.environ.get("LOUKA_LOGISQUEBEC_TTL_DAYS", "7")) MAX_FICHES = int(os.environ.get("LOUKA_LOGISQUEBEC_MAX", "0")) # 0 = tout def _parse_fiche(html: str) -> dict: """Extrait le payload complet d'une page fiche LogisQuébec.""" out: dict = {} for node in du.ld_nodes(html): t = node.get("@type") or "" if t == "SingleFamilyResidence": addr = node.get("address") or {} out["address"] = (addr.get("streetAddress") or "").strip() out["locality"] = (addr.get("addressLocality") or "").strip() out["postal_code"] = (addr.get("postalCode") or "").strip() geo = node.get("geo") or {} try: out["lat"] = float(geo.get("latitude")) out["lng"] = float(geo.get("longitude")) except (TypeError, ValueError): pass if node.get("telephone"): out["telephone"] = str(node["telephone"]).strip() rooms = node.get("numberOfRooms") if isinstance(rooms, (int, float)) and rooms > 0: out["rooms"] = int(rooms) elif t == "Product": out["title"] = (node.get("name") or "").strip() out["description"] = (node.get("description") or "").strip()[:6000] offers = node.get("offers") or {} try: out["price"] = float(str(offers.get("price")).replace(",", ".")) except (TypeError, ValueError): pass if node.get("sku"): out["sku"] = str(node["sku"]) photos, seen = [], set() for u in _PHOTO_RE.findall(html): if "/thumbnail/" in u or u in seen: continue seen.add(u) photos.append(u) if photos: out["images"] = photos # « Disponible à partir de : Immédiatement / 1er juillet… » (texte aplati) flat = du.flatten(html) m = _DISPO_RE.search(flat) if m: out["availability"] = m.group(1).strip(" .") # caractéristiques ACTIVES = items possédant un span .caracteristique-texte # (les items sans texte sont la légende inactive des icônes) soup = BeautifulSoup(html, "html.parser") amenities = [] for it in soup.select(".caracteristique-item"): if it.select_one(".caracteristique-texte") is None: continue label = it.get_text(" ", strip=True) label = re.sub(r"\s+", " ", label) # le titre est dupliqué dans le texte : « 2 chambres 2 chambres » half = len(label) // 2 if len(label) % 2 == 1 and label[:half] == label[half + 1:]: label = label[:half] if label and label not in amenities: amenities.append(label) if amenities: out["amenities"] = amenities vt = du.virtual_tour(html) # lien Matterport/iGUIDE… si la fiche en a un if vt: out["virtual_tour"] = vt return out class LogisQuebecConnector(BaseConnector): source_id = "logisquebec" request_delay = 0.5 # -- découverte ------------------------------------------------------------ def _fiche_urls(self) -> list[tuple[str, str, str, str]]: """(url, type_slug, ville_slug, id) des fiches location résidentielles.""" index = self.get(f"{BASE}/sitemap.xml").text subs = [u for u in _LOC_RE.findall(index) if "/sitemap/" in u] out, seen = [], set() for sub in subs: try: xml = self.get(sub).text except Exception: continue for u in _LOC_RE.findall(xml): m = _FICHE_RE.match(u) if m and m.group(3) not in seen: seen.add(m.group(3)) out.append((u, m.group(1), m.group(2), m.group(3))) return out # -- construction ---------------------------------------------------------- def _to_listing(self, url: str, type_slug: str, payload: dict) -> Listing | None: if not payload or payload.get("gone"): return None eid = payload.get("sku") or url.rsplit("-l", 1)[-1] default_type, court_terme = TYPES[type_slug] title = payload.get("title") or "" unit_type = normalize_unit_type(title) if not re.match(r"^\d½|^6½\+$", unit_type or ""): unit_type = "" if not unit_type and payload.get("rooms"): n = payload["rooms"] unit_type = "6½+" if n >= 6 else f"{n}½" if not unit_type: # « 2 chambres » des caractéristiques for a in payload.get("amenities") or []: if re.match(r"^\d+ chambres?$", a): unit_type = normalize_unit_type(a) break if not unit_type: unit_type = default_type locality = payload.get("locality") or "" m = re.match(r"^(.*?)\s*\((.+)\)\s*$", locality) sector, city = (m.group(1), m.group(2)) if m else ("", locality) price = payload.get("price") # clés canoniques (mêmes noms que normalize.extract_details : le # merge_details de finalize() fusionne contact{} clé par clé) details: dict = {} if payload.get("postal_code"): details["postal_code"] = payload["postal_code"] if payload.get("telephone"): details["contact"] = {"phone": payload["telephone"]} if payload.get("rooms"): details["rooms"] = payload["rooms"] # numberOfRooms (JSON-LD) if court_terme: details["Location court terme"] = "oui" if payload.get("virtual_tour"): details["virtual_tour"] = payload["virtual_tour"] amenities = payload.get("amenities") or [] lst = Listing( source=self.source_id, external_id=str(eid), url=url, title=title, address=payload.get("address") or "", sector=sector, city=city, unit_type=unit_type, price=price, price_label=(f"{price:,.0f} $/mois".replace(",", " ") if price else ""), availability=payload.get("availability") or "", description=payload.get("description") or "", amenities=amenities, details=details, images=payload.get("images") or [], lat=payload.get("lat"), lng=payload.get("lng"), ) low = [a.lower() for a in amenities] if any("animaux accept" in a for a in low): lst.pets = "oui" if "meublé" in low: lst.furnished = True return lst def fetch(self) -> list[Listing]: fiches = self._fiche_urls() if MAX_FICHES: fiches = fiches[:MAX_FICHES] cache = du.TtlDetailCache(self, budget=DETAIL_LIMIT, ttl_days=TTL_DAYS) # priorité du budget aux fiches jamais visitées (tri stable) : les # nouveautés sont toujours captées au premier passage, le reste du # budget sert au rafraîchissement TTL seen = {r["external_id"] for r in cache.con.execute( "SELECT external_id FROM detail_cache WHERE source=?", (self.source_id,))} fiches.sort(key=lambda f: f[3] in seen) out: list[Listing] = [] try: for url, type_slug, _ville, eid in fiches: payload = cache.get(eid, url, _parse_fiche) if payload is None: # jamais visitée + budget épuisé : continue # captée à une prochaine synchronisation lst = self._to_listing(url, type_slug, payload) if lst is not None: out.append(lst) finally: cache.close() return out