Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/res_cogir.py : Résidences Cogir (residencescogir.com) — 855# résidences privées pour aînés (RPA) partout au Québec : réseaux Jazz,6# ex-Sélection (Domaine des Forges, Jardins de Renoir…), Villa, etc.7# Domaine DISTINCT de cogir.net (le connecteur `cogir` couvre le locatif8# traditionnel Cogir Immobilier — aucun doublon avec les RPA d'ici).9# Site rendu serveur mais derrière un WAF (403 en direct) : tout passe par10# Scrapfly ASP sans rendu JS. Le sitemap.xml liste les fiches11# /residences-aines-<ville>/<slug> (les Venvi « -ontario » sont exclues) ;12# chaque fiche expose le tableau « Modèles disponibles » (Type | Plan |13# À partir ($)), l'adresse civique (lien Google Maps), le téléphone, les14# coordonnées GPS (widget Walk Score) et la galerie. Une annonce Lou-Ka par15# résidence × typologie, prix plancher « à partir de » ; la superficie est16# récupérée du nom de fichier du plan (« …_3.5_typeA_702pc.jpg »).17# -----------------------------------------------------------------------------18from __future__ import annotations1920import html as _html21import re22import time2324from bs4 import BeautifulSoup2526from ..schema import Listing, normalize_unit_type27from .base import BaseConnector2829SITEMAP = "https://residencescogir.com/sitemap.xml"3031_RES_URL_RE = re.compile(32 r"https://residencescogir\.com/residences-aines-[a-z0-9-]+/[a-z0-9-]+$")33_ADDR_RE = re.compile(34 r'fa-location-dot[^<]*</i>\s*([^<]{10,120})<')35_LAT_RE = re.compile(r"lat['\"]?\s*[:=]\s*(-?\d+\.\d+)")36_LNG_RE = re.compile(r"lng['\"]?\s*[:=]\s*(-?\d+\.\d+)")37_PHONE_RE = re.compile(r'href="tel:([\d\s\-().+]{7,20})"')38_PRICE_RE = re.compile(r"([\d][\d\s,]{2,8})\s*\$")39_AREA_IN_PLAN_RE = re.compile(r"_(\d{3,4})\s*pc", re.I)40_IMG_RE = re.compile(41 r"https://residencescogir\.com/uploads/residence/"42 r"(?:banner_fr|gallery_imgs|image)/[^\"\s\\']+\.(?:jpe?g|png|webp)", re.I)434445class ResidencesCogirConnector(BaseConnector):46 source_id = "res_cogir"47 request_delay = 0.848 timeout = 6049 max_images = 155051 def _get_html(self, url: str) -> str:52 """HTML via Scrapfly ASP (WAF sur tout le domaine), sans rendu JS —53 les fiches sont rendues serveur."""54 return self.get_scrapfly(url, render_js=False)5556 # -- fiche résidence -------------------------------------------------------------57 def _parse_residence(self, url: str, html: str) -> list[Listing]:58 soup = BeautifulSoup(html, "html.parser")59 slug = url.rstrip("/").rsplit("/", 1)[-1]6061 # nom : <title> « Jazz Lévis | Résidences Cogir | … »62 name = slug.replace("-", " ").title()63 t = soup.find("title")64 if t is not None:65 first = _html.unescape(t.get_text()).split("|")[0].strip()66 if 2 <= len(first) <= 60:67 name = first6869 # adresse « 7, rue Saint Thomas, Lévis, Quebec, G6V 5R1 » (lien Maps)70 address = city = ""71 m = _ADDR_RE.search(html)72 if m:73 full = re.sub(r"\s+", " ", _html.unescape(m.group(1))).strip()74 full = full.replace(" ", " ")75 parts = [p.strip() for p in full.split(",") if p.strip()]76 if len(parts) >= 4: # …, ville, province, code77 city = parts[-3]78 address = ", ".join(parts[:-3])79 else:80 address = full81 if not city: # repli : segment d'URL82 seg = url.rstrip("/").rsplit("/", 2)[-2]83 city = seg.replace("residences-aines-", "").replace("-", " ").title()8485 lat = lng = None86 ml, mg = _LAT_RE.search(html), _LNG_RE.search(html)87 if ml and mg:88 try:89 lat, lng = float(ml.group(1)), float(mg.group(1))90 except ValueError:91 lat = lng = None9293 contact: dict = {}94 m = _PHONE_RE.search(html)95 if m:96 contact["phone"] = m.group(1).strip()9798 # description : premier paragraphe long de la fiche99 desc = ""100 for p in soup.find_all("p"):101 txt = re.sub(r"\s+", " ", p.get_text(" ", strip=True)).strip()102 if len(txt) >= 120 and "crédit d" not in txt.lower():103 desc = txt104 break105106 images = [u for u in dict.fromkeys(_IMG_RE.findall(html))107 if not re.search(r"logo|icon", u, re.I)][: self.max_images]108109 # tableau(x) « Modèles disponibles » : Type | Plans | À partir ($)110 out: list[Listing] = []111 seen: set[str] = set()112 for table in soup.select("section.model-table table"):113 for tr in table.find_all("tr"):114 tds = tr.find_all("td")115 if len(tds) < 2:116 continue117 raw_type = re.sub(r"\s+", " ",118 tds[0].get_text(" ", strip=True)).strip()119 if not raw_type or len(raw_type) > 30:120 continue121 pm = _PRICE_RE.search(tds[-1].get_text(" ", strip=True))122 if not pm:123 continue124 try:125 price = float(pm.group(1).replace(" ", "")126 .replace(" ", "").replace(",", ""))127 except ValueError:128 continue129 if not 400 <= price <= 20000:130 continue131 area = None132 a = tr.find("a", href=True)133 if a is not None:134 am = _AREA_IN_PLAN_RE.search(a["href"])135 if am:136 area = float(am.group(1))137 eid = f"{slug}-{re.sub(r'[^a-z0-9]+', '-', raw_type.lower()).strip('-')}"138 if eid in seen: # même typologie répétée139 continue140 seen.add(eid)141 out.append(Listing(142 source=self.source_id,143 external_id=eid,144 url=url,145 title=f"{raw_type} — {name}",146 address=address,147 city=city,148 unit_type=normalize_unit_type(raw_type),149 price=price,150 price_label=f"À partir de {price:,.0f} $ par mois"151 .replace(",", " "),152 availability="Modèles disponibles",153 area_sqft=area,154 description=desc,155 details={"Résidence pour aînés": "oui",156 **({"contact": contact} if contact else {})},157 images=images,158 lat=lat,159 lng=lng,160 ))161 return out162163 def fetch(self) -> list[Listing]:164 # Un raté transitoire Scrapfly sur le sitemap (contenu vide, vu165 # 2026-08-29) produisait un faux « 0 trouvé ok » : retry court, puis166 # erreur franche plutôt qu'un zéro silencieux.167 urls: list[str] = []168 for attempt in range(3):169 xml = self._get_html(SITEMAP)170 urls = sorted({u for u in re.findall(r"<loc>([^<]+)</loc>", xml)171 if _RES_URL_RE.match(u) and "-ontario/" not in u})172 if urls:173 break174 if attempt < 2:175 time.sleep(5 * (attempt + 1))176 else:177 raise RuntimeError(178 "sitemap sans URL de résidence après 3 essais Scrapfly")179 out: list[Listing] = []180 for url in urls:181 try:182 html = self._get_html(url)183 if html:184 out.extend(self._parse_residence(url, html))185 except Exception:186 continue187 if not out:188 raise RuntimeError(189 f"0 annonce extraite des {len(urls)} fiches du sitemap")190 return out191