Kit gratuito · Agent Readiness

Seu próximo cliente pode ser uma IA

Um agente de IA consegue cumprir uma tarefa de negócio no seu site? Descubra em dez minutos.

Você informa a URL e um objetivo: o que um cliente quer fazer no site. O resultado vem em cinco critérios, com farol e evidência, e as três correções que mais destravam a jornada.

AcessoCompreensãoNavegaçãoExecuçãoBloqueios

Exemplos de objetivo

InstitucionalAchar o contato comercial e a lista de serviços.
E-commerceAchar um tênis de corrida número 42 até R$ 500 e o prazo de entrega para o CEP 01310-100.
NotíciasAchar a reportagem mais recente sobre o tema principal e o contato para anunciar.
SaaS ou B2BAchar o plano para 50 pessoas, o preço e como pedir demonstração.
Eventos ou educaçãoAchar a próxima edição ou turma, com data, local, preço e inscrição.
Serviços locaisAchar endereço, horário de atendimento e como agendar.
Sem terminal

ChatGPT ou Claude

  1. Abra o ChatGPT com modo agente ou o Claude com busca na web.
  2. Copie o prompt web e troque URL e OBJETIVO.
  3. Leia o veredito, os cinco critérios e as três correções.
Ir para o prompt web
Com terminal

Claude Code ou Codex

  1. Baixe o ar-run.mjs e salve na raiz do repositório do site (precisa de Node 20.10 ou mais).
  2. Cole o prompt local no agente, com URL e OBJETIVO. Se ele não achar o script, pede o arquivo ou o caminho.
  3. Se quiser, aceite o modo corrigir: uma correção por vez, com o seu ok.
Ir para o prompt local

1. Prompt web

URL: https://www.suaempresa.com.br
OBJETIVO: (o que um cliente quer fazer no site; se deixar em branco, eu sugiro um)

Exemplos de OBJETIVO por tipo de site:
- Institucional ou indústria: achar o contato comercial (e-mail, telefone ou formulário) e a lista de produtos ou serviços.
- E-commerce: achar um tênis de corrida masculino número 42 até R$ 500 e o prazo de entrega para o CEP 01310-100.
- Notícias ou conteúdo: achar a reportagem mais recente sobre o tema principal do site e o contato para anunciar.
- SaaS ou B2B: achar o plano certo para uma empresa de 50 pessoas, o preço e o caminho para pedir demonstração.
- Eventos ou educação: achar a próxima edição ou turma, com data, local, preço e como se inscrever.
- Serviços locais ou saúde: achar endereço, horário de atendimento e como agendar.
- Viagem ou hotel: achar disponibilidade e preço para 2 pessoas num fim de semana e como reservar.

Você vai avaliar se agentes de IA conseguem usar o site acima para cumprir um objetivo de negócio real. Fale em português do Brasil.

PASSO 0. OBJETIVO
Se o OBJETIVO estiver em branco, abra a página inicial, descubra o tipo de site e escolha UM objetivo da lista de exemplos, adaptado a este site. Diga qual escolheu e por quê.

PASSO 1. TENTE CUMPRIR O OBJETIVO
Navegue como um agente faria, a partir da página inicial. Anote cada passo (URL aberta, o que encontrou, onde travou). Pare antes de enviar formulário, criar conta, pagar ou aceitar termos. Não invente nada que não viu.

PASSO 2. SINAIS TÉCNICOS (tente abrir cada um; se a ferramenta não deixar, marque "não medido")
- /robots.txt: há regras para GPTBot, OAI-SearchBot, ChatGPT-User, ClaudeBot, Claude-User, PerplexityBot, Google-Extended? Bloqueia agentes de busca ou de usuário? (Bloquear bots de treino é decisão de negócio, não erro.)
- /sitemap.xml: existe? tem <lastmod>?
- /llms.txt: existe? tem título, resumo e seções com links?
- Dados estruturados (JSON-LD, Schema.org) na home: Organization com contactPoint? Product/Offer, Service, Event ou Article nas páginas do objetivo?
- O conteúdo principal aparece sem JavaScript? (Se você só consegue ler a versão renderizada, diga isso.)
- Existe versão em Markdown (link rel="alternate" type="text/markdown") ou página .md?

PASSO 3. SCANNERS (opcional; camada separada)
Se conseguir abrir, consulte https://is-agentic.com/api/v1/report?url=URL_CODIFICADA (só leitura). Se não conseguir, sugira ao usuário abrir https://isitagentready.com e https://is-agentic.com no navegador. Nunca some ou tire média de scores. Is Agentic e Ora usam o mesmo motor: contam como uma leitura só.

REGRAS DE HONESTIDADE
- Separe sempre "o que eu executei como agente" de "o que um scanner diz".
- Se não conseguiu abrir uma página, diga "não medido" e o motivo (bloqueio, timeout, limite da ferramenta). Ausência de prova não é reprovação nem aprovação.
- Seu acesso não representa todos os agentes: é uma leitura, de uma rede, hoje.
- Não prometa que a empresa será escolhida ou citada por IA. Não chame nada de índice universal.

FORMATO DA RESPOSTA (curto, nesta ordem)
1. Veredito em uma frase (o objetivo foi cumprido, parcialmente ou não, e por quê).
2. Tabela com 5 linhas: Critério | Farol | Evidência
   - Acesso: consegui abrir as páginas necessárias?
   - Compreensão: entendi o que é oferecido, preços, prazos, condições?
   - Navegação: achei o caminho até o objetivo por links e menus?
   - Execução: cheguei ao ponto de agir (botão, formulário, contato)?
   - Bloqueios: houve antibot, login obrigatório, pop-up, conteúdo só em imagem ou JS?
   Farol: Verde (funcionou, com evidência), Amarelo (funcionou com fricção ou dúvida), Vermelho (impediu), Cinza (não medido, diga por quê).
3. Top 3 correções, priorizadas por impacto no objetivo × esforço. Para cada uma: o problema, a evidência e "como corrigir" concreto (arquivo, trecho ou ajuste).
4. Sinais técnicos em uma lista curta (robots, sitemap, llms.txt, JSON-LD, conteúdo sem JS, Markdown), cada um com resultado ou "não medido".
5. O que não foi medido.

Rode o mesmo objetivo no ChatGPT e no Claude. Se um conseguir e o outro não, a diferença já é um achado.

2. Prompt local

URL: https://www.suaempresa.com.br
OBJETIVO: (o que um cliente quer fazer no site; em branco = sugira um pelo tipo de site)

Exemplos de OBJETIVO: institucional (contato comercial e lista de serviços); e-commerce (tênis de corrida número 42 até R$ 500 e prazo de entrega para o CEP 01310-100); notícias (reportagem mais recente sobre o tema principal e contato para anunciar); SaaS ou B2B (plano para 50 pessoas, preço e pedido de demonstração); eventos ou educação (próxima edição, data, local, preço e inscrição); serviços locais (endereço, horário e agendamento).

Você vai avaliar o Agent Readiness do site acima com o script ar-run.mjs desta pasta e depois, se eu quiser, corrigir.

ETAPA 0. ACHAR O SCRIPT
1. Procure ar-run.mjs na pasta atual e na raiz do repositório (a pasta que tem .git ou package.json).
2. Se não achar, pare e me peça uma destas opções, explicando onde o arquivo deve ficar:
   a) que eu anexe ou faça upload do ar-run.mjs nesta conversa;
   b) que eu informe o caminho completo do repositório do site ou da pasta onde já salvei o ar-run.mjs;
   c) que eu autorize você a baixar o arquivo de https://agentreadiness.aotta.com.br e salvá-lo.
   Lugar certo: na raiz do repositório do site, se houver (assim o modo corrigir encontra robots, sitemap e layout); senão, em qualquer pasta vazia de trabalho.
3. Quando receber o arquivo, salve-o no lugar indicado, confirme o caminho e só então siga.

ETAPA 1. AVALIAR (não altera nada)
1. Confira `node --version`. Precisa de Node 20.10 ou superior. Se faltar, me diga como instalar (nodejs.org, winget install OpenJS.NodeJS.LTS ou brew install node) e pare. Não instale nada global sem eu pedir.
2. Se o OBJETIVO estiver em branco, abra a home, descubra o tipo de site e escolha um dos exemplos acima, adaptado ao site.
3. Rode: node ar-run.mjs avaliar "<URL>" --objetivo "<OBJETIVO>" --tipo <conteudo|negocio|aplicacao|comercio>
   O Agent Ready só roda se eu pedir (flag --agent-ready), porque o modo anônimo tem 3 scans por IP a cada 30 dias.
4. Tente cumprir o OBJETIVO você mesmo com o navegador ou fetch que tiver, a partir da home, anotando URL e resultado de cada passo. Pare antes de enviar formulário, criar conta, pagar ou aceitar termos.
5. Atualize relatorio.md da pasta criada: troque o veredito provisório pelo veredito com o objetivo e preencha a linha Execução (e ajuste as outras se o teste mostrou algo que o script não viu). Em achados.json, mude farois.Execução.
6. Responda curto, em português com acentos: (1) veredito em uma frase; (2) tabela dos 5 critérios com farol e evidência; (3) top 3 correções com "como corrigir"; (4) o que não foi medido. Diga sempre o que veio de scanner e o que veio do objetivo testado. Nunca faça média entre scanners; Is Agentic e Ora são um só motor.
7. Pergunte: "Quer entrar no modo corrigir?"

ETAPA 2. CORRIGIR (só se eu disser sim)
1. Rode: node ar-run.mjs corrigir <pasta-da-avaliação> --repo .
   O script não mexe em nada: ele grava plano-correcao.json com a ordem, o risco, os arquivos prontos em correcoes/ e os arquivos candidatos do repositório.
2. Se modo = "arquivos_prontos" (não há repositório aqui): me entregue os arquivos de correcoes/ com uma instrução curta para o time aplicar e pare.
3. Se modo = "repositorio": percorra o plano UMA correção por vez. Para cada uma, mostre o arquivo e o diff exato e pergunte: ok / pular / parar. Ofereça uma vez a opção "aplicar todas as de baixo risco" (as marcadas baixo_risco: robots.txt para agentes de busca e de usuário, llms.txt, lastmod no sitemap, JSON-LD Organization com contactPoint, meta description). Bots de treino, noindex, canonical, WAF e Markdown por página são sempre uma de cada vez.
4. Preencha os TODO (logo, redes, e-mail comercial) só com dados que existam no repositório ou que eu informar. Não invente contato.
5. Nunca faça commit, push, deploy ou publicação. Ao final, liste os arquivos alterados.
6. Antes e depois: se houver um servidor local ou preview que eu possa subir, rode node ar-run.mjs avaliar <URL local> --sem-scanners --comparar <pasta-da-avaliação> e mostre resolvidos, persistentes e novos. Se não houver, diga que o "depois" só aparece após o deploy e deixe o comando pronto.

3. Script ar-run.mjs

Copie e salve num arquivo chamado ar-run.mjs, na raiz do repositório do site ou em qualquer pasta. O script só lê o site e consulta os scanners gratuitos (Is Agentic e Ora; o Agent Ready só roda com a opção --agent-ready, porque o modo anônimo permite 3 scans por IP a cada 30 dias).

#!/usr/bin/env node
// ar-run.mjs  Kit de Agent Readiness, versão 1.0, 29/10/2026. Juliano Motta, AOTTA Consultoria, https://aotta.com.br
//
// Uso:
//   node ar-run.mjs avaliar <URL> [--objetivo "texto"] [--tipo conteúdo|negócio|aplicacao|comercio]
//                              [--paginas 10] [--saida pasta] [--agent-ready] [--sem-scanners]
//                              [--comparar pasta-ou-achados.json-anterior]
//   node ar-run.mjs corrigir <pasta-da-avaliacao> [--repo caminho-do-repositorio]
//
// Regras: só leitura. O script nunca altera o site, nunca altera o repositorio,
// nunca faz commit, push ou deploy. Os unicos envios externos são os GET ao site
// avaliado e as chamadas de scan documentadas (Is Agentic, Ora e, só com flag, Agent Ready).
// Sem dependencias: usa fetch nativo do Node. npx baixa is-agentic e agent-ready-scanner.

import { spawn, spawnSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';

const VERSAO_KIT = '1.0.0';
const UA_KIT = 'Mozilla/5.0 (compatible; AOTTA-AgentReadinessKit/1.0; +https://aotta.com.br)';
const T_HTTP = 20000;

// ---------------------------------------------------------------- Node
const [maj, min] = process.versions.node.split('.').map(Number);
if (maj < 18) {
  console.error(`Node ${process.versions.node} e antigo demais. Instale o Node 20 LTS ou superior em https://nodejs.org (ou: winget install OpenJS.NodeJS.LTS / brew install node) e rode de novo.`);
  process.exit(2);
}
const nodeOkAgentReady = maj > 20 || (maj === 20 && min >= 10);

// ---------------------------------------------------------------- args
const argv = process.argv.slice(2);
const cmd = argv[0];
function flag(name, def = undefined) {
  const i = argv.indexOf('--' + name);
  if (i === -1) return def;
  const v = argv[i + 1];
  if (v === undefined || v.startsWith('--')) return true;
  return v;
}
if (!cmd || !['avaliar', 'corrigir'].includes(cmd)) {
  console.log(`Kit Agent Readiness ${VERSAO_KIT}
  node ar-run.mjs avaliar <URL> [--objetivo "..."] [--tipo conteúdo|negócio|aplicacao|comercio] [--paginas 10] [--agent-ready] [--sem-scanners] [--comparar <pasta anterior>]
  node ar-run.mjs corrigir <pasta-da-avaliacao> [--repo <caminho>]`);
  process.exit(cmd ? 2 : 0);
}

// ---------------------------------------------------------------- útil
const agora = () => new Date().toISOString();
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function stamp() {
  const d = new Date();
  const p = (n) => String(n).padStart(2, '0');
  return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
}
function escrever(file, data) {
  fs.mkdirSync(path.dirname(file), { recursive: true });
  fs.writeFileSync(file, typeof data === 'string' ? data : JSON.stringify(data, null, 2), 'utf8');
}
function nomeArquivo(u) {
  return u.replace(/^https?:\/\//, '').replace(/[^a-zA-Z0-9.-]+/g, '_').slice(0, 120) || 'raiz';
}
function log(...a) { console.log('[ar]', ...a); }

async function get(url, { ua = UA_KIT, accept = 'text/html,application/xhtml+xml,*/*;q=0.8', timeout = T_HTTP, method = 'GET', body, headers = {} } = {}) {
  const t0 = Date.now();
  try {
    const r = await fetch(url, {
      method, body, redirect: 'follow',
      headers: { 'User-Agent': ua, Accept: accept, 'Accept-Language': 'pt-BR,pt;q=0.9,en;q=0.8', ...headers },
      signal: AbortSignal.timeout(timeout),
    });
    const buf = Buffer.from(await r.arrayBuffer());
    const text = buf.subarray(0, 3_000_000).toString('utf8');
    const h = {};
    r.headers.forEach((v, k) => { h[k] = v; });
    return { ok: true, status: r.status, finalUrl: r.url, headers: h, text, bytes: buf.length, ms: Date.now() - t0 };
  } catch (e) {
    return { ok: false, status: 0, erro: String(e?.name === 'TimeoutError' ? 'timeout' : e?.message || e), ms: Date.now() - t0, headers: {}, text: '' };
  }
}

function runCmd(command, { timeoutMs, env } = {}) {
  return new Promise((resolve) => {
    const t0 = Date.now();
    const child = spawn(command, { shell: true, env: env || process.env, windowsHide: true });
    let out = '', err = '', morto = false;
    child.stdout.on('data', (d) => { out += d; });
    child.stderr.on('data', (d) => { err += d; });
    const timer = setTimeout(() => {
      morto = true;
      if (process.platform === 'win32') spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F']);
      else child.kill('SIGKILL');
    }, timeoutMs);
    child.on('close', (code) => { clearTimeout(timer); resolve({ code, stdout: out, stderr: err, timeout: morto, ms: Date.now() - t0 }); });
    child.on('error', (e) => { clearTimeout(timer); resolve({ code: -1, stdout: out, stderr: String(e), timeout: false, ms: Date.now() - t0 }); });
  });
}
function parseJsonSolto(s) {
  try { return JSON.parse(s); } catch { /* procura o primeiro objeto */ }
  const i = s.indexOf('{'), j = s.lastIndexOf('}');
  if (i >= 0 && j > i) { try { return JSON.parse(s.slice(i, j + 1)); } catch { return null; } }
  return null;
}
function idadeHoras(iso) { const t = Date.parse(iso || ''); return isNaN(t) ? null : (Date.now() - t) / 3600000; }

// ---------------------------------------------------------------- HTML
function attrs(tag) {
  const o = {};
  for (const m of tag.matchAll(/([a-zA-Z:-]+)\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/g)) o[m[1].toLowerCase()] = m[3] ?? m[4] ?? m[5] ?? '';
  return o;
}
function decode(s) {
  return s.replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;|&#x27;/g, "'")
    .replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(+n));
}
function textoSemJs(html) {
  const m = html.match(/<main[\s\S]*?<\/main>/i);
  const base = (m ? m[0] : html)
    .replace(/<(script|style|noscript|svg|template|head)[\s\S]*?<\/\1>/gi, ' ')
    .replace(/<[^>]+>/g, ' ');
  return decode(base).replace(/\s+/g, ' ').trim();
}
function tiposJsonLd(html) {
  const tipos = new Set(); let blocos = 0, invalidos = 0; const objs = [];
  for (const m of html.matchAll(/<script[^>]*type=["']?application\/ld\+json["']?[^>]*>([\s\S]*?)<\/script>/gi)) {
    blocos++;
    try {
      const j = JSON.parse(m[1].trim()); objs.push(j);
      const walk = (x) => {
        if (Array.isArray(x)) return x.forEach(walk);
        if (x && typeof x === 'object') {
          if (x['@type']) [].concat(x['@type']).forEach((t) => tipos.add(String(t)));
          Object.values(x).forEach(walk);
        }
      };
      walk(j);
    } catch { invalidos++; }
  }
  return { blocos, invalidos, tipos: [...tipos], objs };
}
function analisarPagina(url, r) {
  const html = r.text || '';
  const metas = [...html.matchAll(/<meta\s[^>]*>/gi)].map((m) => attrs(m[0]));
  const links = [...html.matchAll(/<link\s[^>]*>/gi)].map((m) => attrs(m[0]));
  const titulo = decode((html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1] || '').trim());
  const desc = metas.find((a) => (a.name || '').toLowerCase() === 'description')?.content || '';
  const robotsMeta = metas.filter((a) => /^(robots|googlebot)$/i.test(a.name || '')).map((a) => a.content).join(', ');
  const canonical = links.find((a) => (a.rel || '').toLowerCase().split(/\s+/).includes('canonical'))?.href || '';
  const altMd = links.some((a) => /alternate/i.test(a.rel || '') && /markdown/i.test(a.type || ''));
  const h1 = (html.match(/<h1[\s>]/gi) || []).length;
  const texto = textoSemJs(html);
  const palavras = texto ? texto.split(' ').length : 0;
  const ld = tiposJsonLd(html);
  const hrefs = [...html.matchAll(/<a\s[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi)].map((m) => ({ href: m[1], texto: decode(m[2].replace(/<[^>]+>/g, ' ')).replace(/\s+/g, ' ').trim() }));
  let host = ''; try { host = new URL(r.finalUrl || url).host; } catch {}
  let canonicalHost = ''; try { canonicalHost = canonical ? new URL(canonical, r.finalUrl || url).host : ''; } catch {}
  const desafio = /cf-browser-verification|challenge-platform|just a moment|captcha|are you a robot|access denied/i.test(html.slice(0, 20000)) && palavras < 200;
  return {
    url, status: r.status, finalUrl: r.finalUrl, ms: r.ms, erro: r.erro, contentType: r.headers['content-type'] || '',
    xRobotsTag: r.headers['x-robots-tag'] || '', titulo, descricao: desc, robotsMeta, noindex: /noindex/i.test(robotsMeta + ' ' + (r.headers['x-robots-tag'] || '')),
    canonical, canonicalOutroHost: !!(canonicalHost && host && canonicalHost !== host), altMarkdown: altMd, h1, palavrasSemJs: palavras,
    jsonld: { blocos: ld.blocos, invalidos: ld.invalidos, tipos: ld.tipos }, _ldObjs: ld.objs, _hrefs: hrefs, desafioAntibot: desafio, bytes: r.bytes || 0,
  };
}

// ---------------------------------------------------------------- robots
const BOTS = [
  { ua: 'ChatGPT-User', tipo: 'usuario' }, { ua: 'OAI-SearchBot', tipo: 'busca' }, { ua: 'GPTBot', tipo: 'treino' },
  { ua: 'Claude-User', tipo: 'usuario' }, { ua: 'Claude-SearchBot', tipo: 'busca' }, { ua: 'ClaudeBot', tipo: 'treino' },
  { ua: 'Perplexity-User', tipo: 'usuario' }, { ua: 'PerplexityBot', tipo: 'busca' },
  { ua: 'Googlebot', tipo: 'busca' }, { ua: 'Google-Extended', tipo: 'treino' }, { ua: 'Bingbot', tipo: 'busca' },
  { ua: 'Applebot-Extended', tipo: 'treino' }, { ua: 'CCBot', tipo: 'treino' }, { ua: 'meta-externalagent', tipo: 'treino' },
];
function parseRobots(txt) {
  const grupos = []; let atual = null; let ultimoFoiUA = false; const sitemaps = []; const contentSignals = [];
  for (const raw of txt.split(/\r?\n/)) {
    const linha = raw.replace(/#.*$/, '').trim(); if (!linha) continue;
    const i = linha.indexOf(':'); if (i < 0) continue;
    const k = linha.slice(0, i).trim().toLowerCase(), v = linha.slice(i + 1).trim();
    if (k === 'user-agent') { if (!ultimoFoiUA || !atual) { atual = { agentes: [], regras: [] }; grupos.push(atual); } atual.agentes.push(v.toLowerCase()); ultimoFoiUA = true; continue; }
    ultimoFoiUA = false;
    if (k === 'sitemap') sitemaps.push(v);
    else if (k === 'content-signal') contentSignals.push(v);
    else if ((k === 'allow' || k === 'disallow') && atual) atual.regras.push({ tipo: k, caminho: v });
  }
  return { grupos, sitemaps, contentSignals };
}
function robotsPermite(rob, ua, caminho = '/') {
  const u = ua.toLowerCase();
  let grupo = null, melhor = -1;
  for (const g of rob.grupos) for (const a of g.agentes) {
    if (a !== '*' && u.includes(a) && a.length > melhor) { grupo = g; melhor = a.length; }
  }
  const geral = !grupo;
  if (!grupo) grupo = rob.grupos.find((g) => g.agentes.includes('*'));
  if (!grupo) return { permitido: true, grupo: 'nenhum', regra: null };
  let regra = null, tam = -1;
  for (const r of grupo.regras) {
    if (r.tipo === 'disallow' && r.caminho === '') continue;
    const re = new RegExp('^' + r.caminho.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\\\$$/, '$').replace(/\*/g, '.*'));
    if (re.test(caminho) && (r.caminho.length > tam || (r.caminho.length === tam && r.tipo === 'allow'))) { regra = r; tam = r.caminho.length; }
  }
  return { permitido: !regra || regra.tipo === 'allow', grupo: geral ? '*' : grupo.agentes.join(','), regra: regra ? `${regra.tipo}: ${regra.caminho}` : null };
}

// ---------------------------------------------------------------- sitemap
async function lerSitemap(urls, dir) {
  const res = { fontes: [], urls: [], comLastmod: 0, erro: null };
  const fila = [...urls]; let n = 0;
  while (fila.length && n < 6) {
    const u = fila.shift(); n++;
    const r = await get(u, { accept: 'application/xml,text/xml,*/*' });
    escrever(path.join(dir, 'sitemap_' + n + '.xml'), r.text || r.erro || '');
    res.fontes.push({ url: u, status: r.status, contentType: r.headers['content-type'] || '', erro: r.erro });
    if (r.status !== 200) continue;
    if (/<sitemapindex/i.test(r.text)) {
      for (const m of r.text.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/gi)) if (fila.length < 5) fila.push(decode(m[1]));
      continue;
    }
    for (const m of r.text.matchAll(/<url>([\s\S]*?)<\/url>/gi)) {
      const loc = m[1].match(/<loc>\s*([^<\s]+)\s*<\/loc>/i)?.[1];
      if (!loc) continue;
      const lm = /<lastmod>/i.test(m[1]);
      res.urls.push(decode(loc)); if (lm) res.comLastmod++;
    }
  }
  return res;
}

// ---------------------------------------------------------------- scanners
async function isAgentic(url, dir) {
  const rec = { ferramenta: 'Is Agentic', versao: 'is-agentic@1.0.1', familia_motor: 'ora', inicio: agora() };
  const r = await runCmd(`npx --yes is-agentic@1.0.1 "${url}" --json`, { timeoutMs: 360000 });
  escrever(path.join(dir, 'stdout.json'), r.stdout); escrever(path.join(dir, 'stderr.txt'), r.stderr);
  rec.fim = agora(); rec.exit = r.code; rec.timeout = r.timeout;
  const j = parseJsonSolto(r.stdout);
  if (r.timeout) return { ...rec, estado: 'parcial', nota: 'Timeout de 6 min. Consulte depois a API de leitura GET https://is-agentic.com/api/v1/report?url= em vez de rodar de novo.' };
  if (!j) return { ...rec, estado: 'falhou', nota: (r.stderr || r.stdout).slice(0, 300) };
  if (j.code) return { ...rec, estado: /quota|rate/i.test(j.code) ? 'cota_esgotada' : 'falhou', nota: `${j.code}: ${j.detail || j.title || ''}` };
  const idade = idadeHoras(j.scanned_at);
  return {
    ...rec, estado: idade !== null && idade > 24 ? 'historico' : 'concluido', nota: idade !== null && idade > 24 ? `relatório existente de ${Math.round(idade / 24)} dias; a CLI lê o que já existe. Medição nova só pelo botao em is-agentic.com (navegador)` : undefined, medicao: j.scanned_at, relatorio: j.report_url,
    metrica: 'score Is Agentic', score: j.score, denominador: 100, rotulo: j.score_label, cobertura: 'dominio',
    detalhe: j.score_breakdown ? `essential ${j.score_breakdown.essential?.earned}/${j.score_breakdown.essential?.available}, recommended ${j.score_breakdown.recommended?.earned}/${j.score_breakdown.recommended?.available}` : '',
    recomendacoes: (j.issues || []).filter((x) => x.result !== 'pass').slice(0, 5).map((x) => ({ id: x.id, nome: x.name, resultado: x.result, nivel: x.tier })),
  };
}
async function ora(url, dir) {
  const rec = { ferramenta: 'Ora', versao: 'API /api/scan format=audit', familia_motor: 'ora', inicio: agora() };
  const corpo = JSON.stringify({ url, force: false, maxAgeSeconds: 86400 });
  escrever(path.join(dir, 'ora-request.json'), corpo);
  let r = await get('https://ora.ai/api/scan?include=essentials&format=audit', { method: 'POST', body: corpo, accept: 'application/json', headers: { 'Content-Type': 'application/json' }, timeout: 180000 });
  escrever(path.join(dir, 'ora-response.json'), r.text || r.erro || ''); escrever(path.join(dir, 'ora-headers.json'), { status: r.status, headers: r.headers });
  if (r.status === 429) return { ...rec, estado: 'cota_esgotada', nota: 'HTTP 429 no Ora. Respeite a janela; não use outro IP.' };
  let j = parseJsonSolto(r.text || '');
  if (!j) return { ...rec, estado: 'falhou', nota: `HTTP ${r.status} ${r.erro || ''}`.trim() };
  const meta = { servedFromCache: j.servedFromCache, resultAgeSeconds: j.resultAgeSeconds, age: r.headers.age };
  let dominio = j.domain || new URL(url).hostname; let n = 0; const limite = Date.now() + 300000;
  while ((r.status === 202 || j.analysisStatus === 'partial') && Date.now() < limite) {
    const espera = Math.max(10, Number(r.headers['retry-after']) || 10) * 1000; await sleep(espera); n++;
    r = await get(`https://ora.ai/api/score/${encodeURIComponent(dominio)}?include=essentials&format=audit`, { accept: 'application/json', timeout: 60000 });
    escrever(path.join(dir, `ora-poll-${String(n).padStart(2, '0')}.json`), r.text || r.erro || '');
    const jj = parseJsonSolto(r.text || ''); if (jj) j = jj;
  }
  rec.fim = agora();
  if (j.analysisStatus !== 'complete') return { ...rec, estado: 'parcial', nota: `analysisStatus=${j.analysisStatus || 'ausente'}; retomar por GET https://ora.ai/api/score/${dominio}` };
  const idade = idadeHoras(j.scannedAt);
  return {
    ...rec, versao: `API audit, contrato ${j.contractVersion || '?'}`, estado: idade !== null && idade > 24 ? 'historico' : 'concluido', medicao: j.scannedAt, relatorio: j.url,
    metrica: 'score geral', score: j.score, denominador: j.scoreMax || 100, rotulo: j.grade ? `nota ${j.grade}` : '', cobertura: j.urlKind === 'domain' ? 'dominio' : `urlKind=${j.urlKind}`,
    detalhe: `essentials.score ${j.essentials?.score ?? 'nulo'}/100${j.pendingChecks?.length ? '; pendingChecks não vazio' : ''}`, cache: meta,
    recomendacoes: (j.topFixes || []).slice(0, 5).map((f) => ({ id: f.id, nome: f.name || f.title })),
  };
}
async function agentReady(url, dir) {
  const rec = { ferramenta: 'Agent Ready', versao: 'agent-ready-scanner@0.6.1 (anônimo)', familia_motor: 'agent-ready', inicio: agora() };
  if (!nodeOkAgentReady) return { ...rec, estado: 'nao_executado', nota: 'Exige Node >= 20.10.' };
  const env = { ...process.env }; delete env.AGENT_READY_API_KEY; delete env.AGENT_READY_API_URL;
  const r = await runCmd(`npx --yes agent-ready-scanner@0.6.1 scan "${url}" --json --timeout 180 --poll-interval 10`, { timeoutMs: 300000, env });
  escrever(path.join(dir, 'stdout.json'), r.stdout); escrever(path.join(dir, 'stderr.txt'), r.stderr);
  rec.fim = agora();
  const j = parseJsonSolto(r.stdout);
  if (r.timeout) return { ...rec, estado: 'parcial', nota: 'Timeout externo de 5 min.' };
  if (!j || j.status !== 'completed') return { ...rec, estado: /429|quota|limit/i.test(r.stdout + r.stderr) ? 'cota_esgotada' : 'falhou', nota: (r.stderr || r.stdout).slice(0, 300) };
  const idade = idadeHoras(j.completedAt || j.createdAt);
  return {
    ...rec, estado: idade !== null && idade > 24 ? 'historico' : 'concluido', medicao: j.completedAt || j.createdAt, metrica: 'vercelScore',
    score: j.vercelScore, denominador: 100, rotulo: j.vercelRating, cobertura: `${j.pagesScanned} páginas`,
    detalhe: `llmstxtScore ${j.llmstxtScore}, accessibilityScore ${j.accessibilityScore} (separados, não somar)`,
    recomendacoes: (j.siteChecks || []).filter((c) => c.status === 'fail').slice(0, 5).map((c) => ({ id: c.checkId, nome: c.name })),
  };
}

// ---------------------------------------------------------------- avaliação direta
async function avaliacaoDireta(alvo, dir, nPaginas) {
  const origem = new URL(alvo).origin;
  const d = { alvo, origem, inicio: agora() };
  // home
  const rHome = await get(alvo);
  escrever(path.join(dir, 'home.html'), rHome.text || rHome.erro || '');
  escrever(path.join(dir, 'home-headers.json'), { status: rHome.status, finalUrl: rHome.finalUrl, headers: rHome.headers });
  d.home = analisarPagina(alvo, rHome);
  // acesso por user agent (teste local, não da rede oficial dos provedores)
  d.porUserAgent = [];
  for (const ua of ['ChatGPT-User', 'ClaudeBot', 'PerplexityBot', 'GPTBot']) {
    const r = await get(alvo, { ua: `Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; ${ua}/1.0)` });
    d.porUserAgent.push({ ua, status: r.status, bytes: r.bytes || 0, palavras: textoSemJs(r.text || '').split(' ').length, erro: r.erro });
  }
  // robots
  const rRob = await get(origem + '/robots.txt', { accept: 'text/plain,*/*' });
  escrever(path.join(dir, 'robots.txt'), rRob.text || rRob.erro || '');
  const rob = rRob.status === 200 ? parseRobots(rRob.text) : { grupos: [], sitemaps: [], contentSignals: [] };
  const caminho = new URL(alvo).pathname || '/';
  d.robots = { status: rRob.status, sitemaps: rob.sitemaps, contentSignals: rob.contentSignals, bots: BOTS.map((b) => ({ ...b, ...robotsPermite(rob, b.ua, caminho) })), _texto: rRob.status === 200 ? rRob.text : '' };
  // sitemap
  const fontes = rob.sitemaps.length ? rob.sitemaps : [origem + '/sitemap.xml'];
  d.sitemap = await lerSitemap(fontes, dir);
  // llms.txt
  d.llms = {};
  for (const f of ['llms.txt', 'llms-full.txt']) {
    const r = await get(origem + '/' + f, { accept: 'text/plain,text/markdown,*/*' });
    const ehHtml = /<html/i.test((r.text || '').slice(0, 500));
    const valido = r.status === 200 && !ehHtml;
    if (valido) escrever(path.join(dir, f), r.text);
    d.llms[f] = { status: r.status, valido, contentType: r.headers['content-type'] || '', bytes: valido ? r.bytes : 0, temH1: valido && /^#\s+\S/m.test(r.text), temResumo: valido && /^>\s+\S/m.test(r.text), secoes: valido ? (r.text.match(/^##\s+/gm) || []).length : 0, _texto: valido ? r.text : '' };
  }
  // markdown
  const rMd = await get(alvo, { accept: 'text/markdown, text/plain;q=0.9' });
  d.markdown = { contentType: rMd.headers['content-type'] || '', status: rMd.status, vary: rMd.headers.vary || '', serveMarkdown: /markdown/i.test(rMd.headers['content-type'] || ''), linkHeader: rHome.headers.link || '' };
  escrever(path.join(dir, 'home-accept-markdown.txt'), (rMd.text || '').slice(0, 200000));
  // 404
  const r404 = await get(origem + '/__aotta-probe-404-' + Date.now().toString(36), { accept: 'text/markdown, text/html;q=0.8' });
  d.probe404 = { status: r404.status, contentType: r404.headers['content-type'] || '' };
  // well-known (só sinal informativo)
  d.wellKnown = {};
  for (const w of ['/.well-known/ard.json', '/.well-known/mcp.json', '/.well-known/agent-card.json']) {
    const r = await get(origem + w, { accept: 'application/json' });
    d.wellKnown[w] = r.status === 200 && /json/i.test(r.headers['content-type'] || '') ? 200 : r.status;
  }
  // páginas internas
  const mesmas = d.sitemap.urls.filter((u) => { try { return new URL(u).origin === new URL(d.home.finalUrl || alvo).origin || new URL(u).host.replace(/^www\./, '') === new URL(alvo).host.replace(/^www\./, ''); } catch { return false; } })
    .filter((u) => u.replace(/\/$/, '') !== alvo.replace(/\/$/, ''));
  const amostra = [];
  const k = Math.max(0, Math.min(nPaginas - 1, mesmas.length));
  for (let i = 0; i < k; i++) amostra.push(mesmas[Math.floor((i * mesmas.length) / k)]);
  d.paginas = [d.home];
  for (const u of amostra) {
    const r = await get(u);
    escrever(path.join(dir, 'paginas', nomeArquivo(u) + '.html'), r.text || r.erro || '');
    d.paginas.push(analisarPagina(u, r));
  }
  // contato (sinal para missão comum)
  const hrefs = d.home._hrefs || [];
  d.contato = {
    linksNaHome: hrefs.filter((h) => /contato|contact|fale|comercial|anuncie|vendas|sales|mailto:|tel:|wa\.me|whatsapp/i.test(h.href + ' ' + h.texto)).slice(0, 10).map((h) => ({ href: h.href, texto: h.texto.slice(0, 60) })),
    contactPointJsonLd: d.paginas.some((p) => JSON.stringify(p._ldObjs || []).includes('contactPoint')),
  };
  d.linksInternosHome = hrefs.filter((h) => h.href.startsWith('/') || h.href.startsWith(origem)).length;
  d.fim = agora();
  return d;
}

// ---------------------------------------------------------------- achados
function gerarAchados(d) {
  const A = [];
  const add = (a) => A.push({ fonte: 'verificacao_direta', ...a });
  const h = d.home;
  if (h.status !== 200 || h.desafioAntibot) add({ id: 'acesso-home', criterio: 'acesso', severidade: 'alta', titulo: 'A página inicial não entrega conteúdo ao cliente HTTP do kit', evidencia: `HTTP ${h.status}${h.erro ? ' ' + h.erro : ''}${h.desafioAntibot ? ', página parece desafio antibot' : ''}`, correcao: 'Liberar agentes de usuário no WAF/CDN (regra de bot verificado para ChatGPT-User, Claude-User, Perplexity-User) e servir 200 com conteúdo sem desafio.', impacto: 'alto', esforco: 'medio', risco: 'medio', arquivos: ['configuracao do WAF/CDN (Cloudflare, Vercel Firewall, Akamai)'] });
  const bloqUA = d.porUserAgent.filter((x) => x.status !== h.status || (h.palavrasSemJs > 100 && x.palavras < h.palavrasSemJs * 0.5));
  if (h.status === 200 && bloqUA.length) add({ id: 'acesso-user-agent', criterio: 'bloqueios', severidade: 'alta', titulo: 'Resposta muda quando o user agent e de IA', evidencia: bloqUA.map((x) => `${x.ua}: HTTP ${x.status}, ${x.palavras} palavras`).join('; ') + ` (navegador comum: HTTP ${h.status}, ${h.palavrasSemJs} palavras). Teste local, não da rede oficial do provedor.`, correcao: 'Revisar regras de bot no WAF/CDN; permitir agentes de usuário e de busca verificados.', impacto: 'alto', esforco: 'medio', risco: 'medio', arquivos: ['configuracao do WAF/CDN'] });
  const bloqueadosUsuario = d.robots.bots.filter((b) => !b.permitido && (b.tipo === 'usuario' || b.tipo === 'busca'));
  if (bloqueadosUsuario.length) add({ id: 'robots-bloqueia-agentes', criterio: 'bloqueios', severidade: 'alta', titulo: 'robots.txt bloqueia agentes de busca ou de usuario', evidencia: bloqueadosUsuario.map((b) => `${b.ua} (${b.regra || 'grupo ' + b.grupo})`).join(', '), correcao: 'No robots.txt, criar grupos User-agent para esses bots com Allow: / (ver correcoes/robots-sugerido.txt). Bots de treino são decisão de negócio separada.', impacto: 'alto', esforco: 'baixo', risco: 'baixo', arquivos: ['public/robots.txt', 'app/robots.ts', 'static/robots.txt'], artefato: 'correcoes/robots-sugerido.txt' });
  const treinoBloq = d.robots.bots.filter((b) => !b.permitido && b.tipo === 'treino');
  if (treinoBloq.length) add({ id: 'robots-bots-treino', criterio: 'sinal_tecnico', severidade: 'info', titulo: 'Bots de treino bloqueados (decisão de negócio, não erro)', evidencia: treinoBloq.map((b) => b.ua).join(', '), correcao: 'Manter ou liberar e decisão juridica/comercial. Não entra nas correções de baixo risco.', impacto: 'baixo', esforco: 'baixo', risco: 'alto', arquivos: ['robots.txt'] });
  if (d.robots.status !== 200) add({ id: 'robots-ausente', criterio: 'sinal_tecnico', severidade: 'baixa', titulo: 'robots.txt ausente ou com erro', evidencia: `HTTP ${d.robots.status}`, correcao: 'Publicar robots.txt com regras explicitas e a linha Sitemap.', impacto: 'baixo', esforco: 'baixo', risco: 'baixo', arquivos: ['public/robots.txt'], artefato: 'correcoes/robots-sugerido.txt' });
  if (h.noindex) add({ id: 'home-noindex', criterio: 'bloqueios', severidade: 'alta', titulo: 'Pagina inicial marcada como noindex', evidencia: `meta robots/X-Robots-Tag: ${h.robotsMeta || h.xRobotsTag}`, correcao: 'Remover noindex da página publica (confirmar que não e ambiente de homologação).', impacto: 'alto', esforco: 'baixo', risco: 'medio', arquivos: ['layout/head do site'] });
  if (h.status === 200 && h.palavrasSemJs < 80) add({ id: 'conteudo-depende-js', criterio: 'compreensao', severidade: 'alta', titulo: 'Conteudo quase todo depende de JavaScript', evidencia: `${h.palavrasSemJs} palavras no HTML inicial da home`, correcao: 'Renderizar no servidor (SSR/SSG) o texto principal, precos, prazos e links de ação; muitos agentes não executam JS.', impacto: 'alto', esforco: 'alto', risco: 'medio', arquivos: ['framework: SSR/SSG'] });
  const pagErro = d.paginas.filter((p) => p.status !== 200);
  if (pagErro.length) add({ id: 'paginas-com-erro', criterio: 'bloqueios', severidade: 'media', titulo: 'Paginas do sitemap sem HTTP 200', evidencia: pagErro.map((p) => `${p.url} -> ${p.status || p.erro}`).join('; '), correcao: 'Corrigir ou retirar do sitemap as URLs quebradas.', impacto: 'medio', esforco: 'baixo', risco: 'baixo', arquivos: ['sitemap'] });
  const pagNoindex = d.paginas.filter((p) => p !== h && p.noindex);
  if (pagNoindex.length) add({ id: 'sitemap-com-noindex', criterio: 'navegacao', severidade: 'baixa', titulo: 'URLs do sitemap com noindex', evidencia: pagNoindex.map((p) => p.url).join('; '), correcao: 'Tirar do sitemap ou remover o noindex, conforme a intenção.', impacto: 'baixo', esforco: 'baixo', risco: 'baixo', arquivos: ['sitemap'] });
  const canonOut = d.paginas.filter((p) => p.canonicalOutroHost);
  if (canonOut.length) add({ id: 'canonical-outro-host', criterio: 'navegacao', severidade: 'media', titulo: 'Canonical aponta para outro dominio', evidencia: canonOut.map((p) => `${p.url} -> ${p.canonical}`).join('; '), correcao: 'Apontar o canonical para a URL de produção da própria página.', impacto: 'medio', esforco: 'baixo', risco: 'baixo', arquivos: ['head/metadata das paginas'] });
  const semTitulo = d.paginas.filter((p) => p.status === 200 && (!p.titulo || !p.descricao));
  if (semTitulo.length) add({ id: 'meta-titulo-descricao', criterio: 'compreensao', severidade: 'baixa', titulo: 'Paginas sem title ou meta description', evidencia: semTitulo.map((p) => `${p.url} (${!p.titulo ? 'sem title' : ''}${!p.descricao ? ' sem description' : ''})`).join('; '), correcao: 'Escrever title único e meta description de 120 a 160 caracteres dizendo o que a página oferece.', impacto: 'medio', esforco: 'baixo', risco: 'baixo', arquivos: ['head/metadata das paginas'] });
  const semH1 = d.paginas.filter((p) => p.status === 200 && p.h1 !== 1);
  if (semH1.length) add({ id: 'h1', criterio: 'compreensao', severidade: 'baixa', titulo: 'Paginas sem exatamente um H1', evidencia: semH1.map((p) => `${p.url} (${p.h1} H1)`).join('; '), correcao: 'Um H1 por página com o assunto principal.', impacto: 'baixo', esforco: 'baixo', risco: 'baixo', arquivos: ['templates'] });
  const tiposTodos = new Set(d.paginas.flatMap((p) => p.jsonld.tipos));
  if (!tiposTodos.size) add({ id: 'jsonld-ausente', criterio: 'compreensao', severidade: 'media', titulo: 'Nenhum dado estruturado (JSON-LD) nas páginas lidas', evidencia: `${d.paginas.length} páginas sem script application/ld+json`, correcao: 'Adicionar JSON-LD Organization com contactPoint na home e o tipo certo em cada template (Product/Offer, Service, Article, Event).', impacto: 'medio', esforco: 'baixo', risco: 'baixo', arquivos: ['layout raiz'], artefato: 'correcoes/jsonld-organization.json' });
  else if (!tiposTodos.has('Organization') && !tiposTodos.has('NewsMediaOrganization') && !tiposTodos.has('Corporation') && !tiposTodos.has('LocalBusiness')) add({ id: 'jsonld-organization', criterio: 'compreensao', severidade: 'baixa', titulo: 'Sem JSON-LD Organization', evidencia: `tipos encontrados: ${[...tiposTodos].join(', ')}`, correcao: 'Adicionar Organization com name, url, logo, sameAs e contactPoint.', impacto: 'medio', esforco: 'baixo', risco: 'baixo', arquivos: ['layout raiz'], artefato: 'correcoes/jsonld-organization.json' });
  if (d.paginas.some((p) => p.jsonld.invalidos)) add({ id: 'jsonld-invalido', criterio: 'compreensao', severidade: 'media', titulo: 'JSON-LD com erro de sintaxe', evidencia: d.paginas.filter((p) => p.jsonld.invalidos).map((p) => p.url).join('; '), correcao: 'Corrigir o JSON (validar em validator.schema.org).', impacto: 'medio', esforco: 'baixo', risco: 'baixo', arquivos: ['templates'] });
  if (!d.contato.contactPointJsonLd) add({ id: 'contactpoint', criterio: 'execucao', severidade: d.contato.linksNaHome.length ? 'baixa' : 'media', titulo: d.contato.linksNaHome.length ? 'Contato existe na página, mas não em dado estruturado' : 'Contato comercial difícil de achar', evidencia: d.contato.linksNaHome.length ? `links na home: ${d.contato.linksNaHome.map((l) => l.href).slice(0, 4).join(', ')}; sem contactPoint no JSON-LD` : 'nenhum link de contato, mailto, tel ou WhatsApp na home; sem contactPoint', correcao: 'Link "Contato" visível no cabeçalho ou rodapé e contactPoint (contactType "sales", email, telephone) no JSON-LD Organization.', impacto: 'alto', esforco: 'baixo', risco: 'baixo', arquivos: ['layout raiz', 'rodape'], artefato: 'correcoes/jsonld-organization.json' });
  if (d.sitemap.urls.length === 0) add({ id: 'sitemap-ausente', criterio: 'navegacao', severidade: 'media', titulo: 'Sitemap ausente ou ilegivel', evidencia: d.sitemap.fontes.map((f) => `${f.url} -> ${f.status}`).join('; '), correcao: 'Publicar /sitemap.xml com todas as páginas publicas e anunciar no robots.txt.', impacto: 'medio', esforco: 'baixo', risco: 'baixo', arquivos: ['public/sitemap.xml', 'app/sitemap.ts'] });
  else if (d.sitemap.comLastmod < d.sitemap.urls.length * 0.8) add({ id: 'sitemap-lastmod', criterio: 'sinal_tecnico', severidade: 'baixa', titulo: 'Sitemap com poucas datas de atualizacao', evidencia: `${d.sitemap.comLastmod} de ${d.sitemap.urls.length} URLs com lastmod`, correcao: 'Gerar <lastmod> real (data de modificação do conteúdo) em todas as URLs.', impacto: 'baixo', esforco: 'baixo', risco: 'baixo', arquivos: ['app/sitemap.ts', 'gerador do sitemap'] });
  if (d.sitemap.urls.length && !d.robots.sitemaps.length) add({ id: 'robots-sem-sitemap', criterio: 'sinal_tecnico', severidade: 'baixa', titulo: 'robots.txt não anuncia o sitemap', evidencia: 'nenhuma linha Sitemap:', correcao: 'Acrescentar "Sitemap: <origem>/sitemap.xml" ao robots.txt.', impacto: 'baixo', esforco: 'baixo', risco: 'baixo', arquivos: ['robots.txt'], artefato: 'correcoes/robots-sugerido.txt' });
  const L = d.llms['llms.txt'];
  if (!L.valido) add({ id: 'llms-txt-ausente', criterio: 'sinal_tecnico', severidade: 'baixa', titulo: 'Sem llms.txt', evidencia: `/llms.txt -> HTTP ${L.status}`, correcao: 'Publicar /llms.txt (rascunho em correcoes/llms.txt). E sinal emergente: ajuda alguns agentes, não e requisito de acesso.', impacto: 'baixo', esforco: 'baixo', risco: 'baixo', arquivos: ['public/llms.txt'], artefato: 'correcoes/llms.txt' });
  else if (!L.temResumo || L.secoes < 2) add({ id: 'llms-txt-estrutura', criterio: 'sinal_tecnico', severidade: 'baixa', titulo: 'llms.txt sem resumo ou secoes', evidencia: `H1 ${L.temH1 ? 'sim' : 'nao'}, resumo > ${L.temResumo ? 'sim' : 'nao'}, ${L.secoes} seções ##`, correcao: 'Seguir o formato llmstxt.org: # Nome, > resumo de uma linha, seções ## com links e descrição curta (modelo em correcoes/llms.txt).', impacto: 'baixo', esforco: 'baixo', risco: 'baixo', arquivos: ['public/llms.txt'], artefato: 'correcoes/llms.txt' });
  if (!d.markdown.serveMarkdown && !d.paginas.some((p) => p.altMarkdown)) add({ id: 'markdown', criterio: 'sinal_tecnico', severidade: 'baixa', titulo: 'Sem versão em Markdown das páginas', evidencia: `Accept: text/markdown devolveu ${d.markdown.contentType || 'nada'}; sem link rel=alternate type=text/markdown`, correcao: 'Servir Markdown quando Accept: text/markdown (com Vary: Accept) ou publicar espelhos .md e anunciar com <link rel="alternate" type="text/markdown">. Esforço medio; ganho principal em scanners e agentes que pedem Markdown.', impacto: 'baixo', esforco: 'medio', risco: 'medio', arquivos: ['middleware/rotas'] });
  if (d.probe404.status === 200) add({ id: 'soft-404', criterio: 'navegacao', severidade: 'media', titulo: 'URL inexistente devolve 200 (soft 404)', evidencia: 'probe aleatorio respondeu HTTP 200', correcao: 'Devolver 404 real para caminhos inexistentes.', impacto: 'medio', esforco: 'baixo', risco: 'baixo', arquivos: ['roteamento'] });
  // prioridade = impacto x facilidade
  const pi = { alto: 3, medio: 2, baixo: 1 }, pe = { baixo: 3, medio: 2, alto: 1 }, ps = { alta: 3, media: 2, baixa: 1, info: 0 };
  A.forEach((a) => { a.prioridade = a.severidade === 'info' ? 0 : (pi[a.impacto] || 1) * (pe[a.esforco] || 1) + (ps[a.severidade] || 0) / 10; a.baixo_risco = a.risco === 'baixo' && !!a.artefato; });
  A.sort((x, y) => y.prioridade - x.prioridade);
  A.forEach((a, i) => { a.ordem = i + 1; });
  return A;
}

function farois(d, A) {
  const tem = (c, sev) => A.some((a) => a.criterio === c && (!sev || sev.includes(a.severidade)));
  const ev = (c) => A.filter((a) => a.criterio === c).map((a) => a.titulo).join('; ');
  const h = d.home;
  const usuarioOk = d.robots.bots.filter((b) => b.tipo !== 'treino').every((b) => b.permitido);
  const f = {};
  f.Acesso = tem('acesso', ['alta']) ? ['Vermelho', ev('acesso')] : ['Verde', `Home HTTP ${h.status} em ${h.ms} ms; ${d.porUserAgent.map((x) => `${x.ua} ${x.status}`).join(', ')} (teste local); robots ${usuarioOk ? 'libera' : 'restringe'} agentes de busca e de usuário.`];
  f['Compreensão'] = tem('compreensao', ['alta']) ? ['Vermelho', ev('compreensao')] : tem('compreensao') ? ['Amarelo', ev('compreensao')] : ['Verde', `${h.palavrasSemJs} palavras sem JS na home; title, description e JSON-LD (${[...new Set(d.paginas.flatMap((p) => p.jsonld.tipos))].slice(0, 6).join(', ')}).`];
  f['Navegação'] = tem('navegacao', ['alta', 'media']) ? ['Amarelo', ev('navegacao')] : tem('navegacao') ? ['Amarelo', ev('navegacao')] : ['Verde', `Sitemap com ${d.sitemap.urls.length} URLs; ${d.linksInternosHome} links internos na home; ${d.paginas.length} páginas lidas.`];
  f['Execução'] = ['Cinza', 'Não medido pelo script. O objetivo precisa ser testado pelo agente (navegador ou fetch) e registrado aqui.'];
  f.Bloqueios = tem('bloqueios', ['alta']) ? ['Vermelho', ev('bloqueios')] : tem('bloqueios') ? ['Amarelo', ev('bloqueios')] : ['Verde', 'Sem desafio antibot, sem noindex na home, sem bloqueio por robots para agentes de busca/usuário e sem erro nas páginas lidas.'];
  return f;
}

// ---------------------------------------------------------------- artefatos de correção
function gerarArtefatos(d, dir) {
  const c = path.join(dir, 'correcoes'); const origem = d.origem;
  // robots
  const atual = d.robots._texto || '';
  const faltam = d.robots.bots.filter((b) => !b.permitido && b.tipo !== 'treino');
  let rob = atual ? atual.trimEnd() + '\n' : 'User-agent: *\nAllow: /\n';
  if (faltam.length) rob += `\n# Agentes de busca e de usuario (acesso em nome de uma pessoa). Acrescentado pelo kit AOTTA.\n${faltam.map((b) => `User-agent: ${b.ua}\nAllow: /\n`).join('\n')}`;
  rob += `\n# Bots de treino: decisão de negócio. Descomente só se a empresa decidir liberar.\n# User-agent: GPTBot\n# Allow: /\n# User-agent: ClaudeBot\n# Allow: /\n# User-agent: Google-Extended\n# Allow: /\n`;
  if (!d.robots.sitemaps.length && d.sitemap.urls.length) rob += `\nSitemap: ${origem}/sitemap.xml\n`;
  escrever(path.join(c, 'robots-sugerido.txt'), rob);
  // llms.txt
  const titulo = d.home.titulo || new URL(origem).host;
  const urls = d.paginas.filter((p) => p.status === 200).map((p) => `- [${(p.titulo || p.url).slice(0, 80)}](${p.finalUrl || p.url}): ${(p.descricao || 'TODO descrever em uma linha').slice(0, 160)}`);
  escrever(path.join(c, 'llms.txt'), `# ${titulo}\n\n> ${d.home.descricao || 'TODO: resumo de uma linha do que a empresa faz e para quem.'}\n\nTODO: 2 a 4 linhas com o que um agente precisa saber (o que vende, para quem, como contratar, contato comercial).\n\n## Páginas principais\n\n${urls.join('\n')}\n\n## Contato\n\n- [Contato comercial](TODO): e-mail, telefone ou WhatsApp comercial\n\n## Optional\n\n- [Sitemap](${origem}/sitemap.xml)\n`);
  // JSON-LD
  const mail = d.contato.linksNaHome.find((l) => l.href.startsWith('mailto:'))?.href.replace('mailto:', '').split('?')[0] || 'TODO@empresa.com.br';
  const tel = d.contato.linksNaHome.find((l) => l.href.startsWith('tel:'))?.href.replace('tel:', '') || 'TODO +55 11 0000-0000';
  escrever(path.join(c, 'jsonld-organization.json'), {
    '@context': 'https://schema.org', '@type': 'Organization', name: titulo.split(/[|\-:]/)[0].trim(), url: origem + '/', logo: 'TODO URL absoluta do logo',
    sameAs: ['TODO LinkedIn', 'TODO Instagram'], contactPoint: [{ '@type': 'ContactPoint', contactType: 'sales', email: mail, telephone: tel, availableLanguage: ['pt-BR'] }],
  });
  escrever(path.join(c, 'COMO-APLICAR.md'), `# Arquivos prontos para o time\n\nGerados a partir da avaliação de ${d.alvo}. Revise os TODO antes de publicar.\n\n- robots-sugerido.txt: substitui /robots.txt. Preserva as regras atuais e só acrescenta Allow para agentes de busca e de usuário que estavam bloqueados. Bots de treino ficam comentados (decisão de negócio).\n- llms.txt: publicar em /llms.txt (texto puro, UTF-8).\n- jsonld-organization.json: colar dentro de <script type="application/ld+json"> no <head> da home ou do layout raiz.\n\nDepois de publicar, rode de novo: node ar-run.mjs avaliar ${d.alvo} --sem-scanners --comparar <pasta desta avaliação>\n`);
}

// ---------------------------------------------------------------- relatório
function relatorio(meta, d, A, F, scanners, comparacao) {
  const top = A.filter((a) => a.severidade !== 'info').slice(0, 3);
  const verm = Object.entries(F).filter(([, v]) => v[0] === 'Vermelho').map(([k]) => k);
  const amar = Object.entries(F).filter(([, v]) => v[0] === 'Amarelo').map(([k]) => k);
  const veredito = verm.length
    ? `Há barreira que pode impedir agentes de usar o site (${verm.join(', ')}); corrigir antes de qualquer outra coisa.`
    : amar.length ? `O site é acessível para agentes, com fricção em ${amar.join(', ')}; o objetivo ainda precisa ser testado para fechar o veredito.`
      : 'O site é acessível e legível para agentes nos pontos medidos; o objetivo ainda precisa ser testado para fechar o veredito.';
  const linha = (cols) => `| ${cols.join(' | ')} |`;
  let md = `# Agent Readiness: ${new URL(d.alvo).host}\n\n`;
  md += `URL: ${d.alvo} · Objetivo: ${meta.missao || 'não informado'} · Tipo: ${meta.tipo} · Medição direta: ${d.inicio} (UTC) · Kit ${VERSAO_KIT}\n\n`;
  md += `## 1. Veredito\n\n${veredito}\n\n<!-- O agente substitui este parágrafo depois de testar o objetivo. -->\n\n`;
  md += `## 2. Cinco critérios\n\n${linha(['Critério', 'Farol', 'Evidência'])}\n${linha(['---', '---', '---'])}\n`;
  for (const [k, [cor, ev]] of Object.entries(F)) md += linha([k, cor, ev.replace(/\|/g, '/')]) + '\n';
  md += `\n## 3. Top 3 correções (impacto na jornada × esforço)\n\n`;
  if (!top.length) md += 'Nenhuma correção relevante detectada pela verificação direta.\n';
  top.forEach((a, i) => { md += `${i + 1}. **${a.titulo}** (impacto ${a.impacto}, esforço ${a.esforco}, risco ${a.risco}). Evidência: ${a.evidencia}. Como corrigir: ${a.correcao}${a.artefato ? ` Arquivo pronto: \`${a.artefato}\`.` : ''}\n`; });
  md += `\n## 4. O que não foi medido\n\n- Teste do objetivo por um agente real (o script não navega nem clica).\n- Acesso a partir das redes oficiais da OpenAI, Anthropic, Perplexity e Google: o teste de user agent é local.\n- Conteúdo renderizado por JavaScript (o script lê só o HTML inicial).\n- Presença ou citação em respostas de IA (é outra medição).\n- Páginas fora da amostra (${d.paginas.length} de ${d.sitemap.urls.length || '?'} URLs do sitemap).\n${scanners.filter((s) => s.estado !== 'concluido').map((s) => `- ${s.ferramenta}: ${s.estado}${s.nota ? ' (' + s.nota + ')' : ''}.`).join('\n')}\n`;
  md += `\n## Anexo A. Sinais técnicos\n\n${linha(['Sinal', 'Resultado'])}\n${linha(['---', '---'])}\n`;
  md += linha(['robots.txt', `HTTP ${d.robots.status}; ${d.robots.bots.map((b) => `${b.ua} ${b.permitido ? 'permitido' : 'BLOQUEADO'}`).join(', ')}`]) + '\n';
  md += linha(['Content-Signal', d.robots.contentSignals.join('; ') || 'nenhum']) + '\n';
  md += linha(['Sitemap', `${d.sitemap.urls.length} URLs, ${d.sitemap.comLastmod} com lastmod`]) + '\n';
  md += linha(['llms.txt', d.llms['llms.txt'].valido ? `${d.llms['llms.txt'].bytes} bytes, ${d.llms['llms.txt'].secoes} seções, resumo ${d.llms['llms.txt'].temResumo ? 'sim' : 'nao'}` : `ausente (HTTP ${d.llms['llms.txt'].status})`]) + '\n';
  md += linha(['llms-full.txt', d.llms['llms-full.txt'].valido ? `${d.llms['llms-full.txt'].bytes} bytes` : `ausente (HTTP ${d.llms['llms-full.txt'].status})`]) + '\n';
  md += linha(['Dados estruturados', [...new Set(d.paginas.flatMap((p) => p.jsonld.tipos))].join(', ') || 'nenhum']) + '\n';
  md += linha(['Conteudo sem JS', d.paginas.map((p) => p.palavrasSemJs).join(', ') + ' palavras por página lida']) + '\n';
  md += linha(['Markdown', `Accept: text/markdown -> ${d.markdown.contentType || '?'}; Vary: ${d.markdown.vary || 'ausente'}`]) + '\n';
  md += linha(['404', `HTTP ${d.probe404.status}`]) + '\n';
  md += linha(['.well-known (MCP, ARD, agent-card)', Object.entries(d.wellKnown).map(([k, v]) => `${k} ${v}`).join(', ') + ' (só aplicável se o site oferece API/agente)']) + '\n';
  md += `\n## Anexo B. Scanners automatizados (camada separada)\n\nScanner automatizado não e tarefa executada por agente. Não há media entre scanners. Is Agentic e Ora usam o mesmo motor e contam como uma leitura.\n\n${linha(['Ferramenta', 'Metodo', 'Metrica', 'Score', 'Medicao', 'Cobertura', 'Estado', 'Primeiras recomendacoes'])}\n${linha(Array(8).fill('---'))}\n`;
  for (const s of scanners) md += linha([s.ferramenta, s.versao, s.metrica || 'n/a', s.score != null ? `${s.score}/${s.denominador}${s.rotulo ? ' (' + s.rotulo + ')' : ''}${s.detalhe ? '; ' + s.detalhe : ''}` : 'n/a', s.medicao || 'n/a', s.cobertura || 'n/a', s.estado, (s.recomendacoes || []).slice(0, 3).map((r) => r.nome || r.id).join('; ') || (s.nota || '')]) + '\n';
  md += `\nRecomendacoes de scanner são sugestoes: confirme na verificação direta e no teste do objetivo antes de agir. Itens de API, MCP, OAuth e pagamento só se aplicam se o site oferece isso.\n`;
  if (comparacao) {
    md += `\n## Anexo C. Antes e depois\n\nComparado com: ${comparacao.base}\n\n- Resolvidos: ${comparacao.resolvidos.join(', ') || 'nenhum'}\n- Persistem: ${comparacao.persistem.join(', ') || 'nenhum'}\n- Novos: ${comparacao.novos.join(', ') || 'nenhum'}\n`;
  }
  md += `\n## Anexo D. Todas as correções\n\n${linha(['#', 'id', 'Critério', 'Severidade', 'Impacto', 'Esforco', 'Risco', 'Baixo risco aplicavel'])}\n${linha(Array(8).fill('---'))}\n`;
  for (const a of A) md += linha([a.ordem, a.id, a.criterio, a.severidade, a.impacto, a.esforco, a.risco, a.baixo_risco ? 'sim' : 'nao']) + '\n';
  return md;
}

// ---------------------------------------------------------------- comandos
async function avaliar() {
  let alvo = argv[1];
  if (!alvo || alvo.startsWith('--')) { console.error('Informe a URL: node ar-run.mjs avaliar https://empresa.com.br'); process.exit(2); }
  if (!/^https?:\/\//i.test(alvo)) alvo = 'https://' + alvo;
  try { new URL(alvo); } catch { console.error('URL invalida: ' + alvo); process.exit(2); }
  const meta = { missao: typeof flag('objetivo') === 'string' ? flag('objetivo') : (typeof flag('missao') === 'string' ? flag('missao') : ''), tipo: typeof flag('tipo') === 'string' ? flag('tipo') : 'nao informado', node: process.versions.node, kit: VERSAO_KIT, inicio: agora() };
  const nPag = Math.min(20, Math.max(1, Number(flag('paginas', 10)) || 10));
  const host = new URL(alvo).host.replace(/[^a-z0-9.-]/gi, '_');
  const base = typeof flag('saida') === 'string' ? flag('saida') : path.join(process.cwd(), `agent-readiness-${stamp()}-${host}`);
  fs.mkdirSync(base, { recursive: true });
  log('pasta:', base);
  log('verificacao direta...');
  const scannersP = [];
  const comScanners = !flag('sem-scanners');
  if (comScanners) {
    log('Is Agentic e Ora em paralelo (cache de 24 h)...');
    scannersP.push(isAgentic(alvo, path.join(base, 'is-agentic')).catch((e) => ({ ferramenta: 'Is Agentic', versao: 'is-agentic@1.0.1', estado: 'falhou', nota: String(e) })));
    scannersP.push(ora(alvo, path.join(base, 'ora')).catch((e) => ({ ferramenta: 'Ora', versao: 'API', estado: 'falhou', nota: String(e) })));
    if (flag('agent-ready')) {
      log('Agent Ready anonimo: consome 1 dos 3 scans por IP a cada 30 dias.');
      scannersP.push(agentReady(alvo, path.join(base, 'agent-ready')).catch((e) => ({ ferramenta: 'Agent Ready', estado: 'falhou', nota: String(e) })));
    }
  }
  const d = await avaliacaoDireta(alvo, path.join(base, 'direto'), nPag);
  log(`direto ok: home HTTP ${d.home.status}, ${d.paginas.length} páginas, sitemap ${d.sitemap.urls.length} URLs`);
  const scanners = await Promise.all(scannersP);
  if (!comScanners) scanners.push({ ferramenta: 'Is Agentic / Ora', versao: '-', estado: 'nao_executado', nota: '--sem-scanners' });
  if (!flag('agent-ready')) scanners.push({ ferramenta: 'Agent Ready', versao: 'agent-ready-scanner@0.6.1', estado: 'nao_executado', nota: 'opcional; use --agent-ready (cota de 3 scans por IP em 30 dias)' });
  scanners.push({ ferramenta: 'Cloudflare isitagentready.com, WebMCPReady, AG AgentReady', versao: 'web, manual', estado: 'nao_executado', nota: 'rodar no navegador se quiser uma terceira leitura' });
  const A = gerarAchados(d);
  const F = farois(d, A);
  gerarArtefatos(d, base);
  let comparacao = null;
  const cmp = flag('comparar');
  if (typeof cmp === 'string') {
    const f = cmp.endsWith('.json') ? cmp : path.join(cmp, 'achados.json');
    try {
      const antes = JSON.parse(fs.readFileSync(f, 'utf8')).achados.map((a) => a.id); const depois = A.map((a) => a.id);
      comparacao = { base: f, resolvidos: antes.filter((x) => !depois.includes(x)), persistem: antes.filter((x) => depois.includes(x)), novos: depois.filter((x) => !antes.includes(x)) };
    } catch (e) { log('nao consegui ler a comparacao:', e.message); }
  }
  const limpo = { ...d, robots: { ...d.robots, _texto: undefined }, llms: Object.fromEntries(Object.entries(d.llms).map(([k, v]) => [k, { ...v, _texto: undefined }])), home: { ...d.home, _ldObjs: undefined, _hrefs: undefined }, paginas: d.paginas.map((p) => ({ ...p, _ldObjs: undefined, _hrefs: undefined })) };
  const saida = { kit: VERSAO_KIT, meta: { ...meta, fim: agora(), alvo }, farois: Object.fromEntries(Object.entries(F).map(([k, v]) => [k, { farol: v[0], evidencia: v[1] }])), achados: A, scanners, direto: limpo, comparacao, nao_medido: ['teste do objetivo', 'redes oficiais dos provedores', 'DOM renderizado', 'citacao em respostas de IA'] };
  escrever(path.join(base, 'achados.json'), saida);
  escrever(path.join(base, 'relatorio.md'), relatorio(meta, d, A, F, scanners, comparacao));
  log('pronto:', path.join(base, 'relatorio.md'));
  for (const [k, v] of Object.entries(F)) log(`${k}: ${v[0]}`);
  for (const s of scanners) log(`${s.ferramenta}: ${s.estado}${s.score != null ? ' ' + s.score + '/' + s.denominador : ''}`);
}

function corrigir() {
  const pasta = argv[1];
  if (!pasta) { console.error('Informe a pasta da avaliacao: node ar-run.mjs corrigir <pasta>'); process.exit(2); }
  const j = JSON.parse(fs.readFileSync(path.join(pasta, 'achados.json'), 'utf8'));
  const repo = typeof flag('repo') === 'string' ? path.resolve(flag('repo')) : null;
  const candidatos = ['public/robots.txt', 'static/robots.txt', 'robots.txt', 'app/robots.ts', 'src/app/robots.ts', 'public/llms.txt', 'static/llms.txt', 'app/sitemap.ts', 'src/app/sitemap.ts', 'public/sitemap.xml', 'app/layout.tsx', 'src/app/layout.tsx', 'pages/_document.tsx', 'index.html', 'astro.config.mjs', 'next.config.js', 'next.config.mjs', 'wp-config.php'];
  const existentes = repo ? candidatos.filter((c) => fs.existsSync(path.join(repo, c))) : [];
  const ehRepo = repo && (fs.existsSync(path.join(repo, '.git')) || fs.existsSync(path.join(repo, 'package.json')));
  const plano = j.achados.filter((a) => a.severidade !== 'info').map((a) => ({
    ordem: a.ordem, id: a.id, titulo: a.titulo, risco: a.risco, baixo_risco: a.baixo_risco, correcao: a.correcao, artefato: a.artefato ? path.join(pasta, a.artefato) : null,
    arquivos_no_repo: existentes.filter((e) => (a.arquivos || []).some((x) => x.includes(path.basename(e).split('.')[0]) || e.includes(x))),
  }));
  const out = { modo: ehRepo ? 'repositorio' : 'arquivos_prontos', repo, arquivos_detectados: existentes, regras: ['uma correção por vez: mostrar arquivo e diff, pedir ok / pular / parar', 'aplicar todas de baixo risco só com ok explicito', 'nunca commit, push ou deploy', 'ao fim rodar: node ar-run.mjs avaliar <URL local ou de preview> --sem-scanners --comparar ' + pasta], plano };
  escrever(path.join(pasta, 'plano-correcao.json'), out);
  console.log(JSON.stringify(out, null, 2));
}

if (cmd === 'avaliar') await avaliar(); else corrigir();

Regras do kit