Tutorials

SQLite para cache e rastreamento de resoluções de CAPTCHA

O rastreamento de resoluções de CAPTCHA costuma começar com print() e parar num log que ninguém consulta. Um arquivo SQLite de poucos megabytes muda isso: tempo, tentativas e falhas de cada resolução viram consultas SQL. Ele vem com o Python, não exige servidor e sustenta uma automação de máquina única abaixo de mil resoluções por hora.

O banco local cobre três funções de uma vez: histórico, cache de token dentro da validade e desduplicação de requisições simultâneas. Abaixo estão o esquema, o código, as consultas e a limpeza, em Python e em Node.js.

Quando o SQLite é suficiente — e quando não é

A fronteira não é o tamanho do arquivo, é a concorrência de escrita: o SQLite aceita muitas leituras simultâneas, mas serializa as gravações. Em um único host isso não pesa; com dois servidores gravando no mesmo arquivo em rede, o modelo quebra.

Cenário SQLite Alternativa mais adequada
Desenvolvimento em máquina única -
Produção pequena (menos de 1.000 resoluções/hora) -
Registro de resultados de testes -
Produção em vários servidores PostgreSQL, MongoDB
Alto throughput distribuído Redis, DynamoDB
Painel de análise em tempo real TimescaleDB, InfluxDB

Nas três primeiras linhas, comece pelo SQLite. Migrar depois custa um .dump.

Esquema: histórico em uma tabela, cache em outra

Separar as duas responsabilidades evita o erro mais comum aqui: usar a tabela de auditoria como cache e devolver um token expirado.

CREATE TABLE IF NOT EXISTS captcha_solves (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    captcha_id TEXT,
    type TEXT NOT NULL,
    sitekey TEXT,
    pageurl TEXT,
    status TEXT NOT NULL DEFAULT 'submitted',
    solution TEXT,
    error TEXT,
    submitted_at TEXT NOT NULL DEFAULT (datetime('now')),
    solved_at TEXT,
    elapsed_ms INTEGER,
    polls INTEGER DEFAULT 0,
    project TEXT
);

CREATE INDEX IF NOT EXISTS idx_submitted_at ON captcha_solves(submitted_at);
CREATE INDEX IF NOT EXISTS idx_type_status ON captcha_solves(type, status);
CREATE INDEX IF NOT EXISTS idx_sitekey ON captcha_solves(sitekey);

-- Token cache for reuse within TTL
CREATE TABLE IF NOT EXISTS token_cache (
    sitekey TEXT NOT NULL,
    pageurl TEXT NOT NULL,
    token TEXT NOT NULL,
    created_at TEXT NOT NULL DEFAULT (datetime('now')),
    expires_at TEXT NOT NULL,
    used INTEGER DEFAULT 0,
    PRIMARY KEY (sitekey, pageurl, token)
);

CREATE INDEX IF NOT EXISTS idx_cache_lookup
ON token_cache(sitekey, pageurl, used, expires_at);

Três colunas fazem o trabalho pesado: elapsed_ms mede o tempo entre envio e resposta, polls mostra quantas consultas de resultado foram necessárias e project separa ambientes sem exigir um banco por pipeline. Na token_cache, o índice idx_cache_lookup cobre a consulta mais frequente — token não usado e ainda válido para aquela sitekey.

Implementação em Python

Passo 1: abra a conexão e crie as tabelas

Dois PRAGMAs fazem toda a diferença no dia a dia.

import os
import time
import sqlite3
from datetime import datetime, timedelta, timezone
import requests

DB_PATH = os.environ.get("CAPTCHA_DB", "captcha_solves.db")
API_KEY = os.environ["CAPTCHAAI_API_KEY"]


def get_db():
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    conn.execute("PRAGMA journal_mode=WAL")  # Better concurrent read performance
    conn.execute("PRAGMA busy_timeout=5000")
    return conn


def init_db():
    conn = get_db()
    conn.executescript("""
        CREATE TABLE IF NOT EXISTS captcha_solves (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            captcha_id TEXT,
            type TEXT NOT NULL,
            sitekey TEXT,
            pageurl TEXT,
            status TEXT NOT NULL DEFAULT 'submitted',
            solution TEXT,
            error TEXT,
            submitted_at TEXT NOT NULL DEFAULT (datetime('now')),
            solved_at TEXT,
            elapsed_ms INTEGER,
            polls INTEGER DEFAULT 0,
            project TEXT
        );
        CREATE INDEX IF NOT EXISTS idx_submitted_at ON captcha_solves(submitted_at);
        CREATE INDEX IF NOT EXISTS idx_type_status ON captcha_solves(type, status);

        CREATE TABLE IF NOT EXISTS token_cache (
            sitekey TEXT NOT NULL,
            pageurl TEXT NOT NULL,
            token TEXT NOT NULL,
            created_at TEXT NOT NULL DEFAULT (datetime('now')),
            expires_at TEXT NOT NULL,
            used INTEGER DEFAULT 0,
            PRIMARY KEY (sitekey, pageurl, token)
        );
        CREATE INDEX IF NOT EXISTS idx_cache_lookup
        ON token_cache(sitekey, pageurl, used, expires_at);
    """)
    conn.close()

init_db()

journal_mode=WAL permite que as leituras aconteçam durante uma gravação; sem isso, qualquer consulta trava o worker. busy_timeout=5000 faz o SQLite esperar até 5 s por um lock em vez de devolver database is locked na hora.

Passo 2: envie a tarefa e registre cada tentativa

O fluxo não muda: consultar o cache, gravar a linha de auditoria, enviar a tarefa para in.php e consultar res.php até o token chegar.

def solve_recaptcha(sitekey, pageurl, project=None):
    conn = get_db()

    # Check cache first
    cached = get_cached_token(conn, sitekey, pageurl)
    if cached:
        conn.close()
        return cached

    # Insert tracking record
    now = datetime.now(timezone.utc).isoformat()
    cursor = conn.execute(
        "INSERT INTO captcha_solves (type, sitekey, pageurl, submitted_at, project) "
        "VALUES (?, ?, ?, ?, ?)",
        ("recaptcha_v2", sitekey, pageurl, now, project)
    )
    row_id = cursor.lastrowid
    conn.commit()

    # Submit to CaptchaAI
    resp = requests.post("https://ocr.captchaai.com/in.php", data={
        "key": API_KEY,
        "method": "userrecaptcha",
        "googlekey": sitekey,
        "pageurl": pageurl,
        "json": 1
    })
    data = resp.json()

    if data.get("status") != 1:
        conn.execute(
            "UPDATE captcha_solves SET status=?, error=? WHERE id=?",
            ("error", data.get("request"), row_id)
        )
        conn.commit()
        conn.close()
        return None

    captcha_id = data["request"]
    conn.execute(
        "UPDATE captcha_solves SET captcha_id=?, status=? WHERE id=?",
        (captcha_id, "polling", row_id)
    )
    conn.commit()

    # Poll
    polls = 0
    for _ in range(60):
        time.sleep(5)
        polls += 1
        result = requests.get("https://ocr.captchaai.com/res.php", params={
            "key": API_KEY, "action": "get",
            "id": captcha_id, "json": 1
        }).json()

        if result.get("status") == 1:
            solved_at = datetime.now(timezone.utc).isoformat()
            submitted = datetime.fromisoformat(now)
            elapsed = int((datetime.now(timezone.utc) - submitted).total_seconds() * 1000)

            conn.execute(
                "UPDATE captcha_solves SET status=?, solution=?, solved_at=?, "
                "elapsed_ms=?, polls=? WHERE id=?",
                ("solved", result["request"], solved_at, elapsed, polls, row_id)
            )
            # Cache the token
            cache_token(conn, sitekey, pageurl, result["request"])
            conn.commit()
            conn.close()
            return result["request"]

        if result.get("request") != "CAPCHA_NOT_READY":
            conn.execute(
                "UPDATE captcha_solves SET status=?, error=?, polls=? WHERE id=?",
                ("error", result.get("request"), polls, row_id)
            )
            conn.commit()
            conn.close()
            return None

    conn.execute(
        "UPDATE captcha_solves SET status=?, polls=? WHERE id=?",
        ("timeout", polls, row_id)
    )
    conn.commit()
    conn.close()
    return None

A linha é gravada antes do envio: se a requisição falhar, o registro da tentativa continua lá — e é justamente ele que falta na hora de explicar o consumo do mês. O status percorre submittedpollingsolved / error / timeout.

Passo 3: reaproveite o token dentro da janela de validade

Um token de reCAPTCHA v2 vale cerca de 90 a 120 segundos e serve para um único envio. O cache cobre o caso em que a automação repete a submissão do formulário segundos depois.

def cache_token(conn, sitekey, pageurl, token, ttl_seconds=90):
    expires_at = (datetime.now(timezone.utc) + timedelta(seconds=ttl_seconds)).isoformat()
    conn.execute(
        "INSERT OR REPLACE INTO token_cache (sitekey, pageurl, token, expires_at) "
        "VALUES (?, ?, ?, ?)",
        (sitekey, pageurl, token, expires_at)
    )


def get_cached_token(conn, sitekey, pageurl):
    now = datetime.now(timezone.utc).isoformat()
    row = conn.execute(
        "SELECT token FROM token_cache "
        "WHERE sitekey=? AND pageurl=? AND used=0 AND expires_at > ? "
        "ORDER BY expires_at ASC LIMIT 1",
        (sitekey, pageurl, now)
    ).fetchone()

    if row:
        conn.execute(
            "UPDATE token_cache SET used=1 WHERE token=?",
            (row["token"],)
        )
        conn.commit()
        return row["token"]
    return None

O TTL de 90 s é conservador de propósito, e a coluna used marca o token como consumido já na leitura, o que evita que duas threads reaproveitem o mesmo valor. Entre máquinas, veja o gerenciamento de TTL de tokens no Redis.

Passo 4: transforme o histórico em números

Com os dados gravados, acompanhar o pipeline vira SQL, não dashboard.

def get_stats(hours=24):
    conn = get_db()
    cutoff = (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat()

    total = conn.execute(
        "SELECT COUNT(*) FROM captcha_solves WHERE submitted_at >= ?", (cutoff,)
    ).fetchone()[0]

    solved = conn.execute(
        "SELECT COUNT(*) FROM captcha_solves WHERE submitted_at >= ? AND status='solved'",
        (cutoff,)
    ).fetchone()[0]

    avg_time = conn.execute(
        "SELECT AVG(elapsed_ms) FROM captcha_solves "
        "WHERE submitted_at >= ? AND status='solved'",
        (cutoff,)
    ).fetchone()[0]

    conn.close()
    return {
        "total": total,
        "solved": solved,
        "success_rate": (solved / total * 100) if total else 0,
        "avg_time_ms": round(avg_time) if avg_time else 0
    }


def cleanup_old_records(days=30):
    conn = get_db()
    cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
    conn.execute("DELETE FROM captcha_solves WHERE submitted_at < ?", (cutoff,))
    conn.execute("DELETE FROM token_cache WHERE expires_at < ?",
                 (datetime.now(timezone.utc).isoformat(),))
    conn.execute("VACUUM")
    conn.commit()
    conn.close()

get_stats() responde ao que se pergunta em cada revisão de sprint: quantas tarefas, quantas concluídas e qual o tempo médio em 24 horas. cleanup_old_records() apaga o histórico antigo e os tokens vencidos, e o VACUUM devolve o espaço em disco, porque o SQLite não encolhe o arquivo sozinho. Agende a limpeza diária no cron.

O mesmo padrão em Node.js

Com better-sqlite3, síncrono e sem camada de callbacks, o resultado é idêntico.

const Database = require("better-sqlite3");
const axios = require("axios");

const db = new Database(process.env.CAPTCHA_DB || "captcha_solves.db");
const API_KEY = process.env.CAPTCHAAI_API_KEY;

db.pragma("journal_mode = WAL");
db.exec(`
  CREATE TABLE IF NOT EXISTS captcha_solves (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    captcha_id TEXT, type TEXT NOT NULL, sitekey TEXT, pageurl TEXT,
    status TEXT DEFAULT 'submitted', solution TEXT, error TEXT,
    submitted_at TEXT DEFAULT (datetime('now')),
    solved_at TEXT, elapsed_ms INTEGER, polls INTEGER DEFAULT 0
  );
  CREATE INDEX IF NOT EXISTS idx_submitted ON captcha_solves(submitted_at);
`);

async function solveAndStore(sitekey, pageurl) {
  const submittedAt = new Date().toISOString();
  const insert = db.prepare(
    "INSERT INTO captcha_solves (type, sitekey, pageurl, submitted_at) VALUES (?, ?, ?, ?)"
  );
  const { lastInsertRowid } = insert.run("recaptcha_v2", sitekey, pageurl, submittedAt);

  const submit = await axios.post("https://ocr.captchaai.com/in.php", null, {
    params: { key: API_KEY, method: "userrecaptcha", googlekey: sitekey, pageurl, json: 1 },
  });

  if (submit.data.status !== 1) {
    db.prepare("UPDATE captcha_solves SET status=?, error=? WHERE id=?")
      .run("error", submit.data.request, lastInsertRowid);
    return null;
  }

  const captchaId = submit.data.request;
  db.prepare("UPDATE captcha_solves SET captcha_id=?, status=? WHERE id=?")
    .run(captchaId, "polling", lastInsertRowid);

  let polls = 0;
  for (let i = 0; i < 60; i++) {
    await new Promise((r) => setTimeout(r, 5000));
    polls++;
    const poll = await axios.get("https://ocr.captchaai.com/res.php", {
      params: { key: API_KEY, action: "get", id: captchaId, json: 1 },
    });

    if (poll.data.status === 1) {
      const elapsed = Date.now() - new Date(submittedAt).getTime();
      db.prepare(
        "UPDATE captcha_solves SET status=?, solution=?, solved_at=?, elapsed_ms=?, polls=? WHERE id=?"
      ).run("solved", poll.data.request, new Date().toISOString(), elapsed, polls, lastInsertRowid);
      return poll.data.request;
    }
    if (poll.data.request !== "CAPCHA_NOT_READY") {
      db.prepare("UPDATE captcha_solves SET status=?, error=?, polls=? WHERE id=?")
        .run("error", poll.data.request, polls, lastInsertRowid);
      return null;
    }
  }

  db.prepare("UPDATE captcha_solves SET status=?, polls=? WHERE id=?")
    .run("timeout", polls, lastInsertRowid);
  return null;
}

A estrutura repete a versão em Python: insere, envia, consulta e atualiza, com instruções preparadas uma vez e reaproveitadas.

Um cenário concreto: QA autorizado com workers em São Paulo

Uma equipe de QA roda testes de integração contra o próprio ambiente de staging (https://staging.example.com/checkout), com workers em São Paulo (sa-east-1). Nas primeiras noites ninguém sabe se a lentidão vem da resolução do CAPTCHA ou da aplicação.

Duas semanas de dados encerram a discussão: AVG(elapsed_ms) mostra o custo real da resolução e a coluna polls indica se o intervalo de 5 s entre consultas gera espera desnecessária. O número também dimensiona o plano — no BASIC (US$ 15/mês, 5 threads), cinco resoluções simultâneas é o teto; com fila constante, o degrau seguinte é o STANDARD (US$ 30/mês, 15 threads). A cobrança da CaptchaAI é por thread simultânea, com resoluções ilimitadas por thread.

Um lembrete de conformidade: se os testes tocam dados de pessoas reais, a LGPD (ou o RGPD, em Portugal) vale também para esse arquivo — use dados fictícios em staging.

Erros comuns e como corrigir

Problema Causa Correção
database is locked Gravações simultâneas sem WAL Adicione PRAGMA journal_mode=WAL
O arquivo cresce sem parar Sem rotina de limpeza Execute cleanup_old_records() diariamente
Consultas lentas com muitos registros Índices ausentes Crie índices em submitted_at e type
O cache devolve token vencido Entradas expiradas não removidas Filtre por expires_at e limpe antes da leitura

Perguntas frequentes

O SQLite aguenta o volume de um pipeline em produção?

Com WAL ativo, ele sustenta a ordem de 1.000 gravações por segundo em disco local. Para uma máquina única com menos de 1.000 resoluções por hora, é confiável. Acima disso, migre para PostgreSQL ou MongoDB.

Posso usar o mesmo arquivo .db com vários workers?

Sim, desde que rodem no mesmo host e com WAL habilitado: as gravações ficam serializadas e o busy_timeout faz cada worker esperar em vez de falhar. Compartilhar o arquivo por NFS não funciona — o lock deixa de ser confiável.

Vale a pena cachear tokens de reCAPTCHA?

Só dentro da janela de validade e para o mesmo par sitekey/pageurl. Como o token é de uso único e expira em 90 a 120 segundos, o ganho aparece nas retentativas imediatas do mesmo formulário.

O que esse banco muda no meu custo com a CaptchaAI?

Ele mostra a sua concorrência real. Como os planos são cobrados por thread simultânea (BASIC US$ 15/mês com 5 threads, STANDARD US$ 30/mês com 15), o dado útil é o pico de resoluções em andamento, não o total do mês. Os planos estão no site da CaptchaAI.

Devo guardar o token completo da solução?

Guarde por pouco tempo, só para depurar, e deixe a limpeza apagar os registros antigos. Depois de expirado, o token não tem utilidade e só aumenta o arquivo.

Próximos passos

Crie as duas tabelas, ligue o WAL e agende a limpeza diária. Em uma semana você terá dados para dimensionar threads com números, não com impressões.

Leituras relacionadas:

Os comentários estão desativados para este artigo.