Fair Gaim (Linux-Magazin, Oktober 2005)

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.

Wer bremst, verliert

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]).

Nadel im Heuhaufen

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.

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 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.

Listing 1: pmwatcher.pl

    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 }

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/2004, http://www.linux-magazin.de/Artikel/ausgabe/2004/07/perl/perl.html

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.