Strippenzieher (Linux-Magazin, März 2020)

Vorbei sind die Zeiten, in denen Hobbyisten einfach mal schnell mit curl und Co Webseiten einholen konnten, um deren Inhalt maschinell weiterzuverarbeiten. Moderne Webauftritte wimmeln nur so von "Reactive Design" und dynamischen Inhalten, die nur zum Vorschein kommen, falls ein echter Standard-Browser mit eingeschaltetem JavaScript darauf zeigt. Wer etwa einen Screen-Scraper für Gmail schreiben wollte, käme mit einem Scraper-Script nicht mal durch die Anmeldung. Auch ein Scraping-Framework with Colly ([2]) dringt da nicht durch, da es weder JavaScript beherrscht noch eine Vorstellung von DOM (Document Object Model) des Browsers hat. Die Lösung liegt auf der Hand: Das Scraper-Programm dirigiert stattdessen einen echten Browser zur gewünschten Webseite, und befragt ihn anschließend, um herauszufinden, was denn so in seiner Anzeige steht.

Bei vollautomatischen Unit-Tests für Web-UIs greifen Entwickler seit Jahren zum Java-Tool Selenium, das über das Selenium-Protokoll, das alle Standardbrowser implementieren, den Browser hochfahren kann und anschließend auf Links klickt, Formularfelder ausfüllt und Submit-Buttons drückt. Googles Chrome-Browser implementiert das sogenannte DevTools-Protokoll, das ähnliches leistet, und das Projekt chromedp auf Github definiert darauf aufbauend eine Go-Bibliothek. Go-Enthusiasten können ihre Unit-Tests und Scraperprogramme nun nativ in ihrer Lieblingssprache schreiben.

Chrome dirigieren

Das Go-Programm in Listing 1 startet zum Beispiel den Chrome-Browser, lotst ihn auf die Seite des Linux-Magazins, und erstellt anschließend einen Screenshot des eingeholten Inhalts. Das Ganze läuft von der Kommandozeile mit

    $ go build screenshot.go
    $ ./screenshot

ab und der User sieht auch keinen Browser aufpoppen, da dieser im Normalfall im "Headless-Modus", also unsichtbar läuft, falls nichts anderes eingestellt ist. Den Code der Library holt, wie in Go üblich, vorher der Aufruf

    $ go get -u github.com/chromedp/chromedp

und übersetzt und installiert ihn auch gleich. Nach dem Einholen der Seite, was je nach Internetverbindung und Servergeschwindigkeit einige Sekunden dauern kann, legt Listing 1 als Ergebnis eine Bilddatei im PNG-Format namens screenshot.png auf der Festplatte ab. Da die Homepage des Linux-Magazins sich über viele Browserlängen erstreckt, damit die User auch etwas zum Herunterscrollen finden, ist der erstellte Screenshot in Abbildung 1 fast 7000 Pixel hoch!

Abbildung 1: Screenshot der in Chrome geladenen Titelseite des Linux-Magazins.

Listing 1 erzeugt dazu in Zeile 13 einen neuen chromedp-Context und gibt dem Konstruktor einen in Go üblichen Standard-Background-Context mit. Letzterer ist ein Hilfskonstrukt zum Steuern von Go-Routinen und Unterprogrammen: Ein Context in Go liefert eine cancel()-Funktion, die das Hauptprogramm aufrufen kann, um einem anderen Programmteil zu signalisieren, dass es Zeit zum Aufräumen ist. Das Tasks-Struktur ab Zeile 17 definiert eine Reihe von Aktionen, die der angeschlossene Chrome-Browser über das DevTools-Protokoll ausführen soll. Die Navigate-Task ab Zeile 18 fährt lediglich die Webseite des Linux-Magazins an. Die zweite Task ab Zeile 20 entsteht über die Funktion ActionFunc(), mit der sich in Chromedp neue maßgeschneiderte Tasks strukturieren lassen. Im vorliegenden Fall erzeugt die Task mittels CaptureScreenshot() in Zeile 33 einen Screenshot der im ferngesteuerten Browser angezeigten Webseite.

Listing 1: screenshot.go

    01 package main
    02 
    03 import (
    04   "context"
    05   emu "github.com/chromedp/cdproto/emulation"
    06   "github.com/chromedp/cdproto/page"
    07   cdp "github.com/chromedp/chromedp"
    08   "io/ioutil"
    09 )
    10 
    11 func main() {
    12   ctx, cancel :=
    13     cdp.NewContext(context.Background())
    14   defer cancel()
    15 
    16   var buf []byte
    17   tasks := cdp.Tasks{
    18     cdp.Navigate(
    19       "https://linux-magazin.de"),
    20     cdp.ActionFunc(
    21       func(ctx context.Context) error {
    22         _, _, contentSize, err :=
    23           page.GetLayoutMetrics().Do(ctx)
    24         if err != nil {
    25           panic(err)
    26         }
    27 
    28         w, h := contentSize.Width,
    29           contentSize.Height
    30 
    31         viewPortFix(ctx, int64(w), int64(h))
    32 
    33         buf, err = page.CaptureScreenshot().
    34           WithQuality(90).
    35           WithClip(&page.Viewport{
    36             X:      contentSize.X,
    37             Y:      contentSize.Y,
    38             Width:  w,
    39             Height: h,
    40             Scale:  1,
    41           }).Do(ctx)
    42         if err != nil {
    43           panic(err)
    44         }
    45         return nil
    46       })}
    47 
    48   err := cdp.Run(ctx, tasks)
    49   if err != nil {
    50     panic(err)
    51   }
    52 
    53   err = ioutil.WriteFile("screenshot.png",
    54     buf, 0644)
    55   if err != nil {
    56     panic(err)
    57   }
    58 }
    59 
    60 func viewPortFix(
    61   ctx context.Context, w, h int64) {
    62   err := emu.SetDeviceMetricsOverride(
    63     w, h, 1, false).
    64     WithScreenOrientation(
    65       &emu.ScreenOrientation{
    66         Type:
    67         emu.OrientationTypePortraitPrimary,
    68         Angle: 0,
    69       }).
    70     Do(ctx)
    71 
    72   if err != nil {
    73     panic(err)
    74   }
    75 }

Gewaltige Ausmaße

Die Frage ist nun natürlich, wie weit aufgezogen der virtuelle Browser eigentlich ist, denn diese Einstellung bestimmt, was auf dem Screenshot zu sehen ist. Ist nur ein Bruchteil der Webseite zu sehen, oder alles, inklusive der nur durch Scrollen erreichbaren Teile? Im vorliegenden Fall soll der Screenshot einfach alles erfassen, was der User sähe, falls er einen unendlich hohen Bildschirm mit voll aufgezogenem Browser hätte. Dazu holt die Funktion GetLayoutMetrics() die Dimensionen des Layouts der dargestellten Seite und die in Zeile 31 aufgerufene und ab Zeile 60 definierte Funktion viewPortFix() stellt die Abmessungen des unsichtbaren Browsers mittels SetDeviceMetricsOverride() darauf ein.

Den Bildpuffer buf, den die Screenshot-Funktion in Zeile 33 zurückgibt, schreibt WriteFile() aus der Go-Library ioutil in eine Bilddatei im PNG-Format auf die Festplatte. Den Ablauf der Tasks steuert die Funktion Run() ab Zeile 48, und fertig ist der Lack. Die Technik, Screenshots von automatisch eingeholten Webseiten zu erstellen, eröffnet eine Reihe von ungeahnten Möglichkeiten beim Testen von neu entwickelten Web-UIs. So kann eine Bilderkennung später feststellen, ob sich die verschiedenen grafischen Elemente des Auftritts auch am richtigen Platz befinden, ohne dass menschliches Testpersonal sich bei jedem Release tatsächlich aufwändig durch den Flow klicken müsste. Auch ließe sich so ein schönes System zum Archivieren von Webauftritten implementieren, im 22. Jahrhundert würden sich Historiker sicher über die anno 2020 auf der Homepage des Linux-Magazins geschaltete Werbung amüsieren.

Einfaches kompliziert

Zu Testzwecken wäre es manchmal ganz nützlich, den ferngesteuerten Browser nicht versteckt im Hintergrund, sondern sichtbar im Vordergrund zu starten. Paradoxerweise ist dies aber seit Einführung des voreingestellten Hintergrundmodus in Chromedp vor einiger Zeit relativ kompliziert, denn NewContext() zur Erzeugung eines neuen Browserkontextes konfiguriert die Browsereinstellungen tief unten und von außen unzugänglich im Motorraum der Library.

Listing 2 legt deshalb mit NewExecAllocator() einen neuen Browser-Controller an und gibt ihm die Option NoFirstRun mit, damit der Browser im Vordergrund läuft. Zurück kommt ein Context, der allerdings nicht kompatibel ist zum Context-Objekt, das chromedp verwendet und in Zeile 24 der ausführenden Funktion Run() mitgibt. Diesen kompatiblen Context erzeugt NewContext() in Zeile 12, das den vorher erzeugten Exec-Context als Mutterkontext mitbekommt. Auch der neue chromedp-Context verfügt über eine cancel()-Funktion, und die defer-Anweisungen in den Zeilen 13 und 14 triggern beide am Ende des Programms, um den aufgespannten Browser sauber zusammenzufalten.

Zu Testzwecken fährt Listing 2 lediglich die Homepages des deutschen und des englischen Linux-Magazins an, wartet dann fünf Sekunden mit Sleep() und terminiert sich anschließend selbst.

Listing 2: foreground.go

    01 package main
    02 
    03 import (
    04   "context"
    05   cdp "github.com/chromedp/chromedp"
    06   "time"
    07 )
    08 
    09 func main() {
    10   pctx, pcancel := cdp.NewExecAllocator(
    11     context.Background(), cdp.NoFirstRun)
    12   ctx, cancel := cdp.NewContext(pctx)
    13   defer cancel()
    14   defer pcancel()
    15 
    16   tasks := cdp.Tasks{
    17     cdp.Navigate(
    18       "https://linux-magazin.de"),
    19     cdp.Navigate(
    20       "http://linux-magazine.com"),
    21     cdp.Sleep(5 * time.Second),
    22   }
    23 
    24   err := cdp.Run(ctx, tasks)
    25   if err != nil {
    26     panic(err)
    27   }
    28 }

Volltextsuche

Wie nun kann die Browser-Drohne User-Interaktionen wie Mausklicks oder Tastatureingaben simulieren, um komplizierteren Webflows zu folgen? Die chromedp-Library bietet hierzu Funktionen an, um bestimmte Formfelder oder Buttons aus dem DOM der dargestellten Webseite per XPath-Query zu selektieren, und mittels SendKeys(), Submit() oder Click() anzusprechen. Um zum Beispiel eine Volltextsuche über alle Repositories auf Github abzufeuern, gilt es zunächst zu analysieren, wie denn das Suchfeld dort überhaupt heißt. Ein Blick auf den Developer-View des Chrome-Browsers über das Menü "Inspect Elements" offenbart, dass das Suchfeld auf das name-Attribut "q" hört (Abbildung 2). Den zugehörigen X-Path-Query //input[@name="q"] definiert Zeile 15 in Listing 3 in der Variablen sel und die Task WaitVisible() in Zeile 19 wartet nach dem Anfahren der Github-Seite, bis das Suchfeld übers Netz eingetrudelt ist.

Abbildung 2: Das Abfragefeld der Github-Volltextsuche über alle Repositories hört auf den Namen "q".

Die Funktion SendKeys() in Zeile 20 schickt dann den Suchstring ("waaah") an das Feld und schließt ihn mit einem Newline-Zeichen ab, was der Github-UI genügt, um die Suche einzuleiten, ohne dass ein Submit-Button zu drücken wäre. Zeile 21 wartet anschließend mit WaitReady("body", cdp.ByQuery), bis das Ergebnis vorliegt und leitet dann mit einer benutzerdefinierten ActionFunc() die Ausgabe des dargestellten HTML-Salats ein.

Listing 3: github.go

    01 package main
    02 
    03 import (
    04   "context"
    05   "fmt"
    06   "github.com/chromedp/cdproto/dom"
    07   cdp "github.com/chromedp/chromedp"
    08 )
    09 
    10 func main() {
    11   ctx, cancel :=
    12     cdp.NewContext(context.Background())
    13   defer cancel()
    14 
    15   sel := `//input[@name="q"]`
    16   tasks := cdp.Tasks{
    17     cdp.Navigate(
    18       "https://github.com/search"),
    19     cdp.WaitVisible(sel),
    20     cdp.SendKeys(sel, "waaah\n"),
    21     cdp.WaitReady("body", cdp.ByQuery),
    22     cdp.ActionFunc(
    23       func(ctx context.Context) error {
    24         node, err := dom.GetDocument().Do(ctx)
    25         if err != nil {
    26           panic(err)
    27         }
    28         res, err := dom.GetOuterHTML().
    29           WithNodeID(node.NodeID).Do(ctx)
    30         if err != nil {
    31           panic(err)
    32         }
    33         fmt.Printf("html=%s\n", res)
    34         return nil
    35       }),
    36   }
    37   err := cdp.Run(ctx, tasks)
    38   if err != nil {
    39     panic(err)
    40   }
    41 }

In der Darstellung in Abbildung 3 holt es dann mit dom.GetDocument() die Root-Node des HTML-Dokuments ein und mit dom.GetOuterHTML() den zugehörigen HTML-Code. Zu Testzwecken gibt die Printf()-Funktion den Source-Code der dargestellten Seite aus, eine Scraper-Applikation könnte daraus interessante Inhalte extrahieren und weiter verarbeiten.

Abbildung 3: Der kopflose Chrome-Browser gibt auf Github.com einen Such-Query ein.

Nur bedingt geduldet

Chromedp unterstützt zwar alle Chrome- und Edge-Browser, aber nicht Firefox, der auf einer anderen Basistechnologie fußt. Das Projekt chromedp auf Github scheint allerdings mit den ständigen Änderungen am DevTools-Protokoll und neuen Chrome-Versionen zu hadern, manchmal suchen die Selektoren einfach vergebens nach den Feldern der Webseite. Auf Github stehen offene "Issues" zum Thema und die Entwickler versuchen mit neuen Versionen der Bibliothek, die Probleme zu umgehen. Neben der Googles offizieller Dokumentation ([3]) finden Interessierte online zwar einige Tutorials, aber keine gründliche und praktische Aufarbeitung des Themas. Ein neues O'Reilly-Buch [4] enthält viel unnützes Füllmaterial, reißt aber unterschiedliche Themen im Bereich Web-Scraping an und geht auch auf Selenium und Chrome DevTools kurz ein.

Populäre Webseiten wie Facebook, Twitter oder Google scheinen ebenfalls daran interessiert zu sein, Scraping mit Tools wie chromedp möglichst zu erschweren. So verwendet Gmail auf der Login-Seite dynamisch generierte Zufallsnamen für die Eingabefelder. Auch erfordern Änderungen am Layout oft eine Anpassung des Scrapers, sodass das ganze Unterfangen ein Kopf-an-Kopf-Rennen bleibt. Schließlich wollen die Branchenriesen ihre User an die offiziellen Seiten binden, um sie mit Werbung zu bombardieren, denn irgendjemand muss ja am Ende die Stromrechnung bezahlen.

Infos

[1]

Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2020/03/snapshot/

[2]

"Daten abstauben", Michael Schilli, Linux-Magazin 04/2019, https://www.linux-magazin.de/ausgaben/2019/04/snapshot-13/

[3]

"Chrome DevTools", https://developers.google.com/web/tools/chrome-devtools/

[4]

“Go Web Scraping Quick Start Guide”, Safari, https://learning.oreilly.com/library/view/go-web-scraping/9781789615708/cover.xhtml

Michael Schilli

arbeitet als Software-Engineer in der San Francisco Bay Area in Kalifornien. In seiner seit 1997 laufenden Kolumne forscht er jeden Monat nach praktischen Anwendungen verschiedener Programmiersprachen. Unter mschilli@perlmeister.com beantwortet er gerne Ihre Fragen.

POD ERRORS

Hey! The above document had some coding errors, which are explained below:

Around line 5:

Unknown directive: =desc