Jonny Controletti (Linux-Magazin, Juni 1999)

Ist die neue Perl-Kolumne schon auf der Website des Linux-Magazins? Kann Amazon das neue Buch endlich liefern? Hat ein Anbieter den Preis für den CD-Brenner gesenkt? Der übers Web konfigurierbare Webseitenüberwacher schlägt Alarm, falls sich etwas rührt auf dem Web.

Bei O'Reilly soll demnächst "Writing Apache Modules with Perl and C" erscheinen. Um den Zeitpunkt, an dem Amazon den Schinken liefern kann, genau abzupassen, könnte ich jeden Tag die entsprechende Webseite anklicken -- aber dazu bin ich zu faul. Ein Perl-Skript zu schreiben, das eine Webseite vom Netz holt, ist ungefähr so schwer wie das "Hello World" in C und so liegt der Gedanke nahe, jede Nacht ein Skript mit Gedächtnis laufen zu lassen, das eine Reihe von Webseiten abklappert und feststellt, ob sich etwas darauf verändert hat. Ist dem so, verschickt es eine informative Email. Da viele Seiten Datumsangaben oder Session-Variablen in den HTML-Text schmuggeln, soll mittels eines regulären Ausdrucks festgestellt werden, ob sich ein bestimmter Ausschnitt der Seite gegenüber dem letzten Aufruf verändert hat.

Das vorgestellte CGI-Skript webwatch.pl zeigt eine Oberfläche nach Abbildung 1, über die der Benutzer URLs registrieren kann.

Vier Fälle sind vorstellbar:

Abb.1: Der Web-Kontrolleur in Aktion

Um webwatch.pl einen URL zur Überwachung anzubieten, trägt man denselben einfach in das Textfeld (Abb. 1 unten) ein und fügt optional noch ein regulären Ausdruck hinzu. Ein Druck auf den Add URL-Knopf, und schon übernimmt webauth.pl die Aufgabe.

Abbildung 1 zeigt drei registrierte URLs:

Der erste ist der des vorher erwähnten Apache-Buches, als regulärer Ausdruck steht dort On Order. So zeigt die Amazon-Seite gegenwärtig On Order an, und dies ist genau der String, den das Skript aus der Seite -- mit Erfolg -- extrahiert. Schlägt dies eines Tages fehl, ist das Buch offensichtlich lieferbar und es geht eine Email an die in webwatch.pl fest verdrahtete Adresse.

Der zweite URL ist der der Perl-Nachrichten. Da kein regulärer Ausdruck angegeben wurde, schnappt sich webauth.pl bei jedem Test die ganze Seite und speichert das Ergebnis. Ändert sich auch nur eine Kleinigkeit, geht der Alarm los und eine Mail nach Abbildung 2 geht raus.

Abb.2: Die Alarm-Email

Der dritte URL sucht nach der Juni-Ausgabe des Linux-Magazins, und während ich diesen Artikel schreibe, kann Tom ihn noch nicht draufgespielt haben, also liefert der Zugriff einen 404-Fehler. Dem Skript ist das egal, es 'merkt' sich, daß ein Fehler vorliegt, und führt den Test jeden Tag aufs neue durch, bis es eines Tages die Seite findet, und wegen des ausbleibenden Fehlers eine Alarm-Email losschickt.

Das Gedächtnis von webwatch.pl implementiert das Storable-Modul, das die wohl simpelste Schnittstelle aller Persistenz-Bibliotheken hat:

    store($ref, $dbfile);

speichert die Referenz $ref und alles was darunterhängt rekursiv in der Datenbankdatei $dbfile ab, andererseits zaubert

    $ref = retrieve($dbfile);

die zwischenzeitlich auf Platte ausgelagerten Daten wieder hervor. In webwatch.pl ist $ref die Referenz auf eine Liste @STORE, die für jeden gespeicherten URL einen Hash enthält, der die Felder

    url       URL
    rgx       Regulärer Ausdruck
    id        Eindeutige ID des Eintrags
    status    Changed/Unchanged    
    checked   Zeitpunkt der letzten Prüfung
    comment   Fehlermeldungen
    error     Fehlercode
    lstchange Letzte festgestellte Änderung der Seite 
    diff      diff-Ausgabe der Änderung
    match     Gefundener String (Regex-Match oder ganze Seite)

als Key-Value-Paare enthält. Das praktische am Storable-Modul ist freilich, daß store($ref, $dbname) den ganzen Rattenschwanz automatisch wegschreibt, egal wieviel Einträge tatsächlich darunterhängen.

Implementierung

Zeile 3 zieht das Algorithm::Diff-Modul, das eine schöne, dem diff-Programm ähnliche Ausgabe der Unterschiede zweier Strings erlaubt, der Abschnitt Installation zeigt, woher man es bekommt.

Storable erledigt, wie erwähnt, das Abspeichern und Wiederladen von Daten, LWP::UserAgent zeichnet für die Web-Zugriffe verantwortlich und HTML::Entities hat bloß ein eine praktische Funktion namens encode_entities, die <>& zu &lt;&gt;&amp; maskiert damit's nicht im ausgegebenen HTML rappelt, falls Sonderzeichen drin sind -- Zeile 16 definiert mit enc sogar noch eine Abkürzung darauf. CGI ist Lincoln Steins praktisches CGI-Modul. Der -noDebug-Schalter, der ab Version 2.38 enthalten ist, erlaubt den Aufruf des Skripts auch von der Kommandozeile aus, ohne daß webwatch.pl, wie sonst üblich, auf die Eingabe der CGI-Parametern von der Konsole wartet -- schließlich soll das Skript ja auch als Cronjob laufen. CGI::Carp erlaubt es, Fehlermeldungen des Skripts im Browser anzuzeigen, das ist besonders zum Testen sehr handlich.

Die Zeilen 10-12 müssen den lokalen Gegebenheiten angepaßt werden, im Abschnitt Installation steht, wie man den Pfad für die Datenbankdatei wählt und die Email-Adresse anpaßt.

14 und 15 definieren nur HTML-Darstellungen der Changed/unchanged-Anzeige, damit's ins Auge sticht, ist Changed in Rot.

Die Zeilen 27 bis 57 handeln die verschiedenen CGI-Fälle ab:

    new      Neuen Record einfügen (url, regex)
    del      Record aus der Tabelle löschen (id)
    upd      Bestehenden Record verändern (id, url, regex)
    run      Testfall ablaufen lassen (id)
    cpdown   URL/Regex-Daten eines Records in
             die Editierfelder kopieren (id)
    runall   Alle Testfälle laufen lassen ()

Der erste if-Block ist gar kein CGI-Fall, denn die Environment-Variable REMOTE_ADDR ist nur dann nicht gesetzt, falls das Skript von der Kommandozeile aufgerufen wurde -- dies wird später der Cronjob tun. In diesem Fall wird webwatch.pl alle Testfälle ausführen, die Ergebnisse auf die Festplatte schreiben und wortlos zurückkehren. Ähnlich wie im nächsten CGI-Fall, der ausgeführt wird, falls der Parameter runall gesetzt ist, und auch alle URLs überprüft, aber nicht abbricht, sondern nach den if-else-Bedingungen das Bild nach Abbildung 1 zum Browser schickt.

Im new-Fall hat der Benutzer den Add URL-Knopf gedrückt und (hoffentlich) einen URL und (optional) einen regulären Ausdruck in die Textfelder eingetragen. Das Skript hängt daraufhin einfach einen neuen Eintrag an @STORE an und übernimmt die übergebenen Parameter. Außerdem erzeugt es aus Uhrzeit (time) und Prozeßnummer ($$) eine eindeutige ID, die es dem Skript später erlaubt, einen Record eindeutig zu referenzieren.

Der del-Fall löscht einen Record, dessen ID festliegt, der upd-Fall setzt URL und Regex eines bestehenden Records neu. run läßt einen über die ID festgelegten Testfall laufen. In allen Fällen sucht zunächst ein grep-Befehl den Record mit der richtigen ID heraus, dessen Referenz anschließend in $r abgelegt wird. Der Zugriff auf die Recordfelder ist dann einfach über $r->{url} etc. möglich.

Ein kleiner Hack ist der cpdown-Fall: Klickt der Anwender auf den CpDown-Link eines Records, soll das Skript die URL und den Regex eines ausgewählten Records in der im Browser dargestellten Liste in die Eingabefelder unten kopieren, damit man sie mit dem Update-Knopf aktualisieren kann. Das spart eine Extra-Seite in dem eh nicht gerade kurzen Skript.

Die Zeilen 61 bis 85 erzeugen den HTML-Code, der die gespeicherten URLs samt den zugehörigen Meßergebnissen anzeigt. Wie Abbildung 1 zeigt, malt das Skript für jeden Record in die letzte Spalte der Tabelle drei Links, CpDown, Del und Run. Die url-Funktion aus dem CGI-Modul liefert hierzu den URL, mit dem webwatch.pl aufgerufen wurde und die CGI-Parameter cpdown, del und run hängt es einfach nach der GET-Methode hintendran. Neben der gewählten Aktion wird auch die ID des Records mitgegeben, damit webwatch.pl auch weiß, welcher Record gemeint ist.

Die Zeilen 88-89 schreiben dann am Ende der Tabelle noch schnell einen Link, der die runall-Methode anfordert, damit man nicht nur von der Kommandozeile, sondern auch vom Browser aus alle Testfälle auf einmal durchrasseln kann, aber im Normalfall sollte diese Aufgabe ein Cronjob übernehmen.

In den Zeilen 92-112 entstehen die Eingabefelder für den URL und den Regex einschließlich des Submit-Knopfes, und, falls es etwas zum Auffrischen gibt (d.h. im upd oder cpdown-Fall), kommt noch einen Update-Knopf hinzu. Zeile 104 schmuggelt den ID-Parameter in ein verstecktes Feld, falls das Skript damit aufgerufen wurde.

Zeile 114 schließlich speichert den Daten-Baum in der Datenbank-Datei ab.

Die Hilfsfunktion page_snippet, die in den Zeilen 118-135 definiert ist, nimmt einen URL und einen optionalen Regex entgegen, holt das entsprechende Dokument vom Netz und versucht, dessen Inhalt mit dem Regex zur Deckung zu bringen. Der abgedeckte Text, der nach den Regex-Regeln in der Variablen $& liegt, wird zurückgereicht. Falls kein Regex angegeben wurde, kommt der gesamte Text des Dokuments zurück.

Eine weitere Hilfsfunktion ist mkdiff, die den Unterschied zwischen zwei hereingereichten Strings im diff-Format ausspuckt. Sie wird später genutzt, um in den ausgesandten Emails darzulegen, was sich denn nun genau am Dokument geändert hat. mkdiff ist ein schamlos abgekupfertes Testbeispiel aus der Algorithm::Diff-Distribution.

Die email-Funktion ab Zeile 164 bastelt aus den Feldern eines Records, dessen Dokument sich geändert hat, eine Mail und schickt sie an den Empfänger der in EMAIL_TO in Zeile 11 festgelegt wurde. Der Einfachheit halber nutzt sie einfach den Sendmail-Daemon, der auf den meisten Linux-Systemen konfiguriert sein sollte.

run_test ab Zeile 187 läßt einen Test laufen, dessen Record hereingereicht wurde. Es ruft die page_snippet-Funktion auf, die im Gutfall einen String (den erkannten Bereich) und im Fehlerfall eine Referenz auf einen Array zurückgibt, der als Elemente den HTTP-Errorcode und eine leserliche Meldung enthält. Die ref-Funktion in Zeile 201 prüft diesen Fall, denn sie gibt für einen String einen falschen Wert zurück und für eine Array-Referenz den String "ARRAY".

Anschließend prüft run_test die eingangs dieses Artikels beschriebenen Fälle, und je nach dem, wie sich ein Dokument verändert hat, setzt es die Record-Felder status, comment und Konsorten. Damit in der Tabelle keine häßlichen Löcher entstehen, wenn mal ein Eintrag leerbleibt, werden manche Felder statt auf "" auf "&nbsp;" gesetzt, ein non-blank-space in HTML.

Gibt page_snippet den Wert 0 zurück, hat der reguläre Ausdruck nicht gegriffen und im Kommentarfeld wird deswegen No Match abgelegt.

Zu Anfang steht in $r->{error} noch der Fehlercode des letzten Aufrufs, falls etwas schiefgelaufen ist. Dieser Wert wird zunächst in $last_time_error gesichert, denn run_test muß den Fehlercode entsprechend des Ergebnisses des aktuellen Tests setzen. Ein frisch eingetragener URL, der noch nie getested wurde, hat wegen Zeile 38 den Status "?" und run_test sorgt dafür, daß nicht beim ersten Mal -- egal ob der Zugriff schiefgelaufen ist oder erfolgreich war -- gleich der Alarm losgeht, schließlich muß sich erst etwas verändern.


Installation

Als erstes muß webwatch.pl ins cgi-bin-Verzeichnis des Webservers. Drei Parameter in den Zeilen 10-12 harren der Anpassung: $DB gibt die Datei an, in der das Skript die Daten zwischen den Aufrufen ablegt, $EMAIL_TO ist die Email-Alarm-Adresse und $EMAIL_FROM sollte der Absender der Email, also WebWatcher sein.

Wichtig ist, daß die Rechte des Benutzers, unter dem der Webserver läuft (meistens nobody), es dem Skript erlauben, die $DB-Datei zu beschreiben. webauth.pl legt die Datei beim ersten Aufruf selbständig an, jedoch muß das angegebene Verzeichnis beschreibbar sein, sonst meldet webwatch.pl einen Fehler. Am einfachsten startet man webwatch.pl einmal von der Kommandozeile, läßt es seine Datenbankdatei anlegen, und ändert dann deren Rechte auf 666.

Um das Skript täglich mittels eines Cronjobs zu starten, ist zu beachten, daß dieser unter Umständen unter anderen Benutzerrechten läuft. Eine einmal angelegte Datenbank-Datei läßt sich zu diesem Zweck einfach mit

    chmod 666 /pfad/zur/db/datei

allgemein beschreibbar machen.

Das Storable-Modul liegt modernen Perl-Versionen bereits bei, auch LWP::UserAgent und CGI sind alte Bekannte.

Den Diff-Algorithmus gibt's unter CPAN/modules/by-authors/id/MJD/Algorithm-Diff-0.59.tar.gz auf dem CPAN, zur Drucklegung klappte die Installation der Algorithm/Diff.pm-Datei noch nicht, sie läßt sich aber sehr leicht von Hand an Ort und Stelle kopieren.

Der täglich ablaufende Cronjob wird mit crontab -e folgendermaßen eingetragen:

    30 0 * * * /home/mschilli/scripts/webwatch.pl

Eigentlich dürfen sich der Cronjob und das CGI-Skript beim Laden und Speichern der Datenbankdatei nicht in die Quere kommen, wer will, kann die Datei noch mit flock sichern.

So laufen täglich um 00:30 alle Testfälle der Reihe nach durch -- und am nächsten Morgen warten die heißesten Neuigkeiten schon in der Mailbox des glücklichen Anwenders. Alles unter Kontrolle!

Listing webwatch.pl

    001 #!/usr/bin/perl -w
    002 ##################################################
    003 # Michael Schilli, 1999 (mschilli@perlmeister.com)
    004 ##################################################
    005 
    006 use Algorithm::Diff qw/diff/;
    007 use LWP::UserAgent;
    008 use Storable;
    009 use HTML::Entities;
    010 use CGI 2.38 qw/:standard/;
    011 use CGI::Carp qw/fatalsToBrowser/;
    012 
    013 my $DB         = "/tmp/controlletti.dat";
    014 my $EMAIL_TO   = "bgates\@microsoft.com";
    015 my $EMAIL_FROM = "webwatch\@host.com";
    016 
    017 my $CHANGED    = "<font color=red>CHANGED</font>";
    018 my $UNCHANGED  = "unchanged";
    019 
    020 sub esc { encode_entities($_[0]); };
    021 
    022 if (-r $DB) {
    023   my $store = retrieve($DB) ||
    024       die("$DB: Cannot restore");
    025   @STORE = @$store;
    026 } else {
    027   @STORE = (); 
    028 }
    029 
    030 if(!$ENV{'REMOTE_ADDR'}) { # Command line call
    031  foreach $r (@STORE) { run_test($r); }
    032  store(\@STORE, $DB) || die "Store $DB failed";
    033  exit(0);
    034 
    035 } elsif(param('runall')) { # Run all tests
    036  foreach $r (@STORE) { run_test($r); }
    037 
    038 } elsif(param('new')) {    # Insert new record 
    039   push(@STORE, 
    040        {url => param('url'), rgx => param('rgx'),
    041         status => '?', id => time . $$});
    042 
    043 } elsif(param('del')) {    # Delete record 
    044   @STORE = grep { $_->{id} != param('id') } @STORE;
    045 
    046 } elsif(param('upd')) {    # Update record 
    047   ($r) = grep { $_->{id} == param('id') } @STORE;
    048   $r->{url}   = param('url');
    049   $r->{rgx}   = param('rgx');
    050 
    051 } elsif(param('run')) {    # Run test now
    052   ($r) = grep { $_->{id} == param('id') } @STORE;
    053   run_test($r);
    054 
    055 } elsif(param('cpdown') || param('id')) {
    056                       # Copy record to edit fields
    057   ($r) = grep { $_->{id} == param('id') } @STORE;
    058   param('url', $r->{url});
    059   param('rgx', $r->{rgx});
    060 }
    061 
    062 
    063 # Display list
    064 print header(), start_html(-BGCOLOR => 'white'),
    065       h1("Web Watcher"), "<TABLE>";
    066 print "<TABLE BORDER=1>",
    067       TR(map { th($_) } qw/URL Regex Checked 
    068               Status Comment LstChange Commands/);
    069 foreach $r (@STORE) {
    070   my $chktime = $r->{checked} ?    
    071                 scalar localtime($r->{checked}) :
    072                 "Not Yet";
    073   print TR(
    074     td(a({href => $r->{url}}, $r->{url})),
    075     td(esc($r->{rgx}) || "&nbsp;"),
    076     td($chktime),
    077     td($r->{status}), 
    078     td($r->{comment}),
    079     td(scalar localtime $r->{lstchange}), 
    080     td(a({href => url() . "?cpdown=1&id=$r->{id}"},
    081        "CpDown"), " ", 
    082        a({href => url() . "?del=1&id=$r->{id}"},
    083        "Del"), " ",
    084        a({href => url() . "?run=1&id=$r->{id}"},
    085        "Run"), " ",
    086       ));
    087 }
    088 print "</TABLE>";
    089 
    090 # Link for running all tests
    091 print p, a({href => url() . "?runall=1"}, 
    092            "Run all tests");
    093 
    094 # Form for new entries
    095 print h2("New Entry"), start_form(), 
    096   table(
    097     TR(td("URL:"), 
    098        td(textfield(-size => 80, -name => 'url'))),
    099     TR(td("Regex:"), 
    100        td(textfield(-name => 'rgx'))),
    101   ),   
    102   submit(-name  => 'new', 
    103          -value => 'Add URL');
    104 
    105 # Hidden ID field in case it's there
    106 if(param('id')) {
    107   print hidden(-name => 'id', 
    108                -value => param('id')),
    109 }
    110 if(param('upd') || param('cpdown')) {
    111   print submit(-name  => 'upd', 
    112                -value => 'Update');
    113 }
    114 
    115 print end_form(), end_html();
    116 
    117 store(\@STORE, $DB) || die "Store to $DB failed";
    118 
    119 
    120 ##################################################
    121 sub page_snippet {
    122 ##################################################
    123   my ($url, $rgx) = @_;
    124 
    125   my $req  = HTTP::Request->new('GET', $url);
    126   my $resp = LWP::UserAgent->new->request($req);
    127 
    128   if($resp->is_error()) {
    129       return [$resp->code, $resp->message];
    130   }
    131 
    132   if($rgx) {
    133       $resp->content() =~ /$rgx/si || return 0;
    134       return $&;
    135   }
    136 
    137   return $resp->content();
    138 }
    139 
    140 
    141 ##################################################
    142 sub mkdiff {
    143 ##################################################
    144   my ($t1, $t2) = @_;
    145   my $r = "";
    146 
    147   my $diffs = diff([split(/\n/, $t1)], 
    148                    [split(/\n/, $t2)]);
    149 
    150   return "" unless @$diffs;
    151 
    152   foreach $chunk (@$diffs) {
    153     foreach $line (@$chunk) {
    154       my ($sign, $nu, $text) = @$line;
    155       $r .= sprintf("%4d$sign %s\n", $nu+1, $text);
    156     }
    157     $r .= "-------------";
    158   }
    159 
    160   return($r);
    161 }
    162 
    163 
    164 ##################################################
    165 # alert by email
    166 ##################################################
    167 sub email {
    168     my ($r) = @_;
    169     my $days = $diff = "";
    170 
    171     my $text = <<EOT;
    172 Dear Webwatch Subscriber,\n
    173 the content of the following URL has changed:\n
    174 $r->{url}\n\nA diff to the previous content reads:
    175 \n$r->{diff}\n\nGreetings from Planet Perl!\n\n
    176 Your humble WebWatch program.
    177 EOT
    178 
    179   open(PIPE, "| /usr/lib/sendmail -t") || 
    180       die("Cannot connect to sendmail");
    181   print PIPE "From: $EMAIL_FROM\n";
    182   print PIPE "To: $EMAIL_TO\n";
    183   print PIPE "Subject: WebWatch Alert\n\n";
    184   print PIPE "$text\n.\n";
    185   close(PIPE) || die "Sendmail failed";
    186 }
    187 
    188 
    189 ##################################################
    190 sub run_test {
    191 ##################################################
    192   my $r = shift;
    193 
    194   my $match = page_snippet($r->{url}, $r->{rgx});
    195   my $last_time_error = $r->{error};
    196 
    197   $r->{comment} = $match ? "&nbsp;" : "No match";
    198   $r->{error}   = "";
    199   $r->{checked} = time;
    200 
    201   if(ref($match)) {
    202     # There's an error
    203     $r->{error}   = $match->[0];
    204     $r->{comment} = "$match->[0]: $match->[1]";
    205     $r->{diff}    = "Error: $r->{comment}";
    206     if($last_time_error eq $match->[0] || 
    207        $r->{status} eq "?") {
    208       # Same error as last time or first time call
    209       $r->{status} = $UNCHANGED;
    210     } else {
    211       # 
    212       $r->{status} = $CHANGED;
    213       email($r);
    214     }
    215     return;
    216   }
    217 
    218   if($last_time_error) {
    219     $r->{status}    = $CHANGED;
    220     $r->{lstchange} = time;
    221     $r->{match}     = $match;
    222     $r->{diff} = "Recovered from $last_time_error";
    223     email($r);
    224   } elsif($r->{match} eq $match) {
    225     $r->{status} = $UNCHANGED;
    226   } else {    
    227     if($r->{status} eq '?') {
    228       $r->{status}  = $UNCHANGED;
    229       $r->{match}   = $match;
    230       return;
    231     } else {
    232       $r->{status}  = $CHANGED;
    233     }
    234     $r->{lstchange} = time;
    235     $r->{diff}      = mkdiff($r->{match}, $match);
    236     $r->{match}     = $match;
    237     email($r);
    238   }
    239 
    240   return;
    241 }

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.