SPB Git

spb/auto-ka Public

Python 81.9% TypeScript 12.4% CSS 5.5%
12.4 KB · 293 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/leprixdugros.py : connecteur Le Prix du Gros (leprixdugros.com),5#   groupe Mauricie / Centre-du-Québec (Kia, Nissan, Hyundai, Mazda + méga6#   centre d'occasion) — Trois-Rivières, Shawinigan, Donnacona, Lévis,7#   Québec, Laval, Sherbrooke, Joliette. INVENTAIRE OCCASION UNIQUEMENT.8#9#   Plateforme : WordPress custom (thème Stereodev « lpdg-2022 »). La page10#   liste expose un endpoint JSON paginé non documenté (celui du filtre Vue) :11#   /inventaire/occasion/?output=json&pg=N -> {items(36/page), total, page}.12#   Chaque item : année/marque/modèle/version, km, VIN, prix, rabais, stock,13#   photo, permalien. ~37 pages pour ~1 300 véhicules d'occasion.14#15#   La page détail (permalien) complète : moteur, rouage, transmission,16#   passagers, couleurs, photos ET la concession où se trouve le véhicule17#   (bloc « c-single-car-map-location » : nom + ville). Ces pages sont mises18#   en cache BD à vie (clé stable — les specs ne changent pas ; le prix et le19#   km frais viennent de la liste). AUTOKA_MAX_DETAILS (env) plafonne les20#   vraies requêtes détail par sync ; AUTOKA_MAX_PAGES limite la pagination.21#22#   NOTE : les pages détail n'ont NI description NI liste d'équipements par23#   véhicule (les blocs « cards » sont du boilerplate : inspection 150 points,24#   reconditionnement) — rien à extraire de plus que les specs/ville/photos.25#   Le site est parfois lent (timeouts ponctuels) : chaque page détail tente26#   d'abord un GET direct, puis bascule sur Scrapfly en cas d'échec ; le27#   try/except par annonce garantit l'émission avec les données liste.28#   Carburant : la source ne l'affiche jamais ; en plus des jetons explicites29#   (hybride/EV/diesel), un moteur « N cylindres X.XL » sans indice électrifié30#   => Essence (sauf modèles hybrides « sans badge » connus, ex. Prius).31# -----------------------------------------------------------------------------32from __future__ import annotations3334import html as htmllib35import os36import re3738from ..schema import Vehicle39from .base import BaseConnector4041_SPEC_RE = re.compile(42    r'c-single-car-features__list__text">\s*<strong>([^<]+)</strong><br>\s*'43    r"<span>([^<]*)</span>", re.S)44_LOC_NAME_RE = re.compile(45    r'c-single-car-map-location__name">\s*([^<]+?)\s*</span>', re.S)46_LOC_ADDR_RE = re.compile(47    r'c-single-car-map-location__adresse">(.*?)</span>', re.S)48_CITY_RE = re.compile(r"([A-Za-zÀ-ÿ'’ .-]+?)\s*,\s*(?:QC|Québec)\b")49_IMG_RE = re.compile(r'https://images\.leprixdugros\.com/[^"\s\\<>]+')5051_FUEL_HINT_RE = re.compile(52    r"(hybride rechargeable|plug-?in|phev|hybride|hybrid|electrique|"53    r"électrique|electric|\bev\b|diesel|tdi)", re.I)5455# moteur thermique explicite (« 4 cylindres 2.00L ») -> Essence par défaut56_CYL_ENGINE_RE = re.compile(r"\d+\s*cylindres?", re.I)57# modèles électrifiés « sans badge » : ne jamais leur inférer Essence58_ELECTRIFIED_MODELS = {59    "prius", "prius prime", "prius c", "prius v", "ioniq", "niro",60    "insight", "clarity", "volt", "bolt", "bolt euv", "leaf", "c-max",61    "cr-z", "ct 200h", "sienna", "venza", "crown", "sonata hybrid",62}63# camions HD & fourgons souvent diesel SANS mention explicite (ex. Ram 350064# « 6 cylindres 6.70L » = Cummins diesel) : ne pas inférer Essence65_DIESEL_PRONE_RE = re.compile(66    r"\b(2500|3500|4500|5500|f-?[2-5]50|sprinter|promaster)\b", re.I)676869def _clean(text: str | None) -> str:70    if not text:71        return ""72    out = htmllib.unescape(str(text))73    out = re.sub(r"<[^>]+>", " ", out)74    return re.sub(r"\s{2,}", " ", out).strip()757677# clé de cache détail stable (bump si le parsing change)78_DETAIL_KEY = "v2"798081class LePrixDuGros(BaseConnector):82    """Le Prix du Gros — JSON liste paginé + pages détail (specs, ville)."""8384    source_id = "leprixdugros"85    base_url = "https://www.leprixdugros.com"86    dealer_name = "Le Prix du Gros"87    request_delay = 1.0              # >= 1 s : le site tolère mal davantage88    max_pages = 80                   # garde-fou dur (~37 pages réelles)89    max_details = 800                # plafond de vraies requêtes détail / sync90                                     # (couvre les ~690 véhicules en 1 sync ;91                                     # cache BD ensuite)9293    # -- liste JSON ----------------------------------------------------------94    def _list_page(self, page: int) -> dict:95        url = (f"{self.base_url}/inventaire/occasion/"96               f"?output=json&pg={page}")97        try:98            return self.get(url).json()99        except ValueError:100            return {}101102    # -- page détail -> payload (specs, concession, photos) -------------------103    def _fetch_detail(self, url: str) -> dict:104        try:105            html = self.get(url).text106        except Exception:107            # site lent / timeout ponctuel : repli Scrapfly (infra distincte)108            html = self.get_scrapfly(url)109            if not html:110                raise111        payload: dict = {"specs": {}, "images": []}112        for name, value in _SPEC_RE.findall(html):113            payload["specs"][_clean(name)] = _clean(value)114        m = _LOC_NAME_RE.search(html)115        if m:116            payload["dealer"] = _clean(m.group(1))117        m = _LOC_ADDR_RE.search(html)118        if m:119            # l'adresse est multi-lignes (<br>) : « 141 Rue Commerciale »,120            # « Donnacona, QC, G3M 1W2 », téléphone — la ville est le début121            # de LA ligne contenant « , QC »122            lines = [_clean(l) for l in re.split(r"<br\s*/?>", m.group(1))]123            lines = [l for l in lines if l]124            payload["address"] = ", ".join(lines[:2])125            for line in lines:126                c = _CITY_RE.match(line)127                if c:128                    payload["city"] = c.group(1).strip()129                    break130        seen: set[str] = set()131        for u in _IMG_RE.findall(html):132            if "_1000x750" in u and u not in seen:133                seen.add(u)134                payload["images"].append(u)135        if not payload["specs"] and not payload["images"]:136            # page d'erreur / interstitiel : ne pas mettre en cache du vide137            raise RuntimeError(f"page détail LPDG illisible : {url}")138        return payload139140    # -- contrat ---------------------------------------------------------------141    def fetch(self) -> list[Vehicle]:142        page_cap = int(os.environ.get("AUTOKA_MAX_PAGES", self.max_pages))143        detail_cap = int(os.environ.get("AUTOKA_MAX_DETAILS", self.max_details))144145        items: list[dict] = []146        seen_ids: set[str] = set()147        page, last_page = 1, 1148        while page <= min(last_page, page_cap, self.max_pages):149            data = self._list_page(page)150            batch = data.get("items") or []151            if not batch:152                break153            per_page = int(data.get("perPage") or len(batch) or 36)154            total = int(data.get("total") or 0)155            last_page = max(1, -(-total // max(per_page, 1)))    # ceil156            new = 0157            for item in batch:158                ext_id = str(item.get("id") or item.get("stocknumber") or "")159                if ext_id and ext_id not in seen_ids:160                    seen_ids.add(ext_id)161                    items.append(item)162                    new += 1163            if new == 0:164                break165            page += 1166167        vehicles: list[Vehicle] = []168        real_fetches = 0169        for item in items:170            detail: dict = {}171            url = str(item.get("permalink") or "")172            ext_id = str(item.get("id") or item.get("stocknumber") or "")173            if url:174                def _fetch(u=url):175                    return self._fetch_detail(u)176177                # clé stable : specs figées ; prix/km frais via la liste178                if real_fetches >= detail_cap:179                    from .. import db180                    if self._detail_con is None:181                        self._detail_con = db.connect()182                    detail = db.get_cached_detail(self._detail_con,183                                                  self.source_id,184                                                  ext_id, _DETAIL_KEY) or {}185                else:186                    before = self._last_request187                    try:188                        detail = self.detail(ext_id, _DETAIL_KEY, _fetch)189                    except Exception:190                        # page détail disparue ou timeout ponctuel : le191                        # véhicule est quand même émis avec les données liste192                        detail = {}193                    if self._last_request != before:194                        real_fetches += 1195            veh = self._to_vehicle(item, detail or {})196            if veh is not None:197                vehicles.append(veh)198        return vehicles199200    # -- item liste + détail -> Vehicle ------------------------------------------201    def _to_vehicle(self, item: dict, detail: dict) -> Vehicle | None:202        inventory = str(item.get("inventory") or "used").lower()203        if inventory == "new":                       # occasion seulement204            return None205206        ext_id = str(item.get("id") or item.get("stocknumber") or "")207        url = str(item.get("permalink") or "")208        if not ext_id or not url:209            return None210211        make = str(item.get("make") or "").strip()212        model = str(item.get("model") or "").strip()213        trim = _clean(item.get("trim"))214        year = item.get("year")215        title = _clean(item.get("title"))216        title = re.sub(r"\s+d[’']occasion à vendre\s*$", "", title, flags=re.I) \217            or " ".join(str(x) for x in (make, model, trim, year or "") if x)218219        price = item.get("price")220        try:221            price = float(price) if price else None222        except (TypeError, ValueError):223            price = None224225        km = item.get("kms")226        try:227            km = float(km) if km not in (None, "") else None228        except (TypeError, ValueError):229            km = None230231        specs = detail.get("specs") or {}232        engine = specs.get("Moteur", "")233        seats = None234        m = re.search(r"\d+", specs.get("Passagers", ""))235        if m:236            seats = int(m.group(0))237238        # carburant : déduit du titre/version/moteur (non affiché ailleurs)239        fuel = ""240        m = _FUEL_HINT_RE.search(f"{title} {trim} {engine}")241        if m:242            fuel = m.group(1)243        elif (_CYL_ENGINE_RE.search(engine)244                and model.lower() not in _ELECTRIFIED_MODELS245                and not _DIESEL_PRONE_RE.search(f"{title} {model} {trim}")):246            # moteur « N cylindres X.XL » sans aucun indice électrifié/diesel247            fuel = "Essence"248249        images = list(detail.get("images") or [])250        if not images and item.get("photo"):251            images = [str(item["photo"])]252253        details: dict = {"inventory_type": inventory}254        if item.get("rebate"):255            details["rebate"] = item["rebate"]256        if str(item.get("is_certified") or "0") not in ("0", "", "None"):257            details["certified"] = True258        if specs.get("Cabine"):259            details["cab"] = specs["Cabine"]260        if detail.get("address"):261            details["branch_address"] = detail["address"]262        if detail.get("dealer"):263            details["branch"] = detail["dealer"]264265        return Vehicle(266            source=self.source_id,267            external_id=ext_id,268            url=url,269            title=title,270            make=make,271            model=model,272            trim=trim,273            year=int(year) if year else None,274            price=price,275            price_label=f"{price:,.0f} $".replace(",", " ") if price else "",276            mileage_km=km,277            mileage_label=f"{km:,.0f} km".replace(",", " ") if km else "",278            transmission=specs.get("Transmission", ""),279            fuel=fuel,280            drivetrain=specs.get("Rouage", ""),281            body_type="",                      # non exposé par la source282            exterior_color=specs.get("Couleur extérieure", ""),283            interior_color=specs.get("Couleur intérieure", ""),284            engine=engine,285            seats=seats,286            vin=str(item.get("vin") or ""),287            stock_number=str(item.get("stocknumber") or ""),288            dealer_name=detail.get("dealer") or self.dealer_name,289            city=detail.get("city", ""),290            details=details,291            images=images[:20],292        )293