Aus Linux-Magazin 06/2025

Bestellungen bei Amazon analysieren

© Muhammad Saboor / 123RF.com

Um den Überblick über seine Amazon-Bestellungen zu behalten, schreibt Mike Schilli ein Go-Programm, das die Order-Liste übersichtlich aufbereitet.

Mittlerweile ist Amazon ein Synonym für Einkäufe jeder Art. Außer sperrigen Gegenständen vom Baumarkt und Gebrauchtwaren auf Ebay ordere ich dort fast ausschließlich und wundere mich, dass es den Ausdruck “ich habe mir das geamazont” noch nicht gibt.

Die Warenflut führt allerdings dazu, dass im Hause Schilli bei mehreren Dutzend Bestellungen pro Monat der Aufwand zunimmt, die Buchungen zu kontrollieren. Aus Datenschutzgründen oder auch aus konkurrenzschwächendem Geschäftsgebaren zeigt Amazon sich zugeknöpft beim Verschicken von Informationen über die bestellten Waren und deren Kosten. E-Mail-Bestätigungen in Gmail geben zum Beispiel nur Nummern einer Order preis, jedoch keine nähere Beschreibung, damit Google nichts sammeln kann.

Man benötigt also eine Liste bestellter Waren des vergangenen Monats im maschinenlesbaren CSV-Format. Das ging früher über die Webseite, aber Amazon hat das unverständlicherweise umgestellt. Man muss nun auf der Seite Mein Konto fast ganz am unteren Ende den Link Deine Daten anfordern klicken (Abbildung 1).

Abbildung 1: Amazon macht es nicht einfach, Bestelldaten in maschinenlesbarem Format anzufordern.

Abbildung 1: Amazon macht es nicht einfach, Bestelldaten in maschinenlesbarem Format anzufordern.

Nur mit Schikane

Damit noch nicht genug der Schikane: Bestätigt der User auf der Folgeseite die Anfrage nach einer maschinenlesbaren CSV-Datei, lässt sich Amazon zuerst die Anfrage “aus Sicherheitsgründen” noch per E-Mail bestätigen. Dann erhält man die lapidare Antwort, die Anfrage sei zwar eingegangen, die Antwort könne aber bis zu einem Monat dauern. Unglaublich, was sich Jeff Bezos’ Megamarkt hier herausnimmt! Es dauert allerdings selten mehr als 20 Minuten, bis wiederum per E-Mail eine Nachricht vorliegt, die den Link zum Herunterladen freigibt.

Die Datensammlung kommt in Form zweier Dateien daher (Abbildung 2). Neben einem ZIP-komprimierten Ordner erhält man eine völlig nutzlose CSV-Datei, die lediglich die Einzelverzeichnisse im Archiv wortreich beschreibt. Im ZIP selbst logiert ein Sammelsurium verschiedener Unterverzeichnisse, in denen sich endlich das gewünschte CSV-Futter findet (Abbildung 3).

Abbildung 2: Endlich liegen die benötigten Daten vor.

Abbildung 2: Endlich liegen die benötigten Daten vor.

Abbildung 3: Die gesammelten Daten liegen in separaten Bereichen.

Abbildung 3: Die gesammelten Daten liegen in separaten Bereichen.

Digitale Bestellungen wie Kindle-Bücher liegen nun in »Digital-Ordering.1«, retournierte Bestellungen normaler Waren in »Retail.Customer.Returns.1«. Sogar, was gerade im Einkaufswagen liegt, lässt sich in »CartItems.1« und »…2« in Erfahrung bringen. In den Ordnern »Retail.OrderHistory.1« und »…2« finden sich die gesuchten Dateien mit den Bestellungen.

Die Unterteilung in die zwei Dateien scheint der Aufteilung in verschiedene Amazon-Niederlassungen geschuldet, etwa wenn man, wie ich, sowohl bei Amazon.com als auch Amazon.de bestellt. Mit der Masse an Bestelldaten, die bei meinem Early-Adopter-Account bis ins Jahr 1998 zurückreichen, lassen sich nun allerhand statistische Auswertungen anstellen.

CSV aus der Blase

Der Code aus Listing 1 schnappt sich in »ParseHistoryFile()« ab Zeile 30 zum Einlesen der Daten als Erstes den Namen der History-Datei und öffnet sie in Zeile 31. Was das CSV-Format anbelangt, scheint man bei Amazon in einer Blase zu leben, denn die Datei startet mit einem UTF-8-BOM aus drei binären Zeichen, der normale CSV-Parser aus der Bahn wirft (Abbildung 4). Listing 1 springt aus diesem Grund mit »Seek()« drei Zeichen vor, bevor es die Daten dem Paket encoding/csv überreicht.

Abbildung 4: Am Anfang der CSV-Datei stehen ungültige Zeichen.

Abbildung 4: Am Anfang der CSV-Datei stehen ungültige Zeichen.

In Amazons CSV-Salat finden sich zudem des Öfteren Fehler, zum Beispiel Felder, die von einer auf die nächste Zeile einfach durch Abwesenheit glänzen, oder Kommentarfelder, die sich über mehrere Zeilen erstrecken. Der CSV-Parser von Go kennt da keine Gnade. Deswegen liest Listing 1 die Daten nicht wie üblich mit »ReadAll()« ein, sondern zeilenweise. So kann es fehlerhafte Zeilen melden und überspringen.

Listing 1

orders.go

package main
import (
  "encoding/csv"
  "fmt"
  "io"
  "os"
  "time"
  "go.uber.org/zap"
)
type Order struct {
  Total   int64
  Item    string
  Id      string
  At      time.Time
  Shipped time.Time
}
type Orders struct {
  Orders []Order
  Log    *zap.Logger
}
func NewOrders() *Orders {
  s := Orders{
    Log: zap.NewNop(),
  }
  return &s
}
func (s *Orders) AddOrder(o Order) {
  s.Orders = append(s.Orders, o)
}
func (s *Orders) ParseHistoryFile(filename string) error {
  file, err := os.Open(filename)
  if err != nil {
    return err
  }
  defer file.Close()
  _, err = file.Seek(3, os.SEEK_CUR)
  if err != nil {
    return err
  }
  reader := csv.NewReader(file)
  line := 0
  for {
    record, err := reader.Read()
    if err != nil {
      if err == io.EOF {
        break
      }
      return err
    }
    line += 1
    if line == 1 {
      continue
    }
    if len(record) > 23 {
      at, err := timeParse(record[2])
      if err != nil {
        s.Log.Error("Can't parse",
          zap.String("order time", record[2]))
      }
      shipped, err := timeParse(record[18])
      if err != nil {
        s.Log.Error("Can't parse",
          zap.String("ship time", record[18]))
      }
      amt, err := intFromAmount(record[9])
      if err != nil {
        err = fmt.Errorf("Line %d: %s", line, err)
        return err
      }
      entry := Order{
        Item:    record[23],
        At:      at,
        Shipped: shipped,
        Id:      record[1],
        Total:   amt,
      }
      s.AddOrder(entry)
    } else {
      return fmt.Errorf("Not enough fields: %v", record)
    }
  }
  return nil
}

Weder kompakt noch clever

Die Zeilen 50 bis 53 in Listing 1 zeigen übrigens, dass Go keinen Wert auf kompakten Code legt, sondern zu simplen, klaren Kontrollstrukturen anregt.

Als Erstes erhöht Zeile 50 die Nummer der gerade bearbeiteten CSV-Zeile um eins, worauf Zeile 51 prüft, ob es sich um die erste Zeile handelt, also den zu verwerfenden Header. Wäre das der Fall, wird sie mit continue innerhalb einer If-Bedingung entsorgt. Vier Zeilen Programmlogik, die sich in C in einer Zeile abwickeln ließe!

Zwar erlaubt Go das Erhöhen eines Zählers um eins mit »++«, aber »line++« gibt keinen Wert zurück, der sich wie in C mit einem cleveren »if(++line == 1) continue« in eine platzsparende Kontrollstruktur einbauen ließe. Obendrein lässt Go keine einzeilige If-Bedingung zu, die geschweiften Klammern müssen sein. Schließlich soll der Leser des Programmtexts bekannte Muster erkennen und nicht lange darüber nachdenken müssen, was ein Code-Snippet eigentlich bezweckt.

Warum überhaupt die Zeilen mitzählen? Erstens lässt sich so die Header-Zeile mit den Kolumnentiteln ausfiltern, wenn der Zähler auf »1« steht. Zweitens ist es unheimlich nervig, eine Fehlermeldung wie Can’t parse amount zu erhalten, ohne nachvollziehen zu können, in welcher Zeile der CSV-Datei das Betragsfeld nur unlesbares Kauderwelsch enthält.

Aus den 28 Feldern jeder CSV-Zeile des Amazon-Formats für die Bestellhistorie interessieren für die Statistik nur die Bestellnummer (Feld 2), das Bestelldatum (3), das Versanddatum (19), die Produktbeschreibung(24)  sowie der Gesamtbetrag (10). Sie alle liefert der CSV-Parser als Strings in Go zurück, was etwas Arbeit erfordert, um sie in korrekte Datentypen umzumodeln.

Listing 2 definiert dazu zwei Hilfsfunktionen. »timeParse()« setzt die Zeitstempel gemäß RFC 3339 um. Währungsbeträge sollte ein Programm der Rechengenauigkeit wegen niemals als »Float« speichern, sondern sie als Integer-Werte umsetzen, also in Cent umrechnen. Die Hilfsfunktion »intFromAmount()« parst dazu den Betrag als »Float«, multipliziert den Wert mit 100 und gibt das Ergebnis als »int64« zurück.

Listing 2

util.go

package main
import (
  "strconv"
  "strings"
  "time"
)
func intFromAmount(s string) (int64, error) {
  found := int64(0)
  s = strings.ReplaceAll(s, "$", "")
  fl, err := strconv.ParseFloat(strings.ReplaceAll(s, ",", ""), 64)
  if err != nil {
    return found, err
  }
  return int64(fl * 100), nil
}
func timeParse(s string) (time.Time, error) {
  spLoc := strings.LastIndex(s, " ")
  if spLoc != -1 {
    s = s[spLoc+1:]
  }
  dt, err := time.Parse(time.RFC3339, s)
  if err != nil {
    return time.Time{}, err
  }
  return dt, nil
}

Der Rest des Codes in Listing 1 ist Boilerplate-Objektorientierung in Go. Der Konstruktor »NewOrders()« ab Zeile 21 gibt einen Pointer auf die Struktur »Orders« zurück, die einen Array-Slice von Bestellungen in Form von »Order«-Strukturen enthält. Die Funktion »AddOrder()« ab Zeile 27 hängt einen frisch geparsten neuen Eintrag ans Ende des Arrays an.

Damit sich das OO-Konstrukt wahlweise stumm oder gesprächig geben kann, enthält die Objektstruktur »Orders« auch noch einen Zap-Logger. Der gibt später beim Parsen Fehlermeldungen aus, falls eine CSV-Zeile einen Fehler enthält. Da der Konstruktor »NewOrders()« den Logger aber in Zeile 23 als »zap.NewNop()« initialisiert, geben die Logzeilen zunächst einmal gar nichts aus.

Nur wenn das Hauptprogramm die (global zugängliche) Instanzvariable »Log« des Objekts später auf einen aktiven Zap-Logger setzt, wachen die Log-Schreiber in den Zeilen 57 und 62 auf und schreiben ihre Fehlermeldungen dorthin, wo der Logger es befiehlt. So kann ein Hauptprogramm den Code einbinden, ohne überhaupt von der Existenz des Loggers zu wissen. Ist es hingegen so schlau, das Log-System auf »DEBUG« oder »INFO« einzustellen, erwacht der schlafende Code zum Leben.

Aus Jeff Bezos’ Garage

Listing 3 nutzt die Funktionen der »Orders«-Struktur in Listing 1, um alle in der Order-Historie aufgelisteten Waren formatiert auszudrucken. Dazu iteriert es nach dem Einlesen der CSV-Datei mit »ParseHistoryFile()« in der For-Schleife ab Zeile 12 über alle gelieferten Order-Objekte.

Das Bestelldatum in »At« liegt als Zeitstempel vor. Gos Formatstring »2006-01-02« macht daraus ein kompaktes Datum mit Jahr, Monat und Tag. Aus dem »int64«-Wert für den Bestellwert formt »float64« eine Fließkommazahl. Teilen durch 100 macht aus den Cent-Beträgen wieder korrekte Währungseinheiten.

Listing 3

list.go

package main
import (
  "fmt"
)
func main() {
  histFile := "Retail.OrderHistory.1.csv"
  orders := NewOrders()
  err := orders.ParseHistoryFile(histFile)
  if err != nil {
    panic(err)
  }
  for _, o := range orders.Orders {
    fmt.Printf("%s %5.2f %.60s\n",
      o.At.Format("2006-01-02"),
      float64(o.Total)/100,
      o.Item,
    )
  }
}

Abbildung 5 zeigt den Aufruf von »go build« mit den drei Quelldateien, um daraus das Binary »list« zu erzeugen. Ruft man es von der Kommandozeile aus auf, führt es alle 4109 Bestellungen auf, die es in meiner bis 1998 zurückreichenden Bestellhistorie findet. Seinerzeit gab es bei Amazon nur Bücher zu kaufen, und die via Tail gefilterte Ausgabe unten zeigt meine damaligen Neigungen: Ratgeber zum Erstellen der amerikanischen Einkommensteuererklärung und Fachbücher über die Regeln in rätselhaften amerikanischen Sportarten wie American Football. Schließlich war ich damals neu im Land. Die mit Head gefilterten aktuellsten Bestellungen im März 2025 umfassen stattdessen Gadgets und elektronischen Bastlerbedarf.

Abbildung 5: Amazon-Bestellungen des Autors zwischen 1998 und 2025.

Abbildung 5: Amazon-Bestellungen des Autors zwischen 1998 und 2025.

Beschleunigte Abwicklung

Gefühlt wickelte Amazon über die Jahre meine Bestellungen immer schneller ab. Aber stimmt das wirklich? In der CSV-Datei stecken die Daten, um dies zu überprüfen. Dort stehen im dritten Feld das Bestelldatum und im Feld Nummer 19 das Versanddatum. Die Differenz der beiden Zeitstempel ist die Bearbeitungszeit.

Nun könnte man das Go erledigen lassen, allerdings zöge sich der Code etwas in die Länge. Viel eleganter klappt das in der für statistische Auswertungen prädestinierten Programmiersprache R. Listing 4 zeigt den kompakten Code, der eine PNG-Datei mit einer Grafik wie in Abbildung 6 erzeugt.

Listing 4

delay.r

#!/usr/local/bin/Rscript
library(ggplot2)
library(dplyr)
library(lubridate)
orders <- read.csv("Retail.OrderHistory.1.csv", stringsAsFactors = FALSE)
orders$OrderDate <- ymd_hms(orders[[3]], tz = "UTC")
orders$ShipDate <- ymd_hms(orders[[19]], tz = "UTC")
orders$ShippingDelay <- as.numeric(difftime(orders$ShipDate, orders$OrderDate, units="days"))
orders <- orders %>% filter(ShippingDelay <= 10)
orders$Year <- year(orders$OrderDate)
avg_delay <- orders %>%
  group_by(Year) %>%
  summarise(AverageDelay = mean(ShippingDelay))
png("delay.png", width = 800, height = 600)
ggplot(avg_delay, aes(x = Year, y = AverageDelay)) +
  geom_line(color = "blue") +
  geom_point(color = "red") +
  labs(title = "Amazon Shipping Delay",
    x = "Year",
    y = "Average Shipping Delay (days)") +
  theme_minimal()

Die Zeilen einer CSV-Datei auszulesen und bestimmte Felder herauszupicken, liegt im Naturell der Sprache R. Zeile 8 zieht die beiden Datumswerte voneinander ab. Zeile 9 filtert Bestellungen aus, die Amazon erst nach 10 Tagen verschickt hat, denn für die Statistik interessieren nur durchschnittliche Bestellungen. Manche Waren, besonders extrem billige Sonderangebote, haben eben längere Lieferzeiten.

Abbildung 6 zeigt, dass in der Amazon-Zentrale irgendwann um 2006 herum anscheinend ein Knoten geplatzt ist, der die durchschnittliche Bearbeitungszeit von drei Tagen auf einen reduzierte. Dann schwankte sie über ein Jahrzehnt lang um einen Tag herum, bis 2021 weitere drastische Verbesserungen in der Abwicklung erfolgten. Heutzutage erfolgt der Versand in den meisten Fällen praktisch sofort.

Manche Bestellungen verschickt Amazon auf getrennten Wegen. In solchen Fällen stehen in der Kolumne Shipping Date zwei Zeitstempel, getrennt durch zwei Leerzeichen und das Wort AND. R ignoriert derartige Einträge einfach, für die Statistik sollen sie keine Rolle spielen.

Abbildung 6: Die Grafik verdeutlicht Amazons Bearbeitungszeit beim Verschicken von Bestellungen.

Abbildung 6: Die Grafik verdeutlicht Amazons Bearbeitungszeit beim Verschicken von Bestellungen.

Sonntäglicher Kaufrausch

Aber mit Bestelldaten aus mehr als 20 Jahren sind weitere Analysen möglich. An welchen Wochentagen klicke ich zum Beispiel am häufigsten auf den Buy-Button?

Listing 5 ist wieder eine Go-Applikation und liest mit »ParseHistoryFile()« aus Listing 1 die CSV-Daten ein. Zur grafischen Darstellung der Wochentage wählt es eine Balkengrafik (Abbildung 7). Die X-Achse beschriftet es mit Wochentagen von Sonntag bis Samstag. Auf der Y-Achse malt es für jeden Wochentag einen Balken, dessen Höhe der Anzahl der Bestellungen an diesem Tag entspricht.

Abbildung 7: An welchen Wochentagen h&auml;ufen sich Bestellungen?

Abbildung 7: An welchen Wochentagen häufen sich Bestellungen?

Listing 5

wday.go

package main
import (
  "time"
)
func main() {
  histFile := "Retail.OrderHistory.1.csv"
  orders := NewOrders()
  err := orders.ParseHistoryFile(histFile)
  if err != nil {
    panic(err)
  }
  xVals := make([]string, 7)
  yVals := make([]float64, 7)
  for i := 0; i < 7; i++ {
    xVals[i] = time.Weekday(i).String()
  }
  for _, o := range orders.Orders {
    wdi := int(o.At.Weekday())
    yVals[wdi]++
  }
  barChart("wday.png", "Order Weekdays", xVals, yVals)
}

Dazu zählt die For-Schleife ab Zeile 14 in Listing 5 von null bis sechs und legt im Array-Slice »xVals« die englischen Strings für die Wochentage ab, indem es die Funktion »Weekday()« aus Gos time-Paket befragt. Zur Berechnung der Balkenhöhe iteriert die For-Schleife ab Zeile 17 durch alle Bestellungen, bestimmt mit »Weekday()« den Wochentagsindex des Bestelldatums »o.At« und zählt in Zeile 19 das entsprechende Array-Element um eins hoch.

Aber warum gibt »time.Weekday()« einmal einen String (etwa »Monday«) zurück und ein anderes Mal einen Integer-Wert wie »1«? Des Rätsels Lösung: Die Funktion liefert als Rückgabe eine Variable des Typs »time.Weekday«, die als »int« implementiert ist und mit dem Schlüsselwort »iota« Werte zwischen 0 und 7 annimmt. Ein »int(x)« liefert also den Integer-Wert. Das »%s« in »Printf()« löst hingegen den Aufruf der Methode »String()« des Typs aus. Die wiederum gibt (entsprechend der Locale-Einstellungen) den Namen des Wochentags als String zurück.

Bunte Balken

Schöne Balkengrafiken zu erstellen, fällt in Go leicht. Dazu gibt es Pakete wie go-chart, das sich von Github hereinziehen lässt. Die Funktion »barChart()« in Listing 6 nimmt neben dem gewünschten Namen der Ausgabedatei und einem String mit der Kopfzeile des Diagramms zwei Array-Slices entgegen.

Listing 6

chart.go

package main
import (
  "os"
  "github.com/wcharczuk/go-chart/v2"
)
func barChart(outFile string, header string, xVals []string, yVals []float64) {
  bars := []chart.Value{}
  for i, x := range xVals {
    bars = append(bars,
      chart.Value{Label: x, Value: yVals[i]})
  }
  barChart := chart.BarChart{
    Title:  header,
    Height: 600,
    Width:  800,
    Bars:   bars,
  }
  f, err := os.Create(outFile)
  if err != nil {
    panic(err)
  }
  defer f.Close()
  barChart.Render(chart.PNG, f)
}

In »xVals« kommt ein Array-Slice von Strings herein, in »yVals« einer von »float64«-Werten für die Balkenhöhen. Zum Erzeugen der Grafik modelt die Funktion die Daten in ein Array von »chart.Value«-Strukturen um und überreicht es der »chart.BarChart«-Struktur ab Zeile 12. Dann muss Zeile 18 nur noch die zu erzeugende PNG-Datei anlegen, damit »Render()« in Zeile 23 alles malt, inklusive Balken in zufällig gewählten Farben sowie Achsen und deren Beschriftung.

Zum Erzeugen des Go-Binaries weisen Sie den Compiler mit »go mod init wday« und »go mod tidy« an, ein Go-Modul zu definieren und dessen Abhängigkeiten von Github einzuholen. Danach binden Sie per »go build« die vier Quelldateien »wday.go«, »chart.go«, »orders.go« und »utils.go« zusammen.

Winterschlussverkauf

Möchte man die Häufung von Bestellungen stattdessen abhängig vom Monat des Eingangs ausgeben, dient das Programm aus Listing 7 zum Erstellen einer entsprechenden Grafik. Es extrahiert mit »Month()« den Monat aus den Bestellungen, statt via »Weekday()« den Wochentag. Erwartungsgemäß geht es in der Vorweihnachtszeit heiß her, mit Sonderangeboten ab dem Black Friday im November. Auch während des Winterschlussverkaufs ab Januar klicke ich anscheinend zügelloser auf den Buy-Button.

Listing 7

month.go

package main
import (
  "time"
)
func main() {
  histFile := "hist-org.csv"
  orders := NewOrders()
  err := orders.ParseHistoryFile(histFile)
  if err != nil {
    panic(err)
  }
  xVals := make([]string, 12)
  yVals := make([]float64, 12)
  for i := 0; i < 12; i++ {
    xVals[i] = time.Month(i + 1).String()
  }
  for _, o := range orders.Orders
 {
    mi := int(o.At.Month()) - 1
    yVals[mi]++
  }
  barChart("month.png", "Order Months", xVals, yVals)
}

Foto-Finish

Amazon packt in den USA der Sammlung noch ein Schmankerl bei: Wenn der Paketbote die Order an der Haustür abliefert, schießt er ein Foto als Zustellbeweis. Es zeigt dann die Haustür oder den Eingangsbereich eines Mietgebäudes mitsamt dem dort drapierten Umschlag oder Paket. Das Verfahren hilft nebenbei, die von schusseligen Zustellern am falschen Ort abgelieferten Sendungen aufzustöbern. Einmal gelang es mir so, ein verloren geglaubtes Paket in der Küche eines nahegelegenen Restaurants aufzuspüren [1].

Amazon-Zusteller in Deutschland erstellen (wahrscheinlich aus Datenschutzgründen) keine Zustellfotos, aber beim US-Original liegen diese Kunstwerke den heruntergeladenen Daten im Ordner »YourOrders.PhotoOnDelivery« als JPGs bei. Wer Spaß daran hat, fertigt daraus eine künstlerisch hochwertige Collage an (Abbildung 8).

Abbildung 8: Amazon sammelt auch alle Zustellfotos.

Abbildung 8: Amazon sammelt auch alle Zustellfotos.

Soweit einige praktische Beispiele zur Illustration des Kaufverhaltens auf Amazon – aber das war erst der Anfang: Wie wäre es mit einer Grafik zu den monatlichen Ausgaben? Man könnte auch eine Applikation schreiben, die die Bestelldaten mit einem Kontoauszug oder einer Kreditkartenabrechnung abgleicht. Der Kreativität sind wie üblich keine Grenzen gesetzt. (uba/jlu)

Infos

  1. “Amazon-Zusteller auf Abwegen”: https://usarundbrief.com/156/p3.html
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
Nach oben