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%
6.1 KB · 159 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/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