# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/treana.py : connecteur TREANA (treana.ca) — condos locatifs en # Montérégie : Saint-Jean-sur-Richelieu (374-378, rue Jacques-Cartier Sud), # Sainte-Catherine phases I et II (1155, rue Centrale) et Venise-en-Québec # (4 immeubles TREANA III à VI). WordPress + Elementor rendu serveur : # chaque page projet porte une ou plusieurs tables d'unités (colonnes # « Unité | Type | Étage | Grandeur | Superficie | Prix/mois »). Seules les # tables avec la colonne « Type » sont lues (les tables 5 colonnes en aval # sont des fiches par typologie qui répètent les mêmes unités). Le site # n'affiche pas de prix : « - » = sur demande (ingéré sans prix), « LOUÉ » # = exclu — la page Sainte-Catherine II peut donc donner 0 annonce. # À Venise, l'immeuble (TREANA III…VI) vient du titre précédant la table. # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price from .base import BaseConnector BASE = "https://treana.ca" # clé de page -> (chemin, ville, adresse civique) PAGES = { "saint-jean": ("/saint-jean-sur-richelieu/", "Saint-Jean-sur-Richelieu", "374-378, rue Jacques-Cartier Sud, Saint-Jean-sur-Richelieu"), "venise": ("/venise-en-quebec/", "Venise-en-Québec", ""), "ste-catherine-1": ("/sainte-catherine_phase_i/", "Sainte-Catherine", "1155, rue Centrale, Sainte-Catherine"), "ste-catherine-2": ("/sainte-catherine-phase-ii/", "Sainte-Catherine", "1155, rue Centrale, Sainte-Catherine"), } LOUE_RE = re.compile(r"lou[ée]", re.I) GRANDEUR_RE = re.compile(r"^\d\s*1/2$") SQFT_RE = re.compile(r"([\d\s ]{3,7})\s*pi\.?\s*ca", re.I) BLDG_RE = re.compile(r"^TREANA\s+([IVX]+)\b", re.I) IMG_RE = re.compile( r"https://(?:www\.)?treana\.ca/wp-content/uploads/[^\"\s\\']+" r"\.(?:jpe?g|png|webp)", re.I) SKIP_IMG_RE = re.compile( r"logo|icon|favicon|cropped-|plan|-\d{2,4}x\d{2,4}\.", re.I) class TreanaConnector(BaseConnector): source_id = "treana" request_delay = 0.6 def fetch(self) -> list[Listing]: listings: list[Listing] = [] for page_key, (path, city, address) in PAGES.items(): try: html = self.get(f"{BASE}{path}").text except Exception: continue self._parse_page(html, page_key, f"{BASE}{path}", city, address, listings) return listings def _parse_page(self, html: str, page_key: str, url: str, city: str, address: str, listings: list[Listing]) -> None: soup = BeautifulSoup(html, "html.parser") images = [u for u in dict.fromkeys(IMG_RE.findall(html)) if not SKIP_IMG_RE.search(u)][:8] pdf_el = soup.find("a", href=re.compile(r"\.pdf$", re.I)) fiche_pdf = pdf_el["href"] if pdf_el else "" seen: set[str] = set() for table in soup.find_all("table"): head = table.find("tr") if head is None: continue cols = [c.get_text(" ", strip=True) for c in head.find_all(["td", "th"])] if "Type" not in cols or "Unité" not in cols: continue # fiche par typologie : ignorée # immeuble : titre « TREANA III » précédant la table (Venise) building = "" h = table.find_previous(["h1", "h2", "h3", "h4"]) for _ in range(4): if h is None: break m = BLDG_RE.match(h.get_text(" ", strip=True)) if m: building = f"TREANA {m.group(1).upper()}" break h = h.find_previous(["h1", "h2", "h3", "h4"]) idx = {name: i for i, name in enumerate(cols)} for tr in table.find_all("tr")[1:]: try: tds = [td.get_text(" ", strip=True) for td in tr.find_all("td")] if len(tds) < len(cols): continue unit_no = tds[idx["Unité"]] if not re.fullmatch(r"\d{2,4}", unit_no): continue price_txt = tds[idx["Prix/mois"]] if LOUE_RE.search(price_txt): continue # unité louée : exclue grandeur = tds[idx["Grandeur"]] if not GRANDEUR_RE.match(grandeur): continue unit_type = normalize_unit_type(grandeur) bslug = re.sub(r"[^a-z0-9]+", "-", building.lower())\ .strip("-") ext_id = "-".join(x for x in (page_key, bslug, unit_no) if x) if ext_id in seen: continue seen.add(ext_id) area = None m = SQFT_RE.search(tds[idx["Superficie"]]) if m: try: area = float(re.sub(r"[^\d]", "", m.group(1))) except ValueError: area = None details: dict = {} floor = tds[idx["Étage"]] if "Étage" in idx else "" if floor: details["floor_label"] = floor type_label = tds[idx["Type"]] if type_label and type_label not in {"-", "–"}: details["type_plan"] = type_label if fiche_pdf: details["fiche_pdf"] = fiche_pdf title = f"{unit_type} — Unité {unit_no}, " + \ (building or "TREANA") + f" ({city})" listings.append(Listing( source=self.source_id, external_id=ext_id, url=url, title=title, address=address, city=city, unit_type=unit_type, price=parse_price(price_txt), price_label="" if price_txt in {"-", "–"} else price_txt, area_sqft=area, amenities=["Tout inclus"] if "TOUS INCLUS" in html else [], details=details, images=images, )) except Exception: continue