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%
8.4 KB · 214 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/aube.py : connecteur Aubé Immobilier (aubeimmo.com)5#   Gestionnaire de Québec (Sainte-Foy, Limoilou) ; site de location6#   www.appartements-quebec.ca = portail Building Stack en marque blanche7#   (même code .NET que les portails *.bstk.io des connecteurs summum.py /8#   org_dupuis.py / urban_services.py) :9#     - /Listing/Listings embarque `var units = [...]` (ApartmentId stable,10#       prix, pi², chambres, sdb, adresse complète avec GPS, contact) ;11#     - les pages immeuble /b/<id> donnent la date de disponibilité par12#       unité et les commodités de l'immeuble (cache self.detail).13#   Petit parc : une poignée d'unités affichées à la fois. Le portail publie14#   « Ville de Québec » comme ville -> ramené à « Québec ».15# -----------------------------------------------------------------------------16from __future__ import annotations1718import hashlib19import html as _html20import json21import re2223from bs4 import BeautifulSoup2425from ..schema import Listing26from .base import BaseConnector2728BASE = "https://www.appartements-quebec.ca"29LIST_URL = f"{BASE}/Listing/Listings"3031_WS_RE = re.compile(r"\s+")32# graphies du portail -> toponyme officiel33_CITY_FIX = {"Ville de Québec": "Québec", "Quebec": "Québec",34             "Quebec City": "Québec"}353637def _extract_json(html: str, marker: str):38    """Décode la structure JSON qui suit `marker` dans un script inline."""39    i = html.find(marker)40    if i < 0:41        return None42    try:43        data, _ = json.JSONDecoder().raw_decode(html[i + len(marker):].lstrip())44    except Exception:45        return None46    return data474849def _sqft(raw) -> float | None:50    try:51        v = float(str(raw).strip().replace(" ", "").replace(",", "."))52    except (TypeError, ValueError):53        return None54    return v if 80 <= v <= 20000 else None555657class AubeConnector(BaseConnector):58    source_id = "aube"59    request_delay = 0.660    max_buildings = 30           # garde-fou pages immeuble61    max_images = 126263    # -- page immeuble /b/<id> : dates de disponibilité + commodités -----------64    def _building_page(self, pub: str, key: str) -> dict:65        """{ 'dates': {unité: dispo}, 'amenities': [...], 'images': [...] }."""66        def _fetch() -> dict:67            html = self.get(f"{BASE}/b/{pub}").text68            soup = BeautifulSoup(html, "html.parser")69            dates: dict[str, str] = {}70            for a in soup.select("ul.wpb-tabs-menu a.apartment-view-potential"):71                spans = [s.get_text(" ", strip=True) for s in a.find_all("span")]72                if len(spans) >= 6 and spans[0]:73                    dates[spans[0]] = spans[5]        # Unité -> Disponible74            amenities: list[str] = []75            for li in soup.select(".facilities ul li label"):76                t = _WS_RE.sub(" ", li.get_text(" ", strip=True))77                if 3 <= len(t) <= 90 and t not in amenities:78                    amenities.append(t)79            imgs = list(dict.fromkeys(re.findall(80                r"https://wfiles\.buildingstack\.com/resources/image/\w+",81                html)))82            return {"dates": dates, "amenities": amenities[:20],83                    "images": imgs[:10]}8485        try:86            return self.detail(pub, key, _fetch)87        except Exception:88            return {}8990    # -- fetch ------------------------------------------------------------------91    def fetch(self) -> list[Listing]:92        html = self.get(LIST_URL).text93        units = _extract_json(html, "var units =") or []9495        # regrouper par immeuble (résidentiel, Québec seulement — prudence)96        by_pub: dict[str, list[dict]] = {}97        for u in units:98            addr = u.get("Address") or {}99            prov = ((addr.get("Province") or {}).get("ProvinceCode") or "")100            if prov and prov.upper() != "QC":101                continue102            if not (u.get("Apartment") or {}).get("IsResidential", True):103                continue                  # commercial / stationnement104            pub = str((u.get("Building") or {})105                      .get("PublicListBuildingName") or "")106            if pub:107                by_pub.setdefault(pub, []).append(u)108109        bldg_info: dict[str, dict] = {}110        for pub, us in sorted(by_pub.items())[: self.max_buildings]:111            key = hashlib.sha1(json.dumps(112                sorted((str(x.get("ApartmentId")),113                        str((x.get("Apartment") or {}).get("Price")))114                       for x in us)).encode("utf-8")).hexdigest()[:16]115            bldg_info[pub] = self._building_page(pub, key)116117        listings: list[Listing] = []118        for pub, us in by_pub.items():119            info = bldg_info.get(pub) or {}120            for u in us:121                try:122                    lst = self._listing(u, pub, info)123                    if lst:124                        listings.append(lst)125                except Exception:126                    continue127128        uniq: dict[str, Listing] = {}129        for lst in listings:130            uniq.setdefault(lst.external_id, lst)131        return list(uniq.values())132133    # -- une annonce par unité ----------------------------------------------------134    def _listing(self, u: dict, pub: str, info: dict) -> Listing | None:135        apt = u.get("Apartment") or {}136        adr = u.get("Address") or {}137        bld = u.get("Building") or {}138        ext_id = str(u.get("ApartmentId") or "")139        if not ext_id:140            return None141        num = str(apt.get("UnitName") or "").strip()142        bname = _WS_RE.sub(" ", _html.unescape(143            str(u.get("BuildingName") or ""))).strip()144145        beds = apt.get("NumberOfBedrooms") or 0146        baths = apt.get("NumberOfBathrooms") or 0147        unit_type = "Studio" if not beds else f"{int(beds) + 2}½"148149        dispo = (info.get("dates") or {}).get(num, "")150        if not dispo:151            availability = "Disponible"152        elif re.search(r"\d", dispo):     # « sept. 01, 2026 »153            availability = f"Libre {dispo}"154        else:                             # « Disponible dès maintenant! »155            availability = dispo156157        area = _sqft(apt.get("Area"))158        desc = " — ".join(x for x in [159            f"{area:g} pi²" if area else "",160            f"{beds} chambre(s)" if beds else "",161            f"{baths} salle(s) de bain" if baths else ""] if x)162163        details: dict = {"bedrooms": int(beds), "bathrooms": int(baths)}164        if isinstance(u.get("ParkingsIsAvailable"), bool):165            details["parking"] = {"available": u["ParkingsIsAvailable"]}166        if isinstance(u.get("StoragesIsAvailable"), bool):167            details["storage"] = u["StoragesIsAvailable"]168        contacts = bld.get("ListingEmployeesContacts") or []169        if contacts:170            c = contacts[0]171            contact = {k: v for k, v in [172                ("name", c.get("FullName")),173                ("phone", c.get("FormattedPhoneNumber")),174                ("email", c.get("Email"))] if v}175            if contact:176                details["contact"] = contact177178        imgs: list[str] = []179        prev = str(u.get("PreviewUrl") or "")180        if prev.startswith("http"):181            imgs.append(prev)182        imgs += [x for x in (info.get("images") or []) if x not in imgs]183184        try:185            lat, lng = float(adr.get("Latitude")), float(adr.get("Longitude"))186        except (TypeError, ValueError):187            lat = lng = None188189        city = _html.unescape(str(adr.get("City") or "")).strip()190        city = _CITY_FIX.get(city, city)191192        burl = str(u.get("BuildingUrl") or "")193        url = BASE + burl if burl.startswith("/") else f"{BASE}/b/{pub}"194        return Listing(195            source=self.source_id,196            external_id=ext_id,197            url=url,198            title=(f"{bname} — Unité {num}" if num else bname),199            address=_html.unescape(str(adr.get("Full") or "")),200            sector="",201            city=city,202            unit_type=unit_type,203            price=float(apt.get("Price") or 0) or None,204            price_label=str(apt.get("PriceFormatted") or ""),205            availability=availability,206            area_sqft=area,207            description=desc[:600],208            amenities=list(info.get("amenities") or []),209            details=details,210            images=imgs[: self.max_images],211            lat=lat,212            lng=lng,213        )214