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.
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. |
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.
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.
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 |
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.
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.
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.
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.
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.
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 }
Michael Schilliarbeitet 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. |