# ----------------------------------------------------------------------------- # Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # dealers.py : coordonnées des concessionnaires (adresse, téléphone, GPS) # # 1 source = 1 site de concessionnaire (sauf portails/marketplaces) : on # visite UNE page par site (l'accueil suffit — l'adresse et le téléphone # sont dans l'en-tête/le pied de page, et la plupart des plateformes (D2C, # SM360, Convertus…) émettent un JSON-LD AutoDealer/LocalBusiness complet). # Extraction : JSON-LD d'abord (address/telephone/geo), replis regex # (lien tel:, adresse avec code postal canadien). GPS par défaut : centre # de la ville de la source (data/villes_gps.json). # # Budget : ~138 requêtes UNE fois (cache en base — on ne re-visite que les # sources absentes de la table), throttle 1 s. # # One-shot : python3 -m autoka.dealers [--refresh] # ----------------------------------------------------------------------------- from __future__ import annotations import json import re import sqlite3 import time from pathlib import Path import requests from .normalize import city_gps SOURCES_PATH = Path(__file__).resolve().parent.parent / "data" / "sources.json" USER_AGENT = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36 " "AutoKaBot/1.0 (+contact@spboucher.ai)") _SCHEMA = """ CREATE TABLE IF NOT EXISTS dealers ( source TEXT PRIMARY KEY, -- id de la source (data/sources.json) name TEXT, address TEXT, phone TEXT, website TEXT, city TEXT, lat REAL, lng REAL, fetched_at REAL ); """ _LD_RE = re.compile( r']*type=["\']application/ld\+json["\'][^>]*>(.*?)', re.S) _TEL_RE = re.compile(r'href=["\']tel:([+\d][\d\s().\-]{6,18}\d)["\']', re.I) # « 123 rue Untel, Ville (QC) J0X 1A0 » — ancré sur le code postal canadien # (MAJUSCULES obligatoires et ; : # { } exclus : évite les couleurs hex et # variables CSS inline des thèmes D2C qui imitent le motif postal) _ADDR_RE = re.compile( r"(\d{1,5}[^<>{}|;:#\n]{5,90}?\b[A-Z]\d[A-Z]\s?\d[A-Z]\d\b)") _STYLE_RE = re.compile(r"<(style|script)[^>]*>.*?", re.S | re.I) _PHONE_TXT_RE = re.compile(r"(? None: con.executescript(_SCHEMA) con.commit() def _iter_ld(html: str): for blob in _LD_RE.findall(html): try: data = json.loads(blob.strip()) except ValueError: continue stack = data if isinstance(data, list) else [data] for item in stack: if isinstance(item, dict): yield from _flatten_ld(item) def _flatten_ld(item: dict): yield item for v in item.get("@graph", []) if isinstance(item.get("@graph"), list) else []: if isinstance(v, dict): yield v def _fmt_address(addr) -> str: if isinstance(addr, str): return " ".join(addr.split()) if isinstance(addr, dict): parts = [addr.get("streetAddress"), addr.get("addressLocality"), addr.get("addressRegion"), addr.get("postalCode")] return ", ".join(str(p).strip() for p in parts if p) return "" def _clean_phone(raw: str) -> str: digits = re.sub(r"\D", "", raw or "") if digits.startswith("1") and len(digits) == 11: digits = digits[1:] if len(digits) != 10: return (raw or "").strip() return f"({digits[0:3]}) {digits[3:6]}-{digits[6:]}" def _extract(html: str) -> dict: """address / phone / lat / lng depuis une page HTML de concessionnaire.""" out: dict = {} for item in _iter_ld(html): types = item.get("@type") or "" types = {types} if isinstance(types, str) else set(types) if not (types & _DEALER_LD_TYPES): continue if not out.get("address") and item.get("address"): out["address"] = _fmt_address(item["address"]) if not out.get("phone") and item.get("telephone"): out["phone"] = _clean_phone(str(item["telephone"])) geo = item.get("geo") or {} if isinstance(geo, dict) and not out.get("lat"): try: lat, lng = float(geo.get("latitude")), float(geo.get("longitude")) if 44.0 <= lat <= 63.0 and -80.0 <= lng <= -57.0: # Québec out["lat"], out["lng"] = lat, lng except (TypeError, ValueError): pass if out.get("address") and out.get("phone"): break if not out.get("phone"): m = _TEL_RE.search(html) or _PHONE_TXT_RE.search(html) if m: out["phone"] = _clean_phone(m.group(1)) if not out.get("address"): text = re.sub(r"<[^>]+>", " ", _STYLE_RE.sub(" ", html)) m = _ADDR_RE.search(text) # garde-fou : une vraie adresse contient un mot (rue, boul., ville…) if m and re.search(r"[A-Za-zÀ-ÿ]{3,}", m.group(1)): out["address"] = " ".join(m.group(1).split()) return out def populate(con: sqlite3.Connection, refresh: bool = False, delay: float = 1.0) -> dict: """Peuple la table dealers — 1 requête par source manquante, throttle 1 s.""" ensure_schema(con) registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"] have = {r["source"] for r in con.execute("SELECT source FROM dealers")} session = requests.Session() session.headers["User-Agent"] = USER_AGENT fetched = filled_addr = filled_phone = errors = 0 for s in registry: sid = s["id"] if s.get("type") == "marketplace": # Kijiji : pas un concessionnaire continue if sid in have and not refresh: continue info: dict = {} try: resp = session.get(s["url"], timeout=25) resp.raise_for_status() info = _extract(resp.text) fetched += 1 except Exception as exc: errors += 1 print(f"[auto-ka] dealers : {sid} — {exc}") gps = (info.get("lat"), info.get("lng")) if gps[0] is None or gps[1] is None: hit = city_gps(s.get("city")) gps = hit if hit else (None, None) con.execute( """INSERT INTO dealers (source, name, address, phone, website, city, lat, lng, fetched_at) VALUES (?,?,?,?,?,?,?,?,?) ON CONFLICT(source) DO UPDATE SET name=excluded.name, address=excluded.address, phone=excluded.phone, website=excluded.website, city=excluded.city, lat=excluded.lat, lng=excluded.lng, fetched_at=excluded.fetched_at""", (sid, s.get("name", sid), info.get("address", ""), info.get("phone", ""), s["url"], s.get("city", ""), gps[0], gps[1], time.time())) con.commit() if info.get("address"): filled_addr += 1 if info.get("phone"): filled_phone += 1 time.sleep(delay) total = con.execute("SELECT COUNT(*) c FROM dealers").fetchone()["c"] out = {"fetched": fetched, "errors": errors, "with_address": filled_addr, "with_phone": filled_phone, "dealers_total": total} print(f"[auto-ka] dealers : {out}") return out def list_dealers(con: sqlite3.Connection) -> list[dict]: ensure_schema(con) counts = {r["source"]: r["n"] for r in con.execute( "SELECT source, COUNT(*) n FROM vehicles WHERE active=1 GROUP BY source")} rows = [dict(r) for r in con.execute( "SELECT * FROM dealers ORDER BY name COLLATE NOCASE")] for r in rows: r["active_listings"] = counts.get(r["source"], 0) return rows if __name__ == "__main__": import sys from . import db con = db.connect() populate(con, refresh="--refresh" in sys.argv) con.close()