Python 61.6%
TypeScript 20.9%
CSS 11.4%
JavaScript 5.1%
HTML 1.1%
1# -----------------------------------------------------------------------------2# Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/occasioncharlevoix.py : connecteur Occasion Charlevoix5# (occasioncharlevoix.com), regroupement de concessionnaires d'occasion de6# Baie-Saint-Paul (Charlevoix, Capitale-Nationale) — ~30 véhicules.7#8# Plateforme : OctoberCMS, thème « nerd2 » + plugins nerd/inventory9# (plateforme québécoise NerdAuto). La page liste /fr/inventaire/occasion10# est ENTIÈREMENT rendue côté serveur : chaque carte (div.vrp4-card)11# contient marque, modèle, version, année, no de stock, kilométrage, photos12# (carrousel) et un bloc « Autoverify » (div.av-srp-ecomm) avec VIN et prix13# réel (data-av-price — le prix affiché en gros est un paiement périodique).14#15# La page détail complète (cache BD, clé statique v1) : transmission,16# cylindrée, motricité, couleurs, catégorie, lien Carfax.17# -----------------------------------------------------------------------------18from __future__ import annotations1920import os21import re2223from ..schema import Vehicle, parse_mileage24from .base import BaseConnector2526# garde-fou non-automobile27_EXCLUDE_RE = re.compile(28 r"motoneige|vtt|moto\b|motomarine|bateau|roulotte|remorque|spyder", re.I)2930_SPEC_RE = re.compile(31 r'class="specs-name[^"]*">\s*([^<]+?)\s*</div>\s*'32 r'<div class="specs-info[^"]*">\s*([^<]*?)\s*<', re.S)3334_CARFAX_RE = re.compile(r'data-lien-carfax="([^"]+)"')35# repli : lien direct vhr.carfax.ca rendu ailleurs dans la page (vérifié36# 2026-08 : ~55 % des fiches ont data-lien-carfax ; les autres n'exposent37# aucun rapport — le bloc Autoverify data-av-* ne porte que VIN/prix/km)38_CARFAX_VHR_RE = re.compile(r'https?://vhr\.carfax\.ca/[^\s"\'<>\\]+', re.I)3940_AV_DETAIL_RE = re.compile(41 r'id="av_vehicle_information"[^>]*data-av-mileage="([^"]*)"')424344def _txt(node) -> str:45 return re.sub(r"\s+", " ", node.get_text(" ", strip=True)) if node else ""464748class OccasionCharlevoix(BaseConnector):49 """Occasion Charlevoix — cartes liste rendues serveur (OctoberCMS nerd)."""5051 source_id = "occasioncharlevoix"52 base_url = "https://www.occasioncharlevoix.com"53 dealer_name = "Occasion Charlevoix"54 city = "Baie-Saint-Paul"55 request_delay = 1.056 max_details = 2005758 def fetch(self) -> list[Vehicle]:59 from bs4 import BeautifulSoup60 cap = int(os.environ.get("AUTOKA_MAX_DETAILS", self.max_details))61 html = self.get(f"{self.base_url}/fr/inventaire/occasion").text62 soup = BeautifulSoup(html, "html.parser")6364 vehicles: list[Vehicle] = []65 seen: set[str] = set()66 real_fetches = 067 for card in soup.select("div.vrp4-card"):68 try:69 veh = self._card_to_vehicle(card)70 except Exception:71 continue # carte malformée isolée72 if veh is None or veh.external_id in seen:73 continue74 seen.add(veh.external_id)7576 # enrichissement page détail (transmission, couleurs, carfax…)77 if real_fetches < cap:78 url = veh.url7980 def _fetch(u=url):81 return self._fetch_detail(u)8283 try:84 extra = self.detail(veh.external_id, "v1", _fetch)85 real_fetches += 186 self._apply_detail(veh, extra)87 except Exception:88 real_fetches += 1 # ne jamais bloquer la source89 vehicles.append(veh)90 return vehicles9192 # -- carte liste -> Vehicle -------------------------------------------------93 def _card_to_vehicle(self, card) -> Vehicle | None:94 link = card.select_one("a.vrp4-card-header[href]")95 if link is None:96 return None97 url = link["href"]98 if "/inventaire/vehicule/" not in url:99 return None100 if url.startswith("/"):101 url = self.base_url + url102103 # /fr/inventaire/vehicule/occasion/<stock>/<marque>/<modèle>/…104 m = re.search(r"/vehicule/[^/]+/([^/]+)/", url)105 ext_id = m.group(1) if m else url.rstrip("/").rsplit("/", 1)[-1]106107 make = _txt(card.select_one(".car-make"))108 model = _txt(card.select_one(".car-model"))109 trim = _txt(card.select_one(".car-trim"))110 if _EXCLUDE_RE.search(f"{make} {model} {trim}"):111 return None112113 year_txt = _txt(card.select_one(".car-year"))114 year = int(year_txt) if year_txt.isdigit() else None115116 stock = _txt(card.select_one(".car-stock")).lstrip("#")117 odo_label = _txt(card.select_one(".car-odometer"))118 km = parse_mileage(odo_label)119120 # bloc Autoverify : VIN + prix réel (le prix visible est un paiement)121 vin, price = "", None122 av = card.select_one(".av-srp-ecomm")123 if av is not None:124 vin = (av.get("data-av-vin") or "").strip().upper()125 try:126 price = float(av.get("data-av-price") or 0) or None127 except ValueError:128 price = None129 # garde-fou : < 1 500 $ = paiement périodique saisi comme prix130 if price is not None and price < 1500:131 price = None132133 images = []134 for img in card.select("img.car-img[src]"):135 src = img["src"]136 if src.startswith("/"):137 src = self.base_url + src138 if src not in images:139 images.append(src)140141 title = " ".join(x for x in (make, model, trim, year_txt) if x)142 if not title:143 return None144145 return Vehicle(146 source=self.source_id,147 external_id=ext_id,148 url=url,149 title=title,150 make=make,151 model=model,152 trim=trim,153 year=year,154 price=price,155 price_label=f"{price:,.0f} $".replace(",", " ") if price else "",156 mileage_km=km,157 mileage_label=odo_label,158 vin=vin,159 stock_number=stock or ext_id.upper(),160 dealer_name=self.dealer_name,161 city=self.city,162 images=images[:20],163 )164165 # -- page détail -------------------------------------------------------------166 def _fetch_detail(self, url: str) -> dict:167 html = self.get(url).text168 payload: dict = {"specs": {}}169 for name, value in _SPEC_RE.findall(html):170 payload["specs"][name.rstrip(": ").strip()] = value.strip()171 m = _CARFAX_RE.search(html)172 if m:173 payload["carfax"] = m.group(1)174 else:175 m = _CARFAX_VHR_RE.search(html)176 if m:177 payload["carfax"] = m.group(0)178 m = _AV_DETAIL_RE.search(html)179 if m and m.group(1):180 payload["mileage"] = m.group(1)181 return payload182183 def _apply_detail(self, veh: Vehicle, extra: dict) -> None:184 specs = (extra or {}).get("specs") or {}185 for name, value in specs.items():186 low = name.lower()187 if not value or value.lower() in ("autre", "n/d", "s/o"):188 continue189 if "transmission" in low and not veh.transmission:190 veh.transmission = value191 elif "motricit" in low and not veh.drivetrain:192 veh.drivetrain = value193 elif "cat" in low and not veh.body_type:194 veh.body_type = value195 elif "cylindr" in low and not veh.engine:196 veh.engine = value197 elif "ext" in low and "couleur" in low and not veh.exterior_color:198 veh.exterior_color = value199 elif "int" in low and "couleur" in low and not veh.interior_color:200 veh.interior_color = value201 if extra.get("carfax") and not veh.carfax_url:202 veh.carfax_url = extra["carfax"]203 if veh.mileage_km is None and extra.get("mileage"):204 try:205 veh.mileage_km = float(extra["mileage"])206 except ValueError:207 pass208