![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
||||||||
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
|||||||
![]() |
|||||||
![]() |
|
||||||
![]() |
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. Folgende Fälle sind vorstellbar:
Um webwatch.pl einen URL zur Überwachung anzubieten, trägt man denselben einfach in das Textfeld ein (siehe Abb. 1) 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.
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!
Listing 1: webwatch.pl |
1 #!/usr/bin/perl -w 2 ################################################## 3 # Michael Schilli, 1999 (mschilli@perlmeister.com) 4 ################################################## 5 6 use Algorithm::Diff qw/diff/; 7 use LWP::UserAgent; 8 use Storable; 9 use HTML::Entities; 10 use CGI 2.38 qw/:standard/; 11 use CGI::Carp qw/fatalsToBrowser/; 12 13 my $DB = "/tmp/controlletti.dat"; 14 my $EMAIL_TO = "bgates\@microsoft.com"; 15 my $EMAIL_FROM = "webwatch\@host.com"; 16 17 my $CHANGED = "<font color=red>CHANGED</font>"; 18 my $UNCHANGED = "unchanged"; 19 20 sub esc { encode_entities($_[0]); }; 21 22 if (-r $DB) { 23 my $store = retrieve($DB) || 24 die("$DB: Cannot restore"); 25 @STORE = @$store; 26 } else { 27 @STORE = (); 28 } 29 30 if(!$ENV{'REMOTE_ADDR'}) { # Command line call 31 foreach $r (@STORE) { run_test($r); } 32 store(\@STORE, $DB) || die "Store $DB failed"; 33 exit(0); 34 35 } elsif(param('runall')) { # Run all tests 36 foreach $r (@STORE) { run_test($r); } 37 38 } elsif(param('new')) { # Insert new record 39 push(@STORE, 40 {url => param('url'), rgx => param('rgx'), 41 status => '?', id => time . $$}); 42 43 } elsif(param('del')) { # Delete record 44 @STORE = grep { $_->{id} != param('id') } @STORE; 45 46 } elsif(param('upd')) { # Update record 47 ($r) = grep { $_->{id} == param('id') } @STORE; 48 $r->{url} = param('url'); 49 $r->{rgx} = param('rgx'); 50 51 } elsif(param('run')) { # Run test now 52 ($r) = grep { $_->{id} == param('id') } @STORE; 53 run_test($r); 54 55 } elsif(param('cpdown') || param('id')) { 56 # Copy record to edit fields 57 ($r) = grep { $_->{id} == param('id') } @STORE; 58 param('url', $r->{url}); 59 param('rgx', $r->{rgx}); 60 } 61 62 63 # Display list 64 print header(), start_html(-BGCOLOR => 'white'), 65 h1("Web Watcher"), "<TABLE>"; 66 print "<TABLE BORDER=1>", 67 TR(map { th($_) } qw/URL Regex Checked 68 Status Comment LstChange Commands/); 69 foreach $r (@STORE) { 70 my $chktime = $r->{checked} ? 71 scalar localtime($r->{checked}) : 72 "Not Yet"; 73 print TR( 74 td(a({href => $r->{url}}, $r->{url})), 75 td(esc($r->{rgx}) || " "), 76 td($chktime), 77 td($r->{status}), 78 td($r->{comment}), 79 td(scalar localtime $r->{lstchange}), 80 td(a({href => url() . "?cpdown=1&id=$r->{id}"}, 81 "CpDown"), " ", 82 a({href => url() . "?del=1&id=$r->{id}"}, 83 "Del"), " ", 84 a({href => url() . "?run=1&id=$r->{id}"}, 85 "Run"), " ", 86 )); 87 } 88 print "</TABLE>"; 89 90 # Link for running all tests 91 print p, a({href => url() . "?runall=1"}, 92 "Run all tests"); 93 94 # Form for new entries 95 print h2("New Entry"), start_form(), 96 table( 97 TR(td("URL:"), 98 td(textfield(-size => 80, -name => 'url'))), 99 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 $r->{match} = $match; 228 if($r->{status} eq '?') { 229 $r->{status} = $UNCHANGED; 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 } |
Der Autor |
Michael Schilli arbeitet als Web-Engineer für America Online Inc., Mountain View (Netscape-Gebäude!). Er ist Autor des 1998 bei Addison-Wesley erschienenen (und 1999 für den englischsprachigen Markt als "Perl Power" herausgekommenen) Buches "GoTo Perl 5" und unter michael@perlmeister.com oder http://perlmeister.com zu erreichen. |
Copyright © 1999 Linux-Magazin Verlag