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.3 KB · 149 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/lak_nature.py : connecteur LAK Appartements Nature5#   (lakappartementsnature.ca — 1004, boul. du Lac, Lac-Beauport, près de la6#    station Le Relais ; 62 unités locatives, gestion Acropole). WordPress +7#    Elementor avec sélecteur de plans « do-selecteur-plans » : chaque unité8#    est un <polygon> SVG rendu serveur avec sa classe d'état9#    (disponible/indisponible) et ses data-* (data-numero=id interne,10#    data-etage, data-type, data-prix, data-promotion). On n'ingère que les11#    unités DISPONIBLES ; la fiche JSON /wp-json/do-selecteur-plans/v1/unite12#    ?id=<numero> (via cache détail) confirme l'état et fournit le vrai13#    numéro d'unité, superficies, plan PDF et galerie.14# -----------------------------------------------------------------------------15from __future__ import annotations1617import hashlib18import re1920from bs4 import BeautifulSoup2122from ..schema import Listing, normalize_unit_type, parse_price23from .base import BaseConnector2425BASE = "https://lakappartementsnature.ca"26JSON_URL = f"{BASE}/wp-json/do-selecteur-plans/v1/unite"2728ADDRESS = "1004, boulevard du Lac, Lac-Beauport, G3B 2T4"29CITY = "Lac-Beauport"3031# photos de l'immeuble dans la page (hors logos/icônes/plans)32IMG_RE = re.compile(r'(?:src|href)="(https://lakappartementsnature\.ca/'33                    r'wp-content/uploads/[^"]+\.(?:jpe?g|png|webp))"', re.I)343536class LakNatureConnector(BaseConnector):37    source_id = "lak_nature"38    request_delay = 0.639    max_details = 40    # garde-fou fiches JSON (vraies requêtes par sync)4041    def fetch(self) -> list[Listing]:42        listings: list[Listing] = []43        html = self.get(f"{BASE}/").text44        soup = BeautifulSoup(html, "html.parser")45        self._fetched = 04647        building_images = [u for u in dict.fromkeys(IMG_RE.findall(html))48                           if not re.search(r"logo|icon|favicon|plan|Lak_plan",49                                            u, re.I)][:12]5051        details_common: dict = {}52        phones = dict.fromkeys(re.findall(r'href="tel:\+?1?(\d{10})"', html))53        if phones:54            d10 = next(iter(phones))55            details_common["contact"] = {56                "phone": f"{d10[:3]}-{d10[3:6]}-{d10[6:]}"}5758        # polygones SVG du sélecteur : un par unité (dupliqués desktop/mobile)59        seen: set[str] = set()60        for poly in soup.find_all("polygon"):61            try:62                classes = " ".join(poly.get("class") or [])63                numero = (poly.get("data-numero") or "").strip()64                if not numero or numero in seen:65                    continue66                seen.add(numero)67                # on n'ingère que les unités encore offertes68                if "indisponible" in classes or "disponible" not in classes:69                    continue7071                unit_type = normalize_unit_type(72                    poly.get("data-type") or "")73                price = parse_price(poly.get("data-prix") or "")74                floor = (poly.get("data-etage") or "").strip()75                promo = (poly.get("data-promotion") or "").strip()7677                # fiche JSON de l'unité : vrai numéro, état confirmé,78                # superficies, plan PDF, galerie79                ext_id = numero80                availability = "Disponible"81                area = None82                images = list(building_images)83                details = dict(details_common)84                amenities: list[str] = []85                key = hashlib.sha1(86                    f"{numero}|{classes}|{price}".encode()).hexdigest()87                try:88                    d = self.detail(numero, key,89                                    lambda n=numero: self._unite_json(n))90                except Exception:91                    d = {}92                if d:93                    if d.get("etat") and d["etat"] != "disponible":94                        continue    # l'API fait foi sur l'état95                    if d.get("unite"):96                        ext_id = str(d["unite"])97                    if d.get("date_libre"):98                        availability = str(d["date_libre"])99                    try:100                        v = float(str(d.get("superficie_habitable") or "")101                                  .replace(" ", "").replace(",", "."))102                        if 200 <= v <= 3000:103                            area = v104                    except ValueError:105                        pass106                    if d.get("superficie_terrasse"):107                        amenities.append(108                            f"Terrasse : {d['superficie_terrasse']} pi²")109                    if d.get("style"):110                        amenities.append(f"Style : {d['style']}")111                    if d.get("pdf"):112                        details["plan_pdf"] = d["pdf"]113                    gal = [u for u in (d.get("gallery") or [])114                           if isinstance(u, str)]115                    if gal:116                        images = gal + building_images117                if promo:118                    amenities.append(f"Promotion : {promo}")119                if floor:120                    amenities.append(f"Étage : {floor}")121122                listings.append(Listing(123                    source=self.source_id,124                    external_id=ext_id,125                    url=f"{BASE}/",126                    title=f"{unit_type} — unité {ext_id}, "127                          f"LAK Appartements Nature",128                    address=ADDRESS,129                    city=CITY,130                    unit_type=unit_type,131                    price=price,132                    availability=availability,133                    area_sqft=area,134                    amenities=amenities,135                    details=details,136                    images=images[:20],137                ))138            except Exception:139                continue140        return listings141142    # -- fiche JSON d'une unité (endpoint du plugin do-selecteur-plans) ----------143    def _unite_json(self, numero: str) -> dict:144        if self._fetched >= self.max_details:145            raise RuntimeError("budget de fiches JSON atteint")146        self._fetched += 1147        d = self.get(f"{JSON_URL}?id={numero}").json()148        return d if isinstance(d, dict) else {}149