# ----------------------------------------------------------------------------- # Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/magnetis_dealers.py : connecteurs des concessionnaires sur la # plateforme WordPress « magnetis-dealer » (agence Magnetis, inventaire # SyncAuto — photos syncauto-XX.s3.ca-central-1.amazonaws.com). # # Stratégie : le sitemap /inventory-sitemap.xml recense chaque page # véhicule /inventaire/occasion//// (versions FR # et EN ; on ne garde que les FR, occasion seulement — le neuf vit sous # /inventaire/neuf/). Chaque page détail embarque : # - ', re.S) _LD_RE = re.compile( r'', re.S) # garde-fou non-automobile (ces réseaux vendent parfois des VR/motoneiges) _EXCLUDE_RE = re.compile( r"moto(?:cyclette|neige|marine)?\b|\bvtt\b|\batv\b|scooter|spyder|ryker|" r"can-?am|ski-?doo|sea-?doo|roulotte|remorque|\bvr\b|motoris|campeur|" r"trailer|snowmobile", re.I) def _clean(text) -> str: if not text: return "" out = htmllib.unescape(str(text)) out = re.sub(r"<[^>]+>", " ", out) return re.sub(r"\s{2,}", " ", out).strip() def _num(value, cast=float): try: v = cast(value) except (TypeError, ValueError): return None return v if v else None class MagnetisConnector(BaseConnector): """Base commune aux sites WordPress « magnetis-dealer » (SyncAuto).""" base_url: str = "" dealer_name: str = "" city: str = "" request_delay: float = 1.0 max_details = 400 # plafond de vraies requêtes par sync # -- sitemap --------------------------------------------------------------- def _vehicle_urls(self) -> list[str]: xml = self.get(f"{self.base_url}/inventory-sitemap.xml").text urls: list[str] = [] for loc in re.findall(r"([^<]+)", xml): loc = loc.strip() if "/en/" in loc or not loc.startswith(self.base_url): continue if _VEH_PATH_RE.search(loc): urls.append(loc) return sorted(set(urls)) # -- page détail -> payload --------------------------------------------------- def _fetch_detail(self, url: str, ext_id: str) -> dict: html = self.get(url).text payload: dict = {} m = _DATA_CLASS_RE.search(html) if m: try: payload["vehicle"] = json.loads(m.group(1).strip()) except ValueError: pass # JSON-LD : la page inclut aussi des « véhicules similaires » — le # bon bloc est celui dont productID correspond à l'id de l'URL. for block in _LD_RE.findall(html): try: data = json.loads(block.strip(), strict=False) except ValueError: continue types = data.get("@type") types = types if isinstance(types, list) else [types] if "Vehicle" not in types and "Car" not in types: continue if str(data.get("productID") or "") == ext_id: payload["ld"] = data break return payload # -- contrat --------------------------------------------------------------- def fetch(self) -> list[Vehicle]: urls = self._vehicle_urls() week = datetime.date.today().isocalendar() cache_key = f"v1:{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_PATH_RE.search(url).group(1) def _fetch(u=url, e=ext_id): return self._fetch_detail(u, e) # 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 disparue (vendu) ou erreur ponctuelle : ne jamais # bloquer la source entière if self._last_request != before: real_fetches += 1 continue if self._last_request != before: real_fetches += 1 if not data or not data.get("vehicle"): continue veh = self._to_vehicle(ext_id, url, data) if veh is not None: vehicles.append(veh) return vehicles # -- payload -> Vehicle ------------------------------------------------------ def _to_vehicle(self, ext_id: str, url: str, data: dict) -> Vehicle | None: v = data.get("vehicle") or {} ld = data.get("ld") or {} if str(v.get("status") or "used").lower() != "used": return None # occasion seulement make = _clean(v.get("make")) model = _clean(v.get("model")) body = _clean(v.get("body")) if not (make or model): return None if _EXCLUDE_RE.search(f"{make} {model} {body}"): return None year = _num(v.get("year"), int) trim = _clean(v.get("trim")) title = " ".join(str(x) for x in (make, model, trim, year or "") if x) price = _num(v.get("price")) km = _num(v.get("kms")) images = ld.get("image") or [] if isinstance(images, str): images = [images] images = [u for u in images if isinstance(u, str) and "img-bkg" not in u] # bannière de repli du site # la concession réelle du véhicule (réseaux multi-concessions) dealer = _clean(v.get("dealer_name_fr") or v.get("dealer_name_en")) dealer = re.sub(r"\s+(?:used|occasion)$", "", dealer, flags=re.I) if dealer.lower() in ("", "sync"): # placeholder SyncAuto dealer = self.dealer_name city = _clean(v.get("dealer_city")).split(",")[0].strip() or self.city details: dict = {} if v.get("msrp") and _num(v["msrp"]) != price: details["msrp"] = v["msrp"] if v.get("create_date"): details["listed_date"] = str(v["create_date"]) offers = ld.get("offers") or {} seller = offers.get("seller") or {} if isinstance(seller, dict) and seller.get("name"): details["branch"] = _clean(seller["name"]) 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=f"{km:,.0f} km".replace(",", " ") if km else "", transmission=_clean(v.get("transmission")), fuel=_clean(v.get("fuel_type")), drivetrain=_clean(v.get("drive")), body_type=body, exterior_color=_clean(v.get("exterior_color")), engine=_clean(v.get("engine_size")), doors=_num(v.get("doors"), int), seats=_num(v.get("passengers"), int), vin=_clean(ld.get("vehicleIdentificationNumber")), stock_number=_clean(v.get("stock_number") or ld.get("sku")), dealer_name=dealer, city=city, description=_clean(ld.get("description"))[:4000], details=details, images=images[:20], ) # --------------------------------------------------------------------------- # Concessionnaires (1 sous-classe = 1 source) # --------------------------------------------------------------------------- class BaieComeauToyota(MagnetisConnector): source_id = "baiecomeautoyota" base_url = "https://baiecomeautoyota.com" dealer_name = "Baie-Comeau Toyota" city = "Baie-Comeau" class SherbrookeToyota(MagnetisConnector): source_id = "sherbrooketoyota" base_url = "https://sherbrooketoyota.ca" dealer_name = "Sherbrooke Toyota" city = "Sherbrooke" class StGeorgesNissan(MagnetisConnector): source_id = "stgnissan" base_url = "https://www.stgnissan.com" dealer_name = "St-Georges Nissan" city = "Saint-Georges" class LussierChevrolet(MagnetisConnector): """Lussier Chevrolet Buick GMC — remplace Auto Bas Prix St-Hyacinthe (autobasprixst-hyacinthe.com redirige vers lussierchevrolet.com depuis la fusion des deux commerces).""" source_id = "lussierchevrolet" base_url = "https://www.lussierchevrolet.com" dealer_name = "Lussier Chevrolet Buick GMC" city = "Saint-Hyacinthe"