spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/plan_a.py : connecteur Plan A Immobilier (plan-a.ca)5# ~3500 unités dans des complexes locatifs (Pierrefonds-Roxboro,6# Pointe-aux-Trembles, Ahuntsic, Laval, Vaudreuil-Dorion, Sherbrooke —7# les projets de Saguenay sont encore exclus). WordPress rendu serveur :8# la page « appartements et condos à louer » expose des cartes-projets9# a.c-card[data-listing=project] avec un JSON data-filters-item (ville,10# budget min, types), le carrousel d'images et le prix « à partir de ».11# Granularité = 1 annonce par complexe (prix plancher), fiches /project/12# pour la description et la disponibilité.13# -----------------------------------------------------------------------------14from __future__ import annotations1516import hashlib17import json18import re1920from bs4 import BeautifulSoup2122from ..schema import Listing, parse_price23from .base import BaseConnector2425BASE = "https://plan-a.ca"26LIST_URL = f"{BASE}/plan-a-appartements-et-condos-a-louer/"2728# Villes couvertes (valeurs du champ "city" des cartes) : Grand Montréal29# + Sherbrooke (expansion provinciale 2026-08). Saguenay reste exclu30# (District Jonquière : prévu au lot Est & Nord).31ALLOWED_CITIES = {32 "montreal": "Montréal",33 "laval": "Laval",34 "vaudreuil-dorion": "Vaudreuil-Dorion", # Montérégie ouest / CMM35 "longueuil": "Longueuil",36 "sherbrooke": "Sherbrooke",37}3839# Secteur (quartier) déduit du slug pour les projets montréalais40_SECTOR_HINTS = [41 ("pierrefonds", "Pierrefonds-Roxboro"),42 ("roxboro", "Pierrefonds-Roxboro"),43 ("gouin", "Pierrefonds-Roxboro"),44 ("pointe-aux-trembles", "Pointe-aux-Trembles"),45 ("ahuntsic", "Ahuntsic"),46 ("cremazie", "Ahuntsic"),47 ("community-high-school", "Pierrefonds-Roxboro"),48]495051class PlanAConnector(BaseConnector):52 source_id = "plan_a"53 request_delay = 0.654 max_details = 20 # garde-fou de fetch des fiches projets5556 def fetch(self) -> list[Listing]:57 listings: list[Listing] = []58 try:59 html = self.get(LIST_URL).text60 except Exception:61 return listings62 soup = BeautifulSoup(html, "html.parser")6364 for card in soup.select('a.c-card[data-listing="project"]'):65 try:66 lst = self._parse_card(card)67 except Exception:68 continue69 if lst:70 listings.append(lst)7172 # Fiches projets (via cache self.detail : une vraie requête seulement73 # si la carte liste a changé) : description, disponibilité, quartier,74 # commodités des unités, services de l'immeuble, types, galerie.75 budget = {"n": 0}76 for lst in listings:77 key = hashlib.sha1("|".join(78 (lst.price_label, lst.availability, lst.unit_type,79 lst.address)).encode()).hexdigest()8081 def _fetch(url=lst.url):82 if budget["n"] >= self.max_details:83 raise RuntimeError("plafond de fiches atteint")84 budget["n"] += 185 return self._parse_detail(self.get(url).text)8687 try:88 payload = self.detail(lst.external_id, key, _fetch)89 except Exception:90 continue91 if payload.get("description"):92 lst.description = payload["description"]93 if payload.get("availability"):94 lst.availability = payload["availability"]95 if payload.get("unit_type"):96 lst.unit_type = payload["unit_type"]97 if payload.get("amenities"):98 lst.amenities = payload["amenities"]99 if payload.get("images"):100 imgs = lst.images + [u for u in payload["images"]101 if u not in lst.images]102 lst.images = imgs[:20]103 if not lst.sector and lst.city == "Montréal" and payload.get("sector"):104 lst.sector = payload["sector"]105106 return listings107108 # -- fiche /project/<slug>/ ------------------------------------------------109 @staticmethod110 def _parse_detail(detail: str) -> dict:111 payload: dict = {}112 dsoup = BeautifulSoup(detail, "html.parser")113114 og = dsoup.find("meta", attrs={"property": "og:description"}) or \115 dsoup.find("meta", attrs={"name": "description"})116 if og and og.get("content"):117 payload["description"] = og["content"].strip()[:600]118119 text = re.sub(r"\s+", " ", dsoup.get_text(" ", strip=True))120 m = re.search(r"Prochaine disponibilité\s+(.{3,40}?)\s+Unités", text)121 availability = m.group(1).strip() if m else ""122 m = re.search(r"Unités? disponibles?\s+(\d+\s*/\s*\d+)", text)123 if m:124 availability = f"{availability} ({m.group(1)} unités)".strip()125 if availability:126 payload["availability"] = availability[:120]127128 m = re.search(r"QUARTIER\s+([A-ZÀ-Ü][\w\-]+(?:[ \-][A-ZÀ-Ü][\w\-]+)*)",129 text)130 if m:131 payload["sector"] = m.group(1).strip()132133 # Types d'unités disponibles (entête de fiche, plus complet que la carte)134 types = [re.sub(r"\s+", "", el.get_text(strip=True))135 for el in dsoup.select(".c-info_unit")]136 types = [t for t in dict.fromkeys(types)137 if re.fullmatch(r"\d½|Studio|Loft", t)]138 if types:139 payload["unit_type"] = ", ".join(types)140141 # Commodités des unités (section .c-feature) + services de l'immeuble142 # (liste à puces sous « SERVICES DANS L'IMMEUBLE ») + atouts (carrousel)143 amenities: list[str] = []144 for sec in dsoup.select("section"):145 over = sec.select_one(".c-overline")146 if over and "commodit" in over.get_text(strip=True).lower():147 for p in sec.select(".c-feature p"):148 t = re.sub(r"\s+", " ", p.get_text(" ", strip=True))149 if t and t not in amenities:150 amenities.append(t)151 for h4 in dsoup.find_all("h4"):152 if "services dans l" in h4.get_text(strip=True).lower():153 ul = h4.find_next("ul")154 if ul:155 for li in ul.find_all("li"):156 t = li.get_text(" ", strip=True).lstrip("• ").strip()157 if t and t not in amenities:158 amenities.append(t)159 break160 for h4 in dsoup.select(".c-cell_title h4"):161 t = re.sub(r"\s+", " ", h4.get_text(" ", strip=True))162 if t and t not in amenities:163 amenities.append(t)164 if amenities:165 payload["amenities"] = amenities[:30]166167 # Galerie : photos wp-content (jamais les icônes svg)168 imgs = []169 for img in dsoup.find_all("img", src=True):170 u = img["src"]171 if re.search(r"/wp-content/uploads/.*\.(?:jpe?g|png|webp)$", u, re.I) \172 and u not in imgs:173 imgs.append(u)174 if imgs:175 payload["images"] = imgs[:20]176177 return payload178179 # -- parsing d'une carte-projet ---------------------------------------------180 def _parse_card(self, card) -> Listing | None:181 href = (card.get("href") or "").split("?")[0]182 m = re.search(r"/project/([a-z0-9\-]+)/?$", href)183 if not m:184 return None185 slug = m.group(1)186187 try:188 filters = json.loads(card.get("data-filters-item") or "{}")189 except (ValueError, TypeError):190 filters = {}191 city_key = (filters.get("city") or "").strip().lower()192 if city_key not in ALLOWED_CITIES:193 return None # Saguenay... exclus194 city = ALLOWED_CITIES[city_key]195196 name_el = card.select_one("h6")197 name = name_el.get_text(" ", strip=True) if name_el else slug198 overline = card.select_one(".c-card_overline")199 locality = overline.get_text(" ", strip=True) if overline else ""200201 subtitles = [s.get_text(" ", strip=True)202 for s in card.select(".c-card_subtitle")]203 address = subtitles[0] if subtitles else ""204 if "Complet" in subtitles or any("complet" in s.lower() for s in subtitles):205 return None # aucun logement disponible206 price_label = next((s for s in subtitles if "$" in s), "")207 unit_types = [s for s in subtitles208 if re.fullmatch(r"\d\s*½|\d\s*1/2|Studio|Loft", s)]209210 notice = card.select_one(".c-card_notice")211 availability = notice.get_text(" ", strip=True) if notice else ""212 if availability.lower() == "maintenant":213 availability = "Disponible maintenant"214215 # prix plancher : JSON budget-min sinon texte « À partir de … $ »216 price = None217 bmin = str(filters.get("budget-min") or "").strip()218 if bmin.isdigit():219 val = float(bmin)220 if 100 <= val <= 20000:221 price = val222 if price is None:223 price = parse_price(price_label)224225 images = [img.get("src") for img in card.select("img.c-card_image")226 if img.get("src")]227 images = list(dict.fromkeys(images))[:20]228229 sector = ""230 if city == "Montréal":231 for key, sec in _SECTOR_HINTS:232 if key in slug:233 sector = sec234 break235 elif locality and locality != city:236 sector = locality237238 return Listing(239 source=self.source_id,240 external_id=slug,241 url=f"{BASE}/project/{slug}/",242 title=name,243 address=f"{address}, {locality}" if address else locality,244 sector=sector,245 city=city,246 unit_type=", ".join(dict.fromkeys(247 t.replace(" ", "").replace("1/2", "½") for t in unit_types)),248 price=price,249 price_label=price_label,250 availability=availability,251 amenities=[],252 images=images,253 )254