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%
5.2 KB · 128 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/helio.py : connecteur Entreprises Hélio (entrepriseshelio.ca)5#   Trois immeubles en Montérégie : 2417 Montarville et 335-345 Place du6#   Collège à Longueuil, 1065-1075 boul. de Palerme à La Prairie.7#   WordPress + Divi rendu serveur : la page /a-louer/ liste une section par8#   typologie (h3 « 3 1/2, Place du Collège », « 4 1/2, Palerme »…) suivie9#   d'un encadré « Disponibilité : <valeur> » et des caractéristiques (li).10#   Une typologie marquée « COMPLET » n'est PAS ingérée : seules les sections11#   avec une vraie disponibilité (date ou « disponible ») deviennent des12#   annonces. Il est donc normal que ce connecteur retourne 0 annonce quand13#   tout le parc est loué (état observé au 2026-08-25).14# -----------------------------------------------------------------------------15from __future__ import annotations1617import re1819from bs4 import BeautifulSoup2021from ..schema import Listing, normalize_unit_type22from .base import BaseConnector2324BASE = "https://www.entrepriseshelio.ca"25LIST_URL = f"{BASE}/a-louer/"2627# immeuble (mot-clé du titre de section) -> adresse et ville28BUILDINGS = {29    "montarville": ("2417, rue de Montarville, Longueuil", "Longueuil"),30    "college": ("335-345, place du Collège, Longueuil", "Longueuil"),31    "palerme": ("1065-1075, boulevard de Palerme, La Prairie", "La Prairie"),32}3334# titre de section d'unité : « 3 1/2, Place du Collège – Demi Sous-Sol »35TITLE_RE = re.compile(r"^(\d\s*1/2)\s*,\s*(.+)$")36COMPLET_RE = re.compile(r"\bCOMPLET\b", re.I)37IMG_RE = re.compile(38    r"https://www\.entrepriseshelio\.ca/wp-content/uploads/"39    r"[^\"\s\\']+\.(?:jpe?g|png|webp)", re.I)40SKIP_IMG_RE = re.compile(r"logo|icon|favicon|-\d{2,3}x\d{2,3}\.", re.I)414243def _slug(text: str) -> str:44    from ..schema import strip_accents45    s = strip_accents(text.lower())46    return re.sub(r"-{2,}", "-", re.sub(r"[^a-z0-9]+", "-", s)).strip("-")474849class HelioConnector(BaseConnector):50    source_id = "helio"51    request_delay = 0.65253    def fetch(self) -> list[Listing]:54        listings: list[Listing] = []55        try:56            html = self.get(LIST_URL).text57        except Exception:58            return listings59        soup = BeautifulSoup(html, "html.parser")6061        for h3 in soup.find_all("h3"):62            try:63                if "et_pb_gallery_title" in (h3.get("class") or []):64                    continue                      # légendes de galerie Divi65                title = h3.get_text(" ", strip=True)66                m = TITLE_RE.match(title)67                if not m:68                    continue69                unit_type = normalize_unit_type(m.group(1))70                where = m.group(2).strip()7172                # section Divi englobante (colonne/row) : dispo + listes73                section = h374                for _ in range(8):75                    section = section.parent76                    if section is None:77                        break78                    cls = " ".join(section.get("class") or [])79                    txt = section.get_text(" ", strip=True)80                    if "et_pb_row" in cls and "Disponibilité" in txt:81                        break82                if section is None:83                    continue84                stext = section.get_text(" | ", strip=True)8586                # disponibilité : valeur qui suit « Disponibilité : »87                availability = ""88                m2 = re.search(r"Disponibilité\s*\|?\s*:\s*\|?\s*([^|]+)",89                               stext)90                if m2:91                    availability = m2.group(1).strip()92                if COMPLET_RE.search(availability):93                    continue                      # typologie complète : exclue94                if not availability:95                    continue                      # rien d'affiché : prudence9697                # immeuble -> adresse/ville98                key = _slug(where)99                address, city = "", ""100                for kw, (addr, ct) in BUILDINGS.items():101                    if kw in key:102                        address, city = addr, ct103                        break104105                amenities = [li.get_text(" ", strip=True)106                             for li in section.find_all("li")107                             if li.get_text(strip=True)108                             and "photos sont représentatives"109                             not in li.get_text()][:20]110                images = [u for u in dict.fromkeys(IMG_RE.findall(str(section)))111                          if not SKIP_IMG_RE.search(u)][:15]112113                listings.append(Listing(114                    source=self.source_id,115                    external_id=f"{unit_type.replace('½', '.5')}-{key}",116                    url=LIST_URL,117                    title=title,118                    address=address,119                    city=city,120                    unit_type=unit_type,121                    availability=availability,122                    amenities=amenities,123                    images=images,124                ))125            except Exception:126                continue127        return listings128