Spearhead
2020-02-14 02:08

Web Scraping to niewdzięczna kochanka w dzisiejszych czasach, gdy watahy nieudolnych juniorów dorwały się w owczym pędzie do frontu i tworzą rozmaite pokraczne straszydła bez żadnego sensu i powodu. Kiedyś strony serwowały po prostu HTML, z którego można sobie spokojnie wyłuskiwać dane. Ale nie, to jest zbyt prymitywne dla naszych szacownych frontów, opentanych wariacką manią frameworków, overengineeringu i innego dziadostwa. Teraz całe mrowie stron pobiera dotakowo tonę JavaScriptu (oczywiście spakowanego jakimś WebPackiem, więc ewentualne próby analizy są cokolwiek utrudnione), który dynamicznie ładuje tony śmieci ze schowanych głębiej API lub jakichś JSON-ów wpakowanych prosto w kod. Tak też i mi się dostało w dzisiejszym przypadku.

Trafiła mi się tym razem strona, której autorzy posiadają w kodzie tagi <script> wewnątrz której znajduje się wywołanie anonimowej funkcji tworzącej JSON, który później jakieś inne skrypty przerabiają na widoczne na stronie elementy. W skrócie wygląda to tak

window.__NUXT__ = (function(a, b, c, d, e, f, g, h, i, /* ... */ jX, jY, jZ, j_) {
    // ...
    return {
        layout: "default",
        data: [ /* ... */ ]
    }
)("1", "0", "0.9", "221", /* ... */ 2019-09-26T13:51:45.723", "190039", "2019-09-26T13:50:13.287"));

Ten __NUXT__ sugeruje, to dziadostwo. Fajnie. Tyle, że ta funkcja ma jakieś 700 parametrów i generuje zwracany JSON na 20000 linii. Innymi słowy, interesujące mnie dane są na liście 700 przekazanych w jakimś losowym porządku argumentów. I nikt mi nie zaręczy, że ten porządek się nie zmieni za tydzien, jak wprowadzą jakieś inne parametry czy inny refactoring.

No dobra. Listę parametrów w Scrapy pobrać łatwo

script = response.css('script:contains("window.__NUXT__")::text')

function_parameter_list = script.re_first(
    'function\('  # opening call - "function("
    '(.*?)'       # capturing group of parameters
    '\)\W*\{'     # closing ")", opening "{"
).split(',')

To mi zwraca listę ['a', 'b', ...] wszystkich parametrów. Teraz dopasować, który to który. Listę argumentów (każdy pamięta, czym się różni parameter od argumentu, prawda...?) można pobrać równie łatwo:

function_argument_list_str = script.re_first(
    '}\('       # closing '}' of function call, opening '(' of argument list
    '(.*)'      # capturing group of arguments
    '\)\)\W+$'  # closing '))', optional empty spaces, end of line
).split(',')

I teraz sobie mogę zrobić odwzorowanie:

argument_map = {k: v for k, v in zip(function_parameter_list, function_argument_list)}

Teraz powiedzmy chcę pobrać wartość parametru TemporaryNotAvailable, widzę, że z kodu można odczytać, że to zmienna o:

window.__NUXT__ = (function(/* ... */) {
    return {
        layout: "default",
        data: [{
            // ...
                TemporaryNotAvailable: o,
            // ...
        }]
    }
})(/* ... */)

Więc styknie sparsować skrypt regexem i pobrać wartość ze stworzonej mapy:

argument_map.get(script.re_first(',TemporaryNotAvailable:(\w+),')

Tylko, że to nie działa. Bo nie może być tak prosto, co nie...? Naiwne splitowanie po , jest zawodne. Starczy, że któryś z argumentów jest stringiem zawierającym ów znak, a stworzona tak lista argumentów się rozjeżdża z listą parametrów. Trzeba zatem listę argumentów sparsować sprytniej.

Na szczęście, niebiosom dzięki, ludki z frontu przekazują tam niemal same literały, bez żadnych złożonych obiektów. Zapis ("1", "0", "0.9", "221") działa tak w Pythonie jak i w JavaScripcie, bo po prostu mamy do czynienia z sekwencją wartości, głównie stringów plus jakieś liczby. Wystarczy więc użyć eval i zrzucamy string na Pythonową krotkę. Tylko kto normalny używa eval? Mnie by też sumienie gryzło. Na szczęście w Pythonie jest też coś takiego jak ast.literal_eval, który z grubsza działa podobnie, ale jest o wiele bardziej bezpieczny - nie wywołuje bowiem żadnych złośliwych funkcji, jedynie zrzuca zwrócony string jakby to była deklaracja szeregu obiektów. No to lecim

function_argument_list_str = script.re_first(
    '}\('       # closing '}' of function call, opening '(' of argument list
    '(.*)'      # capturing group of arguments
    '\)\)\W+$'  # closing '))', optional empty spaces, end of line
)
function_argument_list = ast.literal_eval(function_argument_list_str)

I teraz to PRAWIE działa, bo jednak raz za czas, od święta, typy z JavaScriptu nie pasują do tych z Pythona. Tam na przykład literał fałszu logicznego to false, a w Pythonie False. I program się wyburacza na takich niezgodnościach. Szczęśliwie, nie ma tego wiele i można łatwo naprawić listę JavaScriptowskich argumentów by wyglądały jak Pythonowska krotka

js_keywords_map = {'null': 'None', 'false': 'False', 'true': 'True', 'void': '', 'Array': ''}
for k, v in js_keywords_map.items():
    function_argument_list_str = function_argument_list_str.replace(k, v)

Uff, działa i na ilę mogę stwierdzić, pobierane są te wartości co trzeba.

I tera, panowie fronci, wyjaśnijcie mi po kiego czorta tak nas wkurwiacie, biednych web-scrapperów, z podobnym nonsensem? Nie dało się tego wyjściowego JSON-a od razu wstawić w stronę, albo przekazać go w argumencie? Coś w stylu

window.__initial_state__ = {"a": 12, "b": 23232, /* ... */}

Takie coś już nieraz się spotykało i można bez problemu z miejsca sparsować prostym json.loads po wydłubaniu z odpowiedniego miejsca w dokumencie. Ale nie, trzeba tworzyć podobne cudaczne twory, aż ręce człowiek załamuje, jak zajrzy w źródło strony, bo obejrzeć skąd właściwie strona bierze swoje dane. Tfu.

WeiXiao

web skraperzy zapomnieli o selenium? nawet powiedziałbym że lepiej jest gdy wszystko jest api based, bo możesz sam do tego api wysyłać requesty zamiast regexować htmle ;)

Adam Boduch

Takie czasy, frontu nie pisze się już przy pomocy jQuery ale właśnie frameworków typu React.js, Vue.js. Powiem Ci że obecnie pisanie frontu to niebo a ziemia w porównaniu z tym co było kiedyś. Bajka. Jest to naprawdę przyjemne. Z ciekawostek powiem, że wspomniany Nuxt.js ma możliwość renderowanie po stronie serwera i wyplucia gotowego już kodu HTML do klienta.

Spearhead

@WeiXiao: jak scrapujesz dziesiątki tysięcy podstron to narzut z renderowaniem ich przez headless browser zaczyna przeszkadzać. A API jak najbardziej fajnie mieć, tylko proszę, by było sensowne i zwracało dane, a nie funkcję do ich generowania.

Wibowit

@Spearhead: zamiast odpalać headless browser czy ręcznie coś targać z HTMLi i JSów to można użyć Node.js + https://github.com/jsdom/jsdom jsdom is a pure-JavaScript implementation of many web standards, notably the WHATWG DOM and HTML Standards, for use with Node.js. In general, the goal of the project is to emulate enough of a subset of a web browser to be useful for testing and scraping real-world web applications.

WeiXiao

@Spearhead: a że tak zapytam, a dlaczego ktoś miałby ci ułatwiać scrapa? ktoś Ci wystawia te dane, czy po prostu "kradniesz"?

WeiXiao

@Wibowit: oj tam od razu gotowce, samemu można napisać engine jsa ;)