O que é paginação e como percorrer todas as páginas?

Publicado:

22 min de leitura

Acar Diveroli
Autor: Acar Diveroli
A grade afunila em profundidade: sobre o funil, o cubo de fila azul e, à esquerda, a escala da página em que parou

O script que você escreveu para baixar um catálogo de mil produtos lê sem problemas os vinte produtos da primeira página. O trabalho de verdade começa depois: achar os endereços das 49 páginas restantes, perceber que a lista acabou, não salvar o mesmo produto duas vezes e não recomeçar do zero quando a conexão cai na página 31. A diferença entre o código que lê uma página e o que reúne a lista inteira chama-se paginação.

Neste artigo definimos a paginação em poucas linhas e depois olhamos para ela do lado de quem coleta: como reconhecer os cinco tipos nas ferramentas de desenvolvedor, de onde vem a próxima página, quando a coleta para, como filtrar os registros repetidos. No centro há uma fila de URLs sobre SQLite; uma coleta que matamos no meio do processo continuou, graças a essa fila, da página em que tinha parado. Rodamos o código nos sites de treino books.toscrape.com e quotes.toscrape.com.

O que é paginação?

Se um banco de dados tem dez mil registros, o servidor não manda todos em uma resposta só. A consulta fica pesada e a resposta incha com milhares de linhas que ninguém vai olhar. Em vez disso, a lista é dividida em pedaços de tamanho fixo e o cliente pede um pedaço de cada vez. Os links "1 2 3 ... 50" no rodapé de uma página de categoria, o parâmetro ?page=2 de uma API e os feeds que carregam conforme você desce são faces diferentes da mesma ideia.

Quem projeta a paginação pergunta qual método cansa menos o banco de dados. A pergunta de quem coleta é outra: qual método este site escolheu e como eu percebo isso de fora? O quadro geral da coleta em massa está na nossa página de extração de dados, e o desenho dos rastreadores que vão de link em link, na de web crawler.

Quais são os tipos de paginação e como reconhecê-los?

O caminho para reconhecer é o mesmo em todos os casos: abra as ferramentas de desenvolvedor no navegador, limpe a aba Rede, passe para a segunda página e veja o que mudou. A barra de endereços mudou ou saiu uma requisição em segundo plano?

1. URL com número de página. O endereço muda para ?page=2, /page/2/ ou page-2.html. É o tipo mais fácil de reconhecer e o laço também é simples: você aumenta o número. O ponto fraco é que quase nunca se sabe qual é a última página.

2. Link "próxima". No rodapé da página há um elemento <a> apontando para a página seguinte. Você não gera o endereço, você o lê da página. Se não há link, a lista acabou. Seu código não quebra quando o site muda a estrutura de endereços; em listas HTML essa é a primeira escolha. A lógica dos seletores explicamos no artigo Seletor CSS e XPath.

3. offset/limit. Na requisição à API você vê dois parâmetros como offset=40&limit=20, ou skip e take: "pule os primeiros 40 registros e me dê 20". É o equivalente do número de página na API.

4. Cursor. Na resposta chega uma string de aparência sem sentido como next_cursor, after ou next_page_token, e na requisição seguinte você devolve essa string sem alterar. O cursor carrega a informação "o último registro que entreguei foi este"; você não tenta decifrá-lo, apenas o transporta.

5. Rolagem infinita e "carregar mais". A barra de endereços não muda. Quando você chega ao fim da página ou aperta o botão, aparece uma nova requisição Fetch/XHR na aba Rede. Ao olhar essa requisição, quase sempre você vê um dos três tipos acima. A rolagem infinita não é um método à parte, é uma interface montada em cima da paginação de uma API.

A isso é preciso somar a marca rel="next". Ela aparece em dois lugares. O primeiro é o elemento <link rel="next" href="..."> no <head> do HTML. O Google escreve claramente que não usa mais essa tag, mas muitos sites continuam imprimindo, e quando você a encontra ela é uma cópia limpa do link "próxima". O segundo é o cabeçalho Link da resposta HTTP; o formato é definido pela RFC 8288, e serviços como o GitHub entregam nesse cabeçalho o endereço completo da próxima página.

TipoComo reconhecerDe onde vem a próxima páginaCondição de paradaArmadilha
Número de páginapage=2, /page/2/ no endereçoVocê aumenta o número404, lista vazia ou conteúdo repetidoO que acontece depois da última página varia por site
Link "próxima"<a> no rodapé, rel="next" no <head>Lido da páginaNão há linkEsquecer de transformar o endereço relativo em absoluto
offset/limitoffset, limit, skip na requisição à APIoffset += limitPedaço incompleto ou vazioOs registros deslizam enquanto a lista muda
Cursornext_cursor, after, um token na respostaTransportado da resposta sem alterarCursor vazio ou ausenteO cursor expira e não dá para começar pelo meio
Rolagem infinita, "carregar mais"O endereço não muda, sai uma requisição XHRA regra da API que está por baixoA regra da API ou a ausência de cartões novosRolar com o navegador sai caro sem necessidade

De que passos é feito um laço de paginação?

Seja qual for o tipo, o laço segue os mesmos seis passos:

  1. Coloque o endereço inicial na fila. A página de categoria ou a primeira requisição da API.
  2. Pegue o próximo endereço e verifique se ele é permitido. Se o robots.txt proíbe aquele caminho, a requisição nem sai.
  3. Espere e então envie a requisição. Deixe um intervalo fixo entre duas requisições ao mesmo site.
  4. Extraia os registros. Dê a cada registro uma chave que o identifique de forma única.
  5. Encontre a próxima página. Link, número, offset ou cursor.
  6. Grave juntos os registros, o próximo endereço e a marca "esta página terminou". Depois volte ao passo dois.

A palavra "juntos" do sexto passo é o assunto do resto do artigo. Primeiro, a condição de parada.

Quando a coleta deve parar?

Perceber que a lista acabou é mais difícil do que parece, porque os sites se comportam de formas diferentes depois da última página. Nos sites de treino vimos três comportamentos distintos lado a lado. O books.toscrape.com tem 50 páginas e a requisição de page-51.html devolve 404. Já quotes.toscrape.com/page/11/ devolve com código 200 uma página sem nenhuma citação dentro. A API JSON do mesmo site escreve "has_next": false na última página e, se você pedir a página 11, recebe uma lista vazia. Em sites reais um quarto comportamento é mais comum: mostrar em silêncio de novo a primeira ou a última página quando o número está fora do intervalo.

Por isso não confie em uma única condição, use várias juntas:

  • Sinal explícito: não há link "próxima", has_next é falso, o cursor está vazio, no cabeçalho Link não há rel="next".
  • Pedaço vazio ou incompleto: a página não tem nenhum registro, ou chegam menos registros que o valor de limit.
  • Nenhum registro novo: todos os registros da página já foram vistos. É a condição que pega os sites que mostram a última página repetidas vezes.
  • Limite superior: um número de páginas que não será ultrapassado de jeito nenhum. Um link "próxima" quebrado ou um cursor que se repete não conseguem jogar seu script num laço infinito.
  • Número total: se a API dá total ou total_pages, use para conferir, não para parar.

Ao avançar por número de página, um 404 pode significar tanto "a lista acabou" quanto "a estrutura de endereços mudou". Se você recebe 404 logo na primeira página, é a segunda hipótese.

Como filtrar os registros repetidos?

O mesmo registro chegar duas vezes durante a paginação não é um defeito, é o esperado. A causa mais frequente é a lista mudar enquanto você a percorre. Se, numa lista ordenada por "mais recentes", cinco produtos novos entram no topo justo quando você lê a terceira página, todos os registros descem cinco posições e os cinco primeiros registros da quarta página são os que você acabou de ver. Se um registro é apagado acontece o contrário: um sobe uma posição e você nunca chega a vê-lo. offset/limit e número de página estão sujeitos a esse deslize; o cursor não, porque diz "os que vêm depois deste registro". Produtos patrocinados e produtos listados em duas categorias também geram repetição.

A solução é filtrar a repetição no banco de dados, não no código:

  • Dê a cada registro uma chave estável. O identificador do produto, o endereço do produto ou o campo id da API. Se não houver nenhum, gere um hash a partir dos campos que não mudam. O número de ordem na lista não serve como chave.
  • Faça da chave a chave primária e insira com INSERT OR IGNORE. Se a mesma chave chega uma segunda vez, o SQLite pula a linha em silêncio. Esse comportamento está definido na documentação das regras de conflito do SQLite. Se você quiser atualizar um campo que muda, como o preço, usa ON CONFLICT ... DO UPDATE.
  • Fixe a ordenação. Se o site oferece opções de ordem, escolha um campo que não muda (por nome ou identificador em vez de data de inclusão). O deslize diminui.

Os laços dos três tipos de API se parecem muito; a diferença está em como a próxima requisição é montada. As três funções abaixo produzem os registros um a um (yield), de modo que o código que chama não precisa saber de que tipo de paginação se trata. Os nomes dos campos (items, next_cursor) mudam de API para API; consulte a documentação do seu alvo.

python
import time

import requests


def crawl_offset(session, url, limit=100, max_pages=500, delay=1.0):
    """offset/limit: para quando chega uma pagina incompleta ou vazia."""
    offset = 0
    for _ in range(max_pages):
        response = session.get(url, params={"offset": offset, "limit": limit}, timeout=20)
        response.raise_for_status()
        batch = response.json()["items"]
        yield from batch
        if len(batch) < limit:
            break
        offset += limit
        time.sleep(delay)


def crawl_cursor(session, url, max_pages=500, delay=1.0):
    """cursor: o cursor da resposta vai sem alteracao para a requisicao seguinte."""
    cursor, seen = None, set()
    for _ in range(max_pages):
        response = session.get(url, params={"cursor": cursor} if cursor else {}, timeout=20)
        response.raise_for_status()
        payload = response.json()
        yield from payload["items"]
        cursor = payload.get("next_cursor")
        if not cursor or cursor in seen:  # sem cursor ou ele se repete
            break
        seen.add(cursor)
        time.sleep(delay)


def crawl_link_header(session, url, max_pages=500, delay=1.0):
    """Cabecalho Link: o endereco rel="next" chega pronto, nada e calculado."""
    for _ in range(max_pages):
        response = session.get(url, timeout=20)
        response.raise_for_status()
        yield from response.json()
        url = response.links.get("next", {}).get("url")
        if not url:
            break
        time.sleep(delay)

Testamos as três contra uma API falsa local com 250 registros: cada uma reuniu os 250 registros em três requisições e sem repetição. Contra um endpoint quebrado que devolvia sempre o mesmo cursor, crawl_cursor parou depois da segunda requisição; sem o conjunto seen teria enviado 500 requisições. A função crawl_link_header rodamos também sobre a lista de tags de um repositório público do GitHub e recebemos 200 registros em duas páginas. O Requests analisa o cabeçalho Link por conta própria e o deixa no dicionário response.links. A documentação de paginação do GitHub recomenda o mesmo: não monte o endereço na mão, siga o endereço rel="next".

Como percorrer a rolagem infinita e o botão "carregar mais"?

O primeiro movimento não é a automação de navegador, é a aba Rede. A página quotes.toscrape.com/scroll é um bom exemplo: conforme você desce chegam citações novas e a cada vez sai uma requisição para /api/quotes?page=2, page=3. A resposta é JSON e tem dentro um campo has_next. Chamar essa requisição direto é mais rápido que rolar e pesa menos no site; imagens, fontes e scripts não são baixados. O exemplo de fila logo abaixo reúne as citações por esse caminho. O detalhe de como achar a requisição está na seção "Achar primeiro a requisição de API/XHR" do nosso artigo Páginas estáticas e dinâmicas.

Se a requisição não pode ser repetida (carrega um parâmetro assinado, ou a resposta vem como fragmento HTML e é processada pelo script da página), passa-se para a automação de navegador. Ali a condição de parada vira "o número de cartões parou de crescer":

python
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch()
    page = browser.new_page()
    page.goto("https://quotes.toscrape.com/scroll")
    page.wait_for_selector(".quote")

    count, idle = 0, 0
    while idle < 3 and count < 500:  # parar apos tres voltas sem cartoes novos ou ao atingir o limite
        page.mouse.wheel(0, 10000)
        page.wait_for_timeout(1000)
        current = page.locator(".quote").count()
        idle = idle + 1 if current == count else 0
        count = current

    print(count, "cartoes carregados")
    browser.close()

Esse script carregou os 100 cartões do site de treino. Com o botão "carregar mais" o laço é o mesmo, você só clica no botão em vez de usar a roda e também para quando o botão some da página. No Selenium o mesmo trabalho é feito com a chamada execute_script("window.scrollTo(0, document.body.scrollHeight)") e recontando os cartões; as diferenças entre as duas ferramentas estão no artigo Playwright e Selenium. A rolagem tem um limite: em feeds longos a página mantém milhares de elementos na memória e fica lenta. Se você precisa de mais do que algumas centenas de cartões, vale procurar a requisição da API.

Como retomar uma coleta interrompida: a fila de URLs em SQLite

Em listas curtas basta guardar o próximo endereço numa variável; a função que percorre categorias no nosso artigo Monitoramento de preços da concorrência no e-commerce funciona assim, e para uma categoria de duas páginas é a escolha certa. Quando a lista chega a centenas de páginas, a variável não basta: a conexão cai, o computador hiberna, o servidor reinicia e o endereço guardado na variável some junto com o processo. Você precisa gravar em disco em que ponto parou.

Para isso não é preciso um servidor de filas separado. O SQLite, que vem com o Python, resolve com duas tabelas: queue guarda os endereços a percorrer e o estado deles (pending, done, failed, blocked), e items guarda os registros coletados. O truque é uma regra só: os registros de uma página, o próximo endereço aprendido nessa página e a marca done da página são gravados na mesma transação de banco de dados. A transação chega ao disco inteira ou não chega. Se o processo morre exatamente nesse momento, a página fica pending na fila e é lida de novo na execução seguinte. Não existe página que pareça "concluída" com os registros incompletos.

O script a seguir percorre dois tipos de paginação diferentes na mesma fila: o catálogo de livros seguindo o link "próxima" e as citações pela API JSON que está por trás da rolagem infinita.

python
import hashlib
import json
import sqlite3
import time
from urllib.parse import urljoin, urlsplit
from urllib.robotparser import RobotFileParser

import requests
from bs4 import BeautifulSoup

DB_PATH = "crawl.db"
BOT_NAME = "ExampleCrawler"
USER_AGENT = f"{BOT_NAME}/1.0 (+https://example.com/bot)"
PROXY = None  # exemplo: "http://user:pass@pr.proxynet.io:8000"
DELAY = 1.0  # intervalo minimo entre duas requisicoes ao mesmo site (segundos)
MAX_PAGES = 200  # limite superior contra uma cadeia "proxima" quebrada
MAX_ATTEMPTS = 3

SEEDS = [
    ("https://books.toscrape.com/", "books"),
    ("https://quotes.toscrape.com/api/quotes?page=1", "quotes"),
]

SCHEMA = """
CREATE TABLE IF NOT EXISTS queue (
    url      TEXT PRIMARY KEY,
    kind     TEXT NOT NULL,
    status   TEXT NOT NULL DEFAULT 'pending',  -- pending | done | failed | blocked
    attempts INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS items (
    item_key TEXT PRIMARY KEY,
    kind     TEXT NOT NULL,
    data     TEXT NOT NULL,
    page_url TEXT NOT NULL
);
"""


def parse_books(response):
    """Lista HTML: os registros vem dos cartoes e a proxima pagina do link 'next'."""
    soup = BeautifulSoup(response.content, "html.parser")
    items = []
    for card in soup.select("article.product_pod"):
        link = card.select_one("h3 a")
        url = urljoin(response.url, link["href"])
        items.append((url, {"title": link["title"], "price": card.select_one(".price_color").text}))
    next_link = soup.select_one("li.next a")
    return items, urljoin(response.url, next_link["href"]) if next_link else None


def parse_quotes(response):
    """API JSON (a requisicao por tras da rolagem infinita): para quando has_next acaba."""
    payload = response.json()
    items = []
    for quote in payload["quotes"]:
        key = hashlib.sha1(quote["text"].encode("utf-8")).hexdigest()
        items.append((key, {"text": quote["text"], "author": quote["author"]["name"]}))
    next_url = None
    if payload["has_next"]:
        next_url = urljoin(response.url, f"?page={payload['page'] + 1}")
    return items, next_url


PARSERS = {"books": parse_books, "quotes": parse_quotes}
robots_cache = {}
last_request = {}


def allowed(session, url):
    """Le o robots.txt uma vez por site. 4xx: sem restricao, 5xx: nenhum caminho e percorrido (RFC 9309)."""
    root = "{0.scheme}://{0.netloc}".format(urlsplit(url))
    if root not in robots_cache:
        parser = RobotFileParser()
        response = session.get(root + "/robots.txt", timeout=20)
        if response.status_code >= 500:
            parser.disallow_all = True
        elif response.status_code >= 400:
            parser.allow_all = True
        else:
            parser.parse(response.text.splitlines())
        robots_cache[root] = parser
    return robots_cache[root].can_fetch(BOT_NAME, url)


def polite_get(session, url):
    """Nao pede ao mesmo site com intervalo menor que DELAY (ou que o Crawl-delay do robots.txt)."""
    host = urlsplit(url).netloc
    root = "{0.scheme}://{0.netloc}".format(urlsplit(url))
    delay = max(DELAY, float(robots_cache[root].crawl_delay(BOT_NAME) or 0))
    wait = last_request.get(host, 0) + delay - time.monotonic()
    if wait > 0:
        time.sleep(wait)
    try:
        return session.get(url, timeout=20)
    finally:
        last_request[host] = time.monotonic()


def crawl():
    db = sqlite3.connect(DB_PATH)
    db.executescript(SCHEMA)
    with db:  # na primeira execucao insere os enderecos iniciais, depois nao mexe
        db.executemany("INSERT OR IGNORE INTO queue (url, kind) VALUES (?, ?)", SEEDS)

    session = requests.Session()
    session.headers["User-Agent"] = USER_AGENT
    if PROXY:
        session.proxies = {"http": PROXY, "https": PROXY}

    for _ in range(MAX_PAGES):
        row = db.execute(
            "SELECT url, kind FROM queue WHERE status = 'pending' ORDER BY attempts, rowid LIMIT 1"
        ).fetchone()
        if row is None:
            break  # fila vazia: a coleta terminou
        url, kind = row

        if not allowed(session, url):
            with db:
                db.execute("UPDATE queue SET status = 'blocked' WHERE url = ?", (url,))
            continue

        try:
            response = polite_get(session, url)
            response.raise_for_status()
            items, next_url = PARSERS[kind](response)
        except (requests.RequestException, KeyError, ValueError) as exc:
            with db:
                db.execute(
                    "UPDATE queue SET attempts = attempts + 1,"
                    " status = CASE WHEN attempts + 1 >= ? THEN 'failed' ELSE 'pending' END"
                    " WHERE url = ?",
                    (MAX_ATTEMPTS, url),
                )
            print(f"ERRO  {url}: {exc}")
            continue

        # Os registros, o proximo endereco e a marca "pagina concluida" sao gravados numa transacao so.
        # Se o processo morre no meio deste bloco, nada e gravado e a pagina fica 'pending'.
        with db:
            before = db.total_changes
            db.executemany(
                "INSERT OR IGNORE INTO items (item_key, kind, data, page_url) VALUES (?, ?, ?, ?)",
                [(key, kind, json.dumps(data, ensure_ascii=False), url) for key, data in items],
            )
            new_items = db.total_changes - before
            # Condicao de parada: pagina vazia ou registros todos ja vistos
            if next_url and items and new_items:
                db.execute("INSERT OR IGNORE INTO queue (url, kind) VALUES (?, ?)", (next_url, kind))
            db.execute("UPDATE queue SET status = 'done' WHERE url = ?", (url,))
        print(f"OK    {url}: {len(items)} registros, {new_items} novos")

    for kind, status, count in db.execute(
        "SELECT kind, status, COUNT(*) FROM queue GROUP BY kind, status ORDER BY kind, status"
    ):
        print(f"fila     {kind:7} {status:8} {count}")
    for kind, count in db.execute("SELECT kind, COUNT(*) FROM items GROUP BY kind ORDER BY kind"):
        print(f"registro {kind:7} {count}")
    db.close()


if __name__ == "__main__":
    crawl()

Para rodar o script basta pip install requests beautifulsoup4. A retomada testamos assim: iniciamos o script e matamos o processo diretamente no segundo 14 (não com Ctrl+C). Naquele momento o banco tinha 20 páginas concluídas, 200 livros, 100 citações e um único endereço no estado pending: page-11.html. Rodamos de novo sem mudar nada; a coleta seguiu com page-11.html e terminou em menos de um minuto com 50 páginas de catálogo e 1.000 livros. Para a API de citações não foi nenhuma requisição, as dez páginas dela já estavam done. A terceira execução não achou nenhum endereço pendente e escreveu só o resumo.

Pontos do código que merecem atenção:

  • O bloco with db: é o limite da transação. No módulo sqlite3 do Python, quando a conexão é usada como gerenciador de contexto, a transação é confirmada se o bloco termina sem erro e desfeita se surge uma exceção. O detalhe está na documentação do módulo.
  • A chave primária da fila é o próprio endereço. Se o mesmo endereço é descoberto uma segunda vez, INSERT OR IGNORE o pula; é assim que os links circulares se resolvem.
  • A condição "nenhum registro novo" não briga com a retomada. Como os registros da página interrompida nunca foram gravados, ao ser lida de novo todos são novos e a corrente não quebra.
  • A página que falha vai para o fim da fila. Graças ao ORDER BY attempts, os endereços nunca tentados são pegos primeiro; uma página que não pode ser lida em três tentativas vira failed e a coleta segue sem ela. O contador é propositalmente simples: o código de nova tentativa que decide em qual código de estado esperar e em qual parar, e que trata o cabeçalho Retry-After, já está pronto no nosso artigo Códigos de status HTTP em web scraping e pode entrar no lugar de polite_get.
  • Adicionar um site novo é escrever um analisador. Você escreve uma função que devolve a lista de registros e o próximo endereço e a adiciona ao dicionário PARSERS; a fila, a espera e a lógica de retomada não mudam.

Essa fila é para um processo só. Se vários trabalhadores forem ler da mesma fila, o que pega um endereço precisa levá-lo para um estado intermediário como claimed, e esse estado precisa voltar a pending por tempo limite. Quando a coleta simultânea realmente ganha velocidade está no nosso artigo Concorrência e paralelismo. Conforme o número de trabalhadores cresce, um framework pronto dá menos trabalho: o Scrapy já traz fila, filtro de repetidos e retomada por dentro.

robots.txt, limite de requisições e proxy

A paginação é o trabalho em que você envia mais requisições seguidas a um mesmo site. A função allowed do script lê uma vez o robots.txt de cada site e pergunta a ele sobre cada endereço. Os casos em que o arquivo não é encontrado são regulados pela RFC 9309: uma resposta 4xx conta como "não há arquivo, não há restrição", e diante de uma resposta 5xx o rastreador é obrigado a considerar todos os caminhos proibidos. Nos dois sites de treino a requisição devolveu 404. O motivo de buscarmos o arquivo com session é que assim a requisição sai com o User-Agent do script e, se houver, pelo proxy. O urllib.robotparser não aceita curingas (*, $); os limites estão no nosso artigo O que é o arquivo robots.txt e como lê-lo.

O polite_get deixa pelo menos DELAY segundos entre duas requisições ao mesmo site e, se o site indica Crawl-delay, toma esse valor como base; a espera é contada por site. Se você começar a ver 429, a reação certa não é trocar de IP e sim aumentar o valor de DELAY; os motivos estão no nosso artigo 429 Too Many Requests. Escrever um User-Agent que apresenta o seu bot também faz parte do trabalho: O que é User-Agent.

O proxy entra nesse quadro em dois pontos. O primeiro é a localização: para ver o catálogo e os preços mostrados a quem visita de Türkiye, a requisição precisa sair de Türkiye. O segundo é a distribuição de carga: em coletas longas espalhadas por vários sites usam-se Proxies rotativos para que o tráfego não se acumule em um único endereço. Aqui há uma armadilha. Resultados de busca e listas filtradas costumam depender de uma sessão no servidor; se o IP muda no meio da lista, o site pode te devolver para a primeira página ou entregar os mesmos registros de novo. Para percorrer uma lista do começo ao fim com o mesmo endereço de saída, abra uma sessão de Proxies de sessão fixa e troque de identidade quando a lista acabar. O mecanismo está no nosso artigo O que é rotação de IP e como funciona. Também preenchemos a linha PROXY e rodamos o script por um proxy de teste local com autenticação; todo o tráfego, inclusive o do robots.txt, passou pelo proxy e o resultado não mudou.

Casos de uso

  • Coleta de categorias e catálogos: reunir os endereços de produto das categorias da concorrência é o primeiro passo do monitoramento de preços; o fluxo completo está no nosso artigo Monitoramento de preços da concorrência no e-commerce e a parte de infraestrutura, na página de monitoramento de preços.
  • Listagens de marketplace: listas de vendedores e produtos chegam a milhares de páginas e ali a fila retomável é obrigatória. Para o ajuste de localização e sessão, veja nossa página de proxy para e-commerce.
  • Dados em massa de APIs oficiais: o cursor e o cabeçalho Link aparecem principalmente aqui. Como a sessão é transportada em APIs que exigem login está no nosso artigo Sessões e cookies em Python.
  • Trabalhos pequenos e pontuais: não monte uma fila para uma tabela de cinquenta linhas; as opções sem código estão no nosso artigo Como extrair dados de um site.

Erros comuns

  • Deixar o número da última página fixo no código. O catálogo cresce, 50 páginas viram 53 e as três últimas ficam de fora em silêncio. Escreva a condição de parada, não o número.
  • Não colocar limite superior. Um elemento "próxima" que aponta para si mesmo ou um cursor que se repete faz seu script girar horas na mesma página.
  • Juntar um link relativo na mão. No site de treino o link "próxima" é catalogue/page-2.html na primeira página e page-3.html na segunda. Um endereço montado somando strings quebra na segunda página; urljoin resolve os dois corretamente.
  • Gravar o ponto em que parou separado dos registros. O código que escreve primeiro "página concluída" e só depois insere os registros perde aquela página de vez se morrer no meio. Inverter a ordem leva a repetições. As duas coisas precisam estar na mesma transação.
  • Filtrar repetições com um conjunto em memória. Quando o processo reinicia, o conjunto está vazio; a unicidade é tarefa do banco de dados.
  • Seguir links escondidos. O código que, procurando o link de paginação, joga todos os elementos <a> da página na fila também entra em links-armadilha invisíveis para pessoas. Selecione só o elemento de paginação; o detalhe está no nosso artigo Armadilhas honeypot.

Guia de decisão

SituaçãoRecomendação
A página tem um link "próxima"Siga o link, use urljoin
Só há números de página e a última é desconhecidaAumente o número; use juntas página vazia, 404 e "nenhum registro novo"
Aparece uma requisição JSON na aba RedeLargue o navegador e chame a requisição direto
A API dá um cursorTransporte o cursor sem alterar e guarde os já vistos num conjunto
A API dá um cabeçalho LinkSiga o endereço response.links["next"]
A requisição não pode ser repetida, há rolagem infinitaRole com Playwright ou Selenium e pare quando o número de cartões não crescer mais
A lista passa de 50 páginas ou a coleta leva minutosMonte uma fila SQLite e grave registro e estado na mesma transação
A lista muda enquanto você a percorreChave estável, INSERT OR IGNORE, ordem fixa e uma segunda volta se preciso
Lista filtrada ou ligada a sessão, com proxySessão sticky durante toda a lista e nova identidade no fim dela

Perguntas frequentes

O que é paginação por cursor e qual a diferença para offset?

offset diz "pule tantos registros a partir do começo"; o cursor diz "me dê os que vêm depois deste registro". Com offset você pode saltar para a página que quiser, mas se a lista muda os registros deslizam e surgem repetições ou lacunas. Com cursor não há salto, você só avança em ordem; em troca, o ponto em que parou fica fixo mesmo se a lista mudar. A diferença prática: uma coleta com offset pode ser retomada da página 40, já com um cursor expirado talvez você tenha de começar do zero.

O que é rolagem infinita?

É o JavaScript enviar uma nova requisição em segundo plano quando o fim da página se aproxima e acrescentar os registros que chegam ao pé da lista. Quem navega não vê números de página, mas a API por trás quase sempre trabalha com número de página, offset ou cursor. Em web scraping, o alvo é essa requisição de API.

É possível saber de antemão qual é a última página?

Às vezes. A linha "Página 1 de 50" no rodapé, o campo total_pages na resposta da API ou o endereço rel="last" no cabeçalho Link dizem isso. Use essa informação para mostrar progresso e conferir o resultado. Ainda assim, encerre o laço pelas condições de parada, porque o total pode mudar durante a coleta.

É possível percorrer páginas em paralelo?

Nos tipos número de página e offset sim, porque você consegue gerar os endereços de antemão. Nos tipos link "próxima" e cursor cada página depende da anterior e a corrente avança em ordem; o paralelismo só se monta entre listas diferentes (categorias). O paralelismo não elimina o limite de requisições: continue limitando a velocidade total de requisições ao mesmo site.

Por que SQLite e não um arquivo CSV ou JSON?

Acrescentar a um arquivo é simples, mas não dá três coisas: controle de unicidade, gravação de "tudo ou nada" e uma fila consultável. O SQLite oferece as três num único arquivo, sem instalação. Terminada a coleta, despejar a tabela items em CSV são poucas linhas.

O que acontece se o site mudar o número de registros por página durante a coleta?

Nos tipos link "próxima" e cursor não acontece nada, porque é o site que informa a próxima página. Nos tipos número de página e offset os limites deslizam e alguns registros podem chegar duas vezes enquanto outros nunca chegam. O filtro pela chave resolve o primeiro problema; para o segundo é preciso comparar com o número total e, se necessário, dar uma segunda volta.

Em resumo

Na coleta com paginação o código responde a três perguntas: onde está a próxima página, quando a lista acabou e onde está anotado o ponto em que parei. Na primeira, siga o sinal que o site dá (link "próxima", cursor, cabeçalho Link) em vez de inventar endereços; na rolagem infinita, procure antes a requisição de API por trás. Na segunda, não confie em uma única condição e acrescente sempre "nenhum registro novo" e um limite de páginas. Na terceira, grave os registros e o estado da fila na mesma transação do SQLite; onde quer que o processo morra, a coleta continua da página em que parou. Mantenha a velocidade das requisições baixa e siga as regras do robots.txt. As opções de localização e distribuição de carga estão nos nossos serviços de proxy.