Gastbeitrag von Andreas Knuth

Web Scraping mit PhantomJS-CEF

PhantomJS hat mit seiner intuitiven Javascript API über viele Jahre Maßstäbe in der Browser-Automatisierung gesetzt. Unglücklicherweise wird es keine Weiterentwicklung dieses Projektes mehr geben. Mit dem aktuellen Release des Qt Frameworks V5.6 wurde die Klasse QtWebkit und damit die Basis von PhantomJS entfernt.

PhantomJS-CEF ist ein noch relativ junges Projekt. Es unternimmt den Versuch, die vorhandene, bekannte Javascript API von PhantomJS auf Basis des Chromium Embedded Frameworks (CEF) neu zu implementieren. Außerdem stellt es noch eine neue Java API zur Verfügung.

CEF selber ist ein Open Source C++ Framework, mit dem sich der Browser Chromium (die OpenSource 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. Unter http://cefbuilds.com stellt die Firma binäre Releases für alle wichtigen Plattformen (MacOS, Windows, Linux) zur Verfügung.

Um einen besseren Vergleich aller Tools zu ermöglichen, werde ich das Beispiel von Martin Weck aufgreifen, welches die arc42- Webseite nach dem Begriff Requirements durchsucht und alle Ergebnisslinks als Text ausgibt. Das Szenario kann man gut dem folgenden Schaubild entnehmen.

Lange Rede, kurzer Sinn - beginnen wir mit Vorstellung des Javascript-Demos. Es kann mit dem Kommando

./phantomjs example/arc42.js

aufgerufen werden. Weitere Informationen zu den Binaries gibt es am Ende des Beitrages.

var page = require('webpage').create();
page.viewportSize = { width: 1920, height: 1200 };

var pagenr = 1;

page.open("http://confluence.arc42.org/display/templateEN/arc42+Template+%28English%29+-+Home")
    .then(function() {
        return page.sendMouseEvent('click', 'input[name=queryString][class*=medium-field]');
    })
    .then(function() {
        return page.sendEvent('keypress', 'Requirements');
    })
    .then(function() {
        return page.sendMouseEvent('click', 'input[type=submit][class=aui-button]');
    })
    .then(function() {
        return page.waitForDomElement("a.search-result-link");
    })
	.then(function() {
        return printResults(page);
    })
    .then(function(){
        checkWeiterLink()
    })
    .catch(function(err) {
      console.log("FAIL!" + err);
      phantom.exit();
    })

function checkWeiterLink(){
    page.evaluate(()=>{
        let exist = (document.querySelectorAll('a.pagination-next').length===1);
        if (exist){
            console.log('Weiter Link exist');
            return true;
        } else {
            console.log('no "Weiter" Link exists');
            throw Error("no element inside page ...");
        }
    })
    .then(function(data) {
        iterateSearchResults();
    })
    .catch(function(err) {
      console.log("Finish");
      phantom.exit();
    })
}

function iterateSearchResults(){
    page.sendMouseEvent('click', 'a.pagination-next')
    .then(()=>{
        pagenr++;
        return page.waitForFunctionTrue(checkResultPage,pagenr);
    })
    .then(function() {
        return printResults(page);
    })
    .then(function(){
        checkWeiterLink()
    })
    .catch(function(err) {
      console.log(err);  
      phantom.exit();
    })
}

//This won't work - only named functions are allowed
//var fkt = function(nr){
function checkResultPage(nr){
    return document.querySelector('.pagination-curr').innerText==nr;
    //You can use that either because jQuery is loaded by the webpage
    //return $('.pagination-curr:contains('+nr+')').length===1;
}

function printResults(page){
    return page.evaluate(()=>{
        let nodes = document.querySelectorAll('a[class*=search-result-link]');
        [].forEach.call(nodes, (entry) => {
            console.log('----> '+entry.innerText);
        });
    })
}
Der Ablauf eines Javascript-Programmes ist grundsätzlich „single threaded“ - daher arbeitet PhantomJS-CEF so, dass jede Aktion, die auf einer Webseite ausgeführt wird, asynchron abläuft. Um die einzelnen Arbeitsschritte dennoch zu synchronisieren und eine sequentielle Abarbeitung zu gewährleisten, liefert jeder Methodenaufruf auf einer Webpage ein sogenanntes Promise zurück. Die Verkettung der einzelnen Promises geschieht über die *then*-Funktion. Das Thema der Promises sprengt leider den Rahmen dieses Beitrages - einen detailierten Überblick findet man in dem Buch [Exploring ES6](http://exploringjs.com/es6), welches auch online zur Verfügung steht.

Das Prinzip der Asynchronität ist in Javascript nichts Neues, nur hat man mit den Promises in EcmaScript6 (ES6) ein elegantes Sprachmittel an die Hand bekommen. Vor ES6 setzte man zu diesem Zweck Callbacks ein - dies führte jedoch zu stark ineinander geschachteltem und schwer lesbarem Code. Diese Art von Programmierung wird oftmals als Callback Hell bezeichnet.

Der Code selber lässt sich relativ einfach lesen: Zunächst besorgen wir uns das (Web)Page Objekt, wir definieren auf diesem Objekt die Fenstergröße von 1920x1200 Pixel und laden die Startseite von arc42.

Nach dem Laden der Seite klicken wir auf das Eingabefeld. Im Hintergrund werden die [x,y]Koordinaten des Feldes anhand seines CSS-Selektors bestimmt. In dem Feld tragen wir den Suchbegriff Requirements ein und klicken anschließend auf den Suchen-Button. Wir warten auf die Link-Elemente, die in der printResults Methode ausgegeben werden und überprüfen, ob eine weitere Ergebnisseite existiert.

Diese Überprüfung findet in der checkWeiterLink Funktion statt. Im Falle eines nicht vorhandenen Weiter-Links wird das Programm beendet. Leider ist mir aktuell keine andere Möglichkeit bekannt, eine then-Kaskade zu unterbrechen, ohne dabei auf Exceptions bzw. Errors zurückzugreifen. Im Fall eines nicht vorhandenen Links wird daher ein Error zurück gegeben, der später im catch-Block zur Beendigung von PhantomJS führt.

Falls der Weiter-Link vorhanden ist, rufen wir die Funktion iterateSearchResults auf. In dieser klicken wir auf den Link und warten anschließend darauf, das die richtige Ergebnisseite angezeigt wird.

Nicht jedesmal ist ein CSS Selektor ausreichend, um den exakten Seitenstatus zu ermitteln. In diesem Fall verwenden wir die Hilfsmethode checkResultPage, die dies anhand der Seitenzahlanzeige überprüft. Anschließend werden die nächsten Ergebnislinks ausgegeben und erneut checkWeiterLink aufgerufen. Dieses Wechselspiel zwischen checkWeiterLink und iterateSearchResults läuft solange ab, bis die letzte Ergebnisseite erreicht ist.

Nun könnte man fragen, warum wir zu Beginn eine bestimmte Fenstergröße gewählt haben. Der Sinn dahinter ist die Lage des Weiter-Links auf der Ergebnisseite. Bei den Klicks in PhantomJS-CEF handelt es sich um echte Mausklicks und keine syntetischen DOM Events, d.h. befindet sich der Link nicht im Sichtfeld, bewirkt der Klick auch nichts !

Java API

Javascript nicht jedermanns Sache – im Bereich Enterprise Computing ist die Sprache Java deutlich federführend. Daher hat sich das Projekt auf die Fahne geschrieben, zeitgleich auch eine Java-API zur Verfügung zu stellen.

PhantomJS-CEF wird dabei normal mit einer bestimmten JavaScript-Datei, javabindings.js, gestartet. Mittels einer Websocket-Verbindung kommuniziert der Java Client mit PhantomJS-CEF. Dabei geht die Kommunikation ebenfalls asynchron vonstatten, sodass für die Java-API dieselben Programmierprinzipien angewendet werden können.

Im folgenden Diagramm ist die Anbindung schematisch dargestellt:

alt text
alt text

Auf Java-Seite läuft der Websocket-Server. PhantomJS-CEF versucht eine Verbindung mit dem Server aufzubauen. Bei Erfolg wird dies durch ein Acknowledge bzw. ACK signalisiert. Danach beginnt das Java-Programm Kommandos im JSON-Format an die Javascript-Seite zu senden und bekommt später dann eine Antwort.

Kommen wir nun zu dem Java Source Code für denselben Anwendungsfall der arc42-Webseite:

public class Arc42 {

	public static void main(String[] args) {
		PhantomJs.startServer();
		PhantomJs phantom = new PhantomJs();
		if (System.getProperty("phantomjsDir") != null){
			String phantomjsDir = System.getProperty("phantomjsDir");
			PhantomJs.startPhantomProcess(phantomjsDir);
		}

		CompletableFuture<Page> ac = PhantomJs.waitForAckFuture().thenCompose((ev) -> {
			return phantom.loadUrl("http://confluence.arc42.org/display/templateEN/arc42+Template+%28English%29+-+Home");
		}).thenCompose((page) -> {
			page.setViewportSize(1920, 1200);
			return page.sendMouseEvent(MouseEvent.doubleclick, "input[name=queryString][class*=medium-field]");
		}).thenCompose((page) -> {
			return page.sendKeyEvent(KeyType.keypress, "Requirements");
		}).thenCompose((page) -> {
			return page.sendMouseEvent(MouseEvent.click, "input[type=submit][class=aui-button]");
		}).thenCompose((page) -> {
			return page.awaitEvent(Events.onLoadFinished);
		}).thenCompose((page) -> {
			return page.list("a[class*=search-result-link]");
		}).thenApply((plist) -> {
			printArray(plist.getList());
			return plist.getPage();
		});

		checkWeiterLink(ac);
	}

	private static void checkWeiterLink(CompletableFuture<Page> ac) {
		CompletableFuture<Page> finish = new CompletableFuture<Page>();
		ac.whenCompleteAsync((page,t)-> {
			page.list("a.pagination-next")
			.thenAccept(plist -> {
				if (plist.getList().size()==0){
					PhantomJs.exit();
				} else {
					finish.complete(plist.getPage());
					iterateSearchResults(finish);
				}
			});
		});
	}

	private static void iterateSearchResults(CompletableFuture<Page> ac) {
		CompletableFuture<Page> finish = new CompletableFuture<Page>();
		ac.whenCompleteAsync((page,t)-> {
			page.sendMouseEvent(MouseEvent.click, "a.pagination-next")
			.thenCompose((p)-> {
				return p.awaitEvent(Events.onResourceReceived);
			})
			.thenCompose((p) -> {
				return p.list("a[class*=search-result-link]");
			})
			.thenAccept((pList) -> {
				printArray(pList.getList());
				finish.complete(pList.getPage());
				checkWeiterLink(finish);
			});
		});

	}

	private static void printArray(List<String> array){
		for (String text : array){
			System.out.println(text);
		}
	}
}

Wenn man einmal davon absieht, das im Source Code verschiedene Sorten von then Methoden existieren (thenCompose, thenApply etc), dann lässt sich eine große Ähnlichkeit zum Javascript Code erkennen. Die Rolle der Promises übernehmen in Java die CompletableFutures. Diese sind eine Erweiterung der in Java6 eingeführten Futures - sie implementieren zusätzlich das Interface CompletionStage. Über die Methoden dieses Interfaces lassen sich die einzelnen asynchronen Aufrufe wieder über diverse then-Methoden miteinander koppeln.

Eine ausführliche Erklärung der Verwendung dieser Klasse kann man sich in einem einstündigen Video von Tomasz Nurkiewicz auf vimeo [1] zur Gemüte führen. Wichtig für die Verwendung im Rahmen von Phantomjs-CEF ist nur zu wissen, das jeder Page bzw. Phantom Aufruf ein CompletableFuture (aka Promise) zurückliefert.

Die weiteren Unterschiede sind eher systembedingt. Zunächst warten wir mittels der statischen Methode waitForAckFuture auf das Acknowledge vom Websocket-Client, bevor eine weitere Phantom-Methode die arc42 Webseite lädt und uns das Page Objekt erstmals liefert. Die CF liefern in PhantomJS-CEF immer ein Page Objekt zurück, entweder plain oder in einem erweiterten Objekt, welches zusätzliche Rückgabewerte beinhaltet. Ein Beispiel für solch ein erweitertes Objekt ist das PageList Objekt.

Parallelbetrieb

Wie in Javascript, kann man auch in Java mehrere gleichzeitige Webseiten (Pages) halten. Aufgrund der Asynchronität der Abarbeitung erhalte ich damit im Normalfall auch eine deutlich bessere Performance. Abschließend ein einfaches Beispiel, wie man in Java mit parallelen Pages umgeht:

public class TwitterParallel {

	public static void main(String[] args) {
		PhantomJs.startServer();
		if (System.getProperty("phantomjsDir") != null){
			String phantomjsDir = System.getProperty("phantomjsDir");
			PhantomJs.startPhantomProcess(phantomjsDir);
		}

		String[] users = {"PhantomJS","ariyahidayat","Vitalliumm"};

		List<CompletableFuture<String>> cfList = new ArrayList<CompletableFuture<String>>();

		for (String user: users){
			CompletableFuture<String> finish = new CompletableFuture<String>();
			follow(user,finish);
			cfList.add(finish);
		}

		CompletableFuture<String>[] cfArray = cfList.stream().toArray(size -> new CompletableFuture[size]);
		CompletableFuture.allOf(cfArray).handle((ok,ex) -> {
			PhantomJs.exit();
			return null;
		});
	}

	private static void follow(String user, CompletableFuture<String> finish){
		PhantomJs phantom = new PhantomJs();
		PhantomJs.waitForAckFuture().thenCompose((ev) -> {
			return phantom.loadUrl("http://mobile.twitter.com/"+user);
		}).thenCompose((page) -> {
			return page.text(".UserProfileHeader-statCount");
		}).thenApply((pText) -> {
			System.out.println(">>>>>>>>> "+user+" folgt "+pText.getText()+" Personen");
			return finish.complete("READY");
		});
	}
}

Hier rufen wir die mobile Twitter-Seite von 3 Usern auf - ermittelt wird die Anzahl an Leuten, denen jeder einzelne User folgt. Der eigentliche Ablauf des Programms findet in der Methode follow statt. Zu Beginn wird über alle User iteriert und entsprechend der Anzahl der User die Methode follow mehrfach aufgerufen. Das zusätzlich erstellte CompletableFuture (CF) finish wird der Methode mit übergeben. Über dieses CF wird die Synchronizität aller Abarbeitungen sicher gestellt. Mittels der statischen allOf-Methode erhalte ich den Zeitpunkt, zu dem alle parallelen Verarbeitungen beendet sind. Damit beendet sich das Programm.

Die Java-API ist noch sehr jung und stellt aktuell nur ein Teil der Javascript-API zur Verfügung. Der Author hofft dennoch, dieses Gap in kurzes Zeit füllen zu können.

Um PhantomJS-CEF zu nutzen, stehen aktuell Binaries für Ubuntu Linux und Windows, sowie ein Docker Container unter aknuth/phantomcef zur Verfügung. Mehr Informationen zu dem Projekt können unter https://github.com/aknuth/phantomjs-cef abgerufen werden.

Für den Betrieb auf einem Linux-Server ist es wegen der X-Server-Abhängigkeit aktuell noch notwendig, den virtuellen Framebuffer Xvfb zu verwenden. Der Aufruf könnte dann z.B wie folgt aussehen:

### nur einmal aufzurufen - im oben erwähnten Docker Container nicht notwendig
sudo Xvfb :99 -screen 0 1024x868x24 -ac 2>/dev/null &
### jetzt kann phantomjs normal verwendet werden
./phantomjs arc42.js

PhantomJS-CEF wird immer offscreen betrieben - d.h. man bekommt nie ein Browserfenster zu Gesicht. Damit eignet sich das Projekt u.a. auch für die Verwendung innerhalb einer Desktop Applikation.

Die nächsten Entwicklungsschritte des Projektes sind die Einrichtung eines Continous Integration Prozesses sowie die Bereitstellung von Binaries unter MacOS. Die API wird dabei sukzessive und nach Bedarf erweitert.

Alle hier in diesem Artikel beschriebenen Beispiele sind auf Github zu finden. Der Java Code befindet sich in dem separaten Submodul PhantomJava.

Zum Ende noch die von Martin Weck aufgestellte und um PhantomJS-CEF erweiterte Feature Matrix: (X unterstützt, - nicht unterstützt, L eingeschränkt).

Feature GUI No GUI JavaScript/CSS free fast XPath Selector jQuery Selector CSS Selector HTML Selector
HtmlUnit - - L X - X - - X
SeleniumHQ X L X X - X - X X
ui4j X X X X - - X X -
jaunt - X - - X - - - X
PhantomJS-CEF - X X X X - L X X