SPB Git forge

spb/lou-ka

Public

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

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
8.0 KB · 191 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/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("&nbsp;", " ")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