Pular para o conteúdo
Node.js

Shifter Scraping API: coletando dados sem ser bloqueado

Ilustração de um unicórnio de crina arco-íris atravessando um portal de luz aberto num muro de tijolos, com um cadeado destrancado ao lado, uma coruja mágica carregando páginas web e tabelas de dados, e ao fundo um castelo com velas flutuantes e um globo com pontos acesos pelo mundo

Olá meus Unicórnios! 🦄✨

Sabe quando você precisa de um dado que está ali, na cara, numa página pública, e pensa: "é só um fetch e um pouco de regex"? 😅 Pois é. Aí o fetch volta com 403 e uma página escrita "Just a moment...". Ou volta com 200, todo feliz, e os dados que você queria simplesmente não estão no HTML.

Este artigo é sobre a Scraping API da Shifter, um serviço que faz o trabalho chato no seu lugar: você manda a URL, ele abre a página num navegador de verdade, passando por proxies e CAPTCHAs, e te devolve o HTML pronto. Vou mostrar primeiro os dois problemas que ela resolve (com a saída real de um fetch comum tomando porta na cara), depois o script em Node.js que usa a API, e no meio do caminho as armadilhas que encontrei lendo a documentação dela. Tem três, e uma delas é a própria vitrine do produto se contradizendo. 🙃

Shifter: API de Web ScrapingA página do produto, com os planos e o exemplo de requisição.shifter.io

🧱 O primeiro problema: o fetch que para no "Just a moment..."

Para mostrar o bloqueio acontecendo, usei o ScrapingCourse, um site feito justamente para quem está aprendendo scraping. Ele tem páginas de propósito com cada tipo de proteção, então dá para ver o problema sem incomodar ninguém.

O script mais simples possível: baixa a página e mostra o código HTTP, o título e o tamanho.

// direto.js: tenta baixar a página com um fetch comum, sem ajuda nenhuma
// Uso: node direto.js https://site-que-voce-quer.com/pagina

const alvo = process.argv[2];

async function principal() {
    const resposta = await fetch(alvo);
    const html = await resposta.text();
    const titulo = html.match(/<title>(.*?)<\/title>/);

    console.log("HTTP " + resposta.status);
    console.log("Título: " + (titulo ? titulo[1] : "(sem título)"));
    console.log("Tamanho: " + html.length + " caracteres");
}

principal();

Apontando para a página protegida pela Cloudflare:

> node direto.js https://www.scrapingcourse.com/cloudflare-challenge
HTTP 403
Título: Just a moment...
Tamanho: 5558 caracteres

Repare no detalhe: o servidor respondeu. Não foi erro de rede, não foi timeout. Ele devolveu uma página inteira, só que a página errada. Aquele "Just a moment..." é o desafio da Cloudflare: um pedaço de JavaScript que o navegador precisa executar para provar que é um navegador. O fetch do Node não executa JavaScript nenhum, então fica parado ali para sempre, com um 403 na mão.

E aqui mora a primeira pegadinha para quem está começando: o fetch não lança erro em 403. Ele só lança erro quando a conexão falha. Se o seu código não olhar o resposta.status, ele vai processar a página do desafio achando que é a página do produto, e salvar 5.558 caracteres de nada no seu banco. 😬

👻 O segundo problema: a página que chega com 200 e vazia

Esse é mais traiçoeiro, porque nada parece errado. Na página de treino de JavaScript do mesmo site:

> node direto.js https://www.scrapingcourse.com/javascript-rendering
HTTP 200
Título: JS Rendering Challenge to Learn Web Scraping - ScrapingCourse.com
Tamanho: 29640 caracteres

HTTP 200, título certinho, quase 30 mil caracteres. Tudo lindo. Agora vamos procurar o nome dos produtos, que no navegador aparecem numa grade bonitinha:

> curl -s https://www.scrapingcourse.com/javascript-rendering | grep -c 'itemprop="name"></span>'
12

Doze campos de nome, todos vazios: <span ... itemprop="name"></span>. O único lugar do HTML onde aparece um nome de produto é dentro de um trecho de JavaScript, como ${product.name}. Ou seja: o HTML que o servidor entrega é só o esqueleto, e quem preenche os produtos é o JavaScript da página, rodando no navegador depois que ela carrega.

Isso é o que se chama de página renderizada no cliente (as tais SPAs, feitas em React, Vue e companhia). Para um fetch, ela é uma caixa vazia com etiqueta bonita. 📦

🦄 O que a Shifter faz no seu lugar

Os dois problemas têm a mesma raiz: o site espera um navegador de verdade, e de preferência um que não pareça um robô. Dá para resolver sozinho, claro. Você sobe um Chrome sem tela (o tal headless) com Puppeteer ou Playwright, compra proxies para não ser bloqueado pelo IP, contrata um serviço para resolver CAPTCHA, escreve a lógica de tentar de novo com outro IP quando falha... e agora você tem um sistema de scraping para manter, em vez de um dado para usar.

A proposta da Shifter é juntar tudo isso atrás de um único endereço. Pela documentação, a Scraping API:

  • Abre a página num Chrome headless quando você pede render_js=1, e devolve o HTML depois que o JavaScript rodou. É exatamente o que faltava no segundo problema.
  • Troca de IP sozinha, usando o próprio conjunto de proxies da Shifter, que é uma empresa de proxies residenciais desde 2012 e anuncia mais de 205 milhões de IPs residenciais em mais de 195 países.
  • Resolve CAPTCHA no meio do caminho (reCAPTCHA, hCaptcha e desafios de bot), sem você contratar outro serviço para isso.
  • Tenta de novo sozinha, até 3 vezes com proxies diferentes, antes de te devolver um erro.
  • Só cobra o que deu certo: resposta com status 2xx e corpo não vazio. Timeout, erro de conexão e erro do site alvo não gastam crédito.

O pedido inteiro é um GET com a sua chave e a URL que você quer:

curl "https://scrape.shifter.io/v1?api_key=SUA_CHAVE_AQUI&url=https://example.com&render_js=1"

Só isso. Não tem SDK obrigatório nem biblioteca para instalar: qualquer coisa que faça uma requisição HTTP serve.

💳 Planos, créditos e a primeira contradição

Os planos que a Shifter publicava em setembro de 2026, na página do produto e na documentação:

PlanoCréditos por mêsSimultâneasPreçoProxies
Starter100 mil20US$ 44Datacenter (EUA e Europa)
Growth1 milhão50US$ 134Residenciais e móveis
Business3 milhões100US$ 269Residenciais e móveis
Enterprise10 milhões500US$ 719Residenciais e móveis

Não há plano gratuito: o FAQ da documentação fala em garantia de reembolso de 3 dias na primeira compra.

Agora, quanto custa cada requisição? Aqui eu li duas páginas da própria Shifter e recebi duas respostas diferentes. 🤯

  • A documentação, na página de erros e limites, é categórica: requisição com render_js=1 custa 1 crédito, "sem sobretaxa". O FAQ dela repete: renderização, screenshot e regras de extração estão incluídos sem custo extra.
  • O FAQ da página do produto em português diz outra coisa: datacenter simples custa 1 crédito, datacenter com JavaScript 5, residencial simples 10 e residencial com JavaScript 25.

Entre 1 e 25 créditos por requisição, o seu plano de 100 mil créditos vira 100 mil páginas ou 4 mil páginas. Não é detalhe. Eu não sei qual das duas está desatualizada, e não vou fingir que sei. O que dá para fazer é não confiar em nenhuma das duas para orçamento: rode um punhado de requisições do tipo que você vai usar e olhe o consumo real no painel, em Web Scraping API → Usage. É esse número que vale.

🔑 Pegando a chave e preparando o terminal

Depois de assinar, a chave fica no painel da Shifter, em Web Scraping API → API Keys. Copie uma chave ativa.

Você vai precisar do Node.js 18 ou mais novo, porque é a partir dele que o fetch vem embutido e o script não precisa instalar nada. Para conferir a versão, abra o terminal e digite:

node -v

Se aparecer v18 ou um número maior, está tudo certo. Se aparecer um número menor, ou "comando não encontrado", instale a versão LTS pelo site oficial do Node.js.

Agora, a chave. Ela não entra no código. Nunca. Nem "só para testar". O script lê a chave de uma variável de ambiente, que é um valor que você define no terminal e que vale enquanto aquela janela estiver aberta. Assim o arquivo pode ser copiado, mandado para alguém ou subido no GitHub sem levar a sua chave junto.

No Windows, pelo PowerShell:

$env:SHIFTER_API_KEY = "SUA_CHAVE_AQUI"

No Linux ou no macOS:

export SHIFTER_API_KEY="SUA_CHAVE_AQUI"

Troque SUA_CHAVE_AQUI pela chave que você copiou, mantendo as aspas. Para colar no terminal, use botão direito ou Ctrl+Shift+V: o Ctrl+V comum não funciona em vários terminais e não faz nada, o que assusta na primeira vez. E lembre: fechou a janela do terminal, a variável foi junto. Na próxima vez, defina de novo.

📜 O script completo

Crie uma pasta para o projeto, e dentro dela um arquivo chamado raspar.js (pelo VS Code, ou até pelo Bloco de Notas, desde que o nome termine em .js e não em .js.txt). Cole isto dentro:

// raspar.js: baixa uma página pela Scraping API da Shifter e salva o HTML
// Uso: node raspar.js https://site-que-voce-quer.com/pagina

const fs = require("fs");

const ENDERECO = "https://scrape.shifter.io/v1";
const chave = process.env.SHIFTER_API_KEY;
const alvo = process.argv[2];

// O que cada código de erro quer dizer, segundo a documentação da Shifter
const ERROS = {
    400: "Pedido malformado. Confira a URL do alvo.",
    401: "Chave inválida ou ausente. Confira a SHIFTER_API_KEY no painel.",
    403: "Seu plano não permite essa opção.",
    408: "O site demorou demais para responder.",
    422: "O navegador da Shifter não conseguiu montar a página.",
    429: "Passou do limite de requisições simultâneas do plano.",
    500: "Erro interno da Shifter.",
    509: "Os créditos do plano acabaram.",
};

// Só estes valem uma nova tentativa. Os outros são erro de configuração:
// repetir um 401 três vezes dá 401 três vezes.
const PODE_REPETIR = [408, 422, 429, 500];

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

async function baixar(endereco) {
    // O URLSearchParams codifica a URL do alvo. Sem ele, um "&" dentro
    // dela vira parâmetro da Shifter e o alvo chega cortado.
    const parametros = new URLSearchParams({
        api_key: chave,
        url: endereco,
        render_js: "1",
    });

    for (let tentativa = 1; tentativa <= 3; tentativa++) {
        const resposta = await fetch(ENDERECO + "?" + parametros);

        if (resposta.ok) {
            // Sem extract_rules a resposta é HTML: use .text(), nunca .json()
            return await resposta.text();
        }

        const motivo = ERROS[resposta.status] || "Resposta inesperada.";
        console.log("Tentativa " + tentativa + ": HTTP " + resposta.status + ". " + motivo);

        if (!PODE_REPETIR.includes(resposta.status) || tentativa === 3) {
            break;
        }
        await esperar(tentativa * 2000); // 2 segundos, depois 4
    }

    throw new Error("não consegui baixar " + endereco);
}

async function principal() {
    if (!chave) {
        console.error("Defina a variável SHIFTER_API_KEY antes de rodar.");
        process.exit(1);
    }
    if (!alvo) {
        console.error("Uso: node raspar.js https://site-que-voce-quer.com/pagina");
        process.exit(1);
    }

    try {
        const html = await baixar(alvo);
        fs.writeFileSync("pagina.html", html);
        console.log("Salvei " + html.length + " caracteres em pagina.html");
    } catch (erro) {
        console.error("Deu errado: " + erro.message);
        process.exitCode = 1;
    }
}

principal();

Para rodar, no terminal, dentro da pasta do projeto (a mesma janela onde você definiu a chave):

node raspar.js https://www.scrapingcourse.com/javascript-rendering

Se der certo, aparece um arquivo pagina.html na pasta, com o HTML depois que o JavaScript rodou, que é o que a documentação promete para o render_js=1: o mesmo HTML que você veria no Chrome. Se der errado, o script diz o motivo em português e termina com código de saída 1, para que um agendador ou outro script perceba a falha.

O código é curto, mas três linhas dele existem por causa de armadilha. Vamos a elas.

🔗 Armadilha 1: o "&" que corta a sua URL

A documentação da Shifter mostra tudo em curl, com a URL alvo colada direto no endereço: ...&url=https://example.com&render_js=1. Funciona com example.com. Agora tente com uma URL de busca, que quase sempre tem parâmetros:

const alvo = "https://loja.exemplo.com/busca?q=tenis&pagina=2";

// Do jeito errado: colando o texto
const errado = new URL("https://scrape.shifter.io/v1?api_key=X&url=" + alvo);
console.log("Colando:        url =", errado.searchParams.get("url"));
console.log("                pagina =", errado.searchParams.get("pagina"));

// Do jeito certo: URLSearchParams
const certo = new URL("https://scrape.shifter.io/v1?" + new URLSearchParams({ api_key: "X", url: alvo }));
console.log("URLSearchParams: url =", certo.searchParams.get("url"));
Colando:        url = https://loja.exemplo.com/busca?q=tenis
                pagina = 2
URLSearchParams: url = https://loja.exemplo.com/busca?q=tenis&pagina=2

Colando o texto, o &pagina=2 deixou de ser da loja e virou parâmetro da Shifter. Ela recebe url=https://loja.exemplo.com/busca?q=tenis e baixa sempre a primeira página da busca. Sem erro nenhum, com HTTP 200, e cobrando o crédito. Você só percebe quando nota que as páginas 2, 3 e 4 têm exatamente os mesmos produtos. 😳

O URLSearchParams resolve isso sozinho: ele transforma o & da URL alvo em %26, e a Shifter recebe o endereço inteiro. É por isso que o script monta os parâmetros com ele, e não juntando texto.

🧨 Armadilha 2: o .json() da vitrine

A página do produto tem um exemplo em Node.js, e ele termina assim:

const res = await fetch(`https://scrape.shifter.io/v1?${params}`);
const data = await res.json();
console.log(data.title, data.price);

Parece ótimo: você pede a página e recebe um objeto com title e price. Só que os parâmetros desse exemplo são api_key, url, render_js e country, e a documentação é clara: sem extract_rules, a resposta é o HTML cru. E HTML não é JSON. O que o res.json() faz com um HTML é isto:

SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON

Esse erro é um clássico, e costuma mandar a gente procurar o problema no lugar errado (será que a chave está errada? será que a API mudou?). Não: é só o .json() tentando ler uma página HTML. No raspar.js a resposta é lida com .text(), e o comentário na linha está lá para ninguém "corrigir" isso depois.

E enquanto você lê essa mesma página, repare: o quadro de exemplo no topo mostra o parâmetro como "render": true, e o FAQ também manda "definir render=true". Mas o código logo abaixo, e todos os exemplos da documentação, usam render_js=1. Use render_js=1, que é o nome que a documentação da API descreve.

🚦 Armadilha 3: nem todo erro merece nova tentativa

A tentação, quando uma API falha, é colocar um "tenta de novo até dar certo" em volta de tudo. A tabela de erros da documentação ajuda a separar dois grupos bem diferentes:

CódigoO que aconteceuTentar de novo?
408O site alvo demorou demaisSim
422A renderização falhou (erro de JavaScript ou de navegação)Sim
429Passou do limite de requisições simultâneas do planoSim, esperando um pouco
500Erro interno da ShifterSim, esperando um pouco
400Parâmetro malformadoNão: corrija o pedido
401Chave inválida ou ausenteNão: corrija a chave
403O plano não permite o recurso pedidoNão: mude o plano ou tire a opção
509Os créditos do plano acabaramNão: só no próximo ciclo

O primeiro grupo é instabilidade: pode dar certo daqui a pouco. O segundo é configuração, e ele não se conserta sozinho. Repetir um 401 em loop só enche o seu log de 401. Por isso o script tem a lista PODE_REPETIR e sai do laço (break) no primeiro erro que não está nela.

Outro detalhe do laço: a espera cresce a cada tentativa (tentativa * 2000, ou seja, 2 segundos e depois 4). Isso importa principalmente no 429: se você estourou o limite de simultâneas e tenta de novo na mesma hora, continua estourado. E a condição tentativa === 3 no if não é enfeite: sem ela, depois da última falha o script ainda esperaria 6 segundos à toa antes de desistir. 😅

Lembre também que a Shifter já tenta 3 vezes do lado dela, trocando de proxy, antes de te devolver o erro. Então quando o erro chega até você, ele já é teimoso. Três tentativas do seu lado são mais que suficientes.

🧩 Recebendo JSON em vez de HTML

Com o HTML em mãos, você ainda precisa extrair os dados dele. A Shifter oferece um atalho: o parâmetro extract_rules, onde você diz quais seletores CSS viram quais campos, e ela devolve JSON pronto. Para uma lista de produtos, as regras ficam assim:

const regras = {
    produtos: {
        selector: ".product-item",
        type: "list",
        item: {
            nome: { selector: ".product-name", output: "text" },
            preco: { selector: ".product-price", output: "text" },
            link: { selector: "a", output: "@href" },
        },
    },
};

const parametros = new URLSearchParams({
    api_key: chave,
    url: endereco,
    render_js: "1",
    extract_rules: JSON.stringify(regras),
});

Três detalhes que a documentação explica e que valem ouro:

  • output pode ser "text" (o texto do elemento), "html" (o HTML de dentro dele) ou "@" seguido do nome de um atributo ("@href" pega o link, "@content" pega o conteúdo de uma meta tag).
  • type: "list" com item é o que transforma "todos os .product-item da página" numa lista.
  • Se um campo não existir na página, ele volta como null, e a requisição não falha por isso.

Na documentação, os exemplos de curl mandam as regras já codificadas à mão, do tipo %7B%22title%22%3A.... No Node, não faça isso: JSON.stringify transforma o objeto em texto, e o URLSearchParams faz a codificação, do mesmo jeito que fez com a URL alvo na armadilha 1.

Com as regras acima, o formato da resposta, conforme o exemplo de listas da documentação, é este:

{
    "produtos": [
        {
            "nome": "Item A",
            "preco": "$19.99",
            "link": "/item-a"
        },
        {
            "nome": "Item B",
            "preco": "$24.50",
            "link": "/item-b"
        }
    ]
}

E agora, só agora, o resposta.json() é o certo. É a mesma API com dois formatos de resposta diferentes, e quem decide qual vem é a presença do extract_rules. Troque o .text() do raspar.js quando acrescentar as regras, e não antes.

🌎 País, sessão e as outras opções

Além do render_js e do extract_rules, estas são as opções da documentação que mais aparecem no dia a dia. Todas entram como mais uma linha no URLSearchParams:

ParâmetroPara que serve
countrySai por um IP daquele país, com o código de duas letras (us, de, jp). Útil quando o site mostra preço ou conteúdo diferente por região.
premium_proxy=1Usa um IP residencial, que parece muito mais com um visitante comum do que um IP de datacenter.
wait_for_cssEspera um seletor aparecer na página antes de devolver o HTML (o padrão é esperar até 30 segundos).
session_idUm nome que você inventa. Pedidos com o mesmo nome reaproveitam cookies e o mesmo IP, por até 10 minutos parado.
timeoutTempo máximo, em milissegundos, que o navegador pode gastar na página.

Um cuidado com a sessão, que a documentação avisa e que é fácil de ignorar: não mude o country no meio de uma sessão. Se o primeiro pedido saiu pelos EUA e o segundo sai pela Alemanha com o mesmo session_id, os cookies que o site amarrou à região podem deixar de valer.

⚖️ Scraping com responsabilidade

Uma API que passa por bloqueio é uma ferramenta poderosa, e isso pede um pouco de juízo. Quatro coisas que eu olho antes de apontar um script para um site:

  • O robots.txt. Ele fica em https://site.com/robots.txt e diz o que o dono do site não quer que robôs visitem. Olhei o do próprio ScrapingCourse, o site de treino deste artigo, e ele tem Disallow: /ecommerce/*: mesmo um site feito para ensinar scraping pede que a loja de exemplo dele fique de fora. As duas páginas que usei aqui estão fora dessa regra.
  • Os termos de uso. Muito site proíbe coleta automatizada por contrato. Contornar a barreira técnica não muda o que o contrato diz.
  • A LGPD. Nome, telefone, e-mail e qualquer dado que identifique uma pessoa continuam sendo dado pessoal mesmo numa página pública. Coletar isso exige uma base legal e uma finalidade, e guardar "porque pode ser útil um dia" não é uma delas.
  • O servidor do outro lado. O número de requisições simultâneas do plano (que chega a 500) é um teto, não um objetivo. Colete o que precisa, na velocidade que um site pequeno aguenta.

Preço público de produto, vaga de emprego publicada, dado aberto de governo: é o tipo de coisa para a qual essa ferramenta foi feita. O resto merece uma pausa antes do node raspar.js. 🙏

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

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

Perguntas frequentes

Por que o meu fetch recebe 403 e uma página "Just a moment..."?
Porque o site está atrás de uma proteção anti-bot (no caso do "Just a moment...", a da Cloudflare). O servidor devolve uma página de desafio que só um navegador de verdade, rodando JavaScript, consegue passar. Um fetch comum não roda JavaScript nenhum, então fica parado no desafio com HTTP 403.
É render=true ou render_js=1?
Use render_js=1. A própria página do produto mostra "render": true no quadro de exemplo e na resposta do FAQ, mas a documentação da API e o código de exemplo da mesma página usam render_js=1, e é esse o nome que aparece em todos os exemplos de curl.
Quanto custa uma requisição com JavaScript renderizado?
Depende de qual página da Shifter você lê. A documentação diz que render_js=1 custa 1 crédito, igual à requisição simples. O FAQ da página do produto em português diz 5 créditos no datacenter e 25 no residencial. Antes de fazer conta de orçamento, confira o consumo real no painel, em Web Scraping API, Usage.
A Shifter tem plano gratuito?
Não. Em setembro de 2026 o FAQ da documentação fala em garantia de reembolso de 3 dias na primeira compra, e o plano mais barato (Starter) custa US$ 44 por mês, com 100 mil créditos e proxies de datacenter nos EUA e na Europa.
Quais erros da Shifter vale a pena tentar de novo?
Os de instabilidade: 408 (o site demorou), 422 (a renderização falhou), 429 (limite de simultâneas) e 500 (erro interno). Já 400, 401, 403 e 509 são configuração, chave ou créditos: repetir não muda nada. Requisição que falha não gasta crédito.
Fazer web scraping é permitido?
Depende do que e de como. Leia o robots.txt e os termos de uso do site, não sobrecarregue o servidor, e lembre que dado pessoal (nome, telefone, e-mail) continua protegido pela LGPD mesmo estando numa página pública. Contornar o bloqueio técnico não transforma em permitido o que o site proíbe.

Leia também