# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/levivo.py : connecteur LeVivo Condos Locatifs (levivo.ca) # Quatre projets « tout inclus » en Montérégie : Longueuil 1, Longueuil 2, # Greenfield Park et La Prairie. Squarespace rendu serveur : chaque page # /fr/projets/ affiche l'adresse (ligne au-dessus de « Adresse ») # puis une « Description des unités » en triplets texte (« 4 ½ / À partir # de / 2 100,00 $ »). Granularité : typologie par projet. # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from .base import BaseConnector from ..schema import Listing, normalize_unit_type BASE = "https://www.levivo.ca" PROJECTS = [ ("longueuil-1", "LeVivo Longueuil 1", "Longueuil"), ("longueuil-2", "LeVivo Longueuil 2", "Longueuil"), ("greenfield-park", "LeVivo Greenfield Park", "Longueuil"), ("la-prairie", "LeVivo La Prairie", "La Prairie"), ] TYPE_RE = re.compile(r"^(studio|\d\s*½|\d\s*1/2)$", re.I) # « 2 100,00 $ » (format québécois) PRICE_RE = re.compile(r"^([\d\s ]{1,3}(?:[\s ]?\d{3})*),\d{2}\s*\$$") IMG_RE = re.compile(r'https://images\.squarespace-cdn\.com/content/' r'[^"\s\\)]+\.(?:jpe?g|png|webp)', re.I) class LevivoConnector(BaseConnector): source_id = "levivo" request_delay = 0.6 def fetch(self) -> list[Listing]: listings: list[Listing] = [] for slug, name, city in PROJECTS: url = f"{BASE}/fr/projets/{slug}" try: html = self.get(url).text except Exception: continue soup = BeautifulSoup(html, "html.parser") lines = soup.get_text("\n", strip=True).split("\n") # adresse : la ligne complète précède le libellé « Adresse » address = "" for i, line in enumerate(lines): if line.strip() == "Adresse" and i > 0: address = lines[i - 1].strip() break address = re.sub(r",?\s*Québec(\s+\w\d\w\s*\d\w\d)?$", "", address).strip().rstrip(",") images = [u for u in dict.fromkeys(IMG_RE.findall(html)) if not re.search(r"favicon|logo|icon", u, re.I)][:15] # triplets « 4 ½ / À partir de / 2 100,00 $ » for i, line in enumerate(lines): raw = line.strip() if not TYPE_RE.match(raw): continue if i + 2 >= len(lines): continue if not lines[i + 1].strip().lower().startswith("à partir"): continue m = PRICE_RE.match(lines[i + 2].strip()) if not m: continue try: price = float(m.group(1).replace(" ", "") .replace(" ", "").replace(" ", "")) except ValueError: continue unit_type = ("Studio" if raw.lower() == "studio" else normalize_unit_type(raw)) type_slug = ("studio" if unit_type == "Studio" else unit_type.replace("½", ".5")) ext_id = f"{slug}-{type_slug}" if any(l.external_id == ext_id for l in listings): continue listings.append(Listing( source=self.source_id, external_id=ext_id, url=url, title=f"{unit_type} — {name}", address=address or None, city=city, unit_type=unit_type, price=price, price_label=f"À partir de {lines[i + 2].strip()}", description=f"Condos locatifs « tout inclus » du projet " f"{name} : électricité, chauffage, eau " f"chaude, internet et câblodistribution " f"inclus, avec piscine et salle d'exercice.", images=images, )) return listings