Web Scraping mit PhantomJS-CEF
Das Internet stellt heutzutage die wichtigste Informationsquelle der Welt dar. Besonders in der IT ist das Arbeiten ohne stackoverflow & Co nahezu unmöglich geworden – zu komplex gestalten sich Analyse und Fehlerbehebung. Und obwohl man fast immer und überall online ist, bevorzugen die meisten das Lesen eines PDF in gedruckter Form oder an ihrem E-Book-Reader. Dieser Artikel beschreibt eine mögliche Vorgehensweise zur Erzeugung von PDF aus Webseiten.
PhantomJS [1] feiert in diesen Tagen seinen 5. Geburtstag. In dieser Zeit hat sich das Projekt mit seinen ca. 17.500 Sternchen auf github zu einem der erfolgreichsten Open Source-Projekte gemausert [2]. Ziel des Projektes war es immer, das Web zu automatisieren. Als Zutaten nahm sich der Hauptentwickler Ariya Hidayat einen "unsichtbaren" Browser auf Basis der Webkit Render Engine und das bekannte C++ Qt Framework und baute eine umfassende Javascript API [3] auf.
Meines Erachtens ist gerade diese API das Erfolgsgeheimnis des Projektes. Andere Tools wie z. B. Slimer.js [4] und PhantomJS-CEF [5] orientieren sich daran.
Zum "WarmUp" möchte ich zeigen, wie einfach es ist, mit Hilfe von PhantomJS eine Webseite aufzurufen und den Inhalt als PDF abzuspeichern.
var page = require('webpage').create(); page.open("ht tp://www.google.de",function(status){ page.render('google.pdf'); phantom.exit(); })
In Zeile 1 besorgen wir uns das Page-Objekt und laden die Webseite von google.de. Im Callback, der nach dem Laden der Seite aufgerufen wird, rendern wir den aktuellen Bildschirminhalt als PDF-Datei unter dem Namen google.pdf und beenden das Programm.
Unglücklicherweise wird es keine Weiterentwicklung dieses Original-Projektes mehr geben. Mit dem aktuellen Release von Qt (Version 5.6) wurde die Klasse QTWebkit und damit die Basis von PhantomJS aus dem Qt Framework entfernt [6].
Diesen Schritt hat "Digia", die Hauptentwicklerfirma von Qt, jedoch schon vor langer Zeit angekündigt. Der Grund dafür soll die alte Single-Prozessarchitektur von Webkit 1 sein, die seit Jahren von Apple nicht mehr weiter entwickelt wird [7].
PhantomJS-CEF
Um PhantomJS weiter am Leben zu halten, wurde im Oktober 2015 mit einer Reimplementierung unter dem Namen PhantomJS-CEF [8] begonnen. Als Basis wählte man das Chromium Embedded Framework (CEF) [9]. Seit dieser Zeit konnten wesentliche Teile der Original API erfolgreich portiert werden. CEF selber ist ein C++-Framework, mit dem sich der Browser Chromium (die Open Source-Variante des Google Chrome) programmieren und automatisieren lässt. Hinter CEF steckt unter anderem die Firma Adobe, die dieses Framework in ihren Produkten (z. B. Brackets) selbst einsetzt.
Die Vorteile der Neuimplementierung liegen klar auf Hand:
- CEF stellt immer die aktuellste Version des Chromiums zur Verfügung (März 2016: Version 49)
- CEF wird von Adobe in verschiedenen Produkten verwendet und gilt damit als zukunftssicher
- CEF ist mit seinem Alter von 6 Jahren schon gereift
- State of the Art-Verwendung im Bereich JavaScript – zu 91% EcmaScript6-Support [10]
- CEF ist Multiplattform-fähig (Linux, Windows, MacOS)
Der Hauptfokus der Entwickler von PhantomJS-CEF lag zunächst jedoch in der Implementierung auf Linux-Basis, obgleich auch schon Binärreleases für Windows existieren [11]. Als Nachteil ist die aktuell noch geringe Verbreitung, sowie die X11-Abhängigkeit unter Linux zu nennen. Das macht es derzeit notwendig, auf einem Linux-Server-System den virtuellen Framebuffer zu verwenden.
# nur einmal aufzurufen - im später erwähnten Docker-Container ist dies nicht notwendig sudo Xvfb :99 -screen 0 1024x868x24 -ac 2>/dev/null & # jetzt kann phantomjs normal verwendet werden ./phantomjs google.js
Dennoch wird PhantomJS-CEF offscreen betrieben – d. h. auf keiner der erwähnten Desktop-Plattformen ist ein Browser-Fenster zu sehen. Mit der Portierung des Chromium auf Wayland sollte in naher Zukunft auch die X11-Abhängigkeit verschwinden.
Um einen sanften Einstieg in diese Neuentwicklung zu gewährleisten, empfehle ich die Verwendung unter Linux bzw. die Benutzung des vorhandenen Docker-Containers [12]. Hinweise zu Docker und zu diesem speziellen Container finden sich im folgenden Kasten.
Den meistern Anwendern ist PhantomJS vermutlich eher aus Testautomatisierung von Oberflächen oder vom Web Scraping bekannt. In diesem Artikel möchte ich mich jedoch einem der am meist unterschätzten Features widmen: Dem PDF-Druck von Webseiten.
Hinweise zur Nutzung von PhantomJS-CEF in einem Docker-Container
Da Docker ein reines Linux-Tool ist und spezielle Eigenschaften des Linux-Kernels nutzt, ist es unter MacOS und Windows notwendig, vorab eine Mini-Virtualisierung einzurichten. Glücklicherweise stellt Docker dieses in Form der Docker-Toolbox selbst zur Verfügung. Diese Toolbox kann man einfach herunterladen [13]. Eine Beschreibung der Installation findet man separat für MacOS, für Windows und für Linux.
Die anschließenden Schritte sind für alle Betriebssysteme identisch. Docker ist ein reines Kommandozeilen-Tool, daher öffnet man zunächst das jeweilige Terminal. Mit dem folgenden Befehl holt man sich den PhantomJS-CEF-Container:
docker run -t -i --rm aknuth/phantomcef
Zunächst lädt Docker einmalig alle Schichten dieses Containers herunter und speichert sie im lokalen Repository. Es wird eine interaktive Shell unter dem User phantomjs geöffnet. Zum Glück wurde im Hintergrund schon der virtuelle Framebuffer gestartet - daher können wir direkt anfangen, unser PDF-Blog Beispiel zu starten:
./phantomjs examples/article_ia_java8.js 2>/dev/null
Durch den Anhang 2>/dev/null vermeidet man unschöne, wenig aussagekräftige Fehlermeldungen.
Im aktuellen Verzeichnis sollte sich nun ein PDF mit dem Namen java8.pdf befinden. Unglücklicherweise kann man sich das PDF nicht anschauen und bekommt es auch nicht aus dem Container ins eigene Betriebssystem transportiert. Daher schließen wir den Container zunächst einmal mit exit und öffnen ihn erneut mit einem zusätzlichen Docker Mountpoint:
//unter Windows
docker run -t -i --rm -v /c/Users/[username]/docker_tmp:/opt/phantomjs/docker_tmp aknuth/phantomcef
//unter Linux bzw. unter MacOS
docker run -t -i --rm -v ~/docker_tmp:/opt/phantomjs/docker_tmp aknuth/phantomcef
Der username unter Windows ist bitte durch den eigenen Nutzernamen zu ersetzen. Das docker_tmp-Verzeichnis sollte im Hostsystem schon vorhanden sein.
Nun kann ich wieder obigen phantomjs-Befehl absetzen und das PDF mit
cp java8.pdf docker_tmp
nach docker_tmp kopieren. Falls es Berechtigungsprobleme gibt, bitte vor dem Kopieren mit
sudo chmod -R 777 docker_tmp
alle Berechtigungen erteilen. Nach dem Kopieren sollte das PDF im eigenen (Host-)Betriebssystem zur Verfügung stehen.
Technische Vorgehensweise
Als Usecase soll die PDF-Generierung eines Blog-Artikels dienen. Dabei wird nur der reine Artikel ohne Header, Footer, Sidebar und Kommentare gedruckt. Ganz konkret verwende ich einen Online Artikel von Informatik Aktuell zum Thema Java 8.
Der nachfolgende Sourcecode kann auf alle Artikel von Informatik Aktuell angewendet werden – das Prinzip lässt sich sogar auf alle Blog-Artikel übertragen.
Die Vorgehensweise ist dabei wie folgt:
- Öffnen der Webseite im Browser sowie Öffnen der Developer Tools mit Strg-Shift-I im Chrome bzw. Firefox
- Selektion des eigentlichen Artikel-Bereiches. Nach dem Betätigen der Tastenkombination Strg-Shift-C kann man Bereiche der Webseite selektieren und durch einen Mausklick wird der Bereich im DOM-Explorer blau hinterlegt. In unserem Beispiel handelt es sich um das div-Element mit der ID content (s. Abb.1). Die Selektion auf programmatischer Ebene mit jQuery lautet:
$('div#content')
- Entfernen aller DOM-Elemente, die nicht direkte Eltern-Elemente des obigen Content-Knotens sind:
$('#sidebar').remove();
$('#header').remove();
$('#footer').remove();
$('#copyright').remove();
$('#topnavi').remove();
- Der eigentliche Artikel besteht nun aus einer Vielzahl aus parallel liegenden div- und span-Elementen, deren Abschluß das div-Element mit der Klasse quellen bildet. Um Platz zu sparen löschen wir alle folgenden Knoten sowie die Anzeige der Navigationsleiste #breadcrumb:
$('div.quellen').nextAll().remove();
$('#breadcrumb').remove();
Für einen optimalen Ausdruck entfernen wir zusätzlich alle Elternelemente mit festen Breitenangaben. Nach dem Löschen dieser Knoten erscheint der Artikel über die gesamte Breite des Bildschirm – unabhängig von der Auflösung.
$('#content-wrap').unwrap();
$('#content').unwrap();
- Danach erfolgt die eigentliche PDF-Erzeugung
In unseren Beispiel können wir die DOM-Baum-Manipulationen mühelos mittels jQuery vornehmen, da die Webseite dieses Framework schon im Header geladen bekommt. Für den unwahrscheinlichen Fall, das ein Blog-Artikel ohne die Verwendung von jQuery auskommt, kann man die Library nach dem Laden der Seite immer noch mühelos selbst injecten.
page.injectJs('jQuery.min.js') // diese Datei muss sich im Ordner der aufgerufenen JavaScript Datei befinden
Zum besseren Verständnis ist es wichtig zu wissen, das PhantomJS-CEF alle seine Aufgaben asynchron über Promises erledigt. Promises existieren seit EcmaScript 6 und sind eine elegante Alternative zu den klassischen Callbacks aus früheren Javascript-Tagen. Einen detaillierten Überblick zu dem Thema findet man in dem Buch Exploring ES6 [14], welches auch online zur Verfügung steht.
Callbacks und Promises werden im Rahmen von asynchroner Programmierung eingesetzt. Da Javascript nur ein Thread zur Abarbeitung eines Programms zur Verfügung steht, geschieht das Laden einer Webseite in PhantomJS und PhantomJS-CEF asynchron. Die Benachrichtigung über die Fertigstellung des Ladevorganges erhält man dann entweder über einen Callback, wie in PhantomJS (old)
page.open("http://www.google.de",function(status){
//an dieser Stelle geht es nach dem Laden der Seite weiter
})
oder über das Ergebnis eines Promises wie in PhantomJS-CEF
page.open('http://www.google.de')
.then(function(status) {
//an dieser Stelle geht es nach dem Laden der Seite weiter
})
Hier nun der vollständige Code zur Erzeugung des PDFs:
//(1)
var page = require('webpage').create();
page.viewportSize = { width: 1920, height: 1200 };
//(2)
page.paperSize = {
format: 'A4',
orientation: 'portrait',
margin: '1cm'
};
//(3) page.open('http://www.informatik-aktuell.de/entwicklung/programmiersprachen/java-8-im-praxiseinsatz.html')
.then(function(){
//(4)
return page.evaluate(function () {
$('#sidebar').remove();$('#header').remove();$('#footer').remove();$('#copyright').remove();
$('#breadcrumb').remove();$('#topnavi').remove();
$('div[style=clear\\:left]').nextAll().remove();
$('div[style=clear\\:left]').remove();
$('#content-wrap').unwrap();
$('#content').unwrap();
$('pre').css('overflow','visible');
$('pre').css('page-break-inside', 'avoid');
})
})
//(5)
.then(function(){
return page.render('java8.pdf');
})
//(6)
.catch(function(error) {
console.log('Error: ' + error);
})
//(7)
.then(phantom.exit);
In (1) besorgen wir uns zunächst das page-Objekt, welches die eigentliche Webseite repräsentiert. PhantomJS-CEF besitzt zwei Kontexte, den phantomjs- und den page-Kontext (s.Abb.2).
Mittels der page.evaluate-Methode kann man aus dem phantomjs-Kontext auf die (Web)Page zugreifen. DOM-Baum-Manipulationen lassen sich nur im Page-Kontext durchführen. Wichtig ist dabei zu wissen, das beide Kontexte sandboxmäßig voneinander abgeschottet sind. Daher ist es notwendig, bei Bedarf Daten explizit zu übertragen.
Selbst eine globale – im phantomjs-Kontext erzeugte – Variable existiert nicht in der Page.
Einschub Sandbox-Datenübergabe in PhantomJS-CEF
Auch wenn es in unserem Beispiel nicht notwendig ist – selbstverständlich lassen sich Daten zwischen den beiden Kontexten übertragen:
Die Übergabe von Variablen an eine page ließe sich wie folgt realisieren:
page.evaluate(function(data){
//Page Kontext
console.log(data);
},data)
Dabei ist zu beachten, das data serialisierbar sein muss!
page.evaluate liefert ein Promise zurück.
page.open('http://www.google.com')
.then(function(){
return page.evaluate(function(data){
//Page Kontext
return data+'irgend etwas';
}(data))
.then(function(datanew){
//PhantomJS Kontext
console.log(datanew);
})
Der Rückgabewert aus dem page-Kontext steht nun als datanew-Variable im PhantomJS-Kontext zur Verfügung.
Die Page wird im nächsten Schritt (2) für den PDF-Druck sinnvoll formatiert – in diesem Fall wähle ich das DIN A4-Format, Vertikaldruck sowie einen Rand von 1 cm. Diese Werte sind natürlich Geschmackssache und insbesondere die Wahl der Orientierung (horizontal oder vertikal) mag jeder für sich selbst entscheiden.
Mittels des page-Objektes wird in (3) die eigentlich Webseite geladen. Nach dem erfolgreichen Laden der Seite, werden in (4) die angesprochenen DOM-Baum-Manipulationen vorgenommen. Im nächsten Schritt wird der PDF-Druck produziert (5). PhantomJS-CEF erkennt den Dateityp automatisch an der Endung. Würde dort eine Datei mit dem alternativen Suffix .png oder .jpeg stehen, würde stattdessen ein Bild produziert.
Sollte in einem der vorherigen Schritte ein Fehler auftreten (z. B. beim Laden der Seite), wird sofort in die catch-Methode (6) gesprungen und der Fehler angezeigt. Zu guter Letzt beendet sich PhantomJS.
Feintuning
Das Ergebnis kann sich schon sehen lassen. Mir sind jedoch direkt zwei Auffälligkeiten ins Auge gesprungen:
Der Source-Code des Artikels wird nicht über die komplette Breite angezeigt und die Scrollbalken helfen in einem PDF-Dokument auch nicht wirklich
Der Source-Code wird teilweise durch einen Seitenumbruch gestört
In diesem Beispiel ließe sich Punkt 1 einfach umschiffen, in dem man die Orientierung der Seite von vertikal auf horizontal ändert (orientation: landscape). Durch dieses Verfahren gewinnt man jedoch nur etwas Platz – die allgemein gültige Lösung besteht darin, die CSS Eigenschaft overflow des Knotens pre von auto auf visible zu setzen. Dadurch übergibt man dem Browser wieder die Kontrolle über den Zeilenumbruch.
Die Unterbindung des Seitenumbruchs inmitten eines Source-Code-Elementes geschieht mittels der CSS-Eigenschaft page-break-inside. Wird diese auf avoid gesetzt, unternimmt der Browser nach Möglichkeit keinen Umbruch innerhalb dieses Elementes. Das man mit dieser Maßnahme die Gesamtlänge des ausgedruckten Artikels wieder etwas erhöht, versteht sich von selbst.
Fazit:
Mit ein wenig Wissen über den Aufbau von Webseiten, dem Verständnis von CSS und etwas PhantomJS-Knoff-how lassen sich erstaunliche Ergebnisse im Bereich PDF-Druck erzielen. Das dies möglich ist, liegt zum einen an der einfachen API von PhantomJS als auch an dem zugrundeliegenden Chromium-Browser mit seinen reichhaltigen Funktionalitäten.
- PhantomJS
- Github: PhantomJS
- Javascript API
- SlimerJS
- Github: PhantomJS-CEF
- New Features in Qt 5.6
- Blog Beitrag zu slimer.js & PhantomJS: What is headless web browsing?
- Github: PhantomJS-CEF
- Chromium Embedded Framework (CEF)
- V8 JavaScript Engine
- Github: Binärreleases für Windows
- Docker-Container
- Docker-Toolbox
- Exploring ES6