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
3 days agolast push
HTML 98.9% Python 0.6%
5.3 KB · 130 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/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