SPB Git forge

spb/rent-ka

Public
8commits 1branches 0releases
7.4 MBsize
maindefault branch
19 days agolast push
Python 68.8% TypeScript 18.6% CSS 8.7% JavaScript 3.3% HTML 0.6%
12.1 KB · 286 lines python
Raw Blame History
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