# connect2gpx — Description technique ## Vue d'ensemble `connect2gpx` est un outil en ligne de commande (Python ≥ 3.13) qui convertit un lien **Garmin Trails** (page web d'un trail curaté) en fichier **GPX 1.1**. Le projet est organisé en quatre modules sous `src/connect2gpx/`. ## Architecture ``` src/connect2gpx/ ├── cli.py # point d'entrée argparse ├── fetcher.py # pilotage de Chromium headless + CDP ├── parser.py # extraction des points depuis le JSON └── gpx_writer.py # sérialisation GPX ``` ## Flux d'exécution 1. **`cli.main`** parse les arguments (`url`, `-o/--output`), appelle `fetch_trail`, puis `parse_trail`, puis `write_gpx`. 2. **`fetcher.fetch_trail(url)`** : - localise le binaire Chromium (`shutil.which` sur plusieurs noms candidats) ; - lance `chromium --headless=new --remote-debugging-port=9222 ` ; - se connecte au point de débogage HTTP (`http://localhost:9222/json`) pour récupérer le `webSocketDebuggerUrl` de l'onglet `page` ; - ouvre une connexion WebSocket (bibliothèque `websockets`) et envoie les commandes CDP : - `Network.enable` - `Page.enable` puis `Page.navigate` vers l'URL cible ; - écoute les événements `Network.responseReceived` / `Network.loadingFinished` ; - filtre les réponses dont l'URL contient `course-service/curated` ou `curated-course/course/curated` ; - appelle `Network.getResponseBody` pour récupérer le corps JSON ; - sélectionne la réponse dont le corps contient `"geoPoints"` (trace détaillée) ; - parse le corps en `dict` et le retourne ; - termine le processus Chromium dans un bloc `finally`. 3. **`parser.parse_trail(payload)`** : - extrait `courseName`, `description`, `distanceMeter`, `elevationGainMeter`, `elevationLossMeter` ; - convertit `geoPoints` (liste de `{latitude, longitude, elevation, distance}`) en liste de `TrailPoint` (dataclass) ; - retourne un objet `Trail`. 4. **`gpx_writer.write_gpx(trail, path)`** : - construit un arbre XML avec `xml.etree.ElementTree` ; - racine `` avec namespace `http://www.topografix.com/GPX/1/1` ; - `` avec `` et `` ; - `` / `` / `` avec `` ; - indentation via `ET.indent` et écriture UTF-8 avec déclaration XML. ## Pourquoi Chromium headless ? Les endpoints Garmin (`connect.garmin.com/gc-api/...` et `didt-api/...`) sont protégés par **Cloudflare** : une requête HTTP directe (curl, requests, même avec impersonation TLS via `curl_cffi`) renvoie `403`. Seul un navigateur réel (cookies + empreinte TLS + exécution JS) réussit à charger la page et déclencher les XHR. Le mode headless + CDP permet de capturer les réponses réseau sans interface graphique. ## Endpoints exploités | Endpoint | Donnée | |---|---| | `didt-api/curated-course/course/curated/{pays}/{province}/{slug}` | Métadonnées + WKT `LINESTRING` (coordonnées grossières) | | `gc-api/course-service/curated/{connectCourseId}` | Trace détaillée : `geoPoints` avec lat/lon/ele/distance (854 points pour l'exemple) | Le fetcher sélectionne la réponse contenant `"geoPoints"` pour obtenir la trace la plus fidèle (avec altitude). ## Dépendances - `websockets >= 13.0` (client WebSocket pour CDP) - Binaire Chromium/Chrome présent sur le système (`shutil.which`) - Python ≥ 3.13 (type hints `list[...]`, `str | Path`) ## Points de fragilité - Les noms d'endpoints et la structure JSON peuvent changer côté Garmin. - Le timing (sleep 2 s au démarrage, timeout 60 s) est heuristique. - L'extraction repose sur la présence de la sous-chaîne `"geoPoints"` dans le corps de la réponse. - Un seul onglet `page` est attendu ; si Chromium ouvre plusieurs onglets, le premier est utilisé.