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:
- 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. - 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 atributosstyleinline 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 porstyle, 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. - Removemos comentários HTML. Não preciso nem dizer, né?
- 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 ostitle(og e o comum) e adescriptiondentro dele, que embora também não tenham nada de útil, são bons para dar contexto ao modelo. - 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 oaria-titlee oaria-label), a grande parte dos atributos não convencionais (entenda como convencionais oclass,ide 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. - 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.
- Remoção de elementos institucionais. Lembra das variáveis
KEEP_que definimos lá no nosso.envcomo bools (0 parafalse, 1 paratrue)? 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á. - 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 opath,g,circle, etc.) já que, inegavelmente, elas não contêm dado nenhum, apenas as configurações para desenhar o ícone. E, opcionalmente viaKEEP_SVGno.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. - 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 osalts e mandamos a página toda, muito improvável que precise dosrcpara localizar o seletor pedido. - 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