Web Scraping i ScreenShooting w Headless Chrome (CLI)

“Web Scraping” kojarzy mi się z odległymi latami, kiedy to tworzyło się boty i skrypty w Perlu wyciągające ze stron różne potrzebne dane. W większości działały one w command-line i “widziały” tylko to co zawiera źródło strony. Trochę więcej zachodu było jeśli chciało się mieć jakiś dostęp do DOM-a i wykonywać skrypty JS (własne lub te ze strony), aby dostać finalny kod strony, jaki widzą użytkownicy w przeglądarce. To wymagało już jakiś sztuczek z embedowaniem przeglądarki lub jej silnika. A totalnym kosmosem było zrobienie screena strony. No ale czasy się zmieniły…

Obecnie wiele stron posiada dedykowane API i można łatwo, ale nie zawsze przyjemnie pobrać dane. Pojawiły się też projekty i serwisy udostepniające możliwość zrobienia zrzutu ekranu ze strony. Automatyzując jakieś własne zadania możemy nie chcieć się uzależniać od innych, a zrobienie tego w lokalnym środowisku może być jedyną możliwą opcją. Z pomocą przychodzi najpopularniejsza przeglądarka.

Wyodrębnienie jakiś danych ze strony lub zrobienie jej screenshota w Chrome można wykonać bez żadnych rozszerzeń korzystając tylko z DevTools-ów1. No, ale trzeba manualnie coś tam poklikać i postukać w klawisze. Mi bardzo przydałaby się taka możliwość wprost spod terminala. Mały research w sieci doprowadził mnie do “Headless Chrome”.

Headless Chrome

Headless Chrome wprowadzone w Chrome w wersji 592, pozwala na uruchomienie przeglądarki w trybie “headless”, czyli bez interfejsu graficznego. Przenosząc tym samym wszystkie możliwości Chromium i Blinka do wiersza poleceń3.

Headless Chromium allows running Chromium in a headless/server environment. Expected use cases include loading web pages, extracting metadata (e.g., the DOM) and generating bitmaps from page contents – using all the modern web platform features provided by Chromium and Blink.

Stworzono dzięki temu świetne narzędzie do automatyzacji “internetowych” zadań i testów, gdzie jak wiadomo GUI nie jest nikomu do szczęścia potrzebne, a często wręcz przeszkadza.

Przeglądarkę bez UI można uruchomić z linii poleceń dołączając w parametrach flagę --headless. Od razu są też dostępne różne polecenia w działające w tym trybie. Można łatwo “zrzucić” DOM strony czy jej wyrenderowany widok:

:: print page DOM (document.body.innerHTML) to stdout
chrome --headless --disable-gpu --dump-dom https://www.google.com

:: take a screenshot of the loaded page
chrome --headless --disable-gpu --screenshot https://www.google.com

Pamiętać należy, że uruchamianie każdej sesji przeglądarki jest trochę kosztowne, w wielo-procesowych aplikacjach trzeba te wszystkie procesy odpalić, potem obsłużyć zadania, itd… więc trochę czasu to trwa. Lepiej jakoś ograniczyć tworzenie nowych sesji i robić więcej za jednym zamachem. Możliwością na “coś więcej” w takiej “headless” instancji jest tryb “REPL” (read-eval-print loop) - dodatkowy parametr --repl. Można wtedy uruchamiać w kontekście strony dowolny kod JS wprost z wiersza poleceń. Otrzymujemy taką namiastkę pseudo-interaktywnej konsoli JS.

>chrome --headless --disable-gpu --repl https://www.google.com
[0821/182026.493:INFO:headless_shell.cc(447)] Type a Javascript expression to evaluate or "quit" to exit.
>>> document.title
{"result":{"type":"string","value":"Google"}}
>>> quit

Niestety pod Windowsowymi terminalami jest taki problem z aplikacjami Win32, że proces nie otrzymuje uchwytu do konsoli ani standardowych strumieni (jak przy programach konsolowych) i nie czeka też na jego zakończenie. Dlatego strasznie ciężko korzystać z takiej metody obsługi przeglądarki. Potrzebne jest tutaj coś co stworzy proces, przekieruje strumienie i je odpowiednio obsłuży, zapewniając tym samym obustronną interakcję.

Chrome DevTools Protocol

Kolejna ciekawa opcja to uruchomienie przeglądarki w trybie zdalnego debugowania i podłączenie się pod wystawiony WebSocket, gdzie można nawiązać z nią bezpośrednią komunikację w Chrome DevTools Protocol.

The Chrome DevTools Protocol allows for tools to instrument, inspect, debug and profile Chromium, Chrome and other Blink-based browsers. Many existing projects currently use the protocol. The Chrome DevTools uses this protocol and the team maintains its API.

Uruchomienie instancji Chrome z włączoną obsługą CDP sprowadza się do podania w parametrach przełącznika --remote-debugging-port wraz z numerem portu (domyślnie 9222):

chrome --profile-directory="TestProfile" --remote-debugging-port=9222

Za pomocą kilku “metod” HTTP dostępnych na tym porcie (HTTP Endpoints) można poznać adresy możliwych do podłączenia punktów końcowych protokołu CDP - głównej instancji (browser) i listy dostępnych targetów stron (pages).

W wielu przypadkach nie ma potrzeby łączenia się z głównym targetem, bo wystarcza bezpośrednia kontrola strony. Listę wszystkich możliwych targetów stron (otwarte zakładki, strony rozszerzeń) dostaniemy pod adresem /json/list:

[ {
	"description": "",
	"devtoolsFrontendUrl": "/devtools/inspector.html?ws=127.0.0.1:9222/devtools/page/3B6EACAEC01090145D57537786597DD2",
	"id": "3B6EACAEC01090145D57537786597DD2",
	"title": "about:blank",
	"type": "page",
	"url": "about:blank",
	"webSocketDebuggerUrl": "ws://127.0.0.1:9222/devtools/page/3B6EACAEC01090145D57537786597DD2"
} ]

Ścieżki do debugowych punktów końcowych znajdują się w polach webSocketDebuggerUrl. Wystarczy podłączyć się pod podany adres wybranego celu i można poprzez WebSocketa zacząć kontrolę strony za pomocą protokołu CDP.

Chrome DevTools Protocol bazuje na JSONRPC. Zatem wszystkie “pakiety” latające po sockecie to struktury JS-a (JSON). Format komendy (wywołanie funkcji API) zawiera unikalny identyfikator id, nazwę funkcji method oraz wymagane przez nią parametry params. Przykładowy “pakiet” załadowania nowej strony w bieżącej zakładce - metoda Page.navigate:

{
	"id": 1,
	"method": "Page.navigate",
	"params": {
		"url": "https://google.com"
	}
}

W odpowiedzi serwer odsyłała wiadomość z takim samym id, umożliwiający powiazanie request-response oraz rezultat wykonania operacji result. Tak wygląda odpowiedz na wysłaną wcześniej komendę nawigacji do nowej strony:

{
	"id": 1,
	"result": {
		"frameId": "3B6EACAEC01090145D57537786597DD2",
		"loaderId": "F154C097938DBFEED358FA0F77CBD0F0"
	}
}

Zdalny serwer CDP wysyła także komunikaty zdarzeń, jeśli takowe zostały włączone w danym target domain. Taka wiadomość ma podobny format co struktura komendy, ale nie zawiera żadnego identyfikatora. Przykładowy zrzut eventa Page.frameStartedLoading uruchamianego, gdy strona zaczęła się ładować:

{
	"method": "Page.frameStartedLoading",
	"params": {
		"frameId": "3B6EACAEC01090145D57537786597DD2"
	}
}

Protokół jest dosyć prosty i dobrze udokumentowany, ale ręczna obsługa socketa i (de)serializowanie wiadomości może być trochę upierdliwe. Szczególnie, że wysłanie kilku komend po sobie, opóźnienia i eventy trzeba dobrze obsłużyć w kodzie, by otrzymać jako taką asynchroniczność. Dlatego zrezygnowałem z próby implementacji jakiegoś przykładowego skryptu. Zamiast tego do komunikacji z hostem CDP wykorzystałem prostego klienta WebSocketa4 i poeksperymentowałem interaktywnie z surowym protokołem. Powyżej prezentowane przykłady formatu pakietów pochodzą z takiej zabawy. Dla zainteresowanych zabawą z gołym protokołem mogę polecić artykuł Using Chrome DevTools Protocol.

Kłopotliwość bezpośredniego używania surowego protokołu doprowadziła mnie do chrome-remote-interface.

chrome-remote-interface

Pomimo niezaprzeczalnego faktu, że protokół DevTools-ów jest prosty to jednak zdecydowanie łatwiej i wygodniej jest skorzystać z jakiejś lekkiej nakładki zwalniającej nas z bezpośredniego ręcznego babrania się “pakietami” CDP. Rekomendowaną implementacją takiego abstrakcyjnego interfejsu jest moduł chrome-remote-interface z Node.js.

Chrome Debugging Protocol interface that helps to instrument Chrome (or any other suitable implementation by providing a simple abstraction of commands and notifications using a straightforward JavaScript API.

Biblioteka ta jest prostym JS-owym (Node) “bindingiem” do CDP z interfejsem odzwierciedlającym 1:1 komendy i funkcje protokołu. Umożliwia to skupienie się na “czystym” wywoływaniu funkcji jakie udostępnia serwer CDP w przeglądarce bez potrzeby zaprzątania sobie głowy warstwą komunikacji i całą otoczką wokół, dając złudzenie jakbyśmy bezpośrednio operowali na tym właśnie API wewnątrz programu.

Przykładowo, mając odpalonego Chrome z włączoną obsługą CDP na domyślnym porcie, bardzo prosto połączyć się z przeglądarką i załadować wybraną stronę, po czym kawałkiem JS-a wyekstrahować tytuł dokumentu, aby na koniec walnąć screenshota. Całość sprowadza się do kilku linijek kodu (pominięto obsługę błędów!):

// cri-test.js
const CDP = require('chrome-remote-interface');
const fs = require('fs');

const url = "https://www.google.com/";
const filename = "screenshot.png";

(async () => {

	const client = await CDP();
	const { Page, Runtime } = client;
	await Page.enable();
	await Runtime.enable();

	await Page.navigate({ url });
	await Page.loadEventFired();

	const { result } = await Runtime.evaluate({
		expression: "document.querySelector('title').textContent"
	});
	console.log('Title: ' + result.value);

	const { data } = await Page.captureScreenshot();
	fs.writeFileSync(filename, Buffer.from(data, 'base64'));
	console.log('Screenshot: ' + filename);

	client.close();

})();

Kod nie wymaga komentarza - nazwy funkcji odpowiadają tym z API protokołu i są w miarę jednoznaczne. Domyślnie CDP() podłączy się pod aktywną zakładkę (target). Całe API jest asynchroniczne - biblioteka bazuje na Promises, dlatego dla czytelności i wygody nagminnie używam await w celu zachowania pseudo-synchronicznego wykonania kodu.

Po uruchomieniu skryptu z powyższym kodem, jak się można było spodziewać, na konsoli dostałem taki “output”:

>node cri-test.js
Title: Google
Screenshot: screenshot.png

A w pliku screenshot.png w bieżącym katalogu znalazłem widok strony, jaki został wyrenedrowany w przeglądarce.

Otrzymany obrazek zawiera tylko fragment strony jaki był widoczny w oknie przeglądarki. Do otrzymania zrzutu całego widoku strony trzeba trochę pokombinować. Jest wiele możliwości, jedną z najprostszych będzie ustawienie takich wymiarów okna/widoku, aby zmieściła się cała strona.

Można to łatwo zrobić pobierając przez API wymagane wymiary:

const { Emulation } = client;
const metrics = await Page.getLayoutMetrics();

const width = Math.ceil(metrics.contentSize.width);
const height = Math.ceil(metrics.contentSize.height);

await Emulation.setDeviceMetricsOverride({
	width,
	height,
	deviceScaleFactor: 1,
	mobile: false,
});
await Emulation.setVisibleSize({ width, height });

Innym sposobem jest pobranie wymiarów “contentu” wprost z elementu <body/> strony za pomocą JavaScriptu.

Wszystko ostatecznie zależy od rodzaju strony i oczekiwanego efektu. Wiele stron jest responsywnych i dostosowują się do wymiarów okna, zatem zawsze można zacząć od jakiś uniwersalnych szerokości viewport-u i/lub zmieniać tylko wysokość, aby uchwycić pełną zawartość strony5.

Przed zabawą z biblioteką najlepiej zapoznać się z dokumentacją protokołu DevTools, bo poza “własnym” obiektem CDP interfejs modułu bezpośrednio “przekłada” się na funkcje protokołu. Polecam zajrzeć także na Wiki projektu, gdzie znaleźć można wiele pozytecznych przykładów.

Moduł chrome-remote-interface idealnie sprawdza się jako wrapper na surowy protokół ułatwiając z nim pracę, a jednocześnie dając pełna moc wykorzystania jego możliwości. Czasem jednak dla większej wygody, komfortu pracy lub w specjalnych zastosowaniach, okazać się może, że taki interfejs jest zbyt “rozdrobniony” i niskopoziomowy. Wtedy może pojawić się potrzeba pracy z bardziej abstrakcyjnym API wyższego poziomu. I tak natrafiłem na Puppeteera.

Puppeteer

Google wypuścił swoją wysokopoziomową bibliotekę w Node.js do sterowania przeglądarką przez CDP - Puppeteer.

Puppeteer is a Node library which provides a high-level API to control Chrome or Chromium over the DevTools Protocol. Puppeteer runs headless by default, but can be configured to run full (non-headless) Chrome or Chromium.

Biblioteka posiada wysokopoziomowe API, działa “out of the box” i jak zapewniają twórcy (Chrome DevTools team):

Most things that you can do manually in the browser can be done using Puppeteer!

No nie wiem, jak u innych, ale u mnie na Windowsach to nie było to takie działające “od razu”, ale o tym później.

Adekwatny skrypt do tego z CRI zakodowany z wykorzystaniem Puppeteera wygląda tak:

// pup-test.js
const puppeteer = require('puppeteer');

const url = "https://www.google.com/";
const filename = "screenshot.png";

(async () => {

	const browser = await puppeteer.launch();
	const page = await browser.newPage();

	await page.goto(url, { waitUntil: 'load' });
	console.log('Title: ' + await page.title());

	await page.screenshot({ path: filename, fullPage: true });
	console.log('Screenshot: ' + filename);

	await browser.close();

})();

Jest znacznie krótszy i prostszy. To zasługa API biblioteki, która nie jest już prostym wrapperem, a zawiera trochę logiki w warstwie wyższej co znacznie ułatwia i usprawnia zdalne manipulowanie przeglądarką.

Domyślnie przy instalacji modułu puppeteer pobierana jest określona binarka Chromium, aby zapewnić kompatybilność i pełne wsparcie danej wersji protokołu oraz mieć gwarancję, że wszystko będzie działało poprawnie. W większości przypadków jest to dobre rozwiązanie, w końcu moduł powstał głównie do łatwej automatyzacji i testów.

Jeśli z jakiś powodów nie chcemy korzystać z tej “bundlowanej” przeglądarki to można specyfikować ścieżkę do pliku wykonywalnego własnej wersji. A, gdy ktoś nie chce pobierać ciężkiej kobyły, to może skorzystać z wersji puppeteer-core, która jest takim puppeteer bez własnego browsera6.

Niezależnie od wersji, u mnie na Windowsie pojawia się pewien problem z działaniem uruchomionej przez Puppeteera przeglądarki. Występuje on przy próbie używania trybu “headless”, wtedy bez paskudnego hacka robiącego potencjalną dziurę bezpieczeństwa - flagi --no-sandbox procesy chrome.exe sobie wiszą i dyndają, a robota stoi… ;)

const browser = await puppeteer.launch({
	headless: true, 
	executablePath: 'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
	args: [
		'--no-sandbox',
	]
});

Oczywiście taki problem nie występował w przypadku ręcznego uruchamiania Headless Chroma i komunikacji czy to po gołym protokole, czy z użyciem CRI. Ciekawa sprawa, gdzieś jest jakiś babol, ale z braku czasu olałem temat…

Prócz tego drobnego problemu moduł jest bardzo rozbudowany z “user-friendly” API ukrywającym złożoność protokołu CDP. Puppeteer idealnie sprawdzi się przy szybkim automatyzowaniu różnego rodzaju testów webowych..

Poza bogatą dokumentacją, do przejrzenia polecam masę praktycznych przykładów jakie można znaleźć w repozytorium puppeteer-examples i na stronie Getting to Know Puppeteer Using Practical Examples.

Podsumowanie

Moja krótka i szybka droga po próbie znalezienia prostego sposobu na screenshoting stron dobiegła końca. Poruszałem się tylko wokół Chrome i możliwości tej przeglądarki, a znalezione rozwiązania uświadomiły mi, jak dużo można zrobić dziś z przeglądarkami. Pomimo, że zrobiłem tylko proste testy7, wycinek tego co oferuje protokół DevTools-ów i używające go biblioteki. Warto mieć na uwadze, że tam w wielkim świecie istnieje jeszcze masa innych bibliotek i modułów powstałych głównie do zautomatyzowania zadań i testów nie tylko związanych z web-developmentem.

W ostatni wpisie pozostawiłem otwartą kwestię zautomatyzowania przygotowywania danych na potrzeby moich wpisów - głównie zrzutu fragmentu strony do obrazka. W obecnej chwili myślę, że korzystając z chrome-​remote-interface lub Puppeteera bez problemu udałoby mi się prosto i w kilku krokach oskryptować takie nudne czynności.

A czy do tego dojdzie to zobaczymy ;)


Przypisy

  1. Zrobienie zrzutu ekranu strony lub jej fragmentu można wykonać jedną z kilku komend grupy “Screenshot” w oknie poleceń. ↩︎

  2. W wersji 59 dla Linuxa i Maca, a dla Windowsa w wydaniu 60-tym. ↩︎

  3. Tryb ten powinien być też automatycznie dostępny w innych przeglądarkach korzystających z Chromium, o ile jest jawna możliwość jego włączenia. Firefox ma też swój “Headless mode”. ↩︎

  4. Niemiałem pod ręką żadnego gotowego kodu ani prostego klienta opakowującego obsługę WebSocketa, dlatego skorzystałem z najprostszej rzeczy - pierwszego lepszego klienta w postaci rozszerzenia do przeglądarki - Simple WebSocket Client. ↩︎

  5. Taką koncepcję można znaleźć w wielu miejscach w sieci i jednym z przykładów użycia biblioteki ‘chrome-remote-interface’. ↩︎

  6. O szczegółach i różnicach dzielących te dwa moduły można przeczytać w dokumentacji - puppeteer vs puppeteer-core. ↩︎

  7. Pełne źródła (skrypty) przedstawionych tutaj fragmentów kodu dostępne są w moim repozytorium mal-code-repo. ↩︎

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *