Python 68.8%
TypeScript 18.6%
CSS 8.7%
JavaScript 3.3%
HTML 0.6%
1# -----------------------------------------------------------------------------2# Rent-Ka — Rental listings aggregator (Canada, outside Québec)3# Author: Simon-Pierre Boucher — contact@spboucher.ai4# connectors/cogir.py : Cogir Real Estate (cogir.net)5# Server-rendered «gestion-immeubles-residentiels» list page. Rent-Ka keeps6# the buildings whose URL slug is OUTSIDE Québec (verified live 2026-08-27:7# Toronto 22 + boroughs, London 7, Guelph 3, Ottawa 2, Waterloo, Timmins,8# Mississauga in ON; Halifax in NS). Each building page has a9# «Modèles disponibles» table (type + starting price) and a photo gallery10# (DATA/PHOTO). One listing per unit model, else per building.11# Buildings that redirect to an external microsite (608church.com, etc.)12# are skipped — only pages served by cogir.net (same French template) are13# parsed. Retirement/student residences are excluded (FR + EN patterns).14# -----------------------------------------------------------------------------15from __future__ import annotations1617import os18import re19from urllib.parse import unquote, urlparse2021from bs4 import BeautifulSoup2223from ..schema import Listing, normalize_unit_type, parse_price24from .base import BaseConnector252627def _dedup_address(raw: str) -> str:28 """Retire les segments dupliqués des adresses Cogir29 (« 1769 Rue Careau, Québec, QC, Québec, Québec G1M 0E6 »)."""30 parts, seen = [], set()31 for seg in (s.strip() for s in (raw or "").split(",")):32 key = seg.lower()33 if not seg or key in seen:34 continue35 seen.add(key)36 parts.append(seg)37 return ", ".join(parts)3839BASE = "https://www.cogir.net"40LIST_URL = f"{BASE}/gestion-immeubles-residentiels.html"4142# URL slugs outside Québec -> (sector, city, province). Slugs absent from43# this map (Montréal, Québec, Laval… and any new QC city) are ignored.44_ROC_SLUGS = {45 # City of Toronto and former boroughs46 "toronto": ("", "Toronto", "ON"),47 "scarborough": ("Scarborough", "Toronto", "ON"),48 "north-york": ("North York", "Toronto", "ON"),49 "east-york": ("East York", "Toronto", "ON"),50 "etobicoke": ("Etobicoke", "Toronto", "ON"),51 # Rest of Ontario52 "ottawa": ("", "Ottawa", "ON"),53 "london": ("", "London", "ON"),54 "guelph": ("", "Guelph", "ON"),55 "waterloo": ("", "Waterloo", "ON"),56 "kitchener": ("", "Kitchener", "ON"),57 "mississauga": ("", "Mississauga", "ON"),58 "hamilton": ("", "Hamilton", "ON"),59 "timmins": ("", "Timmins", "ON"),60 # Atlantic61 "halifax": ("", "Halifax", "NS"),62 "dartmouth": ("Dartmouth", "Halifax", "NS"),63}64# Generic slugs: replaced by a more precise slug when available («toronto»:65# some buildings also appear under their borough slug)66_GENERIC_SLUGS = {"toronto"}67_BUILDING_RE = re.compile(68 r'href="(immeuble-residentiel-([a-z0-9\-]+)/(\d+)-[^"]+\.html)"')69_EXCLUDE_RE = re.compile(70 r"résidence[s]? pour (aîné|retrait)|résidence[s]? étudiante|"71 r"stationnement|commercial|rangement|entreposage", re.I)72# English variant (pages of ON/NS buildings)73_EXCLUDE_EN_RE = re.compile(74 r"retirement (?:residence|home|living|community)|"75 r"senior[s']*\s+(?:residence|living|housing)|"76 r"student (?:residence|housing)|parking|storage", re.I)777879class CogirConnector(BaseConnector):80 source_id = "cogir"81 request_delay = 0.682 max_buildings = 80 # safety cap (~45 non-QC buildings today)83 max_images = 258485 def fetch(self) -> list[Listing]:86 html = self.get(LIST_URL).text8788 # 1) Buildings outside Québec (deduplicated by numeric id; the most89 # precise slug wins, e.g. scarborough > toronto)90 buildings: dict[str, dict] = {}91 for m in _BUILDING_RE.finditer(html):92 path, slug, bid = m.group(1), m.group(2), m.group(3)93 if slug not in _ROC_SLUGS:94 continue95 cur = buildings.get(bid)96 if cur is None or (cur["slug"] in _GENERIC_SLUGS97 and slug not in _GENERIC_SLUGS):98 buildings[bid] = {"path": path, "slug": slug, "id": bid}99100 listings: list[Listing] = []101 for i, b in enumerate(buildings.values()):102 if i >= self.max_buildings:103 break104 try:105 prov = _ROC_SLUGS[b["slug"]][2]106 self._ingest_building(b, prov, listings)107 except Exception:108 continue109 return listings110111 def _ingest_building(self, b: dict, province: str,112 listings: list[Listing]) -> None:113 """Parse une fiche immeuble et ajoute ses annonces à `listings`."""114 try:115 # Do not follow redirects — pages redirected to an external116 # microsite (608church.com, 107redpath.com…) use a different117 # template and are often 403: skip them.118 resp = self.get(f"{BASE}/{b['path']}", allow_redirects=False)119 if (300 <= resp.status_code < 400120 or "cogir.net" not in (urlparse(resp.url).hostname or "")):121 return122 page = resp.text123 except Exception:124 return125 soup = BeautifulSoup(page, "html.parser")126127 h1 = soup.select_one("h1")128 name = h1.get_text(strip=True) if h1 else f"Immeuble {b['id']}"129 addr_el = soup.select_one("p.adresse")130 address = ""131 if addr_el:132 address = addr_el.get_text(" ", strip=True)133 address = re.sub(r"Coordonnées complètes.*|Visite virtuelle.*",134 "", address).strip(" »")135 address = _dedup_address(address)136 # suffix the province code unless it is already in the address137 if address and not re.search(rf",?\s+(?:{province})\b", address):138 address = f"{address}, {province}"139140 # description (+ éventuelle section « Promotion » en tête)141 desc = ""142 d_h2 = soup.find("h2", string=re.compile("Description", re.I))143 if d_h2 and d_h2.find_parent():144 desc = d_h2.find_parent().get_text(" ", strip=True)145 desc = re.sub(r"^Description\s*", "", desc)[:600]146 p_h2 = soup.find("h2", string=re.compile(r"^\s*Promotion", re.I))147 if p_h2 and p_h2.find_parent():148 promo = p_h2.find_parent().get_text(" ", strip=True)149 promo = re.sub(r"^Promotion\s*", "", promo).strip()[:200]150 if promo:151 desc = f"Promotion : {promo}. {desc}".strip()[:700]152153 # exclude retirement/student residences and commercial (FR + EN)154 if _EXCLUDE_RE.search(name) or _EXCLUDE_RE.search(desc[:200]) \155 or _EXCLUDE_EN_RE.search(name) \156 or _EXCLUDE_EN_RE.search(desc[:200]):157 return158159 # commodités160 amenities: list[str] = []161 s_h2 = soup.find("h2", string=re.compile(162 "Services dans l'immeuble", re.I))163 if s_h2:164 ul = s_h2.find_next("ul")165 if ul:166 amenities = [li.get_text(strip=True)167 for li in ul.select("li")][:20]168169 # photos (relatives DATA/PHOTO/... -> absolues)170 images: list[str] = []171 for im in soup.select("img[src]"):172 src = im["src"]173 if "DATA/PHOTO" not in src:174 continue175 absu = src if src.startswith("http") else f"{BASE}/{src.lstrip('/')}"176 if absu not in images:177 images.append(absu)178 images = images[: self.max_images]179180 # contact de l'immeuble (liens tel:/mailto:, hors pied de page181 # corporatif info@cogir.net / 1-866-671-6381)182 contact: dict = {}183 for a in soup.select('a[href^="tel:"]'):184 digits = re.sub(r"\D", "", a.get("href", ""))[-10:]185 if len(digits) == 10 and not digits.startswith("866"):186 contact["phone"] = (f"{digits[:3]}-{digits[3:6]}-"187 f"{digits[6:]}")188 break189 for a in soup.select('a[href^="mailto:"]'):190 email = unquote(a["href"].removeprefix("mailto:")).strip()191 if email and email.lower() != "info@cogir.net":192 contact["email"] = email193 break194 details_extra = {"contact": contact} if contact else {}195196 sector, city, _prov = _ROC_SLUGS[b["slug"]]197 url = f"{BASE}/{b['path']}"198199 # 2) Une annonce par modèle du tableau « Modèles disponibles »200 # (#tableModele : colType / colModele / colPrix). Les modèles201 # marqués « Pas disponible » ou « Liste d'attente » sont exclus.202 rows = []203 table = soup.select_one("table#tableModele") or soup.find("table")204 for tr in table.select("tbody tr") if table else []:205 typ_el = tr.select_one("td.colType")206 mod_el = tr.select_one("td.colModele")207 prix_el = tr.select_one("td.colPrix")208 tds = tr.select("td")209 typ = (typ_el.get_text(" ", strip=True) if typ_el210 else (tds[0].get_text(" ", strip=True) if tds else ""))211 modele = mod_el.get_text(" ", strip=True) if mod_el else ""212 # certains immeubles mettent « À partir de » dans colModele213 if re.fullmatch(r"à partir de\s*", modele, re.I):214 modele = ""215 if prix_el is not None:216 price_txt = prix_el.get_text(" ", strip=True)217 else:218 price_txt = next((td.get_text(" ", strip=True)219 for td in tds[1:]220 if "$" in td.get_text()), "")221 if not typ or not re.match(222 r"^\d\s*1/2|^\d\s*½|^Studio|^Loft|^Penthouse",223 typ, re.I):224 continue225 if re.search(r"pas disponible|liste d'attente", price_txt,226 re.I):227 continue # modèle affiché mais non offert228 price_txt = re.sub(r"à partir de", "", price_txt, flags=re.I)229 rows.append((typ, modele, price_txt.strip()))230231 if rows:232 seen_ext: set[str] = set()233 for typ, modele, price_txt in rows:234 ut = normalize_unit_type(typ)235 ext = f"{b['id']}-{re.sub(r'[^a-z0-9]+', '', ut.lower()) or 'u'}"236 if ext in seen_ext:237 # 2e modèle du même type (ex. « 3 1/2 + den ») :238 # suffixe du nom de modèle pour un uid unique239 suffix = re.sub(r"[^a-z0-9]+", "-",240 modele.lower()).strip("-")241 ext = f"{ext}-{suffix or len(seen_ext)}"242 if ext in seen_ext:243 continue244 seen_ext.add(ext)245 label = (f"{ut} ({modele})"246 if modele and modele.lower() != typ.lower()247 else ut)248 listings.append(Listing(249 source=self.source_id,250 external_id=ext,251 url=url,252 title=f"{name} — {label}",253 address=address,254 sector=sector,255 city=city,256 province=province,257 unit_type=ut,258 price=parse_price(price_txt),259 price_label=(f"À partir de {price_txt}"260 if price_txt else ""),261 availability="Disponible" if price_txt else "",262 description=desc,263 amenities=amenities,264 details=dict(details_extra),265 images=images,266 ))267 else:268 listings.append(Listing(269 source=self.source_id,270 external_id=b["id"],271 url=url,272 title=name,273 address=address,274 sector=sector,275 city=city,276 province=province,277 unit_type="",278 price=None,279 price_label="",280 availability="",281 description=desc,282 amenities=amenities,283 details=dict(details_extra),284 images=images,285 ))286