spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/contraste.py : connecteur Contraste Immobilier5# (contrasteimmobilier.ca — Beauport, Limoilou, Ste-Foy, Val-Bélair,6# Lévis, St-Nicolas, St-Romuald). Site WordPress rendu serveur :7# page d'accueil = cartes d'immeubles, pages immeubles = unités8# individuelles (div.building-stack-unit avec attributs data-*).9# -----------------------------------------------------------------------------10from __future__ import annotations1112import json13import re1415from bs4 import BeautifulSoup1617from ..schema import Listing, infer_city, normalize_unit_type, parse_price18from .base import BaseConnector1920BASE = "https://contrasteimmobilier.ca"2122# Villes admissibles (agglomération Québec / Lévis) — ex. Beaupré est exclue.23ALLOWED_CITIES = {"quebec", "québec", "levis", "lévis"}2425ADDR_RE = re.compile(26 r"\d{1,5}[^,<>]{2,60},\s*[^,<>]{2,40},\s*Qu[ée]bec(?:,\s*[A-Z]\d[A-Z]\s?\d[A-Z]\d)?"27)28# Adresse courte des pages « projet » (ex. « 2784 ave Sasseville ») : un29# titre Elementor qui commence par un numéro civique + type de voie.30ADDR_SHORT_RE = re.compile(31 r"^\d{1,5}\s+(?:rue|av(?:e|enue)?\.?|boul(?:evard)?\.?|ch(?:emin)?\.?|"32 r"all[ée]e|place|c[ôo]te|montée|route)\b.{2,50}$", re.I)33PHONE_RE = re.compile(r"\(?\b([2-9]\d{2})\)?[\s.\-]?(\d{3})[\s.\-](\d{4})\b")34EMAIL_RE = re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.]+\b")353637class ContrasteConnector(BaseConnector):38 source_id = "contraste"39 request_delay = 0.640 max_buildings = 30 # garde-fou de crawl4142 def fetch(self) -> list[Listing]:43 listings: list[Listing] = []44 try:45 home = self.get(BASE).text46 except Exception:47 return listings4849 # 1) Cartes d'immeubles sur la page d'accueil : nom + ville + lien50 soup = BeautifulSoup(home, "html.parser")51 buildings: dict[str, dict] = {}52 for a in soup.select('a[href*="/appartements/"]'):53 href = (a.get("href") or "").split("?")[0]54 m = re.search(r"/appartements/([a-z0-9\-]+)/?$", href)55 if not m:56 continue57 slug = m.group(1)58 text = re.sub(r"\s+", " ", a.get_text(" ", strip=True))59 if not text or slug in buildings:60 continue61 # "Quartier Élévation I Lévis 6 unités disponibles ... Découvrir"62 name = re.split(r"\s(?:Québec|Lévis|Beaupré)\b", text)[0].strip()63 city_m = re.search(r"\b(Québec|Lévis|Beaupré)\b", text)64 city = city_m.group(1) if city_m else ""65 buildings[slug] = {"name": name or slug, "city": city}6667 # 2) Pages d'immeubles : unités individuelles68 for i, (slug, meta) in enumerate(buildings.items()):69 if i >= self.max_buildings:70 break71 if meta["city"] and meta["city"].lower() not in ALLOWED_CITIES:72 continue # hors Québec/Lévis (ex. Beaupré)73 url = f"{BASE}/appartements/{slug}/"74 try:75 html = self.get(url).text76 except Exception:77 continue78 bsoup = BeautifulSoup(html, "html.parser")7980 # Adresse civique de l'immeuble (bloc Elementor)81 address = sector = ""82 headings = [el.get_text(" ", strip=True)83 for el in bsoup.select(".elementor-heading-title")]84 for h in headings:85 m = ADDR_RE.search(h)86 if m:87 address = m.group(0).strip()88 break89 if not address:90 m = ADDR_RE.search(html)91 if m:92 address = m.group(0).strip()93 if not address:94 # Pages « projet » (Ellipse, Émergence II…) : adresse courte95 # sans ville dans le hero.96 for h in headings:97 if ADDR_SHORT_RE.match(h):98 address = h.strip()99 break100 if address:101 parts = [p.strip() for p in address.split(",")]102 if len(parts) >= 2:103 sector = parts[1]104 city = infer_city(sector, default=meta["city"] or "Québec")105106 # Description (meta og:description)107 desc = ""108 og = bsoup.find("meta", attrs={"property": "og:description"})109 if og and og.get("content"):110 desc = og["content"].strip()[:600]111112 # CARACTÉRISTIQUES de l'immeuble (répéteur JetEngine) :113 # inclusions, animaux, stationnement… — texte source fidèle.114 bldg_amenities = [el.get_text(" ", strip=True) for el in bsoup.select(115 ".jet-listing-dynamic-repeater__item span")]116 bldg_amenities = [a for a in dict.fromkeys(bldg_amenities) if a][:25]117118 # Contact « Pour prendre rendez-vous » (pages projet)119 contact: dict = {}120 for h in headings:121 m = EMAIL_RE.search(h)122 if m and not contact.get("email"):123 contact["email"] = m.group(0)124 m = PHONE_RE.search(h)125 if m and not contact.get("phone"):126 contact["phone"] = f"{m.group(1)}-{m.group(2)}-{m.group(3)}"127 details = {"contact": contact} if contact else {}128129 for unit in bsoup.select("div.building-stack-unit"):130 try:131 pid = unit.get("data-pid") or ""132 name = unit.get("data-name") or pid133 rooms = unit.get("data-rooms") or ""134 price_raw = unit.get("data-price") or ""135 if not pid:136 continue137 # Images : data-images = JSON [{urlPreview, url}, ...]138 images: list[str] = []139 cover = unit.get("data-image") or ""140 if cover:141 images.append(cover)142 try:143 for img in json.loads(unit.get("data-images") or "[]"):144 u = (img or {}).get("url") or ""145 if u:146 images.append(u)147 except (ValueError, TypeError):148 pass149 images = [u for u in dict.fromkeys(images)150 if not re.search(r"logo|icon|favicon", u, re.I)]151152 avail_el = unit.select_one(".building-stack-available-soon")153 availability = (avail_el.get_text(" ", strip=True)154 if avail_el else "Disponible")155 # data-price="0" = prix non affiché sur la carte156 if price_raw in ("", "0"):157 price_raw = ""158 price_label = f"{price_raw}$/mois" if price_raw else ""159160 # data-size est toujours « 0 » chez Contraste (non rempli) ;161 # on ne le prend que s'il devient plausible un jour.162 area = None163 try:164 size = float(unit.get("data-size") or 0)165 if 80 <= size <= 20000:166 area = size167 except (TypeError, ValueError):168 pass169170 # Type de bâtiment (ex. « Maison de ville ») exposé en data-*171 amenities = list(bldg_amenities)172 housing = (unit.get("data-housing-type") or "").strip()173 if housing:174 amenities.append(housing)175176 listings.append(Listing(177 source=self.source_id,178 external_id=f"{slug}-{pid}",179 url=url,180 title=f"{meta['name']} — unité {name}",181 address=address,182 sector=sector,183 city=city,184 unit_type=normalize_unit_type(rooms),185 price=parse_price(price_label),186 price_label=price_label,187 availability=availability,188 area_sqft=area,189 description=desc,190 amenities=amenities,191 details=dict(details),192 images=images,193 ))194 except Exception:195 continue196197 return listings198