E-Baywatch (Linux-Magazin, Januar 2004)

Wer in letzter Sekunde in laufende Versteigerungen eingreifen will, nutzt einen Perl-Agenten, der auf ebay.de fortlaufend nach Stichworten sucht und seinem Mandanten sofort per Instant Message Bescheid gibt, wenn sich eine Auktion dem Ende nähert.

Das heute vorgestellte Skript ebaywatch sendet in regelmäßigen Abständen definierbare Suchanfragen an den Ebay-Versteigerungsserver und wertet die gefundenen Auktionen nach ihrem Enddatum aus. Nähert sich eine Auktion dem Ende, verpackt es eine kurze Beschreibung mit einem fertigen URL in eine IM-Nachricht, und schickt diese über den Jabber-Server an einen gaim-Client ([5]), der sie dem interessierten Benutzer auf den Bildschirm flattern lässt (Abbildung 1). Letzterer kann dann mit einem Mausklick einschreiten und mitsteigern.

In der Datei .ebaywatchrc im Heimatverzeichnis legt der Anwender fest, nach welchen Stichworten der Agent auf der Ebay-Website suchen soll. Jede nicht-auskommentierte Zeile zeigt eine Suchanfrage:

    # ~/.ebaywatchrc
    dwl 650
    nikon

definiert zum Beispiel Anfragen nach der DLink-Netzwerkkarte DWL-650 und nach allen möglichen Produkten der Firma Nikon. Die Suche erfolgt im Titelfeld der laufenden Auktionen und unterstützt mit den in [2] beschriebenen Kürzeln sogar erweiterte Suchfunktionen. So findet zum Beispiel foto -nikon alle Fotoartikel außer Nikon-Produkten oder "beatles (dvd,cd)" alle Beatles-CDs und -DVDs aber keine Vinyl-Schallplatten.

Abbildung 1: Der Perl-Agent meldet pünktlich drei Auktionen, die in wenigen Minuten zuende gehen.

CPAN-Hilfe für Jabber und Ebay

Um automatisch mit den Ebay-Seiten zu kommunizieren, könnte man freilich einen Screen-Scraper schreiben -- aber wie fast immer hat sich schon jemand nach dem Motto ``I wrote code so you don't have to'' (http://www.thinkgeek.com/interests/oreilly/tshirts/6067) daran gemacht: Auf dem CPAN findet sich die Distribution WWW::Search::Ebay von Martin Thurn mit dem Modul WWW::Search::Ebay::ByEndDate, das vorgegebene Suchabfragen absetzt und eintrudelnde Ergebnisse nach dem Enddatum sortiert -- perfekt!

Das ebenfalls vom CPAN erhältlich Net::Jabber-Modul von Ryan Eatmon enthält eine vollständige API, um einen funktionsfähigen Jabber-Client zu schreiben. ebaywatch nutzt nur einen kleinen Teil davon: Es muss im Bedarfsfall nur schnell eine Verbindung zum Jabber-Server jabber.org auf Port 5222 aufnehmen, ihm seine Anwesenheit mitteilen, eine Nachricht an den Mandanten abfeuern und sich dann verabschieden. Wer mehr Funktionalität sucht, der findet sie im Buch des mächtigen Jabberers DJ Adams ([3]).

Jabber erlaubt es sogar, dass man sich mit einem einzigen Benutzernamen von mehreren IM-Clients aus gleichzeitig einloggt. Damit der Server am Ende nicht durcheinanderkommt, qualifiziert man den Benutzernamen noch mit einer sogenannten ``Resource'', einem String, der einen Client vom anderen unterscheidet. Während gaim seinen eigenen Resource-String definiert, wählen wir für das Überwachungsskript einfach den Resource-Namen ebaywatcher. So kommen wir mit nur einem Jabber-Account zurande, den sowohl das sendende Skript als auch die empfangende IM-Applikation nutzen.

Die Konfigurationssektion in ebaywatch definiert in $EBAY_HOST, welchen der vielen internationalen Ebay-Server das Skript kontaktieren soll. Im Listing ist http://search.ebay.de festgelegt. Wer lieber das amerikanische Original will, setzt $EBAY_HOST auf http://search.ebay.com. $MINS_TO_END bestimmt, wieviele Minuten vor Ende einer Auktion die Blitznachricht kommt -- voreingestellt sind 10.

In der Datei $SEEN_DB_FILE legt ebaywatch einen persistenten Hash ab, um Statusinformationen zwischen verschiedenen Aufrufen zu sichern. In Zeile 28 bindet der tie-Befehl den globalen Hash %SEEN mittels des DB_File-Moduls an die konfigurierte Datei. Die Optionen O_RDWR und O_CREAT sichern Schreib/Leserechte und lassen tie() die Datei anlegen, falls diese noch nicht existiert. Zeile 32 mit dem untie-Aufruf in der END-Klausel sorgt dafür, dass der Hash sich auch wieder ordnungsgemäß von der Datei abkoppelt, falls das Programm abbricht.

Was Programme wie ebaywatch treiben, die im Hintergrund laufen, erfährt man am sichersten über eine Logdatei, in die die Funktionen DEBUG(), INFO(), LOGDIE() etc. aus dem Log::Log4perl-Fundus schreiben. Im Listing wurde /tmp/ebaywatch.log als Logfile gewählt. Weitere Hinweise zur Konfiguration des Logsystems finden sich im Abschnitt ``Installation''.

Die Konstruktion des Ebay-Objektes in Zeile 34 ist etwas ungewöhnlich, da sie über die new-Methode der WWW::Search-Klasse mit dem String Ebay::ByEndDate als Parameter erfolgt. Die while-Schleife ab Zeile 39 iteriert durch die Zeilen der Datei ~/.ebaywatchrc, eliminiert Kommentare und Leerzeilen und merkt sich den jeweils geforderten Suchausdruck in $term.

Der persistente Hash %SEEN speichert unter den Schlüsseln "url/$url" URLs von Auktionen, die ebaywatch schon gemeldet hat und deswegen nicht nochmal senden möchte.

Und dann gibt es Suchbegriffe aus ~/.ebaywatchrc, für die Ebay nur Auktionen lieferte, deren Enddatum so weit in der Zukunft lag, dass sich eine Anfrage an Ebay für einige Zeit nicht mehr lohnt. Schließlich wollen wir nicht die Ebay-Leute verärgern, indem wir alle 5 Minuten Abfragen schicken, von denen bereits klar ist, dass sie keine neuen Informationen liefern. ebaywatch speichert diese Suchbegriffe unter den Schlüsseln "notuntil/$term" und legt als zugehörigen Wert die lokale Unix-Zeit der nächsten Abfrage fest.

Zeile 58 wandelt Sonderzeichen in URL-kompatible Sequenzen um und Zeile 60 bereitet den gesamten URL der Suchanfrage nach den Ebay-Richtlinien vor. Die while-Schleife ab Zeile 63 holt mit der next_result()-Methode Ergebnisse von Ebay ein, folgende Methoden liefern wichtige Auktions-Informationen:

url()
URL zur Auktion

title()
Kurzbeschreibung

description()
Auktions-Nummer, Anzahl der Gebote, Aktuelles Gebot

change_date()
Verbleibende Zeit (z.B. 2T 02Std 29Min oder 2d 02h 29m)

Wegen der unterschiedlichen Zeit-Anzeige bei change_date() zwischen amerikanischem und deutschem Format wandelt die ab Zeile 99 definierte Funktion minutes() beide Darstellungen einfach über eine simple Mustererkennung mit regulären Ausdrücken in Minuten um.

In der Ergebnisschleife erscheinen die zeitlich nächstgelegenen Auktionen immer zuerst - das garantiert WWW::Search::Ebay::ByEndDate. Wenn also Zeile 77 feststellt, dass die nächste Auktion mehr als 10 Minuten in der Zukunft liegt, und sie die nächste Untersuchung 10 Minuten vor Ende dieser Auktion anberaumt, kann sie getrost alle späteren Auktionen vergessen und mit last die Schleife abbrechen. In diesem Fall zieht sie 10 Minuten von der Endzeit der Auktion ab, wandelt diesen Wert in die lokale Unix-Uhrzeit in Sekunden um und speichert diese unter "notuntil/$term" im permanenten Hash ab, wobei sie $term mit dem angegebenen Suchausdruck ersetzt.

Steht der Ergebniszähler $hits am Ende der Schleife immer noch auf 0, fanden sich zum Suchbegriff keine Treffer und die nächste Untersuchung des Begriffs verschiebt sich um einen Tag in die Zukunft.

Listing 1: ebaywatch

    001 #!/usr/bin/perl
    002 ###########################################
    003 # ebaywatch
    004 # Mike Schilli, 2003 (m@perlmeister.com)
    005 ###########################################
    006 use warnings;
    007 use strict;
    008 
    009 our $JABBER_ID      = "mikes-ebay-watcher";
    010 our $JABBER_PASSWD  = "*******";
    011 our $JABBER_SERVER  = "jabber.org";
    012 our $JABBER_PORT    = 5222;
    013 our $SEEN_DB_FILE   = "/tmp/ebaywatch";
    014 our $MINS_TO_END    = 10;
    015 our $RC_FILE   = "$ENV{HOME}/.ebaywatchrc";
    016 our $EBAY_HOST = "http://search.ebay.de";
    017 our %SEEN;
    018 
    019 use Net::Jabber qw(Client);
    020 use DB_File;
    021 use Log::Log4perl qw(:easy);
    022 use WWW::Search::Ebay;
    023 
    024 Log::Log4perl->easy_init(
    025   { level => $DEBUG, 
    026     file => ">>/tmp/ebaywatch.log" });
    027 
    028 tie %SEEN, 'DB_File', $SEEN_DB_FILE, 
    029     O_CREAT|O_RDWR, 0755 or 
    030     LOGDIE "tie: $SEEN_DB_FILE ($!)";
    031 
    032 END { untie %SEEN }
    033 
    034 my $search = WWW::Search->new(
    035                         'Ebay::ByEndDate');
    036 open FILE, "<$RC_FILE" or 
    037     LOGDIE "Cannot open $RC_FILE";
    038 
    039 while(<FILE>) {
    040     # Discard comment and empty lines
    041   s/^\s*#.*//;
    042   next if /^\s*$/;
    043   chomp;
    044 
    045   my $term = $_;
    046   my $hits = 0;
    047 
    048   if(exists $SEEN{"notuntil/$term"} and
    049      time() < $SEEN{"notuntil/$term"}) {
    050     DEBUG "Not checking '$term' until ",
    051           scalar localtime 
    052           $SEEN{"notuntil/$term"};
    053     next;
    054   }
    055 
    056   DEBUG "Searching for '$term'";
    057 
    058   my $q = WWW::Search::escape_query($term);
    059 
    060   $search->native_query($q, 
    061       { ebay_host => $EBAY_HOST } );
    062 
    063   while (my $r = $search->next_result()) {
    064     $hits++;
    065     DEBUG "Result: ", $r->url(),
    066           " ", $r->title(), 
    067           " ", $r->description(), 
    068           " ", $r->change_date();
    069 
    070     if($SEEN{"url/" . $r->url()}) {
    071       DEBUG "Already notified";
    072       next;
    073     }
    074 
    075     my $mins = minutes($r->change_date());
    076     
    077     if($mins > $MINS_TO_END) {
    078       $SEEN{"notuntil/$term"} = 
    079         time + ($mins - $MINS_TO_END) * 60;
    080       last;
    081     }
    082 
    083     INFO "Notify for ", $r->description;
    084     $SEEN{"url/" . $r->url()}++;
    085 
    086     my $msg = "<A HREF=" . $r->url() . 
    087       ">" . $r->title() .  "</A> " .
    088       "(${mins}m) " .  $r->description;
    089 
    090     $msg =~ s/[^[:print:]]//g;
    091     jabber_send($msg);
    092   }
    093       # Pause for 1 day on no results
    094   $SEEN{"notuntil/$term"} = 
    095      time + 24*3600 unless $hits;
    096 }
    097 
    098 ###########################################
    099 sub minutes {
    100 ###########################################
    101     my($s) = @_;
    102 
    103     my $min = 0;
    104 
    105     $min += 60*24*$1 if $s =~ /(\d+)[dT]/;
    106     $min += 60*$1 if $s =~ /(\d+)[hS]/;
    107     $min += $1 if $s =~ /(\d+)[mM]/;
    108 
    109     return $min;
    110 }
    111 
    112 ###########################################
    113 sub jabber_send {
    114 ###########################################
    115     my($message) = @_;
    116 
    117     my $c = Net::Jabber::Client->new();
    118 
    119     $c->SetCallBacks(presence => sub {});
    120 
    121     my $status = $c->Connect(
    122         hostname => $JABBER_SERVER,
    123         port     => $JABBER_PORT,
    124     );
    125 
    126     LOGDIE "Can't connect: $!" 
    127         unless defined $status;
    128 
    129     my @result = $c->AuthSend(
    130         username => $JABBER_ID,
    131         password => $JABBER_PASSWD,
    132         resource => 'ebaywatcher',
    133     );
    134 
    135     LOGDIE "Can't log in: $!" 
    136         unless $result[0] eq "ok";
    137 
    138     $c->PresenceSend();
    139 
    140     my $m = Net::Jabber::Message->new();
    141     my $jid = "$JABBER_ID" . '@' .
    142               "$JABBER_SERVER/GAIM";
    143     $m->SetBody($message);
    144     $m->SetTo($jid);
    145     DEBUG "Jabber to $jid: $message";
    146     my $rc = $c->Send($m, 1);
    147 
    148     $c->Disconnect;
    149 }

Um eine Jabber-Nachricht abzufeuern, baut ebaywatch in Zeile 86 den HTML-Code für einen Link und die verfügbaren Auktions-Informationen zusammen und eliminiert in Zeile 90 mit der [:print:]-Klasse in einem regulären Ausdruck alle nicht-druckbaren Zeichen. Die Funktion jabber_send() nimmt dann die String-Nachricht als Parameter entgegen und kreiert ein neues Net::Jabber::Client-Objekt. Nach einer erfolgreichen Kontaktaufnahme mit jabber.org via Connect() sendet der Client seinen Benutzernamen und das Passwort für den im Abschnitt ``Jabber-Geschnatter'' neuangelegten Account. Den resource-Parameter setzt ebaywatch auf ebaywatch, wie oben besprochen. Zeile 138 teilt dem Jabber-Server die Anwesenheit des Skript-Clients mit, die korrespondierende Callback-Funktion für andere Clients wurde vorher in Zeile 119 schon mit

    $c->SetCallBacks(
        presence => sub {}
    );

auf ``Ignorieren'' gesetzt. Die Jabber-ID des Mandanten setzt sich zusammen aus dem Benutzernamen und dem als @jabber.org angehängten Jabber-Server. Die Send-Methode schickt die als Net::Jabber::Message-Objekt verkleidete Nachricht dann an den Server, der sie auch dann entgegennimmt, wenn der Mandant gar nicht Online ist. Und wichtig: Das hintenangehängte /GAIM bestimmt, dass SendTo() die Nachricht nicht an den Jabber-Client im Skript ebaywatch schickt (der bekanntlich unter der Resource ebaywatch eingeloggt ist), sondern an den unter derselben BenutzerID (im Beispiel mikes-ebay-watcher) eingeloggten gaim-Client, der automatisch den Resource-Namen GAIM definiert.

Installation

Wie üblich sind die geforderten Zusatzmodule WWW::Search::Ebay, Net::Jabber und Log::Log4perl über eine CPAN-Shell zu installieren:

    perl -MCPAN -eshell
    cpan> install WWW::Search::Ebay
    cpan> install Net::Jabber
    cpan> install Log::Log4perl

Die ersten beiden fordern weitere Module vom CPAN an. Glücklich ist, wer die CPAN-Shell-Option prerequisites_policy auf follow setzte und nicht den Defaulwert ask beibehalten hat.

Je nachdem, wie detailliert Log4perl die Ereignisse in der Logdatei protokollieren soll, kann man den Loglevel in Zeile 25 entweder bei $DEBUG belassen, oder auf $INFO heraufsetzen oder gar auf $ERROR, wenn nur schwere Fehler erscheinen sollen. Wer Angst hat, dass die Logdatei zu lang wird und die Platte überläuft, kann die Log::Log4perl-Konfiguration mit einem RollingFileAppender erweitern, der Dateien nur bis zu einer vordefinierten Größe vollschreibt, eine einstellbare maximale Zahl von Dateien anlegt und die erste wieder überschreibt, wenn die Maximalzahl erreicht ist ([4]).

Jabber-Geschnatter

Einen Jabber-Account legt man am einfachsten mit gaim an, einem anpassungsfähigen IM-Client, der alle gängigen Instant-Message-Protokolle zugleich spricht. Unter [5] gibt's das fabelhafte Teil zum Herunterladen. Auch Jabber beherrscht es, wenn man im Menüpunkt Tools/Plugins die jabberlib.so auswählt und hinzulädt (Abbildung 2).

Abbildung 2: gaim versteht das Jabber-Protokoll, wenn libjabber geladen wurde.

Dann schnell unter Tools->Accounts auf New Account geklickt und das Formular nach Abbildung 3 ausgefüllt -- schon legt gaim nicht nur einen neuen Account auf dem Jabber-Server jabber.org an, sondern konfiguriert den Client auch noch so, das er sich nach einem Neustart automatisch mit dem gemerkten Passwort einloggt.

Abbildung 3: gaim legt einen neuen Jabber-Account an

Wenn das Skript von der Kommandozeile aus einwandfrei läuft (ein tail -f auf die Logdatei zeigt, was abgeht), wird der Cronjob folgendermaßen aufgesetzt:

    05,10,15,20,25,30,35,40,45,50,55 * * * * /home/mschilli/bin/ebaywatch

Dies startet das Skript alle fünf Minuten, aber entsprechend der implementierten Vorsichtsmaßnahmen wird es sich meist sofort wieder schlafen legen, wenn es einmal festgestellt hat, dass die nächste relevante Auktion erst nach einiger Zeit endet.

Wer eh den ganzen Tag über IM-Messages kommuniziert, wird sich über ein paar zusätzliche Nachrichten freuen, die ein ``virtueller'' Freund schickt. Und dann einfach draufgeklickt und mitgesteigert!

Infos

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

[2]
David A. Karp, 'eBay Hacks: 100 Industrial-Strength Tips and Tools', O'Reilly 2003, ISBN 0596005644

[3]
DJ Adams, ``Programming Jabber'', O'Reilly 2002, ISBN 0596002025

[4]
Logdateien automatisch begrenzen und überschwenken: http://log4perl.sourceforge.net/releases/Log-Log4perl/docs/html/Log/Log4perl/FAQ.html#how_can_i_roll_over_my_logfiles_automatically_at_midnight

[5]
Gaim Homepage: http://gaim.sourceforge.net

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.