Crawlee para Python: Fila, Dedupe e Renderizar uma Verdadeira Rastreamento
Senior Web Scraping Engineer
TL;DR:
- O Crawlee para Python oferece uma fila de requisições, desduplicação automática de URLs,
enqueue_links()e um gravador de dataset, então um crawler paginado é uma única função manipuladora. - O armazenamento do Crawlee é compartilhado por processo, não por crawler.
purge_on_startéTruee ainda assim não isola dois crawlers em um único script. - Medido: dois crawlers idênticos com
max_requests_per_crawl=2em um único script. O primeiro terminou 2 requisições e escreveu 20 itens; o segundo terminou 3 requisições e reportou 50 itens em 5 páginas. - O
BeautifulSoupCrawlernão executa JavaScript. Em uma página renderizada pelo cliente, terminou a requisição e produziu 0 itens. - A classe base
HttpClientdo Crawlee possui quatro métodos. Implementando um que chama a API Universal Scraping Scrapeless retornou 10 itens daquela mesma página, com o manipulador de roteador inalterado. - O plano gratuito do Scrapeless cobre cada requisição neste guia.
O Crawlee para Python é a parte de um scraper que você normalmente escreveria por conta própria: a fila que contém URLs, o conjunto que impede que você busque uma duas vezes, o limitador de concorrência, e o gravador que coloca resultados no disco. Você fornece uma função manipuladora que recebe uma página analisada.
Como o Crawlee controla a fila e o armazenamento, seus padrões decidem como são os seus resultados — e dois deles produzem números errados de maneiras que nenhuma exceção lhe informará.
Este guia constrói um crawler funcional contra um site ao vivo, mede o que o padrão de armazenamento faz a um segundo crawler, e depois troca o transporte para que a mesma função manipuladora funcione em uma página que é renderizada no navegador.
O Que o Crawlee Oferece
O Crawlee fornece várias classes de crawler que compartilham uma interface. A que você escolhe decide como a página é analisada:
BeautifulSoupCrawlereParselCrawlerbuscam via HTTP e entregam ao seu manipulador uma árvore analisada.HttpCrawleroferece a resposta bruta sem análise.PlaywrightCrawlereAdaptivePlaywrightCrawlercontrolam um navegador real.
Todos aceitam o mesmo roteador, as mesmas configurações de concorrência e o mesmo armazenamento. Trocar entre eles altera o objeto de contexto que seu manipulador recebe, razão pela qual a mudança de um crawler HTTP para um crawler de navegador não é uma alteração de uma linha só.
Instalação
bash
pip install 'crawlee[beautifulsoup]'
Os extras importam — o pacote base crawlee não inclui o Beautiful Soup. A execução de verificação usou crawlee 1.9.0 com beautifulsoup4 4.15.0 no Python 3.12.
Seu Primeiro Crawler
Um crawler é uma classe mais uma função manipuladora decorada. A função manipuladora recebe um contexto que carrega a página analisada, a requisição, e os métodos para empurrar dados e enfileirar mais URLs.
python
def build(*, storage_dir: str | None = None, http_client=None, max_requests: int = 3):
crawler = BeautifulSoupCrawler(
http_client=http_client,
max_requests_per_crawl=max_requests,
concurrency_settings=ConcurrencySettings(desired_concurrency=2, max_concurrency=2),
configuration=Configuration(storage_dir=storage_dir) if storage_dir else None,
)
@crawler.router.default_handler
async def handler(context: BeautifulSoupCrawlingContext) -> None:
for quote in context.soup.select("div.quote"):
await context.push_data({
"text": quote.select_one("span.text").get_text(strip=True),
"author": quote.select_one("small.author").get_text(strip=True),
"url": context.request.url,
})
await context.enqueue_links(selector="li.next a")
return crawler
context.soup é um objeto Beautiful Soup, então os seletores existentes continuam iguais. context.push_data() anexa ao dataset. context.enqueue_links(selector=...) encontra âncoras que correspondem a esse seletor, resolve cada uma em relação à página atual usando as regras de base-URL em o Padrão de URL WHATWG, e adiciona os resultados à fila — já deduplicados, então um link "next" que aponta para uma página visitada não custa nada.
ConcurrencySettings rejeita uma max_concurrency abaixo de sua desired_concurrency, então defina ambos quando você diminuí-la.
Executado contra três páginas de um site de citações ao vivo:
text
estático
requisições finalizadas : 3
itens do dataset : 30
páginas distintas : 3
primeira citação : “O mundo como o criamos é um processo do nosso pensamento
primeiro autor : Albert Einstein
Três requisições, dez citações cada, três URLs de origem distintas. A função manipuladora nunca construiu uma URL ou rastreou um conjunto visitado.
Para Onde os Dados Vão
push_data() grava em um dataset sob ./storage, e crawler.get_data() lê os dados de volta:
python
async def report(label, crawler, start_url):
await crawler.run([start_url])
data = await crawler.get_data()
print(f" {label}")
print(f" requisições finalizadas : {crawler.statistics.state.requests_finished}")
print(f" itens do dataset : {data.count}")
python
print(f" páginas distintas : {len({i['url'] for i in data.items})}")
return data
crawler.statistics.state.requests_finished é a contagem que o Crawlee realmente completou, que vale a pena imprimir ao lado da contagem do conjunto de dados. Quando essas duas não concordam com o que você espera, a razão geralmente é a seção seguinte.
O Armazenamento Vive Mais Que Seu Crawler
Configuration().purge_on_start é True. Isso parece uma garantia de que cada execução começa a partir de um conjunto de dados vazio e uma fila vazia. Não é — a purga acontece uma vez, quando o armazenamento é aberto pela primeira vez no processo, então um segundo crawler construído no mesmo script se junta ao armazenamento que o primeiro deixou para trás.
Dois crawlers, construídos pela mesma função, ambos limitados a duas requisições, ambos começando a partir da mesma URL:
python
await report("crawler A", build(max_requests=2), "https://quotes.toscrape.com/")
await report("crawler B", build(max_requests=2), "https://quotes.toscrape.com/")
text
crawler A
requisições finalizadas : 2
itens do conjunto de dados : 20
páginas distintas : 2
crawler B
requisições finalizadas : 3
itens do conjunto de dados : 50
páginas distintas : 5
O Crawler B foi configurado para duas requisições e finalizou três. Seu conjunto de dados relata 50 itens em 5 páginas distintas, que incluem tudo que o Crawler A escreveu. Nada foi levantado, e ambas as execuções foram registradas como bem-sucedidas.
A URL inicial dada ao Crawler B já havia sido visitada, então a deduplicação a descartou, enquanto a página que o Crawler A havia enfileirado e nunca atingiu ainda estava aguardando. O limite e o conjunto de dados que um crawler reporta são ambas propriedades do armazenamento compartilhado, não daquele crawler.
Dê a cada crawler seu próprio diretório de armazenamento quando eles compartilham um processo. Esse é um argumento que o construtor acima aceita exatamente por essa razão:
python
configuration=Configuration(storage_dir=storage_dir) if storage_dir else None,
Re-execute as mesmas três etapas com o storage_dir definido por crawler e as contagens se tornam aquelas que você configurou. Um crawler por processo é a outra resposta, e a mais simples para produção.
Quando a Página É Renderizada no Navegador
https://quotes.toscrape.com/js/ constrói seu DOM a partir de um array JavaScript. BeautifulSoupCrawler o busca sem reclamações:
text
javascript
requisições finalizadas : 1
itens do conjunto de dados : 0
páginas distintas : 0
Uma requisição finalizada, zero itens. A marcação que o Crawlee recebeu não contém elementos div.quote, e um crawler HTTP não tem nada que os criaria.
A resposta documentada é PlaywrightCrawler, o que significa uma dependência de navegador, um objeto de contexto diferente e uma reescrita da análise do manipulador. A mudança mais restrita é manter o BeautifulSoupCrawler e substituir apenas seu transporte, que o Crawlee suporta através do parâmetro http_client.
HttpClient possui quatro métodos, e apenas dois deles precisam de trabalho real. Um objeto de resposta que satisfaça o tipo estrutural HttpResponse do Crawlee — um protocolo no sentido da especificação do protocolo de digitação do Python — envolve o HTML renderizado. Ele precisa expor um código de status e cabeçalhos porque o Crawlee os trata da maneira que a especificação semântica HTTP os define:
python
class RenderedResponse:
"""Adapta uma string HTML renderizada ao protocolo HttpResponse do Crawlee."""
def __init__(self, body: bytes, status_code: int = 200) -> None:
self._body = body
self._status_code = status_code
@property
def http_version(self) -> str:
return "HTTP/1.1"
@property
def status_code(self) -> int:
return self._status_code
@property
def headers(self) -> HttpHeaders:
return HttpHeaders({"content-type": "text/html; charset=utf-8"})
async def read(self) -> bytes:
return self._body
async def read_stream(self) -> AsyncIterator[bytes]:
raise RuntimeError("streaming não é suportado por este cliente")
yield b""
O próprio cliente chama a API Universal de Scraping Scrapeless. Esse endpoint renderiza a página e retorna o HTML como uma string. A chamada bloqueante passa por asyncio.to_thread para que não bloqueie o loop de eventos que a documentação da tarefa asyncio descreve:
python
class ScrapelessHttpClient(HttpClient):
"""Roteia cada requisição Crawlee através da API Universal de Scraping."""
def __init__(self, *, proxy_country: str = "US") -> None:
super().__init__()
self._proxy_country = proxy_country
self._token = os.environ["SCRAPELESS_API_KEY"]
def _render(self, url: str) -> bytes:
python
payload = {
"actor": "unlocker.webunlocker",
"input": {"url": url, "proxy_country": self._proxy_country, "js_render": True},
}
request = urllib.request.Request(
UNLOCKER,
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json", "x-api-token": self._token},
)
with urllib.request.urlopen(request, timeout=180) as response:
return json.loads(response.read().decode())["data"].encode("utf-8")
async def crawl(self, request, *, session=None, proxy_info=None, statistics=None,
timeout: timedelta | None = None) -> HttpCrawlingResult:
body = await asyncio.to_thread(self._render, request.url)
return HttpCrawlingResult(http_response=RenderedResponse(body))
async def send_request(self, url, *, method="GET", headers=None, payload=None,
session=None, proxy_info=None, timeout=None) -> HttpResponse:
body = await asyncio.to_thread(self._render, url)
return RenderedResponse(body)
def stream(self, url, **kwargs):
raise NotImplementedError("este cliente não transmite")
async def cleanup(self) -> None:
return None
Passe para o mesmo crawler e execute a mesma página:
text
javascript+api
requisições concluídas : 1
itens do conjunto de dados : 10
páginas distintas : 1
primeira citação : “O mundo que criamos é um processo do nosso pensamento
Dez itens da página que produziram zero. O manipulador do roteador, os seletores, a chamada do conjunto de dados e enqueue_links estão todos intactos — a fila e a deduplicação do Crawlee continuam funcionando, porque apenas o objeto que busca bytes foi substituído. Mantenha a chave no ambiente como SCRAPELESS_API_KEY; o guia de início rápido da Universal Scraping API lista os outros parâmetros da requisição. Se você precisar de roteamento proxy para o cliente HTTP padrão, o guia de proxy do Crawlee cobre essa configuração.
Começar leva um minuto — crie uma conta gratuita na Scrapeless e o plano gratuito cobre tudo aqui.
Execute
bash
export SCRAPELESS_API_KEY="sua-chave-api"
python3 crawlee_demo.py
A saída completa da execução de verificação:
text
crawlee 1.9.0 | beautifulsoup4 4.15.0
purge_on_start default: True
--- site estático, cliente HTTP padrão, armazenamento isolado ---
estático
requisições concluídas : 3
itens do conjunto de dados : 30
páginas distintas : 3
primeira citação : “O mundo que criamos é um processo do nosso pensamento
primeiro autor : Albert Einstein
--- site javascript, cliente HTTP padrão, armazenamento isolado ---
javascript
requisições concluídas : 1
itens do conjunto de dados : 0
páginas distintas : 0
--- site javascript, ScrapelessHttpClient, armazenamento isolado ---
javascript+api
requisições concluídas : 1
itens do conjunto de dados : 10
páginas distintas : 1
primeira citação : “O mundo que criamos é um processo do nosso pensamento
--- dois crawlers, um processo, armazenamento padrão ---
crawler A
requisições concluídas : 2
itens do conjunto de dados : 20
páginas distintas : 2
crawler B
requisições concluídas : 3
itens do conjunto de dados : 50
páginas distintas : 5
Solução de Problemas
O conjunto de dados tem mais itens do que esta execução produziu. O armazenamento é compartilhado por processo. Defina Configuration(storage_dir=...) por crawler, ou exclua ./storage entre as execuções, ou execute um crawler por processo.
desired_concurrency não pode ser maior que max_concurrency. ConcurrencySettings valida o par na construção. Reduzir max_concurrency sozinho aumenta; defina desired_concurrency para corresponder.
ModuleNotFoundError: Nenhum módulo chamado 'bs4'. O pacote base não possui parser. Instale crawlee[beautifulsoup] ou crawlee[parsel].
ImportError em HttpHeaders. Ele é exportado do pacote de nível superior crawlee, não de um submódulo.
Zero itens e uma requisição concluída. A página é renderizada do lado do cliente. Imprima await context.http_response.read() e procure um valor que você possa ver na página; se o valor estiver ausente, nenhum seletor o encontrará.
Conclusão
O valor do Crawlee é a maquinaria em torno do seu manipulador: uma fila, deduplicação, concorrência limitada e um conjunto de dados. Essa maquinaria também é a coisa a se observar, porque ela mantém o estado que sobrevive ao objeto do crawler. As duas medições neste guia provêm desse fato — um segundo crawler em um processo relatando 50 itens quando buscou muito menos e uma página renderizada pelo cliente retornando um zero limpo.
Ambos são diagnosticáveis em uma linha. Imprima requests_finished junto com a contagem do conjunto de dados em cada execução; quando eles discordarem da sua configuração, observe o armazenamento antes dos seletores. E quando a contagem for zero porque a marcação chegou vazia, a menor correção é mudar o transporte e deixar o manipulador em paz.
Pronto para tentar? Comece com o plano gratuito da Scrapeless e veja o preço atual para volumes maiores.
FAQ
Q: Qual classe de crawler do Crawlee devo começar?
Comece com o BeautifulSoupCrawler se os dados estiverem no HTML servido, porque isso custa uma requisição HTTP por página e fornece uma árvore analisada familiar. Mude para ParselCrawler se preferir XPath, HttpCrawler se quiser os bytes brutos, e um crawler do Playwright somente quando a página realmente precisar de um navegador.
Q: Como o Crawlee é diferente de escrever o loop eu mesmo?
O Crawlee fornece a fila de requisições, deduplicação de URLs, concorrência limitada e persistência do conjunto de dados. Na execução acima, enqueue_links(selector="li.next a") percorreu três páginas sem que o manipulador construísse uma única URL ou rastreasse quais páginas havia visto.
Q: Por que meu conjunto de dados contém resultados de uma execução anterior?
Porque o armazenamento do Crawlee é compartilhado por processo e purge_on_start é acionado uma vez quando o armazenamento é aberto pela primeira vez, não por crawler. Dois crawlers em um script compartilham um conjunto de dados e uma fila de requisições. Dê a cada um um Configuration(storage_dir=...), ou execute um crawler por processo.
Q: Preciso mudar para PlaywrightCrawler para páginas JavaScript?
Não. O PlaywrightCrawler é uma opção, mas muda a classe do crawler e o contexto que seu manipulador recebe. Implementar a interface HttpClient do Crawlee muda apenas como os bytes são recuperados, que é por isso que o manipulador neste guia passou de 0 itens para 10 sem uma edição.
Q: Onde o Crawlee escreve sua saída?
Por padrão, em ./storage, com conjuntos de dados em storage/datasets/. crawler.get_data() lê o conjunto de dados de volta no mesmo processo, e Configuration(storage_dir=...) move toda a árvore para outro lugar.
Na Scorretless, acessamos apenas dados disponíveis ao público, enquanto cumprem estritamente as leis, regulamentos e políticas de privacidade do site aplicáveis. O conteúdo deste blog é apenas para fins de demonstração e não envolve atividades ilegais ou infratoras. Não temos garantias e negamos toda a responsabilidade pelo uso de informações deste blog ou links de terceiros. Antes de se envolver em qualquer atividade de raspagem, consulte seu consultor jurídico e revise os termos de serviço do site de destino ou obtenha as permissões necessárias.



