From 4c3bee89eda50225147083eb71592ff163b162b0 Mon Sep 17 00:00:00 2001 From: Emmanuel Di Pretoro Date: Mon, 9 Nov 2026 15:05:02 +0100 Subject: [PATCH] docs: add technical and high-level documentation --- docs/overview.md | 53 +++++++++++++++++++++++++++++ docs/technical.md | 87 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 docs/overview.md create mode 100644 docs/technical.md diff --git a/docs/overview.md b/docs/overview.md new file mode 100644 index 0000000..268d7a7 --- /dev/null +++ b/docs/overview.md @@ -0,0 +1,53 @@ +# connect2gpx — Présentation + +## En une phrase + +`connect2gpx` transforme un lien de **trail Garmin Connect** en fichier **GPX** +que l'on peut ouvrir dans n'importe quelle application de randonnée ou de +navigation (Garmin, Komoot, Strava, OsmAnd, etc.). + +## Le problème + +Garmin Connect propose une section « Trails » avec des parcours curatés affichés +sur une carte. Ces parcours sont consultables en ligne, mais il n'est pas +toujours simple d'en récupérer la trace GPS au format GPX pour la charger sur +un appareil ou la partager. + +## Ce que fait l'outil + +1. **Vous lui donnez l'URL** d'une page Garmin Trails (par exemple une boucle + dans le Hainaut). +2. **Il ouvre automatiquement un navigateur invisible** (Chromium) pour + contourner la protection anti-robots de Garmin. +3. **Il récupère la trace complète** du parcours, point par point, avec + l'altitude. +4. **Il écrit un fichier `.gpx`** standard, prêt à être utilisé. + +## Résultat + +Un fichier GPX contenant : +- le nom et la description du trail ; +- la trace complète (latitude, longitude, altitude) ; +- typiquement plusieurs centaines à milliers de points. + +Pour l'exemple analysé (« Boucle de Poteaupré et Étang de Virelles ») : +854 points, ~20,7 km, dénivelé positif ~251 m. + +## Utilisation + +```bash +connect2gpx "https://connect.garmin.com/app/trails/..." -o sortie.gpx +``` + +## Prérequis + +- Python 3.13 ou supérieur +- Chromium ou Google Chrome installé sur la machine + +## Limites + +- Fonctionne uniquement avec les **trails publics** de Garmin Connect (pas + avec vos activités personnelles, qui nécessitent une authentification). +- Dépend de la structure interne du site Garmin : si Garmin modifie son site, + l'outil peut nécessiter une mise à jour. +- Un parcours prend environ 10 à 20 secondes à récupérer. diff --git a/docs/technical.md b/docs/technical.md new file mode 100644 index 0000000..fb2b4f4 --- /dev/null +++ b/docs/technical.md @@ -0,0 +1,87 @@ +# 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é.