# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/plan_a.py : connecteur Plan A Immobilier (plan-a.ca) # ~3500 unités dans des complexes locatifs (Pierrefonds-Roxboro, # Pointe-aux-Trembles, Ahuntsic, Laval, Vaudreuil-Dorion, Sherbrooke — # les projets de Saguenay sont encore exclus). WordPress rendu serveur : # la page « appartements et condos à louer » expose des cartes-projets # a.c-card[data-listing=project] avec un JSON data-filters-item (ville, # budget min, types), le carrousel d'images et le prix « à partir de ». # Granularité = 1 annonce par complexe (prix plancher), fiches /project/ # pour la description et la disponibilité. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import json import re from bs4 import BeautifulSoup from ..schema import Listing, parse_price from .base import BaseConnector BASE = "https://plan-a.ca" LIST_URL = f"{BASE}/plan-a-appartements-et-condos-a-louer/" # Villes couvertes (valeurs du champ "city" des cartes) : Grand Montréal # + Sherbrooke (expansion provinciale 2026-08). Saguenay reste exclu # (District Jonquière : prévu au lot Est & Nord). ALLOWED_CITIES = { "montreal": "Montréal", "laval": "Laval", "vaudreuil-dorion": "Vaudreuil-Dorion", # Montérégie ouest / CMM "longueuil": "Longueuil", "sherbrooke": "Sherbrooke", } # Secteur (quartier) déduit du slug pour les projets montréalais _SECTOR_HINTS = [ ("pierrefonds", "Pierrefonds-Roxboro"), ("roxboro", "Pierrefonds-Roxboro"), ("gouin", "Pierrefonds-Roxboro"), ("pointe-aux-trembles", "Pointe-aux-Trembles"), ("ahuntsic", "Ahuntsic"), ("cremazie", "Ahuntsic"), ("community-high-school", "Pierrefonds-Roxboro"), ] class PlanAConnector(BaseConnector): source_id = "plan_a" request_delay = 0.6 max_details = 20 # garde-fou de fetch des fiches projets def fetch(self) -> list[Listing]: listings: list[Listing] = [] try: html = self.get(LIST_URL).text except Exception: return listings soup = BeautifulSoup(html, "html.parser") for card in soup.select('a.c-card[data-listing="project"]'): try: lst = self._parse_card(card) except Exception: continue if lst: listings.append(lst) # Fiches projets (via cache self.detail : une vraie requête seulement # si la carte liste a changé) : description, disponibilité, quartier, # commodités des unités, services de l'immeuble, types, galerie. budget = {"n": 0} for lst in listings: key = hashlib.sha1("|".join( (lst.price_label, lst.availability, lst.unit_type, lst.address)).encode()).hexdigest() def _fetch(url=lst.url): if budget["n"] >= self.max_details: raise RuntimeError("plafond de fiches atteint") budget["n"] += 1 return self._parse_detail(self.get(url).text) try: payload = self.detail(lst.external_id, key, _fetch) except Exception: continue if payload.get("description"): lst.description = payload["description"] if payload.get("availability"): lst.availability = payload["availability"] if payload.get("unit_type"): lst.unit_type = payload["unit_type"] if payload.get("amenities"): lst.amenities = payload["amenities"] if payload.get("images"): imgs = lst.images + [u for u in payload["images"] if u not in lst.images] lst.images = imgs[:20] if not lst.sector and lst.city == "Montréal" and payload.get("sector"): lst.sector = payload["sector"] return listings # -- fiche /project// ------------------------------------------------ @staticmethod def _parse_detail(detail: str) -> dict: payload: dict = {} dsoup = BeautifulSoup(detail, "html.parser") og = dsoup.find("meta", attrs={"property": "og:description"}) or \ dsoup.find("meta", attrs={"name": "description"}) if og and og.get("content"): payload["description"] = og["content"].strip()[:600] text = re.sub(r"\s+", " ", dsoup.get_text(" ", strip=True)) m = re.search(r"Prochaine disponibilité\s+(.{3,40}?)\s+Unités", text) availability = m.group(1).strip() if m else "" m = re.search(r"Unités? disponibles?\s+(\d+\s*/\s*\d+)", text) if m: availability = f"{availability} ({m.group(1)} unités)".strip() if availability: payload["availability"] = availability[:120] m = re.search(r"QUARTIER\s+([A-ZÀ-Ü][\w\-]+(?:[ \-][A-ZÀ-Ü][\w\-]+)*)", text) if m: payload["sector"] = m.group(1).strip() # Types d'unités disponibles (entête de fiche, plus complet que la carte) types = [re.sub(r"\s+", "", el.get_text(strip=True)) for el in dsoup.select(".c-info_unit")] types = [t for t in dict.fromkeys(types) if re.fullmatch(r"\d½|Studio|Loft", t)] if types: payload["unit_type"] = ", ".join(types) # Commodités des unités (section .c-feature) + services de l'immeuble # (liste à puces sous « SERVICES DANS L'IMMEUBLE ») + atouts (carrousel) amenities: list[str] = [] for sec in dsoup.select("section"): over = sec.select_one(".c-overline") if over and "commodit" in over.get_text(strip=True).lower(): for p in sec.select(".c-feature p"): t = re.sub(r"\s+", " ", p.get_text(" ", strip=True)) if t and t not in amenities: amenities.append(t) for h4 in dsoup.find_all("h4"): if "services dans l" in h4.get_text(strip=True).lower(): ul = h4.find_next("ul") if ul: for li in ul.find_all("li"): t = li.get_text(" ", strip=True).lstrip("• ").strip() if t and t not in amenities: amenities.append(t) break for h4 in dsoup.select(".c-cell_title h4"): t = re.sub(r"\s+", " ", h4.get_text(" ", strip=True)) if t and t not in amenities: amenities.append(t) if amenities: payload["amenities"] = amenities[:30] # Galerie : photos wp-content (jamais les icônes svg) imgs = [] for img in dsoup.find_all("img", src=True): u = img["src"] if re.search(r"/wp-content/uploads/.*\.(?:jpe?g|png|webp)$", u, re.I) \ and u not in imgs: imgs.append(u) if imgs: payload["images"] = imgs[:20] return payload # -- parsing d'une carte-projet --------------------------------------------- def _parse_card(self, card) -> Listing | None: href = (card.get("href") or "").split("?")[0] m = re.search(r"/project/([a-z0-9\-]+)/?$", href) if not m: return None slug = m.group(1) try: filters = json.loads(card.get("data-filters-item") or "{}") except (ValueError, TypeError): filters = {} city_key = (filters.get("city") or "").strip().lower() if city_key not in ALLOWED_CITIES: return None # Saguenay... exclus city = ALLOWED_CITIES[city_key] name_el = card.select_one("h6") name = name_el.get_text(" ", strip=True) if name_el else slug overline = card.select_one(".c-card_overline") locality = overline.get_text(" ", strip=True) if overline else "" subtitles = [s.get_text(" ", strip=True) for s in card.select(".c-card_subtitle")] address = subtitles[0] if subtitles else "" if "Complet" in subtitles or any("complet" in s.lower() for s in subtitles): return None # aucun logement disponible price_label = next((s for s in subtitles if "$" in s), "") unit_types = [s for s in subtitles if re.fullmatch(r"\d\s*½|\d\s*1/2|Studio|Loft", s)] notice = card.select_one(".c-card_notice") availability = notice.get_text(" ", strip=True) if notice else "" if availability.lower() == "maintenant": availability = "Disponible maintenant" # prix plancher : JSON budget-min sinon texte « À partir de … $ » price = None bmin = str(filters.get("budget-min") or "").strip() if bmin.isdigit(): val = float(bmin) if 100 <= val <= 20000: price = val if price is None: price = parse_price(price_label) images = [img.get("src") for img in card.select("img.c-card_image") if img.get("src")] images = list(dict.fromkeys(images))[:20] sector = "" if city == "Montréal": for key, sec in _SECTOR_HINTS: if key in slug: sector = sec break elif locality and locality != city: sector = locality return Listing( source=self.source_id, external_id=slug, url=f"{BASE}/project/{slug}/", title=name, address=f"{address}, {locality}" if address else locality, sector=sector, city=city, unit_type=", ".join(dict.fromkeys( t.replace(" ", "").replace("1/2", "½") for t in unit_types)), price=price, price_label=price_label, availability=availability, amenities=[], images=images, )