Pular para o conteúdo
Node.js

Node.js: resolvendo CAPTCHA com o Anti-Captcha

Ilustração colorida de um unicórnio de crina arco-íris diante de um portão de castelo coberto de runas tortas, com uma coruja sussurrando a resposta e varinhas lançando feixes de luz que decifram as runas

Olá meus Unicórnios! 🦄✨

Sabe aquele momento em que você automatiza um sistema inteiro, tudo lindo, tudo funcionando, e aí aparece aquela caixinha com as letras tortas? 😅 Pois é. O script para ali, olhando para a tela, sem a menor ideia do que fazer.

Foi o que aconteceu comigo testando o meu próprio formulário. Eu queria rodar o teste de ponta a ponta, incluindo o envio de verdade, e o reCAPTCHA que eu mesma tinha colocado ali estava me barrando. A ironia não passou despercebida. 🙃

A saída foi a API do Anti-Captcha, um serviço em que pessoas de verdade resolvem o desafio por você e devolvem a resposta. Este artigo é o que eu aprendi usando ela com Node.js puro: como o fluxo de duas etapas funciona, como esperar sem levar bloqueio, os dois tipos de tarefa que interessam e onde a resposta engana.

🧩 Por que são dois passos, e não um

A primeira coisa que estranha quem chega na documentação é que não existe um "resolva este CAPTCHA e me devolva a resposta". São duas chamadas separadas, e isso faz todo sentido quando você lembra quem está do outro lado.

Do outro lado tem gente. Uma pessoa precisa receber a sua imagem, olhar, digitar. Isso leva alguns segundos, às vezes bem mais. Nenhuma API razoável deixaria a sua conexão HTTP pendurada esperando isso acontecer.

Então o fluxo é este:

  seu codigo                    Anti-Captcha
      |                              |
      |---- createTask ------------->|   "resolve esta imagem para mim"
      |<--- taskId: 918273645 -------|   "anotado, e este o numero"
      |                              |
      |                          (uma pessoa resolve)
      |                              |
      |---- getTaskResult ---------->|   "ja ficou pronto?"
      |<--- status: processing ------|   "ainda nao"
      |                              |
      |---- getTaskResult ---------->|   "e agora?"
      |<--- status: ready + solucao -|   "pronto, a resposta e esta"

Você cria a tarefa, guarda o número dela, e depois fica perguntando de tempos em tempos se já ficou pronta. É o mesmo padrão de qualquer processamento demorado, e a parte que dá trabalho é justamente o "de tempos em tempos".

🔑 A chave nunca fica no arquivo

Antes de qualquer linha: a sua chave do Anti-Captcha é uma credencial, e credencial não mora dentro do código. Ela paga por cada CAPTCHA resolvido, então quem pegar a sua chave gasta o seu dinheiro.

O jeito certo é ler do ambiente. No Linux ou no macOS:

export ANTICAPTCHA_KEY=SUA_CHAVE_AQUI
node captcha.js captcha.png

No Windows, pelo PowerShell:

$env:ANTICAPTCHA_KEY = "SUA_CHAVE_AQUI"
node captcha.js captcha.png

E no código, uma linha só:

const CHAVE = process.env.ANTICAPTCHA_KEY;

Se você está escrevendo isto num repositório, aproveite e ponha o .env no .gitignore antes de esquecer. Chave vazada no histórico do git continua lá depois de você apagar do arquivo. 😬

📮 A função que fala com a API

Todas as três chamadas que interessam (createTask, getTaskResult e getBalance) são iguais na forma: um POST com JSON, para https://api.anti-captcha.com/ mais o nome do método, sempre com a sua chave dentro do corpo. Dá para escrever uma função só e nunca mais repetir isso.

E aqui mora a primeira armadilha, a que me pegou:

const API = "https://api.anti-captcha.com";

// Toda chamada e um POST com JSON. Quem diz se deu errado nao e o status
// HTTP: e o campo errorId do corpo. Zero e sucesso, qualquer numero maior
// que zero e erro, e o numero identifica qual. Por isso olhe sempre o corpo.
async function chamar(metodo, corpo) {
    const resposta = await fetch(API + "/" + metodo, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(Object.assign({ clientKey: CHAVE }, corpo)),
    });

    const dados = await resposta.json();

    if (dados.errorId > 0) {
        throw new Error(
            "Anti-Captcha recusou " + metodo + ": " +
            dados.errorCode + " (" + dados.errorDescription + ")"
        );
    }

    return dados;
}

Repare no if. Ele não olha resposta.ok, não olha resposta.status: olha o errorId de dentro do JSON. É esse campo que diz se deu certo, e ele vem 0 no sucesso e um número maior que zero no erro.

Quem escreve um if (!resposta.ok) throw por reflexo, como eu escrevi, passa direto por uma chave errada e só descobre o problema lá na frente, quando tenta usar um taskId que nunca existiu. Não custa nada checar o campo certo, e economiza meia hora de confusão.

💰 O saldo, que é a chamada mais fácil de escrever

O getBalance devolve quanto ainda tem na conta, em dólares. É a primeira coisa que vale testar, porque se ela funciona, a sua chave está certa e o resto é detalhe.

async function verSaldo() {
    const dados = await chamar("getBalance", {});
    return dados.balance;
}

🖼️ A tarefa de imagem

É a mais simples das duas: aquela imagem com letras tortas, que uma pessoa lê e digita. O tipo se chama ImageToTextTask, e a imagem viaja em base64 dentro do campo body.

function tarefaDeImagem(caminhoDaImagem) {
    // A imagem viaja em base64, sem o prefixo "data:image/png;base64,".
    const base64 = fs.readFileSync(caminhoDaImagem).toString("base64");
    return { type: "ImageToTextTask", body: base64 };
}

Aquele comentário de uma linha é o que me custou algumas tentativas. Se você pegou a imagem de dentro de um <img src="data:image/png;base64,iVBORw0...">, é tentador mandar a string inteirinha do jeito que ela está. Mas o body quer só o base64, sem o data:image/png;base64, na frente e sem quebras de linha.

Vale saber os limites, porque eles têm códigos de erro próprios: o arquivo precisa ter mais de 100 bytes e menos de 500.000 bytes, e só valem JPG, GIF e PNG. Abaixo do mínimo volta ERROR_ZERO_CAPTCHA_FILESIZE, acima do máximo volta ERROR_TOO_BIG_CAPTCHA_FILESIZE, e com outro formato volta ERROR_IMAGE_TYPE_NOT_SUPPORTED.

A tarefa aceita uns campos opcionais que ajudam bastante a pessoa do outro lado a acertar. numeric: 1 avisa que só tem dígito, numeric: 2 que só tem letra, math: true que o desafio é uma continha para calcular, e comment é uma instrução em texto, tipo "digite só o texto vermelho". Quanto mais específico, menos retrabalho.

🤖 A tarefa de reCAPTCHA

Esta é diferente na cabeça, e é onde muita gente se perde: você não manda imagem nenhuma. Manda o endereço da página e a chave pública do site, e alguém abre aquela página de verdade e clica no "não sou um robô".

function tarefaDeRecaptcha(enderecoDaPagina, chaveDoSite) {
    return {
        type: "RecaptchaV2TaskProxyless",
        websiteURL: enderecoDaPagina,
        websiteKey: chaveDoSite,
    };
}

A websiteKey é aquela chave pública que fica visível no HTML da página, no atributo data-sitekey do div do reCAPTCHA. Não é segredo nenhum: ela é pública por natureza, porque o navegador de todo mundo precisa dela para desenhar o widget.

O Proxyless no fim do nome quer dizer que o trabalho será feito a partir do IP do serviço, não do seu. Existe a versão que usa proxy, para quando o site trava a sessão no endereço de quem está navegando, e ela pede um monte de campo a mais. Para o caso de testar o próprio formulário, a versão sem proxy resolve.

Se o reCAPTCHA for do tipo invisível, aquele que não mostra caixinha nenhuma, acrescente isInvisible: true na tarefa. Sem isso, a pessoa do outro lado abre a página procurando um widget que não aparece, e a tarefa morre em ERROR_VISIBLE_RECAPTCHA.

⏳ A espera, que é a parte que dá trabalho

Criar a tarefa é uma linha. O que exige cuidado é a espera, porque é aqui que dá para escrever um laço que funciona e ainda assim te coloca em apuros.

async function criarTarefa(tarefa) {
    const dados = await chamar("createTask", { task: tarefa });
    return dados.taskId;
}

function esperar(milissegundos) {
    return new Promise(function (resolva) {
        setTimeout(resolva, milissegundos);
    });
}

async function pegarResultado(idDaTarefa) {
    // A primeira consulta so vale a pena depois de uns segundos: antes disso
    // a resposta e sempre "processing" e voce so gasta requisicao a toa.
    await esperar(5000);

    for (let tentativa = 1; tentativa <= 40; tentativa++) {
        const dados = await chamar("getTaskResult", { taskId: idDaTarefa });

        // Cuidado: quando o trabalhador nao consegue resolver, a tarefa fica
        // "ready" do mesmo jeito, com errorId 0, e a solucao vem vazia.
        // Se voce so testar o status, vai achar que deu certo e seguir com undefined.
        if (dados.status === "ready") {
            if (!dados.solution || (!dados.solution.text && !dados.solution.gRecaptchaResponse)) {
                throw new Error("A tarefa terminou sem resposta: " + (dados.errorCode || "solucao vazia"));
            }
            return dados.solution.text || dados.solution.gRecaptchaResponse;
        }

        await esperar(3000);
    }

    throw new Error("A tarefa " + idDaTarefa + " nao ficou pronta a tempo.");
}

São três decisões nesse pedaço, e cada uma evita um problema diferente.

A espera antes da primeira consulta. Tem uma pessoa lendo a imagem do outro lado; ninguém faz isso em 200 milissegundos. Perguntar logo depois de criar a tarefa só devolve processing e gasta requisição.

O intervalo entre as consultas. A recomendação que a própria Anti-Captcha dá no FAQ é de uns 5 segundos. O que você não pode fazer é um while sem espera nenhuma: a documentação tem um ERROR_IP_BLOCKED, descrito como bloqueio "por uso impróprio da API", e martelar o getTaskResult é exatamente o uso impróprio que ele descreve.

O limite de tentativas. O for com 40 voltas existe para o seu script não ficar preso para sempre se algo der errado do lado de lá. Um laço infinito esperando resposta de rede é sempre uma má ideia.

🎭 A armadilha: "pronto" não quer dizer "resolvido"

Se você só for ler um pedaço deste artigo, leia este. 🙏

Quando cinco trabalhadores diferentes tentam e nenhum consegue resolver a sua imagem, a API não te devolve um erro na chamada. A tarefa termina normalmente, com errorId igual a 0, e com status igual a ready. Tudo com cara de sucesso.

Só que o solution vem vazio, e o motivo aparece num errorCode ali do lado:

{
    "errorId": 0,
    "status": "ready",
    "solution": {},
    "errorCode": "ERROR_CAPTCHA_UNSOLVABLE"
}

Isso mesmo: errorId zero, status pronto, e nenhuma resposta. 🤯 O código ingênuo, aquele que todo mundo escreve na primeira versão, faz assim:

// NAO faca assim
if (dados.status === "ready") {
    return dados.solution.text;   // devolve undefined e ninguem percebe
}

E aí o seu programa segue felizão, preenchendo o formulário com undefined, levando erro de validação lá na frente, e você procurando bug no lugar errado. Foi exatamente o que aconteceu comigo, e o pior é que a mensagem de erro do formulário não ajudava em nada.

Por isso o código lá de cima checa as duas coisas: se ficou pronto e se veio resposta dentro. Sem resposta, ele levanta um erro com o errorCode junto, que é o que te diz o que houve de verdade.

🗂️ Onde cada resposta se esconde

Os dois tipos de tarefa entregam a resposta no mesmo objeto solution, mas em campos de nomes diferentes. É um detalhe bobo que faz perder tempo:

  Tipo da tarefa                  Onde esta a resposta
  ---------------------------------------------------------------
  ImageToTextTask                 solution.text
  RecaptchaV2TaskProxyless        solution.gRecaptchaResponse

Na tarefa de imagem, solution.text é literalmente o que a pessoa digitou olhando o desenho, e é isso que você põe no campo do formulário.

No reCAPTCHA, o solution.gRecaptchaResponse é um token comprido que você coloca no campo escondido g-recaptcha-response antes de enviar o formulário. Vem junto um solution.userAgent, o navegador que a pessoa usou, que alguns sites conferem: se o seu envio usar um user agent diferente do que gerou o token, o site pode recusar mesmo com o token certo.

🚨 Os erros que você vai encontrar de verdade

A lista completa da documentação tem quase quarenta códigos, mas na prática você esbarra sempre nos mesmos. Estes são os que valem conhecer de cabeça:

  Codigo                          O que aconteceu
  ------------------------------------------------------------------------
  ERROR_KEY_DOES_NOT_EXIST        A chave esta errada ou nao existe mais
  ERROR_ZERO_BALANCE              A conta zerou, ponha credito
  ERROR_NO_SLOT_AVAILABLE         Nenhum trabalhador livre agora, tente depois
  ERROR_CAPTCHA_UNSOLVABLE        5 pessoas tentaram e nenhuma conseguiu
  ERROR_IP_BLOCKED                Seu IP levou bloqueio por uso impróprio
  ERROR_RECAPTCHA_INVALID_SITEKEY O provedor disse que a websiteKey nao vale
  ERROR_TASK_NOT_SUPPORTED        Voce digitou errado o "type" da tarefa

Repare que dois deles não são culpa sua e passam sozinhos: o ERROR_NO_SLOT_AVAILABLE quer dizer só que não tem gente livre neste instante, e o ERROR_CAPTCHA_UNSOLVABLE quer dizer que aquela imagem específica era difícil demais. Nos dois casos, tentar de novo daqui a pouco costuma resolver, e é aí que uma nova tentativa faz sentido.

Os outros são problema de configuração e não adianta insistir. Bater de novo num ERROR_KEY_DOES_NOT_EXIST mil vezes só vai te render um ERROR_IP_BLOCKED de brinde. 😅

🧵 Juntando tudo

Com as peças no lugar, resolver vira três linhas, e o main() no fim amarra tudo:

async function resolver(tarefa) {
    const idDaTarefa = await criarTarefa(tarefa);
    console.log("Tarefa criada: " + idDaTarefa);
    return await pegarResultado(idDaTarefa);
}

async function main() {
    if (!CHAVE) {
        console.error("Defina a variavel ANTICAPTCHA_KEY antes de rodar.");
        process.exit(1);
    }

    try {
        console.log("Saldo: US$ " + (await verSaldo()).toFixed(4));

        const caminho = process.argv[2];
        if (!caminho) {
            console.log("Passe o caminho de uma imagem: node captcha.js captcha.png");
            return;
        }

        const resposta = await resolver(tarefaDeImagem(caminho));
        console.log("Resposta do CAPTCHA: " + resposta);
    } catch (erro) {
        console.error("Falhou: " + erro.message);
        process.exit(1);
    }
}

main();

Um detalhe que parece besteira mas não é: o process.exit(1) dentro do catch. Sem ele, o seu script termina com código 0 mesmo tendo falhado, e qualquer coisa que chame esse script (um agendador, um teste automatizado, um outro programa) vai achar que deu tudo certo.

E o catch aqui faz alguma coisa: imprime a mensagem em português e devolve o código de saída certo. Um catch que só engole o erro seria pior que não ter catch nenhum.

🖥️ Rodando

Com a chave no ambiente, é isto que aparece no terminal, tanto quando dá certo quanto quando a chave está errada:

Terminal mostrando o saldo em dólares, o número da tarefa criada, a resposta do CAPTCHA, e em seguida a mensagem de falha ERROR_KEY_DOES_NOT_EXIST com código de saída 1

Repare na segunda metade da tela. Aquela mensagem de erro, com o errorCode e a descrição em texto, é exatamente o que a função chamar() monta quando o errorId vem maior que zero. E o echo $? mostrando 1 é o process.exit(1) fazendo o trabalho dele.

Esse é o valor de tratar o erro direito: em vez de um undefined misterioso três funções adiante, você lê na cara qual foi o problema e vai consertar a coisa certa. 💚

💸 Uma palavra sobre o custo

Cada CAPTCHA resolvido custa uma fração de centavo de dólar, e é cobrado do saldo da conta. Individualmente é irrisório; num laço que roda sozinho, a conta cresce e ela dói quando cresce.

Dois cuidados bobos evitam susto. O primeiro é o limite de tentativas que já está no código: script preso em laço é script gastando. O segundo é lembrar que uma tarefa impossível também é cobrada pelas tentativas feitas, então imagem cortada ou ilegível sendo enviada em série é dinheiro indo embora sem resposta nenhuma voltar.

Por isso vale conferir o que você está mandando antes de mandar em volume. Uma imagem que nem você consegue ler não vai ficar mais fácil para a pessoa do outro lado. 😊

Por hoje é só, meus unicórnios! 🦄✨

Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟

Perguntas frequentes

Como sei que a chamada ao Anti-Captcha deu errado?
Olhando o campo errorId do corpo da resposta, nunca o status HTTP. O errorId vem 0 quando deu certo e um número maior que zero quando deu errado, e esse número identifica o problema: 1 é ERROR_KEY_DOES_NOT_EXIST, 10 é ERROR_ZERO_BALANCE, 2 é ERROR_NO_SLOT_AVAILABLE. Junto vêm errorCode e errorDescription em texto.
Por que minha resposta do CAPTCHA veio como undefined?
Porque você provavelmente testou só o status. Quando cinco trabalhadores diferentes não conseguem resolver a imagem, a tarefa termina com status igual a ready e errorId igual a 0, mas o objeto solution vem vazio e o errorCode traz ERROR_CAPTCHA_UNSOLVABLE. Se o seu código só pergunta "já ficou pronto?", ele segue em frente com undefined.
De quanto em quanto tempo devo consultar o getTaskResult?
A recomendação que a Anti-Captcha dá no FAQ dela é de uns 5 segundos entre as consultas. Vale também não perguntar imediatamente depois de criar a tarefa: nos primeiros segundos a resposta vai ser processing de qualquer jeito, e você só gasta requisição à toa. Consultar em laço apertado é justamente o tipo de uso que leva ao ERROR_IP_BLOCKED.
Onde fica a resposta de uma tarefa de imagem e de um reCAPTCHA?
Em campos diferentes do mesmo objeto solution. Na ImageToTextTask o texto digitado pelo trabalhador vem em solution.text. Na RecaptchaV2TaskProxyless vem em solution.gRecaptchaResponse, que é o token que você coloca no campo g-recaptcha-response do formulário, junto com um solution.userAgent.
Preciso mandar a imagem como arquivo ou como base64?
Como base64, dentro do campo body da tarefa, sem quebras de linha e sem o prefixo data:image/png;base64,. Só JPG, GIF e PNG são aceitos, o arquivo precisa ter mais de 100 bytes e menos de 500.000 bytes. Fora desses limites voltam o ERROR_ZERO_CAPTCHA_FILESIZE e o ERROR_TOO_BIG_CAPTCHA_FILESIZE.
Posso ficar consultando o saldo o tempo todo?
Não. A documentação do getBalance pede para não chamar mais de uma vez a cada 30 segundos e para guardar o valor em cache. É uma chamada barata de escrever e fácil de esquecer dentro de um laço, então deixe ela fora do ciclo que resolve os CAPTCHAs.

Leia também