# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/aube.py : connecteur Aubé Immobilier (aubeimmo.com) # Gestionnaire de Québec (Sainte-Foy, Limoilou) ; site de location # www.appartements-quebec.ca = portail Building Stack en marque blanche # (même code .NET que les portails *.bstk.io des connecteurs summum.py / # org_dupuis.py / urban_services.py) : # - /Listing/Listings embarque `var units = [...]` (ApartmentId stable, # prix, pi², chambres, sdb, adresse complète avec GPS, contact) ; # - les pages immeuble /b/ donnent la date de disponibilité par # unité et les commodités de l'immeuble (cache self.detail). # Petit parc : une poignée d'unités affichées à la fois. Le portail publie # « Ville de Québec » comme ville -> ramené à « Québec ». # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import html as _html import json import re from bs4 import BeautifulSoup from ..schema import Listing from .base import BaseConnector BASE = "https://www.appartements-quebec.ca" LIST_URL = f"{BASE}/Listing/Listings" _WS_RE = re.compile(r"\s+") # graphies du portail -> toponyme officiel _CITY_FIX = {"Ville de Québec": "Québec", "Quebec": "Québec", "Quebec City": "Québec"} def _extract_json(html: str, marker: str): """Décode la structure JSON qui suit `marker` dans un script inline.""" i = html.find(marker) if i < 0: return None try: data, _ = json.JSONDecoder().raw_decode(html[i + len(marker):].lstrip()) except Exception: return None return data def _sqft(raw) -> float | None: try: v = float(str(raw).strip().replace(" ", "").replace(",", ".")) except (TypeError, ValueError): return None return v if 80 <= v <= 20000 else None class AubeConnector(BaseConnector): source_id = "aube" request_delay = 0.6 max_buildings = 30 # garde-fou pages immeuble max_images = 12 # -- page immeuble /b/ : dates de disponibilité + commodités ----------- def _building_page(self, pub: str, key: str) -> dict: """{ 'dates': {unité: dispo}, 'amenities': [...], 'images': [...] }.""" def _fetch() -> dict: html = self.get(f"{BASE}/b/{pub}").text soup = BeautifulSoup(html, "html.parser") dates: dict[str, str] = {} for a in soup.select("ul.wpb-tabs-menu a.apartment-view-potential"): spans = [s.get_text(" ", strip=True) for s in a.find_all("span")] if len(spans) >= 6 and spans[0]: dates[spans[0]] = spans[5] # Unité -> Disponible amenities: list[str] = [] for li in soup.select(".facilities ul li label"): t = _WS_RE.sub(" ", li.get_text(" ", strip=True)) if 3 <= len(t) <= 90 and t not in amenities: amenities.append(t) imgs = list(dict.fromkeys(re.findall( r"https://wfiles\.buildingstack\.com/resources/image/\w+", html))) return {"dates": dates, "amenities": amenities[:20], "images": imgs[:10]} try: return self.detail(pub, key, _fetch) except Exception: return {} # -- fetch ------------------------------------------------------------------ def fetch(self) -> list[Listing]: html = self.get(LIST_URL).text units = _extract_json(html, "var units =") or [] # regrouper par immeuble (résidentiel, Québec seulement — prudence) by_pub: dict[str, list[dict]] = {} for u in units: addr = u.get("Address") or {} prov = ((addr.get("Province") or {}).get("ProvinceCode") or "") if prov and prov.upper() != "QC": continue if not (u.get("Apartment") or {}).get("IsResidential", True): continue # commercial / stationnement pub = str((u.get("Building") or {}) .get("PublicListBuildingName") or "") if pub: by_pub.setdefault(pub, []).append(u) bldg_info: dict[str, dict] = {} for pub, us in sorted(by_pub.items())[: self.max_buildings]: key = hashlib.sha1(json.dumps( sorted((str(x.get("ApartmentId")), str((x.get("Apartment") or {}).get("Price"))) for x in us)).encode("utf-8")).hexdigest()[:16] bldg_info[pub] = self._building_page(pub, key) listings: list[Listing] = [] for pub, us in by_pub.items(): info = bldg_info.get(pub) or {} for u in us: try: lst = self._listing(u, pub, info) if lst: listings.append(lst) except Exception: continue uniq: dict[str, Listing] = {} for lst in listings: uniq.setdefault(lst.external_id, lst) return list(uniq.values()) # -- une annonce par unité ---------------------------------------------------- def _listing(self, u: dict, pub: str, info: dict) -> Listing | None: apt = u.get("Apartment") or {} adr = u.get("Address") or {} bld = u.get("Building") or {} ext_id = str(u.get("ApartmentId") or "") if not ext_id: return None num = str(apt.get("UnitName") or "").strip() bname = _WS_RE.sub(" ", _html.unescape( str(u.get("BuildingName") or ""))).strip() beds = apt.get("NumberOfBedrooms") or 0 baths = apt.get("NumberOfBathrooms") or 0 unit_type = "Studio" if not beds else f"{int(beds) + 2}½" dispo = (info.get("dates") or {}).get(num, "") if not dispo: availability = "Disponible" elif re.search(r"\d", dispo): # « sept. 01, 2026 » availability = f"Libre {dispo}" else: # « Disponible dès maintenant! » availability = dispo area = _sqft(apt.get("Area")) desc = " — ".join(x for x in [ f"{area:g} pi²" if area else "", f"{beds} chambre(s)" if beds else "", f"{baths} salle(s) de bain" if baths else ""] if x) details: dict = {"bedrooms": int(beds), "bathrooms": int(baths)} if isinstance(u.get("ParkingsIsAvailable"), bool): details["parking"] = {"available": u["ParkingsIsAvailable"]} if isinstance(u.get("StoragesIsAvailable"), bool): details["storage"] = u["StoragesIsAvailable"] contacts = bld.get("ListingEmployeesContacts") or [] if contacts: c = contacts[0] contact = {k: v for k, v in [ ("name", c.get("FullName")), ("phone", c.get("FormattedPhoneNumber")), ("email", c.get("Email"))] if v} if contact: details["contact"] = contact imgs: list[str] = [] prev = str(u.get("PreviewUrl") or "") if prev.startswith("http"): imgs.append(prev) imgs += [x for x in (info.get("images") or []) if x not in imgs] try: lat, lng = float(adr.get("Latitude")), float(adr.get("Longitude")) except (TypeError, ValueError): lat = lng = None city = _html.unescape(str(adr.get("City") or "")).strip() city = _CITY_FIX.get(city, city) burl = str(u.get("BuildingUrl") or "") url = BASE + burl if burl.startswith("/") else f"{BASE}/b/{pub}" return Listing( source=self.source_id, external_id=ext_id, url=url, title=(f"{bname} — Unité {num}" if num else bname), address=_html.unescape(str(adr.get("Full") or "")), sector="", city=city, unit_type=unit_type, price=float(apt.get("Price") or 0) or None, price_label=str(apt.get("PriceFormatted") or ""), availability=availability, area_sqft=area, description=desc[:600], amenities=list(info.get("amenities") or []), details=details, images=imgs[: self.max_images], lat=lat, lng=lng, )