# -----------------------------------------------------------------------------
# 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 doit suivre de près (sinon : page à puces « • » sans venu serait le menu du pied de page)
m = re.match(r".{0,1000}?",
html[mi.end():mi.end() + 6000], re.S)
if m:
for li in re.findall(r"- ]*>(.*?)
", m.group(1), re.S):
t = re.sub(r"\s+", " ",
BeautifulSoup(li, "html.parser")
.get_text(" ", strip=True)).strip(" .•")
if t:
amen.append(t)
if not amen: # repli : puces « • Eau chaude inclus » dans le texte
amen = [re.sub(r"\s+", " ", a).strip(" .")
for a in re.findall(r"•\s*([^•<\n]{3,60})", body)]
# couper l'avertissement photos collé au dernier item
amen = [re.split(r"\*\*|Les photos", a)[0].strip(" .")
for a in amen]
out["amenities"] = list(dict.fromkeys(a for a in amen if len(a) >= 3))[:20]
# Description : paragraphes entre « Description » et « Détails : »
# (plus promotion « UN MOIS GRATUIT … » affichée sous le prix)
desc_bits: list[str] = []
mp = re.search(r"(UN MOIS GRATUIT[^.]*\.(?:\s*Prix régulier[^.]*\.)?)",
body)
if mp:
desc_bits.append("Promotion : " + re.sub(r"\s+", " ", mp.group(1)))
m = re.search(r"\s*Description\s*\s*(.*?)"
r"(?:\s*D[ée]tails|\s*\s*)",
html, re.S)
if m:
txt = re.sub(r"\s+", " ", BeautifulSoup(m.group(1), "html.parser")
.get_text(" ", strip=True))
if txt:
desc_bits.append(txt)
if not desc_bits:
og = soup.find("meta", attrs={"property": "og:description"}) or \
soup.find("meta", attrs={"name": "description"})
if og and og.get("content"):
desc_bits.append(og["content"].strip())
out["description"] = " — ".join(desc_bits)[:600]
# Téléphone du bureau de location (lien tel:)
for a in soup.select('a[href^="tel:"]'):
num = re.sub(r"\D", "", a.get("href", "").split(",")[0])
num = num[1:] if len(num) == 11 and num.startswith("1") else num
if len(num) == 10:
out["phone"] = f"{num[:3]}-{num[3:6]}-{num[6:10]}"
break
out["images"] = [u for u in dict.fromkeys(IMG_RE.findall(html))
if not IMG_NOISE_RE.search(u)][:25]
return out
def _apply_detail(self, lst: Listing, payload: dict) -> None:
if not payload:
return
if payload.get("availability"):
lst.availability = payload["availability"]
if lst.price is None and payload.get("price_label"):
lst.price_label = payload["price_label"]
lst.price = parse_price(lst.price_label)
if payload.get("amenities"):
lst.amenities = payload["amenities"]
if payload.get("description"):
lst.description = payload["description"]
if payload.get("phone"):
lst.details.setdefault("contact", {})["phone"] = payload["phone"]
if payload.get("images"):
lst.images = payload["images"]