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%
6.0 KB · 153 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/gbd.py : connecteur GBD Location (gbdlocation.com) — promoteur-5#   locateur des Basses-Laurentides : Escapade (3045 chemin d'Oka) et6#   Centrocité (2969-3019 boul. des Promenades) à Sainte-Marthe-sur-le-Lac,7#   Triplex rue André (3159-3163), East River/Albatros (710-740 rue des8#   Hérons, Saint-Eustache), Quartier Urbain (rue de la Salamandre,9#   Deux-Montagnes). Site statique (HTML latin-1 rendu serveur) : l'accueil10#   liste les pages /projets-locatifs/… ; chaque page projet publie11#   « VILLE : … », « Nombre de Chambres : … », « Nombre de pièces : n ½ »,12#   « Emplacement(s) : <adresses> » et les caractéristiques (li sans lien).13#   Certaines pages historiques (Hudson River, La Vallée) sont mortes mais14#   répondent 200 : détectées par l'absence de « VILLE : ». Aucun prix15#   publié (jamais inventé). Granularité TYPOLOGIE par projet.16# -----------------------------------------------------------------------------17from __future__ import annotations1819import re2021from bs4 import BeautifulSoup2223from ..schema import Listing24from .base import BaseConnector2526BASE = "https://www.gbdlocation.com"2728CITY_RE = re.compile(r"VILLE\s*:\s*([\wÀ-ÿ' -]{3,40}?)\s+Immeubles?\b", re.I)29BEDS_RE = re.compile(r"Nombre de [Cc]hambres\s*:\s*([\d, ]+(?:ou\s*\d+)?)")30TYPES_RE = re.compile(r"Nombre de pièces\s*:\s*((?:\d\s*½\s*(?:et\s*)?)+)", re.I)31TYPE_TOKEN_RE = re.compile(r"(\d)\s*½")32ADDR_RE = re.compile(r"Emplacements?\s*:\s*([^\n]{5,90}?)\s*"33                     r"(?=(?:Situé|Projet|Les\s|À proximité|$))", re.I)34FLOORS_RE = re.compile(r"Immeubles?\s*:\s*(\d)\s*étages?", re.I)3536# graphies abrégées du site -> toponymes officiels37CITY_MAP = {38    "St-Eustache": "Saint-Eustache",39    "Ste-Marthe-sur-le-Lac": "Sainte-Marthe-sur-le-Lac",40}414243class GbdConnector(BaseConnector):44    source_id = "gbd"45    request_delay = 0.84647    def fetch(self) -> list[Listing]:48        listings: list[Listing] = []49        try:50            # HTML encodé latin-1 : bs4 détecte l'encodage depuis les octets51            home = BeautifulSoup(self.get(BASE + "/").content, "html.parser")52        except Exception:53            return listings54        paths: list[str] = []55        for a in home.select('a[href*="/projets-locatifs/"], '56                             'a[href*="/locations-maisons"]'):57            href = (a.get("href") or "").split("#")[0]58            if href.startswith("/") and href.count("/") >= 2 \59                    and href not in paths:60                paths.append(href)61        for path in paths:62            try:63                listings.extend(self._project(path))64            except Exception:65                continue                       # une page cassée ne bloque pas66        return listings6768    # -- page projet : ville, typologies, adresse, caractéristiques ------------69    def _project(self, path: str) -> list[Listing]:70        url = BASE + path71        soup = BeautifulSoup(self.get(url).content, "html.parser")72        text = soup.get_text(" ", strip=True)73        cm = CITY_RE.search(text)74        if cm is None:75            return []                          # page morte (« Page d'erreur »)76        city = cm.group(1).strip()77        city = CITY_MAP.get(city, city)7879        h1 = soup.select_one("h1")80        name = h1.get_text(" ", strip=True) if h1 else path.strip("/")81        slug = path.rstrip("/").rsplit("/", 1)[-1]8283        tm = TYPES_RE.search(text)84        types = [f"{n}½" for n in TYPE_TOKEN_RE.findall(tm.group(1))] \85            if tm else []86        if not types:87            return []8889        beds_by_type: dict[str, float] = {}90        bm = BEDS_RE.search(text)91        if bm:92            nums = [float(n) for n in re.findall(r"\d+", bm.group(1))]93            if len(nums) == len(types):        # « 2 ou 3 » -> 4½=2ch, 5½=3ch94                beds_by_type = dict(zip(types, nums))95            elif len(nums) == 1:96                beds_by_type = {t: nums[0] for t in types}9798        address = ""99        am = ADDR_RE.search(text)100        if am:101            address = am.group(1).strip(" ,.")102103        desc = ""104        for p in soup.select("p"):105            t = p.get_text(" ", strip=True)106            if len(t) > 80 and not t.startswith("*"):107                desc = t108                break109110        # caractéristiques : les <li> sans lien (les <li> de navigation en ont)111        amenities = []112        for li in soup.select("li"):113            if li.find("a") is None:114                t = li.get_text(" ", strip=True)115                if 8 < len(t) < 120 and t not in amenities:116                    amenities.append(t)117118        images = []119        for img in soup.select("img"):120            src = img.get("src") or img.get("data-src") or ""121            if src.startswith("/images/") and re.search(r"\.(?:jpe?g|png|webp)$",122                                                        src, re.I):123                full = BASE + src124                if full not in images and not re.search(r"logo|plan", full, re.I):125                    images.append(full)126127        details: dict = {"building": name}128        fm = FLOORS_RE.search(text)129        if fm:130            details["floors"] = fm.group(1)131132        # une annonce par typologie (aucun prix publié par GBD)133        out: list[Listing] = []134        for ut in types:135            out.append(Listing(136                source=self.source_id,137                external_id=f"{slug}-{ut.replace('½', '.5')}",138                url=url,139                title=f"{name} — {ut}",140                address=address,141                city=city,142                unit_type=ut,143                bedrooms=beds_by_type.get(ut),144                price=None,145                price_label="",146                availability="Sur demande",147                description=desc[:2000],148                amenities=list(amenities),149                details=dict(details),150                images=images[:15],151            ))152        return out153