Puts! Só de pensar que já fiz crawler pra classificação do brasileirão… e agora o globoesporte.com tem isso: classificacao.json

Agora vou atrás do calendário compartilhado do Brasileirão 2011.


mais sobre crawlers e spiders

logo_mercadolivreNo mês passado escrevi um artigo com um programa para capturar todos os items da primeira página de cada categoria do MercadoLivre.

Lá, lidava com alguns problemas como:

  • limite de concorrência no download das páginas
  • processamento de html em thread, síncrono
  • manter a maior parte do processo assíncrono, para ganhar tempo e CPU

Depois disso, precisei fazer umas alterações no código e acabei modificando um pouco programa, usando outras técnicas como:

Cada item desta lista corresponde aos itens da lista mais acima, respectivamente.

O esquema de cooperação do twisted é muito melhor que o Controller que havia criado anteriormente. Porém, muito mais complicado para jovens aprendizes. Recomendo este link para mais detalhes.

Sobre o processamento do html, vale a pena verificar o lxml. Antes, havia usado BeautifulSoup, que é muito bom, mas perde violentamente em desempenho e suporte a broken-html.

Por fim, o truque de usar generators para executar alguns callbacks inline é incrível, e absurdamente prático em casos como esse, do programa abaixo.

O resultado é final é o mesmo, mas a melhoria em desempenho é absurda. Fiz alguns testes na minha máquina e obtive o seguinte:

  • esta versão consome, em média, 20% menos de CPU
  • como não há necessidade de gravar os arquivos no disco, não consome disco
  • o processo todo ficou 657% mais rápido, simplesmente
  • ainda, o código é muito menor +_+

Veja ai:

#!/usr/bin/env python
# coding: utf-8

from lxml import html
from twisted.web import client
from twisted.python import log
from twisted.internet import task, defer, reactor

class MercadoLivre:
    def __str__(self):
        return 'http://www.mercadolivre.com.br/jm/ml.allcategs.AllCategsServlet'

    def parse_categories(self, content):
        category = subcategory = ''
        doc = html.fromstring(content)
        for link in doc.iterlinks():
            el, attr, href, offset = link
            try: category = el.find_class('categ')[0].text_content()
            except: pass
            else: continue
            if category:
                try: subcategory = el.find_class('seglnk')[0].text_content()
                except: continue
                else: yield (href, category, subcategory)

    def parse_subcategory(self, content):
        doc = html.fromstring(content)
        for element in doc.find_class('col_titulo'):
            yield element[0].text_content()

class Engine:
    def finish(self, result):
        reactor.stop()

    @defer.inlineCallbacks
    def fetch_categories(self, link, parser):
        try:
            doc = yield client.getPage(link)
            defer.returnValue(parser(doc))
        except Exception, e:
            print e

    def fetch_subcategory(self, links, parser, limit):
        coop = task.Cooperator()
        work = (client.getPage(link[0]).addCallback(parser).addCallback(self.page_items, *link) for link in links)
        result = defer.DeferredList([coop.coiterate(work) for x in xrange(limit)])
        result.addCallback(self.finish)
        result.addErrback(log.err)

    def page_items(self, items, href, category, subcategory):
        print 'Categoria: %s / %s' % (category.encode('utf-8'), subcategory.encode('utf-8'))
        for item in items: print ' -> %s' % item.encode('utf-8')
        print ''

def main(limit, *parsers):
    e = Engine()
    for parser in parsers:
        links = e.fetch_categories(str(parser), parser.parse_categories)
        links.addCallback(e.fetch_subcategory, parser.parse_subcategory, limit)

if __name__ == '__main__':
    reactor.callWhenRunning(main, 150, MercadoLivre())
    reactor.run()

twisted crawler, alvo: mercadolivre

logo_mercadolivreJá pensou em fazer um programa que acessa o Mercado Livre, identifica o link de cada categoria, e extrai todos os produtos da primeira página de cada uma dessas categorias?

Pode até parecer complexo, mas não é. Esse programa existe, é simples, e está aqui, neste artigo, pronto pra você testar e modificar. :)

A algumas semanas tenho feito alguns crawlers pra esses sites que vendem produtos, como o Web Motors, Submarino, e até mesmo o eBay. A idéia é simplesmente extrair todos os produtos da primeira página de cada categoria, e montar uma base de dados com essas informações. O objetivo? Segredo de Estado.

Um dos problemas que encontrei ao fazer esse tipo de crawler, é controlar a concorrência de acesso. Se o programa acessa uma categoria por vez, demora uma eternidade pra baixar e processar cada página. Por outro lado, se ele acessa todas as categorias descontroladamente, o processo acaba criando muitos File Descriptors e isso causa diversos outros problemas pro sistema operacional – e não adianta falar em aumentar o limite usando ulimit -n, porque esses crawlers têm baixado milhões de links por dia.

A melhor maneira que encontrei pra solucionar esse problema, foi criando uma classe chamada “Controller”, que coloca as requisições em uma fila, baixa N páginas por vez, e ao invés de processá-las, simplesmente salva em um arquivo no disco.

Todos esses arquivos, com nomes únicos, são gerados usando um hash SHA1, e colocados em uma lista para serem processados offline, após o processo de download terminar.

O twisted tem um recurso muito interessante, que permite agrupar diversos deferreds em um único, e quando todos eles terminam, executa um callback. Nesse caso, quando todos os processos de baixar a página da subcategoria terminam, executa a função “offline”, que começa a processar cada uma, e extrair os produtos de lá.

Esta segunda etapa também poderia ser feita em paralelo, usando o twisted-parallels, mas decidi não colocar porque o código ficaria muito maior e você não teria tanta paciência pra entendê-lo. Usar um thread pool pra isso não valeria a pena, pois vale lembrar que o python usa o GIL, e I/O em thread só consome recurso e não aumenta em nada o desempenho.

A classe “MercadoLivre” tem 3 funções:

  • __str__: retorna o link com todas as categorias do Mercado Livre
  • parse_categories: um parser que retorna uma lista composta por tuples de (url, nome)
  • parse_subcategory: um parser pro conteúdo de cada categoria, que retorna uma lista com o título dos produtos anunciados lá

Todos esses parsers são baseados no BeautifulSoup 3.1. É necessário tê-lo instalado pra usar o programa.

Por fim, para usar este programa em outros sites, basta substituir a classe “MercadoLivre” pela sua própria, tipo “Submarino”.

Detalhe: essa porcaria de WordPress mostra a identação do código errada no artigo, mas se você clicar em “view plain”, poderá copiar e colar o código correto. Chame o suporte!

Aqui o código (muito belo, por sinal…):

#!/usr/bin/env python
# coding: utf-8

import os, re, sys
import shutil, hashlib
from Queue import Queue
from BeautifulSoup import BeautifulSoup

# twisted
from twisted.web import client
from twisted.internet import defer, threads, reactor

class MercadoLivre:
noscript = re.compile(r”(?is)]*>(.*?)“)

def __str__(self):
return ‘http://www.mercadolivre.com.br/jm/ml.allcategs.AllCategsServlet’

def parse_categories(self, content):
catlist = {}
cleanup = lambda s: s.replace(‘\n’, ”).strip()

# parse the document
soup = BeautifulSoup(content)

# find all categories and their items
current = ”
for item in soup.findAll(‘a’, {‘class’:[‘categ’,’seglnk’]}):
text = cleanup(item.contents[0])
attrs = dict(item.attrs)
if attrs.get(u’class’) == u’categ’:
current = text
catlist[current] = []
else:
catlist[current].append((attrs.get(u’href’, ”), text))

# return the list of categories and their items
return catlist

def parse_subcategory(self, content):
result = []
soup = BeautifulSoup(self.noscript.sub(”, content),
convertEntities=BeautifulSoup.HTML_ENTITIES)

for item in soup.findAll(‘div’, {‘class’:’col_titulo’}):
result.append(item.find(‘a’).contents[0].strip())

return result

class Controller:
def __init__(self, fetch):
self.count = 0
self.limit = fetch
self.queue = Queue()
self.tmpdir = ‘/tmp/mercadolivre.%d’ % os.getpid()
self.dispatch()

def encode(self, text):
return unicode(text).encode(‘utf-8’, ‘replace’)

def getPage(self, url, *args, **kwargs):
d = defer.Deferred()
self.queue.put((d, url, args, kwargs))
return d

def dispatch(self):
while True:
try:
assert self.count < self.limit d, url, args, kwargs = self.queue.get_nowait() except: break self.count += 1 deferred = client.getPage(url, *args, **kwargs) deferred.addBoth(self.decrease_count) deferred.chainDeferred(d) reactor.callLater(1, self.dispatch) def decrease_count(self, result): self.count -= 1 return result class main(Controller): def __init__(self, fetch, parser): # set the concurrent download limit for the crawler Controller.__init__(self, fetch) # fetch the main categories page self.total = 0 self.files = [] self.parser = parser d = self.getPage(str(parser), timeout=60) d.addCallback(self.fetch_categories) d.addErrback(self.error_categories) def error_categories(self, error): # hmmm... fatal error, cannot continue print 'cannot fetch categories from %s: %s' % (str(self.parser), str(error)) reactor.stop() def error_subcategory(self, error, href, category, subcategory): # problem fetching subcategory contents... print 'error "%s / %s": [%s] %s' % (category, subcategory, href, error.value) def fetch_categories(self, content): # parse the contents in a thread reactor.callInThread(self.parse_categories, content) def parse_categories(self, content): try: categories = self.parser.parse_categories(content) except Exception, e: print 'error parsing categories: %s' % str(e) reactor.stop() return print 'going to fetch %d categories...' % len(categories) tasks = [] for category, contents in categories.items(): category = self.encode(category) for href, subcategory in contents: href, subcategory = self.encode(href), self.encode(subcategory) d = self.getPage(href, timeout=60) d.addCallback(self.save_subcategory, href, category, subcategory) d.addErrback(self.error_subcategory, href, category, subcategory) tasks.append(d) # call the offline subcategory parser after downloading everything... d = defer.gatherResults(tasks) d.addCallback(self.offline) def save_subcategory(self, contents, href, category, subcategory): # create the tmpdir if it doesn't exist if not os.path.exists(self.tmpdir): os.mkdir(self.tmpdir) # create a unique hash for each category hash = hashlib.new('sha1', category+subcategory).hexdigest() filename = os.path.join(self.tmpdir, hash+'.dump') try: fd = open(filename, 'w') fd.write(contents) fd.close() except: return self.files.append((href, category, subcategory, filename)) print 'saving "%s / %s": %s' % (category, subcategory, hash) def offline(self, null): # start processing each subcategory... reactor.stop() for item in self.files: href, category, subcategory, filename = item sys.stdout.write('parsing "%s / %s": ' % (category, subcategory)) sys.stdout.flush() fd = open(filename) try: results = self.parser.parse_subcategory(fd.read()) except Exception, e: print 'error! %s' % str(e) continue lr = len(results) self.total += lr print '%d items' % lr for result in results: print ' ' + self.encode(result) print '\n%d items processed. cleaning up!' % self.total shutil.rmtree(self.tmpdir) if __name__ == '__main__': reactor.callWhenRunning(main, 10, MercadoLivre()) reactor.run() [/sourcecode] Agora, é só você se dedicar e fazer isso pros sites que te interessam. Notas?


paulistão 2009

palmeirasAproveitando que meu time está em primeiro, aproveitei pra colocar mais um crawler com BautifulSoup, só que desta vez usando twisted.

A fonte de informação é o Globo Esporte, e pela leve bagunça que há no HTML e CSS, o código ficou meio feioso. Além disso, consegui deixar ainda mais feio pra poder imprimir bonito no terminal.

#!/usr/bin/env python
# coding: utf-8
# 20090127 AF - paulistão 2009, crawler

import re
from sys import stdout
from twisted.internet import reactor
from twisted.web.client import getPage
from BeautifulSoup import BeautifulSoup

crlft = re.compile(r'[\r\n\t]*')
URL = 'http://globoesporte.globo.com/Esportes/Futebol/Classificacao/0,,ESP0-9839,00.html'

def failure(err):
    print(err)
    reactor.stop()

def parser(contents):
    current = 1
    columns = 10

    f = lambda m: crlft.sub('', m.contents[0]+' ')
    soup = BeautifulSoup(contents, convertEntities='html')
    classes = ['borda borda-forte', 'borda semborda', ' borda-forte',
                ' semborda', 'time borda', 'borda', 'time ', '']

    stdout.write('% 18s' % 'TIME')
    for label in ['P', 'J', 'V', 'E', 'D', 'GP', 'GC', 'SG', '(%)']:
        stdout.write('% 5s' % label)
    stdout.write('\n')

    for item in soup.findAll('td', {'class':classes}):
        if item.find('span'):
            temp = '%02d. % 15s' % ((current / columns)+1,
                item.span.find('a') and f(item.span.a) or f(item.span))
            stdout.write(temp.encode('utf-8'))
        else:
            stdout.write('% 5s' % f(item))
            if not current % columns: stdout.write('\n')
        current += 1
    reactor.stop()

if __name__ == '__main__':
    deferred = getPage(URL)
    deferred.addCallback(parser)
    deferred.addErrback(failure)
    reactor.run()

E o resultado, no terminal:

$ python paulista.py
              TIME    P    J    V    E    D   GP   GC   SG  (%)
01.      Palmeiras    9    3    3    0    0    7    0    7  100
02.         Santos    6    2    2    0    0    4    1    3  100
03.    São Caetano    6    2    2    0    0    3    0    3  100
04.        Guarani    6    2    2    0    0    2    0    2  100
05.       Mirassol    4    2    1    1    0    5    3    2   66
06.      São Paulo    4    2    1    1    0    3    1    2   66
07.    Corinthians    4    2    1    1    0    3    2    1   66
08.    Ponte Preta    4    3    1    1    1    2    1    1   44
09.     Bragantino    3    2    1    0    1    4    3    1   50
10.       Paulista    3    2    1    0    1    2    2    0   50
11.    Santo André    3    3    1    0    2    1    2   -1   33
12.        Barueri    2    2    0    2    0    4    4    0   33
13.          Oeste    2    2    0    2    0    2    2    0   33
14.         Ituano    1    2    0    1    1    1    2   -1   16
15.    Botafogo-SP    1    2    0    1    1    4    6   -2   16
16.  Guaratinguetá    1    2    0    1    1    2    4   -2   16
17.        Marília    1    3    0    1    2    3    8   -5   11
18.       Noroeste    0    2    0    0    2    1    4   -3    0
19.     Portuguesa    0    2    0    0    2    0    3   -3    0
20.     Mogi Mirim    0    2    0    0    2    0    5   -5    0

urban dictionary crawler

udlogo2Algum tempo atrás, o Urban Dictionary teve uma API para que outros sistemas e sites pudessem fazer busca nos termos de lá, que são muito legais.

Hoje, o link para a API não funciona mais, e virou um termo do dicionário. Procurando no google, encontrei um link que menciona uma API de acesso via SOAP com WSDL, mas também não é muito útil porque segundo o administrador de lá (15 de setembro) o formulário que emite as chaves de acesso está quebrado.

Em outras palavras: não existe mais API pro Urban Dictionary. Apesar a API de SOAP ainda funcionar, não é possível emitir novas chaves e por isso não é possível ter novos usuários com acesso a essa API. Não apenas eu, mas um outro cara gostaria de ter acesso ao Urban Dictionary através de uma API mais simples.

Enquanto eles não se manifestam, fiz um crawler usando BeautifulSoup, que resolve esse problema de um jeito meio tosco, mas pelo menos é simples e funciona.

#!/usr/bin/env python
# coding: utf-8
# 20080108 AF

import urllib
from BeautifulSoup import BeautifulSoup

def urbandict(search, limit=5, cutafter=256):
    query = urllib.urlencode(dict(term=search))
    url = 'http://www.urbandictionary.com/define.php?' + query
    response = urllib.urlopen(url)
    soup = BeautifulSoup(response.read(), convertEntities='html')

    fix = lambda item: item.replace('\r', '').replace('\n', '')
    cut = lambda item: len(item) > cutafter and item[:cutafter] + '(...)' or item
    extract = lambda item: isinstance(item, unicode) and item or \
        (item.name == 'a' and item.contents[0] or '')

    for item, count in zip(soup.findAll('div', attrs={'class':'definition'}), range(limit)):
        yield cut(' '.join([fix(extract(k)) for k in item.contents])).encode('utf-8')

for term in urbandict('stupid'):
    print term + '\n'

O limite padrão para a quantidade de respostas retornadas é 5, embora todas elas sejam capturadas. Graças à organização do site, isso é possível de forma simples pois os resultados são agrupados em DIVs cuja classe é “definition”.

Também, as seguintes funções modificam a exposição dos resultados:

  • fix: remove CRLF, e faz com que cada resultado seja uma única linha
  • cut: corta cada resultado após cutafter bytes, e adiciona (…)

Dessa maneira, fica fácil embutir os resultados do Urban Dictionary em qualquer lugar: um site, um programa no desktop, etc.

$ python ud.py
Someone who has to look up “stupid” in the dictionary because they don’t know what it means.

1) George W. Bush.  2) Karl Rove  3) Dick Cheney  4) You get the idea!


crawler com beautiful soup

Hoje precisei escrever uns programas pra extrair cotação de moedas do Banco Central. Aí, pra facilitar escrevi uma classe bem abstrata que faz o trabalho básico de ir até o website e fazer o GET ou POST, e retornar o conteúdo.

Veja que beleza:

# coding: utf-8
# 2008-06-11 AF
# crawler.py

import urllib, httplib

class crawler:
    data = ''
    host = None
    method = 'GET'
    params = {}
    headers = {}
    request = '/'

    def __call__(self, fetch_anyway=False):
        if not self.host:
            raise ValueError('You must provide at least the host')

        if not self.data:
            # make the connection
            fd = httplib.HTTPConnection(self.host)

            # set the params and send the request
            params = urllib.urlencode(self.params)
            fd.request(self.method, self.request, params, self.headers)

            # get the response
            response = fd.getresponse()
            self.status, self.reason = response.status, response.reason

            # read the data
            if self.status == 200 or fetch_anyway is True:
                self.data = response.read()

            fd.close()

        return self.data

Basicamente, se eu criar uma classe que herda dela e especificar apenas o host ela, já funciona. Depois de todo o trabalho, resolvi fazer um programa relativamente simples que extrai o desempenho dos clubes do Campeonato Brasileiro 2008, usando o site da Gazeta Esportiva.

Pra interpretar o conteúdo de maneira bem fácil e rápida, usei o Beautiful Soup. Apesar do meu Palmeiras não estar bem, com esse papo furado ai do Luxa, é legal ver a lista de desempenhos – especialmente por não ter o Corinthians nela, ainda mais depois da derrota de hoje contra o Sport. :)

Aí vai o programa (cheio de comentários):

#!/usr/bin/env python
# coding: utf-8

import re, sys
from crawler import crawler
from BeautifulSoup import BeautifulSoup

class fetch(crawler):
    host = 'www.gazetaesportiva.net'
    request = '/campeonatos/futebol/nacional/2008/brasileirao/conteudo/desempenho.php'

if __name__ == '__main__':
    # cria o crawler
    doc = fetch()

    # busca o documento
    buffer = doc()
    if not buffer:
        print doc.status, doc.reason
        sys.exit(1)

    # armazena as listas de times e desempenho nas rodadas
    name_list = []
    data_list = []

    # cria o soup, já convertendo as entidades HTML pra
    # caracteres unicode: aacute vira "á"
    soup = BeautifulSoup(buffer, convertEntities='html')

    # encontra os nomes dos times
    name = soup.findAll('strong')

    # depois de encontrar os nomes, compilamos essa
    # expressão regular que irá remover as tags
    # NOTA: talvez já tenha isso no BS e eu não sei :p
    junk = re.compile('</?strong>', re.IGNORECASE)

    # encontra os dados dos times (pelo caracter º)
    data = soup.findAll(text=re.compile('\d{1,2}[\xc2\xba]'))

    # cria a lista de nomes removendo as tags
    # e qualquer outro lixo
    for n in name:
        text = junk.sub('', str(n)).strip()
        if text: name_list.append(text)

    # cria a lista de desempenho por time
    if len(data) % len(name_list):
        print 'Oops! Problemas com os dados.'
        sys.exit(1)

    div = len(data) / len(name_list)
    for n in range(0, len(data)+1, div):
        data_list.append(data[n:n+div])

    # imprime tudo :)
    for k, v in zip(name_list, data_list):
        print k, ', '.join(v)

Além de simples e razoavelmente rápido, o Beautil Soup tem muitos recursos que permitem interpretar o conteúdo de forma eficiente, evitando o trabalho demorado de escrever um mega parser pra cada HTML que vai interpretar.

Se rodar o programa, ele mostra isso:

$ python desempenho-futebol.py
Atlético/MG 11º, 13º, 15º, 7º, 12º
Atlético/PR 6º, 5º, 5º, 9º, 5º
Botafogo 2º, 10º, 9º, 15º, 9º
Cruzeiro 2º, 2º, 1º, 1º, 2º
Coritiba 2º, 8º, 7º, 8º, 13º
Figueirense 9º, 4º, 12º, 11º, 16º
Flamengo 1º, 3º, 2º, 2º, 1º
Fluminense 11º, 17º, 19º, 20º,  20º
Goiás 13º, 14º, 16º, 17º, 19º
Grêmio 6º, 6º, 3º, 5º, 4º
Internacional 6º, 11º, 13º, 13º, 17º
Ipatinga 14º, 20º, 20º, 16º, 15º
Náutico 5º, 1º, 4º, 3º, 3º
Palmeiras 18º, 12º, 10º, 6º, 10º
Portuguesa 9º, 16º, 18º, 19º, 14º
Santos 17º, 7º, 14º, 14º, 17º
São Paulo 14º, 15º, 17º, 18º, 11º
Sport 18º, 17º, 11º, 10º, 6º
Vasco 14º, 8º, 7º, 4º, 7º
Vitória 18º, 17º, 6º, 12º, 8º

Se você for copiar e colar o código, não esqueça que esse maldito CSS do wordpress fica mexendo nas aspas. Pra resolver isso, tem os esquemas que publiquei aqui pra resolver.