SPB Git forge

spb/auto-ka

Public
61commits 1branches 0releases
14.4 MBsize
maindefault branch
13 days agolast push
Python 61.6% TypeScript 20.9% CSS 11.4% JavaScript 5.1% HTML 1.1%
8.0 KB · 208 lines python
Raw Blame History
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