SPB Git

spb/lou-ka Public

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

HTML 99.7%
9.8 KB · 232 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/medway.py : connecteur Groupe Medway (condosmedway.ca)5#   Condos locatifs neufs multi-villes : Rivière-du-Loup, Lévis (Saint-Nicolas,6#   Lauzon, Rucher), Québec (boul. Laurier, Charest, Wilfrid-Hamel), Pont-Rouge,7#   La Malbaie, Saint-Raphaël. WordPress (thème custom « turbutheme »), tout8#   rendu serveur. Répertoire /condos-a-louer/ : cartes projet (nom, adresse9#   civique avec ville, nombre d'unités). Chaque page projet /projet/<slug>/10#   embarque les unités sous DEUX gabarits selon le projet :11#     1. span JSON `condosParEtageJson` (Rive, Edmond, Nico, Anastasie…) —12#        unités par étage : id, prix, type « 3 ½ / 4 ½ bureau », superficie,13#        disponibilité oui/non + « Prochainement » avec date, fiche /condo/… ;14#     2. tableau JS inline `units = [[…]]` (sélecteur d'étages : Kali, Wil,15#        Alto) — number, state (Available/Reserved/Sold), price, typology16#        (« 4etdemiB », « StudioA »), superficies, date, plan.17#   On ne retient que les unités disponibles (maintenant ou prochainement ;18#   Reserved/Sold exclues) — le prix de chaque unité vient de ces données (les19#   fiches /condo/ n'affichent pas le prix). robots.txt WP standard.20# -----------------------------------------------------------------------------21from __future__ import annotations2223import html as htmllib24import json25import re2627from bs4 import BeautifulSoup2829from ..schema import Listing, normalize_unit_type30from .base import BaseConnector3132BASE = "https://condosmedway.ca"33LIST_URL = f"{BASE}/condos-a-louer/"3435# « 35 rue Saint-Louis, Rivière-du-Loup, QC • 154 unités » -> adresse + ville36_UNITS_SUFFIX = re.compile(r"[••]\s*\d+\s*unités?", re.I)37_JSON_RE = re.compile(38    r'id="condosParEtageJson"[^>]*>\s*(\{.*?\})\s*</span>', re.S)39# gabarit « sélecteur d'étages » : tableau JS inline (une liste par étage)40_UNITS_RE = re.compile(r"units\s*=\s*(\[\[.*?\]\])\s*;?\n", re.S)41_TYPOLOGY_RE = re.compile(r"(\d)\s*etdemi", re.I)424344class MedwayConnector(BaseConnector):45    source_id = "medway"46    request_delay = 0.64748    def fetch(self) -> list[Listing]:49        html = self.get(LIST_URL).text50        soup = BeautifulSoup(html, "html.parser")5152        listings: dict[str, Listing] = {}53        seen_projects: set[str] = set()54        for card in soup.select("div.condo-item"):55            try:56                self._parse_project(card, seen_projects, listings)57            except Exception:58                continue59        return list(listings.values())6061    # -- carte projet (répertoire /condos-a-louer/) ---------------------------------62    def _parse_project(self, card, seen: set[str],63                       listings: dict[str, Listing]) -> None:64        link = card.select_one('a[href*="/projet/"]')65        name_el = card.select_one("h2")66        if not link or not name_el:67            return68        m = re.search(r"/projet/([^/?#]+)", link["href"])69        if not m or m.group(1) in seen:70            return71        slug = m.group(1)72        seen.add(slug)73        project = name_el.get_text(strip=True)7475        # adresse de la carte : « 35 rue Saint-Louis, Rivière-du-Loup, QC • 154 unités »76        addr_el = name_el.find_next(77            "div", class_=lambda c: c and "flex-col" in c)78        raw = addr_el.get_text(" ", strip=True) if addr_el else ""79        raw = _UNITS_SUFFIX.sub("", htmllib.unescape(raw)).strip(" ••")80        address = re.sub(r"\s+", " ", raw).strip().rstrip(",")81        # ville = dernier segment non vide une fois la province « QC » retirée82        # (« …, Québec, QC » -> Québec ; « …, La Malbaie QC » -> La Malbaie)83        city = ""84        parts = [p.strip() for p in address.split(",") if p.strip()]85        for p in reversed(parts[1:]):86            cand = re.sub(r"\bQC\s*$", "", p).strip().rstrip(",")87            if cand and not re.match(r"^G\d[A-Z]", cand):88                city = cand89                break9091        # page projet : JSON des unités par étage (?t=r = volet résidentiel)92        page = self.get(f"{BASE}/projet/{slug}/?t=r").text93        page_url = f"{BASE}/projet/{slug}/?t=r"94        mj = _JSON_RE.search(page)95        if mj:96            data = json.loads(mj.group(1))97            for floor in data.values():98                for unit_slug, u in (floor.get("condos") or {}).items():99                    if not isinstance(u, dict) or unit_slug in listings:100                        continue101                    dispo = (u.get("disponibilite") or "").lower() == "oui"102                    prochaine = (u.get("disponibilite_prochaine") or "").lower() == "oui"103                    if not (dispo or prochaine):104                        continue105                    self._add_unit(listings, unit_slug, u, project, address, city)106            return107108        # gabarit « sélecteur d'étages » (Kali, Wil, Alto) : units = [[…]]109        mu = _UNITS_RE.search(page)110        if not mu:111            return   # page marketing sans sélecteur (ex. 3000 Laurier) : rien112        for floor in json.loads(mu.group(1)):113            for u in floor:114                if not isinstance(u, dict) or u.get("state") != "Available":115                    continue    # Reserved / Sold : non disponibles116                self._add_selector_unit(listings, u, slug, page_url,117                                        project, address, city)118119    # -- unité (entrée du JSON condosParEtageJson) -----------------------------------120    def _add_unit(self, listings: dict[str, Listing], unit_slug: str, u: dict,121                  project: str, address: str, city: str) -> None:122        title = (u.get("nom-unite") or "").strip() or unit_slug123        url = (u.get("single_projet_url") or "").replace("\\/", "/") \124            or f"{BASE}/condo/{unit_slug}/"125126        # disponibilité brute : « Maintenant » / « Prochainement » (+ date libre)127        availability = (u.get("disponibilite_libelle") or "").strip()128        date_txt = (u.get("disponibilite_date") or "").strip()129        if date_txt:130            availability = f"{availability}{date_txt}".strip(" —")131132        price = None133        raw_price = str(u.get("prix") or "").strip()134        if re.fullmatch(r"\d+(?:[.,]\d+)?", raw_price):135            price = float(raw_price.replace(",", "."))136137        area = None138        raw_area = str(u.get("superficie_pieds") or "").strip()139        if re.fullmatch(r"\d+(?:[.,]\d+)?", raw_area):140            area = float(raw_area.replace(",", "."))141142        images: list[str] = []143        plan = (u.get("plan_url") or "").replace("\\/", "/")144        if plan.startswith("http"):145            images.append(plan)146147        amenities: list[str] = []148        finition = (u.get("finition") or "").strip()149        if finition and not finition.lower().startswith("non-attribu"):150            amenities.append(f"Finition : {finition}")151152        listings[unit_slug] = Listing(153            source=self.source_id,154            external_id=unit_slug,155            url=url,156            title=title,157            address=address,158            sector=project,159            city=city,160            unit_type=normalize_unit_type((u.get("type") or "").strip()),161            price=price,162            price_label=f"{raw_price} $" if price is not None else "",163            availability=availability,164            area_sqft=area,165            amenities=amenities,166            images=images,167        )168169    # -- unité (gabarit sélecteur d'étages : Kali, Wil, Alto) ---------------------------170    def _add_selector_unit(self, listings: dict[str, Listing], u: dict,171                           project_slug: str, page_url: str, project: str,172                           address: str, city: str) -> None:173        uid = str(u.get("id") or "").strip()174        number = (u.get("number") or "").strip()      # « Unité 410 »175        if not uid:176            return177        ext_id = f"{project_slug}-{uid}"              # id BD WordPress : stable178        if ext_id in listings:179            return180181        # type structuré : typology « 4etdemiB » / « StudioA » -> 4½ / Studio182        typology = (u.get("typology") or "").strip()183        unit_type = ""184        m = _TYPOLOGY_RE.search(typology)185        if m:186            unit_type = normalize_unit_type(f"{m.group(1)} ½")187        elif typology.lower().startswith("studio"):188            unit_type = "Studio"189190        # disponibilité affichée : légende « Disponible » (+ date si annoncée)191        availability = "Disponible"192        date_txt = (u.get("available_date") or "").strip()193        if date_txt:194            availability += f" — {date_txt}"195196        price = None197        raw = u.get("price")198        if isinstance(raw, (int, float)) and raw > 0:199            price = float(raw)200201        area = None202        ia = u.get("indoor_area")203        if isinstance(ia, (int, float)) and ia > 0:204            area = float(ia)205206        amenities: list[str] = []207        ba = u.get("balcony_area")208        if isinstance(ba, (int, float)) and ba > 0:209            amenities.append(f"Balcon {ba:g} pi²")210211        images: list[str] = []212        plan = (u.get("plan_img_src") or "").replace("\\/", "/")213        if plan.startswith("http"):214            images.append(plan)215216        listings[ext_id] = Listing(217            source=self.source_id,218            external_id=ext_id,219            url=page_url,                  # pas de fiche par unité sur ce gabarit220            title=f"{project}{number}".strip(" —") or ext_id,221            address=address,222            sector=project,223            city=city,224            unit_type=unit_type,225            price=price,226            price_label=f"{price:g} $" if price is not None else "",227            availability=availability,228            area_sqft=area,229            amenities=amenities,230            images=images,231        )232