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 · 164 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/celeste.py : connecteur CÉLESTE LaSalle (louerceleste.com)5#   815, rue Gagné, LaSalle (Montréal) — deux immeubles (A et B), 3½ à 5½.6#   WordPress/Avada : chaque unité est un « avada_portfolio » exposé par la7#   REST API (/wp-json/wp/v2/avada_portfolio, ~452 items FR+EN). Taxonomie8#   portfolio_category : 62/64/66 = types FR (3½/4½/5½), 60 = loué,9#   58 = disponible. Candidats = items FR marqués disponibles ou non marqués10#   loués ; la fiche fait foi (« Disponibilité: LOUÉ » -> exclu). La fiche11#   fournit grandeur, étage, salles de bain, « Loyer : à partir de $…/mois »,12#   détails/équipements et la galerie. Inventaire souvent nul (immeubles13#   pleins) : le connecteur retourne alors une liste vide, ce qui est correct.14# -----------------------------------------------------------------------------15from __future__ import annotations1617import re1819from bs4 import BeautifulSoup2021from ..schema import Listing22from .base import BaseConnector2324BASE = "https://louerceleste.com"25API = f"{BASE}/wp-json/wp/v2/avada_portfolio"26ADDRESS = "815, rue Gagné, LaSalle, Montréal, QC"2728CAT_TYPES_FR = {62: "3½", 64: "4½", 66: "5½"}   # types (taxonomie FR)29CAT_LOUE = 60                                    # marqué loué30CAT_DISPO = 58                                   # marqué disponible3132_PRICE_RE = re.compile(r"Loyer\s*:?\s*\|?\s*à partir de\s*\$?\s*([\d\s,]+)"33                       r"\s*/\s*mois", re.I)34_FLOOR_RE = re.compile(r"Étage\s*:\s*\|?\s*(\d+)", re.I)35_BATH_RE = re.compile(r"salles? de bain\s*:\s*\|?\s*([\d.]+)", re.I)36_SIZE_RE = re.compile(r"Grandeur\s*:\s*\|?\s*(\d\s*½|\d\s*1/2)", re.I)37_LOUE_RE = re.compile(r"Disponibilité\s*:\s*\|?\s*LOU[ÉE]", re.I)38_IMG_RE = re.compile(39    r'https://louerceleste\.com/wp-content/uploads/[^"\'\s\\)]+?'40    r'\.(?:jpg|jpeg|png|webp)', re.I)41_SKIP_IMG = re.compile(r"logo|icon|favicon|-\d{2,4}x\d{2,4}\.", re.I)424344class CelesteConnector(BaseConnector):45    source_id = "celeste"46    request_delay = 0.647    max_candidates = 40          # garde-fou de crawl des fiches48    max_images = 204950    # -- inventaire REST (toutes les unités, FR + EN) ---------------------------51    def _portfolio_items(self) -> list[dict]:52        items: list[dict] = []53        for page in range(1, 12):54            try:55                batch = self.get(API, params={56                    "per_page": "100", "page": str(page),57                    "_fields": "id,link,title,portfolio_category",58                }).json()59            except Exception:      # HTTP 400 = fin de pagination WP60                break61            if not isinstance(batch, list) or not batch:62                break63            items.extend(batch)64        return items6566    def fetch(self) -> list[Listing]:67        listings: list[Listing] = []68        count = 069        for it in self._portfolio_items():70            try:71                cats = set(it.get("portfolio_category") or [])72                unit_type = next((t for c, t in CAT_TYPES_FR.items()73                                  if c in cats), "")74                if not unit_type:75                    continue         # item EN ou hors taxonomie des types76                # candidat = marqué disponible, ou pas explicitement loué77                if CAT_DISPO not in cats and CAT_LOUE in cats:78                    continue79                if count >= self.max_candidates:80                    break81                count += 182                lst = self._parse_unit(it, unit_type)83                if lst is not None:84                    listings.append(lst)85            except Exception:86                continue87        return listings8889    # -- fiche de l'unité : la mention « LOUÉ » fait foi -------------------------90    def _parse_unit(self, it: dict, unit_type: str) -> Listing | None:91        url = it.get("link") or ""92        title = re.sub(r"<[^>]+>", "",93                       (it.get("title") or {}).get("rendered") or "").strip()94        if not url:95            return None96        html = self.get(url).text97        soup = BeautifulSoup(html, "html.parser")98        txt = soup.get_text(" | ", strip=True)99100        if _LOUE_RE.search(txt):101            return None              # loué malgré la taxonomie102103        price = None104        price_label = ""105        pm = _PRICE_RE.search(txt)106        if pm:107            digits = re.sub(r"[^\d]", "", pm.group(1))108            if digits:109                price = float(digits)110                price_label = f"À partir de {int(price)} $/mois"111        if price is not None and not (100 <= price <= 20000):112            price = None113114        bathrooms = None115        bm = _BATH_RE.search(txt)116        if bm:117            try:118                bathrooms = float(bm.group(1))119            except ValueError:120                bathrooms = None121122        details: dict = {}123        fm = _FLOOR_RE.search(txt)124        if fm:125            details["floor"] = fm.group(1)126        sm = _SIZE_RE.search(txt)127        if sm:                       # ex. « 3 ½ » (confirme le type)128            unit_type = sm.group(1).replace(" ", "").replace("1/2", "½")129130        # détails / équipements (listes de la fiche)131        amenities: list[str] = []132        for li in soup.select(".fusion-text li, .post-content li"):133            t = li.get_text(" ", strip=True)134            if t and len(t) < 80 and t not in amenities:135                amenities.append(t)136        amenities = amenities[:25]137138        images: list[str] = []139        for u in _IMG_RE.findall(html):140            if _SKIP_IMG.search(u):141                continue142            if u not in images:143                images.append(u)144        images = images[: self.max_images]145146        return Listing(147            source=self.source_id,148            external_id=str(it.get("id")),149            url=url,150            title=title or f"CÉLESTE — {unit_type}",151            address=ADDRESS,152            sector="LaSalle",153            city="Montréal",154            unit_type=unit_type,155            bathrooms=bathrooms,156            price=price,157            price_label=price_label,158            availability="Disponible",159            description="",160            amenities=amenities,161            details=details,162            images=images,163        )164