# ----------------------------------------------------------------------------- # Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/albioccasion.py : connecteur ALBI Occasion (albioccasion.com), # la division occasion d'ALBI le Géant — très grosse chaîne multi- # succursales (Mascouche, Repentigny, Blainville, Laval, Rive-Nord…), # ~1 100+ véhicules d'occasion. # # Plateforme : ASP.NET WebForms maison. La liste /fr/inventaire/?page=N # (12 véhicules/page, « Page X de Y » dans le HTML) référence chaque page # détail /fr/vehicule///. La page détail expose un microdata # schema.org complet (itemprop) : prix, km, VIN, année, version, # transmission, portes, places, couleurs, carrosserie + des lignes de # spécifications (SpecLine : entraînement, catégorie…), la galerie photos # (S3 enzo-production …/vehicules_medias/…) et la succursale réelle du # véhicule (lien Google Maps « ALBI Occasion »). # # Pages détail en cache BD (clé hebdomadaire — le prix est revalidé chaque # semaine). AUTOKA_MAX_PAGES limite la pagination liste et # AUTOKA_MAX_DETAILS plafonne les vraies requêtes détail par sync. # ----------------------------------------------------------------------------- from __future__ import annotations import datetime import html as htmllib import os import re import urllib.parse from ..schema import Vehicle from .base import BaseConnector _LINK_RE = re.compile(r'href="/fr/vehicule/(\d+)/([^"/]+)/?"') _TOTAL_PAGES_RE = re.compile(r'TotalPagesLabel">\s*(\d+)') _H1_RE = re.compile(r"]*>(.*?)", re.S) _ITEMPROP_CONTENT_RE = r'itemprop="{name}"[^>]*content="([^"]*)"' _ITEMPROP_TEXT_RE = r'itemprop="{name}"[^>]*>\s*([^<]+)' _SPEC_RE = re.compile(r'class="SpecLine">\s*([^:<]+):\s*(?:]*>)?' r"\s*([^<]*)", re.S) _STOCK_RE = re.compile(r'data\.stock\s*=\s*"([^"]+)"') _BRANCH_RE = re.compile(r"maps/dir//([^,\"]+),") _IMG_RE = re.compile(r'https://[^"\'\s\\]+/vehicules_medias/[^"\'\s\\]+' r"\.(?:jpe?g|png|webp)", re.I) _EXCLUDE_RE = re.compile( r"moto(?:cyclette|neige|marine)?\b|\bvtt\b|scooter|spyder|ryker|" r"can-?am|ski-?doo|sea-?doo|roulotte|remorque|\bvr\b|campeur", re.I) def _clean(text) -> str: if not text: return "" out = htmllib.unescape(str(text)) out = re.sub(r"<[^>]+>", " ", out) return re.sub(r"\s{2,}", " ", out).strip() def _prop(html: str, name: str) -> str: m = re.search(_ITEMPROP_CONTENT_RE.format(name=name), html) if m and m.group(1).strip(): return _clean(m.group(1)) m = re.search(_ITEMPROP_TEXT_RE.format(name=name), html) return _clean(m.group(1)) if m else "" def _to_float(text) -> float | None: if text in (None, ""): return None t = re.sub(r"[^\d.]", "", str(text)) try: return float(t) if t else None except ValueError: return None class AlbiOccasion(BaseConnector): """ALBI Occasion — liste ASP.NET paginée + pages détail (microdata).""" source_id = "albioccasion" base_url = "https://www.albioccasion.com" dealer_name = "ALBI Occasion" city = "Mascouche" request_delay = 2.5 # le site retourne 429 sous ~1 s/requête max_pages = 150 # garde-fou dur (~95 pages réelles) max_details = 400 # plafond de vraies requêtes par sync def get(self, url, **kw): """GET avec backoff sur 429 (rate-limit ALBI).""" import time as _t import requests as _rq for attempt in range(4): try: return super().get(url, **kw) except _rq.exceptions.HTTPError as exc: if exc.response is not None and exc.response.status_code == 429 \ and attempt < 3: _t.sleep(20 * (attempt + 1)) # 20 s, 40 s, 60 s continue raise # -- pages liste -> [(id, slug)] -------------------------------------------- def _vehicle_refs(self) -> list[tuple[str, str]]: cap = int(os.environ.get("AUTOKA_MAX_PAGES", self.max_pages)) refs: list[tuple[str, str]] = [] seen: set[str] = set() page, last_page = 1, 1 while page <= min(last_page, cap, self.max_pages): url = f"{self.base_url}/fr/inventaire/" if page > 1: url += f"?page={page}" html = self.get(url).text if page == 1: m = _TOTAL_PAGES_RE.search(html) if m: last_page = int(m.group(1)) new = 0 for veh_id, slug in _LINK_RE.findall(html): if veh_id not in seen: seen.add(veh_id) refs.append((veh_id, slug)) new += 1 if new == 0: # page recyclée / fin atteinte break page += 1 return refs # -- page détail -> payload --------------------------------------------------- def _fetch_detail(self, url: str) -> dict: html = self.get(url).text payload: dict = {} m = _H1_RE.search(html) if m: payload["title"] = _clean(m.group(1)) for name in ("price", "vehicleModelDate", "vehicleConfiguration", "vehicleIdentificationNumber", "mileageFromOdometer", "vehicleTransmission", "color", "vehicleInteriorColor", "bodyType", "numberOfDoors", "vehicleSeatingCapacity", "description", "name"): value = _prop(html, name) if value: payload[name] = value specs: dict[str, str] = {} for label, value in _SPEC_RE.findall(html): label, value = _clean(label), _clean(value) if label and value: specs.setdefault(label, value) payload["specs"] = specs m = _STOCK_RE.search(html) if m: payload["stock"] = m.group(1).strip() m = re.search(r'data\.make\s*=\s*"([^"]*)"', html) if m: payload["make"] = m.group(1).strip() m = re.search(r'data\.model\s*=\s*"([^"]*)"', html) if m: payload["model"] = m.group(1).strip() m = _BRANCH_RE.search(html) if m: payload["branch"] = urllib.parse.unquote_plus(m.group(1)).strip() images, seen = [], set() for u in _IMG_RE.findall(html): if u not in seen: seen.add(u) images.append(u) payload["images"] = images[:20] return payload # -- contrat --------------------------------------------------------------- def fetch(self) -> list[Vehicle]: refs = self._vehicle_refs() week = datetime.date.today().isocalendar() cache_key = f"v1:{week.year}w{week.week}" # revalidation hebdomadaire cap = int(os.environ.get("AUTOKA_MAX_DETAILS", self.max_details)) vehicles: list[Vehicle] = [] real_fetches = 0 for veh_id, slug in refs: url = f"{self.base_url}/fr/vehicule/{veh_id}/{slug}/" def _fetch(u=url): return self._fetch_detail(u) if real_fetches >= cap: # plafond : cache seulement from .. import db if self._detail_con is None: self._detail_con = db.connect() data = db.get_cached_detail(self._detail_con, self.source_id, veh_id, cache_key) if data is None: continue else: before = self._last_request try: data = self.detail(veh_id, cache_key, _fetch) except Exception: # page disparue (vendu) ou erreur ponctuelle : continuer if self._last_request != before: real_fetches += 1 continue if self._last_request != before: real_fetches += 1 if not data: continue veh = self._to_vehicle(veh_id, url, data) if veh is not None: vehicles.append(veh) return vehicles # -- payload -> Vehicle ------------------------------------------------------ def _to_vehicle(self, veh_id: str, url: str, d: dict) -> Vehicle | None: specs = d.get("specs") or {} make = _clean(d.get("make")) model = _clean(d.get("model")) title = _clean(d.get("name") or d.get("title")) if not title and not (make or model): return None body = _clean(d.get("bodyType") or specs.get("Catégorie")) if _EXCLUDE_RE.search(f"{make} {model} {body} {title}"): return None year = None m = re.search(r"(19|20)\d{2}", str(d.get("vehicleModelDate") or "")) if m: year = int(m.group(0)) branch = _clean(d.get("branch")) city = self.city if branch: # « ALBI Occasion Mascouche » -> ville ; les succursales de # marque (« ALBI Le Géant Mazda »…) restent sur la ville de repli m = re.match(r"ALBI\s+Occasion\s+(.+)$", branch, re.I) if m: city = m.group(1).strip() details: dict = {} if branch: details["branch"] = branch if specs.get("Type"): details["size_class"] = specs["Type"] if specs.get("Garantie manufacturier"): details["manufacturer_warranty"] = specs["Garantie manufacturier"] price = _to_float(d.get("price")) km = _to_float(d.get("mileageFromOdometer")) return Vehicle( source=self.source_id, external_id=veh_id, url=url, title=title or " ".join(x for x in (make, model, year or "") if x), make=make, model=model, trim=_clean(d.get("vehicleConfiguration")), year=year, 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=_clean(d.get("vehicleTransmission") or specs.get("Transmission")), drivetrain=_clean(specs.get("Entraînement")), body_type=body, exterior_color=_clean(d.get("color") or specs.get("Couleur extérieure")), interior_color=_clean(d.get("vehicleInteriorColor") or specs.get("Couleur intérieure")), doors=int(_to_float(d.get("numberOfDoors") or specs.get("Portes")) or 0) or None, seats=int(_to_float(d.get("vehicleSeatingCapacity") or specs.get("Places Assises")) or 0) or None, vin=_clean(d.get("vehicleIdentificationNumber")), stock_number=_clean(d.get("stock")), dealer_name=branch or self.dealer_name, city=city, description=_clean(d.get("description"))[:4000], details=details, images=d.get("images") or [], )