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.

Carregando publicação patrocinada...