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/barnes_quebec.py : BARNES Québec (barnes-quebec.com) — LOCATIONS5# Agence de prestige (Montréal, Québec, Laval, Mont-Tremblant…). Site6# WordPress indexé dans Algolia ; la config expose l'App ID et une clé API7# dans le HTML. L'index « quebec_all » mélange ventes (type "property") et8# locations (type "rental") : on filtre côté serveur avec9# facetFilters=[["type:rental"]]. Chaque location porte le loyer mensuel10# numérique (property_rent), l'adresse, chambres/salles de bains, superficie11# et géolocalisation. Les enregistrements sont dupliqués par langue (FR/EN) →12# on dédoublonne par titre normalisé en gardant la fiche FR. Adapté du13# connecteur « à vendre » d'Immo-Ka (agent-courtage/immoka).14# -----------------------------------------------------------------------------15from __future__ import annotations1617import os18import re1920from ..schema import Listing21from .base import BaseConnector2223from . import _detailutil as du2425APP_ID = "HCW55VIQNM"26# clé « search-only » publiée dans le HTML (var AlgoliaPHPVars) — elle TOURNE27# (rotation constatée 2026-08-29 : 403 sur l'ancienne clé). On la relit à28# chaque sync depuis /louer/ ; cette constante n'est qu'un secours.29API_KEY_FALLBACK = "6319a160319545bfd5dafbb01ffa4ca6"30_API_KEY_RE = re.compile(r'"algolia_search_api_key"\s*:\s*"([0-9a-f]{16,64})"')31INDEX = "quebec_all"32QUERY_URL = f"https://{APP_ID}-dsn.algolia.net/1/indexes/{INDEX}/query"33SITE = "https://barnes-quebec.com"34STAGING = "stg-quebec-staging.kinsta.cloud" # permaliens parfois en staging35HITS_PER_PAGE = 10036MAX_PAGES = 4037# facetFilters=[["type:rental"]] URL-encodé (le corps Algolia est un query string)38FACET_RENTAL = "facetFilters=%5B%5B%22type%3Arental%22%5D%5D"39DETAIL_LIMIT = int(os.environ.get("LOUKA_BARNES_DETAIL_LIMIT", "200"))4041# Galerie WordPress : .../wp-content/uploads/AAAA/MM/{ref}-{hash}-{L}x{H}.jpg42_IMG_RE = re.compile(43 r'https://barnes-quebec\.com/wp-content/uploads/\d{4}/\d{2}/[^"\'\\ ]+?\.(?:jpg|jpeg|png|webp)',44 re.I)45_SIZE_RE = re.compile(r'-(\d{2,4})x(\d{2,4})(?=\.[a-z]+$)', re.I)46# ville Algolia au format « Montréal (Rosemont/La Petite-Patrie) »47_CITY_SECTOR_RE = re.compile(r"^(.*?)\s*\(([^)]+)\)\s*$")4849# caractéristiques de la fiche Barnes (table Centris rendue « libellé | valeur »50# uniquement — l'ordre inverse capte la table des pièces et produit du bruit)51_DETAIL_LABELS = [52 "Année de construction", "Superficie habitable", "Superficie du terrain",53 "Stationnement", "Garage", "Mode de chauffage", "Énergie pour le chauffage",54 "Piscine", "Vue", "Zonage", "Date d'emménagement", "Bail", "Meublé",55 "Animaux", "Inclus dans le loyer",56]575859class BarnesQuebecConnector(BaseConnector):60 source_id = "barnes_quebec"61 request_delay = 0.256263 def fetch(self) -> list[Listing]:64 self._api_key = self._resolve_api_key()65 # une location apparaît en plusieurs langues (FR/EN) avec des objectID66 # distincts mais le même titre. Clé stable = titre normalisé ; on garde67 # la meilleure fiche (loyer connu + FR).68 best: dict[str, tuple[int, Listing]] = {}69 page = 070 while page < MAX_PAGES:71 data = self._query(page)72 hits = data.get("hits", [])73 if not hits:74 break75 for h in hits:76 if h.get("type") != "rental": # ceinture + bretelles77 continue78 lst = self._to_listing(h)79 if lst is None:80 continue81 key = " ".join(lst.title.split()).lower()82 score = (2 if lst.price else 0) + (1 if h.get("lang_fr") == 1 else 0)83 if key not in best or score > best[key][0]:84 best[key] = (score, lst)85 if page + 1 >= data.get("nbPages", 0):86 break87 page += 188 listings = [lst for _, lst in best.values()]89 # Algolia n'expose qu'une photo : la galerie vient de la fiche.90 du.enrich(self, listings, DETAIL_LIMIT, parse_barnes_detail, key="v1")91 return listings9293 def _resolve_api_key(self) -> str:94 """Relit la clé search dans le HTML de /louer/ (elle tourne)."""95 try:96 m = _API_KEY_RE.search(self.get(SITE + "/louer/").text)97 if m:98 return m.group(1)99 except Exception: # noqa: BLE001 — page indisponible : on tente le secours100 pass101 return API_KEY_FALLBACK102103 def _query(self, page: int) -> dict:104 resp = self.post(105 QUERY_URL,106 headers={"X-Algolia-API-Key": self._api_key,107 "X-Algolia-Application-Id": APP_ID,108 "Content-Type": "application/json"},109 json={"params": f"hitsPerPage={HITS_PER_PAGE}&page={page}&{FACET_RENTAL}"},110 )111 return resp.json()112113 def _to_listing(self, h: dict) -> Listing | None:114 object_id = str(h.get("objectID") or "")115 mls = str(h.get("property_mls_reference") or "").strip()116 if not object_id and not mls:117 return None118119 permalink = (h.get("permalink") or "").replace(STAGING, "barnes-quebec.com")120 if permalink.startswith("http://"):121 permalink = "https://" + permalink[len("http://"):]122123 # loyer mensuel : champ numérique dédié (property_price est null pour124 # les locations). property_pretty_rent = libellé propre sans HTML.125 rent = h.get("property_rent") or 0126 try:127 rent = float(rent)128 except (TypeError, ValueError):129 rent = 0.0130 price_label = (h.get("property_pretty_rent") or "").strip()131132 # « Montréal (Rosemont/La Petite-Patrie) » -> ville + secteur133 city_raw = (h.get("property_address_city") or "").strip()134 m = _CITY_SECTOR_RE.match(city_raw)135 city, sector = (m.group(1), m.group(2)) if m else (city_raw, "")136 regions = h.get("regions") or []137 if not city and regions: # rare : fiche sans ville renseignée138 city = str(regions[0]).strip()139140 # le titre concatène adresse + ville (+ région) : on isole la rue141 title = (h.get("title") or "").strip()142 address = title143 if city_raw and city_raw in title:144 address = title[:title.find(city_raw)].strip(" ,-")145146 beds = _pos(h.get("property_bedrooms_integer"))147 unit_type = ""148 if beds is not None:149 unit_type = "Studio" if beds == 0 else f"{beds} chambres"150151 lat = h.get("property_address_latitude") or None152 lng = h.get("property_address_longitude") or None153 try:154 lat = float(lat) if lat else None155 lng = float(lng) if lng else None156 except (TypeError, ValueError):157 lat = lng = None158 if lat == 0 or lng == 0:159 lat = lng = None160161 details: dict = {"Agence": "BARNES Québec"}162 if mls:163 details["No Centris"] = mls164 baths = _pos(h.get("property_bathrooms"))165 if baths is not None:166 details["Salles de bain"] = str(baths)167 rooms = _pos(h.get("property_rooms"))168 if rooms is not None:169 details["Nombre de pièces"] = str(rooms)170 ptype = (h.get("property_type") or "").strip()171 if ptype:172 details["Type de propriété"] = ptype173 if regions:174 details["Région"] = str(regions[0])175176 # image « liste » (la galerie complète vient de la fiche détail)177 images = []178 for k in ("image_full", "image_large", "image_medium"):179 if h.get(k):180 images = [h[k]]181 break182183 return Listing(184 source=self.source_id,185 external_id=mls or object_id,186 url=permalink or SITE + "/rental/",187 title=title,188 address=address,189 sector=sector,190 city=city,191 unit_type=unit_type,192 price=rent if rent > 0 else None, # explicite : pas de plafond parse_price193 price_label=price_label,194 area_sqft=_posf(h.get("property_area")),195 description=(h.get("content") or "")[:4000],196 details=details,197 images=images,198 lat=lat,199 lng=lng,200 )201202203def _pos(v):204 try:205 n = float(v)206 return int(n) if n and n > 0 else None207 except (TypeError, ValueError):208 return None209210211def _posf(v):212 try:213 n = float(v)214 return n if n > 0 else None215 except (TypeError, ValueError):216 return None217218219def parse_barnes_detail(html: str) -> dict:220 """Galerie photo pleine résolution (absente d'Algolia) + description/pièces."""221 out: dict = {}222 # regroupe par image de base (sans le suffixe -LxH), garde la plus grande223 best: dict[str, tuple[int, str]] = {}224 for u in _IMG_RE.findall(html):225 m = _SIZE_RE.search(u)226 area = int(m.group(1)) * int(m.group(2)) if m else 10 ** 8 # sans suffixe = original227 base = _SIZE_RE.sub("", u)228 if base not in best or area > best[base][0]:229 best[base] = (area, u)230 imgs = [u for _, u in best.values()]231 # ignore les vignettes de courtiers/logos (les photos gardent la réf Centris chiffrée)232 imgs = [u for u in imgs if re.search(r"/\d{6,}", u)] or imgs233 if imgs:234 out["images"] = imgs[:60]235 desc = du.ld_description(html)236 if desc:237 out["description"] = desc238 text = du.flatten(html)239 det: dict = {}240 for label in _DETAIL_LABELS:241 m = re.search(re.escape(label) + r"\b\s*\|\s*([^|]{1,55})", text)242 if m:243 val = m.group(1).strip(" |,")244 if val and 1 <= len(val) <= 55 and val.lower() != label.lower():245 det[label] = val246 if det:247 out.setdefault("details", {}).update(det)248 return out249