# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/kijiji.py : Kijiji (kijiji.ca) — petites annonces de LOCATION # UNIQUEMENT les catégories logement À LOUER, UNIQUEMENT le Québec (l9001) : # c37 appartements & condos à louer · c36 chambres à louer & colocation # Les pages listent 40+ annonces dans __NEXT_DATA__ (Apollo state) avec titre, # prix, GPS, adresse, date de disponibilité et attributs (meublé, animaux, # inclusions…) — aucune API privée nécessaire. Adapté du connecteur « à # vendre » d'Immo-Ka (agent-courtage/immoka). # ----------------------------------------------------------------------------- from __future__ import annotations import json import os import re from ..schema import Listing from .base import BaseConnector from . import _detailutil as du BASE = "https://www.kijiji.ca" # (code catégorie, segment d'URL, type d'unité par défaut) CATEGORIES = [ (37, "b-appartement-condo", ""), # unité déduite des attributs (36, "b-chambre-a-louer-colocataire", "Chambre"), ] MAX_PAGES = int(os.environ.get("LOUKA_KIJIJI_MAX_PAGES", "100")) DETAIL_LIMIT = int(os.environ.get("LOUKA_KIJIJI_DETAIL_LIMIT", "400")) # les annonces vivent sous des clés Apollo « RealEstateListing:123 » (c37) # ou « StandardListing:123 » (c36) _LISTING_KEY_RE = re.compile(r"^(?:RealEstate|Standard)Listing:\d+$") _NEXT_RE = re.compile( r'', re.S) # attributs binaires -> commodité affichable (uniquement si la valeur est vraie) _AMENITY_LABELS = { "heat": "Chauffage inclus", "hydro": "Électricité incluse", "water": "Eau incluse", "internet": "Internet inclus", "cabletv": "Câble/télé inclus", "laundryinunit": "Laveuse/sécheuse dans l'unité", "laundryinbuilding": "Buanderie dans l'immeuble", "dishwasher": "Lave-vaisselle", "fridgefreezer": "Réfrigérateur/congélateur", "airconditioning": "Air climatisé", "balcony": "Balcon", "elevator": "Ascenseur", "gym": "Salle d'entraînement", "pool": "Piscine", "concierge": "Concierge", "twentyfourhoursecurity": "Sécurité 24 h", "storagelocker": "Espace de rangement", "bicycleparking": "Stationnement pour vélo", "yard": "Cour", "wheelchairaccessible": "Accessible en fauteuil roulant", } _UNIT_TYPES = { "apartment": "Appartement", "condo": "Condo", "basement-apartment": "Appartement au sous-sol", "house": "Maison", "townhouse": "Maison de ville", "duplex-triplex": "Duplex/Triplex", } _AGREEMENTS = {"one-year": "Bail de 1 an", "month-to-month": "Au mois", "not-available": ""} # villes fréquentes sans accents dans les adresses Kijiji _CITY_FIX = { "montreal": "Montréal", "quebec": "Québec", "levis": "Lévis", "trois-rivieres": "Trois-Rivières", "riviere-des-prairies": "Montréal", "ville de montreal": "Montréal", "ville de quebec": "Québec", } def _fix_city(raw: str) -> str: key = (raw or "").strip().lower() if key in _CITY_FIX: return _CITY_FIX[key] return " ".join(w.capitalize() for w in key.replace("-", " ").split()) def _attr_value(a: dict) -> str: """Première valeur d'un attribut Apollo (canonique, sinon affichée).""" for k in ("canonicalValues", "values"): vals = a.get(k) or [] if vals: return str(vals[0]) return "" def _apply_attrs(attrs: list[dict], out: dict) -> None: """Interprète les attributs Kijiji (mêmes clés en liste et en fiche).""" amenities = out.setdefault("amenities", []) details = out.setdefault("details", {}) for a in attrs or []: cn = a.get("canonicalName") or "" val = _attr_value(a) if not val: continue if cn in _AMENITY_LABELS: if val == "1": amenities.append(_AMENITY_LABELS[cn]) elif cn == "furnished": out["furnished"] = val == "1" elif cn == "petsallowed": out["pets"] = "oui" if val == "1" else "non" elif cn == "numberbedrooms": out["bedrooms"] = val # '0' = studio, sinon nb de chambres elif cn == "numberbathrooms": try: # canonique en dixièmes : '15' = 1.5 n = int(val) / 10 details["Salles de bain"] = f"{n:g}" except ValueError: pass elif cn in ("areainfeet", "sizesqft"): m = re.search(r"[\d.]+", val.replace(",", "")) if m and float(m.group(0)) > 0: out["area_sqft"] = float(m.group(0)) elif cn == "dateavailable": m = re.match(r"(\d{4}-\d{2}-\d{2})", val) if m: out["availability_date"] = m.group(1) elif cn == "unittype": details["Type d'unité"] = _UNIT_TYPES.get(val, val) elif cn == "agreementtype": bail = _AGREEMENTS.get(val, val) if bail: details["Bail"] = bail elif cn == "numberparkingspots" and val.isdigit() and int(val) > 0: amenities.append(f"Stationnement ({val})") def _parse_kijiji_detail(html: str) -> dict: """Fiche Kijiji : description complète, attributs, galerie haute résolution.""" m = _NEXT_RE.search(html) if not m: return {} try: data = json.loads(m.group(1)) except ValueError: return {} apollo = data.get("props", {}).get("pageProps", {}).get("__APOLLO_STATE__", {}) it = next((v for k, v in apollo.items() if _LISTING_KEY_RE.match(k) and isinstance(v, dict) and v.get("description")), None) if not it: return {} out: dict = {} if it.get("description"): out["description"] = str(it["description"]).strip()[:6000] imgs = [re.sub(r"rule=kijijica-\d+-\w+", "rule=kijijica-1600-jpg", u) for u in it.get("imageUrls") or []] if imgs: out["images"] = imgs _apply_attrs((it.get("attributes") or {}).get("all") or [], out) out.pop("bedrooms", None) # le type d'unité est déjà fixé au niveau liste loc = it.get("location") or {} addr = (loc.get("address") or "").replace(", Canada", "") if re.match(r"\s*\d", addr): out["address"] = addr.split(",")[0] return out class KijijiConnector(BaseConnector): source_id = "kijiji" request_delay = 1.2 def _page(self, seg: str, cat: int, page: int) -> list[dict]: """Annonces (Apollo state) d'une page de catégorie.""" path = (f"{seg}/quebec/c{cat}l9001" if page == 1 else f"{seg}/quebec/page-{page}/c{cat}l9001") html = self.get(f"{BASE}/{path}").text m = _NEXT_RE.search(html) data = json.loads(m.group(1)) if m else {} apollo = (data.get("props", {}).get("pageProps", {}) .get("__APOLLO_STATE__", {})) return [v for k, v in apollo.items() if _LISTING_KEY_RE.match(k) and isinstance(v, dict)] def _to_listing(self, it: dict, unit_default: str) -> Listing | None: lid = str(it.get("id") or "") url = it.get("url") or "" if not lid or not url: return None price = None pr = it.get("price") or {} if isinstance(pr, dict) and pr.get("amount"): price = round(pr["amount"] / 100.0, 0) # cents → $/mois loc = it.get("location") or {} coords = loc.get("coordinates") or {} address = (loc.get("address") or "").replace(", Canada", "") parts = [p.strip() for p in address.split(",") if p.strip()] street = parts[0] if parts and re.match(r"\s*\d", parts[0]) else "" city = _fix_city(parts[1] if street and len(parts) > 1 else (loc.get("name") or (parts[0] if parts else ""))) images = [re.sub(r"rule=kijijica-\d+-", "rule=kijijica-640-", u) for u in it.get("imageUrls") or []] extra: dict = {} _apply_attrs((it.get("attributes") or {}).get("all") or [], extra) unit_type = unit_default beds = extra.pop("bedrooms", None) if not unit_type and beds: try: # Kijiji code parfois « 2.5 » (2 ch. + den) n = int(float(beds)) except ValueError: n = 0 unit_type = "Studio" if n == 0 else f"{n} chambres" # → n+2 ½ lst = Listing( source=self.source_id, external_id=lid, url=url, title=it.get("title") or "", address=street, city=city, unit_type=unit_type, price=price, price_label=(f"{price:,.0f} $/mois".replace(",", " ") if price else ""), description=(it.get("description") or "")[:2000], amenities=extra.get("amenities") or [], details=extra.get("details") or {}, images=images, lat=coords.get("latitude"), lng=coords.get("longitude"), ) if extra.get("availability_date"): lst.availability_date = extra["availability_date"] lst.availability = f"Libre le {extra['availability_date']}" if extra.get("furnished") is not None: lst.furnished = extra["furnished"] if extra.get("pets"): lst.pets = extra["pets"] if extra.get("area_sqft"): lst.area_sqft = extra["area_sqft"] return lst def fetch(self) -> list[Listing]: out: dict[str, Listing] = {} for cat, seg, unit_default in CATEGORIES: for page in range(1, MAX_PAGES + 1): try: items = self._page(seg, cat, page) except Exception: break fresh = 0 for it in items: lst = self._to_listing(it, unit_default) if lst is not None and lst.uid not in out: out[lst.uid] = lst fresh += 1 # plus rien de neuf (page de fin remplie de topAds répétés) if fresh == 0 or len(items) < 10: break listings = list(out.values()) du.enrich(self, listings, DETAIL_LIMIT, _parse_kijiji_detail, key="v1") return listings