1

O futuro do webscraping? Como fazer scrapers que não quebram quando a página muda utilizando IA e agentes (1/3)

Por muito e muito tempo, o webscraping (e automações baseadas em browser ou DOM extraction no geral) era inerentemente fadado a nunca ser perfeito. Você poderia ter o fingerprint perfeito, o browser perfeito, o workflow perfeito, lifecycle perfeito, proxy, absolutamente tudo, e mesmo assim, mais hora ou menos hora, você sofreria com uma dor irreparável: uma atualização de página.

Um pequeno seletor que mudou, uma árvore DOM com algumas alterações, uma mudança de texto num botão ou mesmo uma repaginada completa da página que você estava mirando. Qualquer uma dessas coisas poderia, e eventualmente iria, quebrar seu scraper. Era basicamente um trabalho infinito, imprevisível e ingrato manter uma extração ou automação recorrente sobre uma página que você não controlava. Até agora.

A IA foi um game changer em muitas áreas, e embora eu veja muita gente ensinando jeitos de ligar o Claude ou o Cursor a navegadores, posso contar nos dedos as vezes que vi alguém falando de integrar IA ao fluxo de extração de dados, webscraping e fluxos de automações em browser específicas, e a maioria das vezes que ouvi isso foi dentro da minha empresa atual, em conversas com meus liderados. Foi pensando nisso que resolvi começar a escrever uma série de artigos sobre o que considero ser o futuro do webscraping e das automações baseadas em navegador, que fico muito feliz de compartilhar com vocês a seguir.

Alguns disclaimers...

Antes de iniciar, gostaria de esclarecer algumas coisas que julgo necessárias para a boa interpretação desses artigos:

  1. Durante a escrita desse artigo, utilizarei como motor de automação o pain.headless, meu SaaS de browsers in cloud que atualmente se encontra em versão beta. Não estou utilizando ele por jabá ou por autopromoção, e sim porque quero testar os limites de meus browsers e estou extremamente familiarizado e encantado pela simplicidade do SDK. Isso não torna os exemplos/códigos aqui irreproduzíveis in natura, já que estou oferecendo 5 mil créditos de teste para qualquer um que queira fazer parte do BETA testing, sem compromisso algum. Além disso (embora eu goste de pensar que todo dev saiba disso), qualquer um dos exemplos que você verá aqui são perfeitamente adaptáveis para outras tecnologias/linguagens, como Selenium, Puppeteer ou Playwright, bastando apenas a força de vontade e interesse em fazê-lo.
  2. NÃO SOU UM ESPECIALISTA OU GURU DE IA. Embora goste de pensar que sei usar as ferramentas que nos proporciona, saiba que não sou um especialista em IA, modelos ou orquestração, sei apenas um pouco mais que o básico para um profissional de TI, e tenho muita força de vontade para fazer as coisas funcionarem. Caso você identifique coisas que eu poderia ter feito diferente, otimizações ou recomendações que tenha a fazer, sinta-se completamente livre para dizer e garanto que verei e testarei todas de mente aberta, como alguém que realmente quer aprender.
  3. Não passarei pelo código completo para não estender (mais) o tamanho desse texto. Aqui mostrarei apenas os trechos/arquivos principais, com o código-fonte completo disponível aqui. Como são 3 artigos diferentes, cada um estará em uma branch diferente para que, conforme as publicações e leituras de vocês, seja possível aos interessados dissecar exatamente o que foi feito.

Com isso esclarecido, abre esse claude vadia, e bora trabalhar.

Nosso alvo

Você pode usar o que faremos aqui para basicamente qualquer coisa (com as suas devidas adaptações, claro). Como o intuito aqui é mostrar como fazer a IA nos auxiliar na extração de dados e não focar em anti-bot ou alvos complexos, iremos ser felizes no simples. Nosso scraper irá entrar em três e-commerces de hardware brasileiros: KaBuM!, Pichau e TerabyteShop. Lá dentro, ele irá pesquisar por uma lista de termos informados, entrar nos três primeiros resultados e coletar alguns dados básicos do produto; mas, lembrando novamente, após entender o conceito base aqui, o céu é literalmente o limite.

Fazendo nosso setup

Bom, vamos começar de forma simples, criando nosso diretório base e adicionando as dependências necessárias para trabalharmos:

# Main deps
yarn add cheerio dotenv openai pain-headless
# & dev dependencies
yarn add -D @types/node typescript tsx

Após isso, será criado um arquivo package.json na nossa pasta, onde adicionaremos o seguinte script para rodar nosso projeto:

{
	"scripts": {
		"dev": "tsx src/index.ts"
	}
	// resto do arquivo...
}

Agora que nossas dependências estão prontas, precisamos também de um lugar para manter nossas configurações e chaves, então criaremos um .env.example com o seguinte conteúdo:

PHLESS_API_KEY=your_pain_headless_api_key

KEEP_FOOTER=0
KEEP_NAVBAR=1
KEEP_IMAGE_SOURCES=0
KEEP_SVG=0

NUM_RESULTS_PER_PROVIDER=3

OPENROUTER_API_KEY=your_openrouter_api_key

Após isso, copie esse arquivo como um .env e preencha com suas chaves (mais pra frente veremos melhor o que essas variáveis KEEP_ fazem, não se preocupe) e lembre-se de nunca commitar, revelar ou deixar publicamente acessível o seu arquivo .env com as suas chaves. Durante esse artigo, eu utilizarei o OpenRouter como provedor de IA, pela sua versatilidade e compatibilidade, além da possibilidade de poder testar diversos modelos sem alterar código.

Com o terreno bem adubado, deveremos ter a seguinte árvore de arquivos no nosso projeto:

|- node_modules/
|- src/
|---- helpers/
|------ agentic.ts
|------ code-parser.ts
|------ files.ts
|------ scraper.ts
|---- services/
|------ open-router.ts
|------ pain-headless.ts
|---- index.ts
|---- types.ts
|- .gitignore  
|- .env
|- .env.example
|- package.json
|- yarn.lock

Agora, com tudo pronto, podemos partir para os primeiros blocos de código.

Facilitando um pouco a vida

Antes de abordar como ficará nosso core, vamos criar os tipos que usaremos (e se você não está tipando seus códigos hoje em dia, bom, recomendo que volte duas casas e repense) e alguns helpers que facilitarão a vida e melhorarão a leitura do nosso código.

// src/helpers/files.ts

import { writeFile, mkdir } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';

// Usaremos essa function para escrever os dados extraidos pelo nosso scraper
export const outputJson = async (filename: string, data: unknown): Promise<void> => {
	const __filename = fileURLToPath(import.meta.url);
	const __dirname = dirname(__filename);
	const path = join(__dirname, '..', '..', 'output');

	await mkdir(path, { recursive: true });
	await writeFile(`${path}/${filename.replace('.json', '')}.json`, JSON.stringify(data, null, 4), 'utf8');
};
// src/types.ts

export type Provider = 'kabum' | 'pichau' | 'terabyte';

export interface ProductData {
	title: string;
	thumbnail: string;
	description: string;
	price: number | string;
	link: string;
	rate: string | number;
	provider: Provider;
}

Após criados os helpers e os tipos que precisamos, podemos criar nossos services para consumir serviços externos:

// src/services/pain-headless.ts

import { StealthBrowser } from "pain-headless"

// Apenas um shortcut para evitar repetir codigo, como instanciaremos muitos browsers
export const getBrowser = (): StealthBrowser => new StealthBrowser({
	apiKey: process.env?.PHLESS_API_KEY || '',
	proxy: {
		type: 'premium',
		country: 'br',
		strategy: 'sticky',
	},
	waitElements: {
		until: 'visible',
		timeout:  15000,
	}
});

Apenas para caráter de explicação, utilizaremos proxy para evitar blocks por IPs (pensando que em cenário real provavelmente teríamos algumas dezenas de produtos para olhar, um IP de servidor pode não ser muito bem visto, mesmo em sites com anti-bot fraco). A estratégia sticky do pain.headless mantém o mesmo IP entre os goTo que rodaremos, o que simularia um tráfego humano de alguém fazendo algumas pesquisas. Caso fôssemos escalar para centenas de produtos, o recomendado seria fazer a rotação do browser após algumas dezenas de produtos.

E, por último, configuraremos um simples helper para poder chamar o OpenRouter e rodar nossos prompts no Gemma 4 26B (em meus poucos testes, ganhou do QWEN Flash, tanto em custo quanto em eficiência, produzindo seletores mais corretos e menos alucinações).

// src/services/open-router.ts

import OpenAI from 'openai';

export const runPrompt = async (prompt: string, model: string = 'google/gemma-4-26b-a4b-it'): Promise<string> => {
	const logs: string[] = [];  // Como trabalharemos com mais de um browser rodando ao mesmo tempo, fica mais facil acumular os logs e exibi-los juntos
	const ai = new OpenAI({
		baseURL: 'https://openrouter.ai/api/v1',  // Necessario para o SDK consumir o OpenRouter ao inves da API oficial da OpenAI
		apiKey: process.env.OPENROUTER_API_KEY,
	});

	logs.push(`[${model}]: sending prompt...`);

	const response = await ai.chat.completions.create({
		model,
		messages: [{ role:  'user', content:  prompt }],
	});

	if (response.usage) {
		const inputTokens = response.usage.prompt_tokens ?? 0;
		const outputTokens = response.usage.completion_tokens ?? 0;
		const totalTokens = response.usage.total_tokens ?? (inputTokens + outputTokens);
		
		logs.push(`[${model}]: used ${totalTokens} tokens.`);
	}

	logs.push(`[${model}]: response: ${response.choices[0]?.message?.content}`);

	console.log(logs.join('\n'));
	
	return response.choices[0]?.message?.content || '';
}

OBS: Devido ao limite de chars aqui do TabNews, vou criar uma thread nesse post para disponibilizar o artigo completo.

Carregando publicação patrocinada...
0

Reduzindo custos

Bom, agora a parte que imagino que tenha vindo à cabeça da maioria aqui quando leu a proposta dessa brincadeira toda: quanto vem a facada após cada execução? E aqui temos algumas coisas bem interessantes para conversar.

Primeiramente, eu vou desconsiderar os custos de infra para manter o browser, por dois motivos principais: o primeiro é que estou usando o pain.headless, que até a data desse artigo está oferecendo créditos de teste grátis com uma cota generosa. O segundo é que, se tratando de scraping, você vai ter custos de infra com ou sem o uso da inteligência artificial, seja o custo monetário de um serviço como o `
pain.headless ou o gasto com VPS + manutenção de infra própria.

Mas bom, falando de IA, temos um cenário melhor do que você pode imaginar. Atualmente, nosso projeto utiliza IA para gerar seletores inteligentes baseados no conteúdo da página, então uma boa parte da facada que poderíamos ter no bolso é mitigada aqui, já que na maioria dos modelos os tokens de output são muito mais caros que os tokens de input, e nosso output aqui será minúsculo, já que nosso prompt cuspirá apenas uma lista de seletores ou um not-found.

Como estamos utilizando o Gemma 4 26B, temos um modelo inteligente o suficiente para nosso objetivo com um custo muito interessante: 0.06 para cada 1M de _tokens_ de _input_ e 0.33 para cada 1M de tokens de output. O único ponto de atenção aqui é que atualmente nosso projeto envia todo o código da página para que sejam encontrados os seletores, então precisamos de uma maneira de reduzir o tamanho do prompt, tanto para economizar quanto para não estourar o limite de 262 mil tokens de input do modelo. Para isso, utilizaremos algumas substituições inteligentes de conteúdo da página que não será útil para o modelo (e na verdade poderia deixá-lo mais burro):

// src/helpers/code-parser.ts

import * as cheerio from 'cheerio';

class CodeParser {
    static parse(code: string): CodeParser {
        return new CodeParser(code);
    }

    public originalCode: string;
    private $: cheerio.CheerioAPI;

    constructor(public code: string) {
        this.originalCode = code;

        this.code = this.code.trim();

        this.code = this.removeMultipleBlankLines();

        this.$ = cheerio.load(this.code);

        this.code = this.removeNoScripts();
        this.code = this.removeHead();
        this.code = this.removeScripts();
        this.code = this.removeStyles();
        this.code = this.removeIframes();
        this.code = this.removeHtmlComments();
        this.code = this.removeLinks();
        this.code = this.removeInlineStyles();
        
        this.code = this.optimizeDOM();

        if (parseInt(process.env?.KEEP_HEADER || '1') === 0) {
            this.code = this.removeHeader();
        }
        
        if (parseInt(process.env?.KEEP_FOOTER || '1') === 0) {
            this.code = this.removeFooter();
        }
        
        if (parseInt(process.env?.KEEP_IMAGE_SOURCES || '1') === 0) {
            this.code = this.removeImageSources();
        }
        
        if (parseInt(process.env?.KEEP_SVG || '1') === 0) {
            this.code = this.removeSvgElements();
        }

        this.code = this.minifyHtml();
        this.code = this.removeMultipleBlankLines();
    }

    public getOriginalCode(): string {
        return this.originalCode;
    }

    public getOptimizedCode(): string {
        return this.code;
    }

    protected removeHead(): string {
        const head = this.$('head');
        if (head.length > 0) {
            const title = head.find('title').clone();
            const metaDesc = head.find('meta[name="description"]').clone();
            
            head.empty();
            if (title.length > 0) head.append(title);
            if (metaDesc.length > 0) head.append(metaDesc);
        }
        return this.$.html();
    }

    protected removeNoScripts(): string {
        this.$('noscript').remove();
        return this.$.html();
    }

    protected removeMultipleBlankLines(): string {
        return this.code.replace(/\n{2,}/g, '\n');
    }

    protected removeScripts(): string {
        this.$('script, noscript').remove();
        return this.$.html();
    }

    protected removeStyles(): string {
        this.$('style').remove();
        return this.$.html();
    }

    protected removeFooter(): string {
        this.$('footer').remove();
        return this.$.html();
    }

    protected removeHeader(): string {
        this.$('header').remove();
        return this.$.html();
    }

    protected removeInlineStyles(): string {
        this.$('[style]').removeAttr('style');
        return this.$.html();
    }

    protected removeImageSources(): string {
        this.$('img[src]').attr('src', '');
        return this.$.html();
    }

    protected removeIframes(): string {
        this.$('iframe').remove();
        return this.$.html();
    }

    protected removeHtmlComments(): string {
        this.$('*').contents().each((_, el) => {
            if (el.type === 'comment') {
                this.$(el).remove();
            }
        });
        
        this.$.root().contents().each((_, el) => {
            if (el.type === 'comment') {
                this.$(el).remove();
            }
        });

        return this.$.html();
    }

    protected removeSvgElements(): string {
        this.$('svg').remove();
        return this.$.html();
    }

    protected removeLinks(): string {
        this.$('link').remove();
        return this.$.html();
    }

    protected optimizeDOM(): string {
        this.$('*').each((_, el) => {
            const $el = this.$(el);

            const tagEl = el as any;

            if (tagEl.name && tagEl.name.toLowerCase() === 'svg') {
                $el.empty();
            }

            if (tagEl.type === 'tag' && tagEl.attribs) {
                const attribs = tagEl.attribs;
                const attributesToRemove: string[] = [];

                for (const attr in attribs) {
                    const value = attribs[attr];
                    const attrLower = attr.toLowerCase();

                    if (attrLower.startsWith('aria-')) {
                        if (!['aria-title', 'aria-role', 'aria-label', 'aria-placeholder'].includes(attrLower)) {
                            attributesToRemove.push(attr);
                        }
                    } else if (attrLower.startsWith('data-')) {
                        if (!['data-testid', 'data-test', 'data-cy', 'data-qa'].includes(attrLower)) {
                            attributesToRemove.push(attr);
                        }
                    } else if (['style', 'loading', 'width', 'srcset', 'height', 'target', 'rel', 'translate'].includes(attrLower)) {
                        attributesToRemove.push(attr);
                    } else if ((value || '').trim() === '') {
                        attributesToRemove.push(attr);
                    }
                }

                attributesToRemove.forEach(attr => {
                    $el.removeAttr(attr);
                });

                const className = $el.attr('class');
                if (className) {
                    const classes = className.split(/\s+/);
                    const newClasses = classes.filter(cls => {
                        if (/^(xs|sm|md|lg|xl|2xl|3xl|4xl|5xl|max|min)-?:/.test(cls)) {
                            return false;
                        }
                        if (/^(w-|h-|max-w-|max-h-|min-w-|min-h-)/.test(cls)) {
                            return false;
                        }
                        if (/^(flex|grid|hidden|block|inline|inline-block|inline-flex|inline-grid|table)$/.test(cls)) {
                            return false;
                        }
                        if (/^(text-|font-|leading-|tracking-)/.test(cls)) {
                            return false;
                        }
                        if (/^(absolute|sticky|relative|fixed|static)$/.test(cls)) {
                            return false;
                        }
                        if (/^-?(top|left|right|bottom|inset)-/.test(cls)) {
                            return false;
                        }
                        if (/^(justify-|items-|content-|self-|place-|gap-|order-|flex-|grid-)/.test(cls)) {
                            return false;
                        }
                        if (/^-?(m|p)(t|r|b|l|x|y)?-/.test(cls)) {
                            return false;
                        }
                        if (/^sc-/.test(cls)) {
                            return false;
                        }
                        return true;
                    });

                    if (newClasses.length === 0) {
                        $el.removeAttr('class');
                    } else if (newClasses.length !== classes.length) {
                        $el.attr('class', newClasses.join(' '));
                    }
                }
            }
        });

        return this.$.html();
    }

    protected minifyHtml(): string {
        return this.code.replace(/>\s+</g, '><');
    }
}

export default CodeParser;

Como você deve ter notado, removemos MUITA coisa do código original. Em meus testes, sozinhas essas otimizações conseguiram fazer o HTML da KaBuM sair de 956 mil tokens de input para apenas 48 mil tokens (cerca de 95% de redução). Mas vamos destrinchar melhor um pouco dessas modificações:

  1. Removemos scripts e no-scripts. Na prática, nada aqui é útil pra gente; geralmente se trata de trackers, avisos de que a página precisa de JS, ou código minificado gerado durante o build. Poucas vezes a tag <script> contém algum JSON, mas mesmo assim, o trabalho e a volatilidade de extrair esse dado (que 99% das vezes em que aparece no código também está presente na página em elementos comuns) simplesmente não compensa.
  2. Remoção de resources, styles e CSS. Nenhum dado se encontra dentro das tags <style> ou <link>, se tratando puramente de carregamento de recursos externos necessários para o render e estilização da página. O mesmo vale para os atributos style inline nos elementos da página (muitas vezes gerados por frameworks automaticamente) que, na prática, não nos servem de nada (se chegar ao ponto de precisar fazer um seletor por style, o modelo se dá melhor pegando a estrutura XPath de acesso direto). Por último, removemos também os <iframe>, que embora possam conter dados, simplesmente não são acessíveis pelo nosso scraper, então não são úteis para nós, além de poderem confundir o nosso modelo.
  3. Removemos comentários HTML. Não preciso nem dizer, né?
  4. Remoção de conteúdo não utilizado da tag <head>. Embora já tenhamos removido styles, links e scripts, que compõem geralmente 80% do conteúdo do head, ainda sobram meta tags que não vão ter dados muito úteis pra gente. Então, mantemos apenas os title (og e o comum) e a description dentro dele, que embora também não tenham nada de útil, são bons para dar contexto ao modelo.
  5. Remoção de atributos HTML. Na prática, com exceção de alguns data-attributes feitos para serem usados por automações (como os data-testid, data-cy, etc.) e alguns aria-roles que indicam o que aquele elemento é (como o aria-title e o aria-label), a grande parte dos atributos não convencionais (entenda como convencionais o class, id e cia que geralmente são usados no scraping) não são úteis. Em um cenário de dependermos deles, encontraríamos a mesma (ou até pior) efetividade de usar o XPath bruto, não compensando mantê-los frente à economia de tokens que geram.
  6. Remoção de classes genéricas. Aqui seguimos a mesma lógica do ponto anterior: classes do Tailwind, classes geradas automaticamente por frameworks e classes internas do React que mudam a cada F5/build também não valem a pena ser mantidas.
  7. Remoção de elementos institucionais. Lembra das variáveis KEEP_ que definimos lá no nosso .env como bools (0 para false, 1 para true)? Aqui começaremos a de fato utilizá-las. Dependendo do(s) seu(s) target(s), pode ou não fazer sentido carregar elementos de navegação institucionais, como uma navbar ou um footer. No nosso caso, como utilizaremos a barra de busca dos e-commerces, que via de regra ficam no header do site, manteremos os headers/navbars, mas eliminaremos o elemento footer, que jamais será utilizado. Se o objetivo fosse ver as manchetes de um portal de notícias ou pegar os produtos destacados da home de um e-commerce, faria total sentido desabilitar ambos os elementos da análise do modelo, pois nunca pegaríamos ou interagiríamos com nada de lá.
  8. Remoção de SVGs. Dentro do nosso código, temos dois tipos de otimizações para SVGs. Por padrão, removemos todo o conteúdo dentro de uma tag <svg> (como o path, g, circle, etc.) já que, inegavelmente, elas não contêm dado nenhum, apenas as configurações para desenhar o ícone. E, opcionalmente via KEEP_SVG no .env, mantemos ou não a tag principal. Deixei opcional, pois em alguns casos extremos pode ser que um evento ou dado esteja atrelado ao SVG em si.
  9. Remoção dos srcs de imagens e vídeos. Mais um configurável via .env, a lógica aqui é bem simples: pode até ser que queiramos extrair esse dado, mas dificilmente ele será útil na hora do modelo montar o seletor para o elemento que o contém. E como mantemos os alts e mandamos a página toda, muito improvável que precise do src para localizar o seletor pedido.
  10. Redução do código restante. Após toda essa série de remoções, otimizamos o código restante removendo atributos vazios, removendo múltiplos espaços (trailing spaces) e múltiplas quebras de linha, além de rodar um simples algoritmo de minificação do código HTML restante.

OBS: Parte 2/3

0

Penso, logo existo

Bom, agora, sem mais delongas, vamos pra parte legal. Temos que construir o "cérebro" do nosso scraper pensante. Por ora, as capacidades de raciocínio do nosso scraper socrático se limitarão a encontrar o(s) seletor(es) pedidos por nós (evoluiremos isso nos próximos artigos da série).

Para criar essa "inteligência", utilizaremos dois métodos muito simples:

// src/helpers/agentic.ts

import { runPrompt } from "../services/open-router";
import type CodeParser from "./code-parser";

const formatAnswer = (answer: string): string => {
    answer = answer.replace('```json', '').replace(/```/g, '').replace(/`/g, '');

    if ((answer.startsWith('\'') && answer.endsWith('\'')) || (answer.startsWith('"') && answer.endsWith('"'))) {
        return answer.slice(1, -1);
    }

    if (answer.includes('not-found')) {
        throw new Error(`Can\'t find the required selector(s) in the page. Refines your prompt/target or check if the page is correct.`);
    }

    return answer;
}

export const findSelector = async (code: CodeParser, target: string): Promise<string> => {
    const prompt = `
        # MISSION
         
        You are a web scraper expert. Given the following HTML code of a website, your mission is find a exact single selector (in CSS or XPATH) that allows ${target}. 
    
        # RULES
        
        - Do not suggest selectors that are too generic. Try to find the most specific selector possible. 
        - If you don't find a selector that allows we directly type in the ecommerce search bar, return 'not-found'.
        - Return only the selector, without any explanation, prefix or suffix.
    
        # HTML
    
        ${code.getOptimizedCode()}
    `;

    return formatAnswer((await runPrompt(prompt))).replace(/\n/g, '');
}

export const findSelectors = async (code: CodeParser, selectors: string[]): Promise<string[]> => {
    const prompt = `
        # MISSION
         
        You are a web scraper expert. Given the following HTML code of a website, your mission is find a the following selectors (in CSS or XPATH):

        - ${selectors.join('\n- ')}
    
        # RULES
        
        - Do not suggest selectors that are too generic. Try to find the most specific selector possible. 
        - If you don't find a selector that matches the request, return the selector as 'not-found'.
        - Return exactly as requested: an array of strings in JSON format, without any explanation, prefix or suffix.
        - The output JSON must match exactly the number of selectors provided, even if some selector is 'not-found'.

        # HTML
    
        ${code.getOptimizedCode()}
    `;

    const answer = formatAnswer((await runPrompt(prompt)))

    try {
        const parsedAnswer = JSON.parse(answer);

        if (!Array.isArray(parsedAnswer)) {
            throw new Error(`Invalid answer format. Expected an array, got ${typeof parsedAnswer}`);
        }

        if (parsedAnswer.length !== selectors.length) {
            throw new Error(`Invalid answer format. Expected ${selectors.length} selectors, got ${parsedAnswer.length}`);
        }

        return parsedAnswer;
    } catch (error) {
        if (error instanceof SyntaxError) {
            throw new Error(`Invalid answer format. Expected an array of strings, got ${answer}`);
        }

        throw error;
    }
}

Como podem perceber, criamos duas funções com praticamente o mesmo intuito, porém com o prompt ligeiramente modificado para cada uma das finalidades (um único seletor ou múltiplos seletores). Não utilizaremos uma chamada por seletor desejado porque senão teríamos que enviar o prompt com o código-fonte inteiro várias vezes, trazendo assim um gasto desnecessário, além de um overhead de espera completamente mal otimizado.

OBS: Esse prompt podia ser infinitamente melhor, eu sei. Nas próximas publicações desta série, refinaremos muito os prompts para nossos modelos. Por ora, esse serve para o nosso projeto.

Agora que o nosso modelo sabe pensar, temos que dar ao desocupado bichinho algo pra pensar, para que ele não se entedie. Para nosso projeto, precisaremos que ele ache para nós 3 coisas: a barra de pesquisa, os resultados da busca e os dados do produto. Para isso, criamos no mesmo arquivo algumas funções de shortcut para melhor organização do código:

// src/helpers/agentic.ts

export const findSearchBarSelector = async (code: CodeParser): Promise<string> => {
    return await findSelector(code, 'directly type in the ecommerce search bar');
}

export const findResultsSelector = async (code: CodeParser): Promise<string> => {
    return await findSelector(code, 'that allow me to find all product links present in the page. Only return the selector if the element has an href attribute pointing to the products page.');
}

export const getProductPageSelectors = async (code: CodeParser): Promise<string[]> => {
    return await findSelectors(code, [
        'Product title',
        'Product description (most complete as possible)',
        'Product main image containing the src attribute',
        'Product rate number (e.g. 4.5). If no rate is available, or the product actually doesnt have any reviews, return a selector that contains the text informing this',
        'Product current price (if a discount is available, return the discounted price)',
    ]);
}

Pronto, como diriam os gregos, o pensamento transforma! Nosso """agente""" está pronto para ser integrado ao nosso workflow.

Fazendo o trabalho sujo

Para facilitar o entendimento, clareza mental, organização e termos um código limpo (e não um script com 2 mil linhas que ninguém gosta nem de abrir), vamos quebrar nosso workflow em tasks menores para nosso scraper executar. A primeira delas será abrir o site inicial dos nossos alvos.

// src/helpers/scraper.ts

import type { StealthBrowser } from "pain-headless";
import type { ProductData, Provider } from "../types";
import { findResultsSelector, findSearchBarSelector, getProductPageSelectors } from "./agentic";
import CodeParser from "./code-parser";

const PROVIDER_URLS: Record<Provider, string> = {
    kabum: 'https://www.kabum.com.br/',
    pichau: 'https://www.pichau.com.br/',
    terabyte: 'https://www.terabyteshop.com.br/',
}

export const goToProviderSite = async (browser: StealthBrowser, provider: Provider): Promise<void> => {
	await browser.goTo(PROVIDER_URLS[provider]);
	await browser.sleep(10000);

	await browser.solveSimpleCaptcha({ throwOnNoCaptcha: false });

	console.log(`[${provider}]: navigating to ${PROVIDER_URLS[provider]}`);
}

Um disclaimer sobre o método browser.solveSimpleCaptcha(): ele resolve qualquer possível captcha na página (a KaBuM! possui o reCAPTCHA). Embora o fingerprint oferecido pelo pain.headless seja realmente muito bom e estejamos com proxies residenciais, nunca é demais garantir, ainda mais pelo fato de que o pain.headless não nos cobra caso nenhum captcha seja identificado na página. Se estiver no Selenium, recomendo que integre a extensão do CapSolver no browser, e se estiver no Puppeteer/Playwright, pode usar os plugins desenvolvidos pela comunidade para integrar com o 2Captcha de forma built-in.

Em seguida, precisaremos que nosso scraper seja capaz de pesquisar utilizando a barra de busca do site. Aqui já começaremos a utilizar os "questionamentos" que ensinamos nossa criança a fazer anteriormente.

// src/helpers/scraper.ts

export const fillSearchBar = async (
	browser: StealthBrowser,
	target: string,
	searchBarSelector: string,
	provider: string
): Promise<string> => {
    if (!searchBarSelector.length) {
        const pageCode = CodeParser.parse((await browser.extractProperty('html', 'outerHTML')).data as string);
        
        searchBarSelector = await findSearchBarSelector(pageCode);
    }

    console.log(`[${provider}]: filling search ${searchBarSelector} bar with ${target}`);
    
    await browser.type(searchBarSelector, target, {
        fillAll: false,
        hitAfter: {
            enter: true,
        }
    });

    await browser.sleep(90000);

    return searchBarSelector;
}

Outro disclaimer que preciso fazer aqui: você deve estar se perguntando por que a função recebe um possível seletor se ainda nem pegamos o seletor, né? Bom, nosso projeto está sendo feito para ser capaz de buscar múltiplos produtos em múltiplos alvos. Usamos isso para uma espécie de "cache", evitando chamar o mesmo prompt duas vezes, o que nos gera economia e diminui a chance de o modelo dar respostas inconsistentes.

E sobre esse sleep, bom, por estarmos usando proxies residenciais, temos uma latência bem maior na rede, então infelizmente precisamos esperar um pouco mais que o comum. A boa notícia é que o pain.headless durante o sleep simula comportamento humano, rolando a página, mexendo o mouse e selecionando texto, o que ajuda demais a não sermos detectados como bot.

Agora, a próxima task que nosso scraper vai executar durante o workflow, a leitura dos resultados da busca:

// src/helpers/scraper.ts

export const getProductsInSearch = async (
	browser: StealthBrowser,
	resultsSelector: string,
	provider: string
): Promise<{resultsSelector: string, productsLinks: string[]}> => {
    if (!resultsSelector.length) {
        const pageSourceCode = CodeParser.parse((await browser.extractProperty('html', 'outerHTML')).data as string);

        resultsSelector = await findResultsSelector(pageSourceCode);
    }

    console.log(`[${provider}]: using '${resultsSelector}' results selector`);

    const results = await browser.extractData({
        fields: {
            links: {
                element: resultsSelector,
                isList: true,
                attribute: 'href',
            },
        }
    });

    console.log(`[${provider}]: search returned ${(results.data.links  || []).length}`);

    return {
        resultsSelector,
        productsLinks: results.data.links as string[],
    }
}

Por último, nosso scraper terá que extrair de fato os dados da página do produto, da seguinte maneira:

// src/helpers/scraper.ts

export const extractProductData = async (
	browser: StealthBrowser,
	provider: Provider,
	link: string,
	productSelectors: string[]
): Promise<{ data: ProductData, productSelectors: string[] }> => {
    if (!link.startsWith('http')) {
        link = PROVIDER_URLS[provider] + (link.startsWith('/') ? link.substring(1) : link);
    }
    
    await browser.goTo(link);
    await browser.sleep(5000);
    
    console.log(`[${provider}]: in product page ${link}`);
    
    if (!productSelectors.length) {
        const productPageCode = CodeParser.parse((await browser.extractProperty('html', 'outerHTML')).data as string);

        productSelectors = await getProductPageSelectors(productPageCode);
    }

    console.log(`[${provider}]: product selectors: `, productSelectors);

    const { data: productData } = await browser.extractData({
        fields: {
            name: productSelectors[0],
            description: productSelectors[1],
            image: {
                element: productSelectors[2],
                attribute: 'src',
            },
            rate: productSelectors[3],
            price: productSelectors[4],
        }
    }) as any;

    const data: ProductData = {
        title: productData.name,
        description: productData.description,
        thumbnail: productData.image,
        rate: productData.rate,
        price: productData.price,
        link,
        provider,
    }

    console.log(`[${provider}]: product data: `, data);

    return {
        data,
        productSelectors,
    }
}

Agora, com todas as tasks/habilidades feitas, vamos fazer o orquestrador do nosso workflow:

// src/helpers/scraper.ts

export const extractProductsFromProvider = async (
	browser: StealthBrowser,
	numOfResults: number,
	targets: string[],
	provider: Provider
): Promise<ProductData[]> => {
    await goToProviderSite(browser, provider);
    
    const products: ProductData[] = [];
    
    let resultsSelector = '';
    let searchBarSelector = '';
    let productSelectors: string[] = [];
    
    for (const target of targets) {
        console.log(`[${provider}]: searching for ${target}`);

        searchBarSelector = await fillSearchBar(browser, target, searchBarSelector, provider);

        const pageData = await getProductsInSearch(browser, resultsSelector, provider);
        const productLinks = pageData.productsLinks.slice(0, numOfResults);
        
        resultsSelector = pageData.resultsSelector;
        
        for (const link of productLinks) {
            const data = await extractProductData(browser, provider, link, productSelectors);

            products.push(data.data);
            productSelectors = data.productSelectors;
        }
    }

    return products;
}

Com tudo pronto, vamos dar uma pausa para respirar. Quero trazer para reflexão aqui por que acredito que esse é o futuro do web scraping. Notaram que, diferente do tradicional, escrevemos um scraper orientado a um objetivo, e não a uma plataforma? Alguns anos atrás isso seria inconcebível. Tanto que hoje estratégias antigas, como as que a Meta usava (trocar o HTML a cada refresh) ou captchas, já não são mais uma barreira, e as empresas que levam a guerra anti-bot a sério estão partindo para soluções completamente diferentes.

Players como LinkedIn, Reddit, e-commerces como Mercado Livre, Magazine Luiza e outros gigantes da internet já estão com soluções anti-bot que "cagam e andam" para o fato de você conseguir ou não encontrar o conteúdo dentro da página. Akamai e Turnstile te bloqueiam antes mesmo de você receber o primeiro byte de resposta. O futuro desse setor, na opinião deste humilde escritor, está concentrado nas empresas criarem métodos mais e mais avançados de identificar fingerprints de robôs, e de nós, ratos da internet, mascararmos cada vez mais nosso rastro. O game agora se resume a apenas limitar quem acessa a página, e não a dificultar a vida de quem já acessou para extrair o dado. Uma outra ponta que vejo ganhando espaço nesse jogo de gato e rato também é a detecção de comportamento não humano; então, se você passou pelo WAF, mas não navega como um ser humano, vai ser banido em alguns minutos.

Mas bom, saindo dos meus devaneios Dieguianos, vamos voltar ao que interessa.

Finalmente, nossos resultados

O último arquivo que temos que editar agora, nosso entrypoint:

// src/index.ts

import { outputJson } from './helpers/files';
import { extractProductsFromProvider } from './helpers/scraper';
import { getBrowser } from './services/pain-headless';
import { ProductData } from './types';
import dotenv from 'dotenv';

dotenv.config();

async function main() {
    const [firstBrowser, secondBrowser, thirdBrowser] = [
        getBrowser(),
        getBrowser(),
        getBrowser(),
    ];

    const targets = [
        'RTX 5050',
    ];

    const resultsFromEachProvider = parseInt(process.env?.NUM_RESULTS_PER_PROVIDER || '1');

    try {
        const allResults: ProductData[] = (await Promise.all([
            extractProductsFromProvider(firstBrowser, resultsFromEachProvider, targets, 'kabum'),
            extractProductsFromProvider(secondBrowser, resultsFromEachProvider, targets, 'pichau'),
            extractProductsFromProvider(thirdBrowser, resultsFromEachProvider, targets, 'terabyte'),
        ])).flat();

        await outputJson(getNow(), {
            targets,
            resultsPerTarget: resultsFromEachProvider,
            timestamp: getNow(),
            data: allResults.reduce((acc, item) => {
                if (!acc[item.provider]) {
                    acc[item.provider] = [];
                }
                acc[item.provider].push(item);
                return acc;
            }, {} as Record<string, ProductData[]>),
        });
    } finally {
        firstBrowser.close();
        secondBrowser.close();
        thirdBrowser.close();
    }
}

main();

Agora, é só rodar o yarn dev que configuramos a partir da raiz e, voilà, após alguns minutos você deverá ver um diretório output/ na raiz do projeto, e dentro um arquivo JSON com a data atual. Esse é o JSON vindo da nossa extração. Para deixar um pouco mais dinâmico, pedi ao meu Antigravity que fizesse um simples script Node para converter o arquivo mais recente em um .html no mesmo diretório, para que tenhamos uma visualização mais atrativa dos resultados.

IMAGEM
IMAGEM

Próximos passos

Bom, embora o resultado seja impressionante, não pretendo parar por aqui. Nos próximos artigos desta série, veremos os seguintes pontos de melhoria:

  • Mais skills de IA (analisar quais atributos usar para obter um dado, planner, etc.)
  • Otimização de custos mais agressiva
  • Testar outros modelos
  • Criar um agente independente (você dá o objetivo e ele executa) utilizando a infra que construímos
  • Integrar tudo isso dentro do pain.headless como uma nova feature do meu produto.

Se você tem interesse em ver a construção de um scraper independente e de um agente de IA focado nisso, fique ligado no perfil para mais posts.