# -----------------------------------------------------------------------------
# Lou-Ka — Agrégateur de logements à louer (province de Québec)
# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
# connectors/sentinelle.py : connecteur La Sentinelle (lasentinellelevis.com)
# Immeuble de 144 condos locatifs au 7002, boul. Guillaume-Couture
# (Vieux-Lévis). La page /projet/ contient un TABLEAU structuré des unités
# (numéro, étage, superficie pi², type, prix, statut, lien fiche) : on ne
# visite que les fiches des unités « Disponible », via self.detail(...)
# (cache BD, clé = ligne du tableau) — la fiche apporte la date de
# disponibilité, la liste des commodités (ul.uk-list-disc), les photos
# et le plan.
# -----------------------------------------------------------------------------
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://lasentinellelevis.com"
LIST_URL = f"{BASE}/projet/"
SECTOR = "Vieux-Lévis"
ADDRESS = "7002, boul. Guillaume-Couture, Lévis, G6V 0C1"
PHONE = "418-741-3737" # lien tel: du pied de page (bureau de location)
_SKIP_IMG = re.compile(r"logo|favicon|icon|brochu|promenade", re.I)
_STATUS_RE = re.compile(r"^(Disponible|Loué|Réservé)$", re.I)
class _BudgetReached(Exception):
"""Plafond de requêtes « fiche » atteint pour cette synchronisation."""
class SentinelleConnector(BaseConnector):
source_id = "sentinelle"
request_delay = 0.6
max_details = 60 # plafond de vraies requêtes fiche par sync
def fetch(self) -> list[Listing]:
html = self.get(LIST_URL).text
soup = BeautifulSoup(html, "html.parser")
self._fetches = 0
listings: list[Listing] = []
seen: set[str] = set()
# Tableau des unités :
| 104 |
# Étage 1 | 1241 | 4½+ | 1695$ |
# Disponible | ...
rows = soup.select("tr[data-url*='/unite-']")
for tr in rows:
try:
lst = self._row_listing(tr)
except Exception:
continue
if lst and lst.external_id not in seen:
seen.add(lst.external_id)
listings.append(lst)
if rows:
return listings
# Repli (si le tableau disparaît) : balayer les liens de fiches
unit_urls = sorted(set(re.findall(
rf'href="({re.escape(BASE)}/projet/etage-\d+/unite-\d+/)"', html)))
for url in unit_urls[: self.max_details]:
unit_no = url.rstrip("/").split("-")[-1]
if f"unite-{unit_no}" in seen:
continue
try:
payload = self._fetch_detail(url)
except Exception:
continue
if payload.get("status") not in (None, "Disponible"):
continue
lst = self._build(unit_no, url, payload)
seen.add(lst.external_id)
listings.append(lst)
return listings
# -- ligne du tableau -------------------------------------------------------
def _row_listing(self, tr) -> Listing | None:
url = (tr.get("data-url") or "").split("?")[0]
m = re.search(r"/unite-(\d+)/?$", url)
if not m:
return None
unit_no = m.group(1)
cells = [td.get_text(" ", strip=True) for td in tr.find_all("td")]
row: dict = {}
for c in cells:
if _STATUS_RE.match(c):
row["status"] = c.capitalize()
elif re.match(r"^\d[\d\s]*\$$", c):
row["price_label"] = c
elif re.match(r"^Étage\s+\d+$", c):
row["floor"] = int(re.search(r"\d+", c).group(0))
elif re.match(r"^\d\s*(?:½|1/2)\s*\+?$", c):
row["type"] = c
elif re.match(r"^\d{3,5}$", c) and c != unit_no:
v = float(c)
if 80 <= v <= 20000:
row["sqft"] = v
if row.get("status") and row["status"] != "Disponible":
return None # loué / réservé : pas une annonce active
# Fiche de l'unité (cache BD : re-téléchargée si la ligne change)
key = hashlib.sha1(("|".join(cells)).encode("utf-8")).hexdigest()
try:
payload = self.detail(f"unite-{unit_no}", key,
lambda u=url: self._fetch_detail(u))
except Exception: # _BudgetReached inclus : rien de caché
payload = {}
return self._build(unit_no, url, payload, row)
# -- fiche --------------------------------------------------------------------
def _fetch_detail(self, url: str) -> dict:
"""Télécharge une fiche d'unité (appelé seulement hors cache)."""
if self._fetches >= self.max_details:
raise _BudgetReached()
self._fetches += 1
dhtml = self.get(url).text
soup = BeautifulSoup(dhtml, "html.parser")
text = soup.get_text("\n", strip=True)
payload: dict = {}
m = re.search(r"Unité\s+(\d+)\s*-\s*(\d\s*(?:½|1/2)\s*\+?)", text)
if m:
payload["type"] = m.group(2).strip()
fm = re.search(r"Étage\s+(\d+)", text)
if fm:
payload["floor"] = int(fm.group(1))
sm = re.search(r"\n(Disponible|Loué|Réservé)\n", text)
if sm:
payload["status"] = sm.group(1)
am = re.search(r"Disponible à partir de\s*:\s*([^\n]+)", text)
if am:
payload["availability"] = \
f"Disponible à partir de : {am.group(1).strip()}"
pm = re.search(r"^([\d\s ]{3,})\$\s*$", text, re.M)
if pm:
payload["price_label"] = pm.group(0).strip()
sqm = re.search(r"Superficie\s+([\d\s]+)pi", text)
if sqm:
try:
v = float(sqm.group(1).replace(" ", ""))
if 80 <= v <= 20000:
payload["sqft"] = v
except ValueError:
pass
# commodités : liste à puces de la fiche (ul.uk-list-disc)
amenities: list[str] = []
for ul in soup.select("ul.uk-list-disc"):
for li in ul.find_all("li"):
t = li.get_text(" ", strip=True)
if t and not t.lower().startswith("superficie") \
and t not in amenities:
amenities.append(t)
payload["amenities"] = amenities[:15]
if "espace bureau" in text.lower():
payload["office"] = True
imgs = re.findall(
rf'(?:src|href)="({re.escape(BASE)}/wp-content/uploads/'
rf'[^"]+\.(?:jpg|jpeg|png|webp))"', dhtml, re.I)
payload["images"] = [u for u in dict.fromkeys(imgs)
if not _SKIP_IMG.search(u)][:10]
return payload
# -- assemblage -----------------------------------------------------------
def _build(self, unit_no: str, url: str, payload: dict,
row: dict | None = None) -> Listing:
row = row or {}
raw_type = payload.get("type") or row.get("type") or ""
unit_type = normalize_unit_type(raw_type)
price_label = row.get("price_label") \
or payload.get("price_label") or ""
price = parse_price(price_label)
availability = payload.get("availability") \
or (row.get("status") or "Disponible")
area = row.get("sqft") or payload.get("sqft")
floor = row.get("floor") or payload.get("floor")
desc_parts = []
if floor:
desc_parts.append(f"Étage {floor}")
if area:
desc_parts.append(f"Superficie {area:.0f} pi²")
if "+" in raw_type or payload.get("office"):
desc_parts.append("Avec espace bureau")
details: dict = {"contact": {"phone": PHONE}}
if floor and 0 < int(floor) <= 60:
details["floor"] = int(floor)
return Listing(
source=self.source_id,
external_id=f"unite-{unit_no}",
url=url,
title=f"La Sentinelle — Unité {unit_no}"
f"{' (' + unit_type + ')' if unit_type else ''}",
address=ADDRESS,
sector=SECTOR,
city=infer_city(SECTOR, default="Lévis"),
unit_type=unit_type,
price=price,
price_label=price_label,
availability=availability,
area_sqft=float(area) if area else None,
description=" | ".join(desc_parts),
amenities=list(payload.get("amenities") or []),
details=details,
images=list(payload.get("images") or []),
)