88 lines
3.8 KiB
Markdown
88 lines
3.8 KiB
Markdown
# 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 <url>` ;
|
|
- 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 `<gpx version="1.1" ...>` avec namespace `http://www.topografix.com/GPX/1/1` ;
|
|
- `<metadata>` avec `<name>` et `<desc>` ;
|
|
- `<trk>` / `<trkseg>` / `<trkpt lat=... lon=...>` avec `<ele>` ;
|
|
- 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é.
|