Web Scraping i ScreenShooting w Headless Chrome (CLI)
• tech • 2185 słów • 11 minut czytania
“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 webdevelopmentem.
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
-
Zrobienie zrzutu ekranu strony lub jej fragmentu można wykonać jedną z kilku komend grupy “Screenshot” w oknie poleceń. ↩︎
-
W wersji 59 dla Linuxa i Maca, a dla Windowsa w wydaniu 60-tym. ↩︎
-
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”. ↩︎
-
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. ↩︎
-
Taką koncepcję można znaleźć w wielu miejscach w sieci i jednym z przykładów użycia biblioteki ‘chrome-remote-interface’. ↩︎
-
O szczegółach i różnicach dzielących te dwa moduły można przeczytać w dokumentacji - puppeteer vs puppeteer-core. ↩︎
-
Pełne źródła (skrypty) przedstawionych tutaj fragmentów kodu dostępne są w moim repozytorium mal-code-repo. ↩︎
Komentarze (0)