# ----------------------------------------------------------------------------- # Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/occasionbeaucage.py : connecteur Occasion Beaucage # (occasionbeaucage.com), groupe de mégacentres d'occasion de l'Estrie / # Centre-du-Québec / Montérégie (Sherbrooke, Magog, Granby, Drummondville, # Victoriaville, Saint-Hyacinthe, Cowansville) — ~1 200 véhicules. # # Plateforme : Next.js App Router (autoroot.io) sur Vercel, inventaire # EvalAuto. Chaque page liste /auto-usage// (24 véhicules/page) embarque # dans son flux RSC (self.__next_f.push) un objet # {"vehicles":[...],"count":N,"page":n} où chaque véhicule est COMPLET : # prix, km, VIN, stock, transmission, carburant, carrosserie, couleurs, # moteur, portes, places, carfax, photos ET la concession du véhicule # (display_dealer_name / display_city). AUCUNE page détail nécessaire : # ~52 requêtes pour tout l'inventaire, ville exacte par véhicule. # # Les refs de flux («$313») pointant vers d'autres lignes RSC (les listes # de photos) sont résolues localement. AUTOKA_MAX_PAGES (env) limite la # pagination pour les tests. # # Enrichissement détail : la description complète et la liste d'équipements # ne vivent PAS dans le flux liste — seulement dans le flux RSC de la page # détail /auto-usage//, sous forme de refs ($21…) vers des lignes # longues «T,» (longueur en OCTETS UTF-8) : description = HTML brut, # options = JSON {"fr": "équip1|équip2|…"}. Ces payloads sont mis en cache # BD à clé stable (les équipements ne changent pas ; le prix frais vient de # la liste) et plafonnés par AUTOKA_MAX_DETAILS / max_details par sync — # l'inventaire complet se couvre en quelques synchronisations. # ----------------------------------------------------------------------------- from __future__ import annotations import html as htmllib import json import os import re from ..schema import Vehicle from .base import BaseConnector # chunks du flux RSC : self.__next_f.push([1,""]) _FLIGHT_RE = re.compile(r'self\.__next_f\.push\(\[1,("(?:\\.|[^"\\])*")\]\)') # non-automobiles éventuels (garde-fou ; le groupe ne vend que des autos) _EXCLUDE_RE = re.compile( r"can-?am|harley|ski-?doo|sea-?doo|spyder|ryker|motoneige|vtt|" r"motomarine|roulotte|remorque", re.I) def _js_str(literal: str) -> str: """Chaîne littérale JS (avec guillemets) -> texte. Les chunks RSC sont encodés comme du JSON ; repli tolérant si un escape non-JSON traîne.""" try: return json.loads(literal) except ValueError: out = literal[1:-1].encode("utf-8").decode("unicode_escape") try: # unicode_escape mange l'UTF-8 -> réparer return out.encode("latin-1").decode("utf-8") except (UnicodeEncodeError, UnicodeDecodeError): return out def _s(value) -> str: """Scalaire du flux RSC -> str propre ('$undefined', '$D...' -> '').""" if value is None: return "" text = str(value).strip() return "" if text.startswith("$") else text def _strip_html(text: str | None) -> str: """HTML (avec entités) -> texte brut compact.""" if not text: return "" out = htmllib.unescape(str(text)) out = re.sub(r"<[^>]+>", " ", out) return re.sub(r"\s{2,}", " ", out).strip() # clé de cache détail stable : description/équipements ne changent pas pour # une annonce donnée (bump en cas de changement de parsing) _DETAIL_KEY = "v1" class OccasionBeaucage(BaseConnector): """Occasion Beaucage — inventaire complet via les pages liste (RSC).""" source_id = "occasionbeaucage" base_url = "https://www.occasionbeaucage.com" dealer_name = "Occasion Beaucage" request_delay = 1.0 max_pages = 120 # garde-fou dur (~52 pages réelles) max_details = 200 # plafond de vraies requêtes détail/sync # -- flux RSC ---------------------------------------------------------- def _flight_blob(self, html: str) -> str: return "".join(_js_str(m) for m in _FLIGHT_RE.findall(html)) def _page_payload(self, page: int) -> dict: """{"vehicles": [...], "count": N, "page": n} de la page liste.""" url = f"{self.base_url}/auto-usage/" if page > 1: url += f"{page}/" blob = self._flight_blob(self.get(url).text) decoder = json.JSONDecoder() for m in re.finditer(r'"vehicles":\[', blob): start = blob.rfind("{", 0, m.start()) if start < 0: continue try: obj, _ = decoder.raw_decode(blob, start) except ValueError: continue if isinstance(obj, dict) and "count" in obj \ and isinstance(obj.get("vehicles"), list): obj["_blob"] = blob # pour résoudre les refs $ return obj return {} @staticmethod def _resolve_ref(blob: str, ref: str): """«$313» -> payload JSON de la ligne RSC 313 (ex. liste de photos). Les lignes à préfixe de longueur (T,) ne sont pas suivies d'un saut de ligne — on repère donc «:» n'importe où et on valide en décodant le JSON qui suit (raw_decode s'arrête à la fin du tableau). """ decoder = json.JSONDecoder() pattern = re.compile(re.escape(ref[1:]) + r':(?:T[0-9a-f]+,)?(?=[\["])') for row in pattern.finditer(blob): try: value, _ = decoder.raw_decode(blob, row.end()) except ValueError: continue if isinstance(value, (list, str)): return value return None @staticmethod def _flight_row(blob: str, ref: str): """«$21» -> contenu de la ligne RSC 21, y compris les lignes longues «21:T6bc,» dont la longueur T est en OCTETS UTF-8 et dont le contenu est du texte brut (HTML ou JSON sérialisé), sans saut de ligne final. Les lignes JSON classiques sont décodées directement.""" rid = ref[1:] if not rid or not re.fullmatch(r"[0-9a-f]+", rid): return None # $undefined, $D…, etc. decoder = json.JSONDecoder() pattern = re.compile( r"(? length = int(m.group(1), 16) raw = blob[m.end():].encode("utf-8")[:length] return raw.decode("utf-8", errors="ignore") if blob[m.end():m.end() + 1] not in '["{': continue try: value, _ = decoder.raw_decode(blob, m.end()) except ValueError: continue return value return None # -- page détail -> payload (description complète + équipements) -------- def _fetch_detail(self, slug: str) -> dict: blob = self._flight_blob( self.get(f"{self.base_url}/auto-usage/{slug}/").text) decoder = json.JSONDecoder() veh: dict | None = None for m in re.finditer(r'"vehicle":\{', blob): try: obj, _ = decoder.raw_decode(blob, m.start() + len('"vehicle":')) except ValueError: continue if isinstance(obj, dict) and obj.get("slug"): veh = obj break if not veh: return {} payload: dict = {} desc = veh.get("description") if isinstance(desc, str) and desc.startswith("$"): desc = self._flight_row(blob, desc) desc = _strip_html(desc if isinstance(desc, str) else "") if desc: payload["description"] = desc[:4000] opts = veh.get("options") if isinstance(opts, str) and opts.startswith("$"): opts = self._flight_row(blob, opts) if isinstance(opts, str) and opts.lstrip().startswith("{"): try: # ligne longue = JSON sérialisé opts = json.loads(opts) except ValueError: opts = None if isinstance(opts, dict): opts = opts.get("fr") or opts.get("en") or "" if isinstance(opts, str) and opts: features = [f.strip() for f in opts.split("|") if f.strip()] if features: payload["features"] = features[:60] return payload # -- contrat ----------------------------------------------------------- def fetch(self) -> list[Vehicle]: cap = int(os.environ.get("AUTOKA_MAX_PAGES", self.max_pages)) vehicles: list[Vehicle] = [] seen: set[str] = set() page, last_page = 1, 1 while page <= min(last_page, cap, self.max_pages): payload = self._page_payload(page) batch = payload.get("vehicles") or [] if not batch: break per_page = max(len(batch), 1) count = int(payload.get("count") or 0) last_page = max(1, -(-count // per_page)) # ceil new = 0 for d in batch: veh = self._to_vehicle(d, payload.get("_blob", "")) if veh is not None and veh.external_id not in seen: seen.add(veh.external_id) vehicles.append(veh) new += 1 if new == 0: # page recyclée break page += 1 self._enrich_details(vehicles) return vehicles # -- enrichissement détail plafonné (cache stable) ----------------------- def _enrich_details(self, vehicles: list[Vehicle]) -> None: cap = int(os.environ.get("AUTOKA_MAX_DETAILS", self.max_details)) real_fetches = 0 for veh in vehicles: slug = veh.url.rstrip("/").rsplit("/", 1)[-1] detail: dict = {} if real_fetches >= cap: # plafond : cache seulement from .. import db if self._detail_con is None: self._detail_con = db.connect() detail = db.get_cached_detail( self._detail_con, self.source_id, str(veh.external_id), _DETAIL_KEY) or {} else: def _fetch(s=slug): return self._fetch_detail(s) before = self._last_request try: detail = self.detail(veh.external_id, _DETAIL_KEY, _fetch) except Exception: detail = {} # page disparue : données liste if self._last_request != before: real_fetches += 1 if not detail: continue desc = detail.get("description") or "" if len(desc) > len(veh.description or ""): veh.description = desc if detail.get("features") and not veh.features: veh.features = list(detail["features"]) # -- enregistrement liste -> Vehicle ------------------------------------- def _to_vehicle(self, d: dict, blob: str) -> Vehicle | None: if d.get("is_sold") or d.get("is_active") is False: return None slug = _s(d.get("slug")) ext_id = _s(d.get("inventory_provider_vehicle_id")) or slug if not ext_id or not slug: return None make = _s(d.get("make")) model = _s(d.get("model")) body = _s(d.get("body_type")) if _EXCLUDE_RE.search(f"{make} {model} {body}"): # non-automobile return None year = d.get("year") trim = _s(d.get("trim")) title = " ".join(x for x in (make, model, trim, _s(year)) if x) price = d.get("price") try: price = float(price) if price else None except (TypeError, ValueError): price = None km = d.get("mileage_from_odometer") try: km = float(km) if km not in (None, "") else None except (TypeError, ValueError): km = None images = d.get("images") if isinstance(images, str) and images.startswith("$"): images = self._resolve_ref(blob, images) if isinstance(images, str) and images.lstrip().startswith("["): try: # parfois une liste JSON sérialisée images = json.loads(images) except ValueError: images = [] if isinstance(images, str): images = [images] images = [u for u in (images or []) if isinstance(u, str)][:20] doors = d.get("doors") seats = d.get("seating_capacity") details: dict = {} for src, dst in (("is_certified", "certified"), ("is_accidented", "accidented"), ("is_unique_owner", "unique_owner")): if d.get(src) is not None: details[dst] = bool(d[src]) if _s(d.get("fuel_consumption_city")): details["consumption_city_l_100km"] = _s(d["fuel_consumption_city"]) if _s(d.get("fuel_consumption_highway")): details["consumption_hwy_l_100km"] = _s(d["fuel_consumption_highway"]) promo = _s(d.get("promotional_price")) if promo and promo not in ("0", "0.0"): details["promotional_price"] = promo if _s(d.get("dealer_id")): details["branch_slug"] = _s(d["dealer_id"]) return Vehicle( source=self.source_id, external_id=ext_id, url=f"{self.base_url}/auto-usage/{slug}/", title=title, make=make, model=model, trim=trim, year=int(year) if year else None, price=price, price_label=f"{price:,.0f} $".replace(",", " ") if price else "", mileage_km=km, mileage_label=f"{km:,.0f} km".replace(",", " ") if km else "", transmission=_s(d.get("transmission")), fuel=_s(d.get("fuel_type")), drivetrain=_s(d.get("drive_wheel_configuration")), body_type=body, exterior_color=_s(d.get("exterior_color")), interior_color=_s(d.get("interior_color")), engine=_s(d.get("engine_type")), doors=int(doors) if doors else None, seats=int(seats) if seats else None, vin=_s(d.get("vin")), stock_number=_s(d.get("stock_number")), dealer_name=_s(d.get("display_dealer_name")) or _s(d.get("dealer_name")) or self.dealer_name, city=_s(d.get("display_city")) or _s(d.get("city")), description=_s(d.get("short_description"))[:4000], details=details, images=images, carfax_url=_s(d.get("carfax_url")), )