# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/capreit.py : connecteur CAPREIT (capreit.ca) # Flux JSON officiel du moteur de recherche (admin-ajax `property_json`) # filtré sur les villes de la région de Québec ET du Grand Montréal (île de # Montréal, Laval, Rive-Sud, Rive-Nord proche); les fiches propriétés (rendu # serveur) fournissent les types d'unités, prix, disponibilités, commodités # et la galerie photo. Une annonce par type d'unité disponible. # Exclus : hors-province (province != QC) et villes hors des deux régions. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import re from bs4 import BeautifulSoup from ..schema import (Listing, infer_city, normalize_unit_type, parse_price, strip_accents) from .base import BaseConnector BASE = "https://www.capreit.ca" FEED_URL = f"{BASE}/wp-admin/admin-ajax.php?action=property_json&language=fr" # Villes du flux correspondant à la région Québec/Lévis _QC_CITIES = { "ville de quebec", "quebec", "beauport", "levis", "sainte-foy", "charlesbourg", "loretteville", "sillery", "cap-rouge", "val-belair", "l-ancienne-lorette", "saint-augustin-de-desmaures", "wendake", "saint-romuald", "saint-nicolas", "charny", } # Villes du Grand Montréal : clé normalisée du flux -> nom d'affichage _GM_CITIES = { # Île de Montréal "montreal": "Montréal", "cote saint-luc": "Côte Saint-Luc", "cote-saint-luc": "Côte Saint-Luc", "westmount": "Westmount", "dorval": "Dorval", "pointe-claire": "Pointe-Claire", "mont-royal": "Mont-Royal", "dollard-des-ormeaux": "Dollard-des-Ormeaux", # Laval "laval": "Laval", # Longueuil / Rive-Sud "longueuil": "Longueuil", "brossard": "Brossard", "boucherville": "Boucherville", "saint-lambert": "Saint-Lambert", "saint-hubert": "Longueuil", "candiac": "Candiac", "chateauguay": "Châteauguay", # Rive-Nord proche "boisbriand": "Boisbriand", "repentigny": "Repentigny", "terrebonne": "Terrebonne", "mascouche": "Mascouche", "rosemere": "Rosemère", "sainte-therese": "Sainte-Thérèse", "blainville": "Blainville", } _IMG_RE = re.compile( r'https://www\.capreit\.ca/wp-content/uploads/[^"\'\s\\]+' r'\.(?:jpg|jpeg|png|webp)', re.I) _SKIP_IMG = re.compile( r"logo|icon|favicon|cropped|-\d{2,4}x\d{2,4}\.|BIL|Phone|badge", re.I) class CapreitConnector(BaseConnector): source_id = "capreit" request_delay = 0.6 max_properties = 60 # garde-fou (région de Québec + Grand Montréal) max_images = 25 @staticmethod def _city_key(city: str) -> str: return strip_accents((city or "").strip().lower()) def fetch(self) -> list[Listing]: props = self.get(FEED_URL).json() listings: list[Listing] = [] count = 0 for p in props: try: if (p.get("province") or "").strip().upper() != "QC": continue ck = self._city_key(p.get("city", "")) if ck not in _QC_CITIES and ck not in _GM_CITIES: continue if not p.get("has_vacancies"): continue if count >= self.max_properties: break count += 1 listings.extend(self._property_listings(p)) except Exception: continue return listings def _property_listings(self, p: dict) -> list[Listing]: pid = str(p.get("id")) url = p.get("url") or "" title = (p.get("title") or "").strip() address = (p.get("address") or "").strip() feed_city = (p.get("city") or "").strip() # adresse complète : rue + ville + code postal (tous fournis au flux) postal = (p.get("postal_code") or "").strip() if address and feed_city: address = f"{address}, {feed_city}" + (f", QC {postal}" if postal else "") # coordonnées GPS du flux try: lat = float(p["latitude"]) if p.get("latitude") else None lng = float(p["longitude"]) if p.get("longitude") else None except (TypeError, ValueError): lat = lng = None incentive = (p.get("incentive") or "").strip() # secteur : ville précise du flux (ex. Beauport) sinon intersection city_key = self._city_key(feed_city) if city_key in _GM_CITIES: # Grand Montréal : la ville du flux est la vraie ville sector = (p.get("nearest_intersection") or "").strip() city = _GM_CITIES[city_key] elif city_key in ("ville de quebec", "quebec"): sector = (p.get("nearest_intersection") or "").strip() city = infer_city(sector, default="Québec") else: sector = feed_city city = infer_city(sector, default="Québec") # fiche propriété (rendu serveur) via le cache BD : revisitée # seulement quand la ligne du flux change feed_key = hashlib.sha1("|".join( str(p.get(k)) for k in ("id", "min_rent", "earliest_date", "vacancy_message", "price_range", "has_vacancies", "units_count", "incentive") ).encode("utf-8")).hexdigest() d = self.detail(pid, feed_key, lambda: self._fetch_property(url)) desc = d.get("desc", "") amenities = d.get("amenities", []) images = d.get("images", []) rows = d.get("rows", []) # promotion du flux (ex. « 1 mois de loyer gratuit ») if incentive: desc = f"Promotion : {incentive}. {desc}".strip() out: list[Listing] = [] if rows: for r in rows: ut = normalize_unit_type(r["unit_raw"]) slug = re.sub(r"[^a-z0-9]+", "-", strip_accents(r["unit_raw"].lower())).strip("-") out.append(Listing( source=self.source_id, external_id=f"{pid}-{slug or 'u'}", url=url, title=f"{title} — {r['unit_raw']}" if r["unit_raw"] else title, address=address, sector=sector, city=city, unit_type=ut, price=parse_price(r["price"]), price_label=r["price"], availability=r["avail"], description=" — ".join(x for x in [desc, r["sqft"]] if x)[:600], amenities=amenities, images=images, lat=lat, lng=lng, )) else: # repli : annonce par propriété avec le prix plancher du flux min_rent = p.get("min_rent") # date de disponibilité structurée du flux (ex. 20260201) avail_date = None ed = str(p.get("earliest_date") or "") if re.fullmatch(r"20\d{6}", ed): avail_date = f"{ed[:4]}-{ed[4:6]}-{ed[6:]}" out.append(Listing( source=self.source_id, external_id=pid, url=url, title=title, address=address, sector=sector, city=city, unit_type=normalize_unit_type( (p.get("bedroom_range") or "").split("-")[0]), price=float(min_rent) if min_rent else None, price_label=p.get("price_range") or "", availability=p.get("vacancy_message") or "", availability_date=avail_date, description=desc, amenities=amenities, images=images, lat=lat, lng=lng, )) return out def _fetch_property(self, url: str) -> dict: """Scrape la fiche propriété : galerie, commodités, description, et une ligne par type d'unité disponible (« Vos options »).""" out: dict = {"desc": "", "amenities": [], "images": [], "rows": []} try: page = self.get(url).text except Exception: return out soup = BeautifulSoup(page, "html.parser") # galerie photos (héro + blocs JSON de la page) images: list[str] = [] for u in _IMG_RE.findall(page): if _SKIP_IMG.search(u): continue if u not in images: images.append(u) out["images"] = images[: self.max_images] # commodités (listes à icônes) amenities: list[str] = [] seen = set() for li in soup.select("li"): if not li.find("div", class_="icon"): continue t = li.get_text(" ", strip=True) if t and len(t) < 60 and t not in seen: seen.add(t) amenities.append(t) out["amenities"] = amenities[:25] # description (« Caractéristiques de l'immeuble ») h = soup.find(["h2", "h3"], string=re.compile( "Caractéristiques de l['’]immeuble")) if h: nxt = h.find_next(["p", "div"]) if nxt: out["desc"] = nxt.get_text(" ", strip=True)[:600] # types d'unités disponibles for li in soup.select("li.property-options-list-item"): avail_el = li.select_one( ".property-options-list-item-availability") price_el = li.select_one( ".property-options-list-item-price") details = [d.get_text(" ", strip=True) for d in li.select(".property-options-item")] unit_raw = details[0] if details else "" sqft = details[1] if len(details) > 1 else "" if li.get("data-available") == "false": continue out["rows"].append({ "unit_raw": unit_raw, "sqft": sqft, "price": price_el.get_text(" ", strip=True) if price_el else "", "avail": avail_el.get_text(" ", strip=True) if avail_el else "", }) return out