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>
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 | ||