spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/logisma.py : connecteur Logisma (logisma.ca)5# Page de recherche /appartements-a-louer/ (boucle Elementor) : une carte6# par unité disponible. Pages détail pour disponibilité, description,7# promotion, liste « Détails : » (inclusions/animaux/fumeur), téléphone8# (lien tel:) et toutes les images. L'API REST WP (/wp-json/wp/v2/location)9# existe mais n'expose ni contenu ni ACF -> HTML seulement.10# La section commerciale/industrielle est exclue.11# -----------------------------------------------------------------------------12from __future__ import annotations1314import hashlib15import re1617from bs4 import BeautifulSoup1819from ..schema import Listing, infer_city, normalize_unit_type, parse_price20from .base import BaseConnector2122BASE = "https://logisma.ca"23LIST_URL = f"{BASE}/appartements-a-louer/"2425DETAIL_RE = re.compile(r"/appartements-a-louer/([a-z0-9\-]+)/?$")26IMG_RE = re.compile(27 r"https://logisma\.ca/wp-content/uploads/[^\"'\\\s\)]+"28 r"\.(?:jpg|jpeg|png|webp)", re.I)29IMG_NOISE_RE = re.compile(r"logo|favicon|icon|-\d+x\d+\.", re.I)303132class _DetailBudget(Exception):33 """Plafond de vraies requêtes de pages détail atteint pour cette synchro."""343536class LogismaConnector(BaseConnector):37 source_id = "logisma"38 request_delay = 0.639 max_details = 60 # plafond de VRAIES requêtes détail par synchro4041 def __init__(self) -> None:42 super().__init__()43 self._detail_fetches = 04445 def fetch(self) -> list[Listing]:46 html = self.get(LIST_URL).text47 soup = BeautifulSoup(html, "html.parser")4849 listings: dict[str, Listing] = {}50 # Cartes : <h3 class="... containsUrl" attr-url="...">51 # <b>3 1/2</b> Québec, Ste-Foy<br>3 1/2 – 3003 avenue d'Entremont #552 for h3 in soup.select("h3.containsUrl[attr-url]"):53 try:54 lst = self._parse_card(h3)55 except Exception:56 continue57 if lst and lst.external_id not in listings:58 listings[lst.external_id] = lst5960 # Pages détail (avec cache BD) : clé = contenu de la carte, versionnée61 for lst in listings.values():62 key = "v3:" + hashlib.sha1(" | ".join(63 [lst.title, lst.price_label, lst.sector, lst.address])64 .encode("utf-8")).hexdigest()65 try:66 payload = self.detail(lst.external_id, key,67 lambda u=lst.url: self._fetch_detail(u))68 except _DetailBudget:69 continue70 except Exception:71 continue72 self._apply_detail(lst, payload)7374 return list(listings.values())7576 def _parse_card(self, h3) -> Listing | None:77 url = h3.get("attr-url", "").split("?")[0]78 m = DETAIL_RE.search(url)79 if not m:80 return None81 slug = m.group(1)8283 b = h3.find("b")84 unit_raw = b.get_text(" ", strip=True) if b else ""85 lines = [t.strip() for t in h3.get_text("\n", strip=True).split("\n")86 if t.strip()]87 # lines ~ ['3 1/2', 'Québec, Ste-Foy', '3 1/2 – 3003 avenue …, Ste-Foy']88 sector = ""89 title = lines[-1] if lines else slug90 # Ligne ville/secteur : « Québec, Ste-Foy » (exclure hors Québec/Lévis)91 for ln in lines:92 mm = re.match(r"^([A-Za-zÀ-ÿ\-'’ ]+)\s*,\s*([A-Za-zÀ-ÿ\-'’ ]+)$", ln)93 if mm and "–" not in ln:94 city_raw = mm.group(1).strip().lower()95 if city_raw not in ("québec", "quebec", "lévis", "levis"):96 return None # hors agglomération Québec/Lévis97 sector = mm.group(2).strip()98 break99 # Adresse : après le tiret du titre (« 3 1/2 – 3003 avenue … #5, Ste-Foy »)100 address = ""101 if "–" in title or " - " in title:102 rest = re.split(r"–|\s-\s", title, maxsplit=1)[-1].strip()103 segs = [s.strip() for s in rest.split(",")]104 if len(segs) > 1 and not re.search(r"\d", segs[-1]):105 segs = segs[:-1] # retirer le secteur final106 address = ", ".join(s for s in segs if s)107108 # Prix : dans le conteneur de la carte (élément frère du titre)109 price_label = ""110 container = h3.find_parent(class_=re.compile(r"add-link-container")) or \111 h3.find_parent("div", attrs={"data-e-type": "container"})112 if container:113 mp = re.search(r"\d[\d\s ]*\$", container.get_text(" ", strip=True))114 if mp:115 price_label = mp.group(0).strip()116117 return Listing(118 source=self.source_id,119 external_id=slug,120 url=url,121 title=title,122 address=address,123 sector=sector,124 city=infer_city(sector),125 unit_type=normalize_unit_type(unit_raw),126 price=parse_price(price_label),127 price_label=price_label,128 )129130 def _fetch_detail(self, url: str) -> dict:131 if self._detail_fetches >= self.max_details:132 raise _DetailBudget(url)133 self._detail_fetches += 1134 resp = self.get(url)135 # lien mort : le site redirige vers la page liste -> garder la carte136 if resp.url.rstrip("/") != url.rstrip("/"):137 return {}138 html = resp.text139 soup = BeautifulSoup(html, "html.parser")140 body = soup.get_text(" ", strip=True)141 out: dict = {}142143 # Disponibilité : « Disponible le 1er juillet 2026 »144 m = re.search(r"[Dd]isponible\b(?!s)\s*(?:le|dès|en|maintenant|"145 r"immédiatement|1er)[^.<,$]{0,50}", body)146 if m:147 avail = re.sub(r"\s+", " ", m.group(0)).strip()148 avail = re.split(r"\s+(?:UN MOIS|Description|Électro|Offre|Vous)",149 avail)[0]150 out["availability"] = avail.strip()151152 # Prix de secours si absent de la carte (appliqué seulement si besoin)153 mp = re.search(r"(\d[\d\s ]*\$)\s*par mois", body)154 if mp:155 out["price_label"] = mp.group(1).strip()156157 # Liste « Détails : » (items <li> du widget Elementor — certaines pages158 # n'utilisent pas de puces « • » : extraction structurelle d'abord)159 amen: list[str] = []160 m = None161 mi = re.search(r"D[ée]tails\s*:?\s*</strong>", html)162 if mi:163 # le <ul> doit suivre de près (sinon : page à puces « • » sans <ul>,164 # et le premier </ul> venu serait le menu du pied de page)165 m = re.match(r".{0,1000}?<ul>(.*?)</ul>",166 html[mi.end():mi.end() + 6000], re.S)167 if m:168 for li in re.findall(r"<li[^>]*>(.*?)</li>", m.group(1), re.S):169 t = re.sub(r"\s+", " ",170 BeautifulSoup(li, "html.parser")171 .get_text(" ", strip=True)).strip(" .•")172 if t:173 amen.append(t)174 if not amen: # repli : puces « • Eau chaude inclus » dans le texte175 amen = [re.sub(r"\s+", " ", a).strip(" .")176 for a in re.findall(r"•\s*([^•<\n]{3,60})", body)]177 # couper l'avertissement photos collé au dernier item178 amen = [re.split(r"\*\*|Les photos", a)[0].strip(" .")179 for a in amen]180 out["amenities"] = list(dict.fromkeys(a for a in amen if len(a) >= 3))[:20]181182 # Description : paragraphes entre « Description » et « Détails : »183 # (plus promotion « UN MOIS GRATUIT … » affichée sous le prix)184 desc_bits: list[str] = []185 mp = re.search(r"(UN MOIS GRATUIT[^.]*\.(?:\s*Prix régulier[^.]*\.)?)",186 body)187 if mp:188 desc_bits.append("Promotion : " + re.sub(r"\s+", " ", mp.group(1)))189 m = re.search(r"<strong>\s*Description\s*</strong>\s*</p>(.*?)"190 r"(?:<strong>\s*D[ée]tails|</div>\s*</div>\s*</div>)",191 html, re.S)192 if m:193 txt = re.sub(r"\s+", " ", BeautifulSoup(m.group(1), "html.parser")194 .get_text(" ", strip=True))195 if txt:196 desc_bits.append(txt)197 if not desc_bits:198 og = soup.find("meta", attrs={"property": "og:description"}) or \199 soup.find("meta", attrs={"name": "description"})200 if og and og.get("content"):201 desc_bits.append(og["content"].strip())202 out["description"] = " — ".join(desc_bits)[:600]203204 # Téléphone du bureau de location (lien tel:)205 for a in soup.select('a[href^="tel:"]'):206 num = re.sub(r"\D", "", a.get("href", "").split(",")[0])207 num = num[1:] if len(num) == 11 and num.startswith("1") else num208 if len(num) == 10:209 out["phone"] = f"{num[:3]}-{num[3:6]}-{num[6:10]}"210 break211212 out["images"] = [u for u in dict.fromkeys(IMG_RE.findall(html))213 if not IMG_NOISE_RE.search(u)][:25]214 return out215216 def _apply_detail(self, lst: Listing, payload: dict) -> None:217 if not payload:218 return219 if payload.get("availability"):220 lst.availability = payload["availability"]221 if lst.price is None and payload.get("price_label"):222 lst.price_label = payload["price_label"]223 lst.price = parse_price(lst.price_label)224 if payload.get("amenities"):225 lst.amenities = payload["amenities"]226 if payload.get("description"):227 lst.description = payload["description"]228 if payload.get("phone"):229 lst.details.setdefault("contact", {})["phone"] = payload["phone"]230 if payload.get("images"):231 lst.images = payload["images"]232