Python 61.6%
TypeScript 20.9%
CSS 11.4%
JavaScript 5.1%
HTML 1.1%
1# -----------------------------------------------------------------------------2# Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/kijiji.py : annonces de PARTICULIERS — Kijiji (kijiji.ca)5#6# Note : Kijiji Autos (kijijiautos.ca, plateforme MoVe/m.mobile.de) n'existe7# plus — le domaine ne résout plus (SERVFAIL, constaté 2026-08-18) ; les8# annonces ont été rapatriées sur kijiji.ca. On cible donc la catégorie9# « Autos et camions » (c174) du Québec (l9001), filtrée vendeur particulier10# (?for-sale-by=ownr) pour ne pas dupliquer l'inventaire des concessionnaires11# déjà couverts par les autres connecteurs.12#13# Stratégie : les pages liste (SRP) de kijiji.ca (Next.js) embarquent le14# cache Apollo complet dans <script id="__NEXT_DATA__"> — chaque annonce y15# est un objet AutosListing structuré : marque/modèle/année/km canoniques,16# prix (en cents), carburant/boîte/rouage/carrosserie, couleurs, portes,17# places, VIN (quand le vendeur l'a saisi), lien Carfax, photos CDN et18# surtout la géolocalisation exacte (lat/lng + adresse). Aucune page détail19# n'est nécessaire : tout est dans la liste — 40 annonces/requête.20#21# Pagination : /b-autos-camions/quebec/page-N/c174l9001?for-sale-by=ownr22# jusqu'à totalCount (bornée par MAX_PAGES par politesse).23# -----------------------------------------------------------------------------24from __future__ import annotations2526import json27import re2829from ..schema import Vehicle30from .base import BaseConnector3132BASE = "https://www.kijiji.ca"33LIST_PATH = "/b-autos-camions/quebec/{page}c174l9001?for-sale-by=ownr"3435_NEXT_DATA_RE = re.compile(36 r'<script id="__NEXT_DATA__" type="application/json">(.*?)</script>', re.S)3738# suffixes d'adresse à écarter pour isoler la ville : « QC », code postal39# complet ou partiel (« J7V »), ou les deux (« QC H7X 2S6 »)40_ADDR_TAIL_RE = re.compile(41 r"^(?:QC|Qc|Qu[ée]bec)?\s*(?:[A-Za-z]\d[A-Za-z](?:\s?\d[A-Za-z]\d)?)?$")4243# grandes photos plutôt que les vignettes 200 px de la liste44_IMG_RULE_RE = re.compile(r"rule=kijijica-\d+-")4546# valeurs canoniques Kijiji -> vocabulaire Auto-Ka (normalize.py gère le reste)47_TRANSMISSIONS = {"1": "Manuelle", "2": "Automatique", "3": "",48 "auto": "Automatique", "man": "Manuelle"}49_BODIES = {"sedan": "Berline", "suvcrossover": "VUS", "htchbck": "Hayon",50 "conv": "Cabriolet", "coup": "Coupé", "pickuptruck": "Camionnette",51 "vanminicomma": "Fourgonnette", "wagon": "Familiale",52 "othrbdytyp": ""}53_COLORS = {"white": "Blanc", "black": "Noir", "gray": "Gris", "grey": "Gris",54 "silver": "Argent", "blue": "Bleu", "red": "Rouge", "brown": "Brun",55 "green": "Vert", "burgundy": "Bourgogne", "gold": "Doré",56 "orange": "Orange", "off_white": "Blanc cassé", "beige": "Beige",57 "tan": "Beige", "yellow": "Jaune", "purple": "Violet",58 "other": ""}5960MAX_PAGES = 90 # 90 × 40 = 3 600 annonces — couvre le volume QC actuel61PAGE_SIZE = 40626364def _attr_map(listing: dict) -> dict[str, str]:65 out: dict[str, str] = {}66 for a in ((listing.get("attributes") or {}).get("all") or []):67 vals = a.get("canonicalValues") or []68 if vals and vals[0] is not None:69 out[a.get("canonicalName") or ""] = str(vals[0])70 return out717273def _city_from_location(loc: dict) -> str:74 """Ville depuis l'adresse — formats observés : « Rue X, Laval, QC H7X 2S6 »,75 « Vaudreuil-Dorion, QC J7V », « Anjou, QC H1J 2W1 », « Laval, H7Y 2B7 »."""76 parts = [p.strip() for p in (loc.get("address") or "").split(",") if p.strip()]77 # retirer depuis la fin : « QC », code postal (complet/partiel) ou les deux78 while parts and _ADDR_TAIL_RE.match(parts[-1]):79 parts.pop()80 if parts:81 return parts[-1]82 # repli : nom de zone Kijiji (« Laval / North Shore » -> Laval)83 return ((loc.get("name") or "").split("/")[0]).strip()848586class KijijiParticuliers(BaseConnector):87 """Annonces de particuliers — Kijiji, catégorie Autos et camions, Québec."""8889 source_id = "kijiji"90 request_delay = 1.2 # politesse : gros site, gros volume9192 def __init__(self) -> None:93 super().__init__()94 self.session.headers.update({95 "Accept": "text/html,application/xhtml+xml",96 "Accept-Language": "fr-CA,fr;q=0.9,en;q=0.5",97 })9899 # -- extraction -----------------------------------------------------------100101 def _fetch_page(self, page: int) -> tuple[list[dict], int]:102 """Annonces AutosListing + totalCount d'une page SRP."""103 seg = "" if page <= 1 else f"page-{page}/"104 html = self.get(BASE + LIST_PATH.format(page=seg)).text105 m = _NEXT_DATA_RE.search(html)106 if not m:107 raise RuntimeError(f"kijiji : __NEXT_DATA__ introuvable (page {page})")108 data = json.loads(m.group(1))109 apollo = (data.get("props", {}).get("pageProps", {})110 .get("__APOLLO_STATE__") or {})111 total = 0112 for key, val in (apollo.get("ROOT_QUERY") or {}).items():113 if key.startswith("searchResultsPageByUrl"):114 total = int((val.get("pagination") or {}).get("totalCount") or 0)115 break116 listings = [v for k, v in apollo.items()117 if k.startswith("AutosListing:") and isinstance(v, dict)]118 return listings, total119120 def _to_vehicle(self, l: dict) -> Vehicle | None:121 ext_id = str(l.get("id") or "")122 if not ext_id:123 return None124 attrs = _attr_map(l)125 if attrs.get("forsaleby") not in ("", "ownr"):126 return None # topListings = pubs de marchands127 if attrs.get("vehicletype") == "new":128 return None # occasion seulement129130 price = None131 p = l.get("price") or {}132 if p.get("type") == "FIXED" and p.get("amount"):133 price = round(p["amount"] / 100.0, 2)134 if price < 500: # « 1 $ » = prix symbolique de petite annonce135 price = None136137 loc = l.get("location") or {}138 coords = loc.get("coordinates") or {}139 images = [_IMG_RULE_RE.sub("rule=kijijica-640-", u)140 for u in (l.get("imageUrls") or [])]141142 km = None143 if attrs.get("carmileageinkms", "").replace(".", "", 1).isdigit():144 km = float(attrs["carmileageinkms"])145146 def _int(name: str) -> int | None:147 v = attrs.get(name, "")148 return int(v) if v.isdigit() else None149150 details = {"forsaleby": "particulier"}151 if l.get("activationDate"):152 details["posted"] = l["activationDate"][:10]153 if attrs.get("pricerating"):154 details["kijiji_price_rating"] = attrs["pricerating"]155 if attrs.get("electricrange", "").replace(".", "", 1).isdigit():156 details["electric_range_km"] = float(attrs["electricrange"])157158 vin = attrs.get("vin", "").strip().upper()159 if not re.fullmatch(r"[A-HJ-NPR-Z0-9]{17}", vin):160 vin = ""161162 veh = Vehicle(163 source=self.source_id,164 external_id=ext_id,165 url=l.get("url") or f"{BASE}/v-view-details.html?adId={ext_id}",166 kind="auto",167 title=l.get("title") or "",168 make=attrs.get("carmake", ""),169 model=attrs.get("carmodel", "").capitalize(),170 trim=attrs.get("cartrim", ""),171 year=_int("caryear"),172 price=price,173 price_label=(f"{price:,.0f} $".replace(",", " ") if price else ""),174 mileage_km=km,175 transmission=_TRANSMISSIONS.get(attrs.get("cartransmission", ""),176 attrs.get("cartransmission", "")),177 fuel=("" if attrs.get("carfueltype") == "other"178 else attrs.get("carfueltype", "")),179 drivetrain=("" if attrs.get("drivetrain") == "other"180 else attrs.get("drivetrain", "")),181 body_type=_BODIES.get(attrs.get("carbodytype", ""),182 attrs.get("carbodytype", "")),183 exterior_color=_COLORS.get(attrs.get("carcolor", ""),184 attrs.get("carcolor", "").capitalize()),185 interior_color=_COLORS.get(attrs.get("carinteriorcolor", ""),186 attrs.get("carinteriorcolor", "").capitalize()),187 doors=_int("noofdoors"),188 seats=_int("noofseats"),189 vin=vin,190 dealer_name="Particulier (Kijiji)",191 city=_city_from_location(loc),192 lat=coords.get("latitude"),193 lng=coords.get("longitude"),194 description=l.get("description") or "",195 details=details,196 images=images,197 carfax_url=attrs.get("carprooflink", ""),198 )199 return veh200201 # -- contrat ---------------------------------------------------------------202203 def fetch(self) -> list[Vehicle]:204 vehicles: dict[str, Vehicle] = {}205 listings, total = self._fetch_page(1)206 pages = min(MAX_PAGES, -(-max(total, 1) // PAGE_SIZE))207 for l in listings:208 v = self._to_vehicle(l)209 if v:210 vehicles[v.external_id] = v211 for page in range(2, pages + 1):212 try:213 listings, _ = self._fetch_page(page)214 except Exception:215 break # fin de pagination / page vide216 new = 0217 for l in listings:218 v = self._to_vehicle(l)219 if v and v.external_id not in vehicles:220 vehicles[v.external_id] = v221 new += 1222 if new == 0: # au-delà de la dernière page223 break224 return list(vehicles.values())225