SPB Git forge

spb/lou-ka

Public

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

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
9.5 KB · 222 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/gwlra.py : connecteur GWL Realty Advisors Residential5#   (gwlraresidential.com — gestionnaire pancanadien, plateforme RentCafe/Yardi6#   derrière Cloudflare : tout passe par Scrapfly ASP).7#   Découverte : sitemap.xml → pages /apartments/qc/<ville>/<slug>/floorplans8#   (immeubles du Québec seulement). Chaque page floorplans (rendu serveur)9#   expose les plans « div.fp-container » (id fp-container-<fpid>) avec le10#   prix « à partir de $X /mois » — seuls les plans avec prix sont retenus11#   (les autres = liste d'attente) — plus un JSON-LD ApartmentComplex12#   (adresse civique, animaux, chambres/sdb/pi² par plan). Une annonce par13#   plan d'étage disponible, external_id = <slug>-<fpid>.14# -----------------------------------------------------------------------------15from __future__ import annotations1617import json18import re19import time2021from bs4 import BeautifulSoup2223from ..schema import Listing, normalize_unit_type, parse_price24from .base import BaseConnector2526BASE = "https://www.gwlraresidential.com"27SITEMAP_URL = f"{BASE}/sitemap.xml"2829FLOORPLAN_PATH_RE = re.compile(30    r"apartments/qc/([a-z0-9-]+)/([a-z0-9-]+)/floorplans")31FPID_RE = re.compile(r"fp-container-(\d+)")32LDJSON_RE = re.compile(33    r'<script[^>]*type="application/ld\+json"[^>]*>(.*?)</script>', re.S)34TITLE_RE = re.compile(r"<title>\s*Plans d.étage au (.+?) à ", re.S)35PRICE_RE = re.compile(r"à partir de\s*\|?\s*(\$[\d,\s]+(?:\.\d{2})?)", re.I)36BED_RE = re.compile(r"(\d+)\s*\|\s*Chambre", re.I)37BATH_RE = re.compile(r"([\d.]+)\s*\|\s*Salle", re.I)3839# ville du chemin d'URL -> nom d'affichage40_CITIES = {"montreal": "Montréal", "laval": "Laval", "quebec": "Québec",41           "longueuil": "Longueuil", "brossard": "Brossard"}424344class GwlraConnector(BaseConnector):45    source_id = "gwlra"46    request_delay = 1.047    use_detail_cache = False        # tout passe par Scrapfly, pages fraîches4849    def fetch(self) -> list[Listing]:50        # Un raté transitoire (Scrapfly vide, vu 2026-08-30 ~04:20) produisait51        # un faux « 0 trouvé ok » (médiane 1) : retry du sitemap (3 essais,52        # backoff 5/10 s) puis erreur franche plutôt qu'un zéro silencieux.53        pages: dict[str, tuple[str, str]] = {}   # slug -> (ville, chemin)54        for attempt in range(3):55            try:56                sitemap = self.get_scrapfly(SITEMAP_URL, render_js=False)57            except Exception:58                if attempt == 2:59                    raise60                sitemap = ""61            for city, slug in FLOORPLAN_PATH_RE.findall(sitemap):62                pages.setdefault(slug, (city, f"apartments/qc/{city}/{slug}"63                                              "/floorplans"))64            if pages:65                break66            if attempt < 2:67                time.sleep(5 * (attempt + 1))68        if not pages:69            raise RuntimeError(70                "sitemap sans page floorplans QC après 3 essais")7172        listings: list[Listing] = []73        errors = 074        last_exc: Exception | None = None75        for slug, (city_slug, path) in sorted(pages.items()):76            try:77                listings.extend(self._building(slug, city_slug,78                                               f"{BASE}/{path}"))79            except Exception as exc:80                errors += 181                last_exc = exc82        # tous les immeubles en erreur et rien récolté : erreur franche83        if not listings and last_exc is not None and errors == len(pages):84            raise last_exc85        return listings8687    def _building(self, slug: str, city_slug: str, url: str) -> list[Listing]:88        # le sitemap contient des immeubles retirés (404) : vérifier le statut.89        # Les ratés transitoires (5xx, contenu vide) sont retentés 3 fois puis90        # remontés en erreur franche — seul un 404/410 vaut « immeuble retiré ».91        # Vu 2026-09-06 : Scrapfly renvoie parfois un rendu PARTIEL (200, ~7 Ko92        # de shell GTM/OneTrust sans grille de plans ni JSON-LD) → faux « 093        # trouvé ok ». Une page floorplans complète contient toujours le94        # JSON-LD ApartmentComplex (rendu serveur), même sans dispo : exiger95        # fp-container OU ApartmentComplex avant d'accepter le HTML.96        html = ""97        for attempt in range(3):98            result = self.scrapfly(url, render_js=True)99            status = result.get("status_code")100            if status in (404, 410):101                return []102            html = result.get("content") or ""103            complete = "fp-container" in html or "ApartmentComplex" in html104            if status == 200 and html and complete:105                break106            if attempt < 2:107                time.sleep(5 * (attempt + 1))108        else:109            raise RuntimeError(110                f"page floorplans {url} en échec après 3 essais "111                f"(statut {status!r}, contenu "112                f"{'vide' if not html else 'partiel/incomplet'})")113        soup = BeautifulSoup(html, "html.parser")114115        # JSON-LD ApartmentComplex : nom, adresse, animaux, détail des plans116        # (extraction regex sur le HTML brut : le rendu Scrapfly est parfois117        # capricieux avec les <script> dans BeautifulSoup)118        name, address, pets = "", "", None119        hero = ""120        plans: dict[str, dict] = {}          # nom du plan -> détail JSON-LD121        blobs = [s.string or s.get_text() for s in122                 soup.find_all("script", type="application/ld+json")]123        blobs += LDJSON_RE.findall(html)124        for raw in blobs:125            try:126                d = json.loads(raw or "", strict=False)127            except (ValueError, TypeError):128                continue129            if not isinstance(d, dict) or "ApartmentComplex" not in \130                    str(d.get("@type", "")):131                continue132            name = (d.get("name") or "").strip()133            adr = d.get("address") or {}134            street = (adr.get("streetAddress") or "").strip()135            loc = (adr.get("addressLocality") or "").strip()136            postal = (adr.get("postalCode") or "").strip()137            address = ", ".join(x for x in (street, loc) if x)138            if address and postal:139                address += f", QC {postal}"140            if isinstance(d.get("petsAllowed"), bool):141                pets = "oui" if d["petsAllowed"] else "non"142            for fp in d.get("accommodationFloorPlan") or []:143                if isinstance(fp, dict) and fp.get("name"):144                    plans[fp["name"].strip().lower()] = fp145            if isinstance(d.get("image"), str):146                hero = d["image"]147            break148149        if not name:                          # repli : <title> de la page150            tm = TITLE_RE.search(html)151            if tm:152                name = tm.group(1).strip()153        city = _CITIES.get(city_slug, city_slug.replace("-", " ").title())154155        listings: list[Listing] = []156        for div in soup.select("div.fp-container[id]"):157            m = FPID_RE.search(div.get("id", ""))158            if not m:159                continue160            fpid = m.group(1)161            text = div.get_text(" | ", strip=True).replace("\xa0", " ")162            pm = PRICE_RE.search(text)163            if not pm:                       # pas de prix = liste d'attente164                continue165            fp_name = ""166            h = div.find(["h2", "h3", "h4", "strong"])167            if h:168                fp_name = h.get_text(" ", strip=True)169            if not fp_name:170                fp_name = text.split(" | ", 1)[0]171172            bedrooms = bathrooms = None173            area = None174            bm = BED_RE.search(text)175            if bm:176                bedrooms = float(bm.group(1))177            am = BATH_RE.search(text)178            if am:179                try:180                    bathrooms = float(am.group(1))181                except ValueError:182                    pass183            fp = plans.get(fp_name.strip().lower())184            if fp:185                if bedrooms is None and fp.get("numberOfBedrooms") is not None:186                    bedrooms = float(fp["numberOfBedrooms"])187                if bathrooms is None and \188                        fp.get("numberOfFullBathrooms") is not None:189                    bathrooms = float(fp["numberOfFullBathrooms"])190                size = fp.get("floorSize") or {}191                if isinstance(size, dict) and size.get("minValue"):192                    area = float(size["minValue"])193194            images = []195            for img in div.find_all("img"):196                u = img.get("src") or img.get("data-src") or ""197                if u.startswith("http"):198                    images.append(u)199            if hero:200                images.append(hero)201202            price_label = f"À partir de {pm.group(1)}/mois"203            listings.append(Listing(204                source=self.source_id,205                external_id=f"{slug}-{fpid}",206                url=url,207                title=f"{name or slug} — {fp_name}",208                address=address,209                sector="",210                city=city,211                unit_type=normalize_unit_type(fp_name),212                bedrooms=bedrooms,213                bathrooms=bathrooms,214                price=parse_price(pm.group(1)),215                price_label=price_label,216                availability="Disponible",217                area_sqft=area,218                pets=pets,219                images=list(dict.fromkeys(images))[:10],220            ))221        return listings222