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%
5.6 KB · 133 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/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