# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/cap_gestion.py : connecteur CAP Gestion / CAP Immobilier # (capgestion.ca) — Saint-Jérôme, Lachute, Mirabel, Joliette, Louiseville, # Repentigny, Lavaltrie, Brownsburg-Chatham… # WordPress + plugin immobilier Estatik 4 Pro : la page « Logements à # louer » (rendu serveur, ~10 fiches/page, pagination ?paged-1=N) expose # des cartes .es-listing avec data-post-id (id STABLE), prix, adresse # complète et type de logement. Chaque fiche /propriete// fournit # les champs STRUCTURÉS Estatik (.es-property-field : type, salles de # bain, chambres, superficie, animaux, chauffage, stationnement, étage, # période de location…) + description et galerie. # Les fiches étiquetées « Loué » (es_label/loue) sont exclues : le # logement n'est plus offert. robots.txt permissif (Yoast). # ----------------------------------------------------------------------------- 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://capgestion.ca" LIST_URL = f"{BASE}/gestion-immobiliere-logements-a-louer/" # adresse Estatik : « 412 Av. Barron, Lachute, QC J8H 0W2, Canada » # (la ville est la composante qui précède « QC » ; certaines fiches omettent # la province — on prend alors la dernière composante non postale) _QC_RE = re.compile(r"^(?:QC|Qc|Québec|Quebec)\b", re.I) _POSTAL_RE = re.compile(r"[A-Z]\d[A-Z]\s*\d[A-Z]\d", re.I) # le site écrit tantôt « Brownsburg », tantôt « Brownsburg-Chatham » # (même municipalité) : canonicalisation factuelle _CITY_FIX = {"Brownsburg": "Brownsburg-Chatham"} def _city_from_address(address: str) -> str: parts = [p.strip() for p in address.split(",") if p.strip()] for i, p in enumerate(parts): if _QC_RE.match(p) and i > 0: return _CITY_FIX.get(parts[i - 1], parts[i - 1]) # pas de « QC » : dernière composante non postale/pays (ex. # « 181 de la Scierie, Brownsburg-Chatham ») for p in reversed(parts[1:]): if p.lower() == "canada" or _POSTAL_RE.search(p) or re.search(r"\d", p): continue return _CITY_FIX.get(p, p) return "" # images de la galerie (uploads WordPress), variantes redimensionnées exclues _IMG_RE = re.compile( r"https://capgestion\.ca/wp-content/uploads/[^\"'\s\)]+\.(?:jpe?g|png|webp)", re.I) _SKIP_IMG = re.compile(r"logo|icon|favicon|-\d{2,3}x\d{2,3}\.", re.I) class _CapAtteint(Exception): """Plafond de requêtes détail atteint pour cette synchronisation.""" class CapGestionConnector(BaseConnector): source_id = "cap_gestion" request_delay = 0.7 max_list_pages = 25 # garde-fou (13 pages observées) max_real_details = 60 # vraies requêtes fiches par sync (cache exclu) # -- liste -------------------------------------------------------------------- def _parse_page(self, html: str, cards: dict[str, dict]) -> int: """Cartes es-listing d'une page ; retourne le nombre total de pages.""" soup = BeautifulSoup(html, "html.parser") for el in soup.select(".js-es-listing[data-post-id]"): pid = el.get("data-post-id", "").strip() if not pid or pid in cards: continue labels = [a.get_text(" ", strip=True) for a in el.select(".es-badges a")] link = el.select_one("a.es-listings__image__link") or \ el.select_one(".es-listing__body a[href]") or \ el.find("a", href=re.compile(r"/propriete/|/property/")) url = (link.get("href") or "") if link else "" title_el = el.select_one(".es-listing__title, h3 a, h2 a") title = title_el.get_text(" ", strip=True) if title_el else "" addr_el = el.select_one(".es-address") address = addr_el.get_text(" ", strip=True) if addr_el else "" price_el = el.select_one(".es-price") price_label = price_el.get_text(" ", strip=True) if price_el else "" badge = el.select_one(".es-badge--normal") if badge: # ex. « Par mois » price_label = f"{price_label} {badge.get_text(strip=True)}".strip() unit_el = el.select_one(".es-listing__meta-types-de-logement") unit_type = unit_el.get_text(" ", strip=True) if unit_el else "" imgs = [] for im in el.select(".es-listing__image img"): u = im.get("data-lazy") or im.get("src") or "" if u.startswith("http") and u not in imgs: imgs.append(u) cards[pid] = { "url": url, "title": title, "address": address, "price_label": price_label, "unit_type": unit_type, "labels": labels, "images": imgs, } # pagination : « 1 - 10 of 124 properties » + liens ?paged-1=N pages = 1 for a in soup.select(".js-es-pagination a.page-numbers"): m = re.search(r"paged-1=(\d+)", a.get("href") or "") if m: pages = max(pages, int(m.group(1))) return pages # -- fiche (champs structurés Estatik) ------------------------------------------ def _unit_detail(self, pid: str, url: str, key: str) -> dict: def _fetch() -> dict: if self._real_details >= self.max_real_details: raise _CapAtteint() self._real_details += 1 html = self.get(url).text soup = BeautifulSoup(html, "html.parser") out: dict = {"fields": {}} for f in soup.select(".es-property-field"): label_el = f.select_one(".es-property-field__label") value_el = f.select_one(".es-property-field__value") if label_el is None or value_el is None: txt = f.get_text(" ", strip=True) if ":" in txt: lab, _, val = txt.partition(":") else: continue else: lab = label_el.get_text(" ", strip=True) val = value_el.get_text(" ", strip=True) lab = re.sub(r"\s*:\s*$", "", re.sub(r"\s+", " ", lab)).strip() val = re.sub(r"\s+", " ", val).strip() if lab and val: out["fields"][lab] = val imgs = [u for u in dict.fromkeys(_IMG_RE.findall(html)) if not _SKIP_IMG.search(u)] out["images"] = imgs[:20] return out try: return self.detail(pid, key, _fetch) except Exception: return {} # -- fetch -------------------------------------------------------------------- def fetch(self) -> list[Listing]: self._real_details = 0 cards: dict[str, dict] = {} pages = self._parse_page(self.get(LIST_URL).text, cards) for n in range(2, min(pages, self.max_list_pages) + 1): try: self._parse_page( self.get(LIST_URL, params={"paged-1": n}).text, cards) except Exception: continue listings: list[Listing] = [] for pid, c in cards.items(): if "Loué" in c["labels"]: # plus disponible : exclu continue if not c["url"].startswith("http"): continue key = hashlib.sha1(json.dumps(c, sort_keys=True, ensure_ascii=False) .encode("utf-8")).hexdigest()[:16] det = self._unit_detail(pid, c["url"], key) fields = det.get("fields") or {} # ville : composante de l'adresse Estatik (structurée) city = _city_from_address(c["address"]) unit_type = fields.get("Type de logement") or c["unit_type"] desc = fields.get("La description") or "" area = None try: v = float(str(fields.get("Superficie", "")).replace(",", "") .replace(" ", "")) if 80 <= v <= 20000: area = v except ValueError: pass details: dict = {} beds = fields.get("Chambres totales") if beds and beds.isdigit(): details["bedrooms"] = int(beds) baths = fields.get("Salle(s) de bain") if baths and baths.isdigit(): details["bathrooms"] = int(baths) if fields.get("Étage"): details["floor_label"] = fields["Étage"] if fields.get("Période de location"): details["lease"] = fields["Période de location"] pets = None pets_raw = fields.get("Animaux acceptés", "") if re.search(r"non|aucun|interdit", pets_raw, re.I): pets = "non" elif pets_raw: pets = "conditions" if re.search( r"frais|maximum|condition|stéril", pets_raw, re.I) else "oui" amenities = [] for lab in ("Animaux acceptés", "Type d'animal autorisé", "Électros inclus", "Chauffage et électricité", "Nb de stationnements", "Type de stationnement", "Enquête de prélocation complète"): if fields.get(lab): amenities.append(f"{lab} : {fields[lab]}") # disponibilité : uniquement si affichée (souvent dans le titre, # ex. « … – 1ER AOÛT » ou « Disponible maintenant ») — rien d'inventé availability = "" m = re.search(r"Disponible[^.|]*", desc, re.I) if m: availability = m.group(0).strip() listings.append(Listing( source=self.source_id, external_id=pid, url=c["url"], title=c["title"], address=c["address"], sector="", city=city, unit_type=unit_type, price=parse_price(c["price_label"]), price_label=c["price_label"], availability=availability, area_sqft=area, pets=pets, description=desc[:600], amenities=amenities, details=details, images=(det.get("images") or c["images"])[:20], )) return listings