SPB Git

spb/lou-ka Public

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

HTML 99.7%
9.8 KB · 242 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/immeubles_dcl.py : connecteur Les Immeubles DCL5#   (lesimmeublesdcl.com — Rouyn-Noranda, Val-d'Or, Malartic ; volet logements6#   abordables). Site Laravel + **FleetCart** (catalogue e-commerce détourné7#   en vitrine locative, Vue.js côté client) : la page /logements-a-louer est8#   vide côté serveur, mais son API interne répond en JSON structuré —9#   POST /logements-a-louer (X-CSRF-TOKEN lu dans `window.FleetCart`, session10#   Laravel du GET initial) -> products.data : adresse, ville, code postal,11#   **GPS (lat_lng)**, prix, catégorie (typologie « 1 ½ »… -> unit_type) et12#   attributs (Ameublement -> meublé, Animaux -> pets, Pièces, Chambres,13#   Salle de bain, Commodités, Inclusions, Secteurs, Disponibilité). Fiche14#   /logements-a-louer/<slug> (via cache BD) : description et galerie complète15#   (images S3 `img.popup-img`). Les logements loués vivent dans un catalogue16#   séparé (/logements-loues) : jamais mélangés. robots.txt ouvert.17# -----------------------------------------------------------------------------18from __future__ import annotations1920import hashlib21import re22import time2324from bs4 import BeautifulSoup2526from ..schema import Listing, normalize_unit_type27from .base import BaseConnector2829BASE = "https://www.lesimmeublesdcl.com"30LIST_URL = f"{BASE}/logements-a-louer"3132_CSRF_RE = re.compile(r"csrfToken:\s*'([^']+)'")333435def _attr_values(product: dict) -> dict[str, list[str]]:36    """Attributs FleetCart du produit -> {nom: [valeurs]}."""37    out: dict[str, list[str]] = {}38    for attr in product.get("attributes") or []:39        name = (attr.get("name") or "").strip()40        vals = [v.get("value", "").strip()41                for v in attr.get("values") or [] if v.get("value")]42        if name and vals:43            out[name] = vals44    return out454647class ImmeublesDCLConnector(BaseConnector):48    source_id = "immeubles_dcl"49    request_delay = 0.650    max_details = 25     # garde-fou fiches détail (vraies requêtes par sync)51    max_pages = 10       # garde-fou pagination API5253    def _post_json(self, url: str, payload: dict, token: str):54        """POST JSON avec le même throttling poli que get()."""55        wait = self.request_delay - (time.time() - self._last_request)56        if wait > 0:57            time.sleep(wait)58        resp = self.session.post(59            url, json=payload, timeout=self.timeout,60            headers={"X-CSRF-TOKEN": token,61                     "X-Requested-With": "XMLHttpRequest",62                     "Accept": "application/json"})63        self._last_request = time.time()64        resp.raise_for_status()65        return resp6667    def fetch(self) -> list[Listing]:68        # 1) GET la page : cookie de session Laravel + jeton CSRF (window.FleetCart)69        html = self.get(LIST_URL).text70        m = _CSRF_RE.search(html)71        if not m:72            raise RuntimeError("csrfToken introuvable sur /logements-a-louer")73        token = m.group(1)7475        # 2) API products.index (JSON), paginée via last_page76        self._fetched = 077        listings: dict[str, Listing] = {}78        page = 179        while page <= self.max_pages:80            payload = {"query": "", "fromPrice": 0, "toPrice": 100000,81                       "perPage": 50, "page": page, "attribute": {},82                       "sort": "latest"}83            data = self._post_json(LIST_URL, payload, token).json()84            products = (data.get("products") or {})85            for prod in products.get("data") or []:86                try:87                    self._parse_product(prod, listings)88                except Exception:89                    continue90            if page >= int(products.get("last_page") or 1):91                break92            page += 193        return list(listings.values())9495    # -- produit FleetCart (JSON) --------------------------------------------------------96    def _parse_product(self, prod: dict, listings: dict[str, Listing]) -> None:97        ext_id = str(prod.get("id") or "")98        slug = prod.get("slug") or ""99        if not ext_id or not slug or ext_id in listings:100            return101        url = f"{LIST_URL}/{slug}"102103        name = (prod.get("name") or "").strip()104        address = (prod.get("address") or "").strip()105        city = (prod.get("city") or "").strip()106        zip_code = (prod.get("zip") or "").strip()107        full_address = ", ".join(p for p in (address, city, zip_code) if p)108109        # prix structuré (selling_price) + libellé du site (« 900 $CA »)110        price = None111        selling = prod.get("selling_price") or {}112        cur = selling.get("inCurrentCurrency") or {}113        if isinstance(cur.get("amount"), (int, float)):114            price = float(cur["amount"])115        # « 1 425 $CA » : espaces (fines) insécables -> espace simple116        price_label = re.sub(r"\s+", " ",117                             prod.get("formatted_price") or "").strip()118119        # typologie = catégorie du catalogue (« 1 ½ », « 4 ½ »…)120        unit_type = ""121        for cat in prod.get("categories") or []:122            cname = (cat.get("name") or "").strip()123            if re.match(r"^\d\s*½", cname):124                unit_type = normalize_unit_type(cname.replace("½", "1/2"))125                break126127        attrs = _attr_values(prod)128        availability = ", ".join(attrs.get("Disponibilité", []))129        sector = ", ".join(attrs.get("Secteurs", []))130131        # animaux : champ structuré du site (prudence : conditions si permis)132        pets = None133        pets_raw = " / ".join(attrs.get("Animaux", []))134        if pets_raw:135            if re.search(r"aucun|pas accept|non", pets_raw, re.I):136                pets = "non"137            elif re.search(r"accept|autoris", pets_raw, re.I):138                pets = "conditions"139140        furnished = None141        furn_raw = " ".join(attrs.get("Ameublement", []))142        if re.search(r"^non\s*meubl", furn_raw.strip(), re.I):143            furnished = False144        elif re.search(r"meubl", furn_raw, re.I):145            furnished = True146147        amenities: list[str] = []148        if attrs.get("Pièces"):149            amenities.append(f"{attrs['Pièces'][0]} pièce(s)")150        if attrs.get("Chambres à coucher"):151            amenities.append(f"{attrs['Chambres à coucher'][0]} chambre(s)")152        if attrs.get("Salle de bain"):153            amenities.append(f"{attrs['Salle de bain'][0]} salle(s) de bain")154        for grp in ("Commodités", "Commodités de l'immeuble"):155            amenities.extend(attrs.get(grp, []))156        if attrs.get("Inclusions"):157            amenities.append("Inclus : " + ", ".join(attrs["Inclusions"]))158159        # GPS structuré (lat_lng) — validé ensuite par finalize()160        lat = lng = None161        latlng = prod.get("lat_lng") or []162        if isinstance(latlng, list) and len(latlng) == 2:163            try:164                lat, lng = float(latlng[0]), float(latlng[1])165            except (TypeError, ValueError):166                lat = lng = None167168        images = [m_.get("path", "") for m_ in prod.get("media") or []169                  if isinstance(m_, dict) and m_.get("path", "").startswith("http")]170171        lst = Listing(172            source=self.source_id,173            external_id=ext_id,174            url=url,175            title=name,176            address=full_address,177            sector=sector,178            city=city,179            unit_type=unit_type,180            price=price,181            price_label=price_label,182            availability=availability,183            pets=pets,184            furnished=furnished,185            amenities=list(dict.fromkeys(amenities))[:25],186            images=images,187            lat=lat,188            lng=lng,189        )190191        key = hashlib.sha1(192            f"{name}|{price_label}|{availability}|{sector}"193            .encode("utf-8")).hexdigest()194        try:195            payload = self.detail(ext_id, key,196                                  lambda u=url: self._fetch_detail(u))197            self._apply_detail(lst, payload)198        except Exception:199            pass200        listings[ext_id] = lst201202    # -- fiche détail (/logements-a-louer/<slug>) ----------------------------------------203    def _fetch_detail(self, url: str) -> dict:204        """Description libre et galerie complète (images S3 du carrousel)."""205        if self._fetched >= self.max_details:206            raise RuntimeError("budget de fiches détail atteint")207        self._fetched += 1208        html = self.get(url).text209        soup = BeautifulSoup(html, "html.parser")210        out: dict = {}211212        # bloc « Description » : titre (h5) suivi du texte libre213        for h in soup.find_all(["h2", "h3", "h4", "h5"]):214            if h.get_text(strip=True).lower() == "description":215                parts = []216                for sib in h.find_next_siblings():217                    if sib.name in ("h2", "h3", "h4"):218                        break219                    parts.append(sib.get_text("\n", strip=True))220                txt = "\n".join(p for p in parts if p)221                if txt:222                    out["description"] = re.sub(r"[ \t]+", " ", txt).strip()[:1500]223                break224225        # galerie : liens pleine taille du carrousel (a.popup-img -> S3)226        images: list[str] = []227        for a in soup.select("a.popup-img[href]"):228            u = a["href"].strip()229            if u.startswith("http") and u not in images:230                images.append(u)231        out["images"] = images[:25]232        return out233234    def _apply_detail(self, lst: Listing, d: dict) -> None:235        """Reporte le payload (frais ou en cache) sur l'annonce."""236        if not d:237            return238        if d.get("description"):239            lst.description = d["description"]240        if d.get("images") and len(d["images"]) > len(lst.images):241            lst.images = d["images"]242