Konsumsklave (Linux-Magazin, Mai 2008)

Schnäppchenjäger lieben Preisstürze und lassen sich darüber auch gerne per Email informieren. Ein Perlskript verfolgt die Preisentwicklung auf Amazon und schlägt Alarm, falls überwachte Produkte plötzlich billiger werden.

``Soll ich die Digitalkamera, die ich mir so sehnlichst wünsche, heute kaufen? Oder lieber noch etwas warten, bis der Preis fällt?'' Diese Frage ist nur schwer zu beantworten, aber ein Blick auf die Preisentwicklung der letzten Monate gibt vielleicht Aufschluss darüber, wie sich der Preis eines Produkts weiterentwickeln wird.

Eigentlich sollte Amazon ja genau wie die großen Finanzseiten auf dem Internet die historische Preisentwicklung der angebotenen Waren wie Aktienkurse darstellen. Dann könnte man sich schön ärgern, wenn man ein Angebot verpasst hat oder auf einen weiteren Tiefpunkt warten, in der Hoffnung, dass sich ein einmal ereigneter Preissturz wiederholt.

Preise sind im Kasten

Das heute vorgestellte Skript amtrack liest eine Konfigurationsdatei ~/.amtrack-rc nach Abbildung 1 aus, um herauszufinden, für welche Artikel der Benutzer sich interessiert. Ein Cronjob ruft das Skript in regelmäßigen Abständen auf und jedes Mal verbindet es sich mit dem Amazon-Webservice, fragt die aktuellen Preise aller konfigurierten Artikel ab und speichert sie in einer lokal angelegten SQLite-Datenbank.

Abbildung 1: Die Konfigurationsdatei ~/.amtrack-rc, die alle zu überwachenden Produkte samt ihren ASIN-Nummern auflistet.

Sinkt der Preis eines Artikels, schickt es eine Email mit der Produkt-URL und dem aktuellen Preis an eine in Zeile 79 voreingestellte Adresse. Der erfreute Schnäppchenjäger braucht dann nur noch in seinem Emailclient auf den angegebenen URL zu klicken, um im dann aufgehenden Browserfenster einen letzten Blick auf das Produkt zu werfen und anschließend womöglich gleich einen Kauf zu tätigen.

Da die Preise in einer Datenbank liegen, kann das Skript selbst historische Daten ohne weiteres abfragen und darstellen. Der Aufruf amtrack -l liefert, wie Abbildung 2 zeigt, die zuletzt eingeholten Preise aller überwachten Produkte. Wenn der gesamte Inhalt der Datenbank interessiert, gibt ihn die Option -a aus, die Ausgabe kann sich bei einer schon länger laufenden Installation allerdings etwas in die Länge ziehen.

Ganz ohne Optionen aufgerufen, arbeitet sich amtrack durch die in der Konfigurationsdatei ~/.amtrack-rc definierten Produktkürzel und frischt die Datenbank mit den aktuellsten Preisen auf.

Abbildung 2: Mit der Option -l aufgerufen, listet amtrack die letzten eingeholten Preise aller zu überwachenden Produkte auf.

Wunschzettel

Die Konfigurationsdatei besteht aus zwei Spalten: In der ersten steht die ASIN-Nummer des gewünschten Produkts und rechts, durch ein oder mehrere Leerzeichen getrennt eine kurze Produktbeschreibung. Diese ist für die Datenbank unwesentlich, sie dient nur zur übersichtlichen Ausgabe der Skripts und findet in eventuell ausgesandten Emails Verwendung. Kommentarzeilen fangen mit '#' an und werden genau wie Leerzeilen ignoriert. Die Funktion config_read() ab Zeile 92 liest die vom User gepflegten Zeilen ein und gibt zwei Referenzen zurück: Eine auf einen Array @config und eine auf einen Hash %config. Während ersterer Wertepaare aus ASIN und Text enthält, weist letzterer ASINs den zugehörigen Text zu.

Legal bei Amazon wühlen

Das CPAN-Modul Net::Amazon legt eine objektorientierte Schnittstelle über Amazons REST-basierten Webservice. Man gibt einfach die sogenannte ASIN-Nummer eines Produkts an und das Modul kontaktiert Amazon und holt den Preis ein. Amazon hat ja ursprünglich nur Bücher vertrieben, und die lassen sich ja bekanntlich durch eine ISBN-Nummer eindeutig identifizieren. Für Rest seines Warenbestandes erfand Amazon dann die ASIN, die ähnlich aufgebaut ist, aber auch Buchstaben zulässt und demnach weit mehr Artikel umfasst.

Ein Request der Klasse Net::Amazon::Request::ASIN nimmt als Parameter die ASIN eines gesuchten Artikels entgegen. Die Methode request() der Klasse Net::Amazon nimmt einen solchen Request entgegen, wickelt die Kommunikation mit dem Amazon-Webserver ab und liefert ein Object der Klasse Net::Amazon::Response::ASIN zurück. Dessen is_success() zeigt an, ob alles klar ging oder ob ein Fehler aufgetreten ist. Im Erfolgsfall gibt die Methode properties() ein Objekt der Klasse Net::Amazon::Property zurück, das das gefundene Produkt mit Produktbeschreibung, Verbraucherbewertungen, Abbildungs-URLs, vielem anderen mehr, und schließlich seinem Preis darstellt. Amazon bietet auch andere Sucharten an (z.B. nach Autor), dann kann properties() schon mal mehrere Einträge liefern. Die Methode OurPrice() einer 'Property' gibt den aktuellen Preis eines Produkts im Format ``$ X.XX'' oder ``EUR X,XX'' (für das deutsche Locale, siehe unten) an.

Historischer Cache

Das Skript nutzt das CPAN-Modul Cache::Historical. Dieser 'historische' Cache speichert Daten nicht nur unter einem Primärschlüssel ab wie ein 'normaler' Cache, sondern nutzt als Sekundärschlüssel auch noch ein ebenfalls eingespeistes Datum. Das Skript legt die eingeholten Produktpreise unter der ASIN als Primärschlüssel ab und auch das Einholdatum wandert in den Cache. Hinter den Kulissen von Cache::Historical arbeitet die dateibasierte Datenbank SQLite, die das Modul als Requisit auflistet und die gleich mitinstalliert wird, falls eine CPAN-Shell die Installation vornimmt. Der Parameter sqlite_file des Konstruktors new() legt mit ~/.amzn-tracker-sqlite die Datei fest, in der die Datenbank landet. Wer möchte, kann nach ein paar Testläufen die SQLite-Datenbank mit dem Clientprogramm sqlite3 abfragen und die Daten wie in Abbildung 3 gezeigt auflisten.

Die Methode get_interpolated() des Caches holt zu einem angegebenen Datum (im Skript das aktuelle) und einem vorliegenden Key (der ASIN eines Produkts) den neuesten Datenbankwert ein. Diesen legt das Skript in der Variablen $last_price ab und frischt dann die Datenbank mit dem neuesten Wert von der Amazon-Website auf. Letzteren holt sie sofort wieder aus der Datenbank und vergleicht ihn mit dem vorher in $last_price gespeicherten.

Die Methode values() hingegen lieferte eine Liste von Wertepaaren passend zu einem vorgegebenen Schlüssel. Jedes paar ist eine Referenz auf einen Array, der das Datum als DateTime-Objekt und den eigentlichen Wert enthält.

Abbildung 3: Die SQLite-Datenbank lässt sich auch mit dem Kommandozeilen-Client C abfragen.

Tu was ich meine

Ist der aktuelle Preis eines untersuchten Produkts niedriger als der letzte in der Datenbank gespeicherte, schickt das Skript in Zeile 78 eine Email ab. Es gibt zwar eine ganze Reihe von CPAN-Modulen, die sich mit dem Abschicken von Email beschäftigen, aber Mail::DWIM (``Do-What-I-Mean'') ist eines der einfachsten. Es exportiert die Funktion mail, die als Parameter einen Empfänger, eine Betreffzeile und den Mailtext entgegennimmt. Für die restlichen Parameter wie Absender oder den Mailserver setzt es meist sinnvolle Defaults (im vorliegenden Fall den aktiven User plus die eingestellte Domain, bzw. den aktiven Sendmail-Daemon). Für einen anderen Mail-Transportmechanismus lässt es auch ``smtp'' mit Angabe eines Mailhosts zu. Die Steuerung dieser Defaults erfolgt über Parameter in einer lokalen .maildwim-Datei. Einzelheiten hierzu zeigt die Mail::DWIM-Manpage.

Abbildung 4: Eine Email trifft ein und meldet, dass der Preis des gesuchten 'Roomba' [4] um 10 Dollar gefallen ist.

Die Email enthält den URL zum Produkt, der sich einfach durch Anhängen von /dp/$asin an die Basis-URL der Amazon-Website ergibt. Für den deutschen Amazon ist http://amazon.com durch amazon.de zu ersetzen.

Loggen Professionell

Abbildung 5: Die Log4perl-Konfiguration des Skripts.

Damit der Anwender weiß, was das Skript treibt, loggt dieses alle Aktivitäten mit Log4perl. Die Datei amtrack.l4p, die Log4perl initialisiert, liegt im selben Verzeichnis wie das Skript selbst (Abbildung 5). Damit das Skript die Konfigurationsdatei auch dann findet, falls es aus einem anderen Pfad heraus aufgerufen wurde (z.B. mit bin/amtrack oder einfach nur amtrack aus dem Home-Directory), hilft das Modul FindBin mit der exportierten Variable $Bin. Sie gibt an, in welchem Verzeichnis das aufgerufene Skript liegt, egal, von wo es tatsächlich aufgerufen wurde. Und darum gibt dann auch $Bin/amtrack.l4p den absoluten Pfad zur Log4perl-Konfiguration, die im gleichen Verzeichnis liegt.

Abbildung 6: Ausschnitt aus der Logdatei nach einem erfolgreichen Skriptlauf.

Die Log4perl-Konfiguration ist nicht ganz ohne, denn das Skript soll reguläre Aktivitäten in eine Logdatei schreiben, aber Fehler auf die Konsole bringen. So schreibt ein regelmäßig aufgerufener Cronjob unauffällig die Logdatei weiter (Defaultmodus ist 'append'), aber Fehler wie eine nicht zustande gekommene Netzverbindung zu Amazon gehen nach STDERR und veranlassen damit als Nebeneffekt den aufrufenden Cron, eine Email an den Administrator zu senden.

Aktiv ist nur ein einziger Logger, nur die Kategorie ``main'' (also das Hauptprogram) ist definiert. Net::Amazon ist ebenfalls Log4perl-enabled, ein weiterer Eintrag in der Konfigurationsdatei brächte ruckzuck Details über die Kommunikation mit Amazons Webserver auf den Schirm. Der Logger der Kategorie 'main' steuert zwei Appender an, ``Logfile'' und ``Screen'', und damit ``Screen'' nur Meldungen mit der Priorität ERROR und höher erhält, setzt

    log4perl.appender.Screen.Threshold = ERROR

diesen Schwellenwert in der Appenderdefinition. Wer sich näher mit dem Log4perl-Framework auseinander setzen möchte, der sei auf die Log4perl-Homepage ([3]) verwiesen, die ausführliche Dokumentation und eine FAQ mit gängigen Konfigurationsbeispielen bietet.

Nicht ohne meinen Token

Amazon verlangt von Skripts, die in seinen Daten wühlen, einen Token, den man kostenlos und schnell erhält, wenn man sich unter [2] einschreibt und die Bedingungen akzeptiert. Nach Erhalt ist YOUR_AMZN_TOKEN in Zeile 23 durch den richtigen Token zu ersetzen.

Das Skript funktioniert sowohl mit der Website der amerikanischen Konzernmutter als auch mit der deutschen Niederlassung. Für letztere muss locale => 'de' in Zeile 24 auskommentiert werden. Die Preise erscheinen dann zwar im Format ``EUR X,XX'', werden aber von der Funktion fix_price ordentlich in Fließkommawerte umgewandelt, die sich problemlos numerisch vergleichen lassen. Da amerikanische Ziffern neben einem Punkt als Fließkomma auch noch Kommata verwenden, um Tausender abzutrennen, verwirft fix_price() einfach alles außer den Ziffern und setzt dann ein Fließkomma vor die letzten zwei Ziffern.

Installation

Die oben im Skript angegebenen CPAN-Module installiert eine CPAN-Shell und löst auch gleich alle Abhängigkeiten auf. Ein crontab-Eintrag der Form

    23 */6 * * * /path/to/amtrack

ruft das Skript dann viermal täglich (zu jeder durch 6 teilbaren Stunde), 23 Minuten nach der vollen Stunde auf. Dies dürfte genügen, um schnäppchentechnisch auf dem Laufenden zu bleiben. Das Modul Net::Amazon sorgt selbst dafür, dass das Skript die Nutzungsbedingungen von Amazon einhält und maßregelt das Skript, falls dieses die Preise in zu kurzen Abständen abfragt.

Erweitern

Einige Erweiterungsvorschläge für das Skript: Jedem Preis könnte man in der Konfigurationsdatei ein Limit zuordnen, und nur falls der aktuelle Preis dieses unterschreitet, schickt das Skript die Email ab. Eine weitere Anwendung wäre die grafische Darstellung der Preisgestaltung über einen längeren Zeitraum hinweg, die CPAN-Module RRDTool::OO oder Imager::Plot bieten sich hierzu an.

Listing 1: amtrack

    001 #!/usr/bin/perl -w
    002 use strict;
    003 use Getopt::Std;
    004 use Net::Amazon;
    005 use Net::Amazon::Request::ASIN;
    006 use Log::Log4perl qw(:easy);
    007 use Cache::Historical 0.02;
    008 use DateTime;
    009 use Mail::DWIM qw(mail);
    010 use FindBin qw($Bin);
    011 
    012 my ($home)  = glob "~";
    013 my $amzn_rc = "$home/.amtrack-rc";
    014 
    015 Log::Log4perl->init("$Bin/amtrack.l4p");
    016 
    017 my $cache = Cache::Historical->new(
    018   sqlite_file => 
    019       "$home/.amtrack-sqlite"
    020 );
    021 
    022 my $UA = Net::Amazon->new(
    023   token  => 'YOUR_AMZN_TOKEN',
    024   # locale => 'de',
    025 );
    026 
    027 my($config, $txt_by_asin) = config_read();
    028 
    029 getopts("al", \my %opts);
    030 
    031 if($opts{l} or $opts{a}) {
    032   for my $key (sort keys %$txt_by_asin) {
    033     my $txt = $txt_by_asin->{$key};
    034     for my $val ($cache->values( $key )) {
    035       my($dt, $price) = @$val;
    036       print "$dt $txt $price\n";
    037       last if $opts{l};
    038     }
    039   }
    040 } else {
    041   update($config);
    042 }
    043 
    044 ###########################################
    045 sub fix_price {
    046 ###########################################
    047     my($price) = @_;
    048 
    049     if(defined $price) {
    050         $price =~ s/[^\d]//g;
    051         $price =~ s/..$/.$&/g;
    052     }
    053     return $price;
    054 }
    055 
    056 ###########################################
    057 sub update  {
    058 ###########################################
    059   my($config) = @_;
    060 
    061   for my $line (@$config) {
    062 
    063     my($asin, $txt) = @$line;
    064     my $now = DateTime->now();
    065 
    066     my $last_price = fix_price($cache->
    067             get_interpolated($now, $asin));
    068 
    069     track($asin, $txt, $cache);
    070 
    071     my $price_now = fix_price($cache->
    072             get_interpolated($now, $asin));
    073 
    074     if(defined $last_price and 
    075        defined $price_now) {
    076 
    077       if( $price_now < $last_price) {
    078         mail(
    079           to      => 'foo@bar.com',
    080           subject => "[amtrack] " .
    081            "$txt cheaper ($price_now < " .
    082            "$last_price)",
    083           text    => "URL: " .
    084               "http://amazon.com/dp/$asin",
    085         );
    086       }
    087     }
    088   }
    089 }
    090 
    091 ###########################################
    092 sub config_read {
    093 ###########################################
    094 
    095     my @config = ();
    096     my %config = ();
    097 
    098     open AMZNRC, "$amzn_rc" or 
    099         die "Cannot open $amzn_rc";
    100     while(<AMZNRC>) {
    101         s/#.*//;
    102         next if /^\s*$/;
    103         chomp;
    104         my($asin, $txt) = split ' ', $_, 2;
    105         push @config, [$asin, $txt];
    106         $config{ $asin } = $txt;
    107     }
    108     close AMZNRC;
    109 
    110     return \@config, \%config;
    111 }
    112 
    113 ###########################################
    114 sub track {
    115 ###########################################
    116   my($asin, $txt, $cache) = @_;
    117 
    118   INFO "Tracking asin $asin";
    119 
    120   my $req = 
    121       Net::Amazon::Request::ASIN->new(
    122         asin  => $asin);
    123 
    124   my $resp = $UA->request($req);
    125 
    126   if($resp->is_success()) {
    127     my($prop) = $resp->properties();
    128     my $price = $prop->OurPrice();
    129     INFO "Tracking $asin ",
    130          "($txt): $price";
    131     $cache->set(DateTime->now(), 
    132                 $asin, $price) if $price;
    133   } else {
    134     ERROR "Can't fetch asin $asin: ",
    135           $resp->message();
    136   }
    137 }

Infos

[1]
Listings zu diesem Artikel: ftp://www.linux-magazin.de/pub/listings/magazin/2008/04/Perl

[2]
``Amazon Web Services'', Token erhältlich unter http://www.amazon.com/soap

[3]
Log4perl Homepage, http://log4perl.com

[4]
Der Staubsaugroboter ``Roomba'', http://usarundbrief.com/59/p6.html

Michael Schilli

arbeitet als Software-Engineer bei Yahoo! in Sunnyvale, Kalifornien. Er hat "Goto Perl 5" (deutsch) und "Perl Power" (englisch) für Addison-Wesley geschrieben und ist unter mschilli@perlmeister.com zu erreichen. Seine Homepage: http://perlmeister.com.