Micro-Manager (Linux-Magazin, Juni 2016)

Mit eines Screenscrapers oder einer Applikation mittels der offiziellen Ebay-API gelingt es, bei neu eingegangenem Kunden-Feedback Alarm auszulösen sowie Bugs in der monatlichen Ebay-Abrechnung aufzuspüren.

Wenn ich demnächst meine Memoiren schreibe, werde ich sicher eine langatmige Abhandlung über meinen Lebensleitsatz einbetten, der da heißt: "Was du nicht ständig kontrollierst, läuft irgendwann garantiert unbemerkt schief". Getreu diesem Motto möchte ich zum Beispiel sofort eine Email erhalten, wenn einer meiner Ebay-Kunden ein sogenanntes Feedback über eine zwischen uns abgewickelte Transaktion hinterlassen hat. Da ich mich zunächst nicht lange bei Ebay als Developer anmelden wollte, schrieb ich kurzerhand einen Screenscraper, der aus dem HTML-Salat der Feedback-Seite die aktuelle Zahl der Feedbacks herausfieselt, abspeichert, und bei späteren Tests Alarm schlägt, falls sich der Zähler erhöht hat.

Einfach abspachteln

Abbildung 1: Auf der Feedback-Seite bei Ebay steht die Anzahl der bisher eingegangenen Kundenkommentare (hier 362).

Klicke ich auf Ebay.com auf meinen Usernamen, lande ich auf der Feedback-Seite in Abbildung 1. "View Source" im Browser bringt den HTML-Code aus Abbildung 2 zum Vorschein und eine Textsuche nach dem String "362" (die aktuelle Anzahl der Feedbacks) zeigt, dass diese sich in einem Markup-Tag der Klasse "mbg-l" befindet:

    <span class="mbg-l"> ( 362<img src="...

Ein XPath-Prozessor wie zum Beispiel das CPAN-Modul HTML::TreeBuilder::XPath kann nun den Inhalt dieses Tags sehr einfach hervorholen, die Abfrage

    /html/body//span[@class="mbg-l"]

findet alle span-Elemente im HTML-Body, die ein "class"-Attribut mit dem Wert "mbg-l" führen. Der doppelte Schrägstrich im Ausdruck gibt dabei an, dass sich die gesuchten Elemente in beliebiger Verschachtelungstiefe in Elementen unterhalb des HTML-Body-Tags befinden können. Der XPath-Query spuckt dann einen String wie etwa "( 362) (..." aus, und daraus mittels eines regulären Ausdrucks in Perl die erste Zahl herauszufieseln ist ein Kinderspiel. Listing 1 tut genau dies in der Funktion feedback_fetch ab Zeile 70, speichert gefundene Zahlen in einer Cache-Datei, vergleicht den Wert dort beim nächsten Aufruf mit dem aktuell eingeholten, und feuert eine Email ab, wenn der Zähler sich erhöht hat.

Abbildung 2: In einem HTML-Element der Klasse "mbg-l" steht die Anzahl der Feedbacks (362).

Listing 1: ebay-feedback

    01 #!/usr/local/bin/perl -w
    02 use strict;
    03 use Sysadm::Install qw(:all);
    04 use LWP::Simple ;
    05 use HTML::TreeBuilder::XPath;
    06 use Log::Log4perl qw(:easy);
    07 
    08 my $nick      = "my-ebay-name";
    09 my $ebay_url  = 
    10   "http://feedback.ebay.com" .
    11   "/ws/eBayISAPI.dll?ViewFeedback2" . 
    12   "&userid=$nick";
    13 
    14 my( $home )   = glob "~";
    15 my $data_dir  = "$home/logs";
    16 my $cache     = 
    17   "$data_dir/ebay-feedback.cache";
    18 my $log_file  = 
    19   "$data_dir/ebay-feedback.log";
    20 
    21   # mail prefs
    22 my $mailer    = "/usr/bin/mail";
    23 my $mail_to   = 'my@email.com';
    24 
    25 mkd $data_dir if !-d $data_dir;
    26 
    27 Log::Log4perl->easy_init( { 
    28   level => $DEBUG, 
    29   file => ">>$log_file" } );
    30 
    31 my $last_feedback;
    32 
    33 if( -f $cache ) {
    34   $last_feedback = slurp $cache;
    35 }
    36 
    37 my $feedback = feedback_fetch();
    38 
    39 if( !defined $last_feedback or 
    40   $last_feedback != $feedback ) {
    41 
    42   $last_feedback ||= 0;
    43 
    44   INFO "New feedback: $feedback";
    45   INFO "Sending mail to $mail_to";
    46 
    47   open PIPE, 
    48     "| $mailer -s 'New Ebay Feedback: " .
    49     "$feedback' $mail_to";
    50 
    51   print PIPE <<EOT;
    52 Ebay feedback changed: It's $feedback now
    53 and was $last_feedback yesterday:
    54 
    55     $ebay_url
    56 
    57 Greetings!
    58 
    59 Your faithful Ebay feedback scraper.
    60 EOT
    61   close PIPE;
    62 
    63   blurt $feedback, $cache;
    64 
    65 } else {
    66   INFO "Feedback unchanged ($feedback).";
    67 }
    68 
    69 ###########################################
    70 sub feedback_fetch {
    71 ###########################################
    72   INFO "Fetching $ebay_url";
    73 
    74   my $content = get $ebay_url;
    75 
    76   if( !defined $content ) {
    77     ERROR "Fetching $ebay_url failed";
    78     return undef;
    79   }
    80 
    81   my $tree= HTML::TreeBuilder::XPath->new;
    82   $tree->parse( $content );
    83 
    84   my( $text ) = $tree->findvalue(
    85       '/html/body//span[@class="mbg-l"]');
    86 
    87   if( $text =~ /\s*(\d+)/ ) {
    88     return $1;
    89   }
    90 
    91   ERROR "Pattern in page not found";
    92   return undef;
    93 }

Email als Kommando

Das Schreiben und Lesen der Cache-Datei, die als einzigen Wert die beim letzten Aufruf festgestellte Anzahl der Feedbacks speichert, erledigen die Funktionen slurp und blurt aus dem CPAN-Modul Sysadm::Install, die diese in Zeile 3 exportiert. Zum Einholen der Ebay-Seite nutzt das Skript das abgespeckte Modul LWP::Simple, dessen get-Methode schlicht einen HTTP-Request auf den angegebenen URL absetzt und den Inhalt der gefundenen Seite im Erfolgsfall zurückgibt. Unter Linux eine Mail abzuschicken, lässt sich oft ganz einfach mittels der Utilty /usr/bin/mail erledigen, wer mehr Flexibilität wünscht, dem sei das CPAN-Modul Mail::DWIM empfohlen. Da ich aber auf meiner Linux-Kiste daheim /usr/bin/mail bereits durch ein Perl-Skript ersetzt habe, das mit den Erfordernissen meines ISPs zurechtkommt, beließ ich es einfach dabei. Abbildung 3 zeigt die eingetroffene Email in meinem Gmail-Account.

Vor der Inbetriebnahme sind noch die Parameter am Skriptkopf an die lokalen Verhältnisse anzupassen. In Zeile 8 erhält die Variable $nick den Namen des Ebay-Users zugewiesen, dessen Feedback-Zähler ebay-feedback überwachen soll. Die URL gilt für US-Ebay-Accounts, deutsche Accounts laufen statt dessen unter ebay.de. Und schließlich benötigt Zeile 23 in der Variablen $mail_to noch die Emailadresse des Users, an den die Alarmemails gehen sollen.

Abbildung 3: Eine Email benachrichtigt den User über das eingetroffene Feedback.

Kontrolle des Kontrolleurs

Wie lässt sich kontrollieren, ob das Skript immer noch funktioniert oder ob Ebay sein Seitenlayout geändert hat? Listing 1 protokolliert dazu alle Vorgänge mittels Log::Log4perl in die Logdatei ~/logs/ebay-feedback.log und ein Nagios-Skript könnte zum Beispiel jeden Tag die dort eintrudelnden Einträge nach einem Muster wie "OK" unter dem aktuellen Datum durchforsten und Alarm schlagen, falls es nicht fündig wird.

Abbildung 4: Der Scraper hat festgestellt, dass die Zahl der Feedbacks seit dem letzten Test von 361 auf 362 hochgeschnellt ist.

Ebay kann nicht addieren

Abbildung 5: Bei Ebay ergeben $4.40 + $0.45 + $1.00 + $0.39 nicht $6.24 sondern $6.25.

Eigentlich man sollte nicht meinen, dass es Leute gibt, die die Einzelposten einer computergenierten Abrechnung zusammenzählen und nachprüfen, ob die Gesamtsumme stimmt. Aber ich muss gestehen, dass ich da auch manchmal nicht widerstehen kann. Besonders bei der monatlichen Ebay-Abrechnung, wenn mal wieder nur vier Posten draufstehen, wie in Abbildung 5. Jeder Erstklässler mit einem Taschenrechner könnte mühelos verifizieren, dass $4.40 + $0.45 + $1.00 + $0.39 die Gesamtsumme von $6.24 ergeben. Doch Ebays Java-Jockeys haben anscheinend den Artikel zur Fließkommaarithmetik, den jeder Programmierer [2] gelesen haben sollte, noch nicht gelesen. Sonst wüssten sie nämlich, dass CPUs Fließkommazahlen ungenau abspeichern und deswegen beim Addieren erstaunliche Rundungsfehler auftreten können, wenn man nicht aufpasst wie ein Luchs.

Listing 2: ebay-invoice

    01 #!/usr/local/bin/perl -w
    02 use strict;
    03 use LWP::UserAgent;
    04 use DateTime;
    05 use Path::Tiny;
    06 
    07 my $dt_today = DateTime->today;
    08 my $dt = DateTime->new(
    09   year  => $dt_today->year,
    10   month => $dt_today->month,
    11   day   => 1,
    12 );
    13 my $invoice_date = 
    14   $dt->subtract( days => 1 )->ymd;
    15 
    16 my $ua = LWP::UserAgent->new;
    17 
    18 $ua->default_header(
    19   "X-EBAY-API-CALL-NAME", "GetAccount" );
    20 $ua->default_header(
    21   "X-EBAY-API-COMPATIBILITY-LEVEL", 863 );
    22 $ua->default_header(
    23   "Content-Type", "text/xml" );
    24 $ua->default_header(
    25   "X-EBAY-API-SITEID", "0" );
    26 
    27 my $token = path( "token" )->slurp;
    28 chomp $token;
    29 
    30 my $body  = <<EOT;
    31 <?xml version="1.0" encoding="utf-8"?>
    32 <GetAccountRequest 
    33  xmlns="urn:ebay:apis:eBLBaseComponents">
    34 <RequesterCredentials>
    35     <eBayAuthToken>$token</eBayAuthToken>
    36 </RequesterCredentials>
    37 <AccountHistorySelection>SpecifiedInvoice</AccountHistorySelection>
    38 <InvoiceDate>$invoice_date</InvoiceDate>
    39 </GetAccountRequest>
    40 EOT
    41 
    42 my $resp = $ua->post(
    43   "https://api.ebay.com/ws/api.dll",
    44   Content => $body );
    45 
    46 if( $resp->is_error ) {
    47     die $ua->message;
    48 }
    49 
    50 print $resp->decoded_content;

So stellten mir diese Freunde der Sonne im März frecherweise $6.25 in Rechnung, obwohl die Gesamtsumme der Einzelposten zweifelsohne $6.24 beträgt. Beim Kundencenter hatte ich vor einiger Zeit schon einmal angerufen, und hing eine halbe Stunde mit einem Repräsentanten aus Übersee in der Leitung, um eine Diskrepanz von ein paar Cents berichtigen zu lassen, aber die 1-Cent-Beschwerde hebe ich mir für einen ganz besonderen Tag auf.

Unermüdlicher Buchprüfer

Das Skript in Listing 2 hilft dabei, diese Buchprüfung jeden Monat automatisch durchzuführen. Es stützt sich auf die offizielle Ebay-API, weist sich mit einem Token gegenüber dem Ebay-Webservice aus, und holt die Abrechnung des vergangenen Monats als XML ein. Dort stehen sowohl die Einzelposten (jeweils unter dem Tag NetDetailAmount) als auch die Gesamtsumme (unter InvoiceNewFee) und das nachgeschaltete invoice-check in Listing 3 kann dann prüfen, ob Ebay richtig gerechnet hat.

Abbildung 6: Zwei Skripts holen die letzte Abrechnung und prüfen sie auf Richtigkeit.

Schlüssel zum Datenreich

Eine Applikation, die Ebay-Nutzerdaten liest oder schreibt, muss sich gegenüber Ebay mit einem Token ausweisen. Hierzu muss sich der Entwickler beim Ebay-Developer-Program registrieren und eine Applikation anmelden. Anschließend kann dann entweder die Applikation den User durch den Login-Prozess führen und so einen Token erhalten, oder, falls der Entwickler den Token nur zu Testzwecken und für seinen eigenen Account benötigt, in Abbildung 7 einfach den Knopf "Sign in to Production" drücken ([3]). Dieser gilt dann für den tatsächlichen Account in der Live-Produktion, und nicht etwa für die sogenannte "Sandbox" in der Entwickler ebenfalls ihre Apps ausprobieren können, bis sie sicher sind, dass diese Produktionsreife erlangt haben.

Abbildung 7: Entwickler können bei Ebay zu Testzwecken einen Token für ihren eigenen Ebay-Account abholen.

Alle Abfragen auf persönliche Daten benötigen einen vom User autorisierten Token, der in Listing 2 in Zeile 35 sichtbar unter dem Tag RequesterCredentials im XML des Requests stehen muss. Es handelt sich um einen 872 Zeichen langen Hex-String, der ohne Zeilenumbruch zwischen den darunter eingebetteten eBayAuthToken-Tags im XML stehen muss.

Abbildung 8: Nach dem Einloggen fragt Ebay noch einmal nach, ob der Entwickler seiner App auch wirklich traut.

Die Dokumentation auf den Seiten des Ebay-Developers-Program ist leicht chaotisch angeordnet und die einzelnen APIs überlappen wohl aus historischen Gründen. Auf [3] erhalten Entwickler ihre Tokens und unter [4] steht, welche Parameter die Abfrage GetAccount benötigt, um eine an einem bestimmten Datum erstellte Abrechnung einzuholen.

Sankt Bürokratius

Wie Listing 2 zeigt, benötigt der an die Ebay-API abgesetzte Post-Request neben dem XML-Body auch noch einige HTTP-Headerwerte. Der Wert des Headers X-EBAY-API-SITEID bestimmt, an welche Ebay-Abteilung in welchem Land die Abfrage geht, und der Wert 0 gilt für Ebay.com in den USA, während Applikationen Ebay.de in Deutschland unter der Zahl 77 erreichen.

Der Header X-EBAY-API-COMPATIBILITY-LEVEL spezifiziert die Mindestversion der Web-API, mit der der Client noch arbeiten kann. Gegenwärtig ist Version 959 aktuell, der im Skript angegebene Wert von 863 hat lediglich akademischen Wert. Um das Ergebnis in XML zu erhalten, setzt Zeile 23 den Header Content-Type auf text/xml. Und dass die Applikation die Servermethode GetAccount aufruft, steht zwar schon im XML-Body des Post-Requests, aber St. Bürokratius verlangt, dass sie ebenfalls nochmal im Header X-EBAY-API-CALL-NAME in Zeile 19 steht.

Ebay erstellt Rechnungen immer zum Monatsende, und um vom heutigen Datum auf den letzten Tag im vergangenen Monat zu kommen, also das letzte Rechnungsdatum, setzt Listing 2 in Zeile 11 den Tageswert des heutigen Datums auf 1 (also den ersten des aktuellen Monats) und zieht dann einen Tag davon ab. Es landet demnach auf dem letzten Tag des vorhergehenden Monats und die DateTime-Funktion ymd modelt das Datum z.B. für die Märzabrechnung 2016 ins Format 20160331 um, das die Ebay-API versteht, falls in der Abfrage unter AccountHistorySelection der Wert SpecifiedInvoice angegeben wurde. Das Datum dieser Abrechung muss dann unter InvoiceDate stehen.

Die sensitiven Token-Daten erwartet das Skript in einer Datei token, von wo es sie in Zeile 27 ausliest, in Zeile 28 ein eventuelles Newline-Zeichen am Dateiende entfernt und den Schlüssel dann über die Variable $token ins XML ab Zeile 31 einbaut. Das Datum der gewünschten Abrechung liegt in der Variablen $invoice_date und fließt ebenfalls ins XML ein, Zeile 38 interpoliert den Wert.

Tritt ein Fehler beim Einholen der Daten auf, bricht Zeile 46 das Skript ab und druckt die von Ebay kommende Fehlermeldung. Typische Probleme sind unerwünschte Leerzeichen und Zeilenumbrüche in den XML-Daten, auf die Ebay sauer reagiert, aber zum Glück in den Fehlermeldungen detailliert beschreibt. Geht alles glatt, druckt Zeile 50 das zurückkommende XML auf der Standardausgabe aus, wo die nächste Stufe der Verarbeitungs-Pipeline die Daten aufgreift.

Addieren, aber richtig

Das Skript in Listing 3 schnappt sich den von Listing 2 kommenden XML-Salat, sucht darin mittels des CPAN-Moduls XML::Simple nach den Einzelposten sowie der Gesamtsumme, und verifiziert, ob die Addition der Einzelposten auch tatsächlich die Gesamtsumme ergibt. Das Schöne an XML::Simple ist, dass es jegliches XML in eine riesige Perl-Datenstruktur verwandelt, in der ein Skript nach Belieben herumfuhrwerken kann. Tags verwandeln sich in Hashschlüssel, und aus Listen von Einzeleinträgen macht es Perl-Arrays. Für den Text, der zwischen zwei XML-Tags als deren Inhalt steht, erfindet XML::Simple einen Hash-Key namens content, unter dem zum Beispiel Zeile 30 in Listing 3 die Gesamtsumme der Rechnung aus dem Tag InvoiceNewFee extrahiert.

Abbildung 9: Die Online-Abrechnung in XML listet auch die Einzelbeträge auf, sodass ein Skript sie nachkontrollieren kann.

Listing 3: invoice-check

    01 #!/usr/local/bin/perl -w
    02 use strict;
    03 use XML::Simple;
    04 use Math::Currency;
    05 
    06 my $xml   = join "", <>;
    07 my $ref   = XMLin( \$xml );
    08 my $total = 0;
    09 my @items = ();
    10 
    11 for my $entry ( @{ $ref->{ 
    12  "AccountEntries" }->{ AccountEntry } } ) {
    13 
    14     # ignore payments
    15   next if $entry->{ ItemID } == 0;
    16 
    17   my $amount = $entry->{ 
    18     "NetDetailAmount" }->{ content };
    19 
    20     # ignore free items
    21   next if $amount == 0;
    22 
    23   my $mc = Math::Currency->new( $amount );
    24   print "$mc\n";
    25   $total += $mc;
    26 }
    27 
    28 my $invoice_amount = 
    29   $ref->{ AccountSummary }->{ 
    30     "InvoiceBalance" }->{ content };
    31 
    32 print "Total:   $total\n";
    33 print "Invoice: \$$invoice_amount\n";

Wie gesagt, wird in [2] dringend davon abgeraten, Dollar (oder Euro) und Centbeträge einfach als Fließkommawerte aufzuaddieren. Vielmehr sollten sorgfältige Programmierer alles in Cents umrechnen und diese als Integer weiter verarbeiten, um vom Ergebnis anschließend die letzten zwei Ziffern wieder abzutrennen und als Centbeträge auszuweisen.

Das CPAN-Modul Math::Currency addiert Beträge ordentlich auf und formatiert das Ergebnis ansprechend, auch wenn die Eingaben wie im XML von Ebay mit ihren einstelligen Nachkommabeträgen (z.B. "$1.6") nicht ganz korrekt vorliegen. Allerdings hat der Zahn der Zeit schon sehr an dem Modul genagt, der letzte Release ist 6 Jahre alt und die beiliegende Test-Suite rasselt mit Pauken und Trompeten durch die Prüfung. Funktionieren tut das Modul aber, und das Rad neu zu erfinden war mir zu umständlich, also zwang ich die CPAN-shell mit

    cpanm --force Math::Currency

dazu, es trotzdem zu installieren. Die XML-Abrechnung enthält nicht nur die Einzelposten und die Gesamtsumme, sondern auch noch die Beträge, die der Kunde aus vorhergegangenen Rechnungen bereits an Ebay überwiesen hat. Zeile 15 erkennt diese an ihrer ItemID, die anders als bei fälligen Gebühren für Verkaufsobjekte den Wert 0 hat.

Kassierer kontrollieren

Wie wird es weitergehen? Dieser Fehler bei der Ebay-Abrechnung brachte mich auf die Idee, beim nächsten Supermarktbesuch auch die Einzelposten auf dem Kassenbon nachzukontrollieren. Wem würde es schon auffallen, wenn die dort ausgewiesene Gesamtsumme um ein paar Cent von der Summe der Einzelposten abwiche? Niemand außer mir wahrscheinlich, der die Kontrolle selbstverständlich nicht von Hand sondern durch ein Skript ausführen würde, das die OCR-Daten des Kassenbons fräße. Vielleicht bald auf diesem Kanal.

Infos

[1]

Listings zu diesem Artikel: ftp://www.linux-magazin.de/pub/listings/magazin/2016/06/Perl

[2]

"What Every Computer Scientist Should Know About Floating-Point Arithmetic", http://docs.oracle.com/cd/E19957-01/806-3568/ncg_goldberg.html

[3]

"Ebay Developers Program Application Keys", https://developer.ebay.com/my/keys

[4]

API-Dokumentation zu GetAccount, um die monatliche Ebay-Rechnung eines Accounts abzurufen: http://developer.ebay.com/Devzone/xml/docs/Reference/ebay/GetAccount.html

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 der Skriptsprache Perl. Unter mschilli@perlmeister.com beantwortet er gerne Ihre Fragen.