# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/kass.py : connecteur KASS Property Management (kassproperties.com) # Gestionnaire Ottawa-Gatineau. WordPress + thème immobilier Houzez (même # famille que gimcote.py) : l'archive /city/gatineau/ liste les cartes du # parc québécois — prix, ville, lits/sdb/pi², galerie (data-images), # étiquettes de statut. Les cartes « Rented » sont sautées, de même que le # widget « propriétés similaires » d'Ottawa (cartes SANS étiquette de # statut) : les villes ontariennes sont exclues du périmètre Lou-Ka. # La fiche détail (cache BD) ajoute la description, le bloc « Details » # structuré (Move-in Date, Pet Friendly, Smoking, superficie, type), les # commodités et l'adresse civique complète. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import html as htmllib import json import re from bs4 import BeautifulSoup from ..schema import (Listing, normalize_unit_type, parse_area_sqft, parse_price, strip_accents) from .base import BaseConnector BASE = "https://kassproperties.com" LIST_URL = f"{BASE}/city/gatineau/" _SIZE_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I) # secteurs de Gatineau repérables dans le titre ou l'adresse _SECTORS = ["Hull", "Aylmer", "Buckingham", "Masson-Angers", "Plateau"] def _pets_value(raw: str) -> str | None: k = strip_accents((raw or "").strip().lower()) if not k: return None if k.startswith(("no", "non")): return "non" if k.startswith(("yes", "oui")): return "oui" return "conditions" class KassConnector(BaseConnector): source_id = "kass" request_delay = 1.0 max_pages = 5 max_details = 20 max_images = 20 def fetch(self) -> list[Listing]: listings: dict[str, Listing] = {} for page in range(1, self.max_pages + 1): url = LIST_URL if page == 1 else f"{LIST_URL}page/{page}/" try: html = self.get(url).text except Exception: break soup = BeautifulSoup(html, "html.parser") before = len(listings) for card in soup.select("div.item-listing-wrap"): try: self._parse_card(card, listings) except Exception: continue if len(listings) == before: # plus de résultats d'archive break # fiches détail Houzez (cache BD) self._fetched = 0 for lst in listings.values(): key = hashlib.sha1( f"{lst.title}|{lst.price_label}|{lst.url}" .encode("utf-8")).hexdigest()[:20] try: payload = self.detail(lst.external_id, key, lambda u=lst.url: self._fetch_detail(u)) except Exception: continue self._apply_detail(lst, payload) return list(listings.values()) # -- carte Houzez ------------------------------------------------------------ def _parse_card(self, card, listings: dict[str, Listing]) -> None: # cartes d'archive seulement : le widget « similaires » (Ottawa) n'a # pas d'étiquette de statut status = [a.get_text(strip=True) for a in card.select("a[href*='/status/']")] if not any(re.search(r"(?i)for rent", s) for s in status): return labels = [a.get_text(strip=True) for a in card.select("a[href*='/label/']")] if any(re.search(r"(?i)rented|lou[ée]", s) for s in labels + status): return # déjà loué addr_el = card.select_one("address.item-address") card_city = addr_el.get_text(" ", strip=True) if addr_el else "" if not re.search(r"(?i)gatineau|hull|aylmer|buckingham", card_city): return # villes ontariennes exclues link = card.select_one("h2.item-title a[href]") if not link: return url = link["href"] title = link.get_text(" ", strip=True) ext = str(card.get("data-hz-id") or "") if not ext: m = re.search(r"/property/([^/]+)/?", url) ext = m.group(1) if m else "" if not ext or ext in listings: return if re.search(r"(?i)parking|storage|commercial|office", title): return # non résidentiel price_el = card.select_one("li.item-price") price_label = price_el.get_text(" ", strip=True) if price_el else "" # lits/sdb/pi² de la carte beds = sqft = "" amen_bits: list[str] = [] for li in card.select("ul.item-amenities li"): t = re.sub(r"\s+", " ", li.get_text(" ", strip=True)) if re.match(r"(?i)^bed", t): beds = t elif "sqft" in t.lower(): sqft = t if t: amen_bits.append(t) unit_type = "" m = re.search(r"(\d+)", beds) if m: unit_type = normalize_unit_type(f"{m.group(1)} chambres") # secteur si l'agence le nomme dans le titre sector = "" for s in _SECTORS: if re.search(rf"(?i)\b{s}\b", title): sector = s break # galerie complète (attribut data-images, JSON Houzez) images: list[str] = [] raw = card.get("data-images") or "" if raw: try: items = json.loads(htmllib.unescape(raw)) urls = [it.get("image", "") if isinstance(it, dict) else str(it) for it in items] except Exception: urls = re.findall(r"https?:[^\"',\\]+", htmllib.unescape(raw)) for u in urls: u = u.replace("\\/", "/").strip() if u.startswith("http"): u = _SIZE_SUFFIX.sub("", u) if u not in images: images.append(u) if not images: thumb = card.select_one("img.wp-post-image[src]") if thumb: images = [_SIZE_SUFFIX.sub("", thumb["src"])] listings[ext] = Listing( source=self.source_id, external_id=ext, url=url, title=title, sector=sector, city="Gatineau", unit_type=unit_type, price=parse_price(price_label.replace(",", "")), price_label=price_label, description=" — ".join(amen_bits), images=images[: self.max_images], ) # -- fiche détail Houzez ------------------------------------------------------- def _fetch_detail(self, url: str) -> dict: if self._fetched >= self.max_details: raise RuntimeError("budget de fiches détail atteint") self._fetched += 1 soup = BeautifulSoup(self.get(url).text, "html.parser") out: dict = {} desc_el = soup.select_one("#property-description-wrap") if desc_el: txt = desc_el.get_text("\n", strip=True) txt = re.sub(r"^Description\n", "", txt) out["description"] = re.sub(r"[ \t]+", " ", txt).strip()[:1500] out["amenities"] = [a.get_text(" ", strip=True) for a in soup.select("#property-features-wrap li") if a.get_text(strip=True)][:25] # bloc « Details » : Move-in Date, Pet Friendly, Smoking, Size, Type for li in soup.select("#property-detail-wrap li"): t = re.sub(r"\s+", " ", li.get_text(" ", strip=True)) lab = strip_accents(t.lower()) val = re.sub(r"^[^ ]+( [^ ]+)? ", "", t).strip() if lab.startswith("move-in date"): out["availability"] = t.replace("Move-in Date", "").strip() elif lab.startswith("pet friendly"): out["pets_raw"] = t.replace("Pet Friendly", "").strip() elif lab.startswith("smoking"): out["smoking_raw"] = t.replace("Smoking", "").strip() elif lab.startswith("property size"): out["size_raw"] = val elif lab.startswith("property type"): out["type_raw"] = t.replace("Property Type", "").strip() for li in soup.select("#property-address-wrap li"): t = re.sub(r"\s+", " ", li.get_text(" ", strip=True)) if t.lower().startswith("address:"): out["address"] = t.split(":", 1)[1].strip() return out def _apply_detail(self, lst: Listing, d: dict) -> None: if not d: return if d.get("description"): lst.description = d["description"] if d.get("amenities"): lst.amenities = list(dict.fromkeys(lst.amenities + d["amenities"])) if d.get("availability"): lst.availability = d["availability"] # format Houzez « 1-Sep-24 » (année sur 2 chiffres) : la # normalisation commune ignorerait l'année et projetterait une # date future — on la résout ici (date passée -> « now ») m = re.match(r"^(\d{1,2})-([A-Za-z]{3})-(\d{2})$", d["availability"].strip()) if m: from datetime import date months = {"jan": 1, "feb": 2, "mar": 3, "apr": 4, "may": 5, "jun": 6, "jul": 7, "aug": 8, "sep": 9, "oct": 10, "nov": 11, "dec": 12} mo = months.get(m.group(2).lower()) if mo: dt = date(2000 + int(m.group(3)), mo, int(m.group(1))) lst.availability_date = ("now" if dt <= date.today() else dt.isoformat()) if d.get("address"): lst.address = d["address"] if not lst.sector: for s in _SECTORS: if re.search(rf"(?i)\b{s}\b", d["address"]): lst.sector = s break if lst.area_sqft is None and d.get("size_raw"): lst.area_sqft = parse_area_sqft(d["size_raw"]) pets = _pets_value(d.get("pets_raw", "")) if pets: lst.pets = pets details: dict = {} if d.get("type_raw"): details["building_type"] = d["type_raw"] smoking = strip_accents(d.get("smoking_raw", "").lower()) if smoking.startswith("no"): details["smoking"] = False elif smoking: details["smoking_raw"] = d["smoking_raw"] if details: lst.details = details