# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/contraste.py : connecteur Contraste Immobilier # (contrasteimmobilier.ca — Beauport, Limoilou, Ste-Foy, Val-Bélair, # Lévis, St-Nicolas, St-Romuald). Site WordPress rendu serveur : # page d'accueil = cartes d'immeubles, pages immeubles = unités # individuelles (div.building-stack-unit avec attributs data-*). # ----------------------------------------------------------------------------- from __future__ import annotations import json import re from bs4 import BeautifulSoup from ..schema import Listing, infer_city, normalize_unit_type, parse_price from .base import BaseConnector BASE = "https://contrasteimmobilier.ca" # Villes admissibles (agglomération Québec / Lévis) — ex. Beaupré est exclue. ALLOWED_CITIES = {"quebec", "québec", "levis", "lévis"} ADDR_RE = re.compile( r"\d{1,5}[^,<>]{2,60},\s*[^,<>]{2,40},\s*Qu[ée]bec(?:,\s*[A-Z]\d[A-Z]\s?\d[A-Z]\d)?" ) # Adresse courte des pages « projet » (ex. « 2784 ave Sasseville ») : un # titre Elementor qui commence par un numéro civique + type de voie. ADDR_SHORT_RE = re.compile( r"^\d{1,5}\s+(?:rue|av(?:e|enue)?\.?|boul(?:evard)?\.?|ch(?:emin)?\.?|" r"all[ée]e|place|c[ôo]te|montée|route)\b.{2,50}$", re.I) PHONE_RE = re.compile(r"\(?\b([2-9]\d{2})\)?[\s.\-]?(\d{3})[\s.\-](\d{4})\b") EMAIL_RE = re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.]+\b") class ContrasteConnector(BaseConnector): source_id = "contraste" request_delay = 0.6 max_buildings = 30 # garde-fou de crawl def fetch(self) -> list[Listing]: listings: list[Listing] = [] try: home = self.get(BASE).text except Exception: return listings # 1) Cartes d'immeubles sur la page d'accueil : nom + ville + lien soup = BeautifulSoup(home, "html.parser") buildings: dict[str, dict] = {} for a in soup.select('a[href*="/appartements/"]'): href = (a.get("href") or "").split("?")[0] m = re.search(r"/appartements/([a-z0-9\-]+)/?$", href) if not m: continue slug = m.group(1) text = re.sub(r"\s+", " ", a.get_text(" ", strip=True)) if not text or slug in buildings: continue # "Quartier Élévation I Lévis 6 unités disponibles ... Découvrir" name = re.split(r"\s(?:Québec|Lévis|Beaupré)\b", text)[0].strip() city_m = re.search(r"\b(Québec|Lévis|Beaupré)\b", text) city = city_m.group(1) if city_m else "" buildings[slug] = {"name": name or slug, "city": city} # 2) Pages d'immeubles : unités individuelles for i, (slug, meta) in enumerate(buildings.items()): if i >= self.max_buildings: break if meta["city"] and meta["city"].lower() not in ALLOWED_CITIES: continue # hors Québec/Lévis (ex. Beaupré) url = f"{BASE}/appartements/{slug}/" try: html = self.get(url).text except Exception: continue bsoup = BeautifulSoup(html, "html.parser") # Adresse civique de l'immeuble (bloc Elementor) address = sector = "" headings = [el.get_text(" ", strip=True) for el in bsoup.select(".elementor-heading-title")] for h in headings: m = ADDR_RE.search(h) if m: address = m.group(0).strip() break if not address: m = ADDR_RE.search(html) if m: address = m.group(0).strip() if not address: # Pages « projet » (Ellipse, Émergence II…) : adresse courte # sans ville dans le hero. for h in headings: if ADDR_SHORT_RE.match(h): address = h.strip() break if address: parts = [p.strip() for p in address.split(",")] if len(parts) >= 2: sector = parts[1] city = infer_city(sector, default=meta["city"] or "Québec") # Description (meta og:description) desc = "" og = bsoup.find("meta", attrs={"property": "og:description"}) if og and og.get("content"): desc = og["content"].strip()[:600] # CARACTÉRISTIQUES de l'immeuble (répéteur JetEngine) : # inclusions, animaux, stationnement… — texte source fidèle. bldg_amenities = [el.get_text(" ", strip=True) for el in bsoup.select( ".jet-listing-dynamic-repeater__item span")] bldg_amenities = [a for a in dict.fromkeys(bldg_amenities) if a][:25] # Contact « Pour prendre rendez-vous » (pages projet) contact: dict = {} for h in headings: m = EMAIL_RE.search(h) if m and not contact.get("email"): contact["email"] = m.group(0) m = PHONE_RE.search(h) if m and not contact.get("phone"): contact["phone"] = f"{m.group(1)}-{m.group(2)}-{m.group(3)}" details = {"contact": contact} if contact else {} for unit in bsoup.select("div.building-stack-unit"): try: pid = unit.get("data-pid") or "" name = unit.get("data-name") or pid rooms = unit.get("data-rooms") or "" price_raw = unit.get("data-price") or "" if not pid: continue # Images : data-images = JSON [{urlPreview, url}, ...] images: list[str] = [] cover = unit.get("data-image") or "" if cover: images.append(cover) try: for img in json.loads(unit.get("data-images") or "[]"): u = (img or {}).get("url") or "" if u: images.append(u) except (ValueError, TypeError): pass images = [u for u in dict.fromkeys(images) if not re.search(r"logo|icon|favicon", u, re.I)] avail_el = unit.select_one(".building-stack-available-soon") availability = (avail_el.get_text(" ", strip=True) if avail_el else "Disponible") # data-price="0" = prix non affiché sur la carte if price_raw in ("", "0"): price_raw = "" price_label = f"{price_raw}$/mois" if price_raw else "" # data-size est toujours « 0 » chez Contraste (non rempli) ; # on ne le prend que s'il devient plausible un jour. area = None try: size = float(unit.get("data-size") or 0) if 80 <= size <= 20000: area = size except (TypeError, ValueError): pass # Type de bâtiment (ex. « Maison de ville ») exposé en data-* amenities = list(bldg_amenities) housing = (unit.get("data-housing-type") or "").strip() if housing: amenities.append(housing) listings.append(Listing( source=self.source_id, external_id=f"{slug}-{pid}", url=url, title=f"{meta['name']} — unité {name}", address=address, sector=sector, city=city, unit_type=normalize_unit_type(rooms), price=parse_price(price_label), price_label=price_label, availability=availability, area_sqft=area, description=desc, amenities=amenities, details=dict(details), images=images, )) except Exception: continue return listings