# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/sovima.py : connecteur Groupe Sovima (groupesovima.com) # Appartements/condos locatifs neufs de la Rive-Sud (+ Repentigny) : # AlterEGO (Brossard), Le Nest (Saint-Bruno-de-Montarville), Quartier # Vauquelin 2 (Longueuil) et Le Neuf Cent Notre-Dame (Repentigny). # Nest Ottawa (Ontario) exclu. Site Webflow rendu serveur : chaque projet a # une page /nos-plans avec les typologies et le prix plancher — motif texte # « ½ … À PARTIR DE $ / mois » (ou « Non disponible pour le # moment », ignoré). Granularité = TYPOLOGIE par projet. # external_id = --demi — stable. ⚠️ Webflow sans charset dans les # en-têtes -> forcer UTF-8 (même piège que aera3r). # ----------------------------------------------------------------------------- from __future__ import annotations import html as _html import re from ..schema import Listing from .base import BaseConnector BASE = "https://www.groupesovima.com" # (slug projet, nom, adresse, ville) SITES: tuple[tuple[str, str, str, str], ...] = ( ("alterego", "AlterEGO sur le fleuve", "Tour 2, 8355, boulevard du St-Laurent, Brossard", "Brossard"), ("le-nest", "Le Nest", "2000, rue Benoît, Saint-Bruno-de-Montarville", "Saint-Bruno-de-Montarville"), ("quartier-vauquelin", "Quartier Vauquelin 2", "4325, rue des Dirigeables, Longueuil", "Longueuil"), ("le-neuf-cent", "Le Neuf Cent Notre-Dame", "900, rue Notre-Dame, Repentigny", "Repentigny"), ) # « 3 ½ À PARTIR DE 1770 $ / mois » dans le texte aplati (tags -> espaces) PLAN_RE = re.compile( r"(\d)\s*½\s*À\s+PARTIR\s+DE\s+([\d\s ,]{3,9})\$\s*/\s*mois", re.I) SCRIPT_RE = re.compile(r"<(script|style)[\s\S]*?", re.I) TAG_RE = re.compile(r"<[^>]+>") IMG_RE = re.compile( r'(?:src|href)="(https://cdn\.prod\.website-files\.com/[^"\s]+' r"\.(?:jpe?g|png|webp|avif))", re.I) def _num(txt: str) -> float | None: n = re.sub(r"[\s ,]", "", txt or "") try: return float(n) except ValueError: return None class SovimaConnector(BaseConnector): source_id = "sovima" request_delay = 0.8 def fetch(self) -> list[Listing]: listings: list[Listing] = [] for slug, name, address, city in SITES: url = f"{BASE}/{slug}/nos-plans" try: resp = self.get(url) resp.encoding = "utf-8" # Webflow sans charset -> latin-1 html = resp.text except Exception: continue # photos : la page /nos-plans n'a pas de galerie, on prend celle # de /nos-appartements (rendue serveur elle aussi) photos_html = "" try: p = self.get(f"{BASE}/{slug}/nos-appartements") p.encoding = "utf-8" photos_html = p.text except Exception: pass listings.extend(self._parse(slug, url, name, address, city, html, photos_html)) return listings def _parse(self, slug: str, url: str, name: str, address: str, city: str, html: str, photos_html: str = "") -> list[Listing]: text = _html.unescape(SCRIPT_RE.sub(" ", html)) flat = re.sub(r"[\s ]+", " ", TAG_RE.sub(" ", text)) images = [u for u in dict.fromkeys( IMG_RE.findall(html) + IMG_RE.findall(photos_html)) if not re.search(r"logo|favicon|webclip|icon|opengraph", u, re.I)][:8] listings: list[Listing] = [] seen: set[str] = set() for m in PLAN_RE.finditer(flat): n = int(m.group(1)) price = _num(m.group(2)) if not (2 <= n <= 7) or price is None or not (500 <= price <= 8000): continue ext_id = f"{slug}-{n}-demi" if ext_id in seen: # motif répété dans la page continue seen.add(ext_id) unit_type = f"{n}½" listings.append(Listing( source=self.source_id, external_id=ext_id, url=url, title=f"{unit_type} — {name}", address=address, sector="", city=city, unit_type=unit_type, price=price, price_label=f"à partir de {int(price)} $ / mois", description=f"{unit_type} au {name} ({address}) — projet " "locatif du Groupe Sovima.", images=list(images), )) return listings