# -----------------------------------------------------------------------------
# Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)
# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
# connectors/occasioncharlevoix.py : connecteur Occasion Charlevoix
# (occasioncharlevoix.com), regroupement de concessionnaires d'occasion de
# Baie-Saint-Paul (Charlevoix, Capitale-Nationale) — ~30 véhicules.
#
# Plateforme : OctoberCMS, thème « nerd2 » + plugins nerd/inventory
# (plateforme québécoise NerdAuto). La page liste /fr/inventaire/occasion
# est ENTIÈREMENT rendue côté serveur : chaque carte (div.vrp4-card)
# contient marque, modèle, version, année, no de stock, kilométrage, photos
# (carrousel) et un bloc « Autoverify » (div.av-srp-ecomm) avec VIN et prix
# réel (data-av-price — le prix affiché en gros est un paiement périodique).
#
# La page détail complète (cache BD, clé statique v1) : transmission,
# cylindrée, motricité, couleurs, catégorie, lien Carfax.
# -----------------------------------------------------------------------------
from __future__ import annotations
import os
import re
from ..schema import Vehicle, parse_mileage
from .base import BaseConnector
# garde-fou non-automobile
_EXCLUDE_RE = re.compile(
r"motoneige|vtt|moto\b|motomarine|bateau|roulotte|remorque|spyder", re.I)
_SPEC_RE = re.compile(
r'class="specs-name[^"]*">\s*([^<]+?)\s*\s*'
r'
\s*([^<]*?)\s*<', re.S)
_CARFAX_RE = re.compile(r'data-lien-carfax="([^"]+)"')
_AV_DETAIL_RE = re.compile(
r'id="av_vehicle_information"[^>]*data-av-mileage="([^"]*)"')
def _txt(node) -> str:
return re.sub(r"\s+", " ", node.get_text(" ", strip=True)) if node else ""
class OccasionCharlevoix(BaseConnector):
"""Occasion Charlevoix — cartes liste rendues serveur (OctoberCMS nerd)."""
source_id = "occasioncharlevoix"
base_url = "https://www.occasioncharlevoix.com"
dealer_name = "Occasion Charlevoix"
city = "Baie-Saint-Paul"
request_delay = 1.0
max_details = 200
def fetch(self) -> list[Vehicle]:
from bs4 import BeautifulSoup
cap = int(os.environ.get("AUTOKA_MAX_DETAILS", self.max_details))
html = self.get(f"{self.base_url}/fr/inventaire/occasion").text
soup = BeautifulSoup(html, "html.parser")
vehicles: list[Vehicle] = []
seen: set[str] = set()
real_fetches = 0
for card in soup.select("div.vrp4-card"):
try:
veh = self._card_to_vehicle(card)
except Exception:
continue # carte malformée isolée
if veh is None or veh.external_id in seen:
continue
seen.add(veh.external_id)
# enrichissement page détail (transmission, couleurs, carfax…)
if real_fetches < cap:
url = veh.url
def _fetch(u=url):
return self._fetch_detail(u)
try:
extra = self.detail(veh.external_id, "v1", _fetch)
real_fetches += 1
self._apply_detail(veh, extra)
except Exception:
real_fetches += 1 # ne jamais bloquer la source
vehicles.append(veh)
return vehicles
# -- carte liste -> Vehicle -------------------------------------------------
def _card_to_vehicle(self, card) -> Vehicle | None:
link = card.select_one("a.vrp4-card-header[href]")
if link is None:
return None
url = link["href"]
if "/inventaire/vehicule/" not in url:
return None
if url.startswith("/"):
url = self.base_url + url
# /fr/inventaire/vehicule/occasion////…
m = re.search(r"/vehicule/[^/]+/([^/]+)/", url)
ext_id = m.group(1) if m else url.rstrip("/").rsplit("/", 1)[-1]
make = _txt(card.select_one(".car-make"))
model = _txt(card.select_one(".car-model"))
trim = _txt(card.select_one(".car-trim"))
if _EXCLUDE_RE.search(f"{make} {model} {trim}"):
return None
year_txt = _txt(card.select_one(".car-year"))
year = int(year_txt) if year_txt.isdigit() else None
stock = _txt(card.select_one(".car-stock")).lstrip("#")
odo_label = _txt(card.select_one(".car-odometer"))
km = parse_mileage(odo_label)
# bloc Autoverify : VIN + prix réel (le prix visible est un paiement)
vin, price = "", None
av = card.select_one(".av-srp-ecomm")
if av is not None:
vin = (av.get("data-av-vin") or "").strip().upper()
try:
price = float(av.get("data-av-price") or 0) or None
except ValueError:
price = None
# garde-fou : < 1 500 $ = paiement périodique saisi comme prix
if price is not None and price < 1500:
price = None
images = []
for img in card.select("img.car-img[src]"):
src = img["src"]
if src.startswith("/"):
src = self.base_url + src
if src not in images:
images.append(src)
title = " ".join(x for x in (make, model, trim, year_txt) if x)
if not title:
return None
return Vehicle(
source=self.source_id,
external_id=ext_id,
url=url,
title=title,
make=make,
model=model,
trim=trim,
year=year,
price=price,
price_label=f"{price:,.0f} $".replace(",", " ") if price else "",
mileage_km=km,
mileage_label=odo_label,
vin=vin,
stock_number=stock or ext_id.upper(),
dealer_name=self.dealer_name,
city=self.city,
images=images[:20],
)
# -- page détail -------------------------------------------------------------
def _fetch_detail(self, url: str) -> dict:
html = self.get(url).text
payload: dict = {"specs": {}}
for name, value in _SPEC_RE.findall(html):
payload["specs"][name.rstrip(": ").strip()] = value.strip()
m = _CARFAX_RE.search(html)
if m:
payload["carfax"] = m.group(1)
m = _AV_DETAIL_RE.search(html)
if m and m.group(1):
payload["mileage"] = m.group(1)
return payload
def _apply_detail(self, veh: Vehicle, extra: dict) -> None:
specs = (extra or {}).get("specs") or {}
for name, value in specs.items():
low = name.lower()
if not value or value.lower() in ("autre", "n/d", "s/o"):
continue
if "transmission" in low and not veh.transmission:
veh.transmission = value
elif "motricit" in low and not veh.drivetrain:
veh.drivetrain = value
elif "cat" in low and not veh.body_type:
veh.body_type = value
elif "cylindr" in low and not veh.engine:
veh.engine = value
elif "ext" in low and "couleur" in low and not veh.exterior_color:
veh.exterior_color = value
elif "int" in low and "couleur" in low and not veh.interior_color:
veh.interior_color = value
if extra.get("carfax") and not veh.carfax_url:
veh.carfax_url = extra["carfax"]
if veh.mileage_km is None and extra.get("mileage"):
try:
veh.mileage_km = float(extra["mileage"])
except ValueError:
pass