Das Gaim-Projekt bietet einen Instant-Messenger-Client, der nicht nur sämtliche Protokolle der führenden Anbieter spricht, sondern sich auch noch mit Perl-Plugins erweitern lässt.
Auf perlmonks.com steht die Welt Kopf: Dort stürzen sich hochkalibrige Perl-Hacker scharenweise selbst auf simple Anfängerfragen. Der Grund: Die Community vergibt Punkte für die besten Antworten. Und mit steigender Punktzahl steigen die Ratgebenden vom Messdiener (Acolyte) über den Mönch (Monk) langsam Rang um Rang zum Papst (Pontiff) und dann zum Heiligen (Saint) auf. Das System funktioniert seit Jahren, die ``Szene'' auf perlmonks.com ist eines der besten Beispiele für eine funktionierende Internet-Community.
Die meisten Punkte gibt's erfahrungsgemaß, wenn man eine Frage als Erster richtig beantwortet. Statt den ganzen Tag die Seite mit den neuesten Fragen abzufragen, liegt es also nahe, die Abfragen von einem Skript durchführen zu lassen, das bei neu erfassten Fragen sofort seinen fleißig arbeitenden Betreiber benachrichtigt.
Das heute vorgestellte Skript pmwatcher.pl fragt in regelmäßigen Abständen die Seite mit den ``Newest Nodes'' auf perlmonks.com ab, merkt sich alte Hüte und sendet bei neu auftauchenden Postings eine Kurznachricht per Instant Message.
Abbildung 1: Neue Perl-Fragen kommen per Instant Message an. Ran an den Speck! |
Die Implmentierung stellt allerdings einiges auf den Kopf: Statt einem
allein operierenden Skript, das bei Bedarf einen Instant-Messenger-Client
mit der Nachrichtenübermittlung beauftragt, ist
pmwatcher.pl
ein Gaim-Plugin, den die Gaim-Applikation
in
regelmäßigen Abständen aufruft. Der Plugin holt dann
die neuesten Postings ein und setzt über interne Gaim-Schnittstellen
Instant Messages an den Benutzer ab.
Allerdings darf ein gaim
-Plugin keine Zeit vertrödeln. Während
der Plugin vor sich hin werkelt, kann die gaim
-Applikation keine
Events mehr bearbeiten und die schöne GUI friert ein. gaim
kontaktiert
den Plugin zwar in regelmäsigen Abständen, doch dieser muss
die Kontrolle sofort wieder zurück an die Applikation geben.
Den Inhalt einer Website vom Netz zu holen, dauert unter Umständen einige Sekunden. Friert die GUI in dieser Zeit ein, führt das zu spürbaren Beeinträchtigungen des Benutzers. Deswegen sollten alle I/O-Aktionen im Plugin asyncron erfolgen, sowohl die DNS-Auflösung als auch das Einholen der geforderten Webseite. Statt all dies neu zu programmieren, bietet es sich an, ein bewährtes Multitasking-Framework wie POE zu verwenden. Wie schon einmal in [3] besprochen, läuft im POE-Kernel nur einziger Prozess. Kooperatives Multitasking zwischen konkurrierenden Aufgaben sorgt dafür, dass jede ihre Zeitscheibe abbekommt.
Abbildung 2: Diagramm: Gaim kommuniziert mit dem Perl-Plugin, der wiederum einen POE-Zustandsautomaten kontrolliert. |
POE nimmt allerdings die Dinge gerne selbst in die Hand und
lässt normalerweise seinen eigenen ``Kernel'' laufen. Es lässt aber
auch sehr einfach mit anderen Event-Schleifen wie die von Gtk oder
perl/Tk integrieren. Und auch für eher ungewöhnliche Szenarien wie
die einer Plugin-Architektur lässt sich eine Lösung finden: In der
ursprünglich von gaim
aufgerufen Plugin-Prozedur plugin_load()
definiert das Plugin-Skript pmwatcher.pl
die verschiedenen Tasks,
die der Kernel später ablaufen lässt. Bevor plugin_load()
aber
die Kontrolle zurück an Gaim reicht, ruft es
Gaim::timeout_add($plugin, $UPDATE, \&refresh);
auf, was garantiert, dass gaim
nach Ablauf von $UPDATE
Sekunden wieder die Plugin-Funktion refresh()
aufruft.
Dort springt dann der POE-Kernel kurz an, erledigt die dringendsten
Aufgaben, und gibt dann die Kontrolle wieder an den Gaim-Kern
zurück.
Das zu plugin_load()
hereingereichte Plugin-Objekt speichert
pmwatcher.pl
in der globalen Variablen $PLUGIN
,
damit refresh()
später wieder einen
neuen Timeout im Gaim-Kern beantragen kann. Damit ruft dieser
wieder zurück und der Kreis der Eventschleife schließt sich.
Weiter abonnieren die Aufrufe von Gaim::signal_connect
in den Zeilen
49 und 55 Meldungen vom Gaim-Kern von Benutzern,
die sich an- und abmeldenden.
Der Gaim-Kern springt in diesen Fällen die im Plugin
definierten Funktionen buddy_signed_on_callback
und
buddy_signed_off_callback
an. Sie prüfen, ob der
gemeldete Benutzername mit dem in Zeile 18 festgesetzten übereinstimmt.
Falls ja, speichert buddy_signed_on_callback
die Gaim-eigene Benutzerstruktur in der globalen Variablen $BUDDY
.
Sie wird später genutzt, um dem Benutzer eine Nachricht zu senden.
Falls der Benutzer sich gerade anmeldet, setzt buddy_signed_on_callback
das Flag $ACTIVE
auf 1, falls er sich abmeldet, setzt
buddy_signed_off_callback
es auf 0. $ACTIVE
steuert im
jede Sekunde aufgerufenen callback refresh
(Zeile 120), ob tatsächlich
eine Zeitscheibe des POE-Kernels abläuft oder gar nichts passiert.
Falls refresh
die Methode run()
des POE-Kernels aufriefe, würde
dieser nie mehr zurückkehren. Statt dessen ruft Zeile 126
run_one_timeslice()
auf, was nur alle aufgestauten Tasks abhandelt,
aber nicht auf Ereignisse wartet, sondern sofort zurückkehrt.
Da mit jeder Zeitscheibe nur ein kleiner Teil eines Requests bearbeitet
wird, kann der ganze HTTP-Request schon 20 refresh()
-Zyklen lang
dauern, aber das spielt keine große Rolle.
Wichtig ist nur, dass die CPU während
des Callbacks mit voller Geschwindigkeit läuft, auf externe Ereignisse
wie das Eintrudeln der Antwort eines HTTP-Requests darf nicht gewartet
werden. Der POE-Kernel mit der Komponente POE::Component::Client::HTTP
erledigt dies zuverlässig.
Der in Zeile 68 definierte Anfangszustand _start
des POE-Kernels leitet
nur den nächsten Zustand http_start
ein. Dort wird die mit ua
gekennzeichnete Komponente POE::Component::Client::HTTP
gestartet,
die, sobald das Ergebnis vorliegt, den Kernel veranlasst,
den Zustand http_ready
anzuspringen.
Bevor http_start
sich beendet, beantragt
es noch einen POE-Timeout 10 Minuten später, der wieder den Zustand
http_start
auslöst, um das nächste Mal die Webseite einzuholen.
Der Handler des Zustands http_ready
bekommt
in $_[ARG1]
eine Referenz auf einen Array hereingereicht,
dessen erstes
Element ein Objekt vom Typ HTTP::Response hereingereicht, das
das Ergebnis des Web-Requests gespeichert hat.
(Vage erinnern wir uns an POEs ungewöhnliche Parameterübergabe
aus [3]).
Um aus der vom Web geholten Perlmonks-Seite die Links und Texte
der ``Questions''-Sektion herauszufieseln, implementiert die Funktion
qparse
ab Zeile 186 einen HTML-Parser. Nicht jeder Link auf der
Seite gehört zu einer neuen Frage, es gibt noch andere Sektionen
wie Discussion oder Meditations, die pmwatcher.pl
aber
ignorieren soll.
HTML::TreeBuilder erzeugt aus einem HTML-Dokument einen Baum von
HTML-Elementen. In diesem navigiert qparse
zunächst zu einem
benannten <A>
-Element, welches
``toc-Questions'' im Attribut name
führt.
Vom gefundenen Knoten vom Typ HTML::Element
geht der Weg mit der
Methode parent()
um eine Etage höher im Baum. Dort sucht die
while
-Schleife ab Zeile 209 dann nach einem <table>
-Element,
in dem es auf der gleichen Hierarchie-Ebene mit right()
nach rechts
fährt. Diese Tabelle enthält in der ersten Spalte die Fragen als
Links und in der zweiten Spalte den Link des Fragestellers.
Deswegen fährt die erste for
-Schleife ab Zeile 213 alle
<tr>
-Elemente
(also Tabellenreihen) an und die innere
Schleife sucht darin jeweils nach
<a>
-Links. Die Methode look_down()
eines Baumelements
sucht, ausgehend vom gegenwärtigen Element abwärts nach Knoten
mit bestimmten Merkmalen und gibt die passenden Elemente als Liste
zurück. Die Bedingung _tag => $tagname
sucht nach Tags mit
den geforderten Namen. attrname => $attrvalue
prüft, ob das
gefundene Tag ein Attribut mit dem eingestellten Namen führt.
Dem jeweils ersten gefundenen Link wird sein
Linktext und das href
-Attribut entlockt, letzteres in einen absoluten
URL umgewandelt und schließlich beide Werte in einem Element
des Arrays %questions gespeichert. Den jeweils zweiten Link (den
des Fragestellers) unterdrückt der Doppelpack der for
-Schleifen,
mit der Anweisung last
nach dem Ende der inneren Schleife.
Wichtig ist noch, nach dem Parsen den Baum mit delete()
abzubauen,
damit kein wertvoller Speicher verloren geht.
Alternative Parser wären XML::LibXML oder XML::XSH gewesen, die mit mächtiger XPath-Syntax arbeiten. Allerdings steigen beide bei schlampigen HTML-Dokumenten, die Webbrowser noch großzügig verarbeiten, schnell aus.
Das Skript muss sich nun noch merken, welche Fragen es schon gemeldet
hat und welche tatsächlich neu sind. Hierzu macht es sich zunutze, dass
auf Perlmonks.com jede Frage eine eindeutige numerische Node-Nummer hat,
die im URL der Frage versteckt ist.
Sie wird mit einem regulären Ausdruck aus dem URL extrahiert und
mit der letzten gespeicherten Nummer aus einem
persistenten Cache::FileCache
-Objekt verglichen.
Nur falls die gefundene Frage
eine höhere Node-Nummer hat, gilt sie als neu. Anschließend wandert
die höchste derzeit bekannte Node-Nummer für folgende
Anfragen in den Cache.
Die Funktion
latest_news
gibt einen Array von formatierten IM-Nachrichten
für den Benutzer zurück.
Ist der Array leer, gibt's nichts neues. Falls doch, erzeugt Zeile 139
aus der global gespeicherten Gaim-Struktur des Benutzers ein
Objekt der Klasse Gaim::Conversation::IM
und ruft für jede
einzelne Nachricht dessen Methode send()
auf.
Wer gaim
noch nicht hat, holt sich am besten die neue Version
1.4.0 von gaim.sourceforge.net ab. Die Perl-Schnittstelle Gaim.pm ist
nicht vom CPAN erhältlich, sondern in der gaim
-Distribution
enthalten und kann mit
cd plugins/perl/common perl Makefile.PL make install
installiert werden. Das Plugin-Verzeichnis muss manuell mit
mkdir ~/.gaim/plugins
angelegt werden. Darin enthaltene Perl-Skripts,
die den Anforderungen genügen, werden beim Programmstart
automatisch als Plugins in gaim
eingehängt. pmwatcher.pl
wird
einfach dort hineinkopiert und gaim
gestartet.
Im Menü Tools->Preferences->Plugins
(Abbildung 1) lässt
sich der Plugin ``pmwatcher'' dann mit einer Checkbox permanent aktivieren.
Die in der Dialogbox angezeigten Daten bezieht gaim
aus dem
Hash %PLUGIN_INFO
, den die Funktion plugin_init()
(Zeile 39
in pmwatcher.pl
) zurückliefert.
Die aufgelisteten Module sind alle vom CPAN erhältlich, zu beachten
ist lediglich, dass POE::Component::Client::HTTP
explizit installiert
werden muss.
Im Skript selbst lässt sich mit $FETCH_INTERVAL
die Zeit zwischen den
einzelnen Webrequests einstellen. Zehn Minuten sind vorgegeben, das
sollte sowohl den Anforderungen an Aktualität genügen und
andererseits die Perlmonks-Betreiber nicht verärgern. Die Variable
$USER
enthält den Instant-Messenger-Namen
des Benutzers, der den Perlmonks-Reigen in Schwung bringt, falls
er sich einloggt. Andernfalls wird zwar der Plugin jede Sekunde
angesprungen, aber es finden keine Web-Requests statt.
Wird gaim
mit der Option -d
für debug gestartet, kommen die
im Perlskript mit der Funktion Gaim::debug_info
abgesetzten
Logmeldungen auf der Standardausgabe heraus. Gaims Plugin-Skripts laufen
übrigens nicht von der Kommandozeile,
sondern brechen mit einer Fehlermeldung ab.
Abbildung 3: Der neue Perl-Plugin wird angeschaltet. |
Bevor der in Zeile 24 eingestellte Benutzer nicht Online ist, passiert nichts. Loggt er sich ein, egal auf welchem Netzwerk, wird alle 10 Minuten die perlmonks.com-Website eingeholt, auf Veränderungen geprüft und dem punktehungrigen Perl-Spezialisten auf direktem Wege mitgeteilt.
001 #!/usr/bin/perl -w 002 use strict; 003 use Gaim; 004 use HTML::TreeBuilder; 005 use URI::URL; 006 use CGI qw(a); 007 use Cache::FileCache; 008 use POE qw(Component::Client::HTTP); 009 use HTTP::Request::Common; 010 011 our $FETCH_INTERVAL = 600; 012 our $FETCH_URL = "http://perlmonks.com/" . 013 "?node=Newest%20Nodes"; 014 015 our $ACTIVE = 0; 016 # Call plugins every second 017 our $UPDATE = 1; 018 our $USER = "mikeschilli"; 019 our $BUDDY = undef; 020 our $PLUGIN = undef; 021 022 our %PLUGIN_INFO = ( 023 perl_api_version => 2, 024 name => "pmwatcher", 025 summary => "Perlmonks Watch Plugin", 026 version => "1.0", 027 description => "Reports latest postings " 028 . "on perlmonks.com", 029 author => "Mike Schilli " . 030 "<m\@perlmeister.com>", 031 load => "plugin_load", 032 ); 033 034 our $cache = new Cache::FileCache({ 035 namespace => "pmwatcher", 036 }); 037 038 ########################################### 039 sub plugin_init { 040 ########################################### 041 return %PLUGIN_INFO; 042 } 043 044 ########################################### 045 sub plugin_load { 046 ########################################### 047 my($plugin) = @_; 048 049 Gaim::signal_connect( 050 Gaim::BuddyList::handle(), 051 "buddy-signed-on", $plugin, 052 \&buddy_signed_on_callback, 053 ); 054 055 Gaim::signal_connect( 056 Gaim::BuddyList::handle(), 057 "buddy-signed-off", $plugin, 058 \&buddy_signed_off_callback, 059 ); 060 061 POE::Component::Client::HTTP->spawn( 062 Alias => "ua", 063 Timeout => 60, 064 ); 065 066 POE::Session->create( 067 inline_states => { 068 _start => sub { 069 $poe_kernel->yield('http_start'); 070 }, 071 http_start => sub { 072 Gaim::debug_info("pmwatcher", 073 "Fetching $FETCH_URL\n"); 074 $poe_kernel->post("ua", "request", 075 "http_ready", GET $FETCH_URL); 076 $poe_kernel->delay('http_start', 077 $FETCH_INTERVAL); 078 }, 079 http_ready => sub { 080 Gaim::debug_info("pmwatcher", 081 "http_ready $FETCH_URL\n"); 082 my $resp= $_[ARG1]->[0]; 083 if($resp->is_success()) { 084 pm_update($resp->content()); 085 } else { 086 Gaim::debug_info("pmwatcher", 087 "Can't fetch $FETCH_URL: " . 088 $resp->message()); 089 } 090 }, 091 } 092 ); 093 094 Gaim::timeout_add($plugin, $UPDATE, 095 \&refresh); 096 $PLUGIN = $plugin; 097 } 098 099 ########################################### 100 sub buddy_signed_on_callback { 101 ########################################### 102 my ($buddy, $data) = @_; 103 104 return if $buddy->get_alias ne $USER; 105 $ACTIVE = 1; 106 $BUDDY = $buddy; 107 } 108 109 ########################################### 110 sub buddy_signed_off_callback { 111 ########################################### 112 my ($buddy, $data) = @_; 113 114 return if $buddy->get_alias ne $USER; 115 $ACTIVE = 0; 116 $BUDDY = undef; 117 } 118 119 ########################################### 120 sub refresh { 121 ########################################### 122 123 Gaim::debug_info("pmwatcher", 124 "Refresh (ACTIVE=$ACTIVE)\n"); 125 if($ACTIVE) { 126 $poe_kernel->run_one_timeslice(); 127 } 128 129 Gaim::timeout_add($PLUGIN, $UPDATE, 130 \&refresh); 131 } 132 133 ########################################### 134 sub pm_update { 135 ########################################### 136 my($html_text) = @_; 137 138 if(my @nws = latest_news($html_text)) { 139 my $c = Gaim::Conversation::IM::new( 140 $BUDDY->get_account(), 141 $BUDDY->get_name()); 142 143 $c->send("$_\n") for @nws; 144 } 145 } 146 147 ########################################### 148 sub latest_news { 149 ########################################### 150 my($html_string) = @_; 151 152 my $start_url = 153 URI::URL->new($FETCH_URL); 154 155 my $max_node; 156 157 my $saved = $cache->get("max-node"); 158 $saved = 0 unless defined $saved; 159 160 my @aimtext = (); 161 162 for my $entry (@{qparse($html_string)}) { 163 my($text, $url) = @$entry; 164 165 my($node) = $url =~ /(\d+)$/; 166 if($node > $saved) { 167 Gaim::debug_info("pmwatcher", 168 "*** New node $text ($url)"); 169 $url = a({href => $url}, $url); 170 push @aimtext, 171 "<b>$text</b>\n$url"; 172 } 173 174 $max_node = $node if 175 !defined $max_node or 176 $max_node < $node; 177 } 178 179 $cache->set("max-node", $max_node) 180 if $saved < $max_node; 181 182 return @aimtext; 183 } 184 185 ########################################### 186 sub qparse { 187 ########################################### 188 my($html_string) = @_; 189 190 my $start_url = 191 URI::URL->new($FETCH_URL); 192 193 my @questions = (); 194 195 my $parser = HTML::TreeBuilder->new(); 196 my $tree = $parser->parse($html_string); 197 198 my($questions) = $tree->look_down( 199 "_tag", "a", 200 "name", "toc-Questions"); 201 202 if(! $questions) { 203 Gaim::debug_info("pmwatcher", 204 "Couldn't find Questions section"); 205 return undef; 206 } 207 208 my $node = $questions->parent(); 209 while($node->tag() ne "table") { 210 $node = $node->right(); 211 } 212 213 for my $tr ($node->look_down( 214 "_tag", "tr")) { 215 for my $a ($tr->look_down( 216 "_tag", "a")) { 217 my $href = $a->attr('href'); 218 my $text = $a->as_text(); 219 my $url = URI::URL->new($href, 220 $start_url); 221 222 push @questions, 223 [$text, $url->abs()]; 224 # Process only the question 225 # node, not the author's node 226 last; 227 } 228 } 229 230 $tree->delete(); 231 return \@questions; 232 }
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. |