# -----------------------------------------------------------------------------
# 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