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%
9.9 KB · 249 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/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