Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/uneo.py : connecteur Unéo Gestion Immobilière (uneo.ca)5# Acquisition-construction-gestion en Montérégie : Saint-Jean-sur-Richelieu,6# Saint-Hyacinthe, Otterburn Park. WordPress (thème immobilier) rendu7# serveur : /projets liste les fiches, chaque fiche /projets/<slug>/ donne8# des faits structurés (« Emplacement », « Type d'unité », « Superficie »,9# « Nombre d'unités », « À partir de X$/mois »).10# Granularité : une annonce par projet × typologie, prix « à partir de »11# du projet (borne basse commune à ses typologies).12# -----------------------------------------------------------------------------13from __future__ import annotations1415import re1617from bs4 import BeautifulSoup1819from ..schema import Listing, normalize_unit_type, parse_price20from .base import BaseConnector2122BASE = "https://uneo.ca"23LIST_URL = f"{BASE}/projets"2425PROJECT_LINK_RE = re.compile(r"https://uneo\.ca/projets/([\w\-]+)/?")26UNIT_TOKEN_RE = re.compile(r"\b(\d)\s*(?:½|1/2)")27# « À partir de 1550$/mois » (l'espace ou le retour à la ligne varie)28PRICE_RE = re.compile(r"[ÀA]\s*partir\s+de\s*:?\s*([\d\s,]{3,9})\s*\$\s*/?\s*mois", re.I)29AREA_RE = re.compile(r"([\d\s]{3,6}(?:\s*[àa@\-]\s*[\d\s]{3,6})?)\s*pieds?\s+carr[ée]s?", re.I)30UNITS_COUNT_RE = re.compile(r"Nombre d[’']unit[ée]s\s*:?\s*(\d{1,4})", re.I)31IMG_RE = re.compile(32 r"https://uneo\.ca/wp-content/uploads/"33 r'[^"\s\\)]+\.(?:jpe?g|png|webp|avif)', re.I)343536class UneoConnector(BaseConnector):37 source_id = "uneo"38 request_delay = 0.839 max_projects = 15 # garde-fou de crawl4041 def fetch(self) -> list[Listing]:42 listings: list[Listing] = []43 try:44 html = self.get(LIST_URL).text45 except Exception:46 return listings4748 slugs = [s for s in dict.fromkeys(PROJECT_LINK_RE.findall(html))49 if s not in ("feed",)]50 for slug in slugs[: self.max_projects]:51 try:52 listings.extend(self._fetch_project(slug))53 except Exception:54 continue55 return listings5657 def _fetch_project(self, slug: str) -> list[Listing]:58 url = f"{BASE}/projets/{slug}/"59 html = self.get(url).text60 soup = BeautifulSoup(html, "html.parser")61 for tag in soup(["script", "style", "noscript"]):62 tag.decompose()6364 h1 = soup.find("h1")65 name = h1.get_text(" ", strip=True) if h1 else \66 slug.replace("-", " ").title()67 text = re.sub(r"\s+", " ", soup.get_text(" ", strip=True))6869 # faits structurés de la fiche70 m = re.search(r"Emplacement\s*:?\s*([A-ZÉÈ][\w\- ]{3,40}?)"71 r"(?:\s+(?:Nombre|Superficie|Type|Année|À partir)|$)", text)72 city = m.group(1).strip() if m else "Saint-Jean-sur-Richelieu"7374 m = re.search(r"Type d[’']unit[ée]s?\s*:?\s*([\d\s½,/et]+)", text)75 unit_zone = m.group(1) if m else ""76 unit_types = list(dict.fromkeys(77 normalize_unit_type(f"{n}½")78 for n in UNIT_TOKEN_RE.findall(unit_zone)))79 if not unit_types:80 unit_types = [""]8182 price_label = ""83 m = PRICE_RE.search(text)84 if m:85 price_label = f"À partir de {m.group(1).strip()}$/mois"8687 area_label = ""88 m = AREA_RE.search(text)89 if m:90 area_label = m.group(0).strip()9192 n_units = None93 m = UNITS_COUNT_RE.search(text)94 if m:95 n_units = int(m.group(1))9697 # description : paragraphe de présentation98 description = ""99 for p in soup.find_all("p"):100 pt = p.get_text(" ", strip=True)101 if len(pt) > 120 and "consentement" not in pt.lower() \102 and "cookies" not in pt.lower():103 description = pt[:900]104 break105106 # adresse civique éventuelle dans la prose (« au 632 rue Champlain »)107 address = ""108 m = re.search(r"(?:au|le)\s+(\d{1,5}[,]?\s+(?:rue|avenue|boulevard|"109 r"chemin|route)\s+[\w\-' ]{2,40})", text, re.I)110 if m:111 address = m.group(1).strip()112113 amenities: list[str] = []114 for kw, label in [115 (r"ascenseur", "Ascenseur"),116 (r"garage souterrain|stationnement au garage", "Garage/stationnement intérieur"),117 (r"internet (?:haute vitesse )?inclus", "Internet inclus"),118 (r"balayeuse centrale", "Balayeuse centrale"),119 (r"interphone|intercom", "Interphone"),120 (r"borne [ée]lectrique", "Borne électrique ($)"),121 (r"remise int[ée]rieure", "Remise intérieure"),122 (r"balcon", "Balcon"),123 (r"thermopompe", "Thermopompe"),124 (r"animaux accept[ée]s|animaux de compagnie sont bienvenus",125 "Animaux acceptés (conditions)"),126 ]:127 if re.search(kw, text, re.I):128 amenities.append(label)129 if area_label:130 amenities.append(f"Superficie : {area_label}")131132 details: dict = {}133 if n_units:134 details["building_units"] = n_units135136 images = [u for u in dict.fromkeys(IMG_RE.findall(html))137 if not re.search(r"logo|icon|favicon|cropped-|plan|"138 r"-\d{2,3}x\d{2,3}\.", u, re.I)][:20]139140 out: list[Listing] = []141 for ut in unit_types:142 ext = f"{slug}-{ut.replace('½', '.5')}" if ut else slug143 out.append(Listing(144 source=self.source_id,145 external_id=ext,146 url=url,147 title=f"{name} — {ut}" if ut else name,148 address=address,149 city=city,150 unit_type=ut,151 price=parse_price(price_label),152 price_label=price_label,153 description=description,154 amenities=list(dict.fromkeys(amenities)),155 details=dict(details),156 images=images,157 ))158 return out159