# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/aera3r.py : connecteur Aera Trois-Rivières + réseau aera.ca # Condos locatifs neufs « tout inclus » du réseau Aera (Momentum) : # - aera3r.com — tour du centre-ville de Trois-Rivières (875, rue de la # Terrière), emménagement été 2026 : 1 ch. 1 600 $, 1 ch. + bureau # 1 800 $, 2 ch. 2 300 $ tout inclus ; # - esplanadegirouard.ca — 560, rue Girouard Ouest, Saint-Hyacinthe # (12 unités) : 2 ch. dès 1 610 $, 3 ch. dès 1 810 $ ; # - petitquartier.ca — maisons de ville, 603, rue Principale, # La Présentation : dès 2 535 $/mois. # aerasaintlambert.com ne publie AUCUN prix (formulaire seulement) et # aerachambly.com est déjà couvert ailleurs (doc) — tous deux exclus. # Sites Webflow statiques page unique rendus serveur : on extrait du texte # les motifs « à partir de (seulement) $ ». ⚠️ Les pages # contiennent aussi des menus de budget de formulaire (« 1 100 $ par # mois »…) — ignorés car sans « à partir de ». Granularité = typologie par # projet. external_id = - — stable. # ----------------------------------------------------------------------------- from __future__ import annotations import html as _html import re from ..schema import Listing, strip_accents from .base import BaseConnector # (slug projet, url, nom, adresse, ville) SITES: tuple[tuple[str, str, str, str, str], ...] = ( ("3r", "https://aera3r.com/", "Aera Trois-Rivières", "875, rue de la Terrière, Trois-Rivières", "Trois-Rivières"), ("girouard", "https://esplanadegirouard.ca/", "Esplanade sur Girouard", "560, rue Girouard Ouest, Saint-Hyacinthe", "Saint-Hyacinthe"), ("petitquartier", "https://petitquartier.ca/", "Le Petit Quartier", "603, rue Principale, La Présentation", "La Présentation"), ) CARD_RE = re.compile( r"(\d\s*chambres?(?:\s*\+\s*bureau)?|[Mm]aisons?\s+de\s+ville|[Ss]tudios?)" r"[^$\n]{0,80}?à\s+partir\s+de\s+(?:seulement\s+)?([\d\s ]{3,7})\$", re.I) MOVE_IN_RE = re.compile(r"(?:Emménagez|Occupation|Livraison)[^<.!]{3,60}", re.I) SCRIPT_RE = re.compile(r"<(script|style)[\s\S]*?", re.I) TAG_RE = re.compile(r"<[^>]+>") IMG_RE = re.compile(r'(?:src|srcset)="(images/[^"\s]+\.(?:jpg|jpeg|png|webp))') def _slug(text: str) -> str: s = strip_accents(text.lower()) return re.sub(r"[^a-z0-9]+", "-", s).strip("-") def _num(txt: str) -> float | None: n = re.sub(r"[\s ]", "", txt or "") try: return float(n) except ValueError: return None class Aera3RConnector(BaseConnector): source_id = "aera3r" request_delay = 1.0 def fetch(self) -> list[Listing]: listings: list[Listing] = [] for slug, url, name, address, city in SITES: try: resp = self.get(url) resp.encoding = "utf-8" # Webflow sans charset -> latin-1 html = resp.text except Exception: continue listings.extend(self._parse_site(slug, url, name, address, city, html)) return listings def _parse_site(self, slug: str, url: str, name: str, address: str, city: str, html: str) -> list[Listing]: text = _html.unescape(SCRIPT_RE.sub(" ", html)) flat = re.sub(r"[\s ]+", " ", TAG_RE.sub(" ", text)) availability = "" am = MOVE_IN_RE.search(flat) if am and re.search(r"20\d\d|immédiate|flexible", am.group(0), re.I): availability = re.split(r"\s+(?:Réservez|condos?)\b", am.group(0), flags=re.I)[0].strip() images = [url + u for u in dict.fromkeys(IMG_RE.findall(html)) if not re.search(r"logo|favicon|webclip|icon", u, re.I)][:10] listings: list[Listing] = [] seen: set[str] = set() for m in CARD_RE.finditer(flat): label = re.sub(r"\s+", " ", m.group(1)).strip() price = _num(m.group(2)) if price is None or not (400 <= price <= 6000): continue label_slug = _slug(label) if label_slug in seen: # motifs répétés dans la page continue seen.add(label_slug) bedrooms = None bm = re.match(r"(\d)\s*chambres?", label, re.I) if bm: bedrooms = float(bm.group(1)) unit_type = "Studio" if re.match(r"studio", label, re.I) else "" details = {} if re.search(r"maison", label, re.I): details["property_type"] = "Maison de ville" label = "Maison de ville" listings.append(Listing( source=self.source_id, external_id=f"{slug}-{label_slug}", url=url, title=f"{label.capitalize()} — {name}", address=address, sector="", city=city, unit_type=unit_type, bedrooms=bedrooms, price=price, price_label=f"à partir de {int(price)} $ par mois", availability=availability, description=f"{label.capitalize()} au {name} ({address}) — " "condos locatifs neufs du réseau Aera, formule " "tout inclus.", details=details, images=list(images), )) return listings