# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/katasa.py : connecteur Groupe Katasa (katasa.ca — Gatineau) # Développeur-gestionnaire basé à Gatineau. WordPress « portfolio » : une # page brochure par immeuble, classée par catégorie. Seules les catégories # RÉSIDENTIELLES LOCATIVES sont crawleés (/portfolio_cat/apartments/ et # /portfolio_cat/apartment50/ — 50+ actifs sans soins) ; les résidences de # retraite (RPA), le commercial, les entrepôts et le parc de maisons # mobiles (location de terrain, pas de logement) sont exclus. # Donnée exploitable : le tableau « Rates » de la page (typologies en # en-tête, « From $ 2,075 » en dessous) -> une annonce par typologie AVEC # prix publié. Les brochures sans prix ni disponibilité (ex. Le Chambord) # ne produisent aucune annonce — rien n'est inventé. # ----------------------------------------------------------------------------- from __future__ import annotations import re from urllib.parse import urljoin from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price, strip_accents from .base import BaseConnector BASE = "https://katasa.ca" # catégories résidentielles locatives seulement (retirement/commercial exclus) CATEGORIES = ["/portfolio_cat/apartments/", "/portfolio_cat/apartment50/"] # immeubles hors périmètre même dans ces catégories _EXCLUDE_SLUGS = {"riviera-mobile-home-park", "212nfederalhighway"} # secteurs de Gatineau repérables dans le contenu de la page _SECTOR_WORDS = ["Aylmer", "Hull", "Buckingham", "Plateau", "Masson-Angers"] _IMG_RE = re.compile( r'https?://katasa\.ca/wp-content/uploads/[^"\'\s\)]+\.(?:jpg|jpeg|webp|png)', re.I) _SKIP_IMG = re.compile(r"logo|icon|favicon|cropped|-\d{2,4}x\d{2,4}\.", re.I) def _slugify(s: str) -> str: s = strip_accents(s.lower()) return re.sub(r"[^a-z0-9]+", "-", s).strip("-") class KatasaConnector(BaseConnector): source_id = "katasa" request_delay = 1.0 max_pages = 15 max_images = 10 @staticmethod def _unit_type(label: str) -> str: t = strip_accents(label.lower()) if "studio" in t: return "Studio" m = re.match(r"^(\d+)\s*bed", t) if m: return normalize_unit_type(f"{m.group(1)} chambres") return normalize_unit_type(label) # -- page immeuble ----------------------------------------------------------- def _parse_building(self, url: str, listings: list[Listing]) -> None: html = self.get(url).text soup = BeautifulSoup(html, "html.parser") slug = url.rstrip("/").rsplit("/", 1)[-1] h1 = soup.find("h1") name = (h1.get_text(" ", strip=True) if h1 else slug).strip() # tableau « Rates » : en-têtes = typologies, cellules = « From $ 2,075 » pairs: list[tuple[str, str]] = [] for table in soup.find_all("table"): cells = [re.sub(r"\s+", " ", c.get_text(" ", strip=True)) for c in table.find_all(["th", "td"])] prices = [c for c in cells if re.search(r"\$\s*[\d,]{3,}", c)] labels = [c for c in cells if c and c not in prices] if prices and len(labels) == len(prices): pairs = list(zip(labels, prices)) break if not pairs: return # brochure sans prix publié : aucune annonce # contenu éditorial (en dehors des menus/pied de page) body = BeautifulSoup(html, "html.parser") for tag in body.select("header, footer, nav"): tag.decompose() text = body.get_text("\n", strip=True) # secteur de Gatineau si l'agence le nomme dans le contenu sector = "" for w in _SECTOR_WORDS: if re.search(rf"(?i)\b{re.escape(name)}\s+{w}\b|\b{w}\b.{{0,20}}{re.escape(name)}", text): sector = w break if not sector: for w in _SECTOR_WORDS: if re.search(rf"(?i)\b{w}\b", text.split("Nearby")[0]): sector = w break og = soup.find("meta", attrs={"property": "og:description"}) blurb = (og.get("content", "").strip() if og else "") if not blurb: # premier paragraphe éditorial substantiel de la page for p in soup.select("div.uncont p"): t = re.sub(r"\s+", " ", p.get_text(" ", strip=True)) if len(t) > 80: blurb = t break # « Included amenities: • Cable TV … » (puces rédigées par l'agence) amenities = [re.sub(r"\s+", " ", a.strip("• ").strip()) for a in re.findall(r"•\s*([^\n•]{3,80})", text)][:15] images = [u for u in dict.fromkeys(_IMG_RE.findall(html)) if not _SKIP_IMG.search(u)][: self.max_images] for label, price_label in pairs: key = _slugify(label) if not key: continue listings.append(Listing( source=self.source_id, external_id=f"{slug}:{key}", url=url, title=f"{name} — {label}", sector=sector, city="Gatineau", unit_type=self._unit_type(label), price=parse_price(price_label), price_label=price_label, description=blurb[:800], amenities=amenities, images=images, )) # -- fetch ----------------------------------------------------------------- def fetch(self) -> list[Listing]: urls: list[str] = [] for cat in CATEGORIES: try: html = self.get(BASE + cat).text except Exception: continue soup = BeautifulSoup(html, "html.parser") # items de la catégorie seulement (le menu de navigation liste # TOUS les immeubles, y compris retraite/commercial : ignoré) for a in soup.select(".t-entry-title a[href*='/portfolio/']"): u = urljoin(BASE, a["href"]).split("#")[0].split("?")[0] slug = u.rstrip("/").rsplit("/", 1)[-1] if slug in _EXCLUDE_SLUGS or u in urls: continue urls.append(u) listings: list[Listing] = [] for url in urls[: self.max_pages]: try: self._parse_building(url, listings) except Exception: continue uniq: dict[str, Listing] = {} for lst in listings: uniq.setdefault(lst.external_id, lst) return list(uniq.values())