Der Instant-Messenger-Client Gaim spricht nicht nur viele Protokolle, sondern lässt sich auch noch mit Perl-Plugins erweitern. Eines versetzt sogar Atheisten in die Lage, katholisch aufzusteigen.
Auf Perlmonks.com steht die Welt Kopf: Dort stürzen sich hochkarätige Perl-Hacker selbst auf simple Anfängerfragen, weil die Community Punkte für die besten Antworten vergibt. Mit wachsender Punktzahl steigen die Ratgebenden vom Messdiener (Acolyte) über Mönch (Monk) und Papst (Pontiff) schließlich zum Heiligen (Saint) auf. Das System funktioniert seit Jahren, Perlmonks.com ist eines der besten Beispiele für eine funktionierende Internet-Community.
Punktbester
Erfahrungsgemäß erhält die meisten Punkte, wer eine Frage als Erster richtig beantwortet. Statt nun die Seite mit den neuesten Fragen selbst immer wieder aufzurufen, liegt es nahe, ein Skript damit zu beauftragen, das seinen Auftraggeber benachrichtigt, sobald es auf Neues stößt (Abbildung 1).
Der hier vorgestellte Pmwatcher.pl tut genau dies: Das Skript fragt in regelmäßigen Abständen die Seite mit »Newest Nodes« auf Perlmonks.com nach Einträgen ab und merkt sich alte Hüte. Bei neuen Postings sendet es sofort eine Kurznachricht als Instant Message.
Die Implementierung stellt einiges auf den Kopf. Das Skript beauftragt nicht etwa einen externen IM-Client mit der Nachrichtenübermittlung, sondern fungiert selbst als Plugin eines solchen Messengers. Gaim, die Mutter-Applikation ruft es regelmäßig auf. Das Plugin holt daraufhin die neuesten Postings und verschickt über interne Schnittstellen Messages an den Benutzer (Abbildung 2).
Wer bremst, verliert
Doch darf ein Gaim-Plugin keine Zeit vertrödeln: Während es vor sich hin werkelt, kann die Gaim-Applikation keine Events mehr bearbeiten und ihr schönes GUI friert ein. Also muss das Plugin die Kontrolle sofort wieder zurückgeben. Den Inhalt einer Website vom Netz holen dauert unter Umständen aber einige Sekunden. Daher sollten alle I/O-Aktionen im Plugin asynchron 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 unter POE nur ein einziger Prozess. Kooperatives Multitasking zwischen konkurrierenden Aufgaben sorgt dafür, dass jede ihre Zeitscheibe bekommt.
POE nimmt die Dinge gerne selbst in die Hand und lässt normalerweise seine eigene Event-Schleife laufen. Es ist aber auch in andere Event-Schleifen wie die von GTK oder Perl/Tk integrierbar. Auch für eher ungewöhnliche Szenarien wie die einer Plugin-Architektur findet sich eine Lösung: In der von Gaim aufgerufen Plugin-Prozedur »plugin_load()« definiert das Plugin-Skript Pmwatcher.pl die verschiedenen Tasks, die es später ablaufen lässt.
Bevor »plugin_load()« die Kontrolle an Gaim zurückreicht, ruft es »Gaim::timeout_add($plugin, $UPDATE, &refresh);« auf, was garantiert, dass Gaim nach Ablauf von »$UPDATE« Sekunden wieder die Plugin-Funktion »refresh()« startet. Dort springt POE kurz an, erledigt dringende Aufgaben und gibt die Kontrolle an Gaim zurück.
Anwesenheitskontrolle
Das zu »plugin_load()« hereingereichte Plugin-Objekt speichert Pmwatcher.pl in der globalen Variablen »$PLUGIN«, so kann »refresh()« später einen neuen Timeout bei Gaim beantragen. Weiter abonnieren die Aufrufe von »Gaim::signal_connect« in den Zeilen 53 und 55 (Listing 1) Meldungen von Gaim-Benutzern, die sich an- und abmelden.
Gaim springt daraufhin die im Plugin definierten Funktionen »buddy_signed_on_callback« und »buddy_signed_off_callback« an. Sie prüfen, ob der gemeldete Benutzername dem in Zeile 22 festgesetzten entspricht. Wenn dem so ist, speichert die Funktion »buddy_signed_on_callback« die Gaim-eigene Benutzerstruktur in der globalen »$BUDDY«-Variablen. Sie ist nötig, wenn später dem Benutzer eine Nachricht zu senden ist.
Eigenregie
Falls sich der Benutzer 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 »refresh()«-Callback (Zeile 124), ob tatsächlich eine POE-Zeitscheibe abläuft oder nichts passiert. Falls »refresh()« die POE-Methode »run()« aufriefe, würde diese nie mehr zurückkehren. Stattdessen ruft Zeile 130 »run_one_timeslice()« auf, die eine aufgestaute Task abhandelt und – ohne auf Events zu warten – zurückkehrt.
Da der Code in jeder Zeitscheibe nur einen kleinen Teil des Requests bearbeitet, kann der ganze HTTP-Request schon 20 »refresh()«-Zyklen dauern. Das spielt keine große Rolle. Wichtig ist nur, dass die CPU während des Callbacks nicht auf externe Ereignisse wartet, etwa die Antwort auf einen HTTP-Request. POE erledigt dies mit der Komponente POE::Component::Client::HTTP zuverlässig.

Abbildung 2: Gaim kommuniziert mit dem Perl-Plugin, das wiederum einen POE-Zustandsautomaten (Perl Object Environment) kontrolliert.
Der in Zeile 72 definierte Anfangszustand »_start« leitet nur den nächsten Zustand »http_start« ein. Dort startet die mit »ua« gekennzeichnete Komponente »POE::Component::Client::HTTP«, das, sobald das Ergebnis vorliegt, den Zustand »http_ready« anspringt. 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« erhält in »$_[ARG1]« eine Referenz auf ein Array, dessen erstes Element (ein Objekt vom Typ HTTP::Response) das Ergebnis des Webrequest speichert. Weitere Erläuterungen zu POEs etwas ungewöhnlicher Art der Übergabe von Parametern geben [3] und [4].
Um aus der Perlmonks-Webseite die Links und Texte der Questions-Sektion herauszufieseln, implementiert die Funktion »qparse« ab Zeile 190 einen HTML-Parser. Andere Sektionen, die mit den gesuchten Fragen nichts zu tun haben, etwa Discussion oder Meditations, soll Pmwatcher.pl ignorieren.
|
Listing 1: |
|---|
001 #!/usr/bin/perl -w
002 ###########################################
003 # pmwatcher - Gaim plugin to watch
004 # perlmonks.com
005 ###########################################
006 use strict;
007 use Gaim;
008 use HTML::TreeBuilder;
009 use URI::URL;
010 use CGI qw(a);
011 use Cache::FileCache;
012 use POE qw(Component::Client::HTTP);
013 use HTTP::Request::Common;
014
015 our $FETCH_INTERVAL = 600;
016 our $FETCH_URL = "http://perlmonks.com/" .
017 "?node=Newest%20Nodes";
018
019 our $ACTIVE = 0;
020 # Call plugins every second
021 our $UPDATE = 1;
022 our $USER = "mikeschilli";
023 our $BUDDY = undef;
024 our $PLUGIN = undef;
025
026 our %PLUGIN_INFO = (
027 perl_api_version => 2,
028 name => "pmwatcher",
029 summary => "Perlmonks Watch Plugin",
030 version => "1.0",
031 description => "Reports latest postings "
032 . "on perlmonks.com",
033 author => "Mike Schilli " .
034 "<m@perlmeister.com>",
035 load => "plugin_load",
036 );
037
038 our $cache = new Cache::FileCache({
039 namespace => "pmwatcher",
040 });
041
042 ###########################################
043 sub plugin_init {
044 ###########################################
045 return %PLUGIN_INFO;
046 }
047
048 ###########################################
049 sub plugin_load {
050 ###########################################
051 my($plugin) = @_;
052
053 Gaim::signal_connect(
054 Gaim::BuddyList::handle(),
055 "buddy-signed-on", $plugin,
056 &buddy_signed_on_callback,
057 );
058
059 Gaim::signal_connect(
060 Gaim::BuddyList::handle(),
061 "buddy-signed-off", $plugin,
062 &buddy_signed_off_callback,
063 );
064
065 POE::Component::Client::HTTP->spawn(
066 Alias => "ua",
067 Timeout => 60,
068 );
069
070 POE::Session->create(
071 inline_states => {
072 _start => sub {
073 $poe_kernel->yield('http_start');
074 },
075 http_start => sub {
076 Gaim::debug_info("pmwatcher",
077 "Fetching $FETCH_URLn");
078 $poe_kernel->post("ua", "request",
079 "http_ready", GET $FETCH_URL);
080 $poe_kernel->delay('http_start',
081 $FETCH_INTERVAL);
082 },
083 http_ready => sub {
084 Gaim::debug_info("pmwatcher",
085 "http_ready $FETCH_URLn");
086 my $resp= $_[ARG1]->[0];
087 if($resp->is_success()) {
088 pm_update($resp->content());
089 } else {
090 Gaim::debug_info("pmwatcher",
091 "Can't fetch $FETCH_URL: " .
092 $resp->message());
093 }
094 },
095 }
096 );
097
098 Gaim::timeout_add($plugin, $UPDATE,
099 &refresh);
100 $PLUGIN = $plugin;
101 }
102
103 ###########################################
104 sub buddy_signed_on_callback {
105 ###########################################
106 my ($buddy, $data) = @_;
107
108 return if $buddy->get_alias ne $USER;
109 $ACTIVE = 1;
110 $BUDDY = $buddy;
111 }
112
113 ###########################################
114 sub buddy_signed_off_callback {
115 ###########################################
116 my ($buddy, $data) = @_;
117
118 return if $buddy->get_alias ne $USER;
119 $ACTIVE = 0;
120 $BUDDY = undef;
121 }
122
123 ###########################################
124 sub refresh {
125 ###########################################
126
127 Gaim::debug_info("pmwatcher",
128 "Refresh (ACTIVE=$ACTIVE)n");
129 if($ACTIVE) {
130 $poe_kernel->run_one_timeslice();
131 }
132
133 Gaim::timeout_add($PLUGIN, $UPDATE,
134 &refresh);
135 }
136
137 ###########################################
138 sub pm_update {
139 ###########################################
140 my($html_text) = @_;
141
142 if(my @nws = latest_news($html_text)) {
143 my $c = Gaim::Conversation::IM::new(
144 $BUDDY->get_account(),
145 $BUDDY->get_name());
146
147 $c->send("$_n") for @nws;
148 }
149 }
150
151 ###########################################
152 sub latest_news {
153 ###########################################
154 my($html_string) = @_;
155
156 my $start_url =
157 URI::URL->new($FETCH_URL);
158
159 my $max_node;
160
161 my $saved = $cache->get("max-node");
162 $saved = 0 unless defined $saved;
163
164 my @aimtext = ();
165
166 for my $entry (@{qparse($html_string)}) {
167 my($text, $url) = @$entry;
168
169 my($node) = $url =~ /(d+)$/;
170 if($node > $saved) {
171 Gaim::debug_info("pmwatcher",
172 "*** New node $text ($url)");
173 $url = a({href => $url}, $url);
174 push @aimtext,
175 "<b>$text</b>n$url";
176 }
177
178 $max_node = $node if
179 !defined $max_node or
180 $max_node < $node;
181 }
182
183 $cache->set("max-node", $max_node)
184 if $saved < $max_node;
185
186 return @aimtext;
187 }
188
189 ###########################################
190 sub qparse {
191 ###########################################
192 my($html_string) = @_;
193
194 my $start_url =
195 URI::URL->new($FETCH_URL);
196
197 my @questions = ();
198
199 my $parser = HTML::TreeBuilder->new();
200 my $tree = $parser->parse($html_string);
201
202 my($questions) = $tree->look_down(
203 "_tag", "a",
204 "name", "toc-Questions");
205
206 if(! $questions) {
207 Gaim::debug_info("pmwatcher",
208 "Couldn't find Questions section");
209 return undef;
210 }
211
212 my $node = $questions->parent();
213 while($node->tag() ne "table") {
214 $node = $node->right();
215 }
216
217 for my $tr ($node->look_down(
218 "_tag", "tr")) {
219 for my $a ($tr->look_down(
220 "_tag", "a")) {
221 my $href = $a->attr('href');
222 my $text = $a->as_text();
223 my $url = URI::URL->new($href,
224 $start_url);
225
226 push @questions,
227 [$text, $url->abs()];
228 # Process only the question
229 # node, not the author's node
230 last;
231 }
232 }
233
234 $tree->delete();
235 return @questions;
236 }
|
Nadel im Heuhaufen
Das Perl-Modul HTML::TreeBuilder erzeugt aus einem Webdokument einen Baum von HTML-Elementen. In diesem navigiert »qparse()« zunächst zu einem »<A>«-Element, das »toc-Questions« im Namensattribut enthält. Vom gefundenen Knoten des Typs HTML::Element geht der Weg mit der Methode »parent()« eine Etage höher im Baum. Dort sucht die »while«-Schleife ab Zeile 213 nach einem »<table>«-Element, in dem es auf der gleichen Hierarchie-Ebene mit »right()« nach rechts fährt.
Die Tabelle enthält in der ersten Spalte die Fragen, in der zweiten den Link zum Fragesteller. Daher fährt die erste For-Schleife ab Zeile 217 alle »<tr>«-Elemente an und die innere Schleife sucht darin »<a>«-Links.
Die Methode »look_down()« eines Baum-elements sucht, ausgehend vom gegenwärtigen Element, abwärts nach Knoten mit bestimmten Merkmalen und gibt sie als Liste zurück. Die Bedingung »_tag => $tagname« sucht nach Tags mit diesem Namen; »attrname => $attrvalue« verlangt darin ein Attribut.
Der Linktext und das Href-Attribut des jeweils ersten Links gelangen in Form einer absoluten URL in ein Element des Array »@questions«. Den zweiten Link (den des Fragestellers) unterdrückt der Doppelpack der For-Schleifen. Dafür sorgt die 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, 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 merken, welche Fragen es schon gemeldet hat und welche neu sind. Dabei nutzt es die eindeutige Node-Nummer, die im URL einer jeden Frage versteckt ist. Sie wird mit einem regulären Ausdruck extrahiert und mit der letzten gespeicherten Nummer aus einem persistenten Cache::FileCache-Objekt verglichen. Falls die gefundene Frage nun eine höhere Node-Nummer hat als die gespeicherte, gilt sie als neu. Anschließend wandert ihre Node-Nummer in den Cache.
Die Funktion »latest_news()« gibt ein Array von formatierten Instant Messages für den Benutzer zurück. Ist das Array leer, gibt es nichts Neues. Ist es dagegen gefüllt, erzeugt Zeile 143 aus der global gespeicherten Gaim-Struktur des angemeldeten Benutzers ein Objekt der Klasse »Gaim::Conversation::IM« und ruft von jedem einzelnen Nachrichten-Objekt die »send()«-Methode auf.
Installation
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 Teil der Gaim-Distribution. Sie ist leicht zu installieren:
cd plugins/perl/common perl Makefile.PL make install
Sein Plugin-Verzeichnis legt jeder User manuell mit »mkdir ~/.gaim/plugins« an. Darin enthaltene Perl-Skripte, die den Anforderungen genügen, hängt Gaim beim Programmstart automatisch als Plugins ein. Es genügt, Pmwatcher.pl dorthin zu kopieren und Gaim zu starten. Im Menü »Tools->Preferences-> Plugins« (Abbildung 3) ist Pmwatcher dann permanent aktivierbar. Die in der Dialogbox angezeigten Daten bezieht Gaim aus dem Hash »%PLUGIN_INFO«, den die Funktion »plugin_init()« (Zeile 43 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 sein muss. Im Skript lässt sich mit »$FETCH_INTERVAL« die Zeit zwischen den Webrequests einstellen, 10 Minuten sind vorgegeben. Das sollte den Anforderungen an Aktualität genügen und 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. Sonst wird zwar das Plugin jede Sekunde angesprungen, aber es finden keine Webrequests statt.
Wenn Gaim mit der Option »-d« (debug) startet, erscheinen die im Perl-Skript mit der Funktion »Gaim::debug_info« abgesetzten Logmeldungen auf der Standardausgabe. Die Plugin-Skripte von Gaim laufen übrigens nicht von der Kommandozeile aus.
Bevor der in Zeile 22 eingestellte Benutzer nicht online ist, passiert nichts. Loggt er sich ein, egal auf welchem Netzwerk, holt das Plugin alle 10 Minuten die Perlmonks.com-Website ein, prüft sie auf Veränderungen und teilt dem punktehungrigen Perl-Spezialisten das Ergebnis mit. Ob\’s bald für die Heiligsprechung reicht? (jcb)
|
Infos |
|---|
|
[1] Listings zu diesem Artikel: [ftp://www.linux-magazin.de/pub/listings/magazin/2005/10/Perl] [2] Mageres Tutorial zu Gaims Perl-Schnittstelle: [http://gaim.soureforge.net/api/perl-howto.html] [3] Michael Schilli, “Trainierter DJ”, Linux-Magazin 07/04: [https://www.linux-magazin.de/Artikel/ausgabe/2004/07/perl/perl.html] [4] Das Perl Object Environment: [http://poe.perl.org] |
|
Der Autor |
|---|
|
|








