Node.js: resolvendo CAPTCHA com o Anti-Captcha
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:
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?
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?
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?
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?
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?
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?
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
API Mágica: CEP e Pix de graça, sem cartão
Lancei a API Mágica: CEP, QR Code Pix, geradores e mais, de graça. Veja como consultar e gerar com Node.js em poucas linhas.
Resend: enviando e recebendo e-mails com Node.js
Tutorial da Resend com Node.js: criar a chave, enviar com fetch, verificar o domínio e receber e-mails por webhook conferindo a assinatura.
Node.js: testando scripts de scraping no ScrapingCourse
O ScrapingCourse é um site feito para treinar scraping. Cinco desafios dele em Node.js, e a armadilha real que cada um esconde.