# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/res_cogir.py : Résidences Cogir (residencescogir.com) — 85 # résidences privées pour aînés (RPA) partout au Québec : réseaux Jazz, # ex-Sélection (Domaine des Forges, Jardins de Renoir…), Villa, etc. # Domaine DISTINCT de cogir.net (le connecteur `cogir` couvre le locatif # traditionnel Cogir Immobilier — aucun doublon avec les RPA d'ici). # Site rendu serveur mais derrière un WAF (403 en direct) : tout passe par # Scrapfly ASP sans rendu JS. Le sitemap.xml liste les fiches # /residences-aines-/ (les Venvi « -ontario » sont exclues) ; # chaque fiche expose le tableau « Modèles disponibles » (Type | Plan | # À partir ($)), l'adresse civique (lien Google Maps), le téléphone, les # coordonnées GPS (widget Walk Score) et la galerie. Une annonce Lou-Ka par # résidence × typologie, prix plancher « à partir de » ; la superficie est # récupérée du nom de fichier du plan (« …_3.5_typeA_702pc.jpg »). # ----------------------------------------------------------------------------- from __future__ import annotations import html as _html import re import time from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type from .base import BaseConnector SITEMAP = "https://residencescogir.com/sitemap.xml" _RES_URL_RE = re.compile( r"https://residencescogir\.com/residences-aines-[a-z0-9-]+/[a-z0-9-]+$") _ADDR_RE = re.compile( r'fa-location-dot[^<]*\s*([^<]{10,120})<') _LAT_RE = re.compile(r"lat['\"]?\s*[:=]\s*(-?\d+\.\d+)") _LNG_RE = re.compile(r"lng['\"]?\s*[:=]\s*(-?\d+\.\d+)") _PHONE_RE = re.compile(r'href="tel:([\d\s\-().+]{7,20})"') _PRICE_RE = re.compile(r"([\d][\d\s,]{2,8})\s*\$") _AREA_IN_PLAN_RE = re.compile(r"_(\d{3,4})\s*pc", re.I) _IMG_RE = re.compile( r"https://residencescogir\.com/uploads/residence/" r"(?:banner_fr|gallery_imgs|image)/[^\"\s\\']+\.(?:jpe?g|png|webp)", re.I) class ResidencesCogirConnector(BaseConnector): source_id = "res_cogir" request_delay = 0.8 timeout = 60 max_images = 15 def _get_html(self, url: str) -> str: """HTML via Scrapfly ASP (WAF sur tout le domaine), sans rendu JS — les fiches sont rendues serveur.""" return self.get_scrapfly(url, render_js=False) # -- fiche résidence ------------------------------------------------------------- def _parse_residence(self, url: str, html: str) -> list[Listing]: soup = BeautifulSoup(html, "html.parser") slug = url.rstrip("/").rsplit("/", 1)[-1] # nom : « Jazz Lévis | Résidences Cogir | … » name = slug.replace("-", " ").title() t = soup.find("title") if t is not None: first = _html.unescape(t.get_text()).split("|")[0].strip() if 2 <= len(first) <= 60: name = first # adresse « 7, rue Saint Thomas, Lévis, Quebec, G6V 5R1 » (lien Maps) address = city = "" m = _ADDR_RE.search(html) if m: full = re.sub(r"\s+", " ", _html.unescape(m.group(1))).strip() full = full.replace(" ", " ") parts = [p.strip() for p in full.split(",") if p.strip()] if len(parts) >= 4: # …, ville, province, code city = parts[-3] address = ", ".join(parts[:-3]) else: address = full if not city: # repli : segment d'URL seg = url.rstrip("/").rsplit("/", 2)[-2] city = seg.replace("residences-aines-", "").replace("-", " ").title() lat = lng = None ml, mg = _LAT_RE.search(html), _LNG_RE.search(html) if ml and mg: try: lat, lng = float(ml.group(1)), float(mg.group(1)) except ValueError: lat = lng = None contact: dict = {} m = _PHONE_RE.search(html) if m: contact["phone"] = m.group(1).strip() # description : premier paragraphe long de la fiche desc = "" for p in soup.find_all("p"): txt = re.sub(r"\s+", " ", p.get_text(" ", strip=True)).strip() if len(txt) >= 120 and "crédit d" not in txt.lower(): desc = txt break images = [u for u in dict.fromkeys(_IMG_RE.findall(html)) if not re.search(r"logo|icon", u, re.I)][: self.max_images] # tableau(x) « Modèles disponibles » : Type | Plans | À partir ($) out: list[Listing] = [] seen: set[str] = set() for table in soup.select("section.model-table table"): for tr in table.find_all("tr"): tds = tr.find_all("td") if len(tds) < 2: continue raw_type = re.sub(r"\s+", " ", tds[0].get_text(" ", strip=True)).strip() if not raw_type or len(raw_type) > 30: continue pm = _PRICE_RE.search(tds[-1].get_text(" ", strip=True)) if not pm: continue try: price = float(pm.group(1).replace(" ", "") .replace(" ", "").replace(",", "")) except ValueError: continue if not 400 <= price <= 20000: continue area = None a = tr.find("a", href=True) if a is not None: am = _AREA_IN_PLAN_RE.search(a["href"]) if am: area = float(am.group(1)) eid = f"{slug}-{re.sub(r'[^a-z0-9]+', '-', raw_type.lower()).strip('-')}" if eid in seen: # même typologie répétée continue seen.add(eid) out.append(Listing( source=self.source_id, external_id=eid, url=url, title=f"{raw_type} — {name}", address=address, city=city, unit_type=normalize_unit_type(raw_type), price=price, price_label=f"À partir de {price:,.0f} $ par mois" .replace(",", " "), availability="Modèles disponibles", area_sqft=area, description=desc, details={"Résidence pour aînés": "oui", **({"contact": contact} if contact else {})}, images=images, lat=lat, lng=lng, )) return out def fetch(self) -> list[Listing]: # Un raté transitoire Scrapfly sur le sitemap (contenu vide, vu # 2026-08-29) produisait un faux « 0 trouvé ok » : retry court, puis # erreur franche plutôt qu'un zéro silencieux. urls: list[str] = [] for attempt in range(3): xml = self._get_html(SITEMAP) urls = sorted({u for u in re.findall(r"<loc>([^<]+)</loc>", xml) if _RES_URL_RE.match(u) and "-ontario/" not in u}) if urls: break if attempt < 2: time.sleep(5 * (attempt + 1)) else: raise RuntimeError( "sitemap sans URL de résidence après 3 essais Scrapfly") out: list[Listing] = [] for url in urls: try: html = self._get_html(url) if html: out.extend(self._parse_residence(url, html)) except Exception: continue if not out: raise RuntimeError( f"0 annonce extraite des {len(urls)} fiches du sitemap") return out