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/aera3r.py : connecteur Aera Trois-Rivières + réseau aera.ca5# Condos locatifs neufs « tout inclus » du réseau Aera (Momentum) :6# - aera3r.com — tour du centre-ville de Trois-Rivières (875, rue de la7# Terrière), emménagement été 2026 : 1 ch. 1 600 $, 1 ch. + bureau8# 1 800 $, 2 ch. 2 300 $ tout inclus ;9# - esplanadegirouard.ca — 560, rue Girouard Ouest, Saint-Hyacinthe10# (12 unités) : 2 ch. dès 1 610 $, 3 ch. dès 1 810 $ ;11# - petitquartier.ca — maisons de ville, 603, rue Principale,12# La Présentation : dès 2 535 $/mois.13# aerasaintlambert.com ne publie AUCUN prix (formulaire seulement) et14# aerachambly.com est déjà couvert ailleurs (doc) — tous deux exclus.15# Sites Webflow statiques page unique rendus serveur : on extrait du texte16# les motifs « <typologie> à partir de (seulement) <prix>$ ». ⚠️ Les pages17# contiennent aussi des menus de budget de formulaire (« 1 100 $ par18# mois »…) — ignorés car sans « à partir de ». Granularité = typologie par19# projet. external_id = <projet>-<typologie en slug> — stable.20# -----------------------------------------------------------------------------21from __future__ import annotations2223import html as _html24import re2526from ..schema import Listing, strip_accents27from .base import BaseConnector2829# (slug projet, url, nom, adresse, ville)30SITES: tuple[tuple[str, str, str, str, str], ...] = (31 ("3r", "https://aera3r.com/", "Aera Trois-Rivières",32 "875, rue de la Terrière, Trois-Rivières", "Trois-Rivières"),33 ("girouard", "https://esplanadegirouard.ca/", "Esplanade sur Girouard",34 "560, rue Girouard Ouest, Saint-Hyacinthe", "Saint-Hyacinthe"),35 ("petitquartier", "https://petitquartier.ca/", "Le Petit Quartier",36 "603, rue Principale, La Présentation", "La Présentation"),37)3839CARD_RE = re.compile(40 r"(\d\s*chambres?(?:\s*\+\s*bureau)?|[Mm]aisons?\s+de\s+ville|[Ss]tudios?)"41 r"[^$\n]{0,80}?à\s+partir\s+de\s+(?:seulement\s+)?([\d\s ]{3,7})\$",42 re.I)43MOVE_IN_RE = re.compile(r"(?:Emménagez|Occupation|Livraison)[^<.!]{3,60}", re.I)44SCRIPT_RE = re.compile(r"<(script|style)[\s\S]*?</\1>", re.I)45TAG_RE = re.compile(r"<[^>]+>")46IMG_RE = re.compile(r'(?:src|srcset)="(images/[^"\s]+\.(?:jpg|jpeg|png|webp))')474849def _slug(text: str) -> str:50 s = strip_accents(text.lower())51 return re.sub(r"[^a-z0-9]+", "-", s).strip("-")525354def _num(txt: str) -> float | None:55 n = re.sub(r"[\s ]", "", txt or "")56 try:57 return float(n)58 except ValueError:59 return None606162class Aera3RConnector(BaseConnector):63 source_id = "aera3r"64 request_delay = 1.06566 def fetch(self) -> list[Listing]:67 listings: list[Listing] = []68 for slug, url, name, address, city in SITES:69 try:70 resp = self.get(url)71 resp.encoding = "utf-8" # Webflow sans charset -> latin-172 html = resp.text73 except Exception:74 continue75 listings.extend(self._parse_site(slug, url, name, address,76 city, html))77 return listings7879 def _parse_site(self, slug: str, url: str, name: str, address: str,80 city: str, html: str) -> list[Listing]:81 text = _html.unescape(SCRIPT_RE.sub(" ", html))82 flat = re.sub(r"[\s ]+", " ", TAG_RE.sub(" ", text))8384 availability = ""85 am = MOVE_IN_RE.search(flat)86 if am and re.search(r"20\d\d|immédiate|flexible", am.group(0), re.I):87 availability = re.split(r"\s+(?:Réservez|condos?)\b",88 am.group(0), flags=re.I)[0].strip()8990 images = [url + u for u in dict.fromkeys(IMG_RE.findall(html))91 if not re.search(r"logo|favicon|webclip|icon", u, re.I)][:10]9293 listings: list[Listing] = []94 seen: set[str] = set()95 for m in CARD_RE.finditer(flat):96 label = re.sub(r"\s+", " ", m.group(1)).strip()97 price = _num(m.group(2))98 if price is None or not (400 <= price <= 6000):99 continue100 label_slug = _slug(label)101 if label_slug in seen: # motifs répétés dans la page102 continue103 seen.add(label_slug)104 bedrooms = None105 bm = re.match(r"(\d)\s*chambres?", label, re.I)106 if bm:107 bedrooms = float(bm.group(1))108 unit_type = "Studio" if re.match(r"studio", label, re.I) else ""109 details = {}110 if re.search(r"maison", label, re.I):111 details["property_type"] = "Maison de ville"112 label = "Maison de ville"113 listings.append(Listing(114 source=self.source_id,115 external_id=f"{slug}-{label_slug}",116 url=url,117 title=f"{label.capitalize()} — {name}",118 address=address,119 sector="",120 city=city,121 unit_type=unit_type,122 bedrooms=bedrooms,123 price=price,124 price_label=f"à partir de {int(price)} $ par mois",125 availability=availability,126 description=f"{label.capitalize()} au {name} ({address}) — "127 "condos locatifs neufs du réseau Aera, formule "128 "tout inclus.",129 details=details,130 images=list(images),131 ))132 return listings133