SPB Git

spb/lou-ka Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

HTML 99.7%
6.5 KB · 166 lines python
Raw Blame History
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