# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/uneo.py : connecteur Unéo Gestion Immobilière (uneo.ca) # Acquisition-construction-gestion en Montérégie : Saint-Jean-sur-Richelieu, # Saint-Hyacinthe, Otterburn Park. WordPress (thème immobilier) rendu # serveur : /projets liste les fiches, chaque fiche /projets// donne # des faits structurés (« Emplacement », « Type d'unité », « Superficie », # « Nombre d'unités », « À partir de X$/mois »). # Granularité : une annonce par projet × typologie, prix « à partir de » # du projet (borne basse commune à ses typologies). # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price from .base import BaseConnector BASE = "https://uneo.ca" LIST_URL = f"{BASE}/projets" PROJECT_LINK_RE = re.compile(r"https://uneo\.ca/projets/([\w\-]+)/?") UNIT_TOKEN_RE = re.compile(r"\b(\d)\s*(?:½|1/2)") # « À partir de 1550$/mois » (l'espace ou le retour à la ligne varie) PRICE_RE = re.compile(r"[ÀA]\s*partir\s+de\s*:?\s*([\d\s,]{3,9})\s*\$\s*/?\s*mois", re.I) AREA_RE = re.compile(r"([\d\s]{3,6}(?:\s*[àa@\-]\s*[\d\s]{3,6})?)\s*pieds?\s+carr[ée]s?", re.I) UNITS_COUNT_RE = re.compile(r"Nombre d[’']unit[ée]s\s*:?\s*(\d{1,4})", re.I) IMG_RE = re.compile( r"https://uneo\.ca/wp-content/uploads/" r'[^"\s\\)]+\.(?:jpe?g|png|webp|avif)', re.I) class UneoConnector(BaseConnector): source_id = "uneo" request_delay = 0.8 max_projects = 15 # garde-fou de crawl def fetch(self) -> list[Listing]: listings: list[Listing] = [] try: html = self.get(LIST_URL).text except Exception: return listings slugs = [s for s in dict.fromkeys(PROJECT_LINK_RE.findall(html)) if s not in ("feed",)] for slug in slugs[: self.max_projects]: try: listings.extend(self._fetch_project(slug)) except Exception: continue return listings def _fetch_project(self, slug: str) -> list[Listing]: url = f"{BASE}/projets/{slug}/" html = self.get(url).text soup = BeautifulSoup(html, "html.parser") for tag in soup(["script", "style", "noscript"]): tag.decompose() h1 = soup.find("h1") name = h1.get_text(" ", strip=True) if h1 else \ slug.replace("-", " ").title() text = re.sub(r"\s+", " ", soup.get_text(" ", strip=True)) # faits structurés de la fiche m = re.search(r"Emplacement\s*:?\s*([A-ZÉÈ][\w\- ]{3,40}?)" r"(?:\s+(?:Nombre|Superficie|Type|Année|À partir)|$)", text) city = m.group(1).strip() if m else "Saint-Jean-sur-Richelieu" m = re.search(r"Type d[’']unit[ée]s?\s*:?\s*([\d\s½,/et]+)", text) unit_zone = m.group(1) if m else "" unit_types = list(dict.fromkeys( normalize_unit_type(f"{n}½") for n in UNIT_TOKEN_RE.findall(unit_zone))) if not unit_types: unit_types = [""] price_label = "" m = PRICE_RE.search(text) if m: price_label = f"À partir de {m.group(1).strip()}$/mois" area_label = "" m = AREA_RE.search(text) if m: area_label = m.group(0).strip() n_units = None m = UNITS_COUNT_RE.search(text) if m: n_units = int(m.group(1)) # description : paragraphe de présentation description = "" for p in soup.find_all("p"): pt = p.get_text(" ", strip=True) if len(pt) > 120 and "consentement" not in pt.lower() \ and "cookies" not in pt.lower(): description = pt[:900] break # adresse civique éventuelle dans la prose (« au 632 rue Champlain ») address = "" m = re.search(r"(?:au|le)\s+(\d{1,5}[,]?\s+(?:rue|avenue|boulevard|" r"chemin|route)\s+[\w\-' ]{2,40})", text, re.I) if m: address = m.group(1).strip() amenities: list[str] = [] for kw, label in [ (r"ascenseur", "Ascenseur"), (r"garage souterrain|stationnement au garage", "Garage/stationnement intérieur"), (r"internet (?:haute vitesse )?inclus", "Internet inclus"), (r"balayeuse centrale", "Balayeuse centrale"), (r"interphone|intercom", "Interphone"), (r"borne [ée]lectrique", "Borne électrique ($)"), (r"remise int[ée]rieure", "Remise intérieure"), (r"balcon", "Balcon"), (r"thermopompe", "Thermopompe"), (r"animaux accept[ée]s|animaux de compagnie sont bienvenus", "Animaux acceptés (conditions)"), ]: if re.search(kw, text, re.I): amenities.append(label) if area_label: amenities.append(f"Superficie : {area_label}") details: dict = {} if n_units: details["building_units"] = n_units images = [u for u in dict.fromkeys(IMG_RE.findall(html)) if not re.search(r"logo|icon|favicon|cropped-|plan|" r"-\d{2,3}x\d{2,3}\.", u, re.I)][:20] out: list[Listing] = [] for ut in unit_types: ext = f"{slug}-{ut.replace('½', '.5')}" if ut else slug out.append(Listing( source=self.source_id, external_id=ext, url=url, title=f"{name} — {ut}" if ut else name, address=address, city=city, unit_type=ut, price=parse_price(price_label), price_label=price_label, description=description, amenities=list(dict.fromkeys(amenities)), details=dict(details), images=images, )) return out