# ----------------------------------------------------------------------------- # Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/kijiji.py : annonces de PARTICULIERS — Kijiji (kijiji.ca) # # Note : Kijiji Autos (kijijiautos.ca, plateforme MoVe/m.mobile.de) n'existe # plus — le domaine ne résout plus (SERVFAIL, constaté 2026-08-18) ; les # annonces ont été rapatriées sur kijiji.ca. On cible donc la catégorie # « Autos et camions » (c174) du Québec (l9001), filtrée vendeur particulier # (?for-sale-by=ownr) pour ne pas dupliquer l'inventaire des concessionnaires # déjà couverts par les autres connecteurs. # # Stratégie : les pages liste (SRP) de kijiji.ca (Next.js) embarquent le # cache Apollo complet dans ', re.S) # suffixes d'adresse à écarter pour isoler la ville : « QC », code postal # complet ou partiel (« J7V »), ou les deux (« QC H7X 2S6 ») _ADDR_TAIL_RE = re.compile( r"^(?:QC|Qc|Qu[ée]bec)?\s*(?:[A-Za-z]\d[A-Za-z](?:\s?\d[A-Za-z]\d)?)?$") # grandes photos plutôt que les vignettes 200 px de la liste _IMG_RULE_RE = re.compile(r"rule=kijijica-\d+-") # valeurs canoniques Kijiji -> vocabulaire Auto-Ka (normalize.py gère le reste) _TRANSMISSIONS = {"1": "Manuelle", "2": "Automatique", "3": "", "auto": "Automatique", "man": "Manuelle"} _BODIES = {"sedan": "Berline", "suvcrossover": "VUS", "htchbck": "Hayon", "conv": "Cabriolet", "coup": "Coupé", "pickuptruck": "Camionnette", "vanminicomma": "Fourgonnette", "wagon": "Familiale", "othrbdytyp": ""} _COLORS = {"white": "Blanc", "black": "Noir", "gray": "Gris", "grey": "Gris", "silver": "Argent", "blue": "Bleu", "red": "Rouge", "brown": "Brun", "green": "Vert", "burgundy": "Bourgogne", "gold": "Doré", "orange": "Orange", "off_white": "Blanc cassé", "beige": "Beige", "tan": "Beige", "yellow": "Jaune", "purple": "Violet", "other": ""} MAX_PAGES = 90 # 90 × 40 = 3 600 annonces — couvre le volume QC actuel PAGE_SIZE = 40 def _attr_map(listing: dict) -> dict[str, str]: out: dict[str, str] = {} for a in ((listing.get("attributes") or {}).get("all") or []): vals = a.get("canonicalValues") or [] if vals and vals[0] is not None: out[a.get("canonicalName") or ""] = str(vals[0]) return out def _city_from_location(loc: dict) -> str: """Ville depuis l'adresse — formats observés : « Rue X, Laval, QC H7X 2S6 », « Vaudreuil-Dorion, QC J7V », « Anjou, QC H1J 2W1 », « Laval, H7Y 2B7 ».""" parts = [p.strip() for p in (loc.get("address") or "").split(",") if p.strip()] # retirer depuis la fin : « QC », code postal (complet/partiel) ou les deux while parts and _ADDR_TAIL_RE.match(parts[-1]): parts.pop() if parts: return parts[-1] # repli : nom de zone Kijiji (« Laval / North Shore » -> Laval) return ((loc.get("name") or "").split("/")[0]).strip() class KijijiParticuliers(BaseConnector): """Annonces de particuliers — Kijiji, catégorie Autos et camions, Québec.""" source_id = "kijiji" request_delay = 1.2 # politesse : gros site, gros volume def __init__(self) -> None: super().__init__() self.session.headers.update({ "Accept": "text/html,application/xhtml+xml", "Accept-Language": "fr-CA,fr;q=0.9,en;q=0.5", }) # -- extraction ----------------------------------------------------------- def _fetch_page(self, page: int) -> tuple[list[dict], int]: """Annonces AutosListing + totalCount d'une page SRP.""" seg = "" if page <= 1 else f"page-{page}/" html = self.get(BASE + LIST_PATH.format(page=seg)).text m = _NEXT_DATA_RE.search(html) if not m: raise RuntimeError(f"kijiji : __NEXT_DATA__ introuvable (page {page})") data = json.loads(m.group(1)) apollo = (data.get("props", {}).get("pageProps", {}) .get("__APOLLO_STATE__") or {}) total = 0 for key, val in (apollo.get("ROOT_QUERY") or {}).items(): if key.startswith("searchResultsPageByUrl"): total = int((val.get("pagination") or {}).get("totalCount") or 0) break listings = [v for k, v in apollo.items() if k.startswith("AutosListing:") and isinstance(v, dict)] return listings, total def _to_vehicle(self, l: dict) -> Vehicle | None: ext_id = str(l.get("id") or "") if not ext_id: return None attrs = _attr_map(l) if attrs.get("forsaleby") not in ("", "ownr"): return None # topListings = pubs de marchands if attrs.get("vehicletype") == "new": return None # occasion seulement price = None p = l.get("price") or {} if p.get("type") == "FIXED" and p.get("amount"): price = round(p["amount"] / 100.0, 2) if price < 500: # « 1 $ » = prix symbolique de petite annonce price = None loc = l.get("location") or {} coords = loc.get("coordinates") or {} images = [_IMG_RULE_RE.sub("rule=kijijica-640-", u) for u in (l.get("imageUrls") or [])] km = None if attrs.get("carmileageinkms", "").replace(".", "", 1).isdigit(): km = float(attrs["carmileageinkms"]) def _int(name: str) -> int | None: v = attrs.get(name, "") return int(v) if v.isdigit() else None details = {"forsaleby": "particulier"} if l.get("activationDate"): details["posted"] = l["activationDate"][:10] if attrs.get("pricerating"): details["kijiji_price_rating"] = attrs["pricerating"] if attrs.get("electricrange", "").replace(".", "", 1).isdigit(): details["electric_range_km"] = float(attrs["electricrange"]) vin = attrs.get("vin", "").strip().upper() if not re.fullmatch(r"[A-HJ-NPR-Z0-9]{17}", vin): vin = "" veh = Vehicle( source=self.source_id, external_id=ext_id, url=l.get("url") or f"{BASE}/v-view-details.html?adId={ext_id}", kind="auto", title=l.get("title") or "", make=attrs.get("carmake", ""), model=attrs.get("carmodel", "").capitalize(), trim=attrs.get("cartrim", ""), year=_int("caryear"), price=price, price_label=(f"{price:,.0f} $".replace(",", " ") if price else ""), mileage_km=km, transmission=_TRANSMISSIONS.get(attrs.get("cartransmission", ""), attrs.get("cartransmission", "")), fuel=("" if attrs.get("carfueltype") == "other" else attrs.get("carfueltype", "")), drivetrain=("" if attrs.get("drivetrain") == "other" else attrs.get("drivetrain", "")), body_type=_BODIES.get(attrs.get("carbodytype", ""), attrs.get("carbodytype", "")), exterior_color=_COLORS.get(attrs.get("carcolor", ""), attrs.get("carcolor", "").capitalize()), interior_color=_COLORS.get(attrs.get("carinteriorcolor", ""), attrs.get("carinteriorcolor", "").capitalize()), doors=_int("noofdoors"), seats=_int("noofseats"), vin=vin, dealer_name="Particulier (Kijiji)", city=_city_from_location(loc), lat=coords.get("latitude"), lng=coords.get("longitude"), description=l.get("description") or "", details=details, images=images, carfax_url=attrs.get("carprooflink", ""), ) return veh # -- contrat --------------------------------------------------------------- def fetch(self) -> list[Vehicle]: vehicles: dict[str, Vehicle] = {} listings, total = self._fetch_page(1) pages = min(MAX_PAGES, -(-max(total, 1) // PAGE_SIZE)) for l in listings: v = self._to_vehicle(l) if v: vehicles[v.external_id] = v for page in range(2, pages + 1): try: listings, _ = self._fetch_page(page) except Exception: break # fin de pagination / page vide new = 0 for l in listings: v = self._to_vehicle(l) if v and v.external_id not in vehicles: vehicles[v.external_id] = v new += 1 if new == 0: # au-delà de la dernière page break return list(vehicles.values())