Laufzeiterfassung (Linux-Magazin, November 2001)

Daten auf fremden Webseiten, die in Tabellenform daherkommen, extrahiert das Modul HTML::TableExtract ohne große Klimmzüge. Der CGI-Helfer CGI zeigt sie anschließend umgewandelt wieder im Browser an.

Am Tag vor dem Marathon in San Francisco wollte ich herausfinden, wann ungefähr ich während des Laufs an der Straßenkreuzung vorbeikäme, an der Angelika auf mich warten würde, um mir beim Leiden zuzusehen. Nun gab es vom Veranstalter einen Streckenplan auf [2] (Abbildung 1), der genau jede Wendung der Strecke mit einer Meilenangabe auflistete.

Abbildung 1: Die Originalseite des Marathonveranstalters

In einer riesigen Tabelle stand da nun allerlei Unrat, aber ich wollte nur die Namen der Straßenkreuzungen und die entsprechenden Meilenangaben extrahieren und daraus eine neue Tabelle erstellen, die für eine Reihe von geschätzten Laufgeschwindigkeiten Ankunftszeiten über alle Straßenkreuzungen anzeigte.

Statt wild mit regulären Ausdrücken herumzufuchteln, erinnerte ich mich an [3] und [4], in denen vor einiger Zeit das Modul HTML::TableExtract von Matthew Sisk vorgestellt wurde, das elegant Daten aus Tabellen völlig fremder Webseiten extrahiert. Statt tiefer in den HTML-Code vorzudringen, sagt man einfach: ``Ich hätte gerne die Spalten Direction, Street und Mile aus der ersten Tabelle auf dieser Seite, die diese Spalten enthält.'' -- Fertig! HTML::TableExtract wird dann den HTML-Code parsen, die Tabelle ausfindig machen und die gefundenen Spalten mit den dazugehörigen Daten extrahieren. Dabei bietet es reinsten Bedienungsluxus, denn die Spaltenüberschriften können selbst in falscher Reihenfolge dastehen oder statt mit <TH> mit <TD> ausgezeichnet sein -- HTML::TableExtract findet sie auch dann, wenn sie sich noch so gut verstecken.

Listing mara zeigt ein CGI-Skript, das die Seite des Marathon-Veranstalters vom Netz holt, die gewünschten Daten extrahiert, für einige vorgeschlagene Laufgeschwindigkeiten die Ankunftszeiten an allen Straßenkreuzungen vorhersagt und alles schließlich als HTML-Tabelle ausgibt, die man sich im Browser ansehen kann.

Da mein Web-Provider mir nicht erlaubt, Perl-Module im Root-Verzeichnis zu installieren, habe ich mir einfach /home/mike/perl eingerichtet, um Zusatzmodule zu installieren, die die Perl-Installation des Providers nicht führt. Statt mittels

    perl -MCPAN -eshell

installiere ich neue Module mit

    perl -e'use lib "/home/mike/perl"; \
    use CPAN; shell'

unterhalb des lokalen Verzeichnisses. Später lasse ich meine Skripts einfach

    use lib '/home/mike/perl';

aufrufen, bevor sie lokal installierte Zusatzmodule nutzen. Das heutige Skript verwendet LWP::Simple zum Einholen von Webseiten, HTML::TableExtract zur Datenextraktion und Date::Calc zur Berechnung von Zeitangaben, die die CPAN-Shell alle flugs installiert:

    cpan> install LWP::Simple
    cpan> install Date::Calc
    cpan> install HTML::TableExtract

Die Zeilen 9 bis 13 im Skript mara ziehen die notwendigen Zusatzmodule herein. Zeile 10 weist LWP::Simple mit () an, keine Funktionen in den Namensraum des Skripts zu abzulassen -- andernfalls ergäbe sich ein Konflikt mit der Funktion head(), die sowohl LWP::Simple als auch das nachher hereingezogene Modul CGI exportieren. Letzteres weisen wir mit qw(:all *table *TR) an, reihenweise Funktionen zu exportieren, um später bei der HTML-Ausgabe Tipparbeit zu sparen.

mara nutzt ausserdem CGI::Carp in Zeile 13 mit dem Tag fatalsToBrowser und weist so das CGI-Skript an, bei auftretenden Fehlern eine detaillierte Fehlerbeschreibung über den Browser auszugeben -- eine nützliche Sache während der Skriptentwicklung, die aber später aus Sicherheitsgründen aus der Produktionsumgebung verschwinden sollte.

Die Zeilen 15 bis 18 legen Konstanten fest: $URL bestimmt den URL zur Webseite des Marathonveranstalters, @START_TIME legt mit 7 Stunden, 0 Minuten und 0 Sekunden fest, dass der Marathon punkt 7 Uhr morgens losgeht und @SPEEDS enthält sechs verschiedene Laufgeschwindigkeiten, von recht flotten 7.5 Meilen pro Stunde (etwa 12 km/h) bis runter zum relaxten Dauerlauf von 5.0 Meilen pro Stunde (etwa 8 km/h).

Zeile 20 holt mittels des voll qualifizierten Aufrufs von LWP:Simple::get() die HTML-Seite mit der Streckentabelle vom Web und legt deren Inhalt in $data ab. Falls etwas schief geht, bricht die() das Programm ab. Nicht gerade freundlich für ein CGI-Programm, aber CGI::Carp wird's schon richten.

Zeile 23 erzeugt ein neues Objekt vom Typ HTML::TableExtract und legt fest, dass dieses später nach einer Tabelle suchen soll, die die Spalten Direction, Street und Mile (nicht unbedingt in dieser Reihenfolge) enthalten soll. Zeile 25 wirft den Parser auf die vom Netz geholten HTML-Daten an.

Zeile 27 nutzt exportierte Funktionen aus dem Modul CGI, um den CGI-Header zu schreiben und die einleitetenden HTML-Tags herauszupusten. start_table() in Zeile 28 schreibt nur <TABLE> mit dem Attribut BORDER=1, das die Tabellenlinien sichtbar zeichnet.

Normalerweise schreibt die vom Modul CGI mit dem Tag :all (Zeile 12) exportierte Funktion table() gleich <TABLE>...</TABLE> und dazwischen die table() als Parameter übergebenen Daten. In Zeile 28 liegen diese Daten aber nicht vor und deswegen nutzen wir start_table, das das CGI-Modul allerdings nur deswegen exportiert, weil wir in Zeile 12 das Tag *table in der Exportliste angegeben haben. end_table() haut auf denselben Sack und schreibt später nur </TABLE>.

Warum dann nicht gleich print "<TABLE>" und print "</TABLE>"? Perl ist ein Kunstwerk und HTML eine Bausünde -- beides vermixe ich nicht gerne.

Die in Zeile 32 aufgerufene rows()-Methode des HTML::TableExtract-Objekts gibt alle Zeilen der ersten im HTML-Dokument gefundenen Tabelle zurück, die die vorher in Zeile 24 festgelegten Spaltenüberschriften führt. Die foreach-Schleife neben dem Label ROW iteriert über alle Tabellenzeilen und legt für jede Zeile in $row eine Referenz auf ein Array ab, der als Elemente Werte für die gesuchten Spalten enthält. $row->[0] ist der Inhalt der ersten Spalte (Himmelsrichtung), $row->[1] der Inhalt der zweiten (Straße/Kreuzung) und $row->[2] die Meilenangabe. Die nicht angeforderten Spalten der Tabelle eliminiert HTML::TableExtract praktischerweise schon vorher.

start_TR() in Zeile 34 leitet mit <TR> eine neue Tabellenzeile in der HTML-Ausgabe des Skripts ein und ist ebenfalls nur wegen der Tag-Angabe *TR in Zeile 12 verfügbar, da das Tag :all sie nicht automatisch aus CGI exportiert.

Manche Tabellenzeilen des Marathonveranstalters enthalten leere Spalten, diese spürt Zeile 37 auf und springt mit next ROW gleich in den nächsten Durchgang der äußeren foreach-Schleife, die die Zeilen der Eingangstabelle kontrolliert. Wegen des Modifizierers /s findet /\s*/ auch ``leere'' Spalten, die Newline-Zeichen enthalten.

Zeile 40 schließlich gibt die Spalten eins und zwei der Originaltabelle zusammengefasst in Spalte eins der Ausgabetabelle aus. Außerdem schreibt sie die Meilenspalte der Originaltabelle als Spalte zwei der Ausgabetabelle.

Die Spalten drei bis acht der Ausgabetabelle müssen wir erst berechnen: Hierfür iteriert Zeile 43 über alle vorgegebenen Geschwindigkeiten in @SPEEDS und rechnet mittels der allen wahren Ingenieuren bekannten Formel t = s/v die Anzahl der Sekunden aus, die der Läufer bei der Geschwindigkeit v braucht, um die in $row->[2] liegende Meilenzahl s abzuspulen. Multiplikation mit 3600 macht aus den herauskommenden Stunden schnell Sekunden. Um die aktuelle Uhrzeit zu berechnen, die sich ergibt, indem man t Sekunden zur Startzeit 7:00 addiert, braucht man zwar keinen Doktorhut in Mathematik, aber aus Faulheit nutzt mara die Funktion Add_Delta_DHMS() aus dem Modul Date::Calc, die zu einem vorgegebenen Datum mit Uhrzeit die angegebene Anzahl von Tagen, Stunden, Minuten und Sekunden addiert und das Ergebnis im Format Jahr, Monat, Tag, Stunde, Minuten, Sekunden als Liste zurückgibt. Today() füllt mit dem heutigen Kalendertag eine sinnvolle Datumsangabe ein. Die sprintf-Funktion macht daraus eine schöne als HH::MM formatierte Uhrzeit und Zeile 48 gibt sie als nächste Tabellenspalte aus. Dies wiederholt sich für die restlichen Geschwindigkeitselemente in @SPEEDS und endlich schließt end_TR die aktuelle Ausgabetabellenzeile mit </TR> ab.

Zeile 53 beendet schließlich mit end_table() die Ausgabetabelle und mit end_html die HTML-Ausgabe des CGI-Skripts. Das Ergebnis des im cgi-bin-Verzeichnis des Webservers installierten Skripts, das der Browser über den URL http://localhost/cgi-bin/mara aufruft, zeigt Abbildung 2.

Meine eigene Geschwindigkeit lag dann doch eher bei knapp 6 Meilen pro Stunde (9.5 km/h). Das Ergebnis findet ihr unter [2]. Rennt fleißig Marathons, seid keine Weicheier!

Listing 1: mara

    01 #!/usr/bin/perl
    02 ##################################################
    03 # mara -- Mike Schilli, 2001 (m@perlmeister.com)
    04 ##################################################
    05 use warnings;
    06 use strict;
    07 
    08 use lib '/home/mike/perl';
    09 use HTML::TableExtract;
    10 use LWP::Simple ();
    11 use Date::Calc qw(Today Add_Delta_DHMS);
    12 use CGI qw(:all *table *TR);
    13 use CGI::Carp qw(fatalsToBrowser);
    14 
    15 my $URL = 
    16    "http://www.chroniclemarathon.com/course.html";
    17 my @START_TIME = (7,0,0);  # 7:00 geht's los
    18 my @SPEEDS     = (7.5, 7.0, 6.5, 6.0, 5.5, 5);
    19 
    20 my $data = LWP::Simple::get($URL) or
    21     die "Fetching $URL failed";
    22 
    23 my $te = new HTML::TableExtract(
    24           headers => [qw(Direction Street Mile)]);
    25 $te->parse($data);
    26 
    27 print header(), start_html();
    28 print start_table({border => 1});
    29 print th(["Where", "Mile", @SPEEDS]);
    30 
    31     # Reihen der ersten passenden Tabelle
    32 ROW: foreach my $row ($te->rows) {
    33 
    34     print start_TR();
    35 
    36     foreach my $col (@$row) {
    37         next ROW if $col =~ /^\s*$/s;
    38     }
    39          
    40     print td("$row->[0] $row->[1]"),
    41           td($row->[2]);
    42 
    43     foreach my $speed (@SPEEDS) {
    44         my $secs = $row->[2] / $speed * 3600;
    45         my $time = sprintf "%02d:%02d", 
    46               (Add_Delta_DHMS(Today(),@START_TIME,
    47                                0,0,0,$secs))[3,4];
    48         print td($time);
    49     }
    50     print end_TR();
    51 }
    52 
    53 print end_table(), end_html();

Abbildung 2: Die ermittelten Streckendaten

Infos

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

[2]
Die Zielzeit des Autors beim San-Francisco-Marathon 2001: http://www.chroniclemarathon.com/ => Bib-Nummer 2003 eingeben.

[3]
Matt Sisk, ``Finance::QuoteHist and HTML::TableExtract'', The Perl Journal #19, Seite 46ff., Herbst 2000

[4]
Michael Schilli, ``Schnurlose Aktienkurse'', Linux-Magazin 11/2000, http://www.linux-magazin.de/ausgabe/2000/11/Perl/perl.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.