SPB Git

spb/auto-ka Public

Python 81.9% TypeScript 12.4% CSS 5.5%
26.9 KB · 834 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/d2c_dealers.py : connecteurs des concessionnaires sur plateforme5#   D2C Media (le fournisseur de sites le plus répandu chez les marchands6#   d'occasion au Québec).7#8#   Stratégie : le sitemap (/fr/sitemap.xml) recense chaque page véhicule9#   (…-id123456.html). Chaque page détail expose un JSON-LD schema.org10#   complet (@type Vehicle/Car) : VIN, kilométrage, transmission, carburant,11#   carrosserie, couleurs, prix, photos. Les pages détail sont mises en cache12#   (clé hebdomadaire) : seuls les nouveaux véhicules — et une revalidation13#   par semaine pour les prix — génèrent de vraies requêtes.14#15#   Enrichissement (clé de cache v2) — en plus du JSON-LD (qui ne liste que16#   3 photos, parfois avec des ids corrompus — bug D2C) :17#   - galerie COMPLÈTE : les URLs imagescdn.d2cmedia.ca/<hash>/<dealer>/<id>/18#     <n>/… présentes dans le HTML (préférence à la variante « cb » grand19#     format, sinon « mb »/« s8 »), triées par index de photo ;20#   - endpoint JSON par véhicule /js/json/<id>.json (celui que le JS du site21#     appelle pour révéler la section « Options ») : optionsTextFR (liste22#     d'équipements en français), moteur + cylindre, carprooflink (rapport23#     Carfax direct quand le concessionnaire l'expose) ;24#   - repli équipements : titres des topFeatures/safetyFeatures du blob25#     window.__vdpJSON embarqué dans la page.26#27#   Le lastmod du sitemap est bumpé quotidiennement par D2C (inutilisable28#   comme clé de cache) — d'où la clé temporelle hebdomadaire.29# -----------------------------------------------------------------------------30from __future__ import annotations3132import datetime33import html as htmllib34import json35import os36import re3738from ..schema import Vehicle39from .base import BaseConnector4041# segments d'URL /occasion/<segment>/ qui ne sont PAS des automobiles42_EXCLUDE_SEGMENTS = re.compile(43    r"motoneige|vtt|bateau|ponton|motomarine|moto(?:s)?\b|roulotte|remorque|"44    r"\bvr\b|campeur|tracteur|souffleuse|scooter|spyder|side|atv|snow|"45    r"powersport|nautique|habitation", re.I)4647_VEH_URL_RE = re.compile(r"-id(\d+)\.html?$")4849_DRIVE_MAP = {50    "FrontWheelDriveConfiguration": "Traction",51    "RearWheelDriveConfiguration": "Propulsion",52    "AllWheelDriveConfiguration": "Intégrale / 4x4",53    "FourWheelDriveConfiguration": "Intégrale / 4x4",54}5556_LD_RE = re.compile(57    r'<script type=["\']application/ld\+json["\']>(.*?)</script>', re.S)5859# galerie CDN D2C : /<variante(2)+hash>/<dealer>/<idVéhicule>/<indexPhoto>/…60# (le nom de fichier peut contenir des espaces : « Silverado 1500-2023.jpg »)61_GALLERY_RE = re.compile(62    r'(https://imagescdn\.d2cmedia\.ca/'63    r'([a-z0-9]{2})[a-f0-9]+/\d+/(\d+)/(\d+)/[^"\'<>]+?\.(?:jpe?g|png|webp))',64    re.I)6566# variante d'image préférée : cb = grand format (1440px), mb = moyen, s8 = mini67_IMG_VARIANT_RANK = {"cb": 0, "mb": 1, "s8": 2}6869_VDP_JSON_RE = re.compile(r'window\.__vdpJSON\s*=\s*')7071MAX_IMAGES = 25727374def _extract_gallery(html: str, ext_id: str) -> list[str]:75    """Galerie complète depuis le HTML, dédupliquée par index de photo,76    dans l'ordre de la galerie (index croissant), meilleure variante d'abord.77    """78    best: dict[int, tuple[int, str]] = {}   # index photo -> (rang, url)79    for url, variant, veh_id, idx in _GALLERY_RE.findall(html):80        if veh_id != ext_id:                 # vignettes d'autres véhicules81            continue82        try:83            idx = int(idx)84        except ValueError:85            continue86        rank = _IMG_VARIANT_RANK.get(variant.lower(), 3)87        if idx not in best or rank < best[idx][0]:88            best[idx] = (rank, url.replace(" ", "%20"))89    return [best[i][1] for i in sorted(best)][:MAX_IMAGES]909192def _extract_vdp_json(html: str) -> dict:93    """Blob `window.__vdpJSON = {...}` embarqué dans la page (si présent)."""94    m = _VDP_JSON_RE.search(html)95    if not m:96        return {}97    try:98        obj, _ = json.JSONDecoder().raw_decode(html, m.end())99    except ValueError:100        return {}101    return obj if isinstance(obj, dict) else {}102103104def _clean(text: str | None) -> str:105    """Désencode les entités HTML (souvent doublement encodées chez D2C)."""106    if not text:107        return ""108    out = htmllib.unescape(htmllib.unescape(str(text)))109    out = re.sub(r"<[^>]+>", " ", out)110    return re.sub(r"\s{2,}", " ", out).strip()111112113class D2CConnector(BaseConnector):114    """Base commune aux sites D2C Media. Sous-classes : base_url + ville."""115116    base_url: str = ""117    dealer_name: str = ""118    city: str = ""119    request_delay: float = 1.0        # robots.txt D2C demande de la politesse120    use_firecrawl: bool = False       # sites derrière Cloudflare121122    # plafond de vraies requêtes détail par sync (AUTOKA_MAX_DETAILS le réduit123    # pour les tests) — le reste sera complété aux syncs suivants124    max_details = 600125126    def _get_text(self, url: str) -> str:127        if self.use_firecrawl:128            return self.get_rendered(url)129        return self.get(url).text130131    # -- sitemap ---------------------------------------------------------------132    def _vehicle_urls(self) -> list[str]:133        xml = self._get_text(f"{self.base_url}/fr/sitemap.xml")134        urls = []135        for loc in re.findall(r"<loc>([^<]+)</loc>", xml):136            loc = loc.strip()137            if "/occasion/" not in loc or not _VEH_URL_RE.search(loc):138                continue139            # catégorie intermédiaire ? (/occasion/motoneiges/…)140            path = loc.split("/occasion/", 1)[1]141            segments = path.split("/")[:-1]142            if any(_EXCLUDE_SEGMENTS.search(s) for s in segments):143                continue144            urls.append(loc)145        return urls146147    # -- page détail -> payload JSON-LD enrichi --------------------------------148    def _fetch_detail(self, url: str) -> dict:149        html = self._get_text(url)150        data: dict = {}151        for block in _LD_RE.findall(html):152            try:153                cand = json.loads(block.strip())154            except ValueError:155                continue156            types = cand.get("@type")157            types = types if isinstance(types, list) else [types]158            if "Vehicle" in types or "Car" in types:159                data = cand160                break161        if not data:162            return {}163164        # --- enrichissements (tous optionnels : jamais bloquants) -------------165        m = _VEH_URL_RE.search(url)166        ext_id = m.group(1) if m else ""167168        try:169            gallery = _extract_gallery(html, ext_id)170        except Exception:171            gallery = []172        if gallery:173            data["_gallery"] = gallery174175        vdp = {}176        try:177            vdp = _extract_vdp_json(html)178        except Exception:179            pass180181        # endpoint JSON par véhicule : équipements, moteur, lien Carfax182        extra: dict = {}183        if ext_id:184            try:185                extra = self.get(f"{self.base_url}/js/json/{ext_id}.json").json()186                if not isinstance(extra, dict):187                    extra = {}188            except Exception:189                extra = {}190191        features = [_clean(t) for t in (extra.get("optionsTextFR") or [])192                    if isinstance(t, str) and _clean(t)]193        if not features:                     # repli : blob __vdpJSON de la page194            for group in (vdp.get("topFeatures"), vdp.get("safetyFeatures")):195                for item in group or []:196                    if isinstance(item, dict):197                        t = _clean(item.get("title"))198                        if t and t not in features:199                            features.append(t)200        if features:201            data["_features"] = features202203        engine = _clean(str(extra.get("moteur") or vdp.get("engine") or ""))204        # D2C renvoie « N.D. » ou « . L » (véhicules électriques) : rejeter205        # toute cylindrée sans chiffre206        if engine.upper() in ("N.D.", "N/A", "ND") or not re.search(r"\d", engine):207            engine = ""208        cyl = _clean(str(extra.get("cylindre") or vdp.get("cylinder") or ""))209        if cyl and cyl.upper() not in ("N.D.", "N/A", "ND", "0"):210            if cyl.isdigit():211                # nombre nu (parfois erroné pour les VÉ) : seulement en212                # complément d'une cylindrée valide, jamais seul213                if engine:214                    engine = f"{engine} {cyl} cyl."215            elif cyl.lower() not in engine.lower():216                engine = f"{engine} {cyl}".strip()217        if engine:218            data["_engine"] = engine219220        carfax = str(extra.get("carprooflink") or "").strip()221        if not carfax:222            cp = vdp.get("carproof")223            if isinstance(cp, dict):224                carfax = str(cp.get("link") or "").strip()225        if carfax.startswith("http"):226            data["_carfax_url"] = carfax227228        return data229230    # -- contrat ---------------------------------------------------------------231    def fetch(self) -> list[Vehicle]:232        urls = self._vehicle_urls()233        week = datetime.date.today().isocalendar()234        # v2 : payloads enrichis (galerie complète, équipements, moteur,235        # carfax) — le bump force la re-crawl progressive des pages en cache236        cache_key = f"v2:{week.year}w{week.week}"   # revalidation hebdomadaire237        cap = int(os.environ.get("AUTOKA_MAX_DETAILS", self.max_details))238239        vehicles: list[Vehicle] = []240        real_fetches = 0241        for url in urls:242            ext_id = _VEH_URL_RE.search(url).group(1)243244            def _fetch(u=url):245                return self._fetch_detail(u)246247            # plafond atteint : ne servir que le cache (fetch réel interdit)248            if real_fetches >= cap:249                from .. import db250                if self._detail_con is None:251                    self._detail_con = db.connect()252                data = db.get_cached_detail(self._detail_con, self.source_id,253                                            ext_id, cache_key)254                if data is None:255                    continue256            else:257                before = self._last_request258                try:259                    data = self.detail(ext_id, cache_key, _fetch)260                except Exception:261                    # page détail disparue (vendu, sitemap pas encore purgé)262                    # ou erreur ponctuelle : ne jamais bloquer la source263                    if self._last_request != before:264                        real_fetches += 1265                    continue266                if self._last_request != before:   # une vraie requête a eu lieu267                    real_fetches += 1268            if not data:269                continue270            veh = self._to_vehicle(ext_id, url, data)271            if veh is not None:272                vehicles.append(veh)273        return vehicles274275    def _to_vehicle(self, ext_id: str, url: str, d: dict) -> Vehicle | None:276        offers = d.get("offers") or {}277        price = offers.get("price")278        try:279            price = float(price) if price not in (None, "", 0, "0") else None280        except (TypeError, ValueError):281            price = None282283        odo = d.get("mileageFromOdometer") or {}284        km = odo.get("value") if isinstance(odo, dict) else odo285        try:286            km = float(km) if km not in (None, "") else None287        except (TypeError, ValueError):288            km = None289290        brand = d.get("brand") or {}291        make = brand.get("name") if isinstance(brand, dict) else str(brand)292293        drive_raw = str(d.get("driveWheelConfiguration") or "")294        drivetrain = ""295        for key, canon in _DRIVE_MAP.items():296            if key in drive_raw:297                drivetrain = canon298                break299300        year = None301        if d.get("vehicleModelDate"):302            try:303                year = int(str(d["vehicleModelDate"])[:4])304            except ValueError:305                pass306307        # galerie complète extraite du HTML (payloads v2) de préférence au308        # JSON-LD (limité à 3 photos, ids parfois corrompus — bug D2C)309        images = d.get("_gallery") or d.get("image") or []310        if isinstance(images, str):311            images = [images]312313        title = _clean(d.get("name"))314        if not title:315            return None316317        return Vehicle(318            source=self.source_id,319            external_id=ext_id,320            url=url,321            title=title,322            make=_clean(make),323            model=_clean(d.get("model")),324            trim=_clean(d.get("vehicleConfiguration")),325            year=year,326            price=price,327            price_label=f"{price:,.0f} $".replace(",", " ") if price else "",328            mileage_km=km,329            mileage_label=f"{km:,.0f} km".replace(",", " ") if km else "",330            transmission=_clean(d.get("vehicleTransmission")),331            fuel=_clean(d.get("fuelType")),332            drivetrain=drivetrain,333            body_type=_clean(d.get("bodyType")),334            exterior_color=_clean(d.get("color")),335            interior_color=_clean(d.get("vehicleInteriorColor")),336            doors=d.get("numberOfDoors"),337            seats=d.get("vehicleSeatingCapacity"),338            vin=_clean(d.get("vehicleIdentificationNumber")),339            stock_number=str(d.get("sku") or ""),340            engine=_clean(d.get("_engine")),341            dealer_name=self.dealer_name,342            city=self.city,343            description=_clean(d.get("description"))[:4000],344            features=[f for f in (d.get("_features") or [])345                      if isinstance(f, str) and f],346            images=[u for u in images if isinstance(u, str)][:MAX_IMAGES],347            carfax_url=str(d.get("_carfax_url") or ""),348        )349350351# ---------------------------------------------------------------------------352# Concessionnaires (1 sous-classe = 1 source)353# ---------------------------------------------------------------------------354355class GirardAutos(D2CConnector):356    source_id = "girardautos"357    base_url = "https://www.girardautos.ca"358    dealer_name = "Girard Autos"359    city = "Trois-Rivières"360361362class ForceOccasion(D2CConnector):363    source_id = "forceoccasion"364    base_url = "https://www.forceoccasion.ca"365    dealer_name = "Force Occasion"366    city = "Québec"367368369class GervaisAuto(D2CConnector):370    source_id = "gervaisauto"371    base_url = "https://www.gervaisauto.com"372    dealer_name = "Gervais Auto"373    city = "Shawinigan"374375376class EquipeFormule(D2CConnector):377    source_id = "equipeformule"378    base_url = "https://www.equipeformule.com"379    dealer_name = "Équipe Formule"380    city = "Rimouski"381382383class AutosEvasion(D2CConnector):384    source_id = "autosevasion"385    base_url = "https://www.autosevasion.com"386    dealer_name = "Autos Évasion"387    city = "Sherbrooke"388389390class MFerlandAuto(D2CConnector):391    source_id = "mferlandauto"392    base_url = "https://www.mferlandauto.com"393    dealer_name = "M. Ferland Auto"394    city = "Sherbrooke"395396397class AutosOccasionLD(D2CConnector):398    source_id = "autosoccasionld"399    base_url = "https://www.autosoccasions.ca"400    dealer_name = "Autos Occasion LD"401    city = "Lévis"402403404class LevisAutos(D2CConnector):405    source_id = "levisautos"406    base_url = "https://www.levisautos.com"407    dealer_name = "Lévis Autos"408    city = "Lévis"409410411class AutoQuebecLevis(D2CConnector):412    source_id = "autoquebeclevis"413    base_url = "https://www.autoquebecoccasionlevis.com"414    dealer_name = "AutoQuébec Occasion Lévis"415    city = "Lévis"416417418class AutoQuebecThetford(D2CConnector):419    source_id = "autoquebectm"420    base_url = "https://www.autoquebectm.com"421    dealer_name = "AutoQuébec Occasion Thetford Mines"422    city = "Thetford Mines"423424425class AbitibiAutoCredit(D2CConnector):426    source_id = "abitibiautocredit"427    base_url = "https://www.abitibiautocredit.ca"428    dealer_name = "Abitibi Auto Crédit"429    city = "Rouyn-Noranda"430431432class NicolOccasion(D2CConnector):433    source_id = "nicoloccasion"434    base_url = "https://www.nicoloccasion.com"435    dealer_name = "Nicol Occasion"436    city = "La Sarre"437438439class PoirierOccasion(D2CConnector):440    source_id = "poirieroccasion"441    base_url = "https://www.poirieroccasion.com"442    dealer_name = "Poirier Occasion"443    city = "Val-d'Or"444445446class AutosStHubert(D2CConnector):447    source_id = "autosthubert"448    base_url = "https://www.autosthubert.com"449    dealer_name = "Auto St-Hubert"450    city = "Saint-Hubert"451452453class SherbrookeAutoOccasion(D2CConnector):454    source_id = "sherbrookeautooccasion"455    base_url = "https://www.sherbrookeautooccasion.com"456    dealer_name = "Sherbrooke Auto Occasion"457    city = "Sherbrooke"458459460# ---------------------------------------------------------------------------461# Expansion 2 — concessionnaires de marque et groupes sur D2C Media462# (inventaire OCCASION seulement : le sitemap /fr/sitemap.xml ne référence463#  que les pages /occasion/…-idNNN.html ; le neuf vit dans un sitemap séparé)464# ---------------------------------------------------------------------------465466class BernierCrepeau(D2CConnector):467    source_id = "berniercrepeau"468    base_url = "https://www.berniercrepeau.com"469    dealer_name = "Bernier Crépeau"470    city = "Trois-Rivières"471472473class ChomedeyToyota(D2CConnector):474    source_id = "chomedeytoyota"475    base_url = "https://www.chomedeytoyotalaval.ca"476    dealer_name = "Chomedey Toyota Laval"477    city = "Laval"478479480class DrummondvilleFord(D2CConnector):481    source_id = "drummondvilleford"482    base_url = "https://www.drummondvilleford.com"483    dealer_name = "Drummondville Ford"484    city = "Drummondville"485486487class DrummondvilleVW(D2CConnector):488    source_id = "drummondvillevw"489    base_url = "https://www.drummondvillevolkswagen.ca"490    dealer_name = "Drummondville Volkswagen"491    city = "Drummondville"492493494class DubeChrysler(D2CConnector):495    source_id = "dubechrysler"496    base_url = "https://www.dubechrysler.com"497    dealer_name = "Dubé Chrysler"498    city = "Rivière-du-Loup"499500501class DuclosLongueuil(D2CConnector):502    source_id = "ducloslongueuil"503    base_url = "https://www.ducloschryslerlongueuil.com"504    dealer_name = "Duclos Longueuil Chrysler"505    city = "Longueuil"506507508class DynastieHyundai(D2CConnector):509    source_id = "dynastiehyundai"510    base_url = "https://www.dynastiehyundai.com"511    dealer_name = "Dynastie Hyundai"512    city = "Rouyn-Noranda"513514515class FichaultKia(D2CConnector):516    source_id = "fichaultkia"517    base_url = "https://www.fichaultkia.com"518    dealer_name = "Fichault Kia"519    city = "Châteauguay"520521522class FordThetford(D2CConnector):523    source_id = "fordthetford"524    base_url = "https://www.fordthetford.com"525    dealer_name = "Ford Thetford"526    city = "Thetford Mines"527528529class GaspeToyota(D2CConnector):530    source_id = "gaspetoyota"531    base_url = "https://www.gaspe-toyota.com"532    dealer_name = "Gaspé Toyota"533    city = "Gaspé"534535536class GaspesieHyundai(D2CConnector):537    source_id = "gaspesiehyundai"538    base_url = "https://www.gaspesiehyundai.com"539    dealer_name = "Gaspésie Hyundai"540    city = "Bonaventure"541542543class GranbyToyota(D2CConnector):544    source_id = "granbytoyota"545    base_url = "https://www.granbytoyota.ca"546    dealer_name = "Granby Toyota"547    city = "Granby"548549550class GrandPortage(D2CConnector):551    source_id = "grandportage"552    base_url = "https://www.grandportageauto.com"553    dealer_name = "Groupe Grand-Portage"554    city = "Rivière-du-Loup"555556557class GroupeMotion(D2CConnector):558    source_id = "groupemotion"559    base_url = "https://www.groupemotion.com"560    dealer_name = "Groupe Motion"561    city = "Longueuil"562563564class GroupePaquin(D2CConnector):565    source_id = "groupepaquin"566    base_url = "https://www.groupepaquin.com"567    dealer_name = "Groupe Paquin"568    city = "Rouyn-Noranda"569570571class HullHyundai(D2CConnector):572    source_id = "hullhyundai"573    base_url = "https://www.hullhyundai.com"574    dealer_name = "Hull Hyundai"575    city = "Gatineau"576577578class HyundaiBeauce(D2CConnector):579    source_id = "hyundaibeauce"580    base_url = "https://www.hyundaibeauce.com"581    dealer_name = "Hyundai Beauce"582    city = "Saint-Georges"583584585class HyundaiDrummondville(D2CConnector):586    source_id = "hyundaidrummondville"587    base_url = "https://www.hyundaidrummondville.com"588    dealer_name = "Hyundai Drummondville"589    city = "Drummondville"590591592class HyundaiLanaudiere(D2CConnector):593    source_id = "hyundailanaudiere"594    base_url = "https://www.hyundailanaudiere.com"595    dealer_name = "Hyundai Lanaudière"596    city = "Joliette"597598599class HyundaiLongueuil(D2CConnector):600    source_id = "hyundailongueuil"601    base_url = "https://www.hyundailongueuil.com"602    dealer_name = "Hyundai Longueuil"603    city = "Longueuil"604605606class HyundaiStJerome(D2CConnector):607    source_id = "hyundaistjerome"608    base_url = "https://www.hyundaistjerome.com"609    dealer_name = "Hyundai St-Jérôme"610    city = "Mirabel"611612613class JeanDumas(D2CConnector):614    source_id = "jeandumas"615    base_url = "https://www.jeandumas.com"616    dealer_name = "Jean Dumas Multiconcessionnaire"617    city = "Alma"618619620class JeanDumasFordBC(D2CConnector):621    source_id = "jeandumasfordbc"622    base_url = "https://www.jeandumasfordbaiecomeau.com"623    dealer_name = "Jean Dumas Ford Baie-Comeau"624    city = "Baie-Comeau"625626627class JeanDumasHondaBC(D2CConnector):628    source_id = "jeandumashondabc"629    base_url = "https://www.jeandumashondabaiecomeau.com"630    dealer_name = "Jean Dumas Honda Baie-Comeau"631    city = "Baie-Comeau"632633634class JolietteMitsubishi(D2CConnector):635    source_id = "joliettemitsubishi"636    base_url = "https://www.joliettemitsubishi.com"637    dealer_name = "Joliette Mitsubishi"638    city = "Joliette"639640641class KiaJoliette(D2CConnector):642    source_id = "kiajoliette"643    base_url = "https://www.kiajoliette.com"644    dealer_name = "Kia Joliette"645    city = "Joliette"646647648class LAmiHonda(D2CConnector):649    source_id = "lamihonda"650    base_url = "https://www.lamihonda.com"651    dealer_name = "L'Ami Honda"652    city = "Rouyn-Noranda"653654655class LeoAutomobile(D2CConnector):656    source_id = "leoautomobile"657    base_url = "https://www.leoautomobile.com"658    dealer_name = "Léo Automobile"659    city = "Chicoutimi"660661662class LevisToyota(D2CConnector):663    source_id = "levistoyota"664    base_url = "https://www.levistoyota.ca"665    dealer_name = "Lévis Toyota"666    city = "Lévis"667668669class LongueuilHonda(D2CConnector):670    source_id = "longueuilhonda"671    base_url = "https://www.longueuilhonda.com"672    dealer_name = "Longueuil Honda"673    city = "Longueuil"674675676class OlivierChryslerBC(D2CConnector):677    source_id = "olivierchryslerbc"678    base_url = "https://www.olivierchryslerbaiecomeau.com"679    dealer_name = "Olivier Chrysler Baie-Comeau"680    city = "Baie-Comeau"681682683class StGeorgesGM(D2CConnector):684    source_id = "stgeorgesgm"685    base_url = "https://www.stgeorgesgm.ca"686    dealer_name = "St-Georges Chevrolet Buick GMC"687    city = "Saint-Georges"688689690class StJeromeMitsubishi(D2CConnector):691    source_id = "stjeromemitsubishi"692    base_url = "https://www.stjeromemitsubishi.ca"693    dealer_name = "St-Jérôme Mitsubishi"694    city = "Saint-Jérôme"695696697class SubaruVictoriaville(D2CConnector):698    source_id = "subaruvictoriaville"699    base_url = "https://www.subaruvictoriaville.ca"700    dealer_name = "Subaru Victoriaville"701    city = "Victoriaville"702703704class ToyotaBaieDesChaleurs(D2CConnector):705    source_id = "toyotabdc"706    base_url = "https://www.toyotabaiedeschaleurs.com"707    dealer_name = "Toyota Baie des Chaleurs"708    city = "Caplan"709710711class TroisRivieresHonda(D2CConnector):712    source_id = "troisriviereshonda"713    base_url = "https://www.troisriviereshonda.com"714    dealer_name = "Trois-Rivières Honda"715    city = "Trois-Rivières"716717718class TRToyota(D2CConnector):719    source_id = "trtoyota"720    base_url = "https://www.trtoyota.ca"721    dealer_name = "Trois-Rivières Toyota"722    city = "Trois-Rivières"723724725class VaudreuilVW(D2CConnector):726    source_id = "vaudreuilvw"727    base_url = "https://www.vaudreuilvolkswagen.com"728    dealer_name = "Vaudreuil Volkswagen"729    city = "Vaudreuil-Dorion"730731732class VWSherbrooke(D2CConnector):733    source_id = "vwsherbrooke"734    base_url = "https://www.vwsherbrooke.com"735    dealer_name = "VW Sherbrooke"736    city = "Sherbrooke"737738739# ---------------------------------------------------------------------------740# Expansion 3 — régions sous-couvertes (Québec métro, Charlevoix, Saguenay–741# Lac-Saint-Jean, Côte-Nord, Bas-Saint-Laurent, Gaspésie)742#743# Groupe Olivier : les quatre sites Olivier de Sept-Îles (Ford / Nissan /744# Chrysler / Occasion) publient EXACTEMENT le même inventaire d'occasion745# (mêmes ids D2C) — on ne connecte que olivieroccasionseptiles.com. Aucun746# recoupement avec Olivier Chrysler Baie-Comeau (déjà connecté) ni avec747# Olivier Hyundai Baie-Comeau.748# ---------------------------------------------------------------------------749750class DupontAutomobile(D2CConnector):751    source_id = "dupontautomobile"752    base_url = "https://www.dupontautomobile.com"753    dealer_name = "Dupont Automobile"754    city = "Alma"755756757class AlmaHonda(D2CConnector):758    source_id = "almahonda"759    base_url = "https://www.almahonda.com"760    dealer_name = "Alma Honda"761    city = "Alma"762763764class HondaNewRichmond(D2CConnector):765    source_id = "hondanewrichmond"766    base_url = "https://www.hondanewrichmond.com"767    dealer_name = "Honda New Richmond"768    city = "New Richmond"769770771class OlivierHyundaiBaieComeau(D2CConnector):772    source_id = "olivierhyundaibc"773    base_url = "https://www.olivierhyundaibaiecomeau.com"774    dealer_name = "Olivier Hyundai Baie-Comeau"775    city = "Baie-Comeau"776777778class OlivierOccasionSeptIles(D2CConnector):779    # inventaire commun aux 4 sites Olivier de Sept-Îles (voir note ci-dessus)780    source_id = "olivieroccasionseptiles"781    base_url = "https://www.olivieroccasionseptiles.com"782    dealer_name = "Olivier Occasion Sept-Îles"783    city = "Sept-Îles"784785786class CharlesbourgToyota(D2CConnector):787    source_id = "charlesbourgtoyota"788    base_url = "https://www.charlesbourgtoyota.com"789    dealer_name = "Charlesbourg Toyota"790    city = "Québec"791792793class HondaCharlesbourg(D2CConnector):794    source_id = "hondacharlesbourg"795    base_url = "https://www.hondacharlesbourg.com"796    dealer_name = "Honda Charlesbourg"797    city = "Québec"798799800class KiaBeauport(D2CConnector):801    source_id = "kiabeauport"802    base_url = "https://www.kiabeauport.com"803    dealer_name = "Kia Beauport"804    city = "Québec"805806807class HondaCharlevoix(D2CConnector):808    source_id = "hondacharlevoix"809    base_url = "https://www.hondacharlevoix.com"810    dealer_name = "Honda Charlevoix"811    city = "La Malbaie"812813814class HyundaiCharlevoix(D2CConnector):815    source_id = "hyundaicharlevoix"816    base_url = "https://www.hyundaicharlevoix.com"817    dealer_name = "Hyundai Charlevoix"818    city = "Baie-Saint-Paul"819820821class CharlevoixToyota(D2CConnector):822    source_id = "charlevoixtoyota"823    base_url = "https://www.charlevoixtoyota.com"824    dealer_name = "Charlevoix Toyota"825    city = "La Malbaie"826827828class AutoVilleneuve(D2CConnector):829    # concessionnaire Ford d'Amqui (vallée de la Matapédia) — petit inventaire830    source_id = "autovilleneuve"831    base_url = "https://www.autovilleneuve.com"832    dealer_name = "Automobile Villeneuve Amqui"833    city = "Amqui"834