# ----------------------------------------------------------------------------- # 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