Drahtlos auf Kurs (Linux-Magazin, November 2000)

Wer wollte nicht schon einmal auf einer Bergwanderung seine Aktienkurse abfragen? Das heutige Skript kommuniziert per E-Mail mit einem 2-Way-Pager.

Eigentlich war ich ja gegenüber diesem ganzen drahtlosen Gesummse eher skeptisch eingestellt. Aber seit man mir von Firmenseite einen Pager angedreht hat, schleppe ich ihn auch brav mit mir herum. Das Teil kann E-Mails empfangen und senden -- und so dachte ich mir: Warum nicht das Angenehme mit dem Nützlichen verbinden?

Aktien über den Äther

Ein Aktienkursservice wär's doch: Der Pager schickt eine E-Mail mit dem Kürzel der gesuchten Aktie im Mailrumpf los. Der Aktienkursservice, der auf einem Server mit Internetanschluss als Perlskript läuft, fängt die Mail ab, extrahiert das Kürzel und schickt, genau wie ein Browser, einen Web-Request an die Yahoo-Finanzseite aus. Aus dem zurückkommenden HTML extrahiert das Skript anschließend den Aktienkurs als relevante Information -- und schickt sie an den Absender der E-Mail, den Pager, zurück. Na, da weiß selbst der Almödi, was an den Börsen der Welt abgeht, wenn der Pager heftig in der Hosentasche vibriert.

Abbildung 1: Der kleine 2-Way-Pager

Sendet also der 2-Way-Pager etwa eine E-Mail mit dem Inhalt "aol" an die Adresse benutzer@irgendwo.com, sorgt dort eine .forward-Datei im Home-Verzeichnis des Benutzers benutzer dafür, dass der Sendmail-Dämon die Mail nicht in die normale Eingangsschlange einreiht, sondern einem Skript nach Listing getquote.pl zu Fressen gibt. Dieses extrahiert das Kürzel der gesuchten Aktie, kontaktiert de.finance.yahoo.com, tippt den Namen in das Suchfeld in Abbildung 2, stellt am Auswahlschalter den Börsenplatz Frankfurt ein und simuliert schließlich einen gedrückten ``Kurse abfragen''-SubmitSubmit-Knopf. Es liest die zurückkommende HTML-Seite und fieselt den gesuchten Kurs der AOL-Aktie aus einer der Tabellen, die Yahoo im Ergebnis nach Abbildung 3 unterbringt. Aus der ursprünglich empfangenen Email geht entweder aus dem Reply-To: oder From:-Header hervor, wer sie abschickte, und an genau diese Adresse schickt das Skript anschließend die extrahierte Information ab.

Abbildung 2: Das Eingabeformular des Yahoo-Aktienservice

Abbildung 3: Die Antwort des Yahoo-Aktienservice

Viel Leistung -- wenig Arbeit!

Das klingt nach verhältnismäßig viel Arbeit, geht aber wie geschmiert, weil einige schlaue Module vom CPAN die Hauptarbeit erledigen -- nur 69 Zeilen hat das gesamte Skript!

Die Zeilen 3 und 4 definieren zwei Parameter: $MAIL_INPUT gibt an, woher die ankommende Email stammt. Legt $MAIL_INPUT den Namen einer Datei fest, liest das Skript diese ein, was besonders zum Testen des Skripts nützlich ist. Unter Produktionsbedingungen steht $MAIL_INPUT selbstverständlich auf "-", was den open-Befehl in Zeile 15 dazu veranlasst, auf der Standardeingabe zu lauschen, wo der sendmail-Dämon die E-Mail auch hinleiten wird. Der andere Parameter, $MAIL_HOST, gibt den Server des Internetproviders für ausgehende Mails an, der später dazu verwendet wird, Antworten auf Anfragen zurückzuschicken.

Zeile 6 weist das Skript an, streng zu sein und darauf zu achten, dass nicht unabsichtlich globale Variablen, symbolische Referenzen oder sonstige schmutzige Tricks einfließen, die Perl in seiner unendlichen Güte sonst durchgehen lässt. Zeile 7 schaltet den Warnungsmodus ein, der vor Perl 5.6 noch mit dem Kommandozeilenschalter -w aktiviert werden musste.

Die Zeilen 9 bis 12 ziehen die praktischen Module vom CPAN herein, die die Arbeit erledigen: Mail::Internet von Graham Barr erleichtert das Analysieren und Schreiben von Email in Perl. LWP::UserAgent und HTTP::Request::Common von Gisle Aas wickeln den Web-Verkehr ab und HTML::TableExtract von Matt Sisk ist ein neuer Parser zum komfortablen Durchforsten von HTML-Texten nach Tabellen mit bestimmten Kriterien.

Listing 1: getquote.pl

    01 #!/usr/bin/perl
    02 
    03 my $MAIL_INPUT = "-";
    04 my $MAIL_HOST  = "mail.subbrhosting.net";
    05 
    06 use strict;
    07 use warnings;
    08 
    09 use Mail::Internet;
    10 use LWP::UserAgent;
    11 use HTTP::Request::Common;
    12 use HTML::TableExtract;
    13 
    14     # Mail einlesen
    15 open MAILIN, "<$MAIL_INPUT" or 
    16      die "Cannot open $MAIL_INPUT";
    17 my @data = <MAILIN>;
    18 close MAILIN;
    19 
    20     # Mail analysieren
    21 my $mail  = Mail::Internet->new(\@data);
    22 my $stock = $mail->body()->[0];
    23 chomp $stock;
    24 
    25     # Antwort zusammenbasteln
    26 my $reply = $mail->reply();
    27 my $quote = get_quote($stock);
    28 $reply->body( [$quote] );
    29 
    30     # ... und absenden
    31 $reply->smtpsend(Host => $MAIL_HOST) or 
    32     die "Reply mail failed";
    33 
    34 ##################################################
    35 sub get_quote {
    36 ##################################################
    37     my $stock = shift;
    38 
    39     my $URL = "http://de.finance.yahoo.com/q?m=F&" . 
    40               "s=$stock" . "&d=v1";
    41 
    42     my $result = "";
    43 
    44     my $ua = LWP::UserAgent->new();
    45     my $resp = $ua->request(GET $URL);
    46 
    47     if($resp->is_error()) {
    48             # Fehler beim Holen der Webseite?
    49         my $error = sprintf "Error: %s", 
    50                             $resp->message();
    51         return $error;
    52     }
    53 
    54     my $data = $resp->content();
    55 
    56     my $te = new HTML::TableExtract( depth => 0, 
    57                                      count => 4 );
    58     $te->parse($data);
    59 
    60         # Alle Tabellenreihen zusammen darstellen
    61     my @rows = $te->rows;
    62     shift @rows;
    63     foreach my $row (@rows) {
    64         $result .= join(' ', $row->[0], $row->[4]);
    65         $result .= " ** ";
    66     }
    67 
    68     return $result;
    69 }

Die Zeilen 15 bis 18 lesen die ankommende E-Mail auf einen Rutsch in den Array @data ein, wobei jedes Element einer Zeile der ursprünglichen Nachricht entspricht. Zeile 21 kreiiert ein neues Mail::Internet-Objekt aus einer Referenz auf den Mailzeilen-Array. Dieses Objekt wird uns die lästige Header-Setzerei und -Leserei ersparen, da wir zukünftig nur noch mit Methoden auf das Mailobjekt zugreifen.

Zeile 22 liest die erste Zeile des Mailtexts. Die body-Methode liefert laut Mail::Internet-Dokumentation eine Referenz auf einen Array, der die Zeilen des Mailtexts als Elemente enthält. $mail->body()->[0] liefert also genau die erste Zeile des Mailtexts -- wo wir den vom Benutzer angegebenen Namen der gesuchten Aktie erwarten. Zeile 23 schneidet noch den Zeilenumbruch ab, falls einer vorhanden ist.

Zeile 26 erzeugt in $reply eine Referenz auf ein neues Mail::Internet-Objekt für die Rückantwort. Wie ein guter Mail-Client stellt Mail::Internet hierzu ein Re: mit dem ursprünglichen Betreff in die Subject-Zeile und besetzt den Body mit dem eingerückten und markierten Text der vorausgegangenen Mail vor. Der Adressat ist der Absender der ursprünglichen Nachricht. Die reply-Methode des Mail::Internet-Pakets sucht zunächst nach einem Reply-to-Header in der ursprünglichen Mail und fällt auf den From-Header zurück, falls jener nicht vorhanden ist, um den neuen Adressaten zu ermitteln.

Zeile 28 überschreibt mit der body-Methode den Nachrichtentext der ausgehenden Mail mit dem Aktienkursergebnis, das vorher die get_quote-Methode mit dem Aktiennamen als Argument vom Internet eingeholt hat. Die body()-Methode in Zeile 28 nimmt eine Referenz auf einen Array entgegen, dessen Elemente die Zeilen der Nachricht enthalten. Im Fall der Funktion get_quote(), die nur einen einzigen Skalar zurückliefert, enthält der frisch erzeugte anonyme Array den Ergebnisstring als einziges Element.

Zeile 31 schließlich sendet die Email zurück. Neben der smtpsend-Methode mit dem Mailhost des Internetproviders als Argument böte Mail::Internet außerdem noch die send-Methode, die sich auf den unter Unix herumlungernden mail-Client stützt. Aber das nur, falls man den Sendmail-Dämon nicht direkt ansprechen möchte.

Der Grabscher

Die Funktion get_quote ab Zeile 35 nimmt das auf yahoo.de übliche Aktienkürzel entgegen (bmw, aol oder z.B. auch 575800 für die Höchst AG) und nutzt das Modul LWP::UserAgent, um einen Agenten zu erzeugen, der sich benimmt wie ein Browser und die Yahoo-Seite aus Abbildung 2 befrägt.

Welche Formularparameter Yahoo erwartet, lässt sich einfach anhand der HTML-Codes der Seite aus Abbildung 2 ableiten -- oder auch gerne mit dem loggenden Proxy aus [1]. Es zeigt sich, dass das Skript auf der Yahoo-Seite http://de.finance.yahoo.com/q heisst, und drei Query-Parameter erwartet: Mit m=F steht der Börsenplatz auf Frankfurt, mit s=aol wird das Aktienkürzel auf aol gesetzt und mit d=v1 kommt noch etwas Geheimnisvolles hinzu, das offensichtlich die Darstellung beeinflusst.

Zeile 39 in getquote.pl jedenfalls baut diesen URL zusammen, Zeile 44 erzeugt einen neuen Useragenten und Zeile 45 feuert den Request ab. Zeile 47 überprüft mit der is_error-Methode, ob bei der Übertragung ein Fehler aufgetreten ist und der zugehörige if-Rumpf setzt im Fehlerfall eine Fehlermeldung und gibt sie als Ergebnis zurück. Geht hingegen alles gut, legt Zeile 54 den HTML-Inhalt von Abbildung 3 in der Variablen $data ab.

Das ausgefuchste HTML::TableExtract

Das Modul HTML::TableExtract ist nun ein ganz ausgefuchstes: Es extrahiert die Daten aus Tabellen in HTML-Dokumenten. Dabei reicht es, die Tabellen symbolisch anzugeben. Man sagt: ``Ich hätte gerne die Tabelle mit den Spaltenüberschriften Kurs und Datum'' und schon schlängelt sich der Parser durch das HTML-Dokument, sucht die richtige Tabelle heraus und extrahiert daraus die angegebenen Spalten. In unserem Fall geht es darum, aus dem wilden HTML-Code der Firma Yahoo die in Abbildung 4 optisch hervorgehobenen Daten zu holen -- da kommt HTML::TableExtract gerade recht.

Abbildung 4: Die gesuchten Daten im HTML-Salat

Nun enthält die Yahoo-Tabelle eine Besonderheit, nämlich eine Spaltenüberschrift, die sich mit COLSPAN über mehrere Kolumnen erstreckt, was HTML::TableExtract zur Drucklegung dieses Artikels noch ins Schleudern brachte. Statt dessen fischt getquote.pl in Zeile 56 die Tabelle Nummer 4 (count => 4) im Dokument (depth ist 0 , weil es sich um keine Untertabelle, sondern um eine Tabelle auf höchster Ebene im Dokument handelt) heraus, Zeile 58 wirft den Parser an und Zeile 61 liest alle vom HTML befreiten Zeilen der gesuchten Tabelle zeilenweise in den Array @rows ein. Zeile 62 entfernt die Spaltenüberschriften und die foreach-Schleife ab Zeile 63 iteriert über alle Tabellenreihen, wobei Zeile 64 jeweils die erste und die fünfte Spalte extrahiert, die den Aktiennamen und den aktuellen Kurs in Euro beinhalten. Abschließend steht etwa folgendes in der Email an den Pager:

    AOL.F 60,70 **

Wurde in Zeile 39 nicht F als Börsenplatz eingestellt, sondern etwa *, antwortet Yahoo nicht nur mit einer, sondern mit vielen Zeilen, die die Kurse an den verschiedenen Börsen angeben. In diesem Fall schnappt sich die foreach-Schleife eine Zeile nach der anderen und gibt die Ergebnisse durch **-Zeichen getrennt in einem String zurück -- in Pagern muss Platz gespart werden.

Installation

Die verwendeten Module gibt's natürlich alle kostenlos auf dem CPAN. LWP::UserAgent und HTTP::Request::Common sind Teile der bekannten Bibliothek libwww, das Internet::Mail-Modul ist Teil der MailTools, für die verwendete SMTP-Methode zum Mailen ist außerdem das Net::SMTP-Modul aus dem libnet-Bundle notwendig. Am schnellsten wird alles wie immer mit Andreas Königs CPAN-Modul installiert:

    perl -MCPAN -eshell
    cpan> install Net::SMTP
    cpan> install HTTP::Request::Common
    cpan> install Internet::Mail
    cpan> install HTML::Parser
    cpan> install HTML::TableExtract

Soweit die Module. Damit das Ganze funktioniert, brauchen wir einen Rechner, der 24 Stunden am Tag am Internet hängt und Emails empfangen und senden kann. Außerdem muss dort perl installiert sein und ein sendmail-ähnlicher Mail-Daemon hausen.

Wer über keinen eigenen Rechner am Internet verfügt, kann sich mit einem Hosting-Service behelfen. Die besseren Services lassen nicht nur statische Webseiten und CGIs zu, sondern erlauben auch Shell-Zugriff auf Linux-Servern.

Damit das Skript bei eintreffender E-Mail sofort reagiert, muss ein Eintrag in die .forward-Datei unter dem Heimatverzeichnis des entsprechenden Benutzers her:

    $ cat /home/benutzer/.forward
    | /home/benutzer/bin/getquote.pl

Kommt E-Mail, die an benutzer@irgendwo.com gerichtet ist, für Benutzer benutzer auf dem entsprechenden Rechner an, leitet sendmail die Mail wegen der .forward-Datei an das Skript getquote.pl weiter, welches die notwendigen Aktionen wie oben beschrieben einleitet.

Änderungen

Ändert Yahoo entweder sein Eingabeformular oder das Ausgabeformat grundlegend, müssen wir freilich die get_quote-Funktion in getquote.pl umschreiben -- aber da wir nicht direkt auf buchstabengetreue Wiedergabe angewiesen sind, sondern logische Angaben machen (z.B. ``vierte Tabelle auf der Seite''), macht es auch nichts aus, wenn Yahoo hin und wieder die Anzeigen wechselt. Das vorgestellte Verfahren ist relativ robust, aber, für alle Fälle: Wie immer wird die Online-Version des Skripts auf www.linux-magazin.de stets auf dem neuesten Stand gehalten.

Viel Spaß beim Pagen -- aber wenn ich einen erwische, der seinen Pager im Kino oder im Restaurant klingeln lässt, dann raucht's! Bis zum nächstenmal!

Referenzen

[1]
``Loggender Proxy'', Michael Schilli, Linux-Magazin 04/00, http://www.linux-magazin.de/ausgabe/2000/04/Proxy/proxy.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.