# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/linea.py : connecteur LINÉA (linea.ca) — Trois-Rivières # Complexe locatif neuf du District 55 (6900, boulevard des Athlètes, secteur # Trois-Rivières-Ouest), 3 phases. WordPress/WPBakery rendu serveur : chaque # page /unites/phase-N/ présente les typologies disponibles sous forme de # colonnes « » (Loft, 3 ½, 4 ½, 5 ½) suivies d'un bloc texte # « De 1 008 pi² à 1 071 pi² / À partir de 1 750 $ / mois ». Granularité = # typologie par phase (le site ne publie pas d'inventaire unité par unité). # external_id = phaseN- — stable tant que la typologie est offerte. # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type BASE = "https://linea.ca" PHASES = ("phase-1", "phase-2", "phase-3") ADDRESS = "6900, boulevard des Athlètes, Trois-Rivières" UNIT_RE = re.compile(r"^\s*(Loft|Studio|\d\s*½)\s*$") PRICE_RE = re.compile(r"À\s+partir\s+de\s+([\d\s  ]+)\s*\$\s*/\s*mois", re.I) AREA_RE = re.compile(r"(?:De\s+)?([\d\s  ]+)\s*pi²(?:\s+à\s+([\d\s  ]+)\s*pi²)?", re.I) IMG_RE = re.compile( # /wp-content/uploads/ ou /dist/uploads/ (RocketCDN) r"https?://[\w./-]+/uploads/[^\"'\s)\\]+?\.(?:jpg|jpeg|png|webp)", re.I) IMG_SKIP_RE = re.compile(r"favicon|logo|icon|texture|-\d+x\d+\.", re.I) from .base import BaseConnector # noqa: E402 (après les constantes, style maison) def _num(txt: str) -> float | None: n = re.sub(r"[\s  ]", "", txt or "") try: return float(n) except ValueError: return None class LineaConnector(BaseConnector): source_id = "linea" request_delay = 0.8 def fetch(self) -> list[Listing]: listings: list[Listing] = [] for phase in PHASES: url = f"{BASE}/unites/{phase}/" try: html = self.get(url).text except Exception: continue listings.extend(self._parse_phase(phase, url, html)) return listings def _parse_phase(self, phase: str, url: str, html: str) -> list[Listing]: soup = BeautifulSoup(html, "html.parser") phase_label = phase.replace("phase-", "Phase ") # Photos de la page (les typologies n'ont pas de galerie propre) images = [u for u in dict.fromkeys(IMG_RE.findall(html)) if not IMG_SKIP_RE.search(u)][:15] listings: list[Listing] = [] current_type = "" # Parcours en ordre du document : un

« 4 ½ » annonce la typologie, # le

suivant porte superficie + « À partir de … $ / mois ». for p in soup.select(".wpb_text_column p"): text = re.sub(r"\s+", " ", p.get_text(" ", strip=True)) if not text: continue m = UNIT_RE.match(text) if m: current_type = normalize_unit_type(m.group(1)) continue if not current_type: continue pm = PRICE_RE.search(text) if not pm: continue price = _num(pm.group(1)) area = None desc_bits = [f"{phase_label} du LINÉA (District 55)"] am = AREA_RE.search(text) if am: area = _num(am.group(1)) if am.group(2): desc_bits.append(f"Superficies de {am.group(1).strip()} à " f"{am.group(2).strip()} pi²") ext_id = f"{phase}-{current_type.replace('½', '.5').lower()}" ext_id = re.sub(r"[^\w.\-]", "-", ext_id) listings.append(Listing( source=self.source_id, external_id=ext_id, url=url, title=f"{current_type} — LINÉA {phase_label}", address=ADDRESS, sector="Trois-Rivières-Ouest", city="Trois-Rivières", unit_type=current_type, price=price, price_label=pm.group(0), area_sqft=area, description=" | ".join(desc_bits), images=images, )) current_type = "" return listings