Wandern nach Plan (Linux-Magazin, September 2021)

Das letzte Jahr ging ja bekanntlich an die Pandemie verloren, und wegen diverser Lockdowns blieb nicht viel Freizeitvergnügen übrig. Fußballspielen war verboten, joggen mit Maske zu anstrengend, und so fingen meine Frau und ich an, jeden Abend auf ausgedehnten Stadtspaziergängen uns bislang unbekannte Gegenden unserer Wahlheimat San Francisco zu erforschen. Und zu unserem Erstaunen stellten wir fest, dass selbst 25 Jahre Aufenthalt in einer Stadt nicht ausreichen, um auch die letzten Winkel auszuspähen. Wir fanden unzählige kleine versteckte Treppchen, ungeteerte Wege und der Tourismusindustrie völlig unbekannte Sehenswürdigkeiten.

Sich alle Abzweigungen auf diesen neu erfundenen verschlungenen Stadtwanderpfaden zu merken, ist schier unmöglich, aber zum Glück hilft hier das Mobiltelefon als Gehirnersatz, mit Wander-Apps, die Touren planen, deren Verlauf während der Begehung aufzuzeichnen, online grafisch darstellen und Wanderer bei Neubegehungen live beim Navigieren unterstützen. Eine der bekanntesten dieser Apps ist das kommerzielle Komoot, das auf Kartendaten von Openstreetmap fußt und solange kostenlos ist, wie der User sich auf die lokale Stadtumgebung beschränkt und von einem anderen User eingeladen wird, gerne auch von einem ebenfalls neu angelegten User.

Abbildung 1: Praktisch: Die Telefon-App zeigt den Wanderweg an und hilft beim Navigieren.

Sicher ist sicher

Dieses Geiz-Abo reicht mir zur Zeit vollkommen aus, aber vielleicht leiste ich mir auch noch das World-Bundle, die Einmalzahlung von $29.99 ist zwar kein Pappenstiel, aber man muss die Hüpferlein bei Komoot ja unterstützen, damit das Licht im Datencenter anbleibt. Was aber, falls Komoot irgendwann den Betrieb einstellt, was passiert dann mit meinen mühevoll erstellten Wanderwegen? Zum Glück erlaubt Komoot den Export der GPS-Daten abgewanderter Strecken, und daraus ließen sich zur Not die Touren wiederherstellen. Bei Dutzenden von gespeicherten Wegen wäre allerdings manuelles Herunterladen zu arbeitsintensiv, ganz zu schweigen von der notwendigen Disziplin, dies auch regelmäßig mit neuen Wegen zu erledigen, denn man weiß ja nie, wann der Hammer niedersausen wird.

Abbildung 2: Archivierte Stadtwanderungen auf der Komoot-App.

Aus diesem Grund käme ein Programm, das sich per Cronjob einmal pro Woche bei Komoot einloggt und bislang noch nicht gesicherte Touren lokal in einem Backup-Verzeichnis ablegt, gerade reicht. Komoot bietet zwar eine API zum skriptgesteuerten Anzapfen der User-Daten an, aber nicht für Otto Normalverbraucher wie mich, und auf Anfragen zur Registrierung verweist das Support-Team dort auf eine B2B-Abteilung, die nur mit Geschäftspartnern verhandelt. Doch mit etwas Fitzelei kann ein Web-Scraper die GPS-Daten auch von der Webseite kratzen, und genau das tut das in dieser Ausgabe vorgestellte Go-Programm, basierend auf der Reverse-Engineering-Arbeit von [3].

Abbildung 3: Kosten für die Komoot-Nutzung

Cron spiegelt

Das Programm hangelt sich durch den Login-Prozess der Webseite, fragt alle dort abgelegten Touren ab, lädt deren JSON-Daten herunter, und konvertiert diese in das von GPS-Trackern unterstützte GPX-Format ([3]). Verlöre nun die Firma Komoot aus irgendwelchen Gründen die eingespeicherten Wegstrecken, ließe sich die Tourensammlung zur Not aus dem Backup wiederherstellen, denn die GPX-Daten repräsentieren die Wanderungen plattformunabhängig als eine Reihe von GPS-Koordinaten mit Zeitstempeln.

Listing 1 zeigt das fertige Go-Programm, das ein Cronjob einmal pro Woche aufruft, und das in einem Unterverzeichnis tours die .gpx-Dateien aller auf einem Komoot-Account liegenden Touren ablegt. Dazu loggt es sich in der Funktion kc.kLogin() mit Usernamen und Passwort bei Komoot ein, fragt mit kc.kTours() eine Liste aller unter dem Account angelegten Touren ab, und sichert nur die, die es noch nicht hat, in einem lokalen Verzeichnis. Der Aufruf von toGpx() in Zeile 46 wandelt die von Komoot kommenden Json-Daten in das plattformunabhängige Tracker-Format GPX um, und der Code ab Zeile 49 legt neu gefundene Daten in einer .gpx-Datei im Unterverzeichnis tours ab. Das Verfahren schont die Server und sollte niemanden bei Komoot stören, und es hilft dem User, die Kontrolle über eigenhändig erstellte Wanderwege zu behalten.

Listing 1: kbak.go

    01 package main
    02 
    03 import (
    04   "fmt"
    05   "io/ioutil"
    06   "log"
    07   "os"
    08 )
    09 
    10 const saveDir = "tours"
    11 
    12 func main() {
    13   kc := NewkColl()
    14 
    15   _ = os.Mkdir(saveDir, 0755)
    16 
    17   err := kc.kLogin()
    18   if err != nil {
    19     log.Fatalf("Login returned %v", err)
    20     return
    21   }
    22 
    23   jdata, err := kc.kTours()
    24 
    25   if err != nil {
    26     log.Fatalf("Fetching tour ids returned %v", err)
    27     return
    28   }
    29 
    30   ids := tourIDs(jdata)
    31 
    32   for _, id := range ids {
    33     gpxPath := fmt.Sprintf("%s/%s.gpx", saveDir, id)
    34     if _, err := os.Stat(gpxPath); err == nil {
    35       fmt.Printf("%s already saved\n", gpxPath)
    36       continue
    37     }
    38 
    39     jdata, err = kc.kTour(id)
    40 
    41     if err != nil {
    42       log.Fatalf("Fetching tour %s returned %v", id, err)
    43       return
    44     }
    45 
    46     gpx := toGpx(jdata)
    47 
    48     fmt.Printf("Saving %s\n", gpxPath)
    49     err := ioutil.WriteFile(gpxPath, gpx, 0644)
    50     if err != nil {
    51       panic(err)
    52     }
    53   }
    54 }

Vorsicht, Passwort

Den Usernamen und das Passwort ins Programm einzubacken wäre kein guter Stil, also lagert sie Listing 2 in die Yaml-Datei creds.yaml aus, die man auch nicht in ein Github-Repo einchecken, sondern nur lokal vorhalten sollte. Der Web-Scraper liest später diese Yaml-Datei vor dem Abholen der User-Daten ein und verwendet die dort eingelagerte Komoot-Email, das zugehörige Passwort und die numerische User-Id nur im flüchtigen Speicher während das Programm läuft. Beispielwerte zeigt Listing 3, für den aktiven Gebrauch des Programms sind sie durch die Werte des genutzten Komoot-Accounts zu ersetzen.

Listing 2: creds.go

    01 package main
    02 
    03 import (
    04   "gopkg.in/yaml.v2"
    05   "io/ioutil"
    06   "os"
    07 )
    08 
    09 var credsPath = "creds.yaml"
    10 
    11 func readCreds() map[string]string {
    12   creds := map[string]string{}
    13 
    14   f, err := os.Open(credsPath)
    15   if err != nil {
    16     panic(err)
    17   }
    18   defer f.Close()
    19 
    20   bytes, err := ioutil.ReadAll(f)
    21   if err != nil {
    22     panic(err)
    23   }
    24 
    25   err = yaml.Unmarshal(bytes, &creds)
    26   if err != nil {
    27     panic(err)
    28   }
    29 
    30   return creds
    31 }

Zum Parsen des Yaml-Salats in creds.yaml zieht Listing 2 das Paket gopkg.in/yaml.v2 von Github herein, das in der exportieren Funktion Unmarshal() in Zeile 25 die Yaml-Datenstruktur in Listing 3 aufdröselt und in eine Go-interne Hashmap umwandelt. Die als creds zurückgereichte Datenstruktur enthält unter dem Schlüssel email die als User-Namen dienende Email des verwendeten Komoot-Accounts, in password das Passwort und in client_id die numerische ID des Users, unter der Komoot auf dessen Daten zugreift. Für einen aktiven Account zeigt der Browser die numerische User-ID im URL-Feld an (Abbildung 4), von wo sie sich leicht in die .yaml-Datei kopieren lässt.

Listing 3: creds.yaml.sample

    1 email: "foo@bar.com"
    2 password: "hunter123"
    3 client_id: "2014254181621"

Abbildung 4: Der Browser zeigt die numerische User-ID des Komoot-Accounts an.

Vom Web kratzen

Der Web-Scraper läuft auf der Kommandozeile, als Browserersatz nutzt er das Go-Paket Colly, das schon einmal auf der Showbühne des Programmier-Snapshots stand [2]. Die Funktionen in Listing 4 loggen sich auf dem Komoot-Account ein (kLogin(), Zeile 23), holen eine Liste der dort eingespeicherten Touren ab (kTours(), Zeile 48) und ziehen sich die GPS-Daten einzelner Touren (kTour(), Zeile 71).

Listing 4: kfetch.go

    01 package main
    02 
    03 import (
    04   "fmt"
    05   "github.com/gocolly/colly/v2"
    06 )
    07 
    08 var loginURL = "https://account.komoot.com/v1/signin"
    09 var signinURL = "https://account.komoot.com/actions/transfer?type=signin"
    10 
    11 type kColl struct {
    12   c     *colly.Collector
    13   creds map[string]string
    14 }
    15 
    16 func NewkColl() kColl {
    17   return kColl{
    18     c:     colly.NewCollector(),
    19     creds: readCreds(),
    20   }
    21 }
    22 
    23 func (kc kColl) kLogin() error {
    24   c := kc.c.Clone()
    25   c.OnRequest(func(req *colly.Request) {
    26     fmt.Println("Visiting", req.URL)
    27   })
    28 
    29   payload := map[string]string{
    30     "email":    kc.creds["email"],
    31     "password": kc.creds["password"],
    32     "reason":   "null",
    33   }
    34 
    35   err := c.Post(loginURL, payload)
    36   if err != nil {
    37     return err
    38   }
    39 
    40   err = c.Visit(signinURL)
    41   if err != nil {
    42     return err
    43   }
    44 
    45   return nil
    46 }
    47 
    48 func (kc kColl) kTours() ([]byte, error) {
    49   c := kc.c.Clone()
    50   toursURL := fmt.Sprintf(
    51     "https://www.komoot.com/user/%s/tours",
    52     kc.creds["client_id"])
    53 
    54   jdata := []byte{}
    55   var err error
    56 
    57   c.OnRequest(func(req *colly.Request) {
    58     fmt.Println("Visiting", req.URL)
    59     req.Headers.Set("onlyprops", "true")
    60   })
    61 
    62   c.OnResponse(func(resp *colly.Response) {
    63     jdata = resp.Body
    64   })
    65 
    66   c.Visit(toursURL)
    67   return jdata, err
    68 }
    69 
    70 func (kc kColl) kTour(tourID string) ([]byte, error) {
    71   c := kc.c.Clone()
    72   tourURL := fmt.Sprintf(
    73     "https://www.komoot.com/tour/%s", tourID)
    74 
    75   jdata := []byte{}
    76   var err error
    77 
    78   c.OnRequest(func(req *colly.Request) {
    79     fmt.Println("Visiting", req.URL)
    80     req.Headers.Set("onlyprops", "true")
    81   })
    82 
    83   c.OnResponse(func(resp *colly.Response) {
    84     jdata = resp.Body
    85   })
    86 
    87   c.Visit(tourURL)
    88   return jdata, err
    89 }

Go bietet zwar keine direkte Objektorientierung, aber mit einer Datenstruktur wie kColl in Zeile 11, einem Konstruktor wie NewkColl() in Zeile 16 und sogenannten Receivern auf der linken Seite der als Methoden genutzten Funktionen praktisch doch. Die Funktionen teilen sich die Datenstruktur, die der Aufrufer anfangs einmal mit dem Konstruktor initialisiert. Der legt dort eine Instanz der Colly-Scrapers ab, sowie die Hashtabelle creds mit den vorher eingelesenen User-Credentials.

Die OnRequest()-Callbacks springt Colly jeweils an, bevor es mit Visit() oder Post() den verlangten HTTP-Request ausführt. In Listing 4 zeigen sie dem User mit Print() an, welche URL gerade dran ist, und setzen zum Teil besondere HTTP-Header, damit Komoot keinen HTML-Code sondern einfacher zu analysierende Json-Daten ausspuckt.

Leckere Cookies

Alle drei Funktionen teilen sich eine Scraper-Instanz, die die eingangs von Komoot beim Einloggen gesetzten Cookies durchschleift, denn an Fuzzy Nobody würde der Server die Tourdaten nicht herausrücken. Nun ersetzt der Colly-Scraper allerdings in den OnRequest()-Aufrufen die Callbacks nicht, sondern stapelt sie auf, sodass die dritte Funktion die angefahrene URL nicht ein- sondern dreimal ausgäbe. Abhilfe schaffen mit Clone() erzeugte Klone, die zwar die Cookies behalten, aber die Callbacks zurücksetzen. Abbildung 5 zeigt, wie sich das Programm mit den noch folgenden Listings kompilieren lässt und seine typische Ausgabe, während es Touren auf dem Server findet, aber nur herunterlädt, falls sie lokal noch nicht schon vorliegen.

Abbildung 5: Ein typischer Aufruf von kbak holt neue Touren von Komoot.

Json und Go, Hund und Katz

Der Webserver von Komoot liefert sowohl die Tourenliste als auch die Details einzelner Touren wegen der in Listing 4 gesetzten Header im Json-Format aus. Json und Go verhalten sich allerdings wie Hund und Katz, denn Json bietet dynamische Datenstrukturen mit wenig Typprüfungen, während Go auf exakten Datenstrukturen besteht. Um tief verschachtelten Json-Text in Go-interne Datenstrukturen umzuwandeln, muss der Programmieren mit Engelszungen auf die Sprache einreden. Das Json-Format in einer Skriptsprache wie Python zu importieren und später in GPX umzuwandlen ließe sich mühelos mit einem Dutzend Programmzeilen erledigen, Go hingegen erfordert, wie aus den Listings 5 und 6 ersichtlich, einige nicht unanstrengende Klimmzüge.

Listing 5: tours.go

    01 package main
    02 
    03 import (
    04   "encoding/json"
    05 )
    06 
    07 func tourIDs(jdata []byte) []string {
    08   var data map[string]interface{}
    09 
    10   err := json.Unmarshal(jdata, &data)
    11   if err != nil {
    12     panic(err)
    13   }
    14 
    15   data = drill(data,
    16     []string{"kmtx", "session",
    17     "_embedded", "profile",
    18     "_embedded", "tours",
    19     "_embedded"})
    20 
    21   items :=
    22   data["items"].([]interface{})
    23 
    24   ids := []string{}
    25 
    26   for _, item := range items {
    27     table :=
    28     item.(map[string]interface{})
    29     id := table["id"].(string)
    30     ids = append(ids, id)
    31   }
    32 
    33   return ids
    34 }
    35 
    36 func drill(part map[string]interface{}, keys []string) map[string]interface{} {
    37   for _, key := range keys {
    38     part = part[key].(map[string]interface{})
    39   }
    40 
    41   return part
    42 }

Da die Komoot-Daten sage und schreibe neun Stufen tief verschachtelt daherkommen, wäre der offiziell vorgeschriebene Weg, die Json-Daten in Go einzulesen, etwas umständlich. Hierzu wäre es notwendig, die vollständige Datenstruktur mit all ihren Ebenen mittels struct-Deklarationen in Go zu definieren. Wer diese Schreibarbeit scheut, kann wie in Zeile 8 in Listing 5 einfach eine eindimensionale Map mit einem leeren Interface interface{} als Platzhalter definieren, und beim hinabsteigen in die Tiefen der Unter-Hashmaps jedesmal eine Type-Assertion auf eine Hashmap wie in Zeile 38 durchführen. Go schaut sich dann den Wert an, kommt zu dem Schluss, dass es sich um eine Map handeln könnte, und erlaubt so den weiteren Abstieg.

Cowboyhaft

Diese praktische (wenngleich etwas cowboyhafte) Bohrmethode verpackt die Funktion drill() ab Zeile 36 in Listing 5. Sie nimmt ein Array mit Schlüsseln entgegen, steigt in die Unter-Hashmaps hinab und gibt die am Ende der Schlüsselkette gefundenen Daten aus. So sammelt Listing 5 die numerischen IDs aller Touren ein, die der User in seinem Account hat. Dies können sowohl auf der Landkarte geplante Touren sein, die der User mit Komoots eigenwilligem Browser-Interface in die Landkarte gemalt hat, oder Wege, die der User bereits beschritten und mit der App oder einem anderen Tracker aufgezeichnet hat.

Von JSON nach GPX

Mittels der IDs kann dann das Hauptprogramm, unterstützt von der Funktion kTour() in Listing 4, die Daten individueller Touren von Komoot einholen. Listing 6 schließlich konvertiert die eingeholten Json-Daten ins GPX-Format, das nicht nur gängige Tracker von Garmin und Co verstehen, sondern Komoot auch zum Hochladen von neuen Touren akzeptiert: Das ist der Restore-Teil der Backup-Lösung. Das Ergebnis der Umwandlung zeigt Abbildung 6 als das für GPX übliche XML, und in Abbildung 7 nimmt Komoot die heruntergeladenen und nach GPX konvertierten Daten anstandslos als neue Tour auf.

Abbildung 6: Die heruntergeladenen Json-Daten, konvertiert ins GPX-Format.

Abbildung 7: Die re-importierte gpx-Datei erkennt Komoot ohne Murren.

Die vorschriftsgemäße Konvertierung von Go-Daten nach XML erfordert allerdings, ähnlich wie Json-Konvertierung vorher, eine gewaltige Menge von type-Deklarationen, die ihre Unterelemente in den verschiedenen Ebenen miteinander verlinken. Letztendlich muss der Go-Code die gesamte GPX-Syntax deklarieren. Die Struktur lässt sich dann mit den eingelesenen Daten befüllen und mit dem Paket encoding/xml in GPX-konformes XML verwandeln.

Die ursprüngliche, mustergültige Version von Listing 6 kam so auf 74 Zeilen, aber weil das Linux-Magazin in Listings übergroße Fonts verwendet und ihren Abdruck deshalb scheut wie der Teufel das Weihwasser, schlampt Listing 6 mit der Implementierung und schreibt das XML einfach als parametrisierten Textstring. Es schafft die Aufgabe in 41 Zeilen. Alle sind glücklich.

Die Zeilen 13 bis 20 in Listing 6 verwenden wieder die in Listing 5 definierte Bohrfunktion drill(), die sich in die Unter-Hashmaps der entpackten Json-Daten vorarbeitet. Die gefundenen Geo-Koordinaten der Tour sichert Listing 6 in Zeile 19 in dem Array-Slice items, nach einer Type-Assertion, die bestätigt, dass es sich um einen Array unbekannten Inhalts ([]interface{}) handelt.

Zeitrechnung

Eine Besonderheit sind die Zeitstempel der aufgezeichneten Trackpunkte, denn das Json-Format auf Komoot listet die Startzeit einer Tour nur anfangs einmal im RFC3339-Format auf, und gibt die Einzelzeiten der Trackpunkte jeweils in tausendstel Sekunden relativ zur Startzeit an. Das GPX-Format listet aber die jeweilige absolute Uhrzeit mit jedem Trackpunkt auf, also muss Listing 6 etwas rechnen.

Listing 6: gpx.go

    01 package main
    02 
    03 import (
    04   "encoding/json"
    05   "fmt"
    06   "time"
    07 )
    08 
    09 func toGpx(jdata []byte) []byte {
    10   var data map[string]interface{}
    11 
    12   json.Unmarshal([]byte(jdata), &data)
    13   tour := drill(data, []string{
    14     "page", "_embedded", "tour"})
    15   start := tour["date"].(string)
    16 
    17   coord := drill(tour, []string{
    18     "_embedded", "coordinates"})
    19   items :=
    20     coord["items"].([]interface{})
    21   ts, err := time.Parse(time.RFC3339, start)
    22   if err != nil {
    23     panic(err)
    24   }
    25 
    26   xml := "<gpx><trk>"
    27   for _, item := range items {
    28     pt := item.(map[string]interface{})
    29     secs := pt["t"].(float64) / 1000.0
    30     t := ts.Add(time.Duration(secs) * time.Second)
    31     xml += fmt.Sprintf(`<trkseg>
    32 <trkpt lat="%f" lon="%f">
    33   <ele>%.1f</ele>
    34   <time>%s</time>
    35 </trkpt></trkseg>`, pt["lat"],
    36     pt["lng"], pt["alt"],
    37     t.Format(time.RFC3339))
    38   }
    39   xml += "</trk></gpx>\n"
    40   return []byte(xml)
    41 }

Die im Startfeld der Tour gefundene Uhrzeit liest Zeile 21 ein und wandelt sie ins Go-interne Zeitformat um. Während dann die for-Schleife ab Zeile 27 durch die ausgegrabenen Trackpunkte rattert, teilt Zeile 29 die dort gefundene Zeitdifferenz in Millisekunden durch 1000 und addiert den erhaltenen Sekundenwert in Zeile 30 mit Add() zur Startzeit. Heraus kommt der Zeitstempel für den jeweiligen Trackpunkt, den Zeile 37 wieder ins RFC3339-Format umwandelt und als String ins XML einpflanzt. Hinzu kommen die Einträge für "lat" (Latitude, geographische Breite) und "lng" (Longitude, geographische Länge, aber "lon" im XML), die zwar als generisches interface{} vorliegen, aber vom %f-Platzhalter der Sprintf()-Funktion per Typ-Assertion ins Float64-Format konvertiert werden. Gleiches gilt für die Höhe alt (Altitude) über dem Meeresspiegel, die ins ele-(Elevation)-Feld des GPX-Formats hineinfließt. Das Hauptprogramm erhält von toGpx() einen Byte-Array mit den XML-Daten zurück, schreibt ihn in die Backup-Datei auf die Platte, und die Sicherung ist abgeschlossen. Wer die erzeugte .gpx-Datei probeweise wieder hochlädt, sieht mit wachsender Begeisterung, dass Komoot sie anstandslos als neue Tour anerkennt. Die Backup-Lösung ist perfekt.

Leider muss man sagen, dass offiziell nicht gewartete Webscraper wie dieser natürlich den Nachteil haben, dass selbst kleinste Layout-Änderungen am Webauftritt von Komoot das Programm ausbremsen können. Damit muss man leben. Aber vielleicht erbarmt sich Komoot irgendwann doch noch, und beschließt, auch Hobbyisten Zutritt zur API zu gewähren und eine dazu erforderliche Oauth2-Clientid registrieren zu lassen. Sauberer wär's.

Infos

[1]

Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2021/09/snapshot/

[2]

Michael Schilli, "Daten abstauben": Linux-Magazin 04/19, S.xxx, <U>https://www.linux-magazin.de/ausgaben/2019/04/snapshot-13/<U>

[3]

Jakob S., "Get Komoot tour data without API", https://python.plainenglish.io/get-komoot-tour-data-without-api-143df64e51fa

[4]

Wikipedia-Eintrag zum Gpx-Format, https://en.wikipedia.org/wiki/GPS_Exchange_Format

Michael Schilli

arbeitet als Software-Engineer in der San Francisco Bay Area in Kalifornien. In seiner seit 1997 laufenden Kolumne forscht er jeden Monat nach praktischen Anwendungen verschiedener Programmiersprachen. Unter mschilli@perlmeister.com beantwortet er gerne Ihre Fragen.

POD ERRORS

Hey! The above document had some coding errors, which are explained below:

Around line 5:

Unknown directive: =desc