Aus Linux-Magazin 09/2021

Go holt GPS-Daten aus der Komoot-App

© varunalight / 123RF.com

Die Wander- und Radel-App Komoot speichert zurückgelegte Ausflugswege. Mike Schilli holt die Daten mit Go wieder heraus.

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. 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: Wander-Apps planen die Touren, zeichnen ihren Verlauf während der Begehung auf, stellen das Ganze online grafisch dar und unterstützen den Ausflügler bei Neubegehungen live beim Navigieren (Abbildung 1). Eine der bekanntesten dieser Apps ist das kommerzielle Komoot, das auf Kartendaten von OpenStreetMap fußt und kostenlos bleibt, solange der User sich auf die lokale Umgebung beschränkt und von einem anderen User eingeladen wird, gern auch von einem ebenfalls neu angelegten User.

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

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

Sicher ist sicher

Dieses Geiz-Abo genügt mir derzeit vollkommen, aber vielleicht leiste ich mir auch noch das World-Bundle. Die dafür fällige Einmalzahlung von 30 US-Dollar ist zwar kein Pappenstiel (Abbildung 2), aber man muss Komoot ja unterstützen, damit das Licht im Datencenter eingeschaltet bleibt. 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 (Abbildung 3) wäre allerdings manuelles Herunterladen zu arbeitsintensiv, ganz zu schweigen von der notwendigen Disziplin, dies auch regelmäßig mit neuen Wegen zu erledigen – man weiß ja nie, wann der Hammer fällt.

Abbildung 2: Kosten für die Komoot-Nutzung.

Abbildung 2: Kosten für die Komoot-Nutzung.

Abbildung 3: Archivierte Stadtwanderungen auf der Komoot-App.

Abbildung 3: Archivierte Stadtwanderungen auf der Komoot-App.

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

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 sie in das von GPS-Trackern unterstützte GPX-Format [2]. 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 vorhandenen Touren ablegt. Dazu meldet es sich in der Funktion »kc.kLogin()« mit Usernamen und Passwort bei Komoot an, fragt mit »kc.kTours()« eine Liste aller unter dem Account angelegten Touren ab und sichert nur die in einem lokalen Verzeichnis, die es noch nicht hat.

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 speichert neu gefundene Daten in einer ».gpx«-Datei im Unterverzeichnis »tours/«. 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

package main
import (
  "fmt"
  "io/ioutil"
  "log"
  "os"
)
const saveDir = "tours"
func main() {
  kc := NewkColl()
  _ = os.Mkdir(saveDir, 0755)
  err := kc.kLogin()
  if err != nil {
    log.Fatalf("Login returned %v", err)
    return
  }
  jdata, err := kc.kTours()
  if err != nil {
    log.Fatalf("Fetching tour ids returned %v", err)
    return
  }
  ids := tourIDs(jdata)
  for _, id := range ids {
    gpxPath := fmt.Sprintf("%s/%s.gpx", saveDir, id)
    if _, err := os.Stat(gpxPath); err == nil {
      fmt.Printf("%s already saved\n", gpxPath)
      continue
    }
    jdata, err = kc.kTour(id)
    if err != nil {
      log.Fatalf("Fetching tour %s returned %v", id, err)
      return
    }
    gpx := toGpx(jdata)
    fmt.Printf("Saving %s\n", gpxPath)
    err := ioutil.WriteFile(gpxPath, gpx, 0644)
    if err != nil {
      panic(err)
    }
  }
}

Vorsicht, Passwort

Es wäre kein guter Stil, den Usernamen und das Passwort ins Programm einzubacken, also lagert Listing 2 sie in die YAML-Datei »creds.yaml« aus. Die sollte man also nicht in ein Github-Repo einchecken, sondern nur lokal vorhalten. Der Webscraper liest später diese YAML-Datei vor dem Abholen der Benutzerdaten ein und verwendet die dort eingelagerte Komoot-E-Mail, 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 ersetzen Sie sie durch die Werte des verwendten Komoot-Accounts.

Listing 2

creds.go

package main
import (
  "gopkg.in/yaml.v2"
  "io/ioutil"
  "os"
)
var credsPath = "creds.yaml"
func readCreds() map[string]string {
  creds := map[string]string{}
  f, err := os.Open(credsPath)
  if err != nil {
    panic(err)
  }
  defer f.Close()
  bytes, err := ioutil.ReadAll(f)
  if err != nil {
    panic(err)
  }
  err = yaml.Unmarshal(bytes, &creds)
  if err != nil {
    panic(err)
  }
  return creds
}

Listing 3

creds.yaml.sample

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

Zum Parsen des YAML-Salats in »creds.yaml« zieht Listing 2 das Paket »gopkg.in/yaml.v2« von Github herein, das in der exportierten Funktion »Unmarshal()« in Zeile 25 die YAML-Datenstruktur aus 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 Benutzernamen dienende E-Mail-Adresse 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.

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

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

Vom Web kratzen

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

Listing 4

kfetch.go

package main
import (
  "fmt"
  "github.com/gocolly/colly/v2"
)
var loginURL = "https://account.komoot.com/v1/signin"
var signinURL = "https://account.komoot.com/actions/transfer?type=signin"
type kColl struct {
  c     *colly.Collector
  creds map[string]string
}
func NewkColl() kColl {
  return kColl{
    c:     colly.NewCollector(),
    creds: readCreds(),
  }
}
func (kc kColl) kLogin() error {
  c := kc.c.Clone()
  c.OnRequest(func(req *colly.Request) {
    fmt.Println("Visiting", req.URL)
  })
  payload := map[string]string{
    "email":    kc.creds["email"],
    "password": kc.creds["password"],
    "reason":   "null",
  }
  err := c.Post(loginURL, payload)
  if err != nil {
    return err
  }
  err = c.Visit(signinURL)
  if err != nil {
    return err
  }
  return nil
}
func (kc kColl) kTours() ([]byte, error) {
  c := kc.c.Clone()
  toursURL := fmt.Sprintf(
    "https://www.komoot.com/user/%s/tours",
    kc.creds["client_id"])
  jdata := []byte{}
  var err error
  c.OnRequest(func(req *colly.Request) {
    fmt.Println("Visiting", req.URL)
    req.Headers.Set("onlyprops", "true")
  })
  c.OnResponse(func(resp *colly.Response) {
    jdata = resp.Body
  })
  c.Visit(toursURL)
  return jdata, err
}
func (kc kColl) kTour(tourID string) ([]byte, error) {
  c := kc.c.Clone()
  tourURL := fmt.Sprintf(
    "https://www.komoot.com/tour/%s", tourID)
  jdata := []byte{}
  var err error
  c.OnRequest(func(req *colly.Request) {
    fmt.Println("Visiting", req.URL)
    req.Headers.Set("onlyprops", "true")
  })
  c.OnResponse(func(resp *colly.Response)
 {
    jdata = resp.Body
  })
  c.Visit(tourURL)
  return jdata, err
}

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 etwas Ähnliches. 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 Hash-Tabelle »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 ausspuckt, sondern einfacher zu analysierende JSON-Daten.

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 einmal ausgäbe, sondern gleich drei Mal. 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, sowie seine typische Ausgabe, während es Touren auf dem Server findet, aber nur herunterlädt, falls sie lokal noch nicht vorliegen.

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

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

Hund und Katz

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

Listing 5

tours.go

package main
import (
  "encoding/json"
)
func tourIDs(jdata []byte) []string {
  var data map[string]interface{}
  err := json.Unmarshal(jdata, &data)
  if err != nil {
    panic(err)
  }
  data = drill(data,
    []string{"kmtx", "session",
    "_embedded", "profile",
    "_embedded", "tours",
    "_embedded"})
  items :=
  data["items"].([]interface{})
  ids := []string{}
  for _, item := range items {
    table :=
    item.(map[string]interface{})
    id := table["id"].(string)
    ids = append(ids, id)
  }
  return ids
}
func drill(part map[string]interface{}, keys []string) map[string]interface{} {
  for _, key := range keys {
    part = part[key].(map[string]interface{})
  }
  return part
}

Da die Komoot-Daten sage und schreibe neun Stufen tief verschachtelt daherkommen, wäre der offiziell vorgeschriebene Weg etwas umständlich, die JSON-Daten in Go einzulesen. Dazu müsste man die vollständige Datenstruktur mit all ihren Ebenen mittels »struct«-Deklarationen in Go definieren. Wer diese Schreibarbeit scheut, kann wie in Zeile 8 von Listing 5 einfach eine eindimensionale Map mit einem leeren Interface »interface{}« als Platzhalter definieren und beim Hinabsteigen in die Tiefen der Unter-Hashmaps jedes Mal eine Type Assertion auf eine Hashmap wie in Zeile 38 vornehmen. 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 – wenn auch cowboyhafte – Bohrmethode verpackt die Funktion »drill()« ab Zeile 36 von 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 Karte gemalt hat, als auch Wege, die der Anwender 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 die Komoot auch zum Hochladen neuer 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 6: Die heruntergeladenen JSON-Daten, konvertiert ins GPX-Format.

Abbildung 7: Die reimportierte GPX-Datei erkennt Komoot ohne Murren.

Abbildung 7: Die reimportierte GPX-Datei erkennt Komoot ohne Murren.

Die vorschriftsgemäße Konvertierung von Go-Daten nach XML erfordert allerdings, ähnlich wie die 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. Listing 6 schreibt das XML einfach als parametrisierten Textstring.

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

Zeitrechnung

Eine Besonderheit sind die Zeitstempel der aufgezeichneten Track-Punkte, 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 Track-Punkt auf, also muss Listing 6 ein wenig rechnen.

Listing 6

gpx.go

package main
import (
  "encoding/json"
  "fmt"
  "time"
)
func toGpx(jdata []byte) []byte {
  var data map[string]interface{}
  json.Unmarshal([]byte(jdata), &data)
  tour := drill(data, []string{
    "page", "_embedded", "tour"})
  start := tour["date"].(string)
  coord := drill(tour, []string{
    "_embedded", "coordinates"})
  items :=
    coord["items"].([]interface{})
  ts, err := time.Parse(time.RFC3339, start)
  if err != nil {
    panic(err)
  }
  xml := "<gpx><trk>"
  for _, item := range items {
    pt := item.(map[string]interface{})
    secs := pt["t"].(float64) / 1000.0
    t := ts.Add(time.Duration(secs) * time.Second)
    xml += fmt.Sprintf(`<trkseg>
<trkpt lat="%f" lon="%f">
  <ele>%.1f</ele>
  <time>%s</time>
</trkpt></trkseg>`, pt["lat"],
    pt["lng"], pt["alt"],
    t.Format(time.RFC3339))
  }
  xml += "</trk></gpx>\n"
  return []byte(xml)
}

Zeile 21 liest die im Startfeld der Tour gefundene Uhrzeit 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 Track-Punkt, den Zeile 37 wieder ins RFC3339-Format umwandelt und als String ins XML einpflanzt.

Hinzu kommen die Einträge für »lat« (Latitude, geografische Breite) und »lng« (Longitude, geografische Länge, aber »lon« im XML), die zwar als generisches »interface{}« vorliegen, aber vom »%f«-Platzhalter der »Sprintf()«-Funktion per Type Assertion ins Float64-Format konvertiert werden. Gleiches gilt für die Höhe »alt« (Altitude) über dem Meeresspiegel, die ins »ele«-Feld (Elevation) des GPX-Formats einfließ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.

Allerdings muss man sagen, dass offiziell nicht gewartete Webscraper wie dieser den Nachteil haben, dass selbst kleinste Layoutänderungen am Webauftritt des Anbieters 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-Client-ID registrieren zu lassen. Sauberer wär’s.

Infos

  1. “Get Komoot tour data without API”: https://python.plainenglish.io/get-komoot-tour-data-without-api-143df64e51fa
  2. GPX-Format: https://en.wikipedia.org/wiki/GPS_Exchange_Format
  3. Colly: Mike Schilli, “Daten abstauben”, LM 04/2019, S. 98, https://www.lm-online.de/42260
  4. Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2021/09/snapshot/
DIESEN ARTIKEL ALS PDF KAUFEN
EXPRESS-KAUF ALS PDFUmfang: 7 HeftseitenPreis €0,99
(inkl. 19% MwSt.)
LINUX-MAGAZIN KAUFEN
EINZELNE AUSGABE Print-Ausgaben Digitale Ausgaben
ABONNEMENTS Print-Abos Digitales Abo
TABLET & SMARTPHONE APPS Readly Logo
E-Mail Benachrichtigung
Benachrichtige mich zu:
0 Kommentare
Älteste
Neuste Beste Bewertung
Inline Feedbacks
Alle Kommentare anzeigen
Nach oben