SPB Git forge

spb/lou-ka

Public

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

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
7.2 KB · 191 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/junic.py : connecteur Junic (junic.ca — promoteur de Gatineau :5#   projets Agora Village Urbain, VILL, Central…)6#   Seul l'AGORA publie un inventaire exploitable : la page7#   residentiel.agora-plateau.com/appartements/ (WordPress/Jupiter) est rendue8#   SERVEUR avec une carte par unité disponible (div.unit) regroupée par9#   typologie (h2 : Micro, Loft, 1 chambre, 1 chambre + bureau…) :10#   numéro « 20 - #320 » (immeuble 20 ou 35, rue de Hambourg, secteur11#   Le Plateau à Gatineau), disponibilité (date ou « MAINTENANT DISPONIBLE »),12#   type, dimension pi², LOYER de base, forfait tout-inclus obligatoire13#   (505 $/mois : meubles en option non — chauffage/clim, internet…),14#   mensualité totale, visite virtuelle et plan de l'unité.15#   Le prix publié est le LOYER de base ; le forfait et la mensualité totale16#   sont reportés en description/détails. VILL (villappartements.com) n'a17#   qu'un portail résident Building Stack avec login — aucune annonce18#   publique, donc hors connecteur.19# -----------------------------------------------------------------------------20from __future__ import annotations2122import re2324from bs4 import BeautifulSoup2526from ..schema import Listing27from .base import BaseConnector2829BASE = "https://residentiel.agora-plateau.com"30LIST_URL = f"{BASE}/appartements/"3132# immeubles du projet (préfixe des numéros d'unité -> adresse civique)33_ADDRESSES = {34    "20": "20 Rue de Hambourg, Gatineau",35    "35": "35 Rue de Hambourg, Gatineau",36}3738# typologies des entêtes h2 -> (type d'unité Lou-Ka, chambres)39_TYPES = {40    "micro": ("Studio", 0),41    "loft": ("Loft", 0),42    "1 chambre": ("3½", 1),43    "2 chambres": ("4½", 2),44    "3 chambres": ("5½", 3),45}4647_WS_RE = re.compile(r"\s+")48_UNIT_RE = re.compile(r"^(\d+)\s*-\s*#?\s*(\w+)")49_DISPO_RE = re.compile(r"Disponibilité\s*([\d-]+)", re.I)50_DIM_RE = re.compile(r"Dimension\s*([\d\s,.]+)\s*pi", re.I)51_LOYER_RE = re.compile(r"Loyer\s*([\d\s]+)\s*\$", re.I)52_INCLUS_RE = re.compile(r"Tout-inclus\s*([\d\s]+)\s*\$", re.I)53_MENSUEL_RE = re.compile(r"Mensualité\s*([\d\s]+)\s*\$", re.I)54_TYPE_RE = re.compile(r"Type\s+([A-Z0-9+ ]+?)(?:\s+Dimension|\s+Loyer|$)")555657def _money(m: re.Match | None) -> float | None:58    if not m:59        return None60    try:61        v = float(m.group(1).replace(" ", "").replace(" ", ""))62    except ValueError:63        return None64    return v if 100 <= v <= 20000 else None656667def _typologie(header: str) -> tuple[str, int]:68    """Entête de section -> (type d'unité, chambres). Les variantes69    « + bureau » / « + mezzanine » gardent la même typologie de base."""70    key = _WS_RE.sub(" ", header).strip().lower()71    for prefix, val in _TYPES.items():72        if key.startswith(prefix):73            return val74    return ("", 0)757677class JunicConnector(BaseConnector):78    source_id = "junic"79    request_delay = 0.680    max_images = 68182    def fetch(self) -> list[Listing]:83        html = self.get(LIST_URL).text84        soup = BeautifulSoup(html, "html.parser")8586        listings: list[Listing] = []87        header = ""88        # le DOM alterne entêtes de typologie et cartes d'unité, dans l'ordre89        for el in soup.select(".appartements_type h2, div.unit"):90            if el.name == "h2":91                header = el.get_text(" ", strip=True)92                continue93            try:94                lst = self._unit(el, header)95                if lst:96                    listings.append(lst)97            except Exception:98                continue                  # une carte ne bloque pas le reste99100        # dédup par external_id (sécurité)101        uniq: dict[str, Listing] = {}102        for lst in listings:103            uniq.setdefault(lst.external_id, lst)104        return list(uniq.values())105106    # -- une annonce par carte d'unité -------------------------------------------107    def _unit(self, el, header: str) -> Listing | None:108        h3 = el.find("h3")109        if not h3:110            return None111        m = _UNIT_RE.match(_WS_RE.sub(" ", h3.get_text(" ", strip=True)))112        if not m:113            return None114        bldg, num = m.group(1), m.group(2)115        txt = _WS_RE.sub(" ", el.get_text(" ", strip=True))116117        unit_type, beds = _typologie(header)118        loyer = _money(_LOYER_RE.search(txt))119        inclus = _money(_INCLUS_RE.search(txt))120        mensuel = _money(_MENSUEL_RE.search(txt))121122        dispo = _DISPO_RE.search(txt)123        if dispo:124            availability = f"Libre le {dispo.group(1)}"125        elif re.search(r"MAINTENANT DISPONIBLE", txt, re.I):126            availability = "Disponible maintenant"127        else:128            availability = "Disponible"129130        area = None131        dm = _DIM_RE.search(txt)132        if dm:133            try:134                v = float(dm.group(1).replace(" ", "").replace(" ", "")135                          .replace(",", "."))136                area = v if 80 <= v <= 20000 else None137            except ValueError:138                pass139140        # visite virtuelle + images (plan de l'unité, aperçu de la visite)141        details: dict = {"building": f"{bldg} Rue de Hambourg",142                         "bedrooms": beds}143        images: list[str] = []144        for a in el.find_all("a", href=True):145            href = str(a["href"])146            if "tourbuzz" in href or "matterport" in href:147                details["virtual_tour"] = href148            elif href.lower().endswith(".pdf"):149                details["floor_plan"] = href      # plan de l'unité (PDF)150            elif re.search(r"\.(?:jpe?g|png|webp)(?:\?|$)", href, re.I):151                if href not in images:152                    images.append(href)153        for img in el.find_all("img", src=True):154            src = str(img["src"])155            if src.startswith("http") and src not in images:156                images.append(src)157        if inclus:158            details["all_inclusive_package"] = inclus159        if mensuel:160            details["total_monthly"] = mensuel161162        tm = _TYPE_RE.search(txt)163        desc_bits = [164            f"Type {tm.group(1).strip()}" if tm else header,165            f"{area:g} pi²" if area else "",166            (f"Loyer de base {loyer:g} $ + forfait tout-inclus "167             f"{inclus:g} $/mois" if loyer and inclus else ""),168            f"mensualité totale {mensuel:g} $" if mensuel else "",169        ]170        desc = " — ".join(x for x in desc_bits if x)171172        return Listing(173            source=self.source_id,174            external_id=f"{bldg}-{num}",175            url=LIST_URL,176            title=f"Agora Village Urbain ({bldg} Hambourg) — Unité {num}",177            address=_ADDRESSES.get(bldg, "Rue de Hambourg, Gatineau"),178            sector="Le Plateau",179            city="Gatineau",180            unit_type=unit_type,181            bedrooms=float(beds),182            price=loyer,183            price_label=f"{loyer:g} $ + {inclus:g} $ tout-inclus"184                        if loyer and inclus else "",185            availability=availability,186            area_sqft=area,187            description=desc[:600],188            details=details,189            images=images[: self.max_images],190        )191