# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/forsa.py : connecteur Gestion immobilière Forsa (gestionforsa.com) # Squarespace. La page /logements-disponibles expose toutes les unités en # sections « liste » (li.list-item) : titre « 2 1/2, Joliette - MAINTENANT » # (type + ville + dispo), description (adresse civique + prix mensuel) et # bouton « Détails » vers une page par unité (slug stable = external_id). # Fiches détail (cache BD) : description riche + galerie complète. # NB : robots.txt interdit /api/ et ?format=json → HTML seulement. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price from .base import BaseConnector BASE = "https://www.gestionforsa.com" LIST_URL = f"{BASE}/logements-disponibles" # villes réelles du parc (titres des cartes) — abréviations usuelles → nom complet _CITY_MAP = { "joliette": "Joliette", "st-gabriel": "Saint-Gabriel", "saint-gabriel": "Saint-Gabriel", "st-charles-borromee": "Saint-Charles-Borromée", "saint-charles-borromee": "Saint-Charles-Borromée", "montreal": "Montréal", } _FORMAT_QS = re.compile(r"\?format=\d+w$") def _city_from(raw: str) -> str: key = (raw.strip().lower() .replace("é", "e").replace("è", "e").replace("ô", "o")) return _CITY_MAP.get(key, raw.strip()) class ForsaConnector(BaseConnector): source_id = "forsa" request_delay = 0.8 max_pages = 1 # tout tient sur la page /logements-disponibles max_details = 20 # garde-fou fiches détail (vraies requêtes) def fetch(self) -> list[Listing]: html = self.get(LIST_URL).text soup = BeautifulSoup(html, "html.parser") listings: dict[str, Listing] = {} for item in soup.select("li.list-item"): try: self._parse_card(item, listings) except Exception: continue # fiches détail (cache BD) : description complète + galerie self._fetched = 0 for lst in listings.values(): card_key = hashlib.sha1( f"{lst.title}|{lst.price_label}|{lst.availability}" .encode("utf-8")).hexdigest() try: payload = self.detail(lst.external_id, card_key, lambda u=lst.url: self._fetch_detail(u)) except Exception: continue if payload.get("description"): lst.description = payload["description"] if payload.get("images"): lst.images = payload["images"] return list(listings.values()) # -- carte « liste » Squarespace ------------------------------------------- def _parse_card(self, item, listings: dict[str, Listing]) -> None: title_el = item.select_one(".list-item-content__title") btn = item.select_one("a.list-item-content__button[href]") if not (title_el and btn): return title = title_el.get_text(" ", strip=True) slug = btn["href"].strip("/").split("/")[-1] if not slug or slug in listings: return # exclusions : chalet à la journée (location saisonnière, pas un bail), # locaux commerciaux, stationnements, rangements if re.search(r"chalet|commercial|stationnement|rangement|entrep[oô]t", title, re.I): return # titre « 2 1/2, Joliette - MAINTENANT » → type, ville, disponibilité unit_type, city, availability = "", "", "" parts = [p.strip() for p in title.split(",", 1)] unit_type = normalize_unit_type(parts[0]) rest = parts[1] if len(parts) > 1 else "" m = re.split(r"\s[-–]\s|,", rest, maxsplit=1) if m: city = _city_from(m[0]) if len(m) > 1: availability = m[1].strip() # description de la carte : adresse civique (1er §) + prix (§ avec $) address, price_label = "", "" desc_el = item.select_one(".list-item-content__description") if desc_el: paras = [p.get_text(" ", strip=True) for p in desc_el.select("p") if p.get_text(strip=True)] for p in paras: if not price_label and "$" in p: price_label = p elif not address: address = p img = item.select_one("img.list-image[data-src]") images = [_FORMAT_QS.sub("", img["data-src"])] if img else [] # « 1 290 $ par mois » : espace fine de milliers → retirer pour parse_price clean_price = re.sub(r"(\d)[\s  ](\d{3})", r"\1\2", price_label) listings[slug] = Listing( source=self.source_id, external_id=slug, url=f"{BASE}/{slug}", title=title, address=address, city=city, unit_type=unit_type, price=parse_price(clean_price), price_label=price_label, availability=availability, images=images, ) # -- fiche détail ------------------------------------------------------------- def _fetch_detail(self, url: str) -> dict: """Description riche (environnement/logement/immeuble, inclusions, exclusions) et galerie complète de la page unité.""" if self._fetched >= self.max_details: raise RuntimeError("budget de fiches détail atteint") self._fetched += 1 html = self.get(url).text soup = BeautifulSoup(html, "html.parser") out: dict = {} # bloc texte principal = le plus long des blocs HTML (hors pied de page) best = "" for b in soup.select("div.sqs-block-html"): t = b.get_text("\n", strip=True) if re.search(r"Squarespace|©|Information de contact", t): continue if len(t) > len(best): best = t if best: out["description"] = re.sub(r"[ \t]+", " ", best).strip()[:1500] images: list[str] = [] for img in soup.select("img[data-src*='squarespace-cdn']"): u = _FORMAT_QS.sub("", img["data-src"]).strip() if u.startswith("http") and u not in images: images.append(u) out["images"] = images[:30] return out