Scraping fácil com Mojolicious (e feeds Atom!)

José Eduardo Perotta de Almeida
Publicado em 01/03/2011

Scraping fácil com Mojolicious (e feeds Atom!)

A suíte Mojolicious de módulos para web pode ser mais conhecida por permitir a criação rápida e fácil de sites dinâmicos, mas oferece muitas outras facilidades, em particular para varredura e coleta de dados de outros sites - ou Web Scraping.

Neste artigo, vamos mostrar como é fácil escrever um scraper em Perl sabendo apenas seletores CSS. Para completar, vamos adicionar um pequeno desafio - a criação de feeds Atom - e mostrar como o CPAN nos ajuda a desenvolver software como se fossem peças de Lego esperando para serem conectadas!

O Cenário

Digamos, por exemplo, que queremos obter uma lista de todos os artigos publicados no site da São Paulo Perl Mongers.

A primeira coisa a fazer é entender a estrutura do documento HTML em que as informações estão. Para isso, abrimos a página que lista os artigos e olhamos o código fonte. Abaixo, um trecho do que foi encontrado:

   

Artigos

Ótimo, a página parece ter toda a informação que precisamos. Hora de automatizar!

Obtendo a página

Fazer um programa que acessa a página em questão (http://sao-paulo.pm.org/artigos) é muito simples:

   use strict;
   use warnings;
   use Mojo::UserAgent;

   my $client = Mojo::UserAgent->new->get( 'http://sao-paulo.pm.org/artigos' );




Pronto. Mojo::UserAgent é um cliente HTTP 1.1 e WebSocket completo, com E/S assíncrona e suporte transparente a TLS, epoll e kqueue. Sua API é bastante simples e direta, como pudemos observar, e a partir desse ponto em nosso código o objeto $client já carregou o site e estamos prontos para acessar seu conteúdo.

O "DOM" de ler o conteúdo de sites

Após uma requisição web, o que nos interessa é a resposta obtida, ou, mais especificamente, o "DOM" da página recebida como resposta. O Modelo de Objetos de Documentos - ou DOM, Document Object Model - é uma especificação do W3C que possibilita acesso e atualização dinâmicas do conteúdo, estrutura e estilo de documentos. É o que o seu navegador constrói e interpreta para exibir o conteúdo de sites, e que vai nos ajudar imensamente na obtenção dos dados desejados.

Se você não está familiarizado com o conceito, imagine um documento HTML como uma grande árvore hierárquica de tags, agrupadas e tratadas mais ou menos como a árvore de diretórios do seu sistema de arquivos. Veja novamente o trecho da página que queremos analisar:

   

Artigos

No código acima, o primeiro <div> possui a classe "top" como atributo e podemos ver dois filhos: o <h2>, sem atributos, com o texto "Artigos" e nenhum filho; e o outro <div>, com a classe "whois" como atributo e vários filhos. Desses filhos, vemos dois <h3> e dois <ul>, e se você abrir o código completo da página verá muitos outros. Tags no mesmo nível são irmãs entre si, mesmo tendo tipos diferentes. Podemos seguir a estrutura adiante, ver que cada <ul> tem vários filhos do tipo <li>, que por sua vez contém tags <a> com o link e título dos artigos, e tags <span> com o nome dos autores entre colchetes.

Acessando o DOM

Sabendo a nomenclatura, podemos solicitar o DOM da resposta obtida por nosso cliente Web e acessar cada um desses elementos facilmente através be buscas por seletores CSS!

  my $dom = $client->res->dom;

A chamada acima nos retorna um objeto Mojo::DOM, que oferece o conteúdo do site já devidamente processado como uma árvore DOM XML/HTML5 minimalista e bastante relaxada, ou seja, funcional mesmo que o site em questão não tenha um html em conformidade com os padrões do W3C - o que, infelizmente, parece praxe nos dias de hoje.

O Mojo::DOM nos fornece dois métodos principais para obtenção de elementos de uma página:

$dom->at( 'seletor' )

Retorna objeto representando o primeiro elemento que casa com o seletor especificado.

$dom->find( 'seletor' )

Retorna uma lista de objetos representando todos os elementos que casam com o seletor especificado.

Portanto, para obter a lista de elementos representando artigos publicados, podemos fazer algo como:

   my $artigos = $dom->find( 'div[class="whois"] > ul > li' );

Ou seja, queremos todos os elementos "li" filhos de tags "ul" que sejam por sua vez filhas de tags "div" com a classe "whois". Confuso? Experimente ler da esquerda para a direita então: queremos ir de tags "div" com a classe "whois" para os "ul" filhos dela, e destes para os "li". Melhor?

Note que assim o DOM vai pegar todos os "ul" disponíveis no documento, ou seja, todos os artigos, independente do ano - exatamente como queremos!

Seletores CSS?

Seletores como o acima são utilizados em CSS para associar um estilo de formatação a uma determinada tag - ou conjunto de tags - HTML. É uma notação simples e ao mesmo tempo muito versátil, que há alguns anos vem sendo usada com sucesso por bibliotecas javascript como o jQuery, especialmente porque podemos combinar seletores da forma que acharmos mais adequada para acessarmos o(s) elemento(s) desejado(s).

Essa naturalmente não é a única forma de se chegar aos elementos em questão, mas é a que vamos usar aqui. Sinta-se à vontade para experimentar outros seletores/Seletores Simples :)

Para quem ainda não está acostumado com a sintaxe de seletores, colocamos um guia de referência rápida com os principais seletores CSS/Guia de Referência Rápida: Seletores CSS.

Extraindo as informações

Pronto para pegar o que nos interessa? Vamos lá! Primeiro, lembre-se que colocamos na variável $artigos todos os elementos <li> e o que eles contém. Assim, podemos utilizar os métodos auxiliares do Mojo::DOM para obter, de cada um deles, o título, a url e o autor:

    foreach my $artigo (@$artigos) {
        my $titulo = $artigo->at('a')->text;
        my $url    = $artigo->at('a')->attrs->{'href'};
        my $autor  = $artigo->at('span')->text;

        # vamos retirar os "[" e "]" dos nomes dos autores,
        # e aproveitar para eliminar espaços desnecessários.
        $autor =~ s/\s*[\[\]]//g;

        # agora vamos exibir o que encontramos
        print "$titulo ($url) '$autor'\n";
    }




A primeira linha do laço acima diz para colocarmos em $titulo o texto que estiver dentro da tag <a>. Em outras palavras, se temos algo como:

   isso é um texto

a chamada at('a')->text retornará "isso é um texto".

Para obter a URL, precisamos do attributo "href" desta tag, portanto escrevemos at('a')->attrs->{'href'}.

Finalmente, como o autor está envolvido em uma tag <span>, utilizamos ->text exatamente como fizemos para o título.

O código completo de nosso crawler fica:

   use strict;
   use warnings;
   use Mojo::UserAgent;

   my $client = Mojo::UserAgent->new->get( 'http://sao-paulo.pm.org/artigos' );
   my $dom = $client->res->dom;
   my $artigos = $dom->find( 'div[class="whois"] > ul > li' );




   foreach my $artigo (@$artigos) {
       my $titulo = $artigo->at('a')->text;
       my $url = $artigo->at('a')->attrs->{'href'};
       my $autor = $artigo->at('span')->text;

       # vamos retirar os "[" e "]" dos nomes dos autores,
       # e aproveitar para eliminar espaços desnecessários.
       $autor =~ s/\s*[\[\]]//g;

       # agora vamos exibir o que encontramos
       print "$titulo ($url) '$autor'\n";
   }




Simples, não?

Pulando etapas

Você deve ter reparado que fazemos algumas chamadas encadeadas, como ->new->get e ->res->dom. De fato, Mojo::UserAgent e Mojo::DOM retornam sempre o próprio objeto, de modo que podemos encadear todas as chamadas! Mais ainda, o método find() possui seus próprios iteradores para que não precisemos fazer o foreach nós mesmos. Assim, o mesmo código acima poderia ser escrito da seguinte forma:

   use strict;
   use warnings;
   use Mojo::UserAgent;

   Mojo::UserAgent->new->get( 'http://sao-paulo.pm.org/artigos' )
               ->res->dom->find( 'div[class="whois"] > ul > li' )
               ->each( sub {
                    my $artigo = shift;

                    my $titulo = $artigo->at('a')->text;
                    my $url = $artigo->at('a')->attrs->{'href'};
                    my $autor = $artigo->at('span')->text;

                    # vamos retirar os "[" e "]" dos nomes dos autores,
                    # e aproveitar para eliminar espaços desnecessários.
                    $autor =~ s/\s*[\[\]]//g;

                    # agora vamos exibir o que encontramos
                    print "$titulo ($url) '$autor'\n";
               });




Existe sempre mais de uma maneira de se fazer as coisas :)

O Desafio: Transformando os dados em um Feed RSS/Atom

Quando falamos de OpenData, pensamos em dados facilmente disponíveis e em formato livre. Suponha que você tenha feito o scraping deste site justamente porque reparou que ele não oferece um feed listando os artigos disponíveis. Temos que resolver este problema!

Nosso web crawler já está pronto, mas estamos apenas imprimindo o conteúdo na tela. O desafio é portanto transformar os dados obtidos em um feed Atom.

E agora? Novamente o CPAN vem ao nosso auxílio, com o XML::Atom::SimpleFeed.

O processo é bem simples: Primeiro, criamos nosso objeto de feed no início do programa. Depois, para cada artigo encontrado, adicionamos uma nova entrada. Ao terminarmos, imprimimos todo o feed.

Vale notar que feeds Atom precisam de um identificador único, que não pode mudar. Para criar um no formato aceito pelo padrão, utilizamos um UUID gerado pelo módulo Data::UUID a partir de um namespace ('sao-paulo.pm.org') e um nome ('artigo').

Veja como precisamos apenas colocar os trechos relacionados ao feed, sem modificar em nada o crawler. Colocamos um "+" no início de cada linha nova para que as modificações fiquem mais visíveis:

    use strict;
    use warnings;
    use Mojo::UserAgent;
  + use XML::Atom::SimpleFeed;
  + use Data::UUID;

  + # criamos nosso feed Atom
  + my $feed = XML::Atom::SimpleFeed->new(
  +     title => 'Artigos Publicados na SPPM',
  +     id    => 'urn:uuid:' . Data::UUID->new->create_from_name_str('sao-paulo.pm.org', 'artigos'),
  + );

    # crawling pelo site da SPPM
    Mojo::UserAgent->new->get( 'http://sao-paulo.pm.org/artigos' )->res
                ->dom->find( 'div[class="whois"] > ul > li' )
                ->each( sub {
                     my $artigo = shift;
                     my $titulo = $artigo->at('a')->text;
                     my $url = $artigo->at('a')->attrs->{'href'};
                     my $autor = $artigo->at('span')->text;

                     # vamos retirar os "[" e "]" dos nomes dos autores,
                     # e aproveitar para eliminar espaços desnecessários.
                     $autor =~ s/\s*[\[\]]//g;

  +                  # agora vamos adicionar o que encontramos
  +                  $feed->add_entry(
  +                      'author' => $autor,
  +                      'title'  => $titulo,
  +                      'link'   => $url,
  +                  );
                });

    # agora que temos os artigos no feed,
    # podemos imprimir
  + $feed->print;




Conclusão

Criamos um webcrawler que extrai artigos de um site e gera um feed Atom completo com direito a UUID, tudo isso em apenas 36 linhas - incluindo linhas em branco, comentários e formatação!

Imagine agora o que você também pode fazer :)

Guia de Referência Rápida: Seletores CSS

Seletores simples

*

Qualquer elemento.

  '*'

E

Um elemento qualquer do tipo E especificado - title, a, head, div, span, tr, ...

  'td'

E[foo]

Um elemento E qualquer com um atributo foo qualquer. Por exemplo, "a[alt]" retorna os elementos "<a>" que possuem o atributo "alt".

  'a[alt]'

E[foo="bar"]

Um elemento E qualquer com um atributo foo possuíndo valor exatamente igual abar.

  'input[class="obrigatorio"]'

E[foo~="bar"]

Um elemento E cujo atributo foo é uma lista de valores separados por espaço, e um desses valores é exatamente igual a bar.

  'input[class~="obrigatorio"]'

E[foo^="bar"]

Um elemento E cujo atributo foo começa exatamente com a string bar.

  'input[name^="obrig"]'

E[foo$="bar"]

Um elemento E cujo atributo foo termina exatamente com a string bar.

  'input[name$="orio"]'

E[foo*="bar"]

Um elemento E cujo atributo foo contém a substring bar.

  'input[name*="gato"]'

E[foo=bar][bar=baz]

Elemento E cujos atributos casam com o que for especificado - seguindo as mesmas regras de busca por atributo definidas acima.

  'a[foo^="obrig"][foo$="ado"]'

E F

Um elemento F descendente de um elemento E.

  'div h1'

E > F

Um elemento F filho de um elemento E.

  'html > body > div > h1'

E + F

Um elemento F precedido imediatamente por um elemento E.

  'h1 + h2'

E ~ F

Um elemento F precedido (em algum momento) por um elemento E.

  'h1 ~ h2'

E, F, G

Elementos do tipo E, F e G.

  'h1, h2, h3'




Seletores Avançados

E:root

O elemento E raíz do documento. Em HTML4, é sempre o elemento <html>.

  ':root'

E:checked

Um elemento E marcado (ou ativado) no momento, como um radio-button ou uma checkbox.

  ':checked'
  'input:checked'

E:empty

Um elemento E que não possui texto nem subtags ("filhos").

  ':empty'
  'span:empty'

E:nth-child(n)

Um elemento E, n-ésimo filho de sua tag "pai".

  'div:nth-child(3)'     # terceiro
  'div:nth-child(odd)'   # impares
  'div:nth-child(even)'  # pares
  'div:nth-child(-n+3)'  # 3 primeiros

E:nth-last-child(n)

Um elemento E, n-ésimo filho de sua tag "pai", contando de trás para frente.

  'div:nth-last-child(3)'     # terceiro
  'div:nth-last-child(odd)'   # impares
  'div:nth-last-child(even)'  # pares
  'div:nth-last-child(-n+3)'  # 3 últimos

E:nth-of-type(n)

Um elemento E, o n-ésimo elemento ("irmão") do mesmo tipo.

  'div:nth-of-type(3)'     # terceiro
  'div:nth-of-type(odd)'   # impares
  'div:nth-of-type(even)'  # pares
  'div:nth-of-type(-n+3)'  # 3 primeiros

E:nth-last-of-type(n)

Um elemento E, o n-ésimo de seu mesmo tipo, contanto de trás para frente.

  'div:nth-last-of-type(3)'     # terceiro
  'div:nth-last-of-type(odd)'   # ímpares
  'div:nth-last-of-type(even)'  # pares
  'div:nth-last-of-type(-n+3)'  # 3 últimos

E:first-child

Um elemento E, primeiro filho de seu pai.

  'div p:first-child'

E:last-child

Um elemento E, último filho de seu pai.

  'div p:last-child'

E:first-of-type

Um elemento E, primeiro irmão de seu tipo.

  'div p:first-of-type'

E:last-of-type

Um elemento E, último irmão de seu tipo.

  my $last = $dom->at('div p:last-of-type');

E:only-child

Um elemento E, filho único de seu pai.

  'div p:only-child'

E:only-of-type

Um elemento E, único irmão de seu tipo.

  'div p:only-of-type'

E:not(s)

Um elemento E que não casa com o seletor s.

  'div p:not(:first-child)'

author

Eduardo Perotta de Almeida

blog comments powered by Disqus