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:
Eine Seite existiert noch nicht, wie zum Beispiel die der nächsten Ausgabe einer Zeitschrift. Der Zugriff auf den URL verursacht einen Fehler (z.B. 404: File not found). Erscheint die Seite eines Tages, soll der Alarm losgehen. Zu registrieren: Nur der URL, der reguläre Ausdruck kann entfallen.
Eine Seite enthält einen Textstring, auf dessen Verschwinden gewartet wird, z.B. steht auf den Beschreibungsseiten von Büchern, die von Amazon noch nicht lieferbar sind, 'Not Yet'. Ist das Buch vorrätig, verschwindet der String und das Skript schlägt Alarm. Zu registrieren: Der URL und als regulärer Ausdruck der String, der verschwinden soll.
Man wartet auf generelle Veränderungen auf einer Seite -- z.B. neue
Einträge auf der Seite mit den Perl-Neuigkeiten. Kommen neue Nachrichten
hinzu, schlägt das Skript an. Zu registrieren: Der URL und, optional,
ein regulärer Ausdruck, der einen bestimmten Seitenbereich abdeckt
(wie z.B. <TABLE>.*?</TABLE>
für den Inhalt der
ersten im HTML vorkommenden Tabelle). Bei weggelassenem Regex
wird die gesamte Seite überwacht.
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.
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 <>&
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 " "
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.
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!
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}) || " "), 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 ? " " : "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 Schilliarbeitet 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. |