# -----------------------------------------------------------------------------
# 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"))
# attributs Apollo (canonicalName) -> libellé FR. Les attributs absents de la
# table gardent leur `name` Kijiji d'origine dans details.
_ATTR_LABELS = {
"numberbedrooms": "Chambres", "numberbathrooms": "Salles de bain",
"areainfeet": "Superficie (pi²)", "sizesqft": "Superficie (pi²)",
"areainacres": "Superficie du terrain", # texte libre : « 26 acres », « 35 000 pi2 »…
"yearbuilt": "Année de construction", "forsalebyhousing": "À vendre par",
"numberparkingspots": "Stationnements", "parkingincluded": "Stationnement inclus",
"unittype": "Type d'unité", "furnished": "Meublé",
"virtualtour": "Visite virtuelle", "videochat": "Visite par vidéo",
}
_ACRES_RE = re.compile(r"([\d\s,.]+)\s*(?:acres?\b|ac\.?$)", re.I)
def _lot_sqft_from_free_text(val: str) -> float | None:
"""Superficie de terrain depuis le champ libre « Size (acres) » de Kijiji :
« 26 acres », « 35 000 pi2 », « 1586 mètre carré », ou un nombre nu (acres)."""
from ..normalize import parse_area_sqft, parse_float
t = (val or "").replace("pieds carres", "pi²").replace("pieds carrés", "pi²") \
.replace("pied carré", "pi²").replace("mètres carrés", "m²") \
.replace("mètre carré", "m²").replace("metre carre", "m²")
t = re.sub(r"(\d),(\d{3})\b", r"\1\2", t) # « 18,000 » = milliers, pas décimale
v = parse_area_sqft(t) # unités pi²/m² explicites
if v:
return v
m = _ACRES_RE.search(t)
n = parse_float(m.group(1)) if m else None
if n is None and re.fullmatch(r"[\d\s,.]+", t.strip()):
n = parse_float(t) # nombre nu = acres (nom du champ)
if n and 0 < n < 1000:
return round(n * 43560) # acres -> pi²
return None
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":
mn = re.search(r"\d+", val) # « 1.5 » -> 1
if mn:
out["bathrooms"] = int(mn.group(0))
elif cn in ("areainfeet", "sizesqft"):
mn = re.search(r"[\d.]+", val.replace(",", ""))
if mn:
out["area_sqft"] = float(mn.group(0))
elif cn == "areainacres":
lot = _lot_sqft_from_free_text(val)
if lot:
out["lot_sqft"] = lot
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 → $
if price < 5000: # prix bidon fréquent sur Kijiji (1 $, 123 $…)
price = None
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))
details = {}
# date de mise en ligne : activationDate = 1re publication (sortingDate
# est re-bumpée par les remontées/TOP AD)
posted = str(it.get("activationDate") or it.get("sortingDate") or "")
if re.match(r"\d{4}-\d{2}-\d{2}", posted):
details["listed_at"] = posted[:10]
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],
details=details,
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())
# v2 = _ATTR_LABELS étendus (terrain/stationnement/année…) + lot_sqft
du.enrich(self, listings, DETAIL_LIMIT, _parse_kijiji_detail, key="v2")
return listings