SPB Git

spb/lou-ka Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

HTML 99.7%
13.2 KB · 303 lines python
Raw Blame History
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