SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
6.9 KB · 160 lines python
Raw Blame History
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