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/cap_gestion.py : connecteur CAP Gestion / CAP Immobilier5# (capgestion.ca) — Saint-Jérôme, Lachute, Mirabel, Joliette, Louiseville,6# Repentigny, Lavaltrie, Brownsburg-Chatham…7# WordPress + plugin immobilier Estatik 4 Pro : la page « Logements à8# louer » (rendu serveur, ~10 fiches/page, pagination ?paged-1=N) expose9# des cartes .es-listing avec data-post-id (id STABLE), prix, adresse10# complète et type de logement. Chaque fiche /propriete/<slug>/ fournit11# les champs STRUCTURÉS Estatik (.es-property-field : type, salles de12# bain, chambres, superficie, animaux, chauffage, stationnement, étage,13# période de location…) + description et galerie.14# Les fiches étiquetées « Loué » (es_label/loue) sont exclues : le15# logement n'est plus offert. robots.txt permissif (Yoast).16# -----------------------------------------------------------------------------17from __future__ import annotations1819import hashlib20import json21import re2223from bs4 import BeautifulSoup2425from ..schema import Listing, parse_price26from .base import BaseConnector2728BASE = "https://capgestion.ca"29LIST_URL = f"{BASE}/gestion-immobiliere-logements-a-louer/"3031# adresse Estatik : « 412 Av. Barron, Lachute, QC J8H 0W2, Canada »32# (la ville est la composante qui précède « QC » ; certaines fiches omettent33# la province — on prend alors la dernière composante non postale)34_QC_RE = re.compile(r"^(?:QC|Qc|Québec|Quebec)\b", re.I)35_POSTAL_RE = re.compile(r"[A-Z]\d[A-Z]\s*\d[A-Z]\d", re.I)36# le site écrit tantôt « Brownsburg », tantôt « Brownsburg-Chatham »37# (même municipalité) : canonicalisation factuelle38_CITY_FIX = {"Brownsburg": "Brownsburg-Chatham"}394041def _city_from_address(address: str) -> str:42 parts = [p.strip() for p in address.split(",") if p.strip()]43 for i, p in enumerate(parts):44 if _QC_RE.match(p) and i > 0:45 return _CITY_FIX.get(parts[i - 1], parts[i - 1])46 # pas de « QC » : dernière composante non postale/pays (ex.47 # « 181 de la Scierie, Brownsburg-Chatham »)48 for p in reversed(parts[1:]):49 if p.lower() == "canada" or _POSTAL_RE.search(p) or re.search(r"\d", p):50 continue51 return _CITY_FIX.get(p, p)52 return ""53# images de la galerie (uploads WordPress), variantes redimensionnées exclues54_IMG_RE = re.compile(55 r"https://capgestion\.ca/wp-content/uploads/[^\"'\s\)]+\.(?:jpe?g|png|webp)",56 re.I)57_SKIP_IMG = re.compile(r"logo|icon|favicon|-\d{2,3}x\d{2,3}\.", re.I)585960class _CapAtteint(Exception):61 """Plafond de requêtes détail atteint pour cette synchronisation."""626364class CapGestionConnector(BaseConnector):65 source_id = "cap_gestion"66 request_delay = 0.767 max_list_pages = 25 # garde-fou (13 pages observées)68 max_real_details = 60 # vraies requêtes fiches par sync (cache exclu)6970 # -- liste --------------------------------------------------------------------71 def _parse_page(self, html: str, cards: dict[str, dict]) -> int:72 """Cartes es-listing d'une page ; retourne le nombre total de pages."""73 soup = BeautifulSoup(html, "html.parser")74 for el in soup.select(".js-es-listing[data-post-id]"):75 pid = el.get("data-post-id", "").strip()76 if not pid or pid in cards:77 continue78 labels = [a.get_text(" ", strip=True)79 for a in el.select(".es-badges a")]80 link = el.select_one("a.es-listings__image__link") or \81 el.select_one(".es-listing__body a[href]") or \82 el.find("a", href=re.compile(r"/propriete/|/property/"))83 url = (link.get("href") or "") if link else ""84 title_el = el.select_one(".es-listing__title, h3 a, h2 a")85 title = title_el.get_text(" ", strip=True) if title_el else ""86 addr_el = el.select_one(".es-address")87 address = addr_el.get_text(" ", strip=True) if addr_el else ""88 price_el = el.select_one(".es-price")89 price_label = price_el.get_text(" ", strip=True) if price_el else ""90 badge = el.select_one(".es-badge--normal")91 if badge: # ex. « Par mois »92 price_label = f"{price_label} {badge.get_text(strip=True)}".strip()93 unit_el = el.select_one(".es-listing__meta-types-de-logement")94 unit_type = unit_el.get_text(" ", strip=True) if unit_el else ""95 imgs = []96 for im in el.select(".es-listing__image img"):97 u = im.get("data-lazy") or im.get("src") or ""98 if u.startswith("http") and u not in imgs:99 imgs.append(u)100 cards[pid] = {101 "url": url, "title": title, "address": address,102 "price_label": price_label, "unit_type": unit_type,103 "labels": labels, "images": imgs,104 }105 # pagination : « 1 - 10 of 124 properties » + liens ?paged-1=N106 pages = 1107 for a in soup.select(".js-es-pagination a.page-numbers"):108 m = re.search(r"paged-1=(\d+)", a.get("href") or "")109 if m:110 pages = max(pages, int(m.group(1)))111 return pages112113 # -- fiche (champs structurés Estatik) ------------------------------------------114 def _unit_detail(self, pid: str, url: str, key: str) -> dict:115 def _fetch() -> dict:116 if self._real_details >= self.max_real_details:117 raise _CapAtteint()118 self._real_details += 1119 html = self.get(url).text120 soup = BeautifulSoup(html, "html.parser")121 out: dict = {"fields": {}}122 for f in soup.select(".es-property-field"):123 label_el = f.select_one(".es-property-field__label")124 value_el = f.select_one(".es-property-field__value")125 if label_el is None or value_el is None:126 txt = f.get_text(" ", strip=True)127 if ":" in txt:128 lab, _, val = txt.partition(":")129 else:130 continue131 else:132 lab = label_el.get_text(" ", strip=True)133 val = value_el.get_text(" ", strip=True)134 lab = re.sub(r"\s*:\s*$", "", re.sub(r"\s+", " ", lab)).strip()135 val = re.sub(r"\s+", " ", val).strip()136 if lab and val:137 out["fields"][lab] = val138 imgs = [u for u in dict.fromkeys(_IMG_RE.findall(html))139 if not _SKIP_IMG.search(u)]140 out["images"] = imgs[:20]141 return out142143 try:144 return self.detail(pid, key, _fetch)145 except Exception:146 return {}147148 # -- fetch --------------------------------------------------------------------149 def fetch(self) -> list[Listing]:150 self._real_details = 0151 cards: dict[str, dict] = {}152153 pages = self._parse_page(self.get(LIST_URL).text, cards)154 for n in range(2, min(pages, self.max_list_pages) + 1):155 try:156 self._parse_page(157 self.get(LIST_URL, params={"paged-1": n}).text, cards)158 except Exception:159 continue160161 listings: list[Listing] = []162 for pid, c in cards.items():163 if "Loué" in c["labels"]: # plus disponible : exclu164 continue165 if not c["url"].startswith("http"):166 continue167 key = hashlib.sha1(json.dumps(c, sort_keys=True, ensure_ascii=False)168 .encode("utf-8")).hexdigest()[:16]169 det = self._unit_detail(pid, c["url"], key)170 fields = det.get("fields") or {}171172 # ville : composante de l'adresse Estatik (structurée)173 city = _city_from_address(c["address"])174175 unit_type = fields.get("Type de logement") or c["unit_type"]176 desc = fields.get("La description") or ""177 area = None178 try:179 v = float(str(fields.get("Superficie", "")).replace(",", "")180 .replace(" ", ""))181 if 80 <= v <= 20000:182 area = v183 except ValueError:184 pass185186 details: dict = {}187 beds = fields.get("Chambres totales")188 if beds and beds.isdigit():189 details["bedrooms"] = int(beds)190 baths = fields.get("Salle(s) de bain")191 if baths and baths.isdigit():192 details["bathrooms"] = int(baths)193 if fields.get("Étage"):194 details["floor_label"] = fields["Étage"]195 if fields.get("Période de location"):196 details["lease"] = fields["Période de location"]197198 pets = None199 pets_raw = fields.get("Animaux acceptés", "")200 if re.search(r"non|aucun|interdit", pets_raw, re.I):201 pets = "non"202 elif pets_raw:203 pets = "conditions" if re.search(204 r"frais|maximum|condition|stéril", pets_raw, re.I) else "oui"205206 amenities = []207 for lab in ("Animaux acceptés", "Type d'animal autorisé",208 "Électros inclus", "Chauffage et électricité",209 "Nb de stationnements", "Type de stationnement",210 "Enquête de prélocation complète"):211 if fields.get(lab):212 amenities.append(f"{lab} : {fields[lab]}")213214 # disponibilité : uniquement si affichée (souvent dans le titre,215 # ex. « … – 1ER AOÛT » ou « Disponible maintenant ») — rien d'inventé216 availability = ""217 m = re.search(r"Disponible[^.|]*", desc, re.I)218 if m:219 availability = m.group(0).strip()220221 listings.append(Listing(222 source=self.source_id,223 external_id=pid,224 url=c["url"],225 title=c["title"],226 address=c["address"],227 sector="",228 city=city,229 unit_type=unit_type,230 price=parse_price(c["price_label"]),231 price_label=c["price_label"],232 availability=availability,233 area_sqft=area,234 pets=pets,235 description=desc[:600],236 amenities=amenities,237 details=details,238 images=(det.get("images") or c["images"])[:20],239 ))240 return listings241