# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/barnes_quebec.py : BARNES Québec (barnes-quebec.com) — LOCATIONS # Agence de prestige (Montréal, Québec, Laval, Mont-Tremblant…). Site # WordPress indexé dans Algolia ; la config expose l'App ID et une clé API # dans le HTML. L'index « quebec_all » mélange ventes (type "property") et # locations (type "rental") : on filtre côté serveur avec # facetFilters=[["type:rental"]]. Chaque location porte le loyer mensuel # numérique (property_rent), l'adresse, chambres/salles de bains, superficie # et géolocalisation. Les enregistrements sont dupliqués par langue (FR/EN) → # on dédoublonne par titre normalisé en gardant la fiche FR. Adapté du # connecteur « à vendre » d'Immo-Ka (agent-courtage/immoka). # ----------------------------------------------------------------------------- from __future__ import annotations import os import re from ..schema import Listing from .base import BaseConnector from . import _detailutil as du APP_ID = "HCW55VIQNM" # clé « search-only » publiée dans le HTML (var AlgoliaPHPVars) — elle TOURNE # (rotation constatée 2026-08-29 : 403 sur l'ancienne clé). On la relit à # chaque sync depuis /louer/ ; cette constante n'est qu'un secours. API_KEY_FALLBACK = "6319a160319545bfd5dafbb01ffa4ca6" _API_KEY_RE = re.compile(r'"algolia_search_api_key"\s*:\s*"([0-9a-f]{16,64})"') INDEX = "quebec_all" QUERY_URL = f"https://{APP_ID}-dsn.algolia.net/1/indexes/{INDEX}/query" SITE = "https://barnes-quebec.com" STAGING = "stg-quebec-staging.kinsta.cloud" # permaliens parfois en staging HITS_PER_PAGE = 100 MAX_PAGES = 40 # facetFilters=[["type:rental"]] URL-encodé (le corps Algolia est un query string) FACET_RENTAL = "facetFilters=%5B%5B%22type%3Arental%22%5D%5D" DETAIL_LIMIT = int(os.environ.get("LOUKA_BARNES_DETAIL_LIMIT", "200")) # Galerie WordPress : .../wp-content/uploads/AAAA/MM/{ref}-{hash}-{L}x{H}.jpg _IMG_RE = re.compile( r'https://barnes-quebec\.com/wp-content/uploads/\d{4}/\d{2}/[^"\'\\ ]+?\.(?:jpg|jpeg|png|webp)', re.I) _SIZE_RE = re.compile(r'-(\d{2,4})x(\d{2,4})(?=\.[a-z]+$)', re.I) # ville Algolia au format « Montréal (Rosemont/La Petite-Patrie) » _CITY_SECTOR_RE = re.compile(r"^(.*?)\s*\(([^)]+)\)\s*$") # caractéristiques de la fiche Barnes (table Centris rendue « libellé | valeur » # uniquement — l'ordre inverse capte la table des pièces et produit du bruit) _DETAIL_LABELS = [ "Année de construction", "Superficie habitable", "Superficie du terrain", "Stationnement", "Garage", "Mode de chauffage", "Énergie pour le chauffage", "Piscine", "Vue", "Zonage", "Date d'emménagement", "Bail", "Meublé", "Animaux", "Inclus dans le loyer", ] class BarnesQuebecConnector(BaseConnector): source_id = "barnes_quebec" request_delay = 0.25 def fetch(self) -> list[Listing]: self._api_key = self._resolve_api_key() # une location apparaît en plusieurs langues (FR/EN) avec des objectID # distincts mais le même titre. Clé stable = titre normalisé ; on garde # la meilleure fiche (loyer connu + FR). best: dict[str, tuple[int, Listing]] = {} page = 0 while page < MAX_PAGES: data = self._query(page) hits = data.get("hits", []) if not hits: break for h in hits: if h.get("type") != "rental": # ceinture + bretelles continue lst = self._to_listing(h) if lst is None: continue key = " ".join(lst.title.split()).lower() score = (2 if lst.price else 0) + (1 if h.get("lang_fr") == 1 else 0) if key not in best or score > best[key][0]: best[key] = (score, lst) if page + 1 >= data.get("nbPages", 0): break page += 1 listings = [lst for _, lst in best.values()] # Algolia n'expose qu'une photo : la galerie vient de la fiche. du.enrich(self, listings, DETAIL_LIMIT, parse_barnes_detail, key="v1") return listings def _resolve_api_key(self) -> str: """Relit la clé search dans le HTML de /louer/ (elle tourne).""" try: m = _API_KEY_RE.search(self.get(SITE + "/louer/").text) if m: return m.group(1) except Exception: # noqa: BLE001 — page indisponible : on tente le secours pass return API_KEY_FALLBACK def _query(self, page: int) -> dict: resp = self.post( QUERY_URL, headers={"X-Algolia-API-Key": self._api_key, "X-Algolia-Application-Id": APP_ID, "Content-Type": "application/json"}, json={"params": f"hitsPerPage={HITS_PER_PAGE}&page={page}&{FACET_RENTAL}"}, ) return resp.json() def _to_listing(self, h: dict) -> Listing | None: object_id = str(h.get("objectID") or "") mls = str(h.get("property_mls_reference") or "").strip() if not object_id and not mls: return None permalink = (h.get("permalink") or "").replace(STAGING, "barnes-quebec.com") if permalink.startswith("http://"): permalink = "https://" + permalink[len("http://"):] # loyer mensuel : champ numérique dédié (property_price est null pour # les locations). property_pretty_rent = libellé propre sans HTML. rent = h.get("property_rent") or 0 try: rent = float(rent) except (TypeError, ValueError): rent = 0.0 price_label = (h.get("property_pretty_rent") or "").strip() # « Montréal (Rosemont/La Petite-Patrie) » -> ville + secteur city_raw = (h.get("property_address_city") or "").strip() m = _CITY_SECTOR_RE.match(city_raw) city, sector = (m.group(1), m.group(2)) if m else (city_raw, "") regions = h.get("regions") or [] if not city and regions: # rare : fiche sans ville renseignée city = str(regions[0]).strip() # le titre concatène adresse + ville (+ région) : on isole la rue title = (h.get("title") or "").strip() address = title if city_raw and city_raw in title: address = title[:title.find(city_raw)].strip(" ,-") beds = _pos(h.get("property_bedrooms_integer")) unit_type = "" if beds is not None: unit_type = "Studio" if beds == 0 else f"{beds} chambres" lat = h.get("property_address_latitude") or None lng = h.get("property_address_longitude") or None try: lat = float(lat) if lat else None lng = float(lng) if lng else None except (TypeError, ValueError): lat = lng = None if lat == 0 or lng == 0: lat = lng = None details: dict = {"Agence": "BARNES Québec"} if mls: details["No Centris"] = mls baths = _pos(h.get("property_bathrooms")) if baths is not None: details["Salles de bain"] = str(baths) rooms = _pos(h.get("property_rooms")) if rooms is not None: details["Nombre de pièces"] = str(rooms) ptype = (h.get("property_type") or "").strip() if ptype: details["Type de propriété"] = ptype if regions: details["Région"] = str(regions[0]) # image « liste » (la galerie complète vient de la fiche détail) images = [] for k in ("image_full", "image_large", "image_medium"): if h.get(k): images = [h[k]] break return Listing( source=self.source_id, external_id=mls or object_id, url=permalink or SITE + "/rental/", title=title, address=address, sector=sector, city=city, unit_type=unit_type, price=rent if rent > 0 else None, # explicite : pas de plafond parse_price price_label=price_label, area_sqft=_posf(h.get("property_area")), description=(h.get("content") or "")[:4000], details=details, images=images, lat=lat, lng=lng, ) def _pos(v): try: n = float(v) return int(n) if n and n > 0 else None except (TypeError, ValueError): return None def _posf(v): try: n = float(v) return n if n > 0 else None except (TypeError, ValueError): return None def parse_barnes_detail(html: str) -> dict: """Galerie photo pleine résolution (absente d'Algolia) + description/pièces.""" out: dict = {} # regroupe par image de base (sans le suffixe -LxH), garde la plus grande best: dict[str, tuple[int, str]] = {} for u in _IMG_RE.findall(html): m = _SIZE_RE.search(u) area = int(m.group(1)) * int(m.group(2)) if m else 10 ** 8 # sans suffixe = original base = _SIZE_RE.sub("", u) if base not in best or area > best[base][0]: best[base] = (area, u) imgs = [u for _, u in best.values()] # ignore les vignettes de courtiers/logos (les photos gardent la réf Centris chiffrée) imgs = [u for u in imgs if re.search(r"/\d{6,}", u)] or imgs if imgs: out["images"] = imgs[:60] desc = du.ld_description(html) if desc: out["description"] = desc text = du.flatten(html) det: dict = {} for label in _DETAIL_LABELS: m = re.search(re.escape(label) + r"\b\s*\|\s*([^|]{1,55})", text) if m: val = m.group(1).strip(" |,") if val and 1 <= len(val) <= 55 and val.lower() != label.lower(): det[label] = val if det: out.setdefault("details", {}).update(det) return out