Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
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