# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/gcs_gabriel.py : connecteur GCS Le Gabriel # (gcs-legabriel.ca — 2860, rue Gabriel-Le Prévost, coin route de l'Église, # plateau de Sainte-Foy, Québec : 121 condos locatifs sur 13 étages, # promoteur GCS Développement immobilier). Site vitrine statique rendu # serveur : les prix « UNITÉS À PARTIR DE » sont affichés PAR TYPE dans le # tableau du slider (Studios 1 241 $* … # 5 1/2 2 848 $*). Une annonce par type d'unité — la granularité du site — # avec les inclusions de la section « Inclusions » et les photos du slider. # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price from .base import BaseConnector BASE = "https://gcs-legabriel.ca" PAGE_URL = f"{BASE}/" ADDRESS = "2860, rue Gabriel-Le Prévost, Québec" SECTOR = "Sainte-Foy" CITY = "Québec" LAT, LNG = 46.774762, -71.298140 # visite virtuelle Google Maps de la page # type affiché -> suffixe d'identifiant stable TYPE_RE = re.compile(r"^(Studios?|\d\s*1/2\s*\+?)$", re.I) IMG_RE = re.compile(r'(?:src|href)="((?:images/content/[^"]+|' r"images/[^\"]*slider[^\"]+)\.(?:jpe?g|png|webp))\"", re.I) class GcsGabrielConnector(BaseConnector): source_id = "gcs_gabriel" request_delay = 0.6 def fetch(self) -> list[Listing]: listings: list[Listing] = [] html = self.get(PAGE_URL).text soup = BeautifulSoup(html, "html.parser") # photos du slider / des sections contenu images = [f"{BASE}/{u.lstrip('/')}" for u in dict.fromkeys(IMG_RE.findall(html)) if not re.search(r"logo|icon|plan", u, re.I)][:20] # inclusions de l'immeuble (accordéon « Inclusions » : div.acc-header # suivi d'un div.acc-content avec la liste
  • ) amenities: list[str] = [] for h in soup.select("div.acc-header"): if h.get_text(strip=True).lower().startswith("inclusion"): ul = h.find_next("ul") if ul: amenities = [li.get_text(" ", strip=True) for li in ul.find_all("li") if li.get_text(strip=True)][:20] break # contact (lien tel: + courriel du pied de page) details_common: dict = {} contact: dict = {} m = re.search(r'href="tel:\+?1?(\d{10})"', html) if m: d10 = m.group(1) contact["phone"] = f"{d10[:3]}-{d10[3:6]}-{d10[6:]}" m = re.search(r'(?:href="mailto:)?([\w.\-]+@gcs[\w.\-]*\.com)', html) if m: contact["email"] = m.group(1).lower() if contact: details_common["contact"] = contact # disponibilité affichée dans le slider (« Possibilité d'occupation # immédiate ! ») availability = "" am = re.search(r"Possibilit[ée] d[’']occupation\s*(?:)?\s*" r"immédiate", html, re.I) if am: availability = "Possibilité d'occupation immédiate" # tableau « UNITÉS À PARTIR DE » : une rangée par type table = soup.select_one("table.prenezmaintable") if table is None: return listings for tr in table.find_all("tr"): try: tds = tr.find_all("td") if len(tds) < 2: continue type_txt = tds[0].get_text(strip=True) if not TYPE_RE.match(type_txt): continue price_txt = tds[1].get_text(strip=True) price = parse_price(price_txt) if price is None: continue plus = type_txt.rstrip("*").strip().endswith("+") unit_type = normalize_unit_type( type_txt.rstrip("+*s ") or type_txt) extra = [tds[2].get_text(" ", strip=True)] \ if len(tds) > 2 and tds[2].get_text(strip=True) else [] ext_id = f"type-{unit_type.replace('½', '.5').lower()}" + \ ("-plus" if plus else "") label = f"{unit_type}+" if plus else unit_type listings.append(Listing( source=self.source_id, external_id=ext_id, url=PAGE_URL, title=f"{label} — Le Gabriel", address=ADDRESS, sector=SECTOR, city=CITY, unit_type=unit_type, price=price, price_label=f"À partir de {price_txt}".replace("*", ""), availability=availability, amenities=list(dict.fromkeys(extra + amenities)), details=dict(details_common), images=images, lat=LAT, lng=LNG, )) except Exception: continue return listings