SPB Git

spb/lou-ka Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

HTML 99.7%
8.5 KB · 198 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/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