Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
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