Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/treana.py : connecteur TREANA (treana.ca) — condos locatifs en5# Montérégie : Saint-Jean-sur-Richelieu (374-378, rue Jacques-Cartier Sud),6# Sainte-Catherine phases I et II (1155, rue Centrale) et Venise-en-Québec7# (4 immeubles TREANA III à VI). WordPress + Elementor rendu serveur :8# chaque page projet porte une ou plusieurs tables d'unités (colonnes9# « Unité | Type | Étage | Grandeur | Superficie | Prix/mois »). Seules les10# tables avec la colonne « Type » sont lues (les tables 5 colonnes en aval11# sont des fiches par typologie qui répètent les mêmes unités). Le site12# n'affiche pas de prix : « - » = sur demande (ingéré sans prix), « LOUÉ »13# = exclu — la page Sainte-Catherine II peut donc donner 0 annonce.14# À Venise, l'immeuble (TREANA III…VI) vient du titre précédant la table.15# -----------------------------------------------------------------------------16from __future__ import annotations1718import re1920from bs4 import BeautifulSoup2122from ..schema import Listing, normalize_unit_type, parse_price23from .base import BaseConnector2425BASE = "https://treana.ca"2627# clé de page -> (chemin, ville, adresse civique)28PAGES = {29 "saint-jean": ("/saint-jean-sur-richelieu/", "Saint-Jean-sur-Richelieu",30 "374-378, rue Jacques-Cartier Sud, Saint-Jean-sur-Richelieu"),31 "venise": ("/venise-en-quebec/", "Venise-en-Québec", ""),32 "ste-catherine-1": ("/sainte-catherine_phase_i/", "Sainte-Catherine",33 "1155, rue Centrale, Sainte-Catherine"),34 "ste-catherine-2": ("/sainte-catherine-phase-ii/", "Sainte-Catherine",35 "1155, rue Centrale, Sainte-Catherine"),36}3738LOUE_RE = re.compile(r"lou[ée]", re.I)39GRANDEUR_RE = re.compile(r"^\d\s*1/2$")40SQFT_RE = re.compile(r"([\d\s ]{3,7})\s*pi\.?\s*ca", re.I)41BLDG_RE = re.compile(r"^TREANA\s+([IVX]+)\b", re.I)42IMG_RE = re.compile(43 r"https://(?:www\.)?treana\.ca/wp-content/uploads/[^\"\s\\']+"44 r"\.(?:jpe?g|png|webp)", re.I)45SKIP_IMG_RE = re.compile(46 r"logo|icon|favicon|cropped-|plan|-\d{2,4}x\d{2,4}\.", re.I)474849class TreanaConnector(BaseConnector):50 source_id = "treana"51 request_delay = 0.65253 def fetch(self) -> list[Listing]:54 listings: list[Listing] = []55 for page_key, (path, city, address) in PAGES.items():56 try:57 html = self.get(f"{BASE}{path}").text58 except Exception:59 continue60 self._parse_page(html, page_key, f"{BASE}{path}",61 city, address, listings)62 return listings6364 def _parse_page(self, html: str, page_key: str, url: str, city: str,65 address: str, listings: list[Listing]) -> None:66 soup = BeautifulSoup(html, "html.parser")6768 images = [u for u in dict.fromkeys(IMG_RE.findall(html))69 if not SKIP_IMG_RE.search(u)][:8]70 pdf_el = soup.find("a", href=re.compile(r"\.pdf$", re.I))71 fiche_pdf = pdf_el["href"] if pdf_el else ""7273 seen: set[str] = set()74 for table in soup.find_all("table"):75 head = table.find("tr")76 if head is None:77 continue78 cols = [c.get_text(" ", strip=True)79 for c in head.find_all(["td", "th"])]80 if "Type" not in cols or "Unité" not in cols:81 continue # fiche par typologie : ignorée8283 # immeuble : titre « TREANA III » précédant la table (Venise)84 building = ""85 h = table.find_previous(["h1", "h2", "h3", "h4"])86 for _ in range(4):87 if h is None:88 break89 m = BLDG_RE.match(h.get_text(" ", strip=True))90 if m:91 building = f"TREANA {m.group(1).upper()}"92 break93 h = h.find_previous(["h1", "h2", "h3", "h4"])9495 idx = {name: i for i, name in enumerate(cols)}96 for tr in table.find_all("tr")[1:]:97 try:98 tds = [td.get_text(" ", strip=True)99 for td in tr.find_all("td")]100 if len(tds) < len(cols):101 continue102 unit_no = tds[idx["Unité"]]103 if not re.fullmatch(r"\d{2,4}", unit_no):104 continue105 price_txt = tds[idx["Prix/mois"]]106 if LOUE_RE.search(price_txt):107 continue # unité louée : exclue108 grandeur = tds[idx["Grandeur"]]109 if not GRANDEUR_RE.match(grandeur):110 continue111 unit_type = normalize_unit_type(grandeur)112113 bslug = re.sub(r"[^a-z0-9]+", "-", building.lower())\114 .strip("-")115 ext_id = "-".join(x for x in (page_key, bslug, unit_no)116 if x)117 if ext_id in seen:118 continue119 seen.add(ext_id)120121 area = None122 m = SQFT_RE.search(tds[idx["Superficie"]])123 if m:124 try:125 area = float(re.sub(r"[^\d]", "", m.group(1)))126 except ValueError:127 area = None128129 details: dict = {}130 floor = tds[idx["Étage"]] if "Étage" in idx else ""131 if floor:132 details["floor_label"] = floor133 type_label = tds[idx["Type"]]134 if type_label and type_label not in {"-", "–"}:135 details["type_plan"] = type_label136 if fiche_pdf:137 details["fiche_pdf"] = fiche_pdf138139 title = f"{unit_type} — Unité {unit_no}, " + \140 (building or "TREANA") + f" ({city})"141 listings.append(Listing(142 source=self.source_id,143 external_id=ext_id,144 url=url,145 title=title,146 address=address,147 city=city,148 unit_type=unit_type,149 price=parse_price(price_txt),150 price_label="" if price_txt in {"-", "–"}151 else price_txt,152 area_sqft=area,153 amenities=["Tout inclus"]154 if "TOUS INCLUS" in html else [],155 details=details,156 images=images,157 ))158 except Exception:159 continue160