# ----------------------------------------------------------------------------- # 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 112414½+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 []), )