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
2 days agolast push
HTML 98.9% Python 0.6%
5.9 KB · 135 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/condos18.py : connecteur Condos Locatifs 185#   (condoslocatifs18.ca — 1195, 18e Rue, Limoilou, Québec, à deux pas de6#    l'hôpital de l'Enfant-Jésus/CHU). One-pager maison rendu serveur : les7#    prix sont affichés PAR TYPE d'unité (« 3 1/2 … À partir de 1200$ par8#    mois ») dans des onglets #s312/#s412/#s512, chacun listant ses variantes9#    de plans (Emplacement / Description / Superficie). Une annonce par type10#    d'unité — la granularité du site — avec la fourchette de superficies des11#    variantes, les plans (lightbox) et les photos du modèle en galerie.12# -----------------------------------------------------------------------------13from __future__ import annotations1415import re1617from bs4 import BeautifulSoup1819from ..schema import Listing, normalize_unit_type, parse_price20from .base import BaseConnector2122BASE = "https://condoslocatifs18.ca"23PAGE_URL = f"{BASE}/"2425ADDRESS = "1195, 18e Rue, Québec, G1J 0G5"26SECTOR = "Limoilou"27CITY = "Québec"28LAT, LNG = 46.835710, -71.227340    # marqueur Google Maps de la page2930AREA_RE = re.compile(r"Superficie\s*:\s*([\d\s]+)\s*pi", re.I)31FLOOR_RE = re.compile(r"Emplacement\s*:\s*([^\n<]+)", re.I)32DESC_RE = re.compile(r"Description\s*:\s*([^\n<]+)", re.I)33# photos du modèle / de l'immeuble (hors icônes, logos et plans)34PHOTO_RE = re.compile(r'(?:src|href)="(/images/condos-locatifs-18/'35                      r'(?:modele-condo-interieur-exterieur/big/[^"]+|'36                      r"condo-a-louer/\d+[^\"]*|img-exterieur[^\"]*))\"", re.I)373839class Condos18Connector(BaseConnector):40    source_id = "condos18"41    request_delay = 0.64243    def fetch(self) -> list[Listing]:44        listings: list[Listing] = []45        html = self.get(PAGE_URL).text46        soup = BeautifulSoup(html, "html.parser")4748        # photos communes de l'immeuble/du modèle49        photos = [BASE + u for u in dict.fromkeys(PHOTO_RE.findall(html))50                  if not re.search(r"_tn|tn\.png|map-", u, re.I)][:15]5152        details_common: dict = {}53        m = re.search(r'href="mailto:([^"?]+)"', html)54        contact = {"email": m.group(1).strip().lower()} if m else {}55        m = re.search(r'href="tel:\+?1?[\s.]*(\d{3})[\s.]*(\d{3})[\s.]*(\d{4})"',56                      html)57        if not m:58            m = re.search(r"\b(418)[\s.]+(\d{3})[\s.]+(\d{4})\b",59                          soup.get_text(" ", strip=True))60        if m:61            contact["phone"] = f"{m.group(1)}-{m.group(2)}-{m.group(3)}"62        if contact:63            details_common["contact"] = contact6465        # onglets prix par type : bouton « 3 1/2 » (id="#s312") + « À partir66        # de 1200$ par mois », puis panneau <div id="s312"> avec les variantes67        for btn in soup.select("a.bouton.appart"):68            try:69                unit_type = normalize_unit_type(btn.get_text(strip=True))70                if not unit_type:71                    continue72                pane_id = (btn.get("id") or "").lstrip("#")73                info = btn.find_next(class_="info_cachee")74                price_label = re.sub(75                    r"\s+", " ", info.get_text(" ", strip=True)) if info else ""76                price = parse_price(price_label)7778                # variantes de plans du panneau correspondant79                pane = soup.find("div", id=pane_id) if pane_id else None80                areas: list[float] = []81                floors: list[str] = []82                desc = ""83                plans: list[str] = []84                if pane is not None:85                    pane_html = str(pane)86                    for a in AREA_RE.findall(pane_html):87                        try:88                            v = float(a.replace(" ", "").replace("&nbsp;", ""))89                            if 200 <= v <= 3000:90                                areas.append(v)91                        except ValueError:92                            continue93                    floors = [f.strip() for f in FLOOR_RE.findall(pane_html)]94                    dm = DESC_RE.search(pane_html)95                    if dm:96                        desc = dm.group(1).strip().rstrip(".") + "."97                    plans = [BASE + u for u in dict.fromkeys(98                        re.findall(r'href="(/images/[^"]*plan-big[^"]+)"',99                                   pane_html, re.I))]100101                amenities: list[str] = []102                if areas:103                    lo, hi = min(areas), max(areas)104                    amenities.append(105                        f"Superficies : {int(lo)} pi²" if lo == hi106                        else f"Superficies : {int(lo)} à {int(hi)} pi²")107                if floors:108                    amenities.append(f"Emplacement : {floors[0]}")109                amenities += ["Ascenseur", "Climatisation murale",110                              "Insonorisation supérieure",111                              "Stationnement intérieur (option)", "Gym"]112113                listings.append(Listing(114                    source=self.source_id,115                    external_id=f"type-{unit_type.replace('½', '.5')}",116                    url=PAGE_URL,117                    title=f"{unit_type} — Condos Locatifs 18",118                    address=ADDRESS,119                    sector=SECTOR,120                    city=CITY,121                    unit_type=unit_type,122                    price=price,123                    price_label=price_label,124                    area_sqft=min(areas) if areas else None,125                    description=desc,126                    amenities=amenities,127                    details=dict(details_common),128                    images=photos + plans,129                    lat=LAT,130                    lng=LNG,131                ))132            except Exception:133                continue134        return listings135