Data Scraping #4 — Selenium podruhé

Ještě jeden díl vydržíme u Selenia. Sice jsem v předchozím díle zmiňoval, že je lepší se mu vyhnout, ale někdy nám může ušetřit docela dost práce.

Postoval jsem odkazy na předchozí články do skupiny české Python komunity a přesně jak komentoval Ondra Bárta: “Jde o to se prostě nenadřít”.

Dneska si ukážeme další možnost selenia, kterou je možnost v klientském prohlížeči spouštět (java)scripty a pak pracovat s jejich výstupem. Jako příklad jsem zvolil populární server bezrealitky.cz a naším zadáním budiž vypsat/uložit seznam všech bytů na prodej v Praze. Takže tento díl bude spíš o JavaScriptu než o Pythonu.

Selenium nám umožňuje spustit v prohlížeči JavaScript a jeho výsledek pak předat zpátky do Pythonu, abychom s ním mohli dál pracovat. Základní použití vypadá takto:

# vytvoříme isntanci FF - známe z minula
driver = get_driver()
# načteme stránku
driver.get("http://google.com")
# spustíme 
result = driver.execute_script("return 40 + 2")
# vypíšeme výsledek
print("The Answer to the Ultimate Question of Life, the Universe, and Everything is", result)
# ukončíme browser
driver.close()

v konzoli pak uvidíme nečekanou odpověď

The Answer to the Ultimate Question of Life, the Universe, and Everything is 42

Toto nám dovolí pracovat víc se stránkou, sic prostřednictvím JavaScriptu, ale může nám to ušetřit spousty práce. Server bezrealitky.cz jsem nezvolil náhodou. Jejich frontend je v Angularu, což nám umožní docela dobře demonstrovat použití a doufám, že to poslouží jako hezký příklad.

Když se podíváme na stránky a vyhledáme si všechny byty v Praze, tak zjistíme, že jejich výpis máme v pravém panelu. Použijeme inspektor a jdeme si prohlédnout HTML.

Celý seznam je obalen v tagu section, na kterém je zajímavé, že má atribut data-ng-controller (1) a říká, že jeho obsah obsluhuje Angular. Dále pak vidíme, že v tomto kontroleru je použita direktiva ng-repeat pro iteraci skrze kolekci results, která obsahuje dané nemovitosti. Protože můžeme z inspektoru spouštět JavaScript přímo na dané stránce, tak se můžeme podívat jak kolekce results vypadá.

var scope = angular.element(document.getElementsByClassName(“page-search”)[0]).scope();

Tímto se dostaneme k objektu scope přímo z daného kontroleru. Kdo nezná Angular, tak by se dalo říct, že scope je objekt, skrz který se synchronizují proměnné v šabloně s kontrolerem, takže vše co je použito v šabloně je právě v tomto objektu. Tedy pokud máme v šabloně ng-repeat=”results in results” — pak scope.results je ten objekt, nad kterým se iteruje. To bychom měli scope, teď tedy vypsat data:

console.log(scope.results.length);
console.table(scope.results);

A vidíme všechna data. Přesněji ne všechna, ale jen ta na první stránce, jelikož když se podíváme pozorněji, tak tam máme stránkování. Kdybychom chtěli všechny byty na prodej v Českých Budějovicích, tak by nám to stačilo.

Jak se tedy dostat ke všem objektům? Můžeme buď párkrát kliknout na načíst další a pak si je zkusit vytáhnout. Můžeme se také podívat do panelu Network v inspektoru a zjistit, odkud se data berou, ale k tomu by byl tenhle článek na nic (navíc se může stát, že klasická knihovna requests nepůjde použít z nějakého důvodu, například komplikovaný state stránky v kombinaci s SSO).

Podíváme se tedy, skrze inspektor, do zdrojáku. V tabu Sources si najdeme naší doménu a pak script, kde by se mohl kód Kontroleru ukrývat. Po trošce hledání můžeme vypozorovat, že všechny výsledky nám dává služba ServiceSearch.

Když se podíváme na danou službu, pak uvidíme, že má všechny výsledky v paměti a také vystavuje metodu getRecords(), která nám dá vše co potřebujeme.

SeviceSearch služba
Metoda, kterou jsme hledali

Stačí tedy upravit JavaScript, abychom dostali výsledek do pole, které si můžeme vrátit do aplikace. Rozepsaný kód bude vypadat takto:

// 1:
var controller = angular.element(document.getElementsByClassName("page-search")[0]);
// 2:
var injector = controller.injector();
// 3:
var searchService = injector.get("ServiceSearch");
// 4:
var records = searchService.getRecords();
// 5:
console.table(records);
  1. dostaneme instanci kontroleru — nikoliv scope — potřebujeme se totiž dostat k dané službě.
  2. Angular má DI, takže potřebujeme získat objekt injektoru, který má informaci o daných službách.
  3. Na injektoru si vyžádáme objekt s názvem ServiceSearch.
  4. Konečně zavoláme metodu getRecords(), která nám vrátí data.
  5. Pro kontrolu si data vypíšeme.

Teď už jen data dát do požadované nějaké struktury, se kterou se nám bude dobře pracovat a vrátit je zpátky do naší Python aplikace.

var output = [];
for(i=0; i< records.length; i++){
r = records[i];
output.push({
id: r.id,
title: r.title,
price: r.price,
surface: r.surface,
position: {
lat: r.marker.position.lat(),
lng: r.marker.position.lng(),
}
});
}
return output;

Tak to bychom měli script. Teď to jen dáme dohromady a celé to předáme driveru do metody execute_script(…), aby nám to spustila. Metoda nám pak vrátí všechna data krásně v Pythonu, takže s tím dál můžeme pracovat.

Celý kód pak vypadá takto:

Vím, že postup vypadá složitě, zvlášť tedy pro někoho, kdo nezná Angular. Ale celé kouzlo je v tom, že místo abychom složitě řešili dynamický JavaScript, stránkování a pak parsovali data, tak nám stačí znát trošku Front-End technologie a k datům se dostat přímočaře (dal by se z toho udělat one-liner, ale i rozepsané je to jen na čtyři řádky). JavaScript můžeme chytře využít ke spoustě věcí a na dané stránce nemusí bežet jen Angular, můžeme takhle ovlivnit snad jakoukoliv knihovnu. V JS se dá taky využít MonkeyPatching a tak můžeme celkem jednoduše změnit chování nějaké funkce, aby pracovala pro nás :).

Na závěr jestě malá poznámka pro pozornější, kdy se jistě ptáte, proč jsem si celou proměnnou results nevrátil do Pythonu přímo. Problém je v tom, že tím, že je v ní uložena reference na objekt marker, který obsahuje zas jiné reference (nejspíš mapu v html a ta zas další). Tím vzniká cirkulární reference a objekt tedy nejde serializovat/přenést. Je tedy potřeba si vytáhnout to co jde a přenést až výsledek.

To by bylo pro dnešní díl vše. Jako vždy, kdyby byly nějaké otázky, tak je rád zodpovím :)