Web Scraping com Scrapy – primeiros passos

Imagine que você queira extrair conteúdo da Web que não esteja em apenas uma página só: você precisa de uma maneira de “navegar” no site para as páginas que realmente contém as informações úteis. Por exemplo, você pode estar interessado nas notícias destaques do dia no Portal Brasil.gov.br, mas somente aquelas das seções “Infraestrutura” e “Ciência e Tecnologia”.

webpage-brasil-links

Bem, há uns tempos atrás, já mostramos aqui no blog como usar a biblioteca requests para acessar páginas disponíveis na Web usando nossa linguagem predileta. Também mostramos como usar a biblioteca BeautifulSoup para facilitar a extração do conteúdo útil da página, o que chamamos de Web Scraping. Hoje, vamos mostrar como usar o framework Scrapy, que contém todas essas funcionalidades e muitas outras mais, de maneira que agiliza bastante resolver problemas como esse da introdução. Tudo em Python, lógico! =)

Vale notar então, que o Scrapy busca resolver não só a extração de conteúdo das páginas (scraping), mas também a navegação para as páginas relevantes para a extração (crawling). Para isso, uma ideia central no framework é o conceito de Spider — na prática, objetos Python com algumas características especiais que você escreve o código e o framework aciona.

Só para você ter uma ideia de como se parece, dê uma olhada no código de um programa que usa Scrapy para extrair informações (link, título e visualizações) de um canal do YouTube abaixo. Não se preocupe em entender esse código ainda, estamos mostrando aqui só para você ter um feeling do código com Scrapy. Ao terminar esse tutorial, você será capaz de entender e escrever programas como esse. =)

import scrapy
from scrapy.contrib.loader import ItemLoader

class YoutubeVideo(scrapy.Item):
    link = scrapy.Field()
    title = scrapy.Field()
    views = scrapy.Field()

class YoutubeChannelLister(scrapy.Spider):
    name = 'youtube-channel-lister'
    youtube_channel = 'LongboardUK'
    start_urls = ['https://www.youtube.com/user/%s/videos' % youtube_channel]

    def parse(self, response):
        for sel in response.css("ul#channels-browse-content-grid > li"):
            loader = ItemLoader(YoutubeVideo(), selector=sel)

            loader.add_xpath('link', './/h3/a/@href')
            loader.add_xpath('title', './/h3/a/text()')
            loader.add_xpath('views', ".//ul/li[1]/text()")

            yield loader.load_item()

Mas antes de começarmos a falar mais sobre o Scrapy, certifique-se de tê-lo instalado em sua última versão (dependendo do caso, você pode precisar usar o comando sudo ou a opção –user para o pip install):

pip install --upgrade scrapy

Nota: dependendo do seu ambiente Python, a instalação pode ser um pouco enrolada por causa da dependência do Twisted. Se você usa Windows, confira as instruções específicas no guia de instalação oficial. Se você usa uma distribuição Linux baseada em Debian, pode querer usar o repositório APT oficial do Scrapy. Se você está usando o pip no Ubuntu, pode precisar instalar os pacotes libffi-dev, libssl-dev, libxml2-dev e libxslt1-dev antes.

Para seguir este tutorial, você precisará do Scrapy com número de versão 0.24 para cima. Você pode verificar a versão do Scrapy instalada com o comando:

python -c 'import scrapy; print('%s.%s.%s' % scrapy.version_info)'

A saída desse comando no ambiente que usamos para esse tutorial está assim:

$ python -c 'import scrapy; print("%s.%s.%s" % scrapy.version_info)'
0.24.2

A anatomia de uma aranha

spider_anatomy

Um Scrapy spider é responsável por definir como seguir os links “navegando” por um site (o que chamamos de crawling) e como extrair as informações das páginas em estruturas de dados Python. 

Para definir um spider mínimo, crie uma classe estendendo scrapy.Spider e dê um nome ao spider usando o atributo name:

import scrapy

class MinimalSpider(scrapy.Spider):
    """A menor Scrapy-Aranha do mundo!"""
    name = 'minimal'

Coloque isso em um arquivo com o nome minimal.py e rode o seu spider para conferir se está tudo certo, usando o seguinte comando:

scrapy runspider minimal.py

Caso estiver tudo certo, você verá na tela algumas mensagens do log marcadas como INFO e DEBUG. Caso houver alguma mensagem marcada com ERROR, significa que deu algo errado e você precisa conferir se tem algum erro no código do spider.

A vida de um spider começa com a geração de requisições HTTP (objetos do tipo Request) para o motor do framework acionar. A parte do spider responsável por isso é o método start_requests(), que retorna um iterable contendo as primeiras requisições a serem feitas para o spider.

Adicionando esse elemento ao nosso spider mínimo, ficamos com:

import scrapy

class MinimalSpider(scrapy.Spider):
    """A menor Scrapy-Aranha do mundo!"""
    name = 'minimal'

    def start_requests(self):
        return [scrapy.Request(url)
                for url in ['http://www.google.com', 'http://www.yahoo.com']]

O método start_requests() deve retornar um iterable de objetos scrapy.Request, que representam uma requisição HTTP a ser acionada pelo framework (incluindo URL, parâmetros, cookies, etc) e definem uma função a ser chamada para quando a requisição completar — uma função callback. 

Nota: Caso esteja familiarizado com implementar AJAX com JavaScript, essa maneira de trabalhar disparando requisições e registrando callbacks pode soar familiar.

No nosso exemplo, retornamos uma lista de requisições simples para o site do Google e do Yahoo, mas o método start_requests() também poderia ser implementado como um Python generator.

Se você tentou executar o exemplo como está agora, pode ter notado que ainda está faltando coisa, o Scrapy irá cuspir duas mensagens marcadas como ERROR, reclamando que um método não foi implementado:

....
  File "/home/elias/.virtualenvs/scrapy/local/lib/python2.7/site-packages/scrapy/spider.py", line 56, in parse
    raise NotImplementedError
exceptions.NotImplementedError:

Isso ocorre porque, como não registramos nenhuma função callback para os objetos Request, o Scrapy tentou chamar o callback padrão, que é o método parse() do objeto Spider. Vamos adicionar esse método ao nosso spider mínimo, para podemos executar o spider:

import scrapy

class MinimalSpider(scrapy.Spider):
    """A menor Scrapy-Aranha do mundo!"""
    name = 'minimal'

    def start_requests(self):
        return (scrapy.Request(url)
                for url in ['http://www.google.com', 'http://www.yahoo.com'])

    def parse(self, response):
        self.log('ACESSANDO URL: %s' % response.url)

Se você executar novamente agora o spider com o comando: scrapy runspider minimal.py deverá observar na saída algo semelhante a:

2014-07-26 15:39:56-0300 [minimal] DEBUG: Crawled (200) <GET http://www.google.com.br/?gfe_rd=cr&amp;ei=_PXTU8f6N4mc8Aas1YDABA> (referer: None)
2014-07-26 15:39:56-0300 [minimal] DEBUG: ACESSANDO URL: http://www.google.com.br/?gfe_rd=cr&amp;ei=_PXTU8f6N4mc8Aas1YDABA
2014-07-26 15:39:57-0300 [minimal] DEBUG: Redirecting (302) to <GET https://br.yahoo.com/?p=us> from <GET https://www.yahoo.com/>
2014-07-26 15:39:58-0300 [minimal] DEBUG: Crawled (200) <GET https://br.yahoo.com/?p=us> (referer: None)
2014-07-26 15:39:58-0300 [minimal] DEBUG: ACESSANDO URL: https://br.yahoo.com/?p=us

Para deixar o código do nosso spider ainda mais enxuto, podemos nos aproveitar do funcionamento padrão do método start_requests(): caso você não o defina, o Scrapy irá criar requisições para uma lista de URLs num atributo com o nome start_urls — exatamente o que estamos fazendo. Portanto, podemos reduzir o código acima e manter o mesmo funcionamento, usando:

import scrapy

class MinimalSpider(scrapy.Spider):
    """A menor Scrapy-Aranha do mundo!"""
    name = 'minimal'
    start_urls = [
        'http://www.google.com',
        'http://www.yahoo.com',
    ]

    def parse(self, response):
        self.log('ACESSANDO URL: %s' % response.url)

Como no método parse() mostrado acima, todos os callbacks recebem o conteúdo da resposta da requisição HTTP como argumento (em um objeto Response). É dentro do callback, onde já temos o conteúdo das páginas que nos interessam, que fazemos a extração das informações, ou seja, o data scraping propriamente dito.

excited-scrapy

Callbacks, Requests & Items

As funções registradas como callback associadas às requisições podem retornar um iterable de objetos, em que cada objeto pode ser:

  • um objeto de uma classe scrapy.Item que você define para conter os dados coletados da página
  • um objeto da classe scrapy.Request representando ainda outra requisição a ser acionada (possivelmente registrando outro callback)

Com esse esquema de requisições e callbacks que podem gerar novas requisições (com novos callbacks), você pode programar a navegação por um site gerando requisições para os links a serem seguidos, até chegar nas páginas com os itens que nos interessam. Por exemplo, para um spider que precise extrair produtos de um site de compras navegando em páginas por categorias, você poderia usar uma estrutura como a seguinte:

import scrapy

class SkeletonSpider(scrapy.Spider):
    name = 'spider-mummy'
    start_urls = ['http://www.someonlinewebstore.com']

    def parse(self, response):
        for c in [...]:
            url_category = ...
            yield scrapy.Request(url_category, self.parse_category_page)

    def parse_category_page(self, response):
        for p in [...]:
            url_product = ...
            yield scrapy.Request(url_product, self.parse_product)

    def parse_product(self, response):
        ...

Na estrutura acima, o callback padrão — método parse() — trata a resposta da primeira requisição ao site da loja e gera novas requisições para as páginas das categorias, registrando outro callback para tratá-las — o método parse_category_page(). Este último faz algo parecido, gerando as requisições para as páginas dos produtos, desta vez registrando um callback que extrai os objetos itens com os dados do produto.

Por que eu preciso definir classes para os itens?

O Scrapy propõe que você crie algumas classes que representem os itens que você pretende extrair das páginas. Por exemplo, se você deseja extrair os preços e detalhes de produtos de uma loja virtual, poderia representar uma classe como a seguinte:

import scrapy

class Produto(scrapy.Item):
    descricao = scrapy.Field()
    preco = scrapy.Field()
    marca = scrapy.Field()
    categoria = scrapy.Field()

Como pode ver, são simples subclasses de scrapy.Item, em que você adiciona os campos desejados (objetos da classe scrapy.Field). Você pode usar uma instância dessa classe como se fosse um dicionário Python:

>>> p = Produto()
>>> p['preco'] = 13
>>> print p
{'preco': 13}

A maior diferença para um dicionário tradicional é que um Item, por padrão, não permite você atribuir um valor para uma chave que não foi declarada como campo:

>>> p['botemo'] = 54
...
KeyError: 'Produto does not support field: botemo'

A vantagem de definir classes para os itens é que isso permite você aproveitar outros recursos do framework que funcionam para essas classes. Por exemplo, o recurso de exportação de dados possibilita escolher entre exportar os itens coletados para JSON, CSV, XML, etc. Ou ainda, o esquema de pipeline de itens, que permite você plugar outros processamentos em cima dos itens coletados (coisas tipo, validar o conteúdo, remover itens duplicados, armazenar no banco de dados, etc).

Let’s do some scraping!

Para fazer o scraping propriamente dito, isto é, a extração dos dados da página, é legal você conhecer XPath, uma linguagem feita para fazer consultas em conteúdo XML — base do mecanismo de seletores do framework. Caso não conheça XPath você pode usar seletores CSS no Scrapy, mas encorajamos você a conhecer XPath mais de perto, pois ela permite expressões mais poderosas do que CSS (de fato, as funções de CSS no Scrapy funcionam convertendo os seletores CSS para expressões com XPath).

Você pode testar o resultado de expressões XPath ou CSS para uma página usando o Scrapy shell. Rode o comando:

scrapy shell http://pt.stackoverflow.com

Esse comando dispara uma requisição para a URL informada e abre um shell Python (ou IPython, caso o tenha instalado) disponibilizando alguns objetos para você explorar. O objeto mais importante é o response, que contém a resposta da requisição HTTP e equivale ao argumento response recebido pelas funções de callback.

dog-excited-tem_ate_um_shell

>>> response.url
'http://pt.stackoverflow.com'
>>> response.headers
{'Cache-Control': 'public, no-cache="Set-Cookie", max-age=60',
 'Content-Type': 'text/html; charset=utf-8',
 'Date': 'Fri, 01 Aug 2014 02:27:12 GMT',
 'Expires': 'Fri, 01 Aug 2014 02:28:12 GMT',
 'Last-Modified': 'Fri, 01 Aug 2014 02:27:12 GMT',
 'Set-Cookie': 'prov=cf983b7c-a352-4713-9aa8-6deb6e262b01; domain=.stackoverflow.com; expires=Fri, 01-Jan-2055 00:00:00 GMT; path=/; HttpOnly',
 'Vary': '*',
 'X-Frame-Options': 'SAMEORIGIN'}

Você pode usar os métodos xpath() e css() do objeto response para executar uma busca no conteúdo HTML da resposta:

>>> response.xpath(&amp;amp;amp;quot;//title&amp;amp;amp;quot;) # obtem o elemento &amp;amp;amp;lt;title&amp;amp;amp;gt; usando XPath
[<Selector xpath='//title' data=u'<title>Stack Overflow em Portugu\xeas</titl'>]
>>> response.css('title') # obtem o elemento <title> com seletor CSS
[<Selector xpath=u'descendant-or-self::title' data=u'<title>Stack Overflow em Portugu\xeas</titl'>]
>>> len(response.css('div')) # conta numero de elementos <div>
252

O resultado de chamar um desses métodos é um objeto lista que contém os objetos seletores resultantes da busca e possui um método extract() que extrai o conteúdo HTML desses seletores. Os objetos seletores contidos nessa lista, por sua vez, além de possuírem o método extract() para extrair o conteúdo dele, também possuem métodos xpath() e css() que você pode usar fazer uma nova busca no escopo de cada seletor.

Veja os exemplos abaixo ainda no mesmo Scrapy shell, que ajudam a esclarecer as coisas.

Extrai conteúdo HTML do elemento , acionando método extract() da lista de seletores (repare como o resultado é uma lista Python):

>>> response.xpath("//title").extract()
[u'<title>Stack Overflow em Portugu\xeas</title>']

Guarda o primeiro seletor do resultado numa variável, e aciona o método extract() do seletor (veja como agora o resultado é uma string):

>>> title_sel = response.xpath('//title')[0]
>>> title_sel.extract()
u'<title>Stack Overflow em Portugu\xeas</title>'

Aplica a expressão XPath text() para obter o conteúdo texto do seletor, e usa o método extract() da lista resultante:

>>> title_sel.xpath('text()').extract()
[u'Stack Overflow em Portugu\xeas']

Imprime a extração do primeiro seletor resultante da expressão XPath text() aplicada no seletor da variável title_sel:

>>> print title_sel.xpath('text()')[0].extract()
Stack Overflow em Português

Bem, dominando essa maneira de trabalhar com seletores, a maneira simples de extrair um item é simplesmente instanciar a classe Item desejada e preencher os valores obtidos usando essa API de seletores.

Veja abaixo o código de um spider usando essa técnica para obter as perguntas mais frequentes do StackOverflow brazuca:

import scrapy
import urlparse

class Question(scrapy.Item):
    link = scrapy.Field()
    title = scrapy.Field()
    excerpt = scrapy.Field()
    tags = scrapy.Field()

class StackoverflowTopQuestionsSpider(scrapy.Spider):
    name = 'so-top-questions'

    def __init__(self, tag=None):
        questions_url = 'http://pt.stackoverflow.com/questions'
        if tag:
            questions_url += '/tagged/%s' % tag

        self.start_urls = [questions_url + '?sort=frequent']

    def parse(self, response):
        build_full_url = lambda link: urlparse.urljoin(response.url, link)

        for qsel in response.css("#questions > div"):
            it = Question()

            it['link'] = build_full_url(
                qsel.css('.summary h3 > a').xpath('@href')[0].extract())
            it['title'] = qsel.css('.summary h3 &amp;amp;amp;gt; a::text')[0].extract()
            it['tags'] = qsel.css('a.post-tag::text').extract()
            it['excerpt'] = qsel.css('div.excerpt::text')[0].extract()

            yield it

Como você pode ver, o spider declara uma classe Item com o nome Question, e usa a API de seletores CSS e XPath para iterar sobre os elementos HTML das perguntas (obtidos com o seletor CSS #questions > div), gerando um objeto Question para cada com os campos preenchidos (link, título, tags e trecho da pergunta).

Duas coisas são interessantes que você note na extração feita no callback parse(): a primeira é que usamos um pseudo-seletor CSS ::text para obter o conteúdo texto dos elementos, evitando as tags HTML. A segunda é como usamos a função urlparse.urljoin() combinando a URL da requisição com conteúdo do atributo href para ter certeza que o resultado seja uma URL absoluta.

Coloque esse código em um arquivo com o nome top_asked_so_questions.py e execute-o usando o comando:

scrapy runspider top_asked_so_questions.py -t json -o perguntas.json

Se tudo deu certo, o Scrapy vai mostrar na tela os itens que extraiu e também escrever um arquivo perguntas.json contendo os mesmos itens. No fim da saída, devem aparecer algumas estatísticas da execução, incluindo a contagem dos itens extraídos:

2014-08-02 14:27:37-0300 [so-top-questions] INFO: Dumping Scrapy stats:
    {'downloader/request_bytes': 242,
     'downloader/request_count': 1,
     ...
     'item_scraped_count': 50,
     'log_count/DEBUG': 53,
     'log_count/INFO': 8,
     ...
     'start_time': datetime.datetime(2014, 8, 2, 17, 27, 36, 912002)}
2014-08-02 14:27:37-0300 [so-top-questions] INFO: Spider closed (finished)

question_block_little_dudes-are_belong_to_us

Argumentos aracnídeos

Talvez você notou que a classe do spider tem um construtor aceitando um argumento tag opcional. Podemos passar esse argumento para o spider para obter as perguntas frequentes com a tag python, usando a opção -a:

scrapy runspider top_asked_so_questions.py -t json -o perguntas.json -a tag=python

Usando esse truque você pode fazer spiders mais genéricos, que você passe alguns parâmetros e obtém um resultado diferente. Por exemplo, você poderia fazer um spider para sites que possuam a mesma estrutura HTML, parametrizando a URL do site. Ou ainda, um spider para um blog em que os parâmetros definam um período desejado para extrair posts e comentários.

Juntando tudo

Nas seções anteriores, você viu como fazer crawling com o Scrapy, navegando entre as páginas de um site usando o mecanismo de “navegação” criando requisições com funções callback. Viu também como usar a API de seletores para extrair o conteúdo da página em itens e executar o spider usando o comando scrapy runspider.

Agora, vamos juntar tudo isso em um spider que resolve o problema que apresentamos na introdução: vamos fazer scraping das notícias destaques do Portal Brasil, oferecendo uma opção para informar o assunto (Infraestrutura, Educação, Esporte, etc). Dessa forma, se apenas executar o spider, ele deve fazer scraping das notícias destaques na página inicial; caso informe um assunto, ele deve fazer scraping dos destaques da página daquele assunto.

Nota: Antes de começar a escrever um spider, é útil explorar um pouco as páginas do site usando o navegador e a ferramenta scrapy shell, assim você pode ver como o site é organizado e testar alguns seletores CSS ou XPath no shell. Existem também extensões para os browsers que permitem você testar expressões XPath em uma página: XPath Helper para o Chrome e XPath Checker para o Firefox. Descobrir a melhor maneira de extrair o conteúdo de um site com XPath ou CSS é mais uma arte do que uma ciência, e por isso não tentaremos explicar aqui, mas vale dizer que você aprende bastante com a experiência.

Veja como fica o código do spider:

import scrapy
import urlparse

class Noticia(scrapy.Item):
    titulo = scrapy.Field()
    conteudo = scrapy.Field()
    link = scrapy.Field()
    data_publicacao = scrapy.Field()

class PortalBrasilDestaques(scrapy.Spider):
    name = 'portal-brasil'

    def __init__(self, assunto=None):
        main_url = 'http://www.brasil.gov.br'
        if assunto:
            self.start_urls = ['%s/%s' % (main_url, assunto)]
        else:
            self.start_urls = [main_url]

    def parse(self, response):
        """Recebe a pagina com as noticias destaques, encontra os links
        das noticias e gera requisicoes para a pagina de cada uma
        """
        links_noticias = response.xpath(
            "//div/h1/a/@href"
            " | //div/h3/a/@href[not(contains(.,'conteudos-externos'))]"
        ).extract()

        for link in links_noticias:
            url_noticia = urlparse.urljoin(response.url, link)
            yield scrapy.Request(url_noticia, self.extrai_noticia)

    def extrai_noticia(self, response):
        """Recebe a resposta da pagina da noticia,
        e extrai um item com a noticia
        """
        noticia = Noticia()

        noticia['link'] = response.url
        noticia['titulo'] = response.xpath(&amp;amp;amp;quot;//article/h1/text()&amp;amp;amp;quot;)[0].extract()
        noticia['conteudo'] = response.xpath(
            "string(//div[@property='rnews:articleBody'])")[0].extract()
        noticia['data_publicacao'] = ''.join(
            response.css('span.documentPublished::text').extract()).strip()

        yield noticia

Da mesma forma como antes, você pode rodar o spider com:

scrapy runspider portal_brasil_destaques.py -t json -o destaques-capa.json

E para obter os destaques de cada seção, pode usar comandos como:

scrapy runspider portal_brasil_destaques.py -t json -o destaques-infraestrutura.json -a assunto=infraestrutura
scrapy runspider portal_brasil_destaques.py -t json -o destaques-ciencia-e-tecnologia.json -a assunto=ciencia-e-tecnologia

O código desse spider é bem semelhante ao anterior na sua estrutura, com o suporte a argumentos no construtor.

A principal diferença é que neste, o primeiro callback (método parse()) gera outras requisições para as páginas das notícias, que são tratadas pelo segundo callback: o método extrai_noticia(), que faz a extração do conteúdo da notícia propriamente dita.

A extração de conteúdo nesse último spider também está um pouco mais complexa, considerando a expressão XPath usada para obter os links das notícias, que filtra os links que contenham a string ‘conteudos-externos’ em seu endereço, pois não são links de notícias. Note como aproveitamos que Python concatena strings literais para quebrar a expressão XPath em duas linhas.

Conclusão

Se você chegou até aqui, parabéns! Aqui vai um troféu pra você:

trofeu-scrapy

Agora que você já aprendeu a escrever spiders Scrapy e está habilitado a baixar a Internet inteira no seu computador, tente não ser banido nos sites por aí! 😀

Visite a documentação oficial do Scrapy, tem bastante coisa legal lá, desde um tutorial ensinando a criar projetos Scrapy completos, perguntas frequentes, dicas para crawlings grandes, como depurar um spider, dicas para evitar ser banido e muito mais.

 

Links úteis:

Obrigado pela revisão, Valdir e Zé!

Webscraping em Python

No post anterior, vimos como fazer requisições HTTP utilizando a excelente requests. A maioria dos documentos disponíveis na web estão em formato HTML, o que não é um grande problema quando acessamos via browser, pois ele é quem faz o trabalho sujo de renderizar o documento pra gente. Mas, se quisermos extrair apenas determinados trechos de informações que podem nos ser úteis, precisaremos percorrer o documento HTML em busca dessas informações, o que não é nada fácil, visto que além de pouca gente seguir os padrões para composição de documentos HTML, muitas vezes os criadores dos sites de onde queremos obter as informações não estão nem um pouco preocupados em classificar as informações que ali disponibilizam, deixando os dados soltos em uma barafunda de table, div, p, etc.

Nesse post vamos ver como fazer Web Scraping, que é a extração de dados de páginas web.

Scraping no reddit

Como já mencionei no post anterior, o reddit é um agregador de conteúdo submetido pelos usuários, e tem vários subreddits voltados a assuntos específicos. Por exemplo, o subreddit programming é um dos meus preferidos, pois além de apresentar as notícias mais relevantes, também apresenta artigos técnicos e discussões de altíssima qualidade. Veja a imagem abaixo, que contém a página inicial desse subreddit:

http2.1

O objetivo desse post é mostrar como fazer um extrator dos principais links submetidos pelos usuários ao reddit programming, usando uma técnica diferente daquela mostrada no final do post anterior.

O resultado esperado é um programinha em linha de comando que gere a seguinte saída:

Descrição do link - URL do link
Descrição do link - URL do link
Descrição do link - URL do link
Descrição do link - URL do link
Descrição do link - URL do link

A primeira coisa que temos que fazer quando queremos desenvolver um extrator de informações da web é entender a estrutura dos documentos HTML de onde vamos retirar a informação. Vamos dar uma olhada no código-fonte do reddit:

http2.2

Na imagem acima, estou usando o developer tools do Chrome para facilitar a visualização das informações do que precisamos no HTML (ctrl+alt+c abre essa ferramenta). Veja que ele já faz o highlighting das informações tanto no código-fonte quanto no documento renderizado. Assim é moleza descobrir o que precisamos extrair do HTML, não? Podemos ver na imagem acima que cada link submetido pelos usuários fica dentro de um elemento p com um atributo class com valor title, como vemos no trecho abaixo (retirado do reddit):

...
    <a class="title " href="http://www.ocamlpro.com/blog/2013/03/14/opam-1.0.0.html">
        OPAM, a new package manager for OCaml is officially released!
    </a>
    <span class="domain">
        (<a href="http://www.reddit.com/domain/ocamlpro.com/">
            ocamlpro.com
        </a>)
    </span>
...

Dentro do elemento p, temos outro elemento, a, que contém um atributo com a URL em si (href) e que tem como conteúdo o título do link, que também é de nosso interesse. A imagem abaixo mostra em mais detalhes uma parte do conteúdo da página que nos interessa.

http2.3

Perceba que precisamos extrair da página várias instâncias de informação como essa mostrada acima, pois para cada link submetido teremos uma estrutura como a apresentada acima. Assim, teremos que procurar por todas as ocorrências de links no documento HTML e extrair essas informações de lá.

Agora que já sabemos onde está a informação que precisamos em meio à bagunça do HTML, chegou o desafio principal, que é extrair essa informação usando um programinha. Mas, antes de meter a mão na massa, vamos conhecer a ferramenta que vai nos possibilitar fazer a extração dos dados de uma forma mais prática: a biblioteca BeautifulSoup.

BeautifulSoup

No post anterior, no último trecho de código, mostrei uma forma meio tosca de buscar a informação no HTML. Vou repetir aquele código aqui para você lembrar sobre o que estou falando:

import requests

def cria_lista_links(content):
    links = []
    for line in content.split('</p>'):
        index = line.find('class="title ')
        if index != -1:
            href_start = line.find('href="', index) + 6
            href_end = line.find('"', href_start)
            links.append(line[href_start:href_end])
    return links

r = requests.get("http://www.reddit.com/r/programming")
print '\n'.join(cria_lista_links(r.content))

É tosca porque não é muito precisa, visto que nossa busca se baseia somente em uma informação textual dentro de um conjunto de linhas com o HTML. Veja que fazemos a busca usando o caractere de " (aspas duplas) ou a string href= como parâmetro. Se o autor da página mudar de aspas duplas para aspas simples, ou de href= para href =, nossa busca já irá parar de funcionar.

A forma mais confiável de fazermos isso é obtida através do parsing (análise sintática) do conteúdo, de forma que ele nos devolva elementos HTML com seus atributos em uma estrutura bem fácil de manipular. Mas, fazer um bom parser para HTML daria bastante trabalho, ainda mais um parser que funcione bem com todas as variações que são usadas em documentos HTML pela web. A boa notícia é que esse parser já existe e está disponível pra gente através da biblioteca BeautifulSoup, que vamos utilizar daqui pra frente.

Analisando código HTML com BeautifulSoup

Considere o HTML apresentado abaixo. Observe com atenção a sua estrutura.

    <html>
        <head>
            <title>Uma página qualquer</title>
        </head>
        <body>
            <div id="content">
                <div class="content-title">
                    Aqui vai um conteúdo 
                    <a class="link" href="http://www.google.com/">qualquer</a>, 
                    um texto sem fundamento algum, escrito sem a mínima 
                    intenção de despertar qualquer interesse por parte
                    do <a class="link-old"> leitor</a>.
                </div>
                <div class="content-author">
                    Escrito por um autor qualquer, que não tinha algo 
                    melhor para escrever.
                </div>
                <div class="content-data">
                    16/03/2013
                </div>
            </div>
            <div class="footer">
                Nenhum direito reservado.
            </div>
        </body>
    </html>

Para entender melhor o HTML acima, vamos ver a árvore de representação dele: http2.4 Agora vamos iniciar um exemplo de análise do HTML acima usando o BeautifulSoup.

import BeautifulSoup

html_data = '''
<html>
    <head><title>Uma página qualquer</title></head>
    <body>
        <div id="content">
            <div class="content-title">
                Aqui vai um conteúdo 
                <a class="link" href="http://www.google.com/">qualquer</a>, 
                um texto sem fundamento algum, escrito sem a mínima 
                intenção de despertar qualquer interesse por parte
                do <a class="link-old"> leitor</a>.
            </div>
            <div class="content-author">
                Escrito por um autor qualquer, que não tinha 
                algo melhor para escrever.
            </div>
            <div class="content-data">
                16/03/2013
            </div>
        </div>
        <div class="footer">
            Nenhum direito reservado.
        </div>
    </body>
</html>'''

soup = BeautifulSoup.BeautifulSoup(html_data)

*Por praticidade, o conteúdo HTML foi inserido em uma string enorme chamada html_data.

Já podemos extrair informações da página, que foi “parseada” na última linha do trecho acima. Perceba que os atributos existentes em cada elemento possuem os mesmos nomes dos elementos no código HTML (.title por exemplo, se refere ao elemento title da página).

print soup.title
#<title>Uma página qualquer</title>

print soup.title.text
# Uma página qualquer

O atributo text de qualquer elemento sempre retorna o texto que está contido em tal elemento. Podemos também encadear o acesso a elementos. Abaixo, estamos acessando o primeiro div do body do HTML analisado.

print soup.body.div
# <div id="content"><div class="content-title">Aqui vai um conteúdo <a class="link" href="http://www.google.com/">qualquer</a>, um texto sem fundamento algum, escrito sem a mínima intenção de despertar qualquer interesse por parte do <a class="link-old"> leitor</a>.</div><div class="content-author">Escrito por um autor qualquer, que não tinha algo melhor para escrever.</div><div class="content-data">16/03/2013</div></div>

Perceba que no trecho de código acima foi impresso somente um dos dois divs que compõem o body (somente o de id content). Olhando para a árvore e para o valor impresso, vemos que foi impressa toda a subárvore do primeiro div do body. Como temos mais de um div dentro do body, quando tentamos acessar o atributo div, nos é retornado somente o primeiro dos divs. Para obter todos os divs do body, podemos fazer assim:

print soup.body.contents
# [u'\n', <div id="content"><div class="content-title">Aqui vai um conteúdo <a class="link" href="http://www.google.com/">qualquer</a>, um texto sem fundamento algum, escrito sem a mínima intenção de despertar qualquer interesse por parte do <a class="link-old"> leitor</a>.</div><div class="content-author">Escrito por um autor qualquer, que não tinha algo melhor para escrever.</div><div class="content-data">16/03/2013</div></div>, <div class="footer">Nenhum direito reservado.</div>, u'\n']

O atributo contents de um elemento qualquer, retorna os elementos que são seus filhos na árvore do HTML. Ou então, podemos percorrer os “filhos” de um elemento, usando o generator childGenerator():

for x in soup.body.childGenerator():
    print x, '\n'

Como resultado, teremos:

<div id="content"><div class="content-title">Aqui vai um conteúdo <a class="link" href="http://www.google.com/">qualquer</a>, um texto sem fundamento algum, escrito sem a mínima intenção de despertar qualquer interesse por parte do <a class="link-old"> leitor</a>.</div><div class="content-author">Escrito por um autor qualquer, que não tinha algo melhor para escrever.</div><div class="content-data">16/03/2013</div></div 

<div class="footer">Nenhum direito reservado.</div>

Além de acessar os elementos filhos de uma tag, podemos também acessar o elemento pai através do atributo parent. Aí vai um exemplo:

a = soup.body.div.a
print a.parent
# <div class="content-title">Aqui vai um conteúdo <a class="link" href="http://www.google.com/">qualquer</a>, um texto sem fundamento algum, escrito sem a mínima intenção de despertar qualquer interesse por parte do <a class="link-old"> leitor</a>.</div>

Como você pode ver, o elemento pai de a é o próprio div que o antecede.

Buscando por elementos específicos

Se quisermos buscar todos os elementos a de uma página HTML, podemos usar o método findAll():

print soup.findAll('a')
# [<a class="link" href="http://www.google.com/">qualquer</a>, <a class="link-old"> leitor</a>]

O método retorna uma lista contendo todas as ocorrências de elementos do tipo procurado. Também podemos obter elementos através de seus atributos id. Em nosso HTML, temos um div com id="content". Podemos obtê-lo com:

print soup.find(id="content")
# <div id="content"><div class="content-title">Aqui vai um conteúdo <a class="link" href="http://www.google.com/">qualquer</a>, um texto sem fundamento algum, escrito sem a mínima intenção de despertar qualquer interesse por parte do <a class="link-old"> leitor</a>.</div><div class="content-author">Escrito por um autor qualquer, que não tinha algo melhor para escrever.</div><div class="content-data">16/03/2013</div></div>

Ou então, buscar por elementos que estejam classificados em uma classe específica:

print soup.find(attrs={'class':'footer'})
# <div class="footer">Nenhum direito reservado.</div>

Enfim, dá pra fazer muita coisa usando a BeautifulSoup. Se quiser conhecê-la melhor, veja a documentação: http://www.crummy.com/software/BeautifulSoup/bs4/doc/.

Implementando o scraper do reddit

Como vimos anteriormente, um link no reddit possui a seguinte estrutura:

...

    <a class="title " href="http://www.ocamlpro.com/blog/2013/03/14/opam-1.0.0.html">
        OPAM, a new package manager for OCaml is officially released!
    </a>
    <span class="domain">
        (<a href="http://www.reddit.com/domain/ocamlpro.com/">
            ocamlpro.com
        </a>)
    </span>

...

Primeiro, precisamos obter o HTML direto do site do reddit:

import requests
import BeautifulSoup

r = requests.get('http://www.reddit.com/r/programming/')
soup = BeautifulSoup.BeautifulSoup(r.content)

Depois, vamos obter cada elemento a que possua o atributo class com valor igual a "title ". Podemos fazer isso usando o método findAll(), percorrendo o resultado e extraindo os atributos que nos interessam (href e text):

for a in soup.findAll('a', attrs={'class': 'title '}):
    print "%s - %s" % (a.text, a.get('href'))

Podemos melhorar o código acima, criando uma função que obtenha a lista de links de um subreddit qualquer:

import requests
import BeautifulSoup

def get_links_from(subreddit):
    links = []
    r = requests.get('http://www.reddit.com/r/%s/' % (subreddit))
    if r.status_code != 200:
        return None

    soup = BeautifulSoup.BeautifulSoup(r.content)

    for a in soup.findAll('a', attrs={'class': 'title '}):
        links.append((a.text, a.get('href')))
    return links

A função acima retorna uma lista contendo tuplas de pares (título do link, URL do link). Assim, podemos percorrer essa lista, imprimindo os valores:

for link_name, link_url in get_links_from('programming'):
    print "%s - %s" % (link_name, link_url)

for link_name, link_url in get_links_from('python'):
    print "%s - %s" % (link_name, link_url)

Está pronto! Barbada, hein? Que tal agora você começar a estudar aquele site de onde você quer extrair informações e implementar um webscraper?

Boa sorte! 🙂

Leitura adicional