SPB Git

spb/lou-ka Public

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

HTML 99.7%
6.7 KB · 167 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/katasa.py : connecteur Groupe Katasa (katasa.ca — Gatineau)5#   Développeur-gestionnaire basé à Gatineau. WordPress « portfolio » : une6#   page brochure par immeuble, classée par catégorie. Seules les catégories7#   RÉSIDENTIELLES LOCATIVES sont crawleés (/portfolio_cat/apartments/ et8#   /portfolio_cat/apartment50/ — 50+ actifs sans soins) ; les résidences de9#   retraite (RPA), le commercial, les entrepôts et le parc de maisons10#   mobiles (location de terrain, pas de logement) sont exclus.11#   Donnée exploitable : le tableau « Rates » de la page (typologies en12#   en-tête, « From $ 2,075 » en dessous) -> une annonce par typologie AVEC13#   prix publié. Les brochures sans prix ni disponibilité (ex. Le Chambord)14#   ne produisent aucune annonce — rien n'est inventé.15# -----------------------------------------------------------------------------16from __future__ import annotations1718import re19from urllib.parse import urljoin2021from bs4 import BeautifulSoup2223from ..schema import Listing, normalize_unit_type, parse_price, strip_accents24from .base import BaseConnector2526BASE = "https://katasa.ca"27# catégories résidentielles locatives seulement (retirement/commercial exclus)28CATEGORIES = ["/portfolio_cat/apartments/", "/portfolio_cat/apartment50/"]2930# immeubles hors périmètre même dans ces catégories31_EXCLUDE_SLUGS = {"riviera-mobile-home-park", "212nfederalhighway"}3233# secteurs de Gatineau repérables dans le contenu de la page34_SECTOR_WORDS = ["Aylmer", "Hull", "Buckingham", "Plateau", "Masson-Angers"]3536_IMG_RE = re.compile(37    r'https?://katasa\.ca/wp-content/uploads/[^"\'\s\)]+\.(?:jpg|jpeg|webp|png)',38    re.I)39_SKIP_IMG = re.compile(r"logo|icon|favicon|cropped|-\d{2,4}x\d{2,4}\.", re.I)404142def _slugify(s: str) -> str:43    s = strip_accents(s.lower())44    return re.sub(r"[^a-z0-9]+", "-", s).strip("-")454647class KatasaConnector(BaseConnector):48    source_id = "katasa"49    request_delay = 1.050    max_pages = 1551    max_images = 105253    @staticmethod54    def _unit_type(label: str) -> str:55        t = strip_accents(label.lower())56        if "studio" in t:57            return "Studio"58        m = re.match(r"^(\d+)\s*bed", t)59        if m:60            return normalize_unit_type(f"{m.group(1)} chambres")61        return normalize_unit_type(label)6263    # -- page immeuble -----------------------------------------------------------64    def _parse_building(self, url: str, listings: list[Listing]) -> None:65        html = self.get(url).text66        soup = BeautifulSoup(html, "html.parser")67        slug = url.rstrip("/").rsplit("/", 1)[-1]6869        h1 = soup.find("h1")70        name = (h1.get_text(" ", strip=True) if h1 else slug).strip()7172        # tableau « Rates » : en-têtes = typologies, cellules = « From $ 2,075 »73        pairs: list[tuple[str, str]] = []74        for table in soup.find_all("table"):75            cells = [re.sub(r"\s+", " ", c.get_text(" ", strip=True))76                     for c in table.find_all(["th", "td"])]77            prices = [c for c in cells if re.search(r"\$\s*[\d,]{3,}", c)]78            labels = [c for c in cells if c and c not in prices]79            if prices and len(labels) == len(prices):80                pairs = list(zip(labels, prices))81                break82        if not pairs:83            return   # brochure sans prix publié : aucune annonce8485        # contenu éditorial (en dehors des menus/pied de page)86        body = BeautifulSoup(html, "html.parser")87        for tag in body.select("header, footer, nav"):88            tag.decompose()89        text = body.get_text("\n", strip=True)9091        # secteur de Gatineau si l'agence le nomme dans le contenu92        sector = ""93        for w in _SECTOR_WORDS:94            if re.search(rf"(?i)\b{re.escape(name)}\s+{w}\b|\b{w}\b.{{0,20}}{re.escape(name)}", text):95                sector = w96                break97        if not sector:98            for w in _SECTOR_WORDS:99                if re.search(rf"(?i)\b{w}\b", text.split("Nearby")[0]):100                    sector = w101                    break102103        og = soup.find("meta", attrs={"property": "og:description"})104        blurb = (og.get("content", "").strip() if og else "")105        if not blurb:   # premier paragraphe éditorial substantiel de la page106            for p in soup.select("div.uncont p"):107                t = re.sub(r"\s+", " ", p.get_text(" ", strip=True))108                if len(t) > 80:109                    blurb = t110                    break111112        # « Included amenities: • Cable TV … » (puces rédigées par l'agence)113        amenities = [re.sub(r"\s+", " ", a.strip("• ").strip())114                     for a in re.findall(r"\s*([^\n•]{3,80})", text)][:15]115116        images = [u for u in dict.fromkeys(_IMG_RE.findall(html))117                  if not _SKIP_IMG.search(u)][: self.max_images]118119        for label, price_label in pairs:120            key = _slugify(label)121            if not key:122                continue123            listings.append(Listing(124                source=self.source_id,125                external_id=f"{slug}:{key}",126                url=url,127                title=f"{name}{label}",128                sector=sector,129                city="Gatineau",130                unit_type=self._unit_type(label),131                price=parse_price(price_label),132                price_label=price_label,133                description=blurb[:800],134                amenities=amenities,135                images=images,136            ))137138    # -- fetch -----------------------------------------------------------------139    def fetch(self) -> list[Listing]:140        urls: list[str] = []141        for cat in CATEGORIES:142            try:143                html = self.get(BASE + cat).text144            except Exception:145                continue146            soup = BeautifulSoup(html, "html.parser")147            # items de la catégorie seulement (le menu de navigation liste148            # TOUS les immeubles, y compris retraite/commercial : ignoré)149            for a in soup.select(".t-entry-title a[href*='/portfolio/']"):150                u = urljoin(BASE, a["href"]).split("#")[0].split("?")[0]151                slug = u.rstrip("/").rsplit("/", 1)[-1]152                if slug in _EXCLUDE_SLUGS or u in urls:153                    continue154                urls.append(u)155156        listings: list[Listing] = []157        for url in urls[: self.max_pages]:158            try:159                self._parse_building(url, listings)160            except Exception:161                continue162163        uniq: dict[str, Listing] = {}164        for lst in listings:165            uniq.setdefault(lst.external_id, lst)166        return list(uniq.values())167