SPB Git

spb/lou-ka Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

HTML 99.7%
11.9 KB · 281 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/lokalia.py : connecteur Espaces Lokalia (espaceslokalia.ca)5#   Le gestionnaire couvre plusieurs régions (Québec/Lévis + Grand Montréal :6#   Laval, Longueuil/Rive-Sud, Montérégie, Rive-Nord proche...)7#   -> on interroge l'endpoint AJAX WordPress `get_immeubles_map_data`8#   région par région (slugs découverts dans le filtre du site), ce qui donne9#   la ville par défaut ET les lat/lng structurés de chaque immeuble.10#   Page immeuble : adresse structurée (lien Google Maps sous le H1),11#   accordéons Inclusions / Équipements & sécurité / Aires communes,12#   description (paragraphes réels + promotion + « N unités • N étages »),13#   téléphone du bureau de location, grille « Prix et types d'unités ».14#   Une annonce par type d'unité offert dans chaque immeuble (les variantes15#   « + DEN » ou avec parenthèse gardent leur propre annonce).16#   Ni disponibilité ni superficie ne sont publiées par la source.17# -----------------------------------------------------------------------------18from __future__ import annotations1920import json21import re22import time2324from bs4 import BeautifulSoup2526from ..schema import Listing, infer_city, normalize_unit_type, parse_price27from .base import BaseConnector2829BASE = "https://www.espaceslokalia.ca"30AJAX_URL = f"{BASE}/wp-admin/admin-ajax.php"31# slug de région (filtre du site) -> ville par défaut des immeubles32REGIONS = {33    # Région de Québec / Lévis34    "quebec": "Québec",35    "levis": "Lévis",36    # Laval37    "laval": "Laval",38    # Longueuil / Rive-Sud / Montérégie39    "longueuil": "Longueuil",40    "saint-hubert": "Longueuil",41    "brossard": "Brossard",42    "saint-lambert": "Saint-Lambert",43    "boucherville": "Boucherville",44    "saint-basile-le-grand": "Saint-Basile-le-Grand",45    "sainte-julie": "Sainte-Julie",46    "chambly": "Chambly",47    "saint-constant": "Saint-Constant",48    "delson": "Delson",49    "candiac": "Candiac",50    "chateauguay": "Châteauguay",51    "beauharnois": "Beauharnois",52    "salaberry-de-valleyfield": "Salaberry-de-Valleyfield",53    "granby": "Granby",54    # Rive-Nord proche55    "lachenaie": "Terrebonne",56    "terrebonne": "Terrebonne",57    "mascouche": "Mascouche",58    "saint-jerome": "Saint-Jérôme",59}6061# repli si le lien Google Maps sous le H1 est absent62ADDR_RE = re.compile(63    r"(?<![\d\)])\b\d{1,5}\s+(?:rue|route|rte|boulevard|bd\.?|avenue|av\.|"64    r"chemin|place|mont[ée]e|all[ée]e)\s[^,<>]{1,50},\s*[^,<>]{2,40},\s*"65    r"(?:QC|Qu[ée]bec)[^,<>]{0,12}(?:,\s*Canada)?", re.I)66IMG_RE = re.compile(r"https://www\.espaceslokalia\.ca/wp-content/uploads/"67                    r'[^"\s\\)]+\.(?:jpe?g|webp)', re.I)686970def _slugify(s: str) -> str:71    return re.sub(r"-+", "-", re.sub(r"[^a-z0-9]+", "-",72                                     s.lower().replace("½", ".5"))).strip("-")737475class LokaliaConnector(BaseConnector):76    source_id = "lokalia"77    request_delay = 0.678    max_buildings = 60       # garde-fou de crawl (toutes régions)7980    def _buildings(self) -> list[tuple[str, str, float | None, float | None]]:81        """Endpoint AJAX interrogé région par région82        -> [(url immeuble, ville par défaut, lat, lng), ...] dédupliqués."""83        out: list[tuple[str, str, float | None, float | None]] = []84        seen: set[str] = set()85        for region, city in REGIONS.items():86            try:87                time.sleep(self.request_delay)   # politesse (POST direct)88                resp = self.session.post(89                    AJAX_URL,90                    data={"action": "get_immeubles_map_data",91                          "regions": json.dumps([region])},92                    timeout=self.timeout,93                )94                resp.raise_for_status()95                items = resp.json()96            except Exception:97                continue98            for item in items:99                content = (item.get("list_content") or "") + \100                          (item.get("popup_content") or "")101                m = re.search(r'href="(https://www\.espaceslokalia\.ca/'102                              r'immeuble/[^"]+)"', content)103                if not m or m.group(1) in seen:104                    continue105                seen.add(m.group(1))106                # lat/lng structurés fournis par la carte107                lat = lng = None108                try:109                    lat = float(item.get("lat"))110                    lng = float(item.get("lng"))111                except (TypeError, ValueError):112                    lat = lng = None113                out.append((m.group(1), city, lat, lng))114        return out115116    def fetch(self) -> list[Listing]:117        listings: list[Listing] = []118        for url, default_city, lat, lng in self._buildings()[: self.max_buildings]:119            try:120                html = self.get(url).text121            except Exception:122                continue123            try:124                listings.extend(125                    self._parse_building(url, html, default_city, lat, lng))126            except Exception:127                continue128        return listings129130    def _parse_building(self, url: str, html: str, default_city: str = "Québec",131                        lat: float | None = None,132                        lng: float | None = None) -> list[Listing]:133        soup = BeautifulSoup(html, "html.parser")134        slug = url.rstrip("/").split("/")[-1]135        h1 = soup.find("h1")136        title = h1.get_text(" ", strip=True) if h1 else slug137        # « Appartements à louer à Lévis - Vivaxcès Le Nicolas » -> nom court138        name = title.split(" - ")[-1].split("│")[-1].strip() or slug139140        # Adresse structurée : lien Google Maps sous le H1141        address = ""142        wrap = soup.select_one(".category-wrapper a.description[href*='maps.google']")143        if wrap:144            address = re.sub(r"\s+", " ", wrap.get_text(" ", strip=True)).strip()145        if not address:146            text = re.sub(r"\s+", " ", soup.get_text(" ", strip=True))147            m = ADDR_RE.search(text)148            address = m.group(0).strip() if m else ""149        # étiquettes de la fiche (« Nouveau », « Promotion »)150        tags = [t.get_text(strip=True)151                for t in soup.select(".category-wrapper span.category")]152153        # secteur / ville : « 89 Rue du Doré-Jaune, Lachenaie, Terrebonne, QC »154        sector = ""155        city = default_city156        if address:157            parts = [p.strip() for p in address.split(",")]158            if len(parts) >= 2:159                sector = parts[1]160        city = infer_city(sector, default=city)161        if not sector and "Lévis" in title:162            city = "Lévis"163        if sector and sector.lower() == city.lower():164            sector = ""              # éviter secteur == ville (redondant)165166        # Description : paragraphes réels de la fiche + « N unités • N étages »167        # + promotion, sinon og:description en repli168        desc_bits: list[str] = []169        promo = soup.select_one(".promotion-main h2")170        if promo:171            desc_bits.append("Promotion : "172                             + promo.get_text(" ", strip=True))173        elif "Promotion" in tags:174            desc_bits.append("Promotion en cours")175        paras = [p.get_text(" ", strip=True)176                 for p in soup.select(".wx-single-description .description-left p")177                 if "wx-disclaimer" not in (p.get("class") or [])]178        if paras:179            desc_bits.append(" ".join(t for t in paras if t))180        size = soup.select_one(".wx-single-description .category-wrap span")181        if size:182            desc_bits.append(re.sub(r"\s+", " ", size.get_text(" ", strip=True)))183        if not desc_bits:184            og = soup.find("meta", attrs={"property": "og:description"})185            if og and og.get("content"):186                desc_bits.append(og["content"].strip())187        desc = " — ".join(b for b in desc_bits if b)[:700]188189        # Commodités : inclusions (icônes avec attribut title) + accordéons190        # « Équipements & sécurité » et « Aires communes » (items <li>)191        amenities = [el.get("title").strip()192                     for el in soup.select("div.icon-box[title]")193                     if el.get("title")]194        for acc_cls in ("equipment", "aires"):195            for li in soup.select(f"li.wx-single-accordion.{acc_cls} ul li"):196                t = re.sub(r"\s+", " ", li.get_text(" ", strip=True))197                if t:198                    amenities.append(t)199        amenities = list(dict.fromkeys(a for a in amenities if a))[:30]200201        # Téléphone du bureau de location (lien tel:)202        details: dict = {}203        tel = soup.select_one('a[href^="tel:"]')204        if tel:205            num = re.sub(r"\D", "", tel.get("href", ""))206            num = num[1:] if len(num) == 11 and num.startswith("1") else num207            if len(num) == 10:208                details["contact"] = {"phone": f"{num[:3]}-{num[3:6]}-{num[6:10]}"}209210        # Photos (exclure icônes .png des inclusions)211        images = [u for u in dict.fromkeys(IMG_RE.findall(html))212                  if not re.search(r"logo|icon|favicon|-150x150", u, re.I)][:25]213214        # Types d'unités + prix : blocs .square-box (h5 = type, p = prix).215        # Dédupliqués par libellé BRUT : « 4 1/2 » et « 4 1/2 + DEN » restent216        # deux annonces (l'id de la 1re occurrence d'un type normalisé garde217        # son schéma historique ; les variantes reçoivent un suffixe du libellé).218        results: list[Listing] = []219        seen_raw: set[str] = set()220        seen_norm: set[str] = set()221        for box in soup.select("div.square-box"):222            h5 = box.find("h5")223            if not h5:224                continue225            unit_raw = re.sub(r"\s+", " ", h5.get_text(" ", strip=True)).strip()226            unit_type = normalize_unit_type(unit_raw)227            raw_key = _slugify(unit_raw)228            if not unit_type or not raw_key or raw_key in seen_raw:229                continue230            seen_raw.add(raw_key)231            if unit_type not in seen_norm:232                seen_norm.add(unit_type)233                ext_id = f"{slug}-{unit_type.replace('½', '.5')}"234            else:                     # variante (« + DEN », parenthèse…)235                ext_id = f"{slug}-{raw_key}"236            price_label = re.sub(r"\s+", " ",237                                 box.get_text(" ", strip=True)238                                 .replace(unit_raw, "", 1)).strip()239            pm = re.search(r"(?:À partir de\s*)?([\d\s]+)\$\s*/?\s*mois",240                           price_label)241            price_label = (f"À partir de {pm.group(1).strip()}$/mois"242                           if pm else price_label[:60])243            results.append(Listing(244                source=self.source_id,245                external_id=ext_id,246                url=url,247                title=f"{name}{unit_raw}",248                address=address,249                sector=sector,250                city=city,251                unit_type=unit_type,252                price=parse_price(price_label),253                price_label=price_label,254                availability="",255                description=desc,256                amenities=amenities,257                details=dict(details),258                images=images,259                lat=lat,260                lng=lng,261            ))262263        # Immeuble sans grille de prix : annonce par immeuble quand même264        if not results:265            results.append(Listing(266                source=self.source_id,267                external_id=slug,268                url=url,269                title=name,270                address=address,271                sector=sector,272                city=city,273                description=desc,274                amenities=amenities,275                details=dict(details),276                images=images,277                lat=lat,278                lng=lng,279            ))280        return results281