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/cogir.py : connecteur Cogir Immobilier (cogir.net)5# Page liste « gestion-immeubles-residentiels » (rendu serveur) filtrée sur6# les villes de la région de Québec/Lévis, du Grand Montréal (île de7# Montréal, Laval, Longueuil/Rive-Sud, Rive-Nord proche) et de l'Outaouais8# (Gatineau/Hull/Aylmer); chaque immeuble a9# une fiche avec un tableau « Modèles disponibles » (type + prix à partir de)10# et une galerie photos (DATA/PHOTO). Une annonce par modèle d'unité, sinon11# par immeuble.12# Exclus : Ontario/Halifax/etc., résidences pour aînés/retraités/étudiantes.13# -----------------------------------------------------------------------------14from __future__ import annotations1516import re17from urllib.parse import unquote1819from bs4 import BeautifulSoup2021from ..schema import Listing, infer_city, normalize_unit_type, parse_price22from .base import BaseConnector232425def _dedup_address(raw: str) -> str:26 """Retire les segments dupliqués des adresses Cogir27 (« 1769 Rue Careau, Québec, QC, Québec, Québec G1M 0E6 »)."""28 parts, seen = [], set()29 for seg in (s.strip() for s in (raw or "").split(",")):30 key = seg.lower()31 if not seg or key in seen:32 continue33 seen.add(key)34 parts.append(seg)35 return ", ".join(parts)3637BASE = "https://www.cogir.net"38LIST_URL = f"{BASE}/gestion-immeubles-residentiels.html"3940# Slugs d'URL correspondant à la région de Québec/Lévis41_QC_SLUGS = {42 "quebec", "sainte-foy", "ste-foy", "levis", "saint-romuald", "charny",43 "loretteville", "neufchatel", "beauport", "charlesbourg", "cap-rouge",44 "val-belair", "l-ancienne-lorette", "saint-augustin", "sillery",45 "saint-nicolas", "wendake", "vanier", "lebourgneuf",46}47_SLUG_TO_SECTOR = {48 "quebec": "", "sainte-foy": "Sainte-Foy", "ste-foy": "Sainte-Foy",49 "levis": "Lévis", "saint-romuald": "Saint-Romuald", "charny": "Charny",50 "loretteville": "Loretteville", "neufchatel": "Neufchâtel",51 "beauport": "Beauport", "charlesbourg": "Charlesbourg",52 "cap-rouge": "Cap-Rouge", "val-belair": "Val-Bélair",53 "l-ancienne-lorette": "L'Ancienne-Lorette",54 "saint-augustin": "Saint-Augustin", "sillery": "Sillery",55 "saint-nicolas": "Saint-Nicolas", "wendake": "Wendake",56 "vanier": "Vanier", "lebourgneuf": "Lebourgneuf",57}5859# Slugs d'URL du Grand Montréal et de l'Outaouais -> (secteur, ville).60# Les autres slugs (Toronto, London, Ottawa, Halifax, Sherbrooke...) restent61# exclus (hors Québec ou résidences aînés).62_GM_SLUGS = {63 # Île de Montréal64 "montreal": ("", "Montréal"),65 "vieux-montreal": ("Vieux-Montréal", "Montréal"),66 "rosemont": ("Rosemont", "Montréal"),67 "lasalle": ("LaSalle", "Montréal"),68 "pierrefonds": ("Pierrefonds", "Montréal"),69 "ville-st-laurent": ("Saint-Laurent", "Montréal"),70 "montreal-est": ("", "Montréal-Est"),71 "westmount": ("", "Westmount"),72 "pointe-claire": ("", "Pointe-Claire"),73 "dorval": ("", "Dorval"),74 # Laval75 "laval": ("", "Laval"),76 # Longueuil / Rive-Sud77 "longueuil": ("", "Longueuil"),78 "saint-hubert": ("Saint-Hubert", "Longueuil"),79 "brossard": ("", "Brossard"),80 "boucherville": ("", "Boucherville"),81 "saint-lambert": ("", "Saint-Lambert"),82 "saint-bruno": ("", "Saint-Bruno-de-Montarville"),83 "sainte-julie": ("", "Sainte-Julie"),84 "beloeil": ("", "Belœil"),85 "varennes": ("", "Varennes"),86 "saint-constant": ("", "Saint-Constant"),87 "delson": ("", "Delson"),88 "candiac": ("", "Candiac"),89 "chateauguay": ("", "Châteauguay"),90 # Rive-Nord proche91 "repentigny": ("", "Repentigny"),92 "terrebonne": ("", "Terrebonne"),93 "mascouche": ("", "Mascouche"),94 # Outaouais (expansion provinciale 2026-08)95 "gatineau": ("", "Gatineau"),96 "hull": ("Hull", "Gatineau"),97 "aylmer": ("Aylmer", "Gatineau"),98}99# Slugs génériques : remplacés par un slug plus précis quand disponible100_GENERIC_SLUGS = {"quebec", "montreal"}101_BUILDING_RE = re.compile(102 r'href="(immeuble-residentiel-([a-z0-9\-]+)/(\d+)-[^"]+\.html)"')103_EXCLUDE_RE = re.compile(104 r"résidence[s]? pour (aîné|retrait)|résidence[s]? étudiante|"105 r"stationnement|commercial|rangement|entreposage", re.I)106107108class CogirConnector(BaseConnector):109 source_id = "cogir"110 request_delay = 0.6111 max_buildings = 150 # garde-fou (région de Québec + Grand Montréal)112 max_images = 25113114 def fetch(self) -> list[Listing]:115 html = self.get(LIST_URL).text116117 # 1) Immeubles des régions couvertes (dédupliqués par id numérique;118 # on privilégie le slug le plus précis, ex. sainte-foy > quebec)119 buildings: dict[str, dict] = {}120 for m in _BUILDING_RE.finditer(html):121 path, slug, bid = m.group(1), m.group(2), m.group(3)122 if slug not in _QC_SLUGS and slug not in _GM_SLUGS:123 continue124 cur = buildings.get(bid)125 if cur is None or (cur["slug"] in _GENERIC_SLUGS126 and slug not in _GENERIC_SLUGS):127 buildings[bid] = {"path": path, "slug": slug, "id": bid}128129 listings: list[Listing] = []130 for i, b in enumerate(buildings.values()):131 if i >= self.max_buildings:132 break133 try:134 page = self.get(f"{BASE}/{b['path']}").text135 except Exception:136 continue137 soup = BeautifulSoup(page, "html.parser")138139 h1 = soup.select_one("h1")140 name = h1.get_text(strip=True) if h1 else f"Immeuble {b['id']}"141 addr_el = soup.select_one("p.adresse")142 address = ""143 if addr_el:144 address = addr_el.get_text(" ", strip=True)145 address = re.sub(r"Coordonnées complètes.*|Visite virtuelle.*",146 "", address).strip(" »")147 address = _dedup_address(address)148149 # description (+ éventuelle section « Promotion » en tête)150 desc = ""151 d_h2 = soup.find("h2", string=re.compile("Description", re.I))152 if d_h2 and d_h2.find_parent():153 desc = d_h2.find_parent().get_text(" ", strip=True)154 desc = re.sub(r"^Description\s*", "", desc)[:600]155 p_h2 = soup.find("h2", string=re.compile(r"^\s*Promotion", re.I))156 if p_h2 and p_h2.find_parent():157 promo = p_h2.find_parent().get_text(" ", strip=True)158 promo = re.sub(r"^Promotion\s*", "", promo).strip()[:200]159 if promo:160 desc = f"Promotion : {promo}. {desc}".strip()[:700]161162 # exclusion aînés / étudiantes / commercial163 if _EXCLUDE_RE.search(name) or _EXCLUDE_RE.search(desc[:200]):164 continue165166 # commodités167 amenities: list[str] = []168 s_h2 = soup.find("h2", string=re.compile(169 "Services dans l'immeuble", re.I))170 if s_h2:171 ul = s_h2.find_next("ul")172 if ul:173 amenities = [li.get_text(strip=True)174 for li in ul.select("li")][:20]175176 # photos (relatives DATA/PHOTO/... -> absolues)177 images: list[str] = []178 for im in soup.select("img[src]"):179 src = im["src"]180 if "DATA/PHOTO" not in src:181 continue182 absu = src if src.startswith("http") else f"{BASE}/{src.lstrip('/')}"183 if absu not in images:184 images.append(absu)185 images = images[: self.max_images]186187 # contact de l'immeuble (liens tel:/mailto:, hors pied de page188 # corporatif info@cogir.net / 1-866-671-6381)189 contact: dict = {}190 for a in soup.select('a[href^="tel:"]'):191 digits = re.sub(r"\D", "", a.get("href", ""))[-10:]192 if len(digits) == 10 and not digits.startswith("866"):193 contact["phone"] = (f"{digits[:3]}-{digits[3:6]}-"194 f"{digits[6:]}")195 break196 for a in soup.select('a[href^="mailto:"]'):197 email = unquote(a["href"].removeprefix("mailto:")).strip()198 if email and email.lower() != "info@cogir.net":199 contact["email"] = email200 break201 details_extra = {"contact": contact} if contact else {}202203 if b["slug"] in _GM_SLUGS:204 sector, city = _GM_SLUGS[b["slug"]]205 else:206 sector = _SLUG_TO_SECTOR.get(b["slug"], "")207 if not sector:208 # essaie de déduire le secteur du nom (« Loretteville »)209 for s in _SLUG_TO_SECTOR.values():210 if s and s.lower() in name.lower():211 sector = s212 break213 city = infer_city(sector or b["slug"], default="Québec")214 url = f"{BASE}/{b['path']}"215216 # 2) Une annonce par modèle du tableau « Modèles disponibles »217 # (#tableModele : colType / colModele / colPrix). Les modèles218 # marqués « Pas disponible » ou « Liste d'attente » sont exclus.219 rows = []220 table = soup.select_one("table#tableModele") or soup.find("table")221 for tr in table.select("tbody tr") if table else []:222 typ_el = tr.select_one("td.colType")223 mod_el = tr.select_one("td.colModele")224 prix_el = tr.select_one("td.colPrix")225 tds = tr.select("td")226 typ = (typ_el.get_text(" ", strip=True) if typ_el227 else (tds[0].get_text(" ", strip=True) if tds else ""))228 modele = mod_el.get_text(" ", strip=True) if mod_el else ""229 # certains immeubles mettent « À partir de » dans colModele230 if re.fullmatch(r"à partir de\s*", modele, re.I):231 modele = ""232 if prix_el is not None:233 price_txt = prix_el.get_text(" ", strip=True)234 else:235 price_txt = next((td.get_text(" ", strip=True)236 for td in tds[1:]237 if "$" in td.get_text()), "")238 if not typ or not re.match(239 r"^\d\s*1/2|^\d\s*½|^Studio|^Loft|^Penthouse",240 typ, re.I):241 continue242 if re.search(r"pas disponible|liste d'attente", price_txt,243 re.I):244 continue # modèle affiché mais non offert245 price_txt = re.sub(r"à partir de", "", price_txt, flags=re.I)246 rows.append((typ, modele, price_txt.strip()))247248 if rows:249 seen_ext: set[str] = set()250 for typ, modele, price_txt in rows:251 ut = normalize_unit_type(typ)252 ext = f"{b['id']}-{re.sub(r'[^a-z0-9]+', '', ut.lower()) or 'u'}"253 if ext in seen_ext:254 # 2e modèle du même type (ex. « 3 1/2 + den ») :255 # suffixe du nom de modèle pour un uid unique256 suffix = re.sub(r"[^a-z0-9]+", "-",257 modele.lower()).strip("-")258 ext = f"{ext}-{suffix or len(seen_ext)}"259 if ext in seen_ext:260 continue261 seen_ext.add(ext)262 label = (f"{ut} ({modele})"263 if modele and modele.lower() != typ.lower()264 else ut)265 listings.append(Listing(266 source=self.source_id,267 external_id=ext,268 url=url,269 title=f"{name} — {label}",270 address=address,271 sector=sector,272 city=city,273 unit_type=ut,274 price=parse_price(price_txt),275 price_label=(f"À partir de {price_txt}"276 if price_txt else ""),277 availability="Disponible" if price_txt else "",278 description=desc,279 amenities=amenities,280 details=dict(details_extra),281 images=images,282 ))283 else:284 listings.append(Listing(285 source=self.source_id,286 external_id=b["id"],287 url=url,288 title=name,289 address=address,290 sector=sector,291 city=city,292 unit_type="",293 price=None,294 price_label="",295 availability="",296 description=desc,297 amenities=amenities,298 details=dict(details_extra),299 images=images,300 ))301302 return listings303