SPB Git

spb/lou-ka Public

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

HTML 99.7%
10.3 KB · 244 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/boreal_abitibi.py : connecteur Constructions Boréal Abitibi5#   (constructionsborealabitibi.com — Val-d'Or et Malartic, logements neufs ;6#   seul acteur structuré à Malartic). WordPress + thème maison Tailwind : la7#   grille /locations/ est remplie en AJAX (admin-ajax.php, action=get_rents,8#   nonce lu dans le HTML de la page). Chaque « rent » est une carte HTML9#   `article.listing-item` : ville (Val-d'Or/Malartic), adresse + n° d'app.,10#   prix (« 1 550 $ / mois »), « Disponible en août », chambres/salles de11#   bain/garage (pictos), « Non meublé, non chauffé, non éclairé ». Fiche12#   détail /loyer/<slug>/ (via cache BD) : Grandeur (4 et demi), étage,13#   parking, inclusions, description et galerie ; la mention « logements14#   non-fumeur / aucun animal » du site alimente `pets`.15#   robots.txt WP standard (admin-ajax.php explicitement permis), sitemap XML.16# -----------------------------------------------------------------------------17from __future__ import annotations1819import hashlib20import json21import re22import time2324from bs4 import BeautifulSoup2526from ..schema import Listing, normalize_unit_type, parse_price27from .base import BaseConnector2829BASE = "https://constructionsborealabitibi.com"30LIST_URL = f"{BASE}/locations/"31AJAX_URL = f"{BASE}/wp-admin/admin-ajax.php"3233# suffixe de redimensionnement WordPress (« -512x384.jpg » -> pleine taille)34_SIZE_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I)35_AJAX_OBJECT_RE = re.compile(r"var ajax_object\s*=\s*(\{.*?\})\s*;?", re.S)36# « 4 et demi » (fiche) -> « 4 1/2 » pour la normalisation commune37_ET_DEMI_RE = re.compile(r"^(\d)\s*et\s*demi$", re.I)383940class BorealAbitibiConnector(BaseConnector):41    source_id = "boreal_abitibi"42    request_delay = 0.643    max_details = 30     # garde-fou fiches détail (vraies requêtes par sync)44    max_pages = 10       # garde-fou pagination AJAX4546    def _post(self, url: str, data: dict):47        """POST avec le même throttling poli que get()."""48        wait = self.request_delay - (time.time() - self._last_request)49        if wait > 0:50            time.sleep(wait)51        resp = self.session.post(url, data=data, timeout=self.timeout)52        self._last_request = time.time()53        resp.raise_for_status()54        return resp5556    def fetch(self) -> list[Listing]:57        # 1) page /locations/ : récupérer le nonce AJAX (grille vide côté serveur)58        html = self.get(LIST_URL).text59        m = _AJAX_OBJECT_RE.search(html)60        if not m:61            raise RuntimeError("ajax_object introuvable sur /locations/")62        cfg = json.loads(m.group(1))63        nonce = cfg.get("nonce", "")6465        # 2) admin-ajax get_rents, paginé (l'inventaire complet, toutes villes)66        self._fetched = 067        listings: dict[str, Listing] = {}68        page = 169        while page <= self.max_pages:70            data = {71                "action": "get_rents", "nonce": nonce, "page": str(page),72                "posts_per_page": "12", "town": "", "show_all": "",73            }74            payload = self._post(AJAX_URL, data).json()75            for card_html in payload.get("rents") or []:76                try:77                    self._parse_card(BeautifulSoup(card_html, "html.parser"),78                                     listings)79                except Exception:80                    continue81            if page >= int(payload.get("max_num_pages") or 1):82                break83            page += 184        return list(listings.values())8586    # -- carte (article.listing-item retournée par get_rents) ---------------------------87    def _parse_card(self, card, listings: dict[str, Listing]) -> None:88        link = card.select_one('a[href*="/loyer/"]')89        if not link:90            return91        url = link["href"].strip()92        m = re.search(r"/loyer/([^/?#]+)", url)93        if not m:94            return95        ext_id = m.group(1).strip("/")96        if not ext_id or ext_id in listings:97            return9899        # ville (« Val-d'Or », « Malartic ») + adresse et n° d'appartement100        city_el = card.select_one("div.h4 strong")101        city = city_el.get_text(strip=True) if city_el else ""102        title_el = card.select_one("h3 a")103        title = re.sub(r"\s+", " ",104                       title_el.get_text(" ", strip=True)) if title_el else ""105        address = f"{title}, {city}" if title and city else title106107        # rangée prix / disponibilité : « 1 550 $ / mois » | « Disponible en août »108        price_label = availability = ""109        for div in card.select("div"):110            txt = re.sub(r"\s+", " ", div.get_text(" ", strip=True))111            if not div.find("div") and re.match(r"^\d[\d\s]*\$\s*/\s*mois$", txt, re.I):112                price_label = txt113            elif not div.find("div") and re.match(r"^Disponible", txt, re.I):114                availability = txt115116        # pictos : chambres / salles de bain / garage117        amenities: list[str] = []118        for icon, label in (("icon-bedroom", "chambre(s)"),119                            ("icon-bathroom", "salle(s) de bain"),120                            ("icon-garage", "garage/stationnement")):121            ic = card.select_one(f"span.{icon}")122            if ic:123                val = ic.find_next_sibling("span")124                if val and val.get_text(strip=True):125                    amenities.append(f"{val.get_text(strip=True)} {label}")126        # mention « Non meublé, non chauffé, non éclairé »127        for div in card.select("div.self-end"):128            txt = div.get_text(" ", strip=True)129            if txt:130                amenities.append(txt)131132        # photo de couverture (le lazy-load expose data-srcset avec la pleine taille)133        images: list[str] = []134        img = card.select_one("img[data-srcset], img[data-src], img[src]")135        if img:136            srcset = img.get("data-srcset") or ""137            candidates = re.findall(r"(https?://\S+)\s+(\d+)w", srcset)138            if candidates:139                images.append(max(candidates, key=lambda c: int(c[1]))[0])140            else:141                u = img.get("data-src") or img.get("src") or ""142                if u.startswith("http"):143                    images.append(_SIZE_SUFFIX.sub("", u))144145        lst = Listing(146            source=self.source_id,147            external_id=ext_id,148            url=url,149            title=f"{title}{city}".strip(" —"),150            address=address,151            city=city,152            price=parse_price(price_label),153            price_label=price_label,154            availability=availability,155            amenities=amenities,156            images=images,157        )158159        key = hashlib.sha1(160            f"{title}|{price_label}|{availability}|{city}"161            .encode("utf-8")).hexdigest()162        try:163            payload = self.detail(ext_id, key,164                                  lambda u=url: self._fetch_detail(u))165            self._apply_detail(lst, payload)166        except Exception:167            pass168        listings[ext_id] = lst169170    # -- fiche détail (/loyer/<slug>/) ---------------------------------------------------171    def _fetch_detail(self, url: str) -> dict:172        """Grandeur, étage, parking, inclusions, description, galerie, animaux."""173        if self._fetched >= self.max_details:174            raise RuntimeError("budget de fiches détail atteint")175        self._fetched += 1176        html = self.get(url).text177        soup = BeautifulSoup(html, "html.parser")178        out: dict = {}179180        # tableau de specs : rangées h2 (libellé) / div (valeur)181        fields: dict[str, str] = {}182        for row in soup.find_all("div", class_="lg:table-row"):183            h2 = row.find("h2")184            val = row.find("div")185            if h2 and val:186                fields[h2.get_text(strip=True).lower()] = \187                    re.sub(r"\s+", " ", val.get_text(" ", strip=True))188        out["fields"] = fields189190        # description : bloc texte libre (proximité écoles/épiceries…)191        desc = ""192        for div in soup.select("div.p-10"):193            txt = div.get_text("\n", strip=True)194            if re.search(r"proximit[ée]", txt, re.I) and len(txt) > len(desc):195                desc = txt196        if desc:197            out["description"] = re.sub(r"[ \t]+", " ", desc).strip()[:1500]198199        # mention transversale affichée sur chaque fiche200        if re.search(r"N[’']ACCEPTONS\s+PAS\s+LES\s+ANIMAUX",201                     soup.get_text(" ", strip=True), re.I):202            out["pets"] = "non"203204        # galerie : images lazy-load (lozad) -> data-src, pleine taille205        images: list[str] = []206        for img in soup.select('img[data-src*="/wp-content/uploads/"], '207                               'img[src*="/wp-content/uploads/"]'):208            u = _SIZE_SUFFIX.sub("", img.get("data-src") or img.get("src") or "")209            if u.startswith("http") and u not in images:210                images.append(u)211        out["images"] = images[:25]212        return out213214    def _apply_detail(self, lst: Listing, d: dict) -> None:215        """Reporte le payload (frais ou en cache) sur l'annonce."""216        if not d:217            return218        fields = d.get("fields") or {}219        grandeur = fields.get("grandeur", "")220        m = _ET_DEMI_RE.match(grandeur)221        if m:222            lst.unit_type = normalize_unit_type(f"{m.group(1)} 1/2")223        elif grandeur:224            lst.unit_type = normalize_unit_type(grandeur)225226        extra: list[str] = []227        if fields.get("étage") or fields.get("etage"):228            extra.append(f"Étage : {fields.get('étage') or fields.get('etage')}")229        if fields.get("parking"):230            extra.append(f"Stationnement : {fields['parking']}")231        if fields.get("inclusions"):232            extra.append(f"Inclus : {fields['inclusions']}")233        if extra:234            lst.amenities = list(dict.fromkeys(lst.amenities + extra))235236        if not lst.availability and fields.get("disponible"):237            lst.availability = f"Disponible {fields['disponible']}"238        if d.get("description"):239            lst.description = d["description"]240        if d.get("pets"):241            lst.pets = d["pets"]242        if d.get("images") and len(d["images"]) > len(lst.images):243            lst.images = d["images"]244