# ----------------------------------------------------------------------------- # Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/d2c_dealers.py : connecteurs des concessionnaires sur plateforme # D2C Media (le fournisseur de sites le plus répandu chez les marchands # d'occasion au Québec). # # Stratégie : le sitemap (/fr/sitemap.xml) recense chaque page véhicule # (…-id123456.html). Chaque page détail expose un JSON-LD schema.org # complet (@type Vehicle/Car) : VIN, kilométrage, transmission, carburant, # carrosserie, couleurs, prix, photos. Les pages détail sont mises en cache # (clé hebdomadaire) : seuls les nouveaux véhicules — et une revalidation # par semaine pour les prix — génèrent de vraies requêtes. # # Enrichissement (clé de cache v2) — en plus du JSON-LD (qui ne liste que # 3 photos, parfois avec des ids corrompus — bug D2C) : # - galerie COMPLÈTE : les URLs imagescdn.d2cmedia.ca//// # /… présentes dans le HTML (préférence à la variante « cb » grand # format, sinon « mb »/« s8 »), triées par index de photo ; # - endpoint JSON par véhicule /js/json/.json (celui que le JS du site # appelle pour révéler la section « Options ») : optionsTextFR (liste # d'équipements en français), moteur + cylindre, carprooflink (rapport # Carfax direct quand le concessionnaire l'expose) ; # - repli équipements : titres des topFeatures/safetyFeatures du blob # window.__vdpJSON embarqué dans la page. # # Le lastmod du sitemap est bumpé quotidiennement par D2C (inutilisable # comme clé de cache) — d'où la clé temporelle hebdomadaire. # ----------------------------------------------------------------------------- from __future__ import annotations import datetime import html as htmllib import json import os import re from ..schema import Vehicle from .base import BaseConnector # segments d'URL /occasion// qui ne sont PAS des automobiles _EXCLUDE_SEGMENTS = re.compile( r"motoneige|vtt|bateau|ponton|motomarine|moto(?:s)?\b|roulotte|remorque|" r"\bvr\b|campeur|tracteur|souffleuse|scooter|spyder|side|atv|snow|" r"powersport|nautique|habitation", re.I) _VEH_URL_RE = re.compile(r"-id(\d+)\.html?$") _DRIVE_MAP = { "FrontWheelDriveConfiguration": "Traction", "RearWheelDriveConfiguration": "Propulsion", "AllWheelDriveConfiguration": "Intégrale / 4x4", "FourWheelDriveConfiguration": "Intégrale / 4x4", } _LD_RE = re.compile( r'', re.S) # galerie CDN D2C : /////… # (le nom de fichier peut contenir des espaces : « Silverado 1500-2023.jpg ») _GALLERY_RE = re.compile( r'(https://imagescdn\.d2cmedia\.ca/' r'([a-z0-9]{2})[a-f0-9]+/\d+/(\d+)/(\d+)/[^"\'<>]+?\.(?:jpe?g|png|webp))', re.I) # variante d'image préférée : cb = grand format (1440px), mb = moyen, s8 = mini _IMG_VARIANT_RANK = {"cb": 0, "mb": 1, "s8": 2} _VDP_JSON_RE = re.compile(r'window\.__vdpJSON\s*=\s*') MAX_IMAGES = 25 def _extract_gallery(html: str, ext_id: str) -> list[str]: """Galerie complète depuis le HTML, dédupliquée par index de photo, dans l'ordre de la galerie (index croissant), meilleure variante d'abord. """ best: dict[int, tuple[int, str]] = {} # index photo -> (rang, url) for url, variant, veh_id, idx in _GALLERY_RE.findall(html): if veh_id != ext_id: # vignettes d'autres véhicules continue try: idx = int(idx) except ValueError: continue rank = _IMG_VARIANT_RANK.get(variant.lower(), 3) if idx not in best or rank < best[idx][0]: best[idx] = (rank, url.replace(" ", "%20")) return [best[i][1] for i in sorted(best)][:MAX_IMAGES] def _extract_vdp_json(html: str) -> dict: """Blob `window.__vdpJSON = {...}` embarqué dans la page (si présent).""" m = _VDP_JSON_RE.search(html) if not m: return {} try: obj, _ = json.JSONDecoder().raw_decode(html, m.end()) except ValueError: return {} return obj if isinstance(obj, dict) else {} def _clean(text: str | None) -> str: """Désencode les entités HTML (souvent doublement encodées chez D2C).""" if not text: return "" out = htmllib.unescape(htmllib.unescape(str(text))) out = re.sub(r"<[^>]+>", " ", out) return re.sub(r"\s{2,}", " ", out).strip() class D2CConnector(BaseConnector): """Base commune aux sites D2C Media. Sous-classes : base_url + ville.""" base_url: str = "" dealer_name: str = "" city: str = "" request_delay: float = 1.0 # robots.txt D2C demande de la politesse use_firecrawl: bool = False # sites derrière Cloudflare # plafond de vraies requêtes détail par sync (AUTOKA_MAX_DETAILS le réduit # pour les tests) — le reste sera complété aux syncs suivants max_details = 600 def _get_text(self, url: str) -> str: if self.use_firecrawl: return self.get_rendered(url) return self.get(url).text # -- sitemap --------------------------------------------------------------- def _vehicle_urls(self) -> list[str]: xml = self._get_text(f"{self.base_url}/fr/sitemap.xml") urls = [] for loc in re.findall(r"([^<]+)", xml): loc = loc.strip() if "/occasion/" not in loc or not _VEH_URL_RE.search(loc): continue # catégorie intermédiaire ? (/occasion/motoneiges/…) path = loc.split("/occasion/", 1)[1] segments = path.split("/")[:-1] if any(_EXCLUDE_SEGMENTS.search(s) for s in segments): continue urls.append(loc) return urls # -- page détail -> payload JSON-LD enrichi -------------------------------- def _fetch_detail(self, url: str) -> dict: html = self._get_text(url) data: dict = {} for block in _LD_RE.findall(html): try: cand = json.loads(block.strip()) except ValueError: continue types = cand.get("@type") types = types if isinstance(types, list) else [types] if "Vehicle" in types or "Car" in types: data = cand break if not data: return {} # --- enrichissements (tous optionnels : jamais bloquants) ------------- m = _VEH_URL_RE.search(url) ext_id = m.group(1) if m else "" try: gallery = _extract_gallery(html, ext_id) except Exception: gallery = [] if gallery: data["_gallery"] = gallery vdp = {} try: vdp = _extract_vdp_json(html) except Exception: pass # endpoint JSON par véhicule : équipements, moteur, lien Carfax extra: dict = {} if ext_id: try: extra = self.get(f"{self.base_url}/js/json/{ext_id}.json").json() if not isinstance(extra, dict): extra = {} except Exception: extra = {} features = [_clean(t) for t in (extra.get("optionsTextFR") or []) if isinstance(t, str) and _clean(t)] if not features: # repli : blob __vdpJSON de la page for group in (vdp.get("topFeatures"), vdp.get("safetyFeatures")): for item in group or []: if isinstance(item, dict): t = _clean(item.get("title")) if t and t not in features: features.append(t) if features: data["_features"] = features engine = _clean(str(extra.get("moteur") or vdp.get("engine") or "")) # D2C renvoie « N.D. » ou « . L » (véhicules électriques) : rejeter # toute cylindrée sans chiffre if engine.upper() in ("N.D.", "N/A", "ND") or not re.search(r"\d", engine): engine = "" cyl = _clean(str(extra.get("cylindre") or vdp.get("cylinder") or "")) if cyl and cyl.upper() not in ("N.D.", "N/A", "ND", "0"): if cyl.isdigit(): # nombre nu (parfois erroné pour les VÉ) : seulement en # complément d'une cylindrée valide, jamais seul if engine: engine = f"{engine} {cyl} cyl." elif cyl.lower() not in engine.lower(): engine = f"{engine} {cyl}".strip() if engine: data["_engine"] = engine carfax = str(extra.get("carprooflink") or "").strip() if not carfax: cp = vdp.get("carproof") if isinstance(cp, dict): carfax = str(cp.get("link") or "").strip() if carfax.startswith("http"): data["_carfax_url"] = carfax return data # -- contrat --------------------------------------------------------------- def fetch(self) -> list[Vehicle]: urls = self._vehicle_urls() week = datetime.date.today().isocalendar() # v2 : payloads enrichis (galerie complète, équipements, moteur, # carfax) — le bump force la re-crawl progressive des pages en cache cache_key = f"v2:{week.year}w{week.week}" # revalidation hebdomadaire cap = int(os.environ.get("AUTOKA_MAX_DETAILS", self.max_details)) vehicles: list[Vehicle] = [] real_fetches = 0 for url in urls: ext_id = _VEH_URL_RE.search(url).group(1) def _fetch(u=url): return self._fetch_detail(u) # plafond atteint : ne servir que le cache (fetch réel interdit) if real_fetches >= cap: from .. import db if self._detail_con is None: self._detail_con = db.connect() data = db.get_cached_detail(self._detail_con, self.source_id, ext_id, cache_key) if data is None: continue else: before = self._last_request try: data = self.detail(ext_id, cache_key, _fetch) except Exception: # page détail disparue (vendu, sitemap pas encore purgé) # ou erreur ponctuelle : ne jamais bloquer la source if self._last_request != before: real_fetches += 1 continue if self._last_request != before: # une vraie requête a eu lieu real_fetches += 1 if not data: continue veh = self._to_vehicle(ext_id, url, data) if veh is not None: vehicles.append(veh) return vehicles def _to_vehicle(self, ext_id: str, url: str, d: dict) -> Vehicle | None: offers = d.get("offers") or {} price = offers.get("price") try: price = float(price) if price not in (None, "", 0, "0") else None except (TypeError, ValueError): price = None odo = d.get("mileageFromOdometer") or {} km = odo.get("value") if isinstance(odo, dict) else odo try: km = float(km) if km not in (None, "") else None except (TypeError, ValueError): km = None brand = d.get("brand") or {} make = brand.get("name") if isinstance(brand, dict) else str(brand) drive_raw = str(d.get("driveWheelConfiguration") or "") drivetrain = "" for key, canon in _DRIVE_MAP.items(): if key in drive_raw: drivetrain = canon break year = None if d.get("vehicleModelDate"): try: year = int(str(d["vehicleModelDate"])[:4]) except ValueError: pass # galerie complète extraite du HTML (payloads v2) de préférence au # JSON-LD (limité à 3 photos, ids parfois corrompus — bug D2C) images = d.get("_gallery") or d.get("image") or [] if isinstance(images, str): images = [images] title = _clean(d.get("name")) if not title: return None return Vehicle( source=self.source_id, external_id=ext_id, url=url, title=title, make=_clean(make), model=_clean(d.get("model")), trim=_clean(d.get("vehicleConfiguration")), year=year, price=price, price_label=f"{price:,.0f} $".replace(",", " ") if price else "", mileage_km=km, mileage_label=f"{km:,.0f} km".replace(",", " ") if km else "", transmission=_clean(d.get("vehicleTransmission")), fuel=_clean(d.get("fuelType")), drivetrain=drivetrain, body_type=_clean(d.get("bodyType")), exterior_color=_clean(d.get("color")), interior_color=_clean(d.get("vehicleInteriorColor")), doors=d.get("numberOfDoors"), seats=d.get("vehicleSeatingCapacity"), vin=_clean(d.get("vehicleIdentificationNumber")), stock_number=str(d.get("sku") or ""), engine=_clean(d.get("_engine")), dealer_name=self.dealer_name, city=self.city, description=_clean(d.get("description"))[:4000], features=[f for f in (d.get("_features") or []) if isinstance(f, str) and f], images=[u for u in images if isinstance(u, str)][:MAX_IMAGES], carfax_url=str(d.get("_carfax_url") or ""), ) # --------------------------------------------------------------------------- # Concessionnaires (1 sous-classe = 1 source) # --------------------------------------------------------------------------- class GirardAutos(D2CConnector): source_id = "girardautos" base_url = "https://www.girardautos.ca" dealer_name = "Girard Autos" city = "Trois-Rivières" class ForceOccasion(D2CConnector): source_id = "forceoccasion" base_url = "https://www.forceoccasion.ca" dealer_name = "Force Occasion" city = "Québec" class GervaisAuto(D2CConnector): source_id = "gervaisauto" base_url = "https://www.gervaisauto.com" dealer_name = "Gervais Auto" city = "Shawinigan" class EquipeFormule(D2CConnector): source_id = "equipeformule" base_url = "https://www.equipeformule.com" dealer_name = "Équipe Formule" city = "Rimouski" class AutosEvasion(D2CConnector): source_id = "autosevasion" base_url = "https://www.autosevasion.com" dealer_name = "Autos Évasion" city = "Sherbrooke" class MFerlandAuto(D2CConnector): source_id = "mferlandauto" base_url = "https://www.mferlandauto.com" dealer_name = "M. Ferland Auto" city = "Sherbrooke" class AutosOccasionLD(D2CConnector): source_id = "autosoccasionld" base_url = "https://www.autosoccasions.ca" dealer_name = "Autos Occasion LD" city = "Lévis" class LevisAutos(D2CConnector): source_id = "levisautos" base_url = "https://www.levisautos.com" dealer_name = "Lévis Autos" city = "Lévis" class AutoQuebecLevis(D2CConnector): source_id = "autoquebeclevis" base_url = "https://www.autoquebecoccasionlevis.com" dealer_name = "AutoQuébec Occasion Lévis" city = "Lévis" class AutoQuebecThetford(D2CConnector): source_id = "autoquebectm" base_url = "https://www.autoquebectm.com" dealer_name = "AutoQuébec Occasion Thetford Mines" city = "Thetford Mines" class AbitibiAutoCredit(D2CConnector): source_id = "abitibiautocredit" base_url = "https://www.abitibiautocredit.ca" dealer_name = "Abitibi Auto Crédit" city = "Rouyn-Noranda" class NicolOccasion(D2CConnector): source_id = "nicoloccasion" base_url = "https://www.nicoloccasion.com" dealer_name = "Nicol Occasion" city = "La Sarre" class PoirierOccasion(D2CConnector): source_id = "poirieroccasion" base_url = "https://www.poirieroccasion.com" dealer_name = "Poirier Occasion" city = "Val-d'Or" class AutosStHubert(D2CConnector): source_id = "autosthubert" base_url = "https://www.autosthubert.com" dealer_name = "Auto St-Hubert" city = "Saint-Hubert" class SherbrookeAutoOccasion(D2CConnector): source_id = "sherbrookeautooccasion" base_url = "https://www.sherbrookeautooccasion.com" dealer_name = "Sherbrooke Auto Occasion" city = "Sherbrooke" # --------------------------------------------------------------------------- # Expansion 2 — concessionnaires de marque et groupes sur D2C Media # (inventaire OCCASION seulement : le sitemap /fr/sitemap.xml ne référence # que les pages /occasion/…-idNNN.html ; le neuf vit dans un sitemap séparé) # --------------------------------------------------------------------------- class BernierCrepeau(D2CConnector): source_id = "berniercrepeau" base_url = "https://www.berniercrepeau.com" dealer_name = "Bernier Crépeau" city = "Trois-Rivières" class ChomedeyToyota(D2CConnector): source_id = "chomedeytoyota" base_url = "https://www.chomedeytoyotalaval.ca" dealer_name = "Chomedey Toyota Laval" city = "Laval" class DrummondvilleFord(D2CConnector): source_id = "drummondvilleford" base_url = "https://www.drummondvilleford.com" dealer_name = "Drummondville Ford" city = "Drummondville" class DrummondvilleVW(D2CConnector): source_id = "drummondvillevw" base_url = "https://www.drummondvillevolkswagen.ca" dealer_name = "Drummondville Volkswagen" city = "Drummondville" class DubeChrysler(D2CConnector): source_id = "dubechrysler" base_url = "https://www.dubechrysler.com" dealer_name = "Dubé Chrysler" city = "Rivière-du-Loup" class DuclosLongueuil(D2CConnector): source_id = "ducloslongueuil" base_url = "https://www.ducloschryslerlongueuil.com" dealer_name = "Duclos Longueuil Chrysler" city = "Longueuil" class DynastieHyundai(D2CConnector): source_id = "dynastiehyundai" base_url = "https://www.dynastiehyundai.com" dealer_name = "Dynastie Hyundai" city = "Rouyn-Noranda" class FichaultKia(D2CConnector): source_id = "fichaultkia" base_url = "https://www.fichaultkia.com" dealer_name = "Fichault Kia" city = "Châteauguay" class FordThetford(D2CConnector): source_id = "fordthetford" base_url = "https://www.fordthetford.com" dealer_name = "Ford Thetford" city = "Thetford Mines" class GaspeToyota(D2CConnector): source_id = "gaspetoyota" base_url = "https://www.gaspe-toyota.com" dealer_name = "Gaspé Toyota" city = "Gaspé" class GaspesieHyundai(D2CConnector): source_id = "gaspesiehyundai" base_url = "https://www.gaspesiehyundai.com" dealer_name = "Gaspésie Hyundai" city = "Bonaventure" class GranbyToyota(D2CConnector): source_id = "granbytoyota" base_url = "https://www.granbytoyota.ca" dealer_name = "Granby Toyota" city = "Granby" class GrandPortage(D2CConnector): source_id = "grandportage" base_url = "https://www.grandportageauto.com" dealer_name = "Groupe Grand-Portage" city = "Rivière-du-Loup" class GroupeMotion(D2CConnector): source_id = "groupemotion" base_url = "https://www.groupemotion.com" dealer_name = "Groupe Motion" city = "Longueuil" class GroupePaquin(D2CConnector): source_id = "groupepaquin" base_url = "https://www.groupepaquin.com" dealer_name = "Groupe Paquin" city = "Rouyn-Noranda" class HullHyundai(D2CConnector): source_id = "hullhyundai" base_url = "https://www.hullhyundai.com" dealer_name = "Hull Hyundai" city = "Gatineau" class HyundaiBeauce(D2CConnector): source_id = "hyundaibeauce" base_url = "https://www.hyundaibeauce.com" dealer_name = "Hyundai Beauce" city = "Saint-Georges" class HyundaiDrummondville(D2CConnector): source_id = "hyundaidrummondville" base_url = "https://www.hyundaidrummondville.com" dealer_name = "Hyundai Drummondville" city = "Drummondville" class HyundaiLanaudiere(D2CConnector): source_id = "hyundailanaudiere" base_url = "https://www.hyundailanaudiere.com" dealer_name = "Hyundai Lanaudière" city = "Joliette" class HyundaiLongueuil(D2CConnector): source_id = "hyundailongueuil" base_url = "https://www.hyundailongueuil.com" dealer_name = "Hyundai Longueuil" city = "Longueuil" class HyundaiStJerome(D2CConnector): source_id = "hyundaistjerome" base_url = "https://www.hyundaistjerome.com" dealer_name = "Hyundai St-Jérôme" city = "Mirabel" class JeanDumas(D2CConnector): source_id = "jeandumas" base_url = "https://www.jeandumas.com" dealer_name = "Jean Dumas Multiconcessionnaire" city = "Alma" class JeanDumasFordBC(D2CConnector): source_id = "jeandumasfordbc" base_url = "https://www.jeandumasfordbaiecomeau.com" dealer_name = "Jean Dumas Ford Baie-Comeau" city = "Baie-Comeau" class JeanDumasHondaBC(D2CConnector): source_id = "jeandumashondabc" base_url = "https://www.jeandumashondabaiecomeau.com" dealer_name = "Jean Dumas Honda Baie-Comeau" city = "Baie-Comeau" class JolietteMitsubishi(D2CConnector): source_id = "joliettemitsubishi" base_url = "https://www.joliettemitsubishi.com" dealer_name = "Joliette Mitsubishi" city = "Joliette" class KiaJoliette(D2CConnector): source_id = "kiajoliette" base_url = "https://www.kiajoliette.com" dealer_name = "Kia Joliette" city = "Joliette" class LAmiHonda(D2CConnector): source_id = "lamihonda" base_url = "https://www.lamihonda.com" dealer_name = "L'Ami Honda" city = "Rouyn-Noranda" class LeoAutomobile(D2CConnector): source_id = "leoautomobile" base_url = "https://www.leoautomobile.com" dealer_name = "Léo Automobile" city = "Chicoutimi" class LevisToyota(D2CConnector): source_id = "levistoyota" base_url = "https://www.levistoyota.ca" dealer_name = "Lévis Toyota" city = "Lévis" class LongueuilHonda(D2CConnector): source_id = "longueuilhonda" base_url = "https://www.longueuilhonda.com" dealer_name = "Longueuil Honda" city = "Longueuil" class OlivierChryslerBC(D2CConnector): source_id = "olivierchryslerbc" base_url = "https://www.olivierchryslerbaiecomeau.com" dealer_name = "Olivier Chrysler Baie-Comeau" city = "Baie-Comeau" class StGeorgesGM(D2CConnector): source_id = "stgeorgesgm" base_url = "https://www.stgeorgesgm.ca" dealer_name = "St-Georges Chevrolet Buick GMC" city = "Saint-Georges" class StJeromeMitsubishi(D2CConnector): source_id = "stjeromemitsubishi" base_url = "https://www.stjeromemitsubishi.ca" dealer_name = "St-Jérôme Mitsubishi" city = "Saint-Jérôme" class SubaruVictoriaville(D2CConnector): source_id = "subaruvictoriaville" base_url = "https://www.subaruvictoriaville.ca" dealer_name = "Subaru Victoriaville" city = "Victoriaville" class ToyotaBaieDesChaleurs(D2CConnector): source_id = "toyotabdc" base_url = "https://www.toyotabaiedeschaleurs.com" dealer_name = "Toyota Baie des Chaleurs" city = "Caplan" class TroisRivieresHonda(D2CConnector): source_id = "troisriviereshonda" base_url = "https://www.troisriviereshonda.com" dealer_name = "Trois-Rivières Honda" city = "Trois-Rivières" class TRToyota(D2CConnector): source_id = "trtoyota" base_url = "https://www.trtoyota.ca" dealer_name = "Trois-Rivières Toyota" city = "Trois-Rivières" class VaudreuilVW(D2CConnector): source_id = "vaudreuilvw" base_url = "https://www.vaudreuilvolkswagen.com" dealer_name = "Vaudreuil Volkswagen" city = "Vaudreuil-Dorion" class VWSherbrooke(D2CConnector): source_id = "vwsherbrooke" base_url = "https://www.vwsherbrooke.com" dealer_name = "VW Sherbrooke" city = "Sherbrooke" # --------------------------------------------------------------------------- # Expansion 3 — régions sous-couvertes (Québec métro, Charlevoix, Saguenay– # Lac-Saint-Jean, Côte-Nord, Bas-Saint-Laurent, Gaspésie) # # Groupe Olivier : les quatre sites Olivier de Sept-Îles (Ford / Nissan / # Chrysler / Occasion) publient EXACTEMENT le même inventaire d'occasion # (mêmes ids D2C) — on ne connecte que olivieroccasionseptiles.com. Aucun # recoupement avec Olivier Chrysler Baie-Comeau (déjà connecté) ni avec # Olivier Hyundai Baie-Comeau. # --------------------------------------------------------------------------- class DupontAutomobile(D2CConnector): source_id = "dupontautomobile" base_url = "https://www.dupontautomobile.com" dealer_name = "Dupont Automobile" city = "Alma" class AlmaHonda(D2CConnector): source_id = "almahonda" base_url = "https://www.almahonda.com" dealer_name = "Alma Honda" city = "Alma" class HondaNewRichmond(D2CConnector): source_id = "hondanewrichmond" base_url = "https://www.hondanewrichmond.com" dealer_name = "Honda New Richmond" city = "New Richmond" class OlivierHyundaiBaieComeau(D2CConnector): source_id = "olivierhyundaibc" base_url = "https://www.olivierhyundaibaiecomeau.com" dealer_name = "Olivier Hyundai Baie-Comeau" city = "Baie-Comeau" class OlivierOccasionSeptIles(D2CConnector): # inventaire commun aux 4 sites Olivier de Sept-Îles (voir note ci-dessus) source_id = "olivieroccasionseptiles" base_url = "https://www.olivieroccasionseptiles.com" dealer_name = "Olivier Occasion Sept-Îles" city = "Sept-Îles" class CharlesbourgToyota(D2CConnector): source_id = "charlesbourgtoyota" base_url = "https://www.charlesbourgtoyota.com" dealer_name = "Charlesbourg Toyota" city = "Québec" class HondaCharlesbourg(D2CConnector): source_id = "hondacharlesbourg" base_url = "https://www.hondacharlesbourg.com" dealer_name = "Honda Charlesbourg" city = "Québec" class KiaBeauport(D2CConnector): source_id = "kiabeauport" base_url = "https://www.kiabeauport.com" dealer_name = "Kia Beauport" city = "Québec" class HondaCharlevoix(D2CConnector): source_id = "hondacharlevoix" base_url = "https://www.hondacharlevoix.com" dealer_name = "Honda Charlevoix" city = "La Malbaie" class HyundaiCharlevoix(D2CConnector): source_id = "hyundaicharlevoix" base_url = "https://www.hyundaicharlevoix.com" dealer_name = "Hyundai Charlevoix" city = "Baie-Saint-Paul" class CharlevoixToyota(D2CConnector): source_id = "charlevoixtoyota" base_url = "https://www.charlevoixtoyota.com" dealer_name = "Charlevoix Toyota" city = "La Malbaie" class AutoVilleneuve(D2CConnector): # concessionnaire Ford d'Amqui (vallée de la Matapédia) — petit inventaire source_id = "autovilleneuve" base_url = "https://www.autovilleneuve.com" dealer_name = "Automobile Villeneuve Amqui" city = "Amqui"