Perl-Script zum Erzeugen einer statischen Kopie einer Drupal-Website
(sinngemäße Übersetzung von http://drupal.org/node/764362)
Dieses HowTo beschreibt die einzelnen Schritte, die durchgeführt wurden um Perl Script zu bauen, das eine statische Kopie des Inhalts einer Drupal-Seite zum "offline" Betrachten mittels eines Browsers (z.B. von einer CD) erstellt, ohne dass ein Webserver oder eine Datenbank vonnöten wäre.
Eine solche Kopie kann beispielsweise für Archivierungszwecke verwendet werden oder zum Betrachten der Inhalte, wenn ein Online-Zugriff nicht immer möglich ist. Oder - wie in dem Fall, der zur Erstellung dieses Scripts geführt hat - um eine portable Momentaufnahme der Webseite für Überprüfungszwecke produzieren zu können.
Die hier beschriebene Vorgehensweise ist nicht unbedingt eine generell anwendbare "Spiegeltechnik" - dafür gibt es Werkzeuge, die vielleicht brauchbarer sind, wie beispielsweise wget (http://www.gnu.org/software/wget/), httrack (http://www.httrack.com/) oder Drupals Boost (http://drupal.org/project/boost) oder HTML export (http://drupal.org/project/html_export) Module. Allerdings dürften diese Tools nicht ganz die Feinsteuerung darüber erlauben, wie die Spiegelung stattfindet und das Ergebnis aussieht.
Situationsbeschreibung:
- eine SSL gesicherte Drupal-Webseite, die ein Log-in erfordert, um auf die Inhalte zugreifen zu können
- Drupal ist in einem Unterverzeichnis "secrets" der Seite https://www.mysecrets.com installiert
- der Inhalt der Seite ist als Drupal "book" organisiert
- Datei Anhänge etc. sind alle in einem "private" Verzeichnis
- Die Module "private upload", "private download" und "custom filter" werden dazu verwendet, Zugriffe "von aussen" auf das "private" Verzeichnis zu verhindern
- das Ergebnis des Spiegelungsprozesses soll eine Menge von HTML-, CSS-, Image- und sonstigen Anhang-Dateien sein, die den momentanen Inhalt der Seite in statischer Form widerspiegeln
- alle verwendeten URLs, Verzeichnisnamen, Benutzernamen, Passwörter, usw. sind frei erfunden, jegliche Ähnlichkeit zu existierenden Webseiten sind rein zufällig und keinesfalls beabsichtigt
- diese Beschreibung und die Perlscript Schnipsel werden unter den Bedingungen der GPL zur Verfügung gestellt.
Voraussetzungen:
Um das Perlscript zu benutzen, werden neben Perl selbst auch einige wetere Perl-Pakete benötigt. HTML::TreeBuilder zum Parsen und Manipulieren der HTML Inhalte, LWP als "Browser" und libcrypt-ssleay-perl damit "https" zusammen mit LWP verwendet werden kann.
Nebenbemerkung:
Diese Beschreibung versucht nicht, Perl im einzelnen zu erklären. Es wird im Gegenteil angenommen, dass der Leser bereits einige Perl-Kenntnisse hat. Der gezeigte Perl-Programmierstil dürfte nicht der bestmögliche und/oder eleganteste sein - ich bin mehr oder weniger auch ein Perl-Anfänger. Auch hoffe ich, dass ich mit dem Programmierstil niemanden verärgere :-) .
Die beschriebene Methode ist nicht notwendigerweise auf auf alle möglichen Drupal-Sites anwendbar, aber sie sollte zumindest für so einfache Konstrukte wie Drupal "book" Seiten funktionieren.
Außerdem sollte sich jeder, der eine solche Kopie erstellt, genau bewusst sein, was er da tut - angefangen von der möglichen Last auf der zu kopierenden Seite bis hin zu möglichen Copyrightverletzungen.
Das Script:
Fangen wir mit eine mehr oder weniger typischen deklarativen Abschnitt an, in dem die hauptsächlich global verwendeten Variablen zu finden sind.
#
use strict;
use warnings;
use LWP 5.64;
use HTML::TreeBuilder;
my $browser = LWP::UserAgent->new;
my %urls2retrieve; # a hash to store what we still have to retrieve
my %urlsretrieved; # another hash containing what we retrieved already (to avoid duplicate retrieval)
my $outfile;
my @urlkeys;
my $nexturl2retrieve;
my $typeofnexturl;
Um auf die Inhalte der betrachteten Drupal-Seite zugreifen zu können, müssen wir "logon" sein. Daher müssen wir uns die Cookies merken, die diesen Status für die ganze Dauer des Scripts beschreiben. Danach führen wir das Logon durch.
# the cookie store is in memory
$browser->cookie_jar({});
# Initial contact
my $host = 'https://www.mysecrets.com'; # the host
my $url = $host . '/secrets'; # the subdirectory Drupal is installed in
my $response = $browser->get($url);
die "Can't get $url -- ", $response->status_line
unless $response->is_success;
my $html = $response->content;
# now post the login
$response = $browser->post( $url . "/user",
[
'name' => 'JamesBond', # the log-in ID
'pass' => 'agent-007', # the password for the above ID
'op' => 'Log%20in',
'form_id' => 'user_login'
],
);
$html = $response->content;
Das Script ist jetzt eingeloggt und kann anfangen, die Inhalte abzurufen. In unserem Fall ist die Seite so eingerichtet, dass Node 3 die "Start-Seite" darstellt. Daher starten wir mit Node 3 und hangeln uns von dort aus weiter durch.
Der Hash %urls2retrieve wird hier so genutzt, dass der hash-key die Teil-URL enthält und der hash-Wert den Typ dessen, worauf die URL zeigt.
An Typen sind hier "node" und "css" definiert, bei denen der Inhalt nicht nur von der Webseite geholt werden müssen, sondern auch eine Verarbeitung des Inhalts stattfinden muss. Weiterhin gibt es den Typ "file", der keine inhaltliche Analyse erfordert.
# initialize the urls to retrieve
$urls2retrieve{"/secrets/?q=node/3"} = 'node';
# iteratively retrieve everything
while (%urls2retrieve) {
@urlkeys = keys(%urls2retrieve);
$nexturl2retrieve = $urlkeys[0];
$typeofnexturl = $urls2retrieve{$nexturl2retrieve};
if ($typeofnexturl eq "node") {
RetrieveNode();
ProcessNode();
} elsif ($typeofnexturl eq "css") {
RetrieveCss();
ProcessCss();
} elsif ($typeofnexturl eq "file") {
RetrieveFile();
}
$urlsretrieved{$nexturl2retrieve} = 1; # record this url as retrieved
delete $urls2retrieve{$nexturl2retrieve}; # don't handle this anymore
} # end while (%urls2retrieve)
Das ist es im Prinzip. Damit haben wir den gesamten Inhalt, der an der Einstiegsseite hängt, abgeholt. Die eigentliche Detailarbeit wird in den unten beschriebenen Subroutinen erledigt.
Die folgende Routine holt einfach einen Node und steckt die gesamte HTML Seite in die $response Variable.
sub RetrieveNode {
print "retrieving: $nexturl2retrieve --------\n";
my $url = $host . $nexturl2retrieve;
$response = $browser->get($url);
die "Can't get $url -- ", $response->status_line unless $response->is_success;
} # end of RetrieveNode()
Nun die etwas kompliziertere Routine, die durch die abgeholte Seite und ihre HTML Struktur durchgeht und einmal das extrahiert, was in weiteren Schritten noch geholt werden muss und zum anderen die betroffenen Elemente so abändert, dass sie zur beabsichtigten (flachen) Zielstruktur passen.
sub ProcessNode {
print "processing: $nexturl2retrieve --------\n";
$nexturl2retrieve =~ /.*\/(.*)/;
$html = $response->decoded_content; # to get e.g. all the non-standard-ascii characters like german umlauts right
my $root = HTML::TreeBuilder->new();
$root->parse_content($html);
Damit ist die gesamte Seite geparst und jetzt suchen wir Dinge, die auf etwas zeigen, das wir noch abholen und ggfs. auch verändern müssen.
Wir fangen mit den "link" Tags an.
my @links = $root->look_down('_tag', 'link'); # get a list of "links"
Jetzt haben wir eine Liste aller "link" Tags, die wir nach und nach einzeln verarbeiten.
Das Script unterscheidet zwischen drei verschiedenen Typen. Wenn der "link" auf einen anderen Node zeigt, prüfen wir, ob dieser andere Node schon geholt wurde oder ob wir ihn schon zum Holen vorgemerkt haben. Falls nicht wird er in die Hash-Liste %urls2retrieve eingetragen.
Danach ändern wir die Referenz in dem "link" von dem Muster ".../?q=node/" zu dem Dateinamen "node.html" ab.
Vergleichbar dazu werden auch die link Referenzen zu CSS Files und anderen Dateien behandelt.
foreach my $link (@links) {
my $linkhref = $link->attr('href');
if ($link->attr('href') =~ /\?q=node/ ) {
# push the link to the to-download list
if (! defined $urlsretrieved{$linkhref} && ! defined $urls2retrieve{$linkhref}) { $urls2retrieve{$linkhref} = 'node'; }
# change the link for local filesystem use
$link->attr('href') =~ /\?q=node\/(.*)/;
my $nodenum = $1;
$link->attr('href', "node" . $nodenum . ".html" ); #set a new href
} elsif ( $link->attr('href') =~ /\.css\?y/ ) {
# push the link to the to-download list
if (! defined $urlsretrieved{$linkhref} && ! defined $urls2retrieve{$linkhref}) { $urls2retrieve{$linkhref} = 'css'; }
# change the link for local filesystem use
$link->attr('href') =~ /.*\/(.*)\.css\?y/;
my $css = $1;
$link->attr('href', $css . ".css?y" ); #set a new href
} else {
# push the link to the to-download list
if (! defined $urlsretrieved{$linkhref} && ! defined $urls2retrieve{$linkhref}) { $urls2retrieve{$linkhref} = 'file'; }
# change the link for local filesystem use
$link->attr('href') =~ /.*\/(.*)/;
my $file = $1;
$link->attr('href', $file ); #set a new href
}
}
Nach den "link" Tags behandeln wir in gleicher Weise die "anchor" Tags.
Weil einige zusätzliche Drupal Module (Private Upload, Private Download, Custom Filter) verwendet werden, müssen die URLs, die auf das "private" Verzeichnis zeigen, gesondert behandelt werden. Referenzen, die nicht auf Nodes oder Dateien im "private" Verzeichnis zeigen, werden entfernt. Damit verschwinden auch die üblichen Links auf administrative Funktionen der Drupal-Website, da diese in einem "Statischen Abbild" des Inhalts sowieso nicht viel Bedeutung haben. Weiterhin werden Referenzen, die nur auf die "Einstiegsseite" zeigen (in unserem Fall also https://www.mysecrets.com/secrets), die beispielsweise im Kopfbereich jeder Seite und in der "Brotkrumen"-Navigation verwendet werden, so abgeändert, dass sie auf die Hauptseite (hier also node3.html) zeigen.
Externen Links wird nicht gefolgt.
my @as = $root->look_down('_tag', 'a'); # get a list of "as"
foreach my $a (@as) {
my $ahref = $a->attr('href');
next if (! defined $ahref);
if ($a->attr('href') =~ /\?q=node/ ) {
# push the ahref to the to-download list
if (! defined $urlsretrieved{$ahref} && ! defined $urls2retrieve{$ahref}) { $urls2retrieve{$ahref} = 'node'; }
# change the a for local filesystem use
$a->attr('href') =~ /\?q=node\/(.*)/;
my $nodenum = $1;
$a->attr('href', "node" . $nodenum . ".html" ); #set a new href
} elsif ( $a->attr('href') =~ /\?q=system\/files/ ) {
# files in private
if (! defined $urlsretrieved{$ahref} && ! defined $urls2retrieve{$ahref}) { $urls2retrieve{$ahref} = 'file'; }
# change the a for local filesystem use
$a->attr('href') =~ /.*\/(.*)/;
my $file = $1;
$a->attr('href', $file ); #set a new href
} elsif ( $a->attr('href') =~ /\?q=/ ) {
# ?q=anythingotherthannode will be removed
$a->delete();
} else {
# check for external link
if ($ahref =~ /$host/ ) {
# push the link to the to-download list
if (! defined $urlsretrieved{$ahref} && ! defined $urls2retrieve{$ahref}) { $urls2retrieve{$ahref} = 'file'; }
# change the link for local filesystem use
$a->attr('href') =~ /.*\/(.*)/;
my $file = $1;
$a->attr('href', $file ); #set a new href
} elsif ($ahref eq '/secrets/') {
# redirect it to our starting page, which is node 3
$a->attr('href', 'node3.html' ); #set a new href
} else {
print "--- external link: $ahref\n";
}
}
} # end foreach @as
Nachdem nun die "anchor" Tags behandelt sind, wenden wir uns noch den "img" Tags zu und verarbeiten die Bild-Referenzen, die wir dort finden. Weil dies eine "gesicherte Seite" ist, die nur eingeloggten Benutzern die Inhalte (auch Bilder) zugänglich machen soll, muss hier ebenfalls das "private" Verzeichnis entsprechend behandelt werden.
my @imgs = $root->look_down('_tag', 'img'); # get a list of "img"
foreach my $img (@imgs) {
my $src = $img->attr('src');
if ($src =~ /\?q=system\/files/ ) {
# files in private
if (! defined $urlsretrieved{$src} && ! defined $urls2retrieve{$src}) { $urls2retrieve{$src} = 'file'; }
# change the a for local filesystem use
$src =~ /.*\/(.*)/;
my $file = $1;
$img->attr('src', $file ); #set a new src
} elsif ( $src =~ /\/secrets\/sites\/default\/files/ ) {
if (! defined $urlsretrieved{$src} && ! defined $urls2retrieve{$src}) { $urls2retrieve{$src} = 'file'; }
# change the a for local filesystem use
$src =~ /.*\/(.*)/;
my $file = $1;
$img->attr('src', $file ); #set a new src
} else {
# check for external link
if ($src =~ /$host/ ) {
if (! defined $urlsretrieved{$src} && ! defined $urls2retrieve{$src}) { $urls2retrieve{$src} = 'file'; }
# change the link for local filesystem use
$src =~ /.*\/(.*)/;
my $file = $1;
$img->attr('src', $file ); #set a new href
} else {
print "--- unhandled img src: $src\n";
}
}
} # end foreach @imgs
Damit ist der gesamte Inhalt dieses Nodes / dieser Seite auf Referenzen analysiert und wo notwendig entsprechend modifiziert und wir können ihn in einer Datei abspeichern. Der Dateiname wird nach dem Muster "node", gefolgt von der Node-Nummer und ".html" zusammengebaut.
$outfile = '';
#determine the name of the file to be written
if ($nexturl2retrieve =~ /\?q=node\//) {
$nexturl2retrieve =~ /\?q=node\/(.*)/ ;
$outfile = "node" . $1 . ".html";
unlink($outfile);
open (OUTPUT, ">$outfile") or die "cannot open $outfile for output \n";
my $x = $root->as_HTML();
print OUTPUT $x;
close OUTPUT;
}
$root->delete(); # clear this html tree
} # end of ProcessNode()
Damit ist die ProcessNode Routine beendet. Als nächste kommt die RetrieveCss Routine, die etsprechend der RetrieveNode Routine gebaut ist, ausser, dass hier gleich das CSS File in einer lokalen Datei abgespeichert wird.
sub RetrieveCss {
print "retrieving: $nexturl2retrieve --------\n";
my $url = $host . $nexturl2retrieve;
$nexturl2retrieve =~ /.*\/(.*)\.css/;
my $cssfile = $1 . ".css";
$response = $browser->get($url, ':content_file' => $cssfile );
die "Can't get $url -- ", $response->status_line unless $response->is_success;
} # end of RetrieveCss()
Das gerade geholte CSS File kann ebenfalls Referenzen beispielsweise auf Grafiken enthalten (z.B. für Listenpunkte oder thematische Farbgebungen), die ebenfalls geholt werden müssen. Die Verarbeitung berücksichtigt die Stelle im Verzeichnis, an der die Grafik zu finden ist, die typischerweise relativ zur Verzeichnisposition des CSS Files selbst angegeben wird.
sub ProcessCss {
print "processing: $nexturl2retrieve --------\n";
my $line;
my $cssurl;
my $url = $host . $nexturl2retrieve;
$nexturl2retrieve =~ /.*\/(.*)\.css/;
my $cssfile = $1 . ".css";
open (CSSFILE, $cssfile);
while (<CSSFILE>) {
chomp;
$line = $_;
if ($line =~ /url\(.*\)/ ) {
$line =~ /url\((.*)\)/ ;
$cssurl = $1;
$nexturl2retrieve =~ /(\/.*\/)/ ;
my $retfromdir = $1;
$cssurl = $retfromdir . $cssurl;
# push the link to the to-download list
if (! defined $urlsretrieved{$cssurl} && ! defined $urls2retrieve{$cssurl}) { $urls2retrieve{$cssurl} = 'file'; }
}
}
close (CSSFILE);
} # end of ProcessCss()
Als letzten Teil müssen jetzt nur noch die einfachen Anhangsdateien und Grafiken/Bilder geholt werden, was in der folgenden Routine passiert.
sub RetrieveFile {
print "retrieving: $nexturl2retrieve --------\n";
my $url;
if ($nexturl2retrieve =~ /$host/) {
$url = $nexturl2retrieve;
} else {
$url = $host . $nexturl2retrieve;
}
return if ($nexturl2retrieve eq "/secrets/");
$nexturl2retrieve =~ /.*\/(.*)/;
my $file = $1;
$response = $browser->get($url, ':content_file' => $file );
print "Can't get $url -- \n", $response->status_line unless $response->is_success;
} # end of RetrieveFile()
Damit wäre meine Beschreibung wie der Inhalt einer Drupal-Seite für eine "Offline-Betrachtung" gespiegelt werden kann, beendet. Oder zumindest die Art und Weise, wie ich es für einige betreute Webseiten durchgeführt habe.
Der Leser mag diese Methode gerne auf ähnliche Situationen anwenden - insbesondere dann, wenn man etwas Einfluss auf den Ablauf des Spiegelungvorgangs haben möchte. Über Kommentare würde ich mich freuen.
(Richard)
Neue Kommentare
vor 2 Tagen 13 Stunden
vor 2 Tagen 16 Stunden
vor 2 Tagen 16 Stunden
vor 2 Tagen 16 Stunden
vor 3 Tagen 13 Stunden
vor 3 Tagen 15 Stunden
vor 4 Tagen 12 Stunden
vor 5 Tagen 5 Stunden
vor 5 Tagen 6 Stunden
vor 5 Tagen 9 Stunden