Lauernder Greifer (Linux-Magazin, November 2011)

Steht keine API für Webinformationen zur Verfügung, hilft oft Perl mit der Brechstange des Screen Scraping. Seit neuestem überwindet es sogar mit JavaScript aufgestellte Hürden.

Nicht weniger als drei ehrwürdige Linksys-Router schaufeln die Ethernetpakete im Wohnbereich der Perlmeister-Labs umher. Auf allen dreien tut die Tomato-Firmware ([2]) seit Jahren ohne jeglichen Störfall ihren Dienst.

Abbildung 1: Tomatos Übersichtsseite listet unter anderem die "Uptime" des Routers in Tagen und Stunden auf.

Da Tomatos Admin-Webseite nicht nur allerlei nützliche Einstellungen erlaubt, sondern auch noch informative Statusdaten anzeigt, lag es nahe, einen Screen-Scraper zu schreiben, um die Daten in regelmäßigen Abständen auf den Heimrechner zu holen, in einer Datenbank zu speichern und bei auffälligen Ausreißern Alarme auszulösen.

Aua, JavaScript!

Doch, oh weh! Beim Einholen der mit Basic Auth gesicherten Seite mit wget http://root:passwort@192.162.0.1 zeigte sich, dass Tomato die Felder der Anzeige mittels JavaScript auffrischt und einfache Webscraper wie das Perl-Modul WWW::Mechanize statt der begehrten Uptime-Zeit lediglich JavaScript-Code runterladen.

Abbildung 2: Durch einfaches Einholen der Webseite lässt sich der Uptime-Wert nicht extrahieren.

Damit die Seite die Daten richtig anzeigt, muss auf der Client-Seite ein JavaScript-Engine anlaufen, der den Code interpretiert und gemäß den darin enthaltenen Anweisungen die DOM (Document Object Model) der im Browser dargestellten Seite auffrischt. Screenscraper in Rohform tun das nicht, verhalten sich wie Browser mit abgeschaltetem JavaScript und erhalten darum nicht das gewünschte Ergebnis.

Das siebte Weltwunder

Die herkulische Aufgabe, diese Browseraktionen in Perl zu implementieren hat das CPAN-Modul WWW::Scripter erledigt. Zusammen mit dem Plugin WWW::Scripter::Plugin::Ajax für Serverrückrufe, der DOM-Schnittstelle HTML::DOM und dem Pure-Perl ECMAScript (JavaScript) Engine JE stellt es alle notwendigen Funktionen bereit. Wenn man darüber nachdenkt, wieviele DOM-spezifische Browserunterschiede es allein zwischen Internet Explorer und Firefox gibt, lässt sich erahnen, wie viel Arbeit in den Modulen steckt. Außerdem verhält sich das Modul wie ein weiterer Browser, Unterschiede zwischen seiner Implementierung und dem sonst verwendeten Desktopbrowser sind unvermeidlich. Eine weitere Möglichkeit, einen javascriptgesteuerten Scriptclient zu implementieren, wäre der Einsatz einer Browserfernsteuerung wie Selenium ([3]).

Listing 1 zieht zunächst WWW::Scripter herein und lädt den separat erhältlichen Ajax-Plugin mit der Methode use_plugin(). Die Klasse ist von WWW::Mechanize und damit auch von LWP::UserAgent abgeleitet und unterstützt demnach die Methode get zum Einholen von Webseiten. Da der Router beim HTTP-Zugang nach einem Passwort für den root-Account fragt, stellt das Skript dieses mittels der ebenfalls ererbten Methode credentials() zur Verfügung.

Listing 1: tomato-overview

    01 #!/usr/local/bin/perl -w
    02 use strict;
    03 use WWW::Scripter;
    04 use Sysadm::Install qw(:all);
    05 use HTML::TreeBuilder::XPath;
    06 
    07 my $w = new WWW::Scripter;
    08 $w->use_plugin('Ajax');
    09 
    10 my $pw = slurp "pw.txt";
    11 chomp $pw;
    12 $w->credentials( "root", $pw );
    13 $w->get('http://192.168.0.1');
    14 
    15 $w->wait_for_timers( max_wait => 1 );
    16 
    17 my $tree= HTML::TreeBuilder::XPath->new;
    18 $tree->parse( $w->content() );
    19 my $uptime =
    20   $tree->findvalue( 
    21     '/html/body//tr[@id="uptime"]/td[@class="content"]');
    22 
    23 print "uptime: $uptime\n";

Passwort ausgelagert

Damit das Passwort nicht im Skript hartkodiert ist, liest es die Funktion slurp aus der Datei pw.txt im aktuellen Verzeichnis ein. Die Datei enthält nur eine Zeile mit dem Passwort und sollte gegen unberechtigten Lese- oder gar Schreibzugriff geschützt sein. Ganz astrein ist diese Lösung freilich nicht, doch irgendwie müssen wir den Schlüssel unter der Fußmatte verstecken, wenn das Skript automatisch laufen soll und der User nicht jedes Mal das Passwort tippt.

Maschine läuft an

Holt get die Seite vom Webinterface des Routers, enthält diese noch keine Daten, sondern nur den eingebetteten JavaScript-Code. Der Aufruf wait_for_timers() startet nun den JavaScript-Engine und lässt ihn auf dem Seiteninhalt herumfuhrwerken. Die Methode würde nun solange blocken, bis auch der letzte JavaScript-Timer im Code aufgehört hätte zu laufen, was aber bei vielen Webseiten einfach unendlich lange dauern würde. Der Parameter max_wait gibt deshalb vor, nicht länger als eine Sekunde zu warten. Diese Zeitspanne reicht der Routerseite erfahrungsgemäß, um die dynamischen Felder zu befüllen. Ein anschließender Aufruf von content() gibt das mit den Daten aufgefrischte HTML zurück.

Abbildung 3: Der JavaScript-Engine hat die Uptime-Daten eingefüllt, nachdem der JavaScript-Engine mittels WWW::Scripter gelaufen ist.

Fieseln aus dem HTML-Salat

Wie in Abbildung 3 ersichtlich, steht der gesuchte Uptime-Wert in einem Wirrwarr von HTML-Tags, und man könnte ihn entweder mit regulären Ausdrücken oder einem HTML-Parser herausfieseln. Listing 1 wählt mit dem XPath-Parser HTML::TreeBuilder::XPath vom CPAN die wohl bequemste Methode, Der Pfadausdruck in Zeile 21 steigt in der Hierarchie des HTML-Dokuments erst zum Body-Tag herunter, und sucht dann wegen dem doppelten Schrägstrich in beliebigen Tiefen nach den weiter rechts spezifizierten Tags. Zum Ziel führt ein TR-Tag mit der dem id-Attribut "uptime", das ein TD-Tag mit dem class-Attribut "content" einschließt, wie Abbildung 3 zeigt. Die Methode findvalue fördert den darin begrabenen Text zutage und es bleibt dem Skript nur noch, den gefundenen Wert auf der Standardausgabe auszugeben.

Eine etwas dynamischere Aufgabe stellt die Tomato-Seite mit den aktuellen Bandbreitenwerten. Unter dem Pfad /bwm-realtime.asp erscheint der Graph in Abbildung 4, der eine mittels JavaScript erstellte Grafik mit den Schwankungen während der letzten 24 Stunden. In der Tabelle darunter stehen die aktuellen Werte für empfangene Daten (RX) in kbit/sec sowie gesendete Daten (TX). Neben dem aktuell gemessenen Wert listet Tomato hier Maximalwerte (Peak) auf, Durchschnittswerte (Avg), sowie die Summe transferierter Bits seit dem Start der Messung.

Abbildung 4: Der Tomato-Router frischt die aktuellen Bandbreitenwerte regelmäßig mit JavaScript auf.

Beim ersten Laden der Seite stehen alle Werte auf Null und erst nach einigen Sekunden füllen sich die Tabellenreihen mit interessanten Werten. Listing 2 wartet aus diesem Grund in der Funktion rounds() beim ersten Aufruf in Zeile 17 geschlagene fünf Testdurchläufe ab, während deren es jeweils den Skripter mit der Methode check_timers() dazu auffordert, die Timer im JavaScript-Code laufen zu lassen. Anschließen legt es in Zeile 27 eine einsekündige Pause ein. Nach Ablauf aller vorgeschriebenen Runden ruft Zeile 30 den rounds() beim Aufruf hereingereichten Callback auf, was während der Proberunden aus Zeile 17 eine leere Funktionshülse ist, aber im Realbetrieb ab Zeile 18 die ab Zeile 34 definierte Funktion extract_bandwidth.

Listing 2: tomato-bandwidth

    01 #!/usr/local/bin/perl -w
    02 use strict;
    03 use Sysadm::Install qw(:all);
    04 use WWW::Scripter;
    05 use HTML::TableExtract;
    06 use YAML qw(Dump);
    07 
    08 my $w = new WWW::Scripter;
    09 $w->use_plugin('Ajax');
    10 
    11 my $pw = slurp "pw.txt";
    12 chomp $pw;
    13 
    14 $w->credentials( "root", $pw );
    15 $w->get('http://192.168.0.1/bwm-realtime.asp');
    16 
    17 rounds( $w, 5, sub { } );
    18 rounds( $w, 1, \&extract_bandwidth );
    19 
    20 ###########################################
    21 sub rounds {
    22 ###########################################
    23     my( $w, $rounds, $callback ) = @_;
    24 
    25     for( 1 .. $rounds ) {
    26         $w->check_timers();
    27         sleep( 1 );
    28     }
    29 
    30     $callback->( $w->content );
    31 }
    32 
    33 ###########################################
    34 sub extract_bandwidth {
    35 ###########################################
    36     my( $html ) = @_;
    37 
    38     my $te = HTML::TableExtract->new( );
    39     $te->parse( $html );
    40 
    41     my $ts = $te->first_table_found();
    42 
    43     my %bw = ();
    44 
    45     foreach my $row ($ts->rows) {
    46         my @cols = map { /(\S+)/ } @$row;
    47 
    48         $bw{ $cols[0] } = 
    49              { avg  => $cols[3],
    50                peak => $cols[5],
    51              };
    52     }
    53 
    54     print Dump( \%bw );
    55 }

Als Parser für die in HTML-Tabellen versteckte Information nutzt das Skript das CPAN-Modul HTML::TableExtract. Dessen Methode parse() nimmt in Zeile 39 den von JavaScript vorher modifizierten HTML-Code der Seite entgegen und formt einen Syntaxbaum daraus. Die Methode first_table_found() sucht dann die erste HTML-Tabelle, die auf Tomatos Bandwidth-Seite tatsächlich die gesuchten Daten enthält. Während der Testphase, während der Entwickler noch nicht weiß, welche Tabelle welche Informationen enthält, hilft die Methode tables() des gleichen Moduls, das alle gefundenen Tabellen als Objekte zurückgibt. Deren Lage und hierarchische Verschachtelung gibt coords() an und ihren Inhalt schüttet rows() zeilenweise aus. Die Manualseite erklärt deren Verwendung ausführlich.

Abbildung 5: WWW::Scripter saugt Informationen von einer JavaScript-getriebenen Webseite.

Tomato gibt zu einem kbit/sec (Kilo-Bit) auch noch einen Wert für KB/sec (Kilo-Byte) aus, da sich diese jedoch lediglich um einen konstanten Faktor unterscheiden, filtert der reguläre Ausdruck in der map-Anweisung in Zeile 46 letzteren Wert jedoch aus, indem er alles nach dem ersten Leerzeichen abschneidet. Die erste Spalte in @cols ist so entweder "RX" oder "TX", gefolgt vom aktuellen Transferwert. In der 4. und 6. Spalte stehen die Werte für die durchschnittliche Bandbreite und Spitzenwerte. Zeile 48 schiebt sie als Hash mit den Schlüsseln "avg" und "peak" in einen weiteren Hash unter "RX" bzw. "TX". Damit sich die Ausgabe des Skripts machinell leicht nachverarbeiten lässt, druckt Zeile 54 den resultierenden Hash mittels der Methode Dump des am Programmkopf geladenen YAML-Moduls aus. Abbildung 5 zeigt das Ergebnis auf der Kommandozeile. Ein weiterverarbeitendes Skript kann die Ausgabe mittels der Load-Methode des gleichen Moduls in den Speicher laden und gleich als Hash weiterverarbeiten.

Installation

Die dynamischen Greiferskripte benötigen außer dem Webscraper WWW::Mechanize auch noch die CPAN-Module WWW::Scripter und WWW::Scripter::Plugin::Ajax, die sich auf Ubuntu mit einer CPAN-Shell installieren lassen. Den in Listing 1 verwendeten XPath-Parser, HTML::TreeBuilder::XPath, und den Tabellenparser HTML::TableExtract aus Listing 2 gibt es ebenfalls auf dem CPAN.

Natürlich könnte man mit dem Tomato-Router besser und sicherer über ssh kommunizieren und eventuell sogar ein neues Image flashen, das eine Web-API bereitstellt. Die gezeigten Tricks lassen sich aber auch auf allerlei interessanten bekannten Seiten im Web anwenden. Der engagierte Scraper-Hacker muss lediglich darauf achten, dass seine Erzeugnisse nicht gegen die TOS (Terms of Service) der Anbieter verstoßen, die Scraping oft nicht gerne sehen.

Infos

[1]

Listings zu diesem Artikel: ftp://www.linux-magazin.de/pub/listings/magazin/2011/11/Perl

[2]

Tomato Firmware für Linksys-Router: http://www.polarcloud.com/tomato

[3]

"Browser ferngesteuert", Michael Schilli, http://www.linux-magazin.de/Heft-Abo/Ausgaben/2006/10/Browser-ferngesteuert

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.