Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
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