# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/gbd.py : connecteur GBD Location (gbdlocation.com) — promoteur- # locateur des Basses-Laurentides : Escapade (3045 chemin d'Oka) et # Centrocité (2969-3019 boul. des Promenades) à Sainte-Marthe-sur-le-Lac, # Triplex rue André (3159-3163), East River/Albatros (710-740 rue des # Hérons, Saint-Eustache), Quartier Urbain (rue de la Salamandre, # Deux-Montagnes). Site statique (HTML latin-1 rendu serveur) : l'accueil # liste les pages /projets-locatifs/… ; chaque page projet publie # « VILLE : … », « Nombre de Chambres : … », « Nombre de pièces : n ½ », # « Emplacement(s) : » et les caractéristiques (li sans lien). # Certaines pages historiques (Hudson River, La Vallée) sont mortes mais # répondent 200 : détectées par l'absence de « VILLE : ». Aucun prix # publié (jamais inventé). Granularité TYPOLOGIE par projet. # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing from .base import BaseConnector BASE = "https://www.gbdlocation.com" CITY_RE = re.compile(r"VILLE\s*:\s*([\wÀ-ÿ' -]{3,40}?)\s+Immeubles?\b", re.I) BEDS_RE = re.compile(r"Nombre de [Cc]hambres\s*:\s*([\d, ]+(?:ou\s*\d+)?)") TYPES_RE = re.compile(r"Nombre de pièces\s*:\s*((?:\d\s*½\s*(?:et\s*)?)+)", re.I) TYPE_TOKEN_RE = re.compile(r"(\d)\s*½") ADDR_RE = re.compile(r"Emplacements?\s*:\s*([^\n]{5,90}?)\s*" r"(?=(?:Situé|Projet|Les\s|À proximité|$))", re.I) FLOORS_RE = re.compile(r"Immeubles?\s*:\s*(\d)\s*étages?", re.I) # graphies abrégées du site -> toponymes officiels CITY_MAP = { "St-Eustache": "Saint-Eustache", "Ste-Marthe-sur-le-Lac": "Sainte-Marthe-sur-le-Lac", } class GbdConnector(BaseConnector): source_id = "gbd" request_delay = 0.8 def fetch(self) -> list[Listing]: listings: list[Listing] = [] try: # HTML encodé latin-1 : bs4 détecte l'encodage depuis les octets home = BeautifulSoup(self.get(BASE + "/").content, "html.parser") except Exception: return listings paths: list[str] = [] for a in home.select('a[href*="/projets-locatifs/"], ' 'a[href*="/locations-maisons"]'): href = (a.get("href") or "").split("#")[0] if href.startswith("/") and href.count("/") >= 2 \ and href not in paths: paths.append(href) for path in paths: try: listings.extend(self._project(path)) except Exception: continue # une page cassée ne bloque pas return listings # -- page projet : ville, typologies, adresse, caractéristiques ------------ def _project(self, path: str) -> list[Listing]: url = BASE + path soup = BeautifulSoup(self.get(url).content, "html.parser") text = soup.get_text(" ", strip=True) cm = CITY_RE.search(text) if cm is None: return [] # page morte (« Page d'erreur ») city = cm.group(1).strip() city = CITY_MAP.get(city, city) h1 = soup.select_one("h1") name = h1.get_text(" ", strip=True) if h1 else path.strip("/") slug = path.rstrip("/").rsplit("/", 1)[-1] tm = TYPES_RE.search(text) types = [f"{n}½" for n in TYPE_TOKEN_RE.findall(tm.group(1))] \ if tm else [] if not types: return [] beds_by_type: dict[str, float] = {} bm = BEDS_RE.search(text) if bm: nums = [float(n) for n in re.findall(r"\d+", bm.group(1))] if len(nums) == len(types): # « 2 ou 3 » -> 4½=2ch, 5½=3ch beds_by_type = dict(zip(types, nums)) elif len(nums) == 1: beds_by_type = {t: nums[0] for t in types} address = "" am = ADDR_RE.search(text) if am: address = am.group(1).strip(" ,.") desc = "" for p in soup.select("p"): t = p.get_text(" ", strip=True) if len(t) > 80 and not t.startswith("*"): desc = t break # caractéristiques : les
  • sans lien (les
  • de navigation en ont) amenities = [] for li in soup.select("li"): if li.find("a") is None: t = li.get_text(" ", strip=True) if 8 < len(t) < 120 and t not in amenities: amenities.append(t) images = [] for img in soup.select("img"): src = img.get("src") or img.get("data-src") or "" if src.startswith("/images/") and re.search(r"\.(?:jpe?g|png|webp)$", src, re.I): full = BASE + src if full not in images and not re.search(r"logo|plan", full, re.I): images.append(full) details: dict = {"building": name} fm = FLOORS_RE.search(text) if fm: details["floors"] = fm.group(1) # une annonce par typologie (aucun prix publié par GBD) out: list[Listing] = [] for ut in types: out.append(Listing( source=self.source_id, external_id=f"{slug}-{ut.replace('½', '.5')}", url=url, title=f"{name} — {ut}", address=address, city=city, unit_type=ut, bedrooms=beds_by_type.get(ut), price=None, price_label="", availability="Sur demande", description=desc[:2000], amenities=list(amenities), details=dict(details), images=images[:15], )) return out