Chcete číst

Web scraping

aneb když to nejde po dobrém ...

(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.

Done!

Napište komentář