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%
4.8 KB · 121 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/sovima.py : connecteur Groupe Sovima (groupesovima.com)5#   Appartements/condos locatifs neufs de la Rive-Sud (+ Repentigny) :6#   AlterEGO (Brossard), Le Nest (Saint-Bruno-de-Montarville), Quartier7#   Vauquelin 2 (Longueuil) et Le Neuf Cent Notre-Dame (Repentigny).8#   Nest Ottawa (Ontario) exclu. Site Webflow rendu serveur : chaque projet a9#   une page /nos-plans avec les typologies et le prix plancher — motif texte10#   « <n> ½ … À PARTIR DE <prix> $ / mois » (ou « Non disponible pour le11#   moment », ignoré). Granularité = TYPOLOGIE par projet.12#   external_id = <projet>-<n>-demi — stable. ⚠️ Webflow sans charset dans les13#   en-têtes -> forcer UTF-8 (même piège que aera3r).14# -----------------------------------------------------------------------------15from __future__ import annotations1617import html as _html18import re1920from ..schema import Listing21from .base import BaseConnector2223BASE = "https://www.groupesovima.com"2425# (slug projet, nom, adresse, ville)26SITES: tuple[tuple[str, str, str, str], ...] = (27    ("alterego", "AlterEGO sur le fleuve",28     "Tour 2, 8355, boulevard du St-Laurent, Brossard", "Brossard"),29    ("le-nest", "Le Nest",30     "2000, rue Benoît, Saint-Bruno-de-Montarville",31     "Saint-Bruno-de-Montarville"),32    ("quartier-vauquelin", "Quartier Vauquelin 2",33     "4325, rue des Dirigeables, Longueuil", "Longueuil"),34    ("le-neuf-cent", "Le Neuf Cent Notre-Dame",35     "900, rue Notre-Dame, Repentigny", "Repentigny"),36)3738# « 3 ½ À PARTIR DE 1770 $ / mois » dans le texte aplati (tags -> espaces)39PLAN_RE = re.compile(40    r"(\d)\s*½\s*À\s+PARTIR\s+DE\s+([\d\s  ,]{3,9})\$\s*/\s*mois", re.I)41SCRIPT_RE = re.compile(r"<(script|style)[\s\S]*?</\1>", re.I)42TAG_RE = re.compile(r"<[^>]+>")43IMG_RE = re.compile(44    r'(?:src|href)="(https://cdn\.prod\.website-files\.com/[^"\s]+'45    r"\.(?:jpe?g|png|webp|avif))", re.I)464748def _num(txt: str) -> float | None:49    n = re.sub(r"[\s  ,]", "", txt or "")50    try:51        return float(n)52    except ValueError:53        return None545556class SovimaConnector(BaseConnector):57    source_id = "sovima"58    request_delay = 0.85960    def fetch(self) -> list[Listing]:61        listings: list[Listing] = []62        for slug, name, address, city in SITES:63            url = f"{BASE}/{slug}/nos-plans"64            try:65                resp = self.get(url)66                resp.encoding = "utf-8"      # Webflow sans charset -> latin-167                html = resp.text68            except Exception:69                continue70            # photos : la page /nos-plans n'a pas de galerie, on prend celle71            # de /nos-appartements (rendue serveur elle aussi)72            photos_html = ""73            try:74                p = self.get(f"{BASE}/{slug}/nos-appartements")75                p.encoding = "utf-8"76                photos_html = p.text77            except Exception:78                pass79            listings.extend(self._parse(slug, url, name, address, city,80                                        html, photos_html))81        return listings8283    def _parse(self, slug: str, url: str, name: str, address: str,84               city: str, html: str, photos_html: str = "") -> list[Listing]:85        text = _html.unescape(SCRIPT_RE.sub(" ", html))86        flat = re.sub(r"[\s  ]+", " ", TAG_RE.sub(" ", text))8788        images = [u for u in dict.fromkeys(89                      IMG_RE.findall(html) + IMG_RE.findall(photos_html))90                  if not re.search(r"logo|favicon|webclip|icon|opengraph",91                                   u, re.I)][:8]9293        listings: list[Listing] = []94        seen: set[str] = set()95        for m in PLAN_RE.finditer(flat):96            n = int(m.group(1))97            price = _num(m.group(2))98            if not (2 <= n <= 7) or price is None or not (500 <= price <= 8000):99                continue100            ext_id = f"{slug}-{n}-demi"101            if ext_id in seen:               # motif répété dans la page102                continue103            seen.add(ext_id)104            unit_type = f"{n}½"105            listings.append(Listing(106                source=self.source_id,107                external_id=ext_id,108                url=url,109                title=f"{unit_type} — {name}",110                address=address,111                sector="",112                city=city,113                unit_type=unit_type,114                price=price,115                price_label=f"à partir de {int(price)} $ / mois",116                description=f"{unit_type} au {name} ({address}) — projet "117                            "locatif du Groupe Sovima.",118                images=list(images),119            ))120        return listings121