ProxynetProxynet

Como salvar dados de scraping em CSV, JSON e SQLite

Publicado:

15 min de leitura

Acar Diveroli
Autor: Acar Diveroli
Um bloco central de registros coletados ligado a quatro cartões CSV, JSON, SQLITE e XLSX; a ligação com o SQLite é azul.

Seu scraper percorre dez páginas e imprime cem registros limpos. Na manhã seguinte você roda de novo e o CSV tem duzentas linhas, metade delas duplicadas. Um colega abre o arquivo no Excel e cada aspa curva aparece como “. Uma semana depois, o processo cai no meio da gravação e deixa um arquivo JSON que nenhum parser consegue abrir. Nada disso é um problema de scraping: tudo é decidido pelas poucas linhas de armazenamento no fim do script.

Este guia cobre os três formatos em que a maioria dos scrapers em Python grava: CSV com o módulo csv e o pandas, JSON e JSON Lines, e SQLite com um upsert que elimina duplicatas entre execuções. Você vai ver como acrescentar dados com segurança, qual codificação o Excel espera, quanto cada formato ocupa e quando trocar o SQLite pelo PostgreSQL. Todos os exemplos rodaram em 28 de setembro de 2026 contra o quotes.toscrape.com, um sandbox criado para praticar scraping, com Python 3.13, Requests 2.34, beautifulsoup4 4.15, pandas 3.0 e SQLite 3.50. A parte de parsing está no tutorial de BeautifulSoup; aqui começamos quando as linhas já existem.

Quais são as opções para armazenar dados de scraping?

Existem duas famílias. Os arquivos planos (CSV, JSON, JSON Lines, XLSX) são um único arquivo gravado do início ao fim. Não precisam de servidor e abrem em ferramentas conhecidas, mas não fazem ideia do que é uma duplicata. Os bancos de dados (SQLite, PostgreSQL, MySQL) guardam linhas em tabelas com chaves e restrições: mesclam as duplicatas para você, respondem perguntas com SQL e sobrevivem a uma falha no meio da gravação.

O SQLite fica entre os dois. É um banco de dados, mas o banco inteiro é um arquivo comum no disco, e o Python traz o módulo sqlite3 na biblioteca padrão. Para um scraper que roda numa única máquina, essa combinação é difícil de superar.

Como um scraper salva suas linhas?

Seja qual for o formato, um scraper bem feito passa pelas mesmas cinco etapas:

  1. Normalize cada registro num dict com as mesmas chaves sempre: quote_id, text, author, tags e um carimbo de data e hora.
  2. Dê a cada registro uma chave estável. Use o ID do próprio site, se houver (um SKU de produto, o ID de um artigo na URL). O quotes.toscrape.com não tem nenhum, então calculamos um hash do autor e do texto da citação e obtemos uma chave de 16 caracteres.
  3. Escolha o modo de gravação. Sobrescrever o arquivo (um snapshot novo), acrescentar ao final (um log que cresce) ou fazer upsert numa tabela (uma linha por chave, atualizada no lugar).
  4. Grave em uma única etapa. Ou o lote inteiro chega, ou nada chega: uma transação de banco de dados, ou um arquivo temporário que substitui o antigo quando estiver completo.
  5. Leia de volta. Abra o arquivo com o leitor que o destinatário vai usar (Excel, pandas, json.loads) antes de confiar nele.

A etapa 2 é a que a maioria dos scripts pula, e é por isso que as duplicatas aparecem.

Comparação entre CSV, JSON Lines e SQLite

CSVJSON (um array)JSON LinesSQLite
Campos aninhados (listas de tags)É preciso achatá-los, por exemplo love|lifeNativoNativoTexto JSON numa coluna, consultado com json_each
Acrescentar a cada execuçãoSim, com o cabeçalho uma única vezNão, o ] final precisa ser reescritoSim, uma linha por registroSim, com upsert
Remove duplicatasNãoNãoNãoSim, pela chave primária
Abre no ExcelSim, com um BOM UTF-8NãoNãoNão (exporte antes)
Sobrevive a uma falha no meio da gravaçãoA última linha pode ficar cortadaO arquivo pode ficar ilegívelSó a última linha se perdeSim, as transações são revertidas
Consultar sem carregar tudoNãoNãoLinha por linhaSim, com SQL e índices
Tamanho para as nossas 100 citações25,9 KB42,4 KB (indentado)35,8 KB49,2 KB (com um índice)

Os tamanhos vêm do nosso próprio teste. O SQLite é o maior aqui porque guarda os dados em páginas fixas de 4 KB (12 páginas para esta tabela e seu índice); essa sobrecarga diminui à medida que a tabela cresce. Para comparação, os mesmos dados em XLSX ocuparam 17,0 KB, já que o XLSX é um formato compactado.

Como salvar dados de scraping em CSV com Python?

O módulo csv da biblioteca padrão cuida das aspas, das vírgulas dentro dos campos e das quebras de linha dentro de valores entre aspas. O DictWriter transforma cada dict numa linha pelo nome da coluna:

python
import csv
from pathlib import Path

FIELDS = ["quote_id", "text", "author", "author_url", "tags", "page", "scraped_at"]

def append_csv(rows, path: Path) -> None:
    new_file = not path.exists() or path.stat().st_size == 0
    with path.open("a", newline="", encoding="utf-8-sig" if new_file else "utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=FIELDS)
        if new_file:
            writer.writeheader()
        for row in rows:
            writer.writerow({**row, "tags": "|".join(row["tags"])})

Três detalhes dessa função evitam as reclamações de sempre com CSV:

  • newline="". A documentação do csv do Python pede isso tanto para leitores quanto para gravadores. Sem ele, as quebras de linha dentro de campos entre aspas são lidas errado e, no Windows, cada linha termina com um \r extra, que muitos leitores transformam numa linha em branco depois de cada registro.
  • utf-8-sig só na primeira gravação. Esse codec grava uma marca de ordem de bytes, o BOM (EF BB BF), no início do arquivo; a documentação de codecs a descreve como a variante de UTF-8 usada pela Microsoft. O Excel lê essa marca e decodifica o arquivo como UTF-8, então \u201c continua \u201c. Quando o script acrescenta dados depois, ele muda para utf-8 simples; caso contrário, uma segunda marca cairia no meio do arquivo. Nosso arquivo de teste tinha exatamente uma depois de duas execuções.
  • O cabeçalho só quando o arquivo é novo. Acrescentar o cabeçalho a cada execução deixa linhas soltas quote_id,text,... no meio dos dados.

As tags são unidas com |, um separador que nunca aparece nos valores.

Ao ler um arquivo com BOM, use encoding="utf-8-sig" de novo. Com utf-8 simples, o nome da primeira coluna volta como '\ufeffquote_id' e row["quote_id"] gera KeyError. Isso aconteceu nos nossos testes; mais armadilhas de codificação estão em erros de codificação Unicode no Python.

Com o pandas, o mesmo arquivo sai em uma linha, e drop_duplicates corrige um CSV ou JSON Lines que já tem repetições:

python
import pandas as pd

df = pd.read_json("data/quotes.jsonl", lines=True, convert_dates=False)
df = df.drop_duplicates(subset="quote_id", keep="last")
df.assign(tags=df["tags"].str.join("|")).to_csv(
    "data/quotes_clean.csv", index=False, encoding="utf-8-sig")
df.assign(tags=df["tags"].str.join(", ")).to_excel(
    "data/quotes.xlsx", index=False, sheet_name="quotes")  # requer openpyxl

Uma planilha do Excel comporta no máximo 1.048.576 linhas, segundo as especificações e limites da Microsoft. Um CSV não tem esse limite, mas o Excel só vai mostrar essa quantidade de linhas.

Como salvar dados de scraping em JSON ou JSON Lines?

Um único array JSON serve para um snapshot que outro programa carrega de uma vez. Grave-o com ensure_ascii=False para que o texto com acentos continue legível em vez de virar escapes como \u201c, e grave de forma atômica: despeje o conteúdo num arquivo temporário na mesma pasta e depois troque-o com os.replace. Se o processo morrer no meio, o arquivo antigo continua intacto.

python
import json, os, tempfile
from pathlib import Path

def write_json_atomic(data, path: Path) -> None:
    fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
    with os.fdopen(fd, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)
    os.replace(tmp, path)

O ponto fraco de um array JSON é acrescentar dados. O arquivo termina em ], então adicionar um registro exige ler e reescrever o arquivo inteiro. O JSON Lines evita isso: cada linha é um valor JSON completo, o arquivo está em UTF-8 e as linhas terminam em \n. Acrescentar é uma gravação simples, e uma falha só pode danificar a última linha:

python
def append_jsonl(rows, path: Path) -> None:
    with path.open("a", encoding="utf-8") as f:
        for row in rows:
            f.write(json.dumps(row, ensure_ascii=False) + "\n")

O pandas lê esse formato com pd.read_json(path, lines=True), e chunksize= permite processar um arquivo grande em partes. Uma surpresa do nosso teste: a documentação do read_json diz que colunas cujo nome termina em _at ou _time são interpretadas como datas por padrão. Nossa coluna scraped_at virou um datetime com fuso horário, e o to_excel() parou com Excel does not support datetimes with timezones (o Excel não aceita datas com fuso horário). Passe convert_dates=False se quiser manter o texto como está.

Se um arquivo não carrega e gera JSONDecodeError, as causas comuns (uma gravação truncada, dois arrays num arquivo, um JSON Lines lido como um único documento) estão em JSONDecodeError: Expecting value.

Como armazenar dados de scraping no SQLite sem duplicatas?

Declare a chave estável como chave primária e deixe o banco de dados decidir entre inserir e atualizar. O SQLite suporta isso desde a versão 3.24.0 com a cláusula upsert (uma inserção que vira atualização quando a chave já existe); na parte DO UPDATE, o prefixo especial excluded. se refere aos valores que a inserção rejeitada tentou gravar (documentação de UPSERT do SQLite).

sql
CREATE TABLE IF NOT EXISTS quotes (
    quote_id   TEXT PRIMARY KEY,
    text       TEXT NOT NULL,
    author     TEXT NOT NULL,
    author_url TEXT,
    tags       TEXT,              -- array JSON
    first_seen TEXT NOT NULL,
    last_seen  TEXT NOT NULL
);

INSERT INTO quotes (quote_id, text, author, author_url, tags, first_seen, last_seen)
VALUES (:quote_id, :text, :author, :author_url, :tags, :scraped_at, :scraped_at)
ON CONFLICT(quote_id) DO UPDATE SET
    tags       = excluded.tags,
    author_url = excluded.author_url,
    last_seen  = excluded.last_seen;

first_seen só é gravado quando a linha é nova; last_seen avança a cada execução. Esse par dá um histórico incremental de graça: uma linha cujo last_seen é anterior à última execução sumiu do site, e uma linha cujo first_seen é igual à última execução é nova. Rastreadores de preço e monitores de mudanças são construídos exatamente sobre esse padrão.

Duas execuções do script completo mais abaixo imprimiram:

text
scraped 100 rows: 100 new, 0 already in the database
scraped 100 rows: 0 new, 100 already in the database

Os arquivos CSV e JSON Lines dessas mesmas duas execuções tinham 200 registros cada. O banco de dados tinha 100.

Com os dados no SQLite, perguntas viram consultas. Estas rodaram contra o nosso banco de teste:

python
import sqlite3

con = sqlite3.connect("data/quotes.db")

# Autores com mais citações
for author, n in con.execute(
        "SELECT author, COUNT(*) AS n FROM quotes GROUP BY author ORDER BY n DESC LIMIT 3"):
    print(author, n)

# Citações com a tag "love" (as tags são guardadas como array JSON)
print(con.execute("""
    SELECT COUNT(*) FROM quotes, json_each(quotes.tags)
    WHERE json_each.value = 'love'""").fetchone()[0])

# Linhas que não estavam no site durante a última execução
print(con.execute("""
    SELECT COUNT(*) FROM quotes
    WHERE last_seen < (SELECT MAX(last_seen) FROM quotes)""").fetchone()[0])
con.close()
text
Albert Einstein 10
J.K. Rowling 9
Marilyn Monroe 7
14
0

Para análise, pd.read_sql_query(sql, con) devolve o resultado como um DataFrame. As etapas de limpeza que costumam vir em seguida estão em como limpar dados de scraping com pandas.

Script completo: scraping e gravação em CSV, JSON Lines e SQLite

O script segue o link "Next" do site até ele desaparecer, tenta de novo em respostas 429 e 5xx com espera exponencial (o Retry do urllib3 também respeita o cabeçalho Retry-After), espera um segundo entre as páginas e grava um único carimbo de data e hora por execução para que as comparações de last_seen funcionem. O quotes.toscrape.com tem 10 páginas e 100 citações.

bash
pip install requests beautifulsoup4 lxml pandas openpyxl
python
"""Faz scraping do quotes.toscrape.com e guarda as linhas em CSV, JSON Lines e SQLite."""
import csv
import hashlib
import json
import sqlite3
import time
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urljoin

import requests
from bs4 import BeautifulSoup
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

BASE = "https://quotes.toscrape.com/"
FIELDS = ["quote_id", "text", "author", "author_url", "tags", "page", "scraped_at"]


def make_session() -> requests.Session:
    retry = Retry(total=4, backoff_factor=1,
                  status_forcelist=[429, 500, 502, 503, 504],
                  allowed_methods=["GET"])
    session = requests.Session()
    session.mount("https://", HTTPAdapter(max_retries=retry))
    session.headers["User-Agent"] = "quotes-storage-demo/1.0 (contact: you@example.com)"
    return session


def quote_key(text: str, author: str) -> str:
    # O site não tem IDs de citação, então derivamos uma chave estável do conteúdo.
    return hashlib.sha1(f"{author}\n{text}".encode("utf-8")).hexdigest()[:16]


def scrape(session: requests.Session, delay: float = 1.0):
    url, page = BASE, 1
    run_at = datetime.now(timezone.utc).isoformat(timespec="seconds")  # um carimbo de tempo por execução
    while url:
        resp = session.get(url, timeout=20)
        resp.raise_for_status()
        soup = BeautifulSoup(resp.content, "lxml")
        for q in soup.select("div.quote"):
            text = q.select_one("span.text").get_text(strip=True)
            author = q.select_one("small.author").get_text(strip=True)
            yield {
                "quote_id": quote_key(text, author),
                "text": text,
                "author": author,
                "author_url": urljoin(BASE, q.select_one("span a")["href"]),
                "tags": [t.get_text(strip=True) for t in q.select("a.tag")],
                "page": page,
                "scraped_at": run_at,
            }
        nxt = soup.select_one("li.next a")
        url = urljoin(url, nxt["href"]) if nxt else None
        page += 1
        time.sleep(delay)


def append_csv(rows, path: Path) -> None:
    new_file = not path.exists() or path.stat().st_size == 0
    with path.open("a", newline="", encoding="utf-8-sig" if new_file else "utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=FIELDS)
        if new_file:
            writer.writeheader()
        for row in rows:
            writer.writerow({**row, "tags": "|".join(row["tags"])})


def append_jsonl(rows, path: Path) -> None:
    with path.open("a", encoding="utf-8") as f:
        for row in rows:
            f.write(json.dumps(row, ensure_ascii=False) + "\n")


SCHEMA = """
CREATE TABLE IF NOT EXISTS quotes (
    quote_id   TEXT PRIMARY KEY,
    text       TEXT NOT NULL,
    author     TEXT NOT NULL,
    author_url TEXT,
    tags       TEXT,              -- array JSON
    first_seen TEXT NOT NULL,
    last_seen  TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_quotes_author ON quotes(author);
"""

UPSERT = """
INSERT INTO quotes (quote_id, text, author, author_url, tags, first_seen, last_seen)
VALUES (:quote_id, :text, :author, :author_url, :tags, :scraped_at, :scraped_at)
ON CONFLICT(quote_id) DO UPDATE SET
    tags       = excluded.tags,
    author_url = excluded.author_url,
    last_seen  = excluded.last_seen
"""


def save_sqlite(rows, path: Path) -> tuple[int, int]:
    con = sqlite3.connect(path)
    try:
        con.executescript(SCHEMA)
        before = con.execute("SELECT COUNT(*) FROM quotes").fetchone()[0]
        with con:  # uma transação: commit se der certo, rollback se der erro
            con.executemany(UPSERT, [{**r, "tags": json.dumps(r["tags"])} for r in rows])
        after = con.execute("SELECT COUNT(*) FROM quotes").fetchone()[0]
        return after - before, len(rows) - (after - before)
    finally:
        con.close()


if __name__ == "__main__":
    out = Path("data")
    out.mkdir(exist_ok=True)
    rows = list(scrape(make_session()))
    append_csv(rows, out / "quotes.csv")
    append_jsonl(rows, out / "quotes.jsonl")
    new, seen = save_sqlite(rows, out / "quotes.db")
    print(f"scraped {len(rows)} rows: {new} new, {seen} already in the database")

with con: envolve o upsert numa única transação: se qualquer linha falhar, nenhuma é gravada. Ele não fecha a conexão, por isso o bloco finally faz isso. Os marcadores nomeados (:quote_id) permitem que o executemany receba os mesmos dicts que o gravador de CSV usa e impedem que apóstrofos quebrem o SQL.

Para um site real, mantenha a mesma estrutura e mude os seletores, a chave e o intervalo. Verifique antes os termos do site e o robots.txt (robots.txt explicado) e use uma API oficial quando existir. O mesmo intervalo de um segundo e as mesmas configurações de nova tentativa são o que mantém um processo longe dos erros HTTP 429. Quando um scraper vira um crawler agendado que distribui as requisições entre vários IPs de saída, Proxies rotativos e Proxies residenciais se conectam à mesma requests.Session pela configuração proxies; o código de armazenamento não muda.

Quando trocar o SQLite pelo PostgreSQL?

A orientação do próprio projeto SQLite é direta sobre os limites: ele aceita um gravador por vez em cada arquivo de banco de dados e não é a escolha certa quando os dados e a aplicação ficam em máquinas diferentes. Num projeto de scraping, isso se traduz em três sinais:

  • Vários workers gravam ao mesmo tempo. Processos de crawler em três servidores precisam de um servidor de banco de dados; alguns processos numa única máquina podem se revezar.
  • O banco de dados precisa ser acessível pela rede. Um painel, uma API e um scraper em hosts separados devem compartilhar um PostgreSQL, e não um arquivo .db numa pasta de rede.
  • Outras pessoas precisam de permissões. Usuários, papéis e acesso somente leitura são recursos de servidor.

O tamanho raramente é o motivo: o máximo documentado fica em torno de 281 TB, muito além do que um único scraper produz. A migração em si é pequena. O PostgreSQL usa a mesma sintaxe INSERT ... ON CONFLICT (key) DO UPDATE com EXCLUDED, então o upsert acima passa quase sem mudanças. Pipelines que crescem para várias etapas são tratados em O que é ETL?.

Casos de uso

Erros comuns

  • Nenhuma chave estável. Sem ela, cada execução acrescenta de novo o conjunto de dados inteiro. Use o ID do site ou calcule um hash dos campos que identificam um registro.
  • Abrir arquivos CSV sem newline="". Caracteres \r extras no Windows e campos de várias linhas quebrados.
  • Usar utf-8 simples num CSV feito para o Excel. Caracteres acentuados e aspas curvas viram sequências como é.
  • Gravar o arquivo de saída no lugar. Uma falha deixa meio arquivo. Grave num arquivo temporário e troque-o com os.replace.
  • Um commit por linha no SQLite. Cada commit sincroniza com o disco. Agrupe o lote numa única transação com executemany.
  • Montar o SQL com f-strings. Uma citação com apóstrofo quebra a instrução. Use marcadores de posição.
  • Manter tudo na memória num rastreamento grande. Grave por página ou por lote para que uma falha perca uma página, e não a execução inteira.

Guia de decisão

NecessidadeRecomendação
Um arquivo que um colega abre no ExcelCSV com utf-8-sig, ou XLSX via pandas
Registros aninhados para outro programaJSON (um array), gravado de forma atômica
Um log que só cresce, com cada execuçãoJSON Lines
Execuções repetidas sem duplicatasSQLite com chave primária e upsert
Histórico: quando cada item foi visto pela primeira e pela última vezSQLite com first_seen e last_seen
Várias máquinas gravando ao mesmo tempoPostgreSQL com o mesmo upsert ON CONFLICT
Análise pontualQualquer um dos anteriores, carregado no pandas

Perguntas frequentes

Qual é o melhor formato para salvar dados de scraping?

Depende de quem vai ler: CSV para planilhas, JSON Lines para registros aninhados e logs que só crescem, SQLite para scrapers agendados que não podem duplicar linhas. Muitos projetos mantêm o SQLite como fonte da verdade e exportam CSV para as pessoas.

Por que meu CSV aparece com caracteres estranhos no Excel?

Quando um CSV não tem marca de ordem de bytes (BOM), o Excel no Windows costuma decodificá-lo com a página de código antiga do sistema em vez de UTF-8, e cada caractere de vários bytes vira dois ou três caracteres errados. Grave o arquivo com encoding="utf-8-sig", ou importe-o pela caixa de diálogo de importação de dados do Excel e escolha UTF-8.

Como acrescentar dados a um CSV existente sem repetir o cabeçalho?

Abra o arquivo no modo "a" e grave o cabeçalho só quando o arquivo for novo ou estiver vazio, como faz o append_csv acima. Acrescentar não remove duplicatas; para isso, carregue o arquivo com o pandas e chame drop_duplicates, ou guarde os dados no SQLite com uma chave.

Qual é a diferença entre JSON e JSON Lines?

Um arquivo JSON contém um único valor, geralmente um array de registros, então precisa ser lido e gravado por inteiro. O JSON Lines contém um valor JSON por linha, então você acrescenta um registro com uma única gravação e lê um arquivo grande linha por linha.

O SQLite dá conta de um projeto de scraping grande?

Numa única máquina, geralmente sim; o limite de tamanho fica muito acima do que um scraper produz. O limite que importa é a concorrência: um gravador por vez em cada arquivo. Várias máquinas gravando ao mesmo tempo pedem PostgreSQL.

Como evitar linhas duplicadas ao rodar o scraper de novo?

Dê a cada registro uma chave estável, transforme-a na chave primária no SQLite e insira com ON CONFLICT(key) DO UPDATE. Em arquivos planos, remova as duplicatas depois de carregar com drop_duplicates(subset="key").

Em resumo

O armazenamento decide se um scraper continua útil depois da primeira execução. CSV com utf-8-sig e newline="" é o formato de entrega para planilhas; JSON Lines, a forma segura mais simples de acrescentar registros estruturados. SQLite com uma chave estável e um upsert transforma execuções repetidas numa única tabela consultável, com histórico de primeira e última aparição; o PostgreSQL assume quando várias máquinas gravam ao mesmo tempo. Se as suas execuções crescerem a ponto de um único endereço IP não bastar para coletar os dados com moderação, veja os planos de proxy da Proxynet.