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/gcs_gabriel.py : connecteur GCS Le Gabriel5# (gcs-legabriel.ca — 2860, rue Gabriel-Le Prévost, coin route de l'Église,6# plateau de Sainte-Foy, Québec : 121 condos locatifs sur 13 étages,7# promoteur GCS Développement immobilier). Site vitrine statique rendu8# serveur : les prix « UNITÉS À PARTIR DE » sont affichés PAR TYPE dans le9# tableau <table class="prenezmaintable"> du slider (Studios 1 241 $* …10# 5 1/2 2 848 $*). Une annonce par type d'unité — la granularité du site —11# avec les inclusions de la section « Inclusions » et les photos du slider.12# -----------------------------------------------------------------------------13from __future__ import annotations1415import re1617from bs4 import BeautifulSoup1819from ..schema import Listing, normalize_unit_type, parse_price20from .base import BaseConnector2122BASE = "https://gcs-legabriel.ca"23PAGE_URL = f"{BASE}/"2425ADDRESS = "2860, rue Gabriel-Le Prévost, Québec"26SECTOR = "Sainte-Foy"27CITY = "Québec"28LAT, LNG = 46.774762, -71.298140 # visite virtuelle Google Maps de la page2930# type affiché -> suffixe d'identifiant stable31TYPE_RE = re.compile(r"^(Studios?|\d\s*1/2\s*\+?)$", re.I)32IMG_RE = re.compile(r'(?:src|href)="((?:images/content/[^"]+|'33 r"images/[^\"]*slider[^\"]+)\.(?:jpe?g|png|webp))\"", re.I)343536class GcsGabrielConnector(BaseConnector):37 source_id = "gcs_gabriel"38 request_delay = 0.63940 def fetch(self) -> list[Listing]:41 listings: list[Listing] = []42 html = self.get(PAGE_URL).text43 soup = BeautifulSoup(html, "html.parser")4445 # photos du slider / des sections contenu46 images = [f"{BASE}/{u.lstrip('/')}" for u in47 dict.fromkeys(IMG_RE.findall(html))48 if not re.search(r"logo|icon|plan", u, re.I)][:20]4950 # inclusions de l'immeuble (accordéon « Inclusions » : div.acc-header51 # suivi d'un div.acc-content avec la liste <li>)52 amenities: list[str] = []53 for h in soup.select("div.acc-header"):54 if h.get_text(strip=True).lower().startswith("inclusion"):55 ul = h.find_next("ul")56 if ul:57 amenities = [li.get_text(" ", strip=True)58 for li in ul.find_all("li")59 if li.get_text(strip=True)][:20]60 break6162 # contact (lien tel: + courriel du pied de page)63 details_common: dict = {}64 contact: dict = {}65 m = re.search(r'href="tel:\+?1?(\d{10})"', html)66 if m:67 d10 = m.group(1)68 contact["phone"] = f"{d10[:3]}-{d10[3:6]}-{d10[6:]}"69 m = re.search(r'(?:href="mailto:)?([\w.\-]+@gcs[\w.\-]*\.com)', html)70 if m:71 contact["email"] = m.group(1).lower()72 if contact:73 details_common["contact"] = contact7475 # disponibilité affichée dans le slider (« Possibilité d'occupation76 # immédiate ! »)77 availability = ""78 am = re.search(r"Possibilit[ée] d[’']occupation\s*(?:<br\s*/?>)?\s*"79 r"immédiate", html, re.I)80 if am:81 availability = "Possibilité d'occupation immédiate"8283 # tableau « UNITÉS À PARTIR DE » : une rangée par type84 table = soup.select_one("table.prenezmaintable")85 if table is None:86 return listings87 for tr in table.find_all("tr"):88 try:89 tds = tr.find_all("td")90 if len(tds) < 2:91 continue92 type_txt = tds[0].get_text(strip=True)93 if not TYPE_RE.match(type_txt):94 continue95 price_txt = tds[1].get_text(strip=True)96 price = parse_price(price_txt)97 if price is None:98 continue99 plus = type_txt.rstrip("*").strip().endswith("+")100 unit_type = normalize_unit_type(101 type_txt.rstrip("+*s ") or type_txt)102103 extra = [tds[2].get_text(" ", strip=True)] \104 if len(tds) > 2 and tds[2].get_text(strip=True) else []105 ext_id = f"type-{unit_type.replace('½', '.5').lower()}" + \106 ("-plus" if plus else "")107 label = f"{unit_type}+" if plus else unit_type108109 listings.append(Listing(110 source=self.source_id,111 external_id=ext_id,112 url=PAGE_URL,113 title=f"{label} — Le Gabriel",114 address=ADDRESS,115 sector=SECTOR,116 city=CITY,117 unit_type=unit_type,118 price=price,119 price_label=f"À partir de {price_txt}".replace("*", ""),120 availability=availability,121 amenities=list(dict.fromkeys(extra + amenities)),122 details=dict(details_common),123 images=images,124 lat=LAT,125 lng=LNG,126 ))127 except Exception:128 continue129 return listings130