# ----------------------------------------------------------------------------- # Immo-Ka — Agrégateur de maisons à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/kijiji.py : Kijiji (kijiji.ca) — petites annonces immobilières # UNIQUEMENT les catégories immobilier À VENDRE, UNIQUEMENT le Québec (l9001) : # c35 maisons à vendre · c643 condos à vendre · c641 terrains à vendre # Les pages listent 40+ annonces dans __NEXT_DATA__ (Apollo state) avec titre, # prix, GPS, adresse et vignette — aucune API privée nécessaire. # ----------------------------------------------------------------------------- from __future__ import annotations import json import os import re from ..schema import PropertyListing from .base import BaseConnector from . import _detailutil as du BASE = "https://www.kijiji.ca" # (code catégorie, segment d'URL, type canonique) CATEGORIES = [ (35, "b-maison-a-vendre", "Maison"), (643, "b-condo-a-vendre", "Condo"), (641, "b-terrain-a-vendre", "Terrain"), ] MAX_PAGES = int(os.environ.get("IMMOKA_KIJIJI_MAX_PAGES", "100")) DETAIL_LIMIT = int(os.environ.get("IMMOKA_KIJIJI_DETAIL_LIMIT", "400")) _ATTR_LABELS = { "numberbedrooms": "Chambres", "numberbathrooms": "Salles de bain", "areainfeet": "Superficie (pi²)", "forsalebyhousing": "À vendre par", "yearbuilt": "Année de construction", "sizesqft": "Superficie (pi²)", } def _parse_kijiji_detail(html: str) -> dict: """Fiche Kijiji : description complète, attributs, galerie haute résolution.""" m = re.search( r'', html, re.S) 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 k.startswith("StandardListing:") 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 features, details = [], {} for a in (it.get("attributes") or {}).get("all") or []: cn = a.get("canonicalName") or "" val = ", ".join(str(v) for v in a.get("values") or []) if not val: continue label = _ATTR_LABELS.get(cn, a.get("name") or cn) features.append(f"{label} : {val}") details[label] = val if cn == "numberbedrooms" and val.isdigit(): out["bedrooms"] = int(val) elif cn == "numberbathrooms" and val.isdigit(): out["bathrooms"] = int(val) elif cn in ("areainfeet", "sizesqft"): mn = re.search(r"[\d.]+", val.replace(",", "")) if mn: out["area_sqft"] = float(mn.group(0)) elif cn == "yearbuilt" and val.isdigit(): out["year_built"] = int(val) if features: out["features"] = features if details: out["details"] = details 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 _next_data(self, html: str) -> dict: m = re.search( r'', html, re.S) return json.loads(m.group(1)) if m else {} 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 data = self._next_data(html) apollo = (data.get("props", {}).get("pageProps", {}) .get("__APOLLO_STATE__", {})) return [v for k, v in apollo.items() if k.startswith("StandardListing:") and isinstance(v, dict)] def _to_listing(self, it: dict, ptype: str) -> PropertyListing | 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 → $ loc = it.get("location") or {} coords = loc.get("coordinates") or {} address = (loc.get("address") or "").replace(", Canada", "") # « Saint-Hubert, QC J3Y 6Y3 » → ville avant la 1re virgule city = loc.get("name") or (address.split(",")[0] if address else "") images = [] for u in it.get("imageUrls") or []: images.append(re.sub(r"rule=kijijica-\d+-", "rule=kijijica-640-", u)) return PropertyListing( source=self.source_id, external_id=lid, url=url, title=it.get("title") or "", address=address.split(",")[0] if re.match(r"\s*\d", address) else "", city=city, property_type=ptype, price=price, price_label=f"{price:,.0f} $".replace(",", " ") if price else "", description=(it.get("description") or "")[:2000], images=images, lat=coords.get("latitude"), lng=coords.get("longitude"), broker_name="Kijiji (particuliers)", agency="Kijiji Québec", ) def fetch(self) -> list[PropertyListing]: out: dict[str, PropertyListing] = {} for cat, seg, ptype 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, ptype) 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