spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
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