Web scraping
(Update: there will probably be some amount of English-speaking people trying to read this article. Sorry, it's in Czech. My next programming articles will all be in English. You can start with Rock Paper Scissors with core.async!)
Challenge: dostat adresář nakladatelů Národní knihovny ČR do Excelové tabulky.
Adresář nepodporuje nějaké hromadné stažení celé databáze - stáhne jen to, co vyhledáte, a má limit 1000 záznamů na stažený soubor. Takže na to musíme nějak chytře. A abychom se neuklikali k smrti, tak si to radši zautomatizujeme.
Krok první: nějak dotazy na tu databázi pokrýt celé spektrum a všechno to stáhnout.
První pokus byl nějak šikovně zformovat URLka a nechat Bash a wget udělat všechnu tu těžkou práci za mě. Jenže adresy se mění a dělá se tam nějaká magie s cookies, není to moc předvídatelné a nedařilo se mi.
Druhý pokus byl přes Twill - pythonovou knihovnu, která imituje prohlížeč - go("http://..."), follow("Link"), get_html() atd. Jenže adresář má nějakou ochranu proti botům, kterou se mi nepovedlo obejít ani změnou User-Agenta. Takže tohle taky ne.
UPDATE: po funuse jsem našel clj-webdriver ... Damn :)
Takže třetí, finální a musím dodat nejbrutálnější pokus: Automator a AppleScript. Jde o to, že programem ovládáte Safari a klávesnici. Vypadá to nějak takhle:
on run {input, parameters} set theCharacters to {"a*", "b*", "c*", "d*", "e*", "f*", "g*", "h*", "i*", "j*", "k*", "l*", "m*", "n*", "o*", "p*", "q*", "r*", "s*", "t*", "u*", "v*", "w*", "x*", "y*", "z*","0*","1*","2*","3*","4*","5*","6*","7*","8*","9*"} repeat with theChar in theCharacters try tell application "Safari" activate do JavaScript "window.open('http://aleph.nkp.cz/F/')" in document 1 delay 2 do JavaScript "document.querySelectorAll('a')[15].click()" in document 1 delay 2 set theJS to "document.querySelector('input[name=request]').value = '" & theChar & "'" do JavaScript theJS in document 1 do JavaScript "document.querySelector('input[type=image]').click()" in document 1 delay 2 do JavaScript "(document.querySelector('td[class="title"]') === null) ? 1 : 0" in document 1 if the result = 1 then tell application "System Events" keystroke "w" using command down end tell error 0 end if do JavaScript "document.querySelector('td[class="title"]').innerHTML.trim().match(/\d+$/)[0].length" in document 1 if the result > 3 then error 0 do JavaScript "document.querySelector('a[title="Vybrat vše"]').click()" in document 1 delay 2 do JavaScript "document.querySelector('a[title="Uložit/odeslat"]').click()" in document 1 delay 2 do JavaScript "document.querySelector('input[value="ALL"]').click()" in document 1 do JavaScript "document.querySelector('input[value="NONE"]').click()" in document 1 do JavaScript "document.querySelector('input[alt="Uložit/odeslat"]').click()" in document 1 delay 2 do JavaScript "document.querySelector('a[href$=".sav"]').click()" in document 1 delay 2 set theURL to URL of document 1 end tell tell application "System Events" tell application "Safari" to activate keystroke "w" using command down tell application "iTerm" to activate keystroke "wget -q " & theURL & " # " & theChar keystroke return end tell end try end repeat return input end run
Má to pár much - když má hledání více než 1000 výsledků, musím ten dotaz rozdělit na menší části. Tedy z "a*" udělám "a*a*", "a*b*", atd.
Tohle jsem tam ale už nenakódoval - v AppleScriptu si nejsem tak jistý. Jen jsem nechal AppleScript daný tab nezavřít a nechal jsem si ho na ruční posouzení později. Potom jsem to rozdělení dělal "ručně" s pomocí makra ve VIMu - udělal jsem si řádek s abcdefghijklmnopqrstuvwxyz0123456789
, vybral ho a zavolal na něm:
:s/\(.\)/"a*1*",/g
Výsledkem je "a*a*","a*b*",...
Nechal jsem to běžet přes noc (s počítačem se totiž během toho nemůže moc věcí dělat, vyžaduje to focus toho Safari a podobně) a výsledkem je 704 souborů s celkovou velikostí okolo 50MB.
Je tam hodně duplicit - ty by se snad mohly řešit nějakým chytřejším pokládáním dotazů (adresář povoluje dělat ORy atd.) - ale už by mi to komplikovalu logiku toho AppleScriptu. Tohle je simple enough.
Krok druhý: nějak to zparsovat do CSVčka.
Pro představu, každý soubor vypadá asi takhle:
Záznamy vyhledané v databázích NK ČR Datum: 26/08/2013 Číslo záznamu: 1 Název nakl. AAA ##spol. s r.o. Adresa Dvojdomí 609/1, 400 01 Ústí nad Labem Adresa Hálkova 4, 120 00 Praha 2 E-mail petr.vrzak@centrum.cz Identifikátor 978-80-85995 Prof. zaměření * Maps Datum aktualizace 20060905 zden Systém. číslo 000008082 Číslo záznamu: 2 Název nakl. Aaada English School for Children Adresa Hloubětínská 26, 198 00 Praha 9 E-mail heck@volny.cz Identifikátor Neregistrován v ISBN Datum aktualizace 20060322 sama Systém. číslo 000014300
Asi by to šlo udělat AWKem, napadl mě i Haskellovský Parsec, ale protože jsem Clojure fanboy, rozhodl jsem se prozkoumat, co nabízí jeho knihovny. A narazil jsem na Instaparse. Jde o to, že mu předáte ve stringu EBNF grammar, Instaparse vytvoří funkci a té prostě už jen předáváte string s těmi daty. Vypadá to asi takhle:
(ns nakladatelstvi.core (:require [instaparse.core :as insta])) (def zaznamy (insta/parser "soubor = header, {zaznam}, <whitespace>, Epsilon header = <whitespace>, 'Záznamy vyhledané v databázích NK ČR', <whitespace>, datum_souboru datum_souboru = <'Datum: '>, #'\d{2}/\d{2}/\d{4}' zaznam = <whitespace>, <cislo_zaznamu>, <whitespace>, nazev, {(<whitespace>, adresa) | (<whitespace>, email) | (<whitespace>, isbn) | <(<whitespace>, odkaz)> | (<whitespace>, ico) | (<whitespace>, url) | (<whitespace>, prof_zamereni) | <(<whitespace>, aktualizace)> | (<whitespace>, system_cislo)}, cislo_zaznamu = <'Číslo záznamu:'>, <whitespace>, number nazev = <'Název nakl.'>, <whitespace>, sentence, [<whitespace>, <'##'>, sentence] adresa = <'Adresa'>, <whitespace>, sentence email = <'E-mail'>, <whitespace>, word isbn = <'Identifikátor'>, <whitespace>, isbnre odkaz = <'Odkaz. forma'>, <whitespace>, sentence ico = <'IČ'>, <whitespace>, number url = <'URL'>, <whitespace>, word prof_zamereni = <'Prof. zaměření'>, <whitespace>, zamereni+ <zamereni> = <' '?>, <'* '>, word aktualizace = <'Datum aktualizace'>, <whitespace>, sentence system_cislo = <'Systém. číslo'>, <whitespace>, number <whitespace> = #'\s+' <sentence> = ('(', sentence) | (')', [[<' '>], sentence]) | (word, [[<' '>], sentence]) <word> = #'[a-zA-Z0-9ĚŠČŘŽÝÁÍÉŮÚĎŤŇÓěščřžýáíéůúďťňó*/\-_=?:.,@#!&]+' <isbnre> = #'(\d+-\d+(-\d+)?)|Neregistrován v ISBN' <number> = #'[0-9]+'")) (def test-soubor (slurp "adr10124698.sav")) (def parsed (zaznamy test-soubor))
No a ono to vyhodí něco jako Hiccupovský zápis XMLka (který převedeme na XML samotným Hiccupem):
[:soubor [:header "Záznamy vyhledané v databázích NK ČR" [:datum_souboru "26/08/2013"]] [:zaznam [:nazev "AAA" "spol." "s" "r.o."] [:adresa "Dvojdomí" "609/1," "400" "01" "Ústí" "nad" "Labem"] [:adresa "Hálkova" "4," "120" "00" "Praha" "2"] [:email "petr.vrzak@centrum.cz"] [:isbn "978-80-85995"] [:prof_zamereni "Maps"] [:system_cislo "000008082"]] [:zaznam [:nazev "Aaada" "English" "School" "for" "Children"] [:adresa "Hloubětínská" "26," "198" "00" "Praha" "9"] [:email "heck@volny.cz"] [:isbn "Neregistrován v ISBN"] [:system_cislo "000014300"]]]
V reále to je ještě trochu složitější - musíme escapovat ampersandy, apostrofy a podobně, aby nás XML mělo rádo, přidat umlauty do podporovaných znaků a podobně - ale nakonec se přece jen povede a všechny soubory jsou přečteny. Potom se už jen sloučí jedním reduce a vyplivne se XML.
Mimochodem, je úžasné, jak jednoduše Clojure umožňuje využít více jader procesoru. Můj kód chroustal hlavně dvě věci - první bylo samozřejmě vyparsovat z toho souboru data do té hierarchické struktury - a druhý bylo "zálohování" na disk. Prvně jsem se díval, že mě to, že jedou sekvenčně po sobě, víceméně brzdí. Tak jsem to zálohování obalil do future, a bylo. Naráz oba ty úkoly jely zároveň. Žádné locky, žádné mutexy, žádné semafory ... Jedna future. Úžasné!
No, ale to XML. Do CSVčka se dá docela jednoduše převést pomocí XSLT:
<?xml version="1.0"?> <xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform"> <xsl:template match="/"> <xsl:for-each select="//zaznam"> <xsl:text> </xsl:text> <xsl:value-of select="system_cislo"/> <xsl:text> </xsl:text> <xsl:value-of select="nazev"/> <xsl:text> </xsl:text> <xsl:value-of select="email"/> <xsl:text> </xsl:text> <xsl:value-of select="adresa"/> <xsl:text> </xsl:text> <xsl:value-of select="isbn"/> <xsl:text> </xsl:text> <xsl:value-of select="prof_zamereni"/> <xsl:text> </xsl:text> <xsl:value-of select="ico"/> <xsl:text> </xsl:text> <xsl:value-of select="url"/> </xsl:for-each> </xsl:template> </xsl:stylesheet>
(Pozn.: asi by šlo použít i clojure.data.csv nebo tak něco, ale když už to Instaparse vyhazuje v Hiccup formátu a Hiccup exportuje do XML... :) )
Krok třetí: vyhodit duplicity.
Tohle je nejjednodušší: Otevřu CSV v Excelu a vyfiltruju to podle sloupce Systémové číslo.