---
title: "Como criar um web crawler em Python, passo a passo"
description: "Um web crawler em Python enfileira os links que acha a partir da URL inicial e visita cada página uma vez. Montamos fila, normalização, profundidade e escopo."
url: https://proxynet.io/pt-br/blog/python-web-crawler
date: 2026-09-25
author: "Acar Diveroli"
category: "Web scraping, Tutoriais"
lang: pt-BR
---

# Como criar um web crawler em Python, passo a passo

Você precisa de uma lista de todas as páginas de categoria e de produto de uma livraria on-line que não tem sitemap. Nosso alvo de teste é o books.toscrape.com, um sandbox criado para praticar scraping. Um crawler que segue todos os links sem lembrar por onde já passou nunca termina aqui: a maioria dos links aponta de volta para páginas que ele já encontrou. Na nossa medição, 3.515 dos 4.098 links das primeiras 60 páginas apontavam para endereços que o crawler já conhecia, e 524 endereços novos ainda esperavam na fila.

A seguir montamos um crawler com Requests e BeautifulSoup, sem framework: fila, conjunto de vistos, normalização de URL, escopo, limite de profundidade, robots.txt, pausas e saída em JSON Lines. O que é um crawler e como ele se diferencia de um scraper está na nossa página de [web crawler](/pt-br/web-crawler) e em [Web scraping vs. web crawling](/pt-br/blog/web-scraping-vs-web-crawling). Todos os números vêm de execuções do script final em 25 de setembro de 2026.

> **Nota: Resposta rápida**
>
> Para criar um web crawler em Python, baixe cada página com Requests, pegue os links `a[href]` com BeautifulSoup, torne-os absolutos com `urljoin` e normalize-os. Os links do mesmo host entram em um `collections.deque` como pares `(url, depth)`. Marcar um endereço como visto no momento em que ele entra na fila evita downloads duplicados; um limite de profundidade e um teto de páginas fazem o rastreamento terminar. Verifique o robots.txt uma vez por host, faça pausas entre as requisições, pare diante de um `429` e grave cada página como uma linha JSON.

## Quais partes um web crawler em Python precisa ter?

Seis partes, cada uma com poucas linhas de Python:

- **Fronteira (frontier):** a fila de endereços que ainda serão visitados, um `collections.deque`.
- **Conjunto de vistos (seen set):** todos os endereços que o crawler encontrou até agora.
- **Fetcher:** uma `requests.Session` com timeout e um `User-Agent` honesto, que informa o nome do bot e um contato ([O que é User-Agent?](/pt-br/blog/what-is-user-agent)).
- **Extrator de links:** `select("a[href]")`. Ler o resto da página é trabalho de um scraper ([O que é o BeautifulSoup](/pt-br/blog/beautifulsoup-tutorial)).
- **Filtro de escopo:** host, esquema e tipo de conteúdo decidem o que conta.
- **Saída:** um objeto JSON por página em um arquivo `.jsonl`.

Usamos Requests 2.34.2 e Beautiful Soup 4.15.0, as versões atuais no PyPI em 25 de setembro de 2026; o Requests exige Python 3.10 ou mais recente. O Scrapy traz as seis partes prontas.

## Como funciona o laço de rastreamento, passo a passo?

1. **Semente.** Normalize o endereço inicial, coloque `(url, 0)` na fila e o endereço no conjunto de vistos.
2. **Retire.** `popleft()` pega a entrada mais antiga.
3. **Consulte o robots.txt.** Leia as regras do host a partir de um cache, baixando-as uma única vez.
4. **Espere e baixe.** Respeite o intervalo por host e então envie uma requisição GET.
5. **Confira a resposta.** Leia o endereço final depois dos redirecionamentos e o cabeçalho `Content-Type`.
6. **Extraia e filtre.** Resolva cada `href` com `urljoin`, normalize-o e descarte o que já foi visto, o que está fora do site ou o que é profundo demais.
7. **Registre e enfileire.** Grave uma linha JSON e acrescente os endereços novos com `depth + 1`.

O laço termina quando a fila esvazia ou quando o teto de páginas é atingido.

## Qual parte evita qual problema?

| Parte | O que evita | Em Python | Sem ela |
|---|---|---|---|
| Conjunto de vistos, preenchido ao enfileirar | Baixar a mesma página duas vezes | `set`, adicionado ao enfileirar | Os 3.515 links conhecidos da nossa execução no books baixados de novo |
| Limite de profundidade e teto de páginas | Cadeias sem fim, como calendários e filtros | `(url, depth)`, `--depth`, `--max-pages` | Uma fila que não para de crescer (524 à espera depois de 60 páginas) |
| Normalização de URL | A mesma página com várias grafias | `urldefrag`, `urlsplit`, `parse_qsl` | A mesma página é baixada e salva mais de uma vez |
| Verificação de redirecionamento e de tipo de conteúdo | Escapar para outro site, analisar um PDF como HTML | `r.url`, `Content-Type`, `stream=True` | Links de páginas de outros sites entram na fila |
| Cache do robots.txt e pausa por host | Caminhos proibidos, rajadas de requisições | `RobotFileParser.parse()`, `time.monotonic()` | O site responde `429` |

## Por que a fila deve ser um deque e não uma lista?

`deque.popleft()` pega o endereço mais antigo, então o rastreamento é feito em largura (BFS): a página inicial, depois as páginas a um clique de distância, depois as que estão a dois. As páginas próximas da home chegam primeiro, e um limite de profundidade faz sentido porque a profundidade cresce em ordem. Usar `pop()` na mesma ponta em que você acrescenta dá uma busca em profundidade (DFS): um ramo até o fundo antes do próximo.

Uma lista pode servir de fila, mas `pop(0)` desloca todos os itens restantes (O(n)), enquanto um deque retira itens nas duas pontas em tempo praticamente constante ([documentação do collections](https://docs.python.org/pt-br/3/library/collections.html)). Crawlers recursivos são DFS disfarçados e podem parar no limite padrão do Python de 1.000 chamadas aninhadas com um `RecursionError`.

A BFS também encontra cada endereço pela primeira vez na sua menor profundidade, então um link profundo demais já no primeiro encontro pode ser marcado como visto e esquecido.

## Como escrever a normalização de URL em código?

O conjunto de vistos compara strings, então `normalize()` dá a cada página uma única grafia:

- `urldefrag()` remove `#reviews`. Pela seção 3.5 da [RFC 3986](https://www.rfc-editor.org/rfc/rfc3986.html), o fragmento nunca chega ao servidor.
- O esquema e o host ficam em minúsculas; `urlsplit().hostname` já vem assim.
- As portas padrão (`:80` para http, `:443` para https) são removidas, e um caminho vazio vira `/`, o que a seção 6.2.3 da mesma RFC trata como equivalente.
- Os parâmetros de tracking (`utm_*`, `gclid`, `fbclid`) são removidos e os demais são ordenados, para que `?b=2&a=1` e `?a=1&b=2` coincidam.
- Links `mailto:`, `tel:` e `javascript:` e portas quebradas retornam `None`.

Nunca apague a query string inteira: `?page=2` é outra página. As barras finais também ficam, porque `/a` e `/a/` podem ser páginas diferentes; um redirecionamento avisa quando não são. No books.toscrape.com, `/` e `/index.html` serviam a mesma página, algo que só uma regra específica para esse site conseguiria unir.

O conjunto de vistos também ignora o esquema. No quotes.toscrape.com, as páginas de autor redirecionam de https para http, e os links relativos delas passam a apontar para `http://quotes.toscrape.com/`; por isso, nossa primeira execução de teste baixou a home e a página de login duas vezes. `page_key()` descarta o esquema, então as duas grafias contam como uma página só.

Os sandboxes não têm fragmentos nem parâmetros de tracking, então estas verificações usam entradas inventadas:

```python
from crawler import normalize

assert normalize("https://Example.COM:443/a#reviews") == "https://example.com/a"
assert normalize("http://example.com") == "http://example.com/"
assert normalize("https://example.com:8443/a") == "https://example.com:8443/a"
assert normalize("https://example.com/list?b=2&a=1") == "https://example.com/list?a=1&b=2"
assert normalize("https://example.com/list?utm_source=mail&page=2") == "https://example.com/list?page=2"
assert normalize("mailto:shop@example.com") is None
assert normalize("javascript:void(0)") is None
assert normalize("https://example.com:abc/") is None
assert normalize("https://example.com/a/") != normalize("https://example.com/a")
print("all normalize checks passed")
```

## Escopo: mesmo host, redirecionamentos e tipo de conteúdo

`self.hosts` guarda só o host inicial. Se um site usa tanto `www.example.com` quanto `example.com`, adicione os dois à mão; uma verificação como `endswith("example.com")` deixaria entrar também `notexample.com`.

O Requests segue redirecionamentos em todos os métodos, exceto HEAD, e guarda o endereço final em `r.url` ([quickstart do Requests](https://requests.readthedocs.io/en/latest/user/quickstart/)), então verifique o escopo em `r.url` e resolva os links relativos a partir dele. No quotes.toscrape.com, `/author/Jane-Austen` redireciona para `http://quotes.toscrape.com/author/Jane-Austen/`; na nossa execução com profundidade 2, 40 de 149 páginas chegaram assim. Um redirecionamento para fora do site é descartado, mas o Requests já baixou o destino. Para evitar até essa requisição, passe `allow_redirects=False` e enfileire você mesmo o endereço do cabeçalho `Location`.

Com `stream=True`, o Requests retorna assim que os cabeçalhos chegam. Se o `Content-Type` não é `text/html`, a conexão é fechada sem que o corpo seja lido.

## Como definir um limite de profundidade e um teto de páginas?

As entradas da fila são `(url, depth, attempts)`. Um link cujo `depth + 1` passaria de `--depth` conta como `too_deep` e fica de fora; `--max-pages` encerra a execução, não importa o que a fila contenha. Nos sandboxes:

- **quotes.toscrape.com, profundidade 2, teto de 500:** a fila esvaziou sozinha depois de 149 páginas (1, 46 e 102 por profundidade); 30 endereços estavam profundos demais.
- **O mesmo site, teto de 60:** a execução parou em 60 páginas com 89 endereços ainda na fila.
- **books.toscrape.com, profundidade 2, teto de 60:** as 60 páginas vieram das profundidades 0 e 1, com 524 à espera. Quem encerrou esta execução foi o teto, não a profundidade.

Escolha a profundidade pela estrutura do site (home, categoria e item dão profundidade 2; cada página de listagem a mais soma um) e o teto pelo seu orçamento. Os links plantados para pegar bots estão em [O que são armadilhas honeypot](/pt-br/blog/honeypot-traps).

## robots.txt e pausas: a versão curta

O crawler mantém um `RobotFileParser` por esquema e host, baixa o robots.txt com a própria sessão (timeout, `User-Agent` do bot, proxy) e passa as linhas para `parse()`. Evitamos `read()`: no Python 3.13.9 ele usa o `urllib` sem timeout e com o `User-Agent` do próprio urllib, e levanta exceção em erros de rede. Um `5xx` ou um arquivo inacessível significa não rastrear nada, como exige a [RFC 9309](https://www.rfc-editor.org/rfc/rfc9309.html); um `4xx` significa que não há regras (os dois sandboxes devolveram `404`). Sintaxe: [O que é robots.txt](/pt-br/blog/robots-txt).

Antes de cada requisição, o crawler confere se já passou, desde a última requisição àquele host, o `--delay` (1 segundo por padrão) ou o `Crawl-delay` do site, o que for maior. Com `Crawl-delay: 2` em um site de teste local e `--delay 0.2`, o servidor registrou intervalos de 2 segundos. Diante de um `429`, o crawler para de enviar requisições ao host e imprime o `Retry-After`. Como repetir as tentativas do jeito certo está em [Códigos de status HTTP no web scraping](/pt-br/blog/http-status-codes-web-scraping), e essas verificações com uma fila em SQLite, em [O que é paginação](/pt-br/blog/pagination-web-scraping).

## Código completo: um web crawler em Python sem framework

Instale as duas bibliotecas em um ambiente virtual, salve o script como `crawler.py` e passe um endereço inicial:

```bash
pip install requests==2.34.2 beautifulsoup4==4.15.0
python crawler.py https://books.toscrape.com/ --depth 2 --max-pages 60
```

```python
"""Um crawler em largura para um único site: Requests + BeautifulSoup, sem framework."""
import argparse
import json
import time
from collections import Counter, deque
from urllib.parse import parse_qsl, urldefrag, urlencode, urljoin, urlsplit, urlunsplit
from urllib.robotparser import RobotFileParser

import requests
from bs4 import BeautifulSoup

BOT_NAME = "ExampleSiteMapper"
USER_AGENT = f"{BOT_NAME}/1.0 (+https://example.com/bot; bot@example.com)"
TIMEOUT = 15      # segundos; sem timeout, o Requests pode esperar para sempre
MAX_RETRIES = 2   # uma URL volta para o fim da fila no máximo duas vezes
TRACKING = {"utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content", "gclid", "fbclid"}
DEFAULT_PORTS = {"http": 80, "https": 443}

def normalize(url):
    """Uma grafia por página, ou None para links que um crawler não deve seguir."""
    url, _fragment = urldefrag(url.strip())
    parts = urlsplit(url)
    scheme = parts.scheme.lower()
    try:
        port = parts.port
    except ValueError:  # uma porta quebrada, como ":abc"
        return None
    if scheme not in DEFAULT_PORTS or not parts.hostname:
        return None  # mailto:, tel:, javascript:, ftp:, ...
    host = parts.hostname  # já vem em minúsculas
    if port and port != DEFAULT_PORTS[scheme]:
        host = f"{host}:{port}"
    query = sorted((k, v) for k, v in parse_qsl(parts.query, keep_blank_values=True)
                   if k not in TRACKING)
    return urlunsplit((scheme, host, parts.path or "/", urlencode(query), ""))

def page_key(url):
    """A chave do conjunto de vistos: http:// e https:// do mesmo endereço contam como uma página."""
    return url.split("://", 1)[1]

class Crawler:
    def __init__(self, start, max_depth, max_pages, delay, out, proxy=None):
        self.start = normalize(start)
        self.hosts = {urlsplit(self.start).hostname}  # adicione aqui um gêmeo "www." de propósito
        self.max_depth, self.max_pages, self.delay, self.out = max_depth, max_pages, delay, out
        self.queue = deque([(self.start, 0, 0)])  # (url, depth, attempts)
        self.seen = {page_key(self.start)}  # marcada quando a URL aparece pela primeira vez, não quando é baixada
        self.robots, self.last, self.stopped = {}, {}, {}
        self.stats, self.depths = Counter(), Counter()
        self.session = requests.Session()
        self.session.headers["User-Agent"] = USER_AGENT
        # por requisição: session.proxies perderia para as variáveis de ambiente HTTP(S)_PROXY
        self.proxies = {"http": proxy, "https": proxy} if proxy else None

    def in_scope(self, url):
        return urlsplit(url).hostname in self.hosts

    def fetch(self, url):
        """GET com pausa por host. stream=True lê os cabeçalhos antes do corpo."""
        parts = urlsplit(url)
        rules = self.robots.get(f"{parts.scheme}://{parts.netloc}")
        gap = max(self.delay, (rules.crawl_delay(BOT_NAME) if rules else None) or 0)
        pause = self.last.get(parts.netloc, float("-inf")) + gap - time.monotonic()
        if pause > 0:
            time.sleep(pause)
        try:
            return self.session.get(url, timeout=TIMEOUT, stream=True, proxies=self.proxies)
        finally:
            self.last[parts.netloc] = time.monotonic()  # por host: http e https compartilham

    def robots_ok(self, url):
        root = "{0.scheme}://{0.netloc}".format(urlsplit(url))
        if root not in self.robots:
            rules = RobotFileParser()
            try:
                with self.fetch(root + "/robots.txt") as r:
                    status = r.status_code
                    rules.parse(r.text.splitlines() if status == 200 else [])
            except requests.RequestException:
                status = None
                rules.parse([])
            if status is None or status >= 500:
                rules.disallow_all = True  # robots.txt inacessível: não rastrear nada neste host
            self.robots[root] = rules
        return self.robots[root].can_fetch(BOT_NAME, url)

    def extract_links(self, html, base):
        soup = BeautifulSoup(html, "html.parser")
        title = " ".join(soup.title.get_text().split()) if soup.title else ""
        return title, [urljoin(base, a["href"]) for a in soup.select("a[href]")]

    def enqueue(self, links, depth):
        for link in links:
            self.stats["raw_links"] += 1
            url = normalize(link)
            if url is None:
                self.stats["not_http"] += 1
                continue
            if page_key(url) in self.seen:
                self.stats["duplicate"] += 1
                continue
            self.seen.add(page_key(url))  # a BFS encontra cada URL primeiro na sua menor profundidade
            if not self.in_scope(url):
                self.stats["offsite"] += 1
            elif depth + 1 > self.max_depth:
                self.stats["too_deep"] += 1
            else:
                self.queue.append((url, depth + 1, 0))
                self.stats["queued"] += 1

    def retry(self, url, depth, attempts, why):
        if attempts < MAX_RETRIES:
            self.queue.append((url, depth, attempts + 1))
            self.stats["retried"] += 1
        else:
            self.stats["failed"] += 1
            print(f"giving up on {url}: {why}")

    def run(self):
        started = time.monotonic()
        with open(self.out, "w", encoding="utf-8", newline="\n") as out:  # JSON Lines: \n, sem BOM
            while self.queue and self.stats["fetched"] < self.max_pages:
                url, depth, attempts = self.queue.popleft()
                host = urlsplit(url).netloc
                if host in self.stopped:
                    self.stats["skipped_stopped_host"] += 1
                    continue
                if not self.robots_ok(url):
                    self.stats["robots_disallowed"] += 1
                    continue
                try:
                    r = self.fetch(url)
                except requests.RequestException as exc:
                    self.retry(url, depth, attempts, type(exc).__name__)
                    continue
                with r:
                    if r.status_code == 429:
                        self.stopped[host] = r.headers.get("Retry-After", "not sent")
                        print(f"429 from {host}, host stopped (Retry-After: {self.stopped[host]})")
                        continue
                    if r.status_code >= 500:
                        self.retry(url, depth, attempts, f"HTTP {r.status_code}")
                        continue
                    final = normalize(r.url)
                    moved = page_key(final) != page_key(url)  # não é só http -> https
                    if not self.in_scope(final) or (moved and page_key(final) in self.seen):
                        self.stats["redirect_skipped"] += 1  # saiu do site, ou é uma página conhecida
                        continue
                    is_html = "text/html" in r.headers.get("Content-Type", "")
                    try:  # o corpo é baixado aqui, e só para HTML
                        html = r.content if r.status_code == 200 and is_html else b""
                    except requests.RequestException as exc:  # a conexão caiu no meio do corpo
                        self.retry(url, depth, attempts, type(exc).__name__)
                        continue
                    if final != url:
                        self.stats["redirected"] += 1
                        self.seen.add(page_key(final))
                    self.stats["fetched"] += 1
                    self.depths[depth] += 1
                    title, links = "", []
                    if html:
                        self.stats["html_bytes"] += len(html)  # para estimar o tráfego
                        title, links = self.extract_links(html, r.url)
                    elif not is_html:
                        self.stats["not_html"] += 1  # o corpo nunca foi baixado
                    record = {"url": url, "final_url": final, "depth": depth,
                              "status": r.status_code, "title": title, "links_found": len(links)}
                    out.write(json.dumps(record, ensure_ascii=False) + "\n")
                    print(f"{r.status_code} d{depth} {url}")
                    self.enqueue(links, depth)
        print(f"\n{self.stats['fetched']} pages in {time.monotonic() - started:.1f} s,"
              f" by depth {dict(sorted(self.depths.items()))}, left in queue {len(self.queue)}")
        for key, value in sorted(self.stats.items()):
            print(f"  {key:<21}{value}")

if __name__ == "__main__":
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("start", help="start URL, e.g. https://books.toscrape.com/")
    ap.add_argument("--depth", type=int, default=2, help="link hops from the start URL")
    ap.add_argument("--max-pages", type=int, default=100, help="hard ceiling on fetched pages")
    ap.add_argument("--delay", type=float, default=1.0, help="minimum seconds between requests to one host")
    ap.add_argument("--out", default="pages.jsonl")
    ap.add_argument("--proxy", help="e.g. http://user:pass@pr.proxynet.io:8000")
    args = ap.parse_args()
    Crawler(args.start, args.depth, args.max_pages, args.delay, args.out, args.proxy).run()
```

Três detalhes importam quando você altera o código:

- **As novas tentativas vão para o fim da fila,** no máximo duas vezes, depois de um erro de conexão ou de um `5xx`. Uma página de teste local que respondeu `500` duas vezes devolveu `200` na terceira tentativa. Novas tentativas dentro do próprio Requests: [Max Retries Exceeded With URL](/pt-br/blog/max-retries-exceeded-with-url).
- **O arquivo é JSON Lines puro.** `encoding="utf-8"` não grava a marca de ordem de bytes (BOM) e `newline="\n"` mantém os finais de linha `\n` que o [JSON Lines](https://jsonlines.org/) pede; sem isso, o Windows grava `\r\n`. `ensure_ascii=False` mantém legível o texto fora do ASCII. Um título com letras turcas passou pela gravação e pela leitura sem perdas ([Erros de codificação no Python](/pt-br/blog/python-unicode-encoding-errors)).
- **Só GET.** O crawler abre `/login` como qualquer outra página e nunca envia um formulário.

### O que vimos ao rodar o script

Usamos Python 3.13.9, o intervalo padrão de um segundo e profundidade 2. No quotes.toscrape.com, com teto de 500:

```text
149 pages in 179.1 s, by depth {0: 1, 1: 46, 2: 102}, left in queue 0
  duplicate            2916
  fetched              149
  html_bytes           722227
  offsite              2
  queued               148
  raw_links            3096
  redirected           40
  too_deep             30
```

No books.toscrape.com, com teto de 60:

```text
60 pages in 69.8 s, by depth {0: 1, 1: 59}, left in queue 524
  duplicate            3515
  fetched              60
  html_bytes           2047561
  queued               583
  raw_links            4098
```

No quotes, 94% dos links apontavam para endereços conhecidos, e só 2 endereços distintos ficavam fora do site. Duas linhas da saída, uma simples e uma redirecionada:

```json
{"url": "https://books.toscrape.com/", "final_url": "https://books.toscrape.com/", "depth": 0, "status": 200, "title": "All products | Books to Scrape - Sandbox", "links_found": 94}
{"url": "https://quotes.toscrape.com/author/Jane-Austen", "final_url": "http://quotes.toscrape.com/author/Jane-Austen/", "depth": 1, "status": 200, "title": "Quotes to Scrape", "links_found": 4}
```

Um pequeno site local cobriu os ramos de erro. O crawler pulou um caminho proibido, três links que não eram web e um redirecionamento para outro host, fechou um PDF sem lê-lo e registrou um `404`. Um `429` com `Retry-After: 120` fez o crawler parar naquele host, e um `503` no robots.txt fez com que nada fosse baixado.

## Onde entra o proxy no crawler?

`--proxy http://user:pass@pr.proxynet.io:8000` é passado em cada requisição, inclusive na do robots.txt. O código não define `session.proxies`, porque a documentação do Requests avisa que as variáveis de ambiente de proxy se sobrepõem a ele ([uso avançado](https://requests.readthedocs.io/en/latest/user/advanced/#proxies)). Através de um proxy de teste local com senha, as 10 primeiras páginas do books deram as mesmas linhas que sem proxy, mesmo com um proxy fora do ar em `HTTPS_PROXY`.

Um único site a uma requisição por segundo raramente precisa de proxy. Ele ajuda quando o rastreamento cobre muitos hosts e o tráfego não deve sair todo de um só endereço: um [Proxies rotativos](https://proxynet.io/pt-br/rotating-proxy) dá a cada requisição ou host um IP de saída diferente. Para uma lista curta e fixa de alvos, um [Proxies de datacenter](https://proxynet.io/pt-br/datacenter-proxy) costuma bastar; os IPs dele vêm por padrão com restrição de site de destino, e o acesso a todos os sites é uma opção na hora do pedido. O código de rotação está em [Como rotacionar proxies em Python](/pt-br/blog/how-to-rotate-proxies-in-python).

O tráfego residencial rotativo é cobrado por GB, então meça `html_bytes` primeiro em uma execução curta. Os dois sandboxes enviaram HTML sem compressão, cerca de 34 KB por página do books e 5 KB por página do quotes: 10.000 páginas dão aproximadamente 0,34 GB ou 0,05 GB, mais os cabeçalhos. A pausa e o robots.txt valem para todos os IPs de saída, e a rotação não é motivo para mandar a um site mais requisições do que ele permite.

## Casos de uso

- **Links internos quebrados:** filtre a saída pelo status `404` ([web crawler](/pt-br/web-crawler)).
- **Uma lista de URLs antes do scraping:** rastreie primeiro e depois faça scraping só das páginas de produto ou de artigo ([extração de dados](/pt-br/data-scraping)).
- **Produtos novos na concorrência:** compare a lista de URLs desta semana com a da semana passada ([Como monitorar preços da concorrência no e-commerce](/pt-br/blog/competitor-price-tracking)).
- **Lacunas do sitemap:** páginas que o crawler encontra, mas que não estão no sitemap ([Como encontrar o sitemap de um site](/pt-br/blog/find-website-sitemap)).
- **Imagens de páginas conhecidas:** liste as páginas e depois baixe ([Como baixar todas as imagens de um site](/pt-br/blog/download-all-images-from-website)).
- **Campos das páginas encontradas:** títulos e preços do mesmo HTML ([O que é o BeautifulSoup](/pt-br/blog/beautifulsoup-tutorial)).

## Erros comuns

- **`list.pop(0)` como fila.** Cada chamada desloca a lista inteira.
- **Recursão para cada link.** O rastreamento vira uma busca em profundidade e pode terminar em `RecursionError`.
- **Marcar URLs como vistas só depois de baixá-las.** O mesmo endereço entra na fila muitas vezes.
- **Apagar a query string inteira.** `?page=2` e todas as páginas seguintes desaparecem.
- **Resolver links a partir da URL pedida.** Depois de um redirecionamento, a base é `r.url`.
- **Sem timeout.** Sem ele, o Requests pode travar indefinidamente.
- **Analisar arquivos PDF e ZIP como HTML.** Verifique o `Content-Type` antes.
- **Sem teto de páginas.** Em um site com filtros ou calendário, a execução nunca termina.
- **Preencher formulários.** Um crawler lê páginas com GET; enviar dados é outro trabalho ([POST com JSON no Python Requests](/pt-br/blog/python-requests-post-json)).

## Guia de decisão

| Necessidade | Recomendação |
|---|---|
| Algumas centenas de páginas de um site, com o mecanismo à vista | O script deste artigo |
| Retomar um rastreamento longo depois que ele para | Uma fila em disco: a versão com SQLite em [O que é paginação](/pt-br/blog/pagination-web-scraping) |
| Muitas requisições ao mesmo tempo | Concorrência, dimensionada como em [Concorrência e paralelismo no web scraping](/pt-br/blog/concurrency-vs-parallelism) |
| Milhares de páginas, com novas tentativas e exportação prontas | Scrapy ([CrawlSpider](/pt-br/blog/web-scraping-vs-web-crawling), [configuração de proxy](/pt-br/blog/scrapy-proxy)) |
| Links que só aparecem depois que o JavaScript roda | Um crawler baseado em navegador, como o PlaywrightCrawler do Crawlee for Python, ou o [Crawl4AI](/pt-br/blog/crawl4ai-proxy) |
| O site publica um sitemap | Leia o sitemap primeiro e rastreie só o que falta nele ([Como encontrar o sitemap de um site](/pt-br/blog/find-website-sitemap)) |
| Um rastreamento espalhado por muitos hosts | Um [Proxies rotativos](https://proxynet.io/pt-br/rotating-proxy) no mesmo ritmo educado por host |

## Perguntas frequentes

### Qual biblioteca Python devo usar para um web crawler?

Para algumas centenas de páginas, Requests para baixar e BeautifulSoup para ler os links bastam. Para milhares de páginas com novas tentativas, exportação e agendamento, o Scrapy poupa você de escrever esse código. Links montados por JavaScript exigem uma ferramenta baseada em navegador.

### Dá para criar um crawler com BeautifulSoup ou preciso do Scrapy?

Dá. O BeautifulSoup é um parser: ele lê links do HTML, mas não baixa páginas, não mantém uma fila nem espera entre as requisições. O script acima escreve essas partes em menos de 200 linhas. O Scrapy já as traz prontas, o que compensa em trabalhos maiores.

### Um crawler em Python vê os links carregados com JavaScript?

Não. O Requests não executa scripts, então os links adicionados por JavaScript nunca chegam ao BeautifulSoup. Primeiro procure, por trás da página, uma requisição JSON que você possa chamar diretamente. Se não houver, use um crawler baseado em navegador, desde que os termos do site permitam automação.

### Devo tornar o crawler multithread?

Só quando você rastreia vários hosts. Em um único site, quem define a velocidade é a pausa entre as requisições, e threads extras só ficariam esperando ou quebrariam o intervalo. Com muitos hosts, um worker educado por host ajuda ([Concorrência e paralelismo no web scraping](/pt-br/blog/concurrency-vs-parallelism)).

### Como encontrar todos os links de um site com Python?

Comece pelo [sitemap](/pt-br/blog/find-website-sitemap), que costuma estar indicado no robots.txt ou em `/sitemap.xml`. Se não houver, rode um crawler como este com limite de profundidade e teto de páginas, e trate o resultado como o conjunto de páginas que os links alcançam, não como uma lista completa.

### Criar e rodar um web crawler é legal?

Isto não é aconselhamento jurídico. A resposta depende do seu país, dos termos do site, de as páginas conterem ou não dados pessoais e do que você faz com as cópias. Obedeça ao robots.txt, mantenha o ritmo baixo e fique fora das áreas que exigem login. País por país: [Web scraping é legal?](/pt-br/blog/is-data-web-scraping-legal).

## Em resumo

Um crawler que termina sem se repetir precisa de um deque para a ordem em largura, de um conjunto de vistos preenchido na hora de enfileirar, de uma grafia por URL, de uma verificação de escopo depois dos redirecionamentos e de um limite de profundidade com teto de páginas. O robots.txt, uma pausa por host e a parada diante de um `429` entram já na primeira versão. Quando o trabalho crescer, passe para uma fila em disco, concorrência entre hosts ou Scrapy. Para rastreamentos que cobrem muitos sites ou países, compare as opções na nossa página de [serviços de proxy](/pt-br/proxy).
