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/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(" ", ""))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