SPB Git

spb/auto-ka Public

Python 81.9% TypeScript 12.4% CSS 5.5%
13.2 KB · 326 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/hgregoire.py : connecteur HGrégoire (hgregoire.com), la plus5#   grosse chaîne de mégacentres d'occasion au Québec (St-Léonard, Laval,6#   St-Eustache, Saguenay/Chicoutimi, Carbonear/Gaspésie, etc.).7#8#   Stratégie : la page de résultats /auto-usage?page=N est rendue côté9#   serveur (WordPress custom) et embarque, pour CHAQUE carte véhicule, un10#   <script type="application/json" data-class="Vehicle"> complet :11#   Id, année/marque/modèle/version, prix (CAD), odomètre, VIN, no de stock,12#   transmission, carburant, traction, carrosserie, couleurs, moteur,13#   consommation, photo principale, Permalink ET la succursale du véhicule14#   (FullLocation.city + province). 24 véhicules par page, ~74 pages —15#   AUCUNE page détail nécessaire : ~75 requêtes pour tout l'inventaire.16#17#   Seules les succursales du QUÉBEC sont conservées (FullLocation.province18#   == "QC") — le groupe opère aussi aux États-Unis (hgreg.com) et un19#   véhicule hors-Québec serait exclu par ce garde-fou.20#21#   robots.txt demande Crawl-delay: 10 → request_delay élevé (sync complet22#   ≈ 12 min, acceptable pour une synchronisation quotidienne).23#   AUTOKA_MAX_PAGES (env) limite le nombre de pages pour les tests.24#25#   ENRICHISSEMENT DÉTAIL (galerie + équipements) : la carte liste n'a qu'UNE26#   photo et quelques équipements ; la page détail expose la galerie complète27#   (~25 photos) et l'onglet « Équipements » (~40 items). Avec Crawl-delay: 1028#   un crawl direct des ~1 700 fiches prendrait ~4,7 h → les pages détail29#   passent par SCRAPFLY (requêtes gérées par leur infra, non soumises au30#   crawl-delay de notre IP). Cache BD à vie (clé stable « v1 » — la galerie31#   et les équipements ne changent pas), plafond AUTOKA_MAX_DETAILS (défaut32#   300/sync) : l'inventaire complet s'enrichit en ~6 synchronisations.33#   Le véhicule est toujours émis avec les données liste même sans détail.34# -----------------------------------------------------------------------------35from __future__ import annotations3637import json38import os39import re4041from ..normalize import MAKES42from ..schema import Vehicle, strip_accents43from .base import BaseConnector4445_MAKE_WORDS = {strip_accents(m).lower() for m in MAKES}4647_BLOB_RE = re.compile(48    r'<script type="application/json" data-class="Vehicle"[^>]*>'49    r'/\*<!\[CDATA\[\*/(.*?)/\*\]\]>\*/</script>', re.S)50_PAGE_RE = re.compile(r'data-page="(\d+)"')51_DOORS_RE = re.compile(r"(\d+)\s*portes?", re.I)52# photo de carte 448x252 -> version plus grande servie par le même CDN53_IMG_SIZE_RE = re.compile(r"/by-size/([^/]+)/\d+x\d+/")5455# page détail : galerie (chaque photo répétée en plusieurs tailles),56# onglet « Équipements » et lien Carfax éventuel57_GALLERY_RE = re.compile(       # album parfois alphanumérique (ex. KA8107)58    r"https://[^\"'\s\\<>]*/photos/by-size/([\w-]+)/\d+x\d+/(\w+)\.(\w+)")59_EQUIP_TAB_RE = re.compile(60    r'id="vdptab-equipment"(.*?)(?:show-all-equipment|</section>)', re.S)61_EQUIP_ITEM_RE = re.compile(r"<span>\s*([^<]+?)\s*</span>")62_CARFAX_RE = re.compile(63    r'https?://[^"\'\s<>]*carfax[^"\'\s<>]*', re.I)6465# clé de cache détail stable (bump si le parsing change)66_DETAIL_KEY = "v1"6768MILE_KM = 1.609344697071# bannières du groupe qui ne sont pas des noms de ville72_BANNER_CITY = {73    "ineos grenadier": "Montréal",        # INEOS Grenadier Montréal74    "le prix du gros": "Trois-Rivières",  # Méga centre d'occasion Le Prix du Gros75}767778def _clean_city(raw: str) -> str:79    """Succursale -> ville : « Kia Vaudreuil » -> « Vaudreuil »,80    « Nissan St-Eustache » -> « St-Eustache » (certaines succursales du81    groupe sont des bannières de marque ; on retire le préfixe de marque)."""82    banner = _BANNER_CITY.get(strip_accents(raw or "").lower().strip())83    if banner:84        return banner85    words = (raw or "").split()86    while len(words) > 1 and strip_accents(words[0]).lower() in _MAKE_WORDS:87        words = words[1:]88    return " ".join(words)899091class HGregoire(BaseConnector):92    """HGrégoire — inventaire usagé complet via les pages liste (SRP)."""9394    source_id = "hgregoire"95    base_url = "https://www.hgregoire.com"96    dealer_name = "HGrégoire"97    request_delay = 10.0             # robots.txt : Crawl-delay: 1098    max_pages = 200                  # garde-fou dur (~74 pages réelles)99    max_details = 300                # plafond de requêtes Scrapfly / sync100101    # -- pages liste ------------------------------------------------------102    def _list_html(self, page: int) -> str:103        url = f"{self.base_url}/auto-usage"104        if page > 1:105            url += f"?page={page}"106        return self.get(url).text107108    def _parse_blobs(self, html: str) -> list[dict]:109        out = []110        for raw in _BLOB_RE.findall(html):111            try:112                out.append(json.loads(raw))113            except ValueError:114                continue115        return out116117    # -- page détail (via Scrapfly) -> payload -----------------------------118    def _fetch_detail(self, url: str) -> dict:119        """Galerie complète + équipements + carfax d'une fiche véhicule.120121        Passe par Scrapfly (pas de crawl-delay pour notre IP). Lève une122        exception si la page ne ressemble pas à une fiche véhicule, pour ne123        jamais mettre en cache un payload vide issu d'une page d'erreur.124        """125        html = self.get_scrapfly(url)126        payload: dict = {"images": [], "features": []}127128        seen: set[str] = set()129        for album, pid, ext in _GALLERY_RE.findall(html):130            if pid not in seen:            # chaque photo répétée en N tailles131                seen.add(pid)132                payload["images"].append(133                    f"https://cfwww.hgregoire.com/photos/by-size/"134                    f"{album}/1010x568/{pid}.{ext}")135136        m = _EQUIP_TAB_RE.search(html)137        if m:138            feats: list[str] = []139            for f in _EQUIP_ITEM_RE.findall(m.group(1)):140                f = " ".join(f.split())141                if f and f not in feats:142                    feats.append(f)143            payload["features"] = feats144145        m = _CARFAX_RE.search(html)146        if m:147            payload["carfax_url"] = m.group(0)148149        if not payload["images"] and 'data-class="Vehicle"' not in html:150            raise RuntimeError(f"page détail HGrégoire illisible : {url}")151        return payload152153    # -- contrat ----------------------------------------------------------154    def fetch(self) -> list[Vehicle]:155        cap = int(os.environ.get("AUTOKA_MAX_PAGES", self.max_pages))156        detail_cap = int(os.environ.get("AUTOKA_MAX_DETAILS", self.max_details))157        vehicles: list[Vehicle] = []158        seen: set[str] = set()159160        html = self._list_html(1)161        pages = [int(p) for p in _PAGE_RE.findall(html)]162        last_page = min(max(pages) if pages else 1, cap, self.max_pages)163164        page = 1165        while True:166            blobs = self._parse_blobs(html)167            if not blobs:168                break169            new = 0170            for d in blobs:171                veh = self._to_vehicle(d)172                if veh is not None and veh.external_id not in seen:173                    seen.add(veh.external_id)174                    vehicles.append(veh)175                    new += 1176            page += 1177            if page > last_page or new == 0:178                break179            html = self._list_html(page)180181        # enrichissement détail (Scrapfly) — cache BD, budget par sync ;182        # le véhicule reste émis avec les données liste même sans détail183        real_fetches = 0184        for veh in vehicles:185            detail: dict = {}186            if real_fetches >= detail_cap:187                from .. import db188                if self._detail_con is None:189                    self._detail_con = db.connect()190                detail = db.get_cached_detail(self._detail_con, self.source_id,191                                              veh.external_id,192                                              _DETAIL_KEY) or {}193            else:194                did_fetch = False195196                def _fetch(u=veh.url):197                    nonlocal did_fetch198                    did_fetch = True199                    return self._fetch_detail(u)200201                try:202                    detail = self.detail(veh.external_id, _DETAIL_KEY, _fetch)203                except Exception:204                    detail = {}      # détail indisponible : liste suffisante205                if did_fetch:206                    real_fetches += 1207            self._apply_detail(veh, detail or {})208        return vehicles209210    @staticmethod211    def _apply_detail(veh: Vehicle, detail: dict) -> None:212        images = [u for u in detail.get("images") or [] if isinstance(u, str)]213        if images:214            veh.images = images[:30]215        feats = [f for f in detail.get("features") or [] if isinstance(f, str)]216        if feats:                    # union liste + détail, ordre préservé217            merged = list(veh.features)218            merged += [f for f in feats if f not in merged]219            veh.features = merged[:80]220        if detail.get("carfax_url"):221            veh.carfax_url = str(detail["carfax_url"])222223    # -- carte JSON -> Vehicle ----------------------------------------------224    def _to_vehicle(self, d: dict) -> Vehicle | None:225        if d.get("Sold") or d.get("Deleted") or d.get("IsForSale") is False:226            return None227        if str(d.get("Type") or "Used") != "Used":     # usagés seulement228            return None229230        loc = d.get("FullLocation") or {}231        province = (loc.get("province") or "").strip().upper()232        if province and province != "QC":               # succursales US exclues233            return None234235        ext_id = str(d.get("Id") or "").strip()236        url = d.get("Permalink") or ""237        if not ext_id or not url:238            return None239240        year = d.get("Year")241        make = str(d.get("Make") or "").strip()242        model = str(d.get("Model") or "").strip()243        title = (d.get("ShortTitle")244                 or " ".join(str(x) for x in (year, make, model) if x)).strip()245246        price = d.get("Price")247        try:248            price = float(price) if price else None249        except (TypeError, ValueError):250            price = None251252        km = d.get("Odometer")253        try:254            km = float(km) if km not in (None, "") else None255        except (TypeError, ValueError):256            km = None257        if km is not None and str(d.get("OdoUnitCode") or "").upper() == "SMI":258            km = round(km * MILE_KM)                    # milles -> km259260        # portes / passagers / équipements depuis la liste CarDetails261        doors = seats = None262        features: list[str] = []263        for item in d.get("CarDetails") or []:264            name = str(item.get("name") or "").strip()265            value = str(item.get("value") or "").strip()266            if not value:                               # équipement pur267                m = _DOORS_RE.search(name)268                if m:269                    doors = int(m.group(1))270                elif name and "Stock" not in name:271                    features.append(name.rstrip(" :"))272            elif "passagers" in name.lower():273                try:274                    seats = int(value)275                except ValueError:276                    pass277278        image = d.get("MainImage") or ""279        if image:                                       # carte 448x252 -> grand280            image = _IMG_SIZE_RE.sub(r"/by-size/\1/1010x568/", image)281282        post = d.get("post") or {}283        description = str(post.get("post_content") or "")[:4000]284285        details: dict = {}286        if loc.get("city_slug"):287            details["branch_slug"] = loc["city_slug"]288        if d.get("Categories"):289            details["categories"] = d["Categories"]290        if d.get("HwyMPG"):291            details["consumption_hwy_l_100km"] = d["HwyMPG"]292        if d.get("OldPrice"):293            details["old_price"] = d["OldPrice"]294295        return Vehicle(296            source=self.source_id,297            external_id=ext_id,298            url=url,299            title=title,300            make=make,301            model=model,302            trim=str(d.get("Trim") or "").strip(),303            year=int(year) if year else None,304            price=price,305            price_label=str(d.get("FormattedPrice") or ""),306            mileage_km=km,307            mileage_label=f"{km:,.0f} km".replace(",", " ") if km else "",308            transmission=str(d.get("Transmission") or ""),309            fuel=str(d.get("Fuel") or ""),310            drivetrain=str(d.get("DrivetrainType") or ""),311            body_type=str(d.get("BodyStyle") or ""),312            exterior_color=str(d.get("ExteriorColor") or ""),313            interior_color=str(d.get("InteriorColor") or ""),314            engine=str(d.get("Engine") or ""),315            doors=doors,316            seats=seats,317            vin=str(d.get("VIN") or ""),318            stock_number=str(d.get("Stock") or ""),319            dealer_name=self.dealer_name,320            city=_clean_city(str(loc.get("city") or "").strip()),321            description=description,322            features=features,323            details=details,324            images=[image] if image else [],325        )326