Aus Linux-Magazin 10/2005

Messenger-Plugin verschafft Vorteil durch Webseiten-Überwachung

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.

Abbildung 1: Neue Fragen sind eingetroffen. Jetzt hat der Ersthelfer Chancen, Punkte zu sammeln.

Abbildung 1: Neue Fragen sind eingetroffen. Jetzt hat der Ersthelfer Chancen, Punkte zu sammeln.

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.

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:
»pmwatcher.pl«

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.

Abbildung 3: Das neue Perl-Plugin lässt sich hier dauerhaft aktivieren.

Abbildung 3: Das neue Perl-Plugin lässt sich hier dauerhaft aktivieren.

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


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]

LINUX-MAGAZIN KAUFEN
EINZELNE AUSGABE Print-Ausgaben Digitale Ausgaben
ABONNEMENTS Print-Abos Digitales Abo
TABLET & SMARTPHONE APPS Readly Logo
E-Mail Benachrichtigung
Benachrichtige mich zu:
0 Kommentare
Älteste
Neuste Beste Bewertung
Inline Feedbacks
Alle Kommentare anzeigen
Nach oben