# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/bons_locataires.py : Bons Locataires (bonslocataires.com) — agence # de location Montréal/Laval/Longueuil. WordPress, aucun anti-bot (seul # mod_security bloque les sitemaps XML — on ne les utilise pas). # Source de vérité : la page carte MapListPro (~8 Mo) dont le tableau JSON # inline contient les 1 600+ emplacements avec lat/lng, taxonomies # (région/quartier/taille) et description complète. Le site garde les # logements loués sur la carte (« DÉJÀ LOUÉ », pin PouceVert) : on ne # conserve que les annonces encore offertes, et on valide que leur page # (locationUrl) répond toujours 200 (certains vieux liens sont morts). # Prix et disponibilité en texte libre -> regex. ~20-40 annonces actives. # ----------------------------------------------------------------------------- from __future__ import annotations import html as H import json import re from ..schema import Listing, normalize_unit_type from .base import BaseConnector MAP_PAGE = ("https://www.bonslocataires.com/logement-appartement-a-louer-" "enquete-proprio-credit-locataire-visite-virtuelle-360/") _PRICE_RE = re.compile(r"(\d{3,4})\s*\$\s*(?:/|par)?\s*mois", re.I) _PRICE_ALT_RE = re.compile(r"(\d{3,4})\s*\$") _IMG_RE = re.compile(r'src="(https?://www\.bonslocataires\.com/wp-content/' r'uploads/[^"]+)"') _LOC_ID_RE = re.compile(r"loc-(\d+)") _TAG_RE = re.compile(r"<[^>]+>") def _texte(fragment: str) -> str: return re.sub(r"\s+\n", "\n", H.unescape(_TAG_RE.sub("", fragment or ""))).strip() class BonsLocatairesConnector(BaseConnector): source_id = "bons_locataires" request_delay = 1.0 timeout = 120 # la page carte fait ~8 Mo def _map_objects(self) -> list[dict]: import time html = "" for attempt in range(3): # mod_security coupe parfois la page 8 Mo try: html = self.get(MAP_PAGE).text break except Exception: if attempt == 2: raise time.sleep(20 * (attempt + 1)) i = html.find('[{"title":') if i < 0: return [] depth, end = 0, None for j in range(i, len(html)): if html[j] == "[": depth += 1 elif html[j] == "]": depth -= 1 if depth == 0: end = j + 1 break try: return json.loads(html[i:end]) except ValueError: return [] @staticmethod def _available(obj: dict) -> bool: blob = (obj.get("title", "") + (obj.get("description") or "") + (obj.get("icon") or "") + (obj.get("pinImageUrl") or "")) return "LOU" not in blob.upper() or "PouceVert" not in blob def fetch(self) -> list[Listing]: out: list[Listing] = [] for obj in self._map_objects(): desc_html = obj.get("description") or "" blob = (obj.get("title", "") + desc_html).upper() pin = (obj.get("pinImageUrl") or "") + (obj.get("icon") or "") if "DÉJÀ LOU" in blob or "DEJA LOU" in blob or "LOUÉ!" in blob \ or "PouceVert" in pin: continue url = (obj.get("locationUrl") or "").replace("http://", "https://") if not url: continue try: # vieux liens morts -> annonce écartée page_html = self.get(url).text except Exception: continue m = _LOC_ID_RE.search(obj.get("cssClass") or "") eid = m.group(1) if m else url.rstrip("/").rsplit("/", 1)[-1] cats = obj.get("customCategories") or {} def _first(tax: str) -> str: terms = cats.get(tax) or [] return terms[0].get("name", "") if terms else "" city = _first("map_location_categories_region") or "Montréal" sector = _first("map_location_categories_city") taille = _first("map_location_categories_taille") title = _texte(obj.get("title", "")).strip("* ") unit_type = normalize_unit_type(taille or title) if not re.match(r"^\d½$|^6½\+$|^Studio$|^Loft$|^Chambre", unit_type or ""): unit_type = "" # la fiche WordPress (déjà téléchargée pour la validation) est plus # riche que le popup carte : prix, galerie, disponibilité page_main = "" m = re.search(r'