SPB Git

spb/auto-ka Public

Python 81.8% TypeScript 12.4% CSS 5.5%
4.8 KB · 129 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/okaze.py : OKAZE (Saguenay) — site Webflow (CMS w-dyn-list).5#6#   Stratégie : la page /inventaire est rendue côté serveur avec TOUTES les7#   annonces (pas de pagination). Chaque carte embarque un <script> inline qui8#   pose des attributs data-* dont `data-search` : une chaîne CSV complète —9#     nom, année, carrosserie, catégorie, couleur ext., couleur int., marque,10#     modèle, moteur, portes, km, prix, motricité, transmission, carburant.11#   On parse ces cartes en HTML brut (regex) : une seule requête par sync.12# -----------------------------------------------------------------------------13from __future__ import annotations1415import re1617from ..schema import Vehicle18from .base import BaseConnector1920# OKAZE « vend tout » : écarter les catégories non automobiles21_EXCLUDE_RE = re.compile(22    r"motoneige|vtt|bateau|ponton|motomarine|\bmoto\b|roulotte|remorque|"23    r"\bvr\b|campeur|tracteur|souffleuse|scooter|spyder|side-by-side|atv",24    re.I)2526_CARD_SPLIT = re.compile(r'class="item-inventaire w-dyn-item"')27_HREF_RE = re.compile(r'href="(/vehicules/[^"]+)"')28_IMG_RE = re.compile(r'<img src="(https://[^"]+)"')29_ATTR_RE = re.compile(30    r"item\.setAttribute\('data-([a-z-]+)',\s*(?:formatDate)?\(\"(.*?)\"\)",31    re.S)323334class Okaze(BaseConnector):35    source_id = "okaze"36    base_url = "https://www.okaze.ca"37    dealer_name = "OKAZE"38    city = "Saguenay"39    request_delay: float = 1.04041    def fetch(self) -> list[Vehicle]:42        html = self.get(f"{self.base_url}/inventaire").text43        vehicles: list[Vehicle] = []44        for block in _CARD_SPLIT.split(html)[1:]:45            veh = self._parse_card(block)46            if veh is not None:47                vehicles.append(veh)48        return vehicles4950    def _parse_card(self, block: str) -> Vehicle | None:51        m = _HREF_RE.search(block)52        if not m:53            return None54        path = m.group(1)55        ext_id = path.rstrip("/").rsplit("/", 1)[-1]5657        attrs = {name: value for name, value in _ATTR_RE.findall(block)}58        title = " ".join((attrs.get("name") or "").split())59        search = attrs.get("search") or ""60        fields = [f.strip() for f in search.split(",")]6162        # data-search : positions fixes quand la fiche est complète (15 champs)63        body = category = ext_color = int_color = engine = ""64        drivetrain = transmission = fuel = ""65        doors = km = None66        if len(fields) >= 15:67            (_, _, body, category, ext_color, int_color, _, _,68             engine) = fields[:9]69            drivetrain, transmission, fuel = fields[-3:]70            try:71                doors = int(fields[9]) or None72            except ValueError:73                doors = None74            try:75                km = float(fields[10])76            except ValueError:77                km = None78        else:                                   # fiche partielle : heuristiques79            for f in fields:80                if re.fullmatch(r"\d{2,6}", f) and km is None:81                    km = float(f)82                elif "intégrale" in f.lower() or "traction" in f.lower() \83                        or "propulsion" in f.lower():84                    drivetrain = f85                elif f.lower() in ("automatique", "manuelle", "cvt"):86                    transmission = f8788        if _EXCLUDE_RE.search(f"{title} {category} {body}"):89            return None90        if not title:91            return None9293        price_label = attrs.get("price", "")94        if not price_label:95            pm = re.search(r'class="inventory-price"[^>]*>([^<]+)<', block)96            price_label = pm.group(1).strip() if pm else ""9798        details = {}99        if category:100            details["category"] = category101        if attrs.get("added"):102            details["listed_date"] = attrs["added"]103104        img = _IMG_RE.search(block)105106        return Vehicle(107            source=self.source_id,108            external_id=ext_id,109            url=f"{self.base_url}{path}",110            title=title,111            make=attrs.get("brand", "").title(),112            model=attrs.get("model", ""),113            price_label=price_label,114            mileage_km=km,115            mileage_label=(f"{km:,.0f} km".replace(",", " ") if km else ""),116            transmission=transmission,117            fuel=fuel,118            drivetrain=drivetrain,119            body_type=body,120            exterior_color=ext_color.capitalize(),121            interior_color=int_color.capitalize(),122            engine=engine,123            doors=doors,124            dealer_name=self.dealer_name,125            city=self.city,126            details=details,127            images=[img.group(1)] if img else [],128        )129