SPB Git forge

spb/auto-ka

Public
61commits 1branches 0releases
14.4 MBsize
maindefault branch
12 days agolast push
Python 61.6% TypeScript 20.9% CSS 11.4% JavaScript 5.1% HTML 1.1%

Enrichissement connecteurs : Carfax, détails AED/LPDG, verticale moto, photos Magnetis, GPS concessionnaires

- Carfax : D2C extrait le lien vhr.carfax.ca du badge de la page (carprooflink
  JSON presque toujours vide) — clé cache v3 ; SM360 mappe carProofId GraphQL
  (souvent vide chez SM360, mais mappé quand publié) — clé v3 ; Occasion
  Charlevoix vérifié + repli vhr.carfax.ca.
- automobileendirect : details.listed_at (jours sur le marché), historique
  d accident (structural_damage/total_damage), promotions, rabais_roulez_vert,
  third_chance ; fuelConsumption (déjà caché) enfin écrit -> fuel_city/
  fuel_hwy_l_100km.
- Moto PowerGo (13 sources) : parsing des specs HTML des pages détail
  (spec-vin/category/engine-size/submodel/color — le JSON-LD ne les a pas) ;
  clé cache v2 ; carburant (Essence/Électrique), rouage Propulsion,
  boîte Automatique (scooters), carrosserie=catégorie ; regular_price/rebate
  (basePrice vs salePrice) et vidéo du listing.
- magnetis_dealers : nœud [Product, Car] relocalisé dans un @graph — galerie
  S3 + VIN + description de nouveau extraits ; clé cache v2.
- leprixdugros : rebate numérique + regular_price reconstitué, certified/
  econoplus, payments/weekly mappés avec garde-fous (sentinelle 9999999).
- Géolocalisation : colonnes lat/lng (migration additive + index),
  data/villes_gps.json (216 villes QC), city_gps() dans normalize,
  remplissage au finalize() + backfill des existants (97 % des actifs).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 18, 2026) parent 55edb1a

11 changed files +1,166 −40

modified autoka/connectors/automobileendirect.py +29 −0
@@ -204,6 +204,35 @@ class AutomobileEnDirect(BaseConnector):
204 204 if r.get("carfaxAmount") is not None:
205 205 details["carfax_reports"] = r["carfaxAmount"]
206 206
207 + # historique d'accident (déclaré par l'API) : dommages structurels
208 + # et total ($) des réparations — signaux clés en auto usagée
209 + if r.get("structuralDamage"):
210 + details["structural_damage"] = True
211 + try:
212 + total_damage = float(r.get("totalDamage") or 0)
213 + except (TypeError, ValueError):
214 + total_damage = 0.0
215 + if total_damage > 0:
216 + details["total_damage"] = total_damage
217 + # date de mise en vente -> jours sur le marché calculables en aval
218 + if r.get("createdDate"):
219 + details["listed_at"] = str(r["createdDate"])
220 + if r.get("promotions"):
221 + details["promotions"] = r["promotions"]
222 + if r.get("rabaisRoulezVert"):
223 + details["rabais_roulez_vert"] = True
224 + if r.get("thirdChance"):
225 + details["third_chance"] = True
226 +
227 + # consommation ville/route (détail — téléchargée/cachée mais jamais
228 + # écrite auparavant) : L/100 km
229 + fc = extra.get("fuelConsumption") or {}
230 + if isinstance(fc, dict):
231 + if fc.get("city"):
232 + details["fuel_city_l_100km"] = fc["city"]
233 + if fc.get("highway"):
234 + details["fuel_hwy_l_100km"] = fc["highway"]
235 +
207 236 try:
208 237 doors = int(extra["doors"]) if extra.get("doors") else None
209 238 except (TypeError, ValueError):
modified autoka/connectors/d2c_dealers.py +22 −4
@@ -12,7 +12,7 @@
12 12 # (clé hebdomadaire) : seuls les nouveaux véhicules — et une revalidation
13 13 # par semaine pour les prix — génèrent de vraies requêtes.
14 14 #
15 −# Enrichissement (clé de cache v2) — en plus du JSON-LD (qui ne liste que
15 +# Enrichissement (clé de cache v3) — en plus du JSON-LD (qui ne liste que
16 16 # 3 photos, parfois avec des ids corrompus — bug D2C) :
17 17 # - galerie COMPLÈTE : les URLs imagescdn.d2cmedia.ca/<hash>/<dealer>/<id>/
18 18 # <n>/… présentes dans le HTML (préférence à la variante « cb » grand
@@ -68,6 +68,10 @@ _IMG_VARIANT_RANK = {"cb": 0, "mb": 1, "s8": 2}
68 68
69 69 _VDP_JSON_RE = re.compile(r'window\.__vdpJSON\s*=\s*')
70 70
71 +# lien direct vers le rapport Carfax/CarProof (badge de la page détail) —
72 +# vhr.carfax.ca seulement : cdn.carfax.ca ne sert que les logos
73 +_CARFAX_RE = re.compile(r'https?://vhr\.carfax\.ca/[^\s"\'<>\\]+', re.I)
74 +
71 75 MAX_IMAGES = 25
72 76
73 77
@@ -217,11 +221,24 @@ class D2CConnector(BaseConnector):
217 221 if engine:
218 222 data["_engine"] = engine
219 223
224 + # lien Carfax : carprooflink du JSON véhicule (rarement rempli), puis
225 + # carproof.link du blob __vdpJSON, puis le href vhr.carfax.ca du badge
226 + # rendu dans la page (le cas le plus fréquent chez D2C — le blob
227 + # carproof est de type « badge » avec link vide, l'URL est dans html)
220 228 carfax = str(extra.get("carprooflink") or "").strip()
221 229 if not carfax:
222 230 cp = vdp.get("carproof")
223 231 if isinstance(cp, dict):
224 232 carfax = str(cp.get("link") or "").strip()
233 + if not carfax:
234 + m2 = _CARFAX_RE.search(str(cp.get("html") or ""))
235 + if m2:
236 + carfax = m2.group(0)
237 + if not carfax:
238 + m2 = _CARFAX_RE.search(html)
239 + if m2:
240 + carfax = m2.group(0)
241 + carfax = htmllib.unescape(carfax)
225 242 if carfax.startswith("http"):
226 243 data["_carfax_url"] = carfax
227 244
@@ -231,9 +248,10 @@ class D2CConnector(BaseConnector):
231 248 def fetch(self) -> list[Vehicle]:
232 249 urls = self._vehicle_urls()
233 250 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 cache
236 − cache_key = f"v2:{week.year}w{week.week}" # revalidation hebdomadaire
251 + # v3 : lien Carfax extrait du badge vhr.carfax.ca de la page (le
252 + # carprooflink du JSON véhicule est presque toujours vide) — le bump
253 + # force la re-crawl progressive des pages en cache
254 + cache_key = f"v3:{week.year}w{week.week}" # revalidation hebdomadaire
237 255 cap = int(os.environ.get("AUTOKA_MAX_DETAILS", self.max_details))
238 256
239 257 vehicles: list[Vehicle] = []
modified autoka/connectors/leprixdugros.py +26 −2
@@ -251,10 +251,34 @@ class LePrixDuGros(BaseConnector):
251 251 images = [str(item["photo"])]
252 252
253 253 details: dict = {"inventory_type": inventory}
254 − if item.get("rebate"):
255 − details["rebate"] = item["rebate"]
254 +
255 + # rabais : numérique > 0 seulement (« 0 » chaîne = pas de rabais) ;
256 + # prix régulier reconstitué = prix affiché + rabais
257 + try:
258 + rebate = float(item.get("rebate") or 0)
259 + except (TypeError, ValueError):
260 + rebate = 0.0
261 + if rebate > 0:
262 + details["rebate"] = rebate
263 + if price:
264 + details["regular_price"] = price + rebate
265 +
256 266 if str(item.get("is_certified") or "0") not in ("0", "", "None"):
257 267 details["certified"] = True
268 + if str(item.get("econoplus") or "0") not in ("0", "", "None", "False"):
269 + details["econoplus"] = True
270 +
271 + # mensualités du JSON liste : `payments` (bloc financement) et
272 + # `weekly` ($/semaine). Garde-fous : payments souvent vide, weekly
273 + # à 9999999 (sentinelle « non calculé ») — n'écrire que du réel.
274 + if item.get("payments") not in (None, "", 0, "0", [], {}):
275 + details["payments"] = item["payments"]
276 + try:
277 + weekly = float(item.get("weekly") or 0)
278 + except (TypeError, ValueError):
279 + weekly = 0.0
280 + if 0 < weekly < 5000: # sentinelle 9999999 exclue
281 + details["weekly_payment"] = weekly
258 282 if specs.get("Cabine"):
259 283 details["cab"] = specs["Cabine"]
260 284 if detail.get("address"):
modified autoka/connectors/magnetis_dealers.py +23 −8
@@ -95,18 +95,32 @@ class MagnetisConnector(BaseConnector):
95 95 pass
96 96
97 97 # JSON-LD : la page inclut aussi des « véhicules similaires » — le
98 − # bon bloc est celui dont productID correspond à l'id de l'URL.
98 + # bon nœud est celui dont productID correspond à l'id de l'URL.
99 + # Depuis 2026 la plateforme publie un unique bloc @graph (WebPage,
100 + # AutoDealer, [Product, Car]…) : on parcourt aussi les @graph.
99 101 for block in _LD_RE.findall(html):
100 102 try:
101 103 data = json.loads(block.strip(), strict=False)
102 104 except ValueError:
103 105 continue
104 − types = data.get("@type")
105 − types = types if isinstance(types, list) else [types]
106 − if "Vehicle" not in types and "Car" not in types:
107 − continue
108 − if str(data.get("productID") or "") == ext_id:
109 − payload["ld"] = data
106 + candidates = data if isinstance(data, list) else [data]
107 + nodes: list = []
108 + for cand in candidates:
109 + if not isinstance(cand, dict):
110 + continue
111 + graph = cand.get("@graph")
112 + nodes.extend(graph if isinstance(graph, list) else [cand])
113 + for node in nodes:
114 + if not isinstance(node, dict):
115 + continue
116 + types = node.get("@type")
117 + types = types if isinstance(types, list) else [types]
118 + if not any(t in ("Vehicle", "Car", "Product") for t in types):
119 + continue
120 + if str(node.get("productID") or "") == ext_id:
121 + payload["ld"] = node
122 + break
123 + if payload.get("ld"):
110 124 break
111 125 return payload
112 126
@@ -114,7 +128,8 @@ class MagnetisConnector(BaseConnector):
114 128 def fetch(self) -> list[Vehicle]:
115 129 urls = self._vehicle_urls()
116 130 week = datetime.date.today().isocalendar()
117 − cache_key = f"v1:{week.year}w{week.week}" # revalidation hebdomadaire
131 + # v2 : nœud Vehicle relocalisé dans un @graph (galerie + VIN + desc.)
132 + cache_key = f"v2:{week.year}w{week.week}" # revalidation hebdomadaire
118 133 cap = int(os.environ.get("AUTOKA_MAX_DETAILS", self.max_details))
119 134
120 135 vehicles: list[Vehicle] = []
modified autoka/connectors/moto_dealers.py +100 −13
@@ -119,9 +119,35 @@ class D2CMotoConnector(D2CConnector):
119 119 # gabarit CDN de la 1re photo du listing : {imgWidth} → largeur désirée
120 120 _PG_IMG_WIDTH_RE = re.compile(r"\{imgWidth\}")
121 121
122 +# liste « Spécifications » de la page détail PowerGo : le JSON-LD n'expose
123 +# NI VIN, NI carburant, NI catégorie — mais le HTML server-rendered liste
124 +# <li class="… spec-vin">NIV: <span …>538XXFZ…</span></li>, spec-color,
125 +# spec-category, spec-engine-size, spec-submodel, spec-weight…
126 +_PG_SPEC_RE = re.compile(
127 + r'<li class="[^"]*\bspec-([a-z0-9_-]+)[^"]*">(.*?)</li>', re.S)
128 +_PG_SPEC_VAL_RE = re.compile(r"<span[^>]*>(.*?)</span>", re.S)
129 +
130 +# moto électrique (make/modèle/titre) : ne jamais leur inférer Essence
131 +_PG_ELECTRIC_RE = re.compile(
132 + r"\b(électrique|electrique|electric|livewire|zero motorcycles|energica|"
133 + r"elettrica|e-?scooter)\b", re.I)
134 +
122 135 MAX_IMAGES = 25
123 136
124 137
138 +def _pg_extract_specs(html: str) -> dict:
139 + """Spécifications de la page détail : {'vin': …, 'category': …, …}."""
140 + specs: dict[str, str] = {}
141 + for name, body in _PG_SPEC_RE.findall(html):
142 + m = _PG_SPEC_VAL_RE.search(body)
143 + if not m:
144 + continue
145 + val = _clean(m.group(1))
146 + if val and val.upper() not in ("-", "N/D", "N.D.", "N/A"):
147 + specs.setdefault(name.lower(), val)
148 + return specs
149 +
150 +
125 151 def _pg_extract_ld_vehicle(html: str) -> dict:
126 152 """JSON-LD @type Vehicle/Car/Motorcycle d'une page détail PowerGo.
127 153
@@ -190,7 +216,15 @@ class PowerGoMotoConnector(BaseConnector):
190 216 return resp.text
191 217
192 218 def _fetch_detail(self, url: str) -> dict:
193 − return _pg_extract_ld_vehicle(self._get_utf8(url))
219 + """Payload détail : JSON-LD + liste « Spécifications » du HTML.
220 +
221 + Le JSON-LD PowerGo est pauvre (ni VIN, ni carburant, ni catégorie) ;
222 + les spécifications server-rendered comblent VIN, couleur, catégorie,
223 + cylindrée, sous-modèle.
224 + """
225 + html = self._get_utf8(url)
226 + return {"ld": _pg_extract_ld_vehicle(html),
227 + "specs": _pg_extract_specs(html)}
194 228
195 229 # -- contrat -------------------------------------------------------------
196 230 def fetch(self) -> list[Vehicle]:
@@ -216,26 +250,27 @@ class PowerGoMotoConnector(BaseConnector):
216 250 url = self.base_url + page
217 251 ext_id = str(it.get("vehicle_id") or it.get("stockNumber") or page)
218 252 # clé de cache = prix + km : revalidation automatique au changement
219 − key = f"v1:{it.get('salePriceValue')}:{it.get('usageValue')}"
253 + # (v2 : + specs HTML — VIN, catégorie, cylindrée, couleur)
254 + key = f"v2:{it.get('salePriceValue')}:{it.get('usageValue')}"
220 255
221 − ld: dict = {}
256 + det: dict = {}
222 257 if real_fetches < cap:
223 258 before = self._last_request
224 259 try:
225 − ld = self.detail(ext_id, key,
226 − lambda u=url: self._fetch_detail(u))
260 + det = self.detail(ext_id, key,
261 + lambda u=url: self._fetch_detail(u))
227 262 except Exception:
228 − ld = {} # page détail disparue : listing seul
263 + det = {} # page détail disparue : listing seul
229 264 if self._last_request != before:
230 265 real_fetches += 1
231 266 else: # plafond atteint : cache BD sinon listing seul
232 267 from .. import db
233 268 if self._detail_con is None:
234 269 self._detail_con = db.connect()
235 − ld = db.get_cached_detail(self._detail_con, self.source_id,
236 − ext_id, key) or {}
270 + det = db.get_cached_detail(self._detail_con, self.source_id,
271 + ext_id, key) or {}
237 272 try:
238 − veh = self._to_vehicle(ext_id, url, it, ld or {})
273 + veh = self._to_vehicle(ext_id, url, it, det or {})
239 274 except Exception:
240 275 continue
241 276 if veh is not None:
@@ -243,7 +278,10 @@ class PowerGoMotoConnector(BaseConnector):
243 278 return vehicles
244 279
245 280 def _to_vehicle(self, ext_id: str, url: str, it: dict,
246 − ld: dict) -> Vehicle | None:
281 + det: dict) -> Vehicle | None:
282 + ld = det.get("ld") or {}
283 + specs = det.get("specs") or {}
284 +
247 285 title = _clean(it.get("label")) or _clean(ld.get("name"))
248 286 if not title:
249 287 return None
@@ -303,26 +341,75 @@ class PowerGoMotoConnector(BaseConnector):
303 341 ("category", _clean(it.get("category"))))
304 342 if v}
305 343
344 + # prix courant vs prix régulier du listing -> rabais affiché
345 + base = it.get("basePriceValue")
346 + try:
347 + base = float(base) if base not in (None, "", 0, "0") else None
348 + except (TypeError, ValueError):
349 + base = None
350 + if base and price and base > price:
351 + details["regular_price"] = base
352 + details["rebate"] = round(base - price, 2)
353 +
354 + # vidéos du listing (visite 360 / YouTube) — URL quand la source la
355 + # publie, sinon simple drapeau booléen (cas motosillimitees)
356 + video = it.get("video360Url") or it.get("videoYoutubeUrl")
357 + if isinstance(video, str) and video.startswith("http"):
358 + details["video_url"] = video
359 + elif video:
360 + details["has_video"] = True
361 +
362 + if specs.get("weight"):
363 + details["weight"] = specs["weight"]
364 +
365 + kind = self._kind(it)
366 +
367 + # VIN / catégorie (carrosserie moto) / cylindrée / couleur : la liste
368 + # « Spécifications » de la page détail (le JSON-LD ne les a pas)
369 + vin = (specs.get("vin") or "").upper().replace(" ", "")
370 + if len(vin) < 11: # « - », tronqué, absent
371 + vin = ""
372 + body_type = specs.get("category") or _clean(it.get("category"))
373 + engine = specs.get("engine-size", "")
374 +
375 + # carburant : Électrique si la marque/le modèle l'indique, sinon
376 + # Essence (aucune moto/scooter thermique n'est diesel/hybride)
377 + blob = " ".join(filter(None, (make, title, _clean(it.get("model")))))
378 + fuel = "Électrique" if _PG_ELECTRIC_RE.search(blob) else "Essence"
379 +
380 + # rouage : roue arrière motrice = Propulsion (universel en moto,
381 + # y compris les trois roues Spyder/Ryker) ; boîte : les scooters
382 + # sont tous à variateur (CVT) -> Automatique, motos trop variées
383 + transmission = "Automatique" if kind == "scooter" else ""
384 +
306 385 return Vehicle(
307 386 source=self.source_id,
308 387 external_id=ext_id,
309 388 url=url,
310 − kind=self._kind(it),
389 + kind=kind,
311 390 title=title,
312 391 make=make, # explicite : marques moto absentes
313 392 model=_clean(it.get("model")) or _clean(ld.get("model")),
393 + trim=specs.get("submodel", ""),
314 394 year=year,
315 395 price=price,
316 396 price_label=_clean(it.get("salePriceLabel")
317 397 or it.get("basePriceLabel")),
318 398 mileage_km=km,
319 399 mileage_label=usage_label,
320 − exterior_color=_clean(ld.get("color")),
400 + transmission=transmission,
401 + fuel=fuel,
402 + drivetrain="Propulsion",
403 + body_type=body_type,
404 + exterior_color=_clean(ld.get("color")) or specs.get("color", ""),
405 + engine=engine,
406 + vin=vin,
321 407 stock_number=_clean(it.get("stockNumber")) or _clean(ld.get("sku")),
322 408 dealer_name=self.dealer_name,
323 409 # groupes multi-succursales (Contant, Imperium…) : la succursale
324 410 # du véhicule est dans le listing JSON
325 − city=_clean(it.get("location")) or self.city,
411 + city=_clean(it.get("location")) or specs.get("location", "")
412 + or self.city,
326 413 description=_clean(ld.get("description"))[:4000],
327 414 details=details,
328 415 images=images[:MAX_IMAGES],
modified autoka/connectors/occasioncharlevoix.py +8 −0
@@ -32,6 +32,10 @@ _SPEC_RE = re.compile(
32 32 r'<div class="specs-info[^"]*">\s*([^<]*?)\s*<', re.S)
33 33
34 34 _CARFAX_RE = re.compile(r'data-lien-carfax="([^"]+)"')
35 +# repli : lien direct vhr.carfax.ca rendu ailleurs dans la page (vérifié
36 +# 2026-08 : ~55 % des fiches ont data-lien-carfax ; les autres n'exposent
37 +# aucun rapport — le bloc Autoverify data-av-* ne porte que VIN/prix/km)
38 +_CARFAX_VHR_RE = re.compile(r'https?://vhr\.carfax\.ca/[^\s"\'<>\\]+', re.I)
35 39
36 40 _AV_DETAIL_RE = re.compile(
37 41 r'id="av_vehicle_information"[^>]*data-av-mileage="([^"]*)"')
@@ -167,6 +171,10 @@ class OccasionCharlevoix(BaseConnector):
167 171 m = _CARFAX_RE.search(html)
168 172 if m:
169 173 payload["carfax"] = m.group(1)
174 + else:
175 + m = _CARFAX_VHR_RE.search(html)
176 + if m:
177 + payload["carfax"] = m.group(0)
170 178 m = _AV_DETAIL_RE.search(html)
171 179 if m and m.group(1):
172 180 payload["mileage"] = m.group(1)
modified autoka/connectors/sm360_dealers.py +9 −4
@@ -76,7 +76,7 @@ _CARFAX_RE = re.compile(r'https?://(?:vhr\.)?carfax\.ca/[^\s"\'<>]+', re.I)
76 76 # API GraphQL publique des widgets SM360 (celle que la page détail utilise
77 77 # elle-même pour afficher équipements et galerie) — id = id inventaire global
78 78 _GRAPHQL_API = "https://webauto-supplier-api.sm360.ca/webauto/graphql"
79 −_GQL_VEHICLE = ("{ vehicle(id: %d) { description tagline "
79 +_GQL_VEHICLE = ("{ vehicle(id: %d) { description tagline carProofId "
80 80 "options { labels } multimedia { pictures { url } } } }")
81 81
82 82 # clés extraites du dataLayer JS de la page détail (valeurs 'entre quotes')
@@ -245,6 +245,9 @@ class SM360Connector(BaseConnector):
245 245 "images": images[:20],
246 246 "description": _clean(vehicle.get("description")),
247 247 "tagline": _clean(vehicle.get("tagline")),
248 + # id CarProof/Carfax (souvent vide chez SM360, mais mappé quand
249 + # le concessionnaire le publie) -> https://vhr.carfax.ca/?id=…
250 + "carproof_id": str(vehicle.get("carProofId") or "").strip(),
248 251 }
249 252
250 253 # -- multi-succursales (hook, surchargé par TLM) -----------------------------
@@ -255,8 +258,8 @@ class SM360Connector(BaseConnector):
255 258 def fetch(self) -> list[Vehicle]:
256 259 urls = self._vehicle_urls()
257 260 week = datetime.date.today().isocalendar()
258 − # v2 : + équipements/galerie GraphQL (bump = re-crawl complet forcé)
259 − cache_key = f"v2:{week.year}w{week.week}" # revalidation hebdomadaire
261 + # v3 : + carProofId GraphQL (bump = re-crawl complet forcé)
262 + cache_key = f"v3:{week.year}w{week.week}" # revalidation hebdomadaire
260 263 cap = int(os.environ.get("AUTOKA_MAX_DETAILS", self.max_details))
261 264
262 265 vehicles: list[Vehicle] = []
@@ -425,7 +428,9 @@ class SM360Connector(BaseConnector):
425 428 features=list(gql.get("options") or []),
426 429 details=details,
427 430 images=images,
428 − carfax_url=data.get("carfax") or "",
431 + carfax_url=(data.get("carfax")
432 + or (f"https://vhr.carfax.ca/?id={gql['carproof_id']}"
433 + if gql.get("carproof_id") else "")),
429 434 )
430 435
431 436
modified autoka/db.py +16 −8
@@ -111,6 +111,10 @@ CREATE INDEX IF NOT EXISTS idx_price_log_uid ON price_log(uid);
111 111 _MIGRATIONS = {
112 112 "vehicles": {
113 113 "kind": "TEXT DEFAULT 'auto'",
114 + # GPS du concessionnaire (déduit de city — data/villes_gps.json) :
115 + # permet la recherche par rayon autour d'un point
116 + "lat": "REAL",
117 + "lng": "REAL",
114 118 },
115 119 }
116 120
@@ -130,6 +134,8 @@ def connect() -> sqlite3.Connection:
130 134 con.execute(f"ALTER TABLE {table} ADD COLUMN {col} {decl}")
131 135 # index sur des colonnes issues de migrations : après l'ALTER TABLE
132 136 con.execute("CREATE INDEX IF NOT EXISTS idx_vehicles_kind ON vehicles(kind)")
137 + con.execute(
138 + "CREATE INDEX IF NOT EXISTS idx_vehicles_latlng ON vehicles(lat, lng)")
133 139 con.commit()
134 140 return con
135 141
@@ -201,7 +207,8 @@ def sync_source(con: sqlite3.Connection, source: str,
201 207 exterior_color=veh.exterior_color, interior_color=veh.interior_color,
202 208 engine=veh.engine, doors=veh.doors, seats=veh.seats, vin=veh.vin,
203 209 stock_number=veh.stock_number, dealer_name=veh.dealer_name,
204 − city=veh.city, region=veh.region, description=veh.description,
210 + city=veh.city, region=veh.region, lat=veh.lat, lng=veh.lng,
211 + description=veh.description,
205 212 features=json.dumps(veh.features, ensure_ascii=False),
206 213 details=json.dumps(veh.details, ensure_ascii=False),
207 214 images=json.dumps(veh.images, ensure_ascii=False),
@@ -214,16 +221,16 @@ def sync_source(con: sqlite3.Connection, source: str,
214 221 mileage_km, mileage_label, transmission, fuel, drivetrain,
215 222 body_type, exterior_color, interior_color, engine, doors,
216 223 seats, vin, stock_number, dealer_name, city, region,
217 − description, features, details, images, carfax_url,
218 − content_hash, first_seen, last_seen, updated_at,
219 − miss_count, active)
224 + lat, lng, description, features, details, images,
225 + carfax_url, content_hash, first_seen, last_seen,
226 + updated_at, miss_count, active)
220 227 VALUES (:uid,:source,:external_id,:url,:kind,:title,:make,
221 228 :model,:trim,:year,:price,:price_label,:mileage_km,
222 229 :mileage_label,:transmission,:fuel,:drivetrain,:body_type,
223 230 :exterior_color,:interior_color,:engine,:doors,:seats,:vin,
224 − :stock_number,:dealer_name,:city,:region,:description,
225 − :features,:details,:images,:carfax_url,:content_hash,
226 − :now,:now,:now,0,1)""",
231 + :stock_number,:dealer_name,:city,:region,:lat,:lng,
232 + :description,:features,:details,:images,:carfax_url,
233 + :content_hash,:now,:now,:now,0,1)""",
227 234 params)
228 235 if veh.price is not None: # prix initial = départ de l'historique
229 236 con.execute("INSERT INTO price_log (uid, ts, price) VALUES (?,?,?)",
@@ -239,7 +246,8 @@ def sync_source(con: sqlite3.Connection, source: str,
239 246 exterior_color=:exterior_color, interior_color=:interior_color,
240 247 engine=:engine, doors=:doors, seats=:seats, vin=:vin,
241 248 stock_number=:stock_number, dealer_name=:dealer_name,
242 − city=:city, region=:region, description=:description,
249 + city=:city, region=:region, lat=:lat, lng=:lng,
250 + description=:description,
243 251 features=:features, details=:details, images=:images,
244 252 carfax_url=:carfax_url, content_hash=:content_hash,
245 253 last_seen=:now, updated_at=:now, miss_count=0, active=1
modified autoka/normalize.py +56 −1
@@ -14,7 +14,7 @@ __all__ = [
14 14 "strip_accents", "parse_price", "price_is_from", "parse_mileage",
15 15 "parse_year", "normalize_make", "split_title", "normalize_transmission",
16 16 "normalize_fuel", "normalize_drivetrain", "normalize_body", "infer_region",
17 − "MAKES",
17 + "city_gps", "MAKES",
18 18 ]
19 19
20 20
@@ -376,3 +376,58 @@ def infer_region(city: str | None) -> str:
376 376 if c in key:
377 377 return region
378 378 return ""
379 +
380 +
381 +# ---------------------------------------------------------------------------
382 +# Ville -> coordonnées GPS (recherche par rayon)
383 +#
384 +# Table statique data/villes_gps.json : ville normalisée -> [lat, lng]
385 +# (centre-ville approximatif — la précision « ville » suffit pour un rayon).
386 +# ---------------------------------------------------------------------------
387 +
388 +_VILLES_GPS: dict[str, tuple[float, float]] | None = None
389 +
390 +
391 +def _load_villes_gps() -> dict[str, tuple[float, float]]:
392 + global _VILLES_GPS
393 + if _VILLES_GPS is None:
394 + import json
395 + from pathlib import Path
396 + path = Path(__file__).resolve().parent.parent / "data" / "villes_gps.json"
397 + table: dict[str, tuple[float, float]] = {}
398 + try:
399 + for name, coords in json.loads(path.read_text()).items():
400 + if isinstance(coords, (list, tuple)) and len(coords) == 2:
401 + table[name] = (float(coords[0]), float(coords[1]))
402 + except (OSError, ValueError):
403 + pass
404 + _VILLES_GPS = table
405 + return _VILLES_GPS
406 +
407 +
408 +def _gps_key(city: str) -> str:
409 + """Clé de recherche : minuscules, sans accents, st-/ste- développés."""
410 + key = strip_accents(str(city)).lower().replace("’", "'").strip()
411 + key = re.sub(r"\bst-", "saint-", key)
412 + key = re.sub(r"\bste-", "sainte-", key)
413 + return re.sub(r"\s{2,}", " ", key)
414 +
415 +
416 +def city_gps(city: str | None) -> tuple[float, float] | None:
417 + """Coordonnées (lat, lng) du centre-ville pour une ville du Québec.
418 +
419 + Correspondance exacte d'abord, puis par mot entier (« Blainville
420 + Signature » -> blainville) — jamais par sous-chaîne nue (« Laval »
421 + ne matche pas « Lavaltrie »).
422 + """
423 + if not city:
424 + return None
425 + table = _load_villes_gps()
426 + key = _gps_key(city)
427 + hit = table.get(key)
428 + if hit:
429 + return hit
430 + for name in sorted(table, key=len, reverse=True):
431 + if re.search(rf"(?<![\w-]){re.escape(name)}(?![\w-])", key):
432 + return table[name]
433 + return None
modified autoka/schema.py +11 −0
@@ -18,6 +18,7 @@ import json
18 18 from dataclasses import dataclass, field, asdict
19 19
20 20 from .normalize import ( # ré-exportés pour les connecteurs
21 + city_gps,
21 22 infer_region,
22 23 normalize_body,
23 24 normalize_drivetrain,
@@ -36,6 +37,7 @@ __all__ = [
36 37 "Vehicle", "parse_price", "parse_mileage", "parse_year", "split_title",
37 38 "normalize_make", "normalize_transmission", "normalize_fuel",
38 39 "normalize_drivetrain", "normalize_body", "infer_region", "strip_accents",
40 + "city_gps",
39 41 ]
40 42
41 43
@@ -70,6 +72,8 @@ class Vehicle:
70 72 dealer_name: str = "" # nom d'affichage du commerce
71 73 city: str = "" # ville du concessionnaire
72 74 region: str = "" # région administrative (déduite de city)
75 + lat: float | None = None # GPS du concessionnaire (déduit de city —
76 + lng: float | None = None # data/villes_gps.json ; rayon de recherche)
73 77 description: str = ""
74 78 features: list[str] = field(default_factory=list) # équipements (texte source)
75 79 details: dict = field(default_factory=dict) # champs structurés (JSON)
@@ -128,6 +132,13 @@ class Vehicle:
128 132 if not self.region:
129 133 self.region = infer_region(self.city)
130 134
135 + # géolocalisation du concessionnaire (précision « ville » — suffit
136 + # pour la recherche par rayon) si le connecteur ne l'a pas fournie
137 + if self.lat is None or self.lng is None:
138 + gps = city_gps(self.city)
139 + if gps:
140 + self.lat, self.lng = gps
141 +
131 142 if self.price_label and price_is_from(self.price_label):
132 143 self.details["price_from"] = True
133 144
added data/villes_gps.json +866 −0
@@ -0,0 +1,866 @@
1 +{
2 + "acton vale": [
3 + 45.647,
4 + -72.567
5 + ],
6 + "ahuntsic": [
7 + 45.553,
8 + -73.662
9 + ],
10 + "alma": [
11 + 48.55,
12 + -71.6491
13 + ],
14 + "amos": [
15 + 48.566,
16 + -78.116
17 + ],
18 + "amqui": [
19 + 48.4633,
20 + -67.431
21 + ],
22 + "anjou": [
23 + 45.613,
24 + -73.556
25 + ],
26 + "asbestos": [
27 + 45.777,
28 + -71.933
29 + ],
30 + "aylmer": [
31 + 45.394,
32 + -75.843
33 + ],
34 + "baie-comeau": [
35 + 49.2167,
36 + -68.1489
37 + ],
38 + "baie-d'urfe": [
39 + 45.414,
40 + -73.915
41 + ],
42 + "baie-saint-paul": [
43 + 47.441,
44 + -70.506
45 + ],
46 + "beaconsfield": [
47 + 45.433,
48 + -73.866
49 + ],
50 + "beauharnois": [
51 + 45.313,
52 + -73.872
53 + ],
54 + "beauport": [
55 + 46.86,
56 + -71.193
57 + ],
58 + "becancour": [
59 + 46.333,
60 + -72.433
61 + ],
62 + "beloeil": [
63 + 45.569,
64 + -73.21
65 + ],
66 + "berthierville": [
67 + 46.083,
68 + -73.183
69 + ],
70 + "blainville": [
71 + 45.669,
72 + -73.881
73 + ],
74 + "boisbriand": [
75 + 45.62,
76 + -73.839
77 + ],
78 + "boischatel": [
79 + 46.901,
80 + -71.147
81 + ],
82 + "bonaventure": [
83 + 48.045,
84 + -65.492
85 + ],
86 + "boucherville": [
87 + 45.591,
88 + -73.436
89 + ],
90 + "bromont": [
91 + 45.317,
92 + -72.65
93 + ],
94 + "brossard": [
95 + 45.4584,
96 + -73.468
97 + ],
98 + "buckingham": [
99 + 45.586,
100 + -75.416
101 + ],
102 + "candiac": [
103 + 45.384,
104 + -73.519
105 + ],
106 + "cap-de-la-madeleine": [
107 + 46.378,
108 + -72.515
109 + ],
110 + "cap-sante": [
111 + 46.672,
112 + -71.786
113 + ],
114 + "caplan": [
115 + 48.097,
116 + -65.68
117 + ],
118 + "carignan": [
119 + 45.448,
120 + -73.296
121 + ],
122 + "carleton": [
123 + 48.1,
124 + -66.117
125 + ],
126 + "carleton-sur-mer": [
127 + 48.1,
128 + -66.117
129 + ],
130 + "chambly": [
131 + 45.448,
132 + -73.289
133 + ],
134 + "chandler": [
135 + 48.35,
136 + -64.683
137 + ],
138 + "charlemagne": [
139 + 45.717,
140 + -73.483
141 + ],
142 + "charlesbourg": [
143 + 46.853,
144 + -71.256
145 + ],
146 + "charny": [
147 + 46.713,
148 + -71.265
149 + ],
150 + "chateau-richer": [
151 + 46.96,
152 + -71.031
153 + ],
154 + "chateauguay": [
155 + 45.383,
156 + -73.75
157 + ],
158 + "chibougamau": [
159 + 49.917,
160 + -74.366
161 + ],
162 + "chicoutimi": [
163 + 48.4284,
164 + -71.0683
165 + ],
166 + "chomedey": [
167 + 45.5399,
168 + -73.7529
169 + ],
170 + "coaticook": [
171 + 45.133,
172 + -71.803
173 + ],
174 + "contrecoeur": [
175 + 45.85,
176 + -73.233
177 + ],
178 + "cowansville": [
179 + 45.2,
180 + -72.746
181 + ],
182 + "delson": [
183 + 45.396,
184 + -73.548
185 + ],
186 + "deux-montagnes": [
187 + 45.534,
188 + -73.902
189 + ],
190 + "disraeli": [
191 + 45.901,
192 + -71.349
193 + ],
194 + "dolbeau-mistassini": [
195 + 48.878,
196 + -72.232
197 + ],
198 + "dollard-des-ormeaux": [
199 + 45.494,
200 + -73.824
201 + ],
202 + "donnacona": [
203 + 46.68,
204 + -71.7239
205 + ],
206 + "dorval": [
207 + 45.447,
208 + -73.753
209 + ],
210 + "drummondville": [
211 + 45.8833,
212 + -72.482
213 + ],
214 + "dunham": [
215 + 45.133,
216 + -72.805
217 + ],
218 + "duvernay": [
219 + 45.6,
220 + -73.671
221 + ],
222 + "east angus": [
223 + 45.486,
224 + -71.664
225 + ],
226 + "farnham": [
227 + 45.283,
228 + -72.983
229 + ],
230 + "forestville": [
231 + 48.738,
232 + -69.085
233 + ],
234 + "gaspe": [
235 + 48.831,
236 + -64.487
237 + ],
238 + "gatineau": [
239 + 45.4765,
240 + -75.7013
241 + ],
242 + "granby": [
243 + 45.4,
244 + -72.7333
245 + ],
246 + "grand-mere": [
247 + 46.61,
248 + -72.683
249 + ],
250 + "havre-saint-pierre": [
251 + 50.241,
252 + -63.6
253 + ],
254 + "hochelaga-maisonneuve": [
255 + 45.543,
256 + -73.54
257 + ],
258 + "hudson": [
259 + 45.45,
260 + -74.15
261 + ],
262 + "hull": [
263 + 45.428,
264 + -75.713
265 + ],
266 + "iberville": [
267 + 45.314,
268 + -73.247
269 + ],
270 + "ile-perrot": [
271 + 45.3872,
272 + -73.9491
273 + ],
274 + "joliette": [
275 + 46.023,
276 + -73.439
277 + ],
278 + "jonquiere": [
279 + 48.413,
280 + -71.248
281 + ],
282 + "kirkland": [
283 + 45.45,
284 + -73.863
285 + ],
286 + "l'ancienne-lorette": [
287 + 46.8,
288 + -71.35
289 + ],
290 + "l'assomption": [
291 + 45.823,
292 + -73.426
293 + ],
294 + "l'ile-perrot": [
295 + 45.3872,
296 + -73.9491
297 + ],
298 + "la baie": [
299 + 48.333,
300 + -70.883
301 + ],
302 + "la malbaie": [
303 + 47.656,
304 + -70.153
305 + ],
306 + "la pocatiere": [
307 + 47.366,
308 + -70.035
309 + ],
310 + "la prairie": [
311 + 45.417,
312 + -73.493
313 + ],
314 + "la sarre": [
315 + 48.796,
316 + -79.199
317 + ],
318 + "la tuque": [
319 + 47.437,
320 + -72.785
321 + ],
322 + "lac-etchemin": [
323 + 46.403,
324 + -70.489
325 + ],
326 + "lac-megantic": [
327 + 45.583,
328 + -70.883
329 + ],
330 + "lachine": [
331 + 45.431,
332 + -73.675
333 + ],
334 + "lachute": [
335 + 45.65,
336 + -74.336
337 + ],
338 + "lasalle": [
339 + 45.431,
340 + -73.629
341 + ],
342 + "laurier-station": [
343 + 46.538,
344 + -71.633
345 + ],
346 + "laval": [
347 + 45.6066,
348 + -73.7124
349 + ],
350 + "lavaltrie": [
351 + 45.886,
352 + -73.283
353 + ],
354 + "les coteaux": [
355 + 45.283,
356 + -74.233
357 + ],
358 + "levis": [
359 + 46.8033,
360 + -71.1779
361 + ],
362 + "longueuil": [
363 + 45.5312,
364 + -73.5181
365 + ],
366 + "loretteville": [
367 + 46.856,
368 + -71.354
369 + ],
370 + "louiseville": [
371 + 46.256,
372 + -72.941
373 + ],
374 + "magog": [
375 + 45.266,
376 + -72.1483
377 + ],
378 + "malartic": [
379 + 48.133,
380 + -78.133
381 + ],
382 + "maniwaki": [
383 + 46.375,
384 + -75.966
385 + ],
386 + "marieville": [
387 + 45.433,
388 + -73.166
389 + ],
390 + "mascouche": [
391 + 45.7492,
392 + -73.6004
393 + ],
394 + "matane": [
395 + 48.8281,
396 + -67.5222
397 + ],
398 + "mcmasterville": [
399 + 45.548,
400 + -73.232
401 + ],
402 + "mercier": [
403 + 45.317,
404 + -73.75
405 + ],
406 + "mirabel": [
407 + 45.65,
408 + -74.08
409 + ],
410 + "mont-joli": [
411 + 48.5847,
412 + -68.192
413 + ],
414 + "mont-laurier": [
415 + 46.55,
416 + -75.5
417 + ],
418 + "mont-royal": [
419 + 45.516,
420 + -73.643
421 + ],
422 + "mont-saint-gregoire": [
423 + 45.354,
424 + -73.155
425 + ],
426 + "mont-saint-hilaire": [
427 + 45.562,
428 + -73.192
429 + ],
430 + "mont-tremblant": [
431 + 46.118,
432 + -74.596
433 + ],
434 + "montmagny": [
435 + 46.98,
436 + -70.554
437 + ],
438 + "montreal": [
439 + 45.5019,
440 + -73.5674
441 + ],
442 + "montreal-est": [
443 + 45.632,
444 + -73.507
445 + ],
446 + "montreal-nord": [
447 + 45.606,
448 + -73.633
449 + ],
450 + "napierville": [
451 + 45.186,
452 + -73.405
453 + ],
454 + "new richmond": [
455 + 48.159,
456 + -65.866
457 + ],
458 + "nicolet": [
459 + 46.217,
460 + -72.617
461 + ],
462 + "notre-dame-de-l'ile-perrot": [
463 + 45.366,
464 + -73.933
465 + ],
466 + "oka": [
467 + 45.465,
468 + -74.087
469 + ],
470 + "otterburn park": [
471 + 45.533,
472 + -73.217
473 + ],
474 + "outremont": [
475 + 45.518,
476 + -73.607
477 + ],
478 + "papineauville": [
479 + 45.622,
480 + -75.018
481 + ],
482 + "pierrefonds": [
483 + 45.489,
484 + -73.856
485 + ],
486 + "pincourt": [
487 + 45.383,
488 + -73.983
489 + ],
490 + "plessisville": [
491 + 46.221,
492 + -71.774
493 + ],
494 + "pointe-aux-trembles": [
495 + 45.658,
496 + -73.499
497 + ],
498 + "pointe-claire": [
499 + 45.449,
500 + -73.817
501 + ],
502 + "pont-rouge": [
503 + 46.755,
504 + -71.695
505 + ],
506 + "port-cartier": [
507 + 50.033,
508 + -66.866
509 + ],
510 + "portneuf": [
511 + 46.69,
512 + -71.889
513 + ],
514 + "prevost": [
515 + 45.87,
516 + -74.08
517 + ],
518 + "princeville": [
519 + 46.172,
520 + -71.875
521 + ],
522 + "quebec": [
523 + 46.8139,
524 + -71.208
525 + ],
526 + "rawdon": [
527 + 46.049,
528 + -73.715
529 + ],
530 + "repentigny": [
531 + 45.742,
532 + -73.45
533 + ],
534 + "richmond": [
535 + 45.666,
536 + -72.15
537 + ],
538 + "rigaud": [
539 + 45.479,
540 + -74.302
541 + ],
542 + "rimouski": [
543 + 48.4489,
544 + -68.5236
545 + ],
546 + "riviere-des-prairies": [
547 + 45.648,
548 + -73.58
549 + ],
550 + "riviere-du-loup": [
551 + 47.8266,
552 + -69.5417
553 + ],
554 + "roberval": [
555 + 48.521,
556 + -72.226
557 + ],
558 + "rosemere": [
559 + 45.636,
560 + -73.8
561 + ],
562 + "rosemont": [
563 + 45.547,
564 + -73.579
565 + ],
566 + "rouyn-noranda": [
567 + 48.2359,
568 + -79.0223
569 + ],
570 + "saguenay": [
571 + 48.426,
572 + -71.07
573 + ],
574 + "saint-agapit": [
575 + 46.562,
576 + -71.437
577 + ],
578 + "saint-amable": [
579 + 45.65,
580 + -73.3
581 + ],
582 + "saint-apollinaire": [
583 + 46.615,
584 + -71.515
585 + ],
586 + "saint-augustin-de-desmaures": [
587 + 46.741,
588 + -71.452
589 + ],
590 + "saint-basile-le-grand": [
591 + 45.533,
592 + -73.283
593 + ],
594 + "saint-cesaire": [
595 + 45.418,
596 + -73.006
597 + ],
598 + "saint-charles-borromee": [
599 + 46.048,
600 + -73.466
601 + ],
602 + "saint-constant": [
603 + 45.3665,
604 + -73.5662
605 + ],
606 + "saint-eustache": [
607 + 45.5651,
608 + -73.9054
609 + ],
610 + "saint-felicien": [
611 + 48.65,
612 + -72.45
613 + ],
614 + "saint-felix-de-valois": [
615 + 46.17,
616 + -73.425
617 + ],
618 + "saint-gabriel-de-brandon": [
619 + 46.296,
620 + -73.387
621 + ],
622 + "saint-georges": [
623 + 46.1187,
624 + -70.6667
625 + ],
626 + "saint-gervais": [
627 + 46.719,
628 + -70.891
629 + ],
630 + "saint-hippolyte": [
631 + 45.933,
632 + -74.017
633 + ],
634 + "saint-hubert": [
635 + 45.4869,
636 + -73.413
637 + ],
638 + "saint-hyacinthe": [
639 + 45.6306,
640 + -72.9571
641 + ],
642 + "saint-jean-sur-richelieu": [
643 + 45.307,
644 + -73.262
645 + ],
646 + "saint-jerome": [
647 + 45.7809,
648 + -74.0036
649 + ],
650 + "saint-joseph-de-beauce": [
651 + 46.308,
652 + -70.873
653 + ],
654 + "saint-laurent": [
655 + 45.5,
656 + -73.666
657 + ],
658 + "saint-lazare": [
659 + 45.4,
660 + -74.133
661 + ],
662 + "saint-leonard": [
663 + 45.5877,
664 + -73.5959
665 + ],
666 + "saint-lin-laurentides": [
667 + 45.85,
668 + -73.766
669 + ],
670 + "saint-mathias-sur-richelieu": [
671 + 45.459,
672 + -73.253
673 + ],
674 + "saint-nicolas": [
675 + 46.704,
676 + -71.348
677 + ],
678 + "saint-philippe": [
679 + 45.355,
680 + -73.476
681 + ],
682 + "saint-raymond": [
683 + 46.904,
684 + -71.836
685 + ],
686 + "saint-remi": [
687 + 45.267,
688 + -73.617
689 + ],
690 + "saint-romuald": [
691 + 46.762,
692 + -71.228
693 + ],
694 + "saint-sauveur": [
695 + 45.887,
696 + -74.171
697 + ],
698 + "sainte-adele": [
699 + 45.951,
700 + -74.133
701 + ],
702 + "sainte-agathe": [
703 + 46.045,
704 + -74.281
705 + ],
706 + "sainte-agathe-des-monts": [
707 + 46.045,
708 + -74.281
709 + ],
710 + "sainte-anne-de-bellevue": [
711 + 45.406,
712 + -73.945
713 + ],
714 + "sainte-anne-des-monts": [
715 + 49.124,
716 + -66.492
717 + ],
718 + "sainte-catherine": [
719 + 45.401,
720 + -73.581
721 + ],
722 + "sainte-croix": [
723 + 46.628,
724 + -71.729
725 + ],
726 + "sainte-foy": [
727 + 46.78,
728 + -71.287
729 + ],
730 + "sainte-julie": [
731 + 45.586,
732 + -73.326
733 + ],
734 + "sainte-marie": [
735 + 46.438,
736 + -71.009
737 + ],
738 + "sainte-rose": [
739 + 45.618,
740 + -73.788
741 + ],
742 + "sainte-therese": [
743 + 45.639,
744 + -73.828
745 + ],
746 + "salaberry-de-valleyfield": [
747 + 45.257,
748 + -74.133
749 + ],
750 + "senneterre": [
751 + 48.391,
752 + -77.24
753 + ],
754 + "sept-iles": [
755 + 50.2168,
756 + -66.3822
757 + ],
758 + "shawinigan": [
759 + 46.5668,
760 + -72.7441
761 + ],
762 + "sherbrooke": [
763 + 45.4042,
764 + -71.8929
765 + ],
766 + "sorel-tracy": [
767 + 46.043,
768 + -73.113
769 + ],
770 + "sutton": [
771 + 45.104,
772 + -72.616
773 + ],
774 + "temiscouata-sur-le-lac": [
775 + 47.68,
776 + -68.877
777 + ],
778 + "terrebonne": [
779 + 45.7057,
780 + -73.6461
781 + ],
782 + "thetford": [
783 + 46.094,
784 + -71.304
785 + ],
786 + "thetford mines": [
787 + 46.094,
788 + -71.304
789 + ],
790 + "trois-pistoles": [
791 + 48.124,
792 + -69.173
793 + ],
794 + "trois-rivieres": [
795 + 46.3432,
796 + -72.543
797 + ],
798 + "val-belair": [
799 + 46.876,
800 + -71.439
801 + ],
802 + "val-d'or": [
803 + 48.0975,
804 + -77.7827
805 + ],
806 + "val-des-sources": [
807 + 45.777,
808 + -71.933
809 + ],
810 + "valleyfield": [
811 + 45.257,
812 + -74.133
813 + ],
814 + "varennes": [
815 + 45.683,
816 + -73.433
817 + ],
818 + "vaudreuil": [
819 + 45.4,
820 + -74.0325
821 + ],
822 + "vaudreuil-dorion": [
823 + 45.4,
824 + -74.0325
825 + ],
826 + "vercheres": [
827 + 45.783,
828 + -73.35
829 + ],
830 + "verdun": [
831 + 45.458,
832 + -73.568
833 + ],
834 + "victoriaville": [
835 + 46.0526,
836 + -71.9614
837 + ],
838 + "ville-marie": [
839 + 47.333,
840 + -79.433
841 + ],
842 + "villeray": [
843 + 45.543,
844 + -73.614
845 + ],
846 + "vimont": [
847 + 45.61,
848 + -73.728
849 + ],
850 + "warwick": [
851 + 45.95,
852 + -71.983
853 + ],
854 + "waterloo": [
855 + 45.349,
856 + -72.516
857 + ],
858 + "westmount": [
859 + 45.483,
860 + -73.597
861 + ],
862 + "windsor": [
863 + 45.567,
864 + -71.999
865 + ]
866 +}
\ No newline at end of file
867