SPB Git forge

spb/auto-ka

Public
61commits 1branches 0releases
14.4 MBsize
maindefault branch
13 days agolast push
Python 61.6% TypeScript 20.9% CSS 11.4% JavaScript 5.1% HTML 1.1%
9.8 KB · 225 lines python
Raw Blame History
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