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/forsa.py : connecteur Gestion immobilière Forsa (gestionforsa.com)5# Squarespace. La page /logements-disponibles expose toutes les unités en6# sections « liste » (li.list-item) : titre « 2 1/2, Joliette - MAINTENANT »7# (type + ville + dispo), description (adresse civique + prix mensuel) et8# bouton « Détails » vers une page par unité (slug stable = external_id).9# Fiches détail (cache BD) : description riche + galerie complète.10# NB : robots.txt interdit /api/ et ?format=json → HTML seulement.11# -----------------------------------------------------------------------------12from __future__ import annotations1314import hashlib15import re1617from bs4 import BeautifulSoup1819from ..schema import Listing, normalize_unit_type, parse_price20from .base import BaseConnector2122BASE = "https://www.gestionforsa.com"23LIST_URL = f"{BASE}/logements-disponibles"2425# villes réelles du parc (titres des cartes) — abréviations usuelles → nom complet26_CITY_MAP = {27 "joliette": "Joliette",28 "st-gabriel": "Saint-Gabriel",29 "saint-gabriel": "Saint-Gabriel",30 "st-charles-borromee": "Saint-Charles-Borromée",31 "saint-charles-borromee": "Saint-Charles-Borromée",32 "montreal": "Montréal",33}3435_FORMAT_QS = re.compile(r"\?format=\d+w$")363738def _city_from(raw: str) -> str:39 key = (raw.strip().lower()40 .replace("é", "e").replace("è", "e").replace("ô", "o"))41 return _CITY_MAP.get(key, raw.strip())424344class ForsaConnector(BaseConnector):45 source_id = "forsa"46 request_delay = 0.847 max_pages = 1 # tout tient sur la page /logements-disponibles48 max_details = 20 # garde-fou fiches détail (vraies requêtes)4950 def fetch(self) -> list[Listing]:51 html = self.get(LIST_URL).text52 soup = BeautifulSoup(html, "html.parser")53 listings: dict[str, Listing] = {}54 for item in soup.select("li.list-item"):55 try:56 self._parse_card(item, listings)57 except Exception:58 continue5960 # fiches détail (cache BD) : description complète + galerie61 self._fetched = 062 for lst in listings.values():63 card_key = hashlib.sha1(64 f"{lst.title}|{lst.price_label}|{lst.availability}"65 .encode("utf-8")).hexdigest()66 try:67 payload = self.detail(lst.external_id, card_key,68 lambda u=lst.url: self._fetch_detail(u))69 except Exception:70 continue71 if payload.get("description"):72 lst.description = payload["description"]73 if payload.get("images"):74 lst.images = payload["images"]75 return list(listings.values())7677 # -- carte « liste » Squarespace -------------------------------------------78 def _parse_card(self, item, listings: dict[str, Listing]) -> None:79 title_el = item.select_one(".list-item-content__title")80 btn = item.select_one("a.list-item-content__button[href]")81 if not (title_el and btn):82 return83 title = title_el.get_text(" ", strip=True)84 slug = btn["href"].strip("/").split("/")[-1]85 if not slug or slug in listings:86 return8788 # exclusions : chalet à la journée (location saisonnière, pas un bail),89 # locaux commerciaux, stationnements, rangements90 if re.search(r"chalet|commercial|stationnement|rangement|entrep[oô]t",91 title, re.I):92 return9394 # titre « 2 1/2, Joliette - MAINTENANT » → type, ville, disponibilité95 unit_type, city, availability = "", "", ""96 parts = [p.strip() for p in title.split(",", 1)]97 unit_type = normalize_unit_type(parts[0])98 rest = parts[1] if len(parts) > 1 else ""99 m = re.split(r"\s[-–]\s|,", rest, maxsplit=1)100 if m:101 city = _city_from(m[0])102 if len(m) > 1:103 availability = m[1].strip()104105 # description de la carte : adresse civique (1er §) + prix (§ avec $)106 address, price_label = "", ""107 desc_el = item.select_one(".list-item-content__description")108 if desc_el:109 paras = [p.get_text(" ", strip=True)110 for p in desc_el.select("p") if p.get_text(strip=True)]111 for p in paras:112 if not price_label and "$" in p:113 price_label = p114 elif not address:115 address = p116117 img = item.select_one("img.list-image[data-src]")118 images = [_FORMAT_QS.sub("", img["data-src"])] if img else []119120 # « 1 290 $ par mois » : espace fine de milliers → retirer pour parse_price121 clean_price = re.sub(r"(\d)[\s ](\d{3})", r"\1\2", price_label)122123 listings[slug] = Listing(124 source=self.source_id,125 external_id=slug,126 url=f"{BASE}/{slug}",127 title=title,128 address=address,129 city=city,130 unit_type=unit_type,131 price=parse_price(clean_price),132 price_label=price_label,133 availability=availability,134 images=images,135 )136137 # -- fiche détail -------------------------------------------------------------138 def _fetch_detail(self, url: str) -> dict:139 """Description riche (environnement/logement/immeuble, inclusions,140 exclusions) et galerie complète de la page unité."""141 if self._fetched >= self.max_details:142 raise RuntimeError("budget de fiches détail atteint")143 self._fetched += 1144 html = self.get(url).text145 soup = BeautifulSoup(html, "html.parser")146 out: dict = {}147148 # bloc texte principal = le plus long des blocs HTML (hors pied de page)149 best = ""150 for b in soup.select("div.sqs-block-html"):151 t = b.get_text("\n", strip=True)152 if re.search(r"Squarespace|©|Information de contact", t):153 continue154 if len(t) > len(best):155 best = t156 if best:157 out["description"] = re.sub(r"[ \t]+", " ", best).strip()[:1500]158159 images: list[str] = []160 for img in soup.select("img[data-src*='squarespace-cdn']"):161 u = _FORMAT_QS.sub("", img["data-src"]).strip()162 if u.startswith("http") and u not in images:163 images.append(u)164 out["images"] = images[:30]165 return out166