Files

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