AI Browser

Agentes de navegador Electron nativos de IA operam por meio de uma árvore semântica de acessibilidade — 17 ferramentas MCP browse_*, sem screenshots/OCR/adivinhação de coordenadas. Entrada CDP confiável, verificação de ações via snapshots/diffs e uma janela compartilhada humano/IA que nunca rouba o foco. Stdio local via npx.

Documentação

AI Browser

Um navegador real cujo único operador é o seu agente.
Ele lê páginas como uma árvore semântica em vez de capturas de tela, age por meio de entrada de navegador confiável — e, por ser uma janela Electron normal e visível, um humano pode alcançar o mouse a qualquer momento.

CI MIT Electron Platforms Tests MCP

Um agente, sem coordenadas: abra o Bing → digite por meio de eventos de teclado confiáveis (sugestões ao vivo) → role os resultados → abra a página 2 → volte para a página 1 → abra o site do projeto em uma nova aba → continue rolando lá. Cada alvo foi encontrado na árvore semântica por papel/rótulo.
AI Browser demo: search, paginate, and open a website — all driven by the semantic tree
Reproduza: npm start e depois node examples/demo.mjs

O que é. Um aplicativo Electron que expõe suas abas por meio de duas interfaces equivalentes — um servidor MCP (17 browse_* ferramentas) e uma API WebSocket JSON-RPC bruta (ws://localhost:9223, métodos ui.*). Seu agente navega, lê uma árvore semântica estruturada (role / label / value / states / actions), e clica ou digita por data-ai-id. Não há etapa de captura de tela nem OCR: src/ não contém nenhuma chamada de captura.

O que não é. Não é headless. A janela realmente abre, realmente renderiza e permanece interativa — veja Deixando um humano intervir. Também não "conduz junto" com você: a transferência é uma conversa entre o agente e o usuário, não uma API de navegador.

Nota de segurança. O servidor WebSocket não tem autenticação — ele escuta em localhost e confia em quem se conectar. Não exponha a porta 9223 além do loopback e evite redes não confiáveis.


Instalação

Requer Node ≥ 18 e uma sessão de desktop (este é um aplicativo GUI).

Opção A — npx (recomendado para usuários de MCP)

O pacote é publicado como @glittering0717/ai-browser; não é necessário clonar:

npx -y @glittering0717/ai-browser --help

Para abrir a janela visível do navegador diretamente:

npx -y @glittering0717/ai-browser start

O primeiro lançamento prepara o mecanismo Electron (~100 MB, baixado uma vez). O npm ≥ 11 bloqueia scripts de instalação de dependências por padrão — o lançador detecta isso e executa o próprio download do Electron, então nada extra é necessário. Se o download dos lançamentos do GitHub estiver lento ou bloqueado (comum em redes da China continental), aponte para um espelho:

ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npx -y @glittering0717/ai-browser start

Opção B — a partir do código-fonte

git clone https://github.com/Glittering/ai-browser.git
cd ai-browser
npm install

Se o download do binário do Electron estiver lento ou falhar completamente, use o espelho:

ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm install

Ou torne-o permanente criando um .npmrc na raiz do projeto:

electron_mirror=https://npmmirror.com/mirrors/electron/

Confirme que o binário realmente foi baixado antes de culpar qualquer outra coisa — um binário ausente ainda deixa você com uma "instalação que foi bem-sucedida":

ls node_modules/electron/dist

Para verificar a instalação de ponta a ponta:

npm start

Uma janela de 1280×800 abre e o terminal imprime AI Browser WS server listening on ws://localhost:9223. (A primeira aba abre na página inicial padrão; ignore-a e navegue para outro lugar.)


Conecte seu agente

Adicione isto ao arquivo de configuração do seu cliente MCP — a configuração do Claude Desktop, ~/.cursor/mcp.json, ou um .mcp.json no nível do projeto:

{
  "mcpServers": {
    "ai-browser": {
      "command": "npx",
      "args": ["-y", "@glittering0717/ai-browser", "mcp"]
    }
  }
}

Uma cópia pronta está em examples/mcp-config.json. Usuários a partir do código-fonte podem, em vez disso, apontar command: "node" para /absolute/path/to/ai-browser/src/main/mcp_server.js.

Você não precisa iniciar o navegador sozinho. Na primeira chamada browse_*, o servidor MCP testa a porta 9223; se nada estiver escutando, ele inicia o Electron em modo destacado e espera até 30 segundos pelo servidor WebSocket (a primeira chamada pode gastar tempo extra preparando o mecanismo). Se você preferir manter uma janela de longa duração compartilhada por todos os clientes, execute npx -y @glittering0717/ai-browser start (ou npm start a partir do código-fonte) em um terminal — um segundo lançamento apenas foca a janela existente.

examples/AGENT_INSTRUCTIONS.md tem um prompt de copiar e colar que você pode inserir em um CLAUDE.md, Cursor Rule, ou qualquer prompt de sistema; ele ensina o loop de ferramentas, os parâmetros que economizam tokens e quando perguntar a um humano. Agentes que se instalam sozinhos (Cline, etc.) podem ler o curto llms-install.md orientado a máquinas em vez deste README.


As ferramentas MCP

Fonte da verdade: src/main/mcp_tools.js. São 17 — uma superfície deliberadamente pequena, já que cada esquema de ferramenta é injetado no contexto do seu agente a cada turno.

FerramentaUma linha
browse_navigateCarregar uma URL na aba ativa (ou na aba fornecida).
browse_get_treeLer a página como uma árvore semântica + contexto da página (modais, mensagens, estatísticas).
browse_actclick / type (anexar no cursor) / setContent (substituir tudo) / clear / focus / hover / scroll_to / upload / get_value (somente leitura) / drag / press / wheel em um data-ai-id.
browse_diffsnapshot uma linha de base, depois diff para obter caminhos adicionados / removidos / alterados com valores antes/depois. É assim que você prova que uma ação fez algo.
browse_capabilitiesSondar o que uma página oferece e qual rota seguir nela (veja abaixo).
browse_repaintForçar o compositor a redesenhar a aba quando a janela mostra uma página desatualizada; retorna repaint_count.
browse_downloadsListar os downloads de uma aba com seus caminhos no disco. Downloads são assíncronos — consulte isso após clicar em um botão de exportação.
browse_canvasLer <canvas> por meio de chamadas de desenho capturadas: list / read / configure / capture. Experimental — veja a seção de canvas abaixo.
browse_evaluateExecutar uma expressão JS no contexto da página (limite de 5000 caracteres, identificadores de Node rejeitados).
browse_scrollRolar a página, ou rolar um elemento para a visualização.
browse_waitConsultar até button_enabled / modal_appeared / text_contains / url_contains.
browse_list_tabsListar abas abertas (id, url, título, ativa).
browse_new_tabAbrir uma nova aba, opcionalmente em uma URL.
browse_close_tabFechar uma aba por id.
browse_set_active_tabAlternar em qual aba as chamadas subsequentes atuam.
browse_networkInspeção de rede no estilo Chrome-DevTools: list (filtrar por método / URL / status / tipo), get (cabeçalhos e corpos de solicitação + resposta), clear, configure.
browse_quitDesligar todo o navegador e liberar o processo.

Prove que aconteceu: browse_diff

Uma ferramenta relatando success não é evidência de que algo mudou — o navegador dirá alegremente que um clique atingiu um elemento que o ignorou, que um arrastar foi concluído quando o aplicativo encaixou o nó em outro lugar completamente, ou que uma tecla foi digitada em um campo que depois se limpou. Cada escrita deve ser envolvida em um par de snapshot/diff:

const base = await browse_diff({ operation: "snapshot", js: "app.graph.serialize()" });
await browse_act({ action: "click", target: "e:button-25" });
const d = await browse_diff({ operation: "diff", snapshot: base.snapshot });
// d.verdict === "changed"
// d.changed_paths → [{ path: "nodes[7].widgets.steps", from: 20, to: 21, reason: "value" }]
  • verdict é um "changed" / "unchanged" explícito, então "nada aconteceu" nunca é inferido de um array vazio que você pode ter construído errado.
  • Cada entrada carrega um path mais ambos os valores, para que você possa verificar por que mudou.
  • Arrays são comparados elemento a elemento por índice, não como conjuntos: um nó movido aparece como entradas alteradas, nunca como "um removido + um adicionado" (o que pareceria dois eventos em vez de uma reordenação).
  • Ruído de ponto flutuante é tolerado (201354.48743718592 vs ...1859 não é uma mudança), mas uma mudança real de 22 unidades de coordenada é.
  • Omita js para um snapshot de DOM independente de site — papel / rótulo / valor / desabilitado / tamanho por elemento mais o texto visível da página. Nenhum JS específico do site é necessário, e mudanças simples de texto de status <div> são capturadas.
  • Omita js e ele também captura a impressão digital do modelo de dados do próprio aplicativo quando a página pendura um em window. O DOM de um aplicativo renderizado em canvas é uma fotografia estática — arrastar um nó ou editar um widget repinta <canvas> e não move nada que a varredura de elementos possa ver, então um diff apenas de DOM responde unchanged a uma escrita que demonstravelmente aconteceu. O caminho de escrita documentado do ComfyUI (node.widgets[].value) moveu o DOM em exatamente zero bytes. Com a impressão digital, essa mesma escrita aparece como model.fp (e model.count quando a contagem de nós mudou). É somente leitura (loadGraphData é um carregador e nunca é chamado), escolhe o candidato que pode realmente ser lido de volta em vez do que parece mais promissor, e um namespace de biblioteca — que pontua bem em nomes e não tem modelo para devolver — é ignorado.
  • rearm: true move a linha de base para frente para o próximo diff, então você pode repetir "confirmar que nada mudou" em muitas etapas; forget: true a descarta.
  • Uma expressão que lança erro, um id ausente ou uma comparação entre abas retornam um erro explícito. Eles nunca degradam para um snapshot vazio, o que faria cada diff subsequente relatar "inalterado" — o único modo de falha que transforma toda verificação em verde falso.

Qual rota nesta página? browse_capabilities

Antes de improvisar em um site desconhecido, pergunte. Uma chamada relata o tamanho da árvore semântica e seu histograma de papel/ação, contagem de canvases, iframes de mesma origem e seu tamanho interno, objetos que o aplicativo pendura em window (auto-detectados por forma: has .graph / ._nodes / getState / serialize), os endpoints JSON que já foram chamados, e uma rota recomendada com a evidência para ela:

  • L1_structured_data — o aplicativo expõe seu próprio modelo; leia e escreva todo o grafo como JSON.
  • L2_semantic_tree — elementos suficientes para encontrar alvos por papel/rótulo e dirigi-los por data-ai-id.
  • L3_pointer_gestures — apenas para o que não tem caminho estruturado (arrastar um nó, desenhar uma conexão, pan/zoom com roda). Sempre siga com browse_diff.

Também sinaliza armadilhas explicitamente: "o corpo está em um iframe de mesma origem" ou "há 7 canvases, mas apenas 17 elementos semânticos — pare de tentar atingir itens desenhados em canvas com coordenadas e vá encontrar o modelo de dados".

Notas que economizam idas e voltas:

  • browse_get_tree aceita subset: "interactive" para descartar ramificações apenas de layout, mode: "diff" para retornar apenas nós que apareceram/desapareceram desde a última leitura, e ax: true para ler através da camada de acessibilidade (links então carregam url).
  • type anexa; setContent substitui. Antes eles eram idênticos (ambos substituíam todo o conteúdo), o que tornava a edição incremental impossível — insira uma tag de tópico estilo Weibo e o próximo type a apagava. Agora type escreve no cursor (params.at: end (padrão) / start / cursor), então "insira um tópico e continue escrevendo o corpo" funciona. Para preencher um campo novamente, use setContent (ou clear primeiro).
  • browse_act também aceita action: "get_value" — uma forma somente leitura de buscar o texto completo de um elemento, o que importa porque browse_get_tree trunca valores longos em 200 caracteres (veja abaixo).
  • Gestos de ponteiro são cidadãos de primeira classe: drag, press e wheel. UIs de canvas / quadro / ordenável são construídas sobre eles, e nenhuma quantidade de evaluate pode substituir — PointerEvents sintéticos não movem um framework que usa Pointer Capture (medido em um canvas React Flow: a posição do nó mudou em exatamente 0,0), então estes passam por entrada confiável do CDP.
    • drag: dx/dy, to_x/to_y, ou to_target (soltar sobre outro elemento). from_anchor / to_anchor (left/right/top/bottom/center) agarram um lado — um lado prefere a alça de conexão desse elemento, mas somente se a alça for realmente testável por hit (veja a nota abaixo). from_x/from_y começam de um ponto vazio (panorâmica de canvas vazio), caso em que nenhum target é necessário.
    • press: key (Delete, Escape, ArrowRight, Enter, … ou uma combinação como "Meta+a"), ou keys para uma sequência. Excluir um nó, desfazer, desmarcar — tudo apenas por teclado.
    • wheel: delta_x / delta_y mais hold: ["Control"] para zoom do canvas. browse_scroll não consegue fazer isso — ele chama window.scrollBy, que um canvas infinito ignora completamente.
    • O resultado do arrasto relata drag_from / drag_to e qual elemento realmente está em cada ponto (from_at_id), além de warning + start_hit_target: false quando o pressionamento caiu em um elemento diferente — sobreposição visual, caso contrário, parece exatamente como "arrastar não faz nada".
  • actions é inferido do comportamento, não de role. Um elemento cujo cursor calculado é um cursor de arrasto, ou que tem draggable="true" / cursor: pointer / um onclick, agora anuncia drag / click na árvore mesmo quando seu papel é group. Sem isso, um canvas cheio de nós role: group relatava actions: [] e o agente não conseguia dizer que eles eram interativos.

  • browse_network captura requisições desde o momento em que uma aba abre, incluindo corpos de requisições POST — nenhuma assinatura necessária. Cabeçalhos de requisição são ocultados por padrão (authorization / cookie → [REDACTED]); passe include_sensitive_headers: true para vê-los.
  • A maioria das ferramentas aceita um tab opcional para trabalho com múltiplas abas; sem ele, elas miram na aba ativa.

Deixando um humano intervir

Não rouba seu foco

O agente funciona bem enquanto a janela está em segundo plano. Entrada confiável do CDP (Input.dispatchKeyEvent / Input.dispatchMouseEvent) não precisa que a janela esteja em primeiro plano, e o aplicativo já roda com disable-backgrounding-occluded-windows, disable-renderer-backgrounding e disable-background-timer-throttling, então uma janela em segundo plano não é limitada nem perde eventos.

Isso já era verdade para act / get_tree / evaluate / peek / scroll — mas dois caminhos costumavam arrastar a janela para a frente em toda chamada: new_tab e set_active_tab (eles chamavam webContents.focus(), que ativa a janela inteira). Além disso, a janela era mostrada com show() na inicialização mesmo quando o agente a lançava. Ambos foram corrigidos:

  • new_tab / set_active_tab não roubam mais o foco da janela. A página ainda pensa que está focada — Emulation.setFocusEmulationEnabled mantém document.hasFocus() e o carregamento preguiçoso dependente de foco se comportando normalmente — enquanto o foco do teclado permanece onde você o deixou.
  • Quando o agente lança o navegador (via MCP), a janela aparece via showInactive(): visível, mas sem roubar o foco. Quando você executa npm start, ela aparece na frente como de costume.

Então a janela permanece visível — esse é o ponto — mas não é arrastada para a frente enquanto o agente dirige. Você pode continuar trabalhando.

AI_BROWSER_FOCUS=always npm start   # pre-1.2 behaviour: show + moveTop + focus on every action
AI_BROWSER_FOCUS=never npm start    # never change focus (the window is still shown)

O padrão é auto: manter a janela visível (restaurá-la se estiver minimizada ou oculta), mas nunca roubar foco. Qualquer que seja o valor, a janela é sempre exibida — isso é um invariante do produto.

npm run focus afirma tudo isso de fora (macOS lsappinfo, sem permissão de acessibilidade necessária) e inclui um grupo de controle deliberado configurado para roubar foco — então a suíte ficaria vermelha se a medição parasse de funcionar, em vez de passar vacuamente.

Também prova a afirmação mais forte — que a janela não precisa estar em primeiro plano de forma alguma. Com outro aplicativo segurando o primeiro plano, ela executa uma fatia representativa da API e verifica o resultado real de cada uma: digitação chega, um clique dispara exatamente uma vez, get_tree retorna uma árvore, canvas getImageData retorna pixels reais, network_list vê a requisição, navigate completa — e depois de ficar em segundo plano por 10 segundos, ainda faz tudo acima sem nunca vir para a frente.

Nota sobre document.hasFocus(): ele retorna true mesmo quando a janela está em segundo plano, porque Emulation.setFocusEmulationEnabled está deliberadamente ligado — é isso que mantém o carregamento preguiçoso dependente de foco e as animações sem travar. Então não é uma forma válida de detectar "estou em primeiro plano"; o teste usa o observador externo em vez disso.


Esta é a parte mais facilmente mal compreendida, então claramente: o agente dirige o navegador, e o humano pode sempre assumir a janela.

Por ser uma janela Electron real, uma pessoa pode clicar, digitar, rolar e fazer login exatamente como faria no Chrome — com mouse e teclado, a qualquer momento, sem precisar de permissão. Isso cobre as coisas que um agente genuinamente não consegue fazer:

  • Login / SSO e qualquer coisa atrás de uma senha que o agente não deveria ter
  • Escaneamentos de QR-code (autorização app-para-web)
  • CAPTCHAs de controle deslizante e imagem, códigos SMS ou e-mail
  • Confirmação de pagamento, intersticiais de controle de risco, telas de OTP
  • Qualquer coisa onde continuar significaria fabricar credenciais

Como funciona a transferência: não existe — no navegador. Nada coordena isso no lado do AI Browser, e nada precisa. É uma conversa entre o agente e seu usuário:

  1. O agente encontra algo que não consegue fazer.
  2. Ele diz isso no chat, claramente: "Por favor, escaneie o código QR na janela do navegador e responda 'done'."
  3. Ele para e espera a resposta — sem loops de tentativa em cliques que não podem funcionar.
  4. Após "done", ele relê com browse_get_tree para confirmar o novo estado e continua.

Veja examples/with-human.mjs para o equivalente com script: ele imprime um prompt e espera Enter via readline. O navegador não tem ideia de que nada disso aconteceu.

Duas consequências práticas para o agente:

  • Nunca chame browse_quit enquanto um humano está trabalhando na janela. Ele derruba a janela e sai do processo — qualquer coisa meio resolvida é perdida. Saia quando a tarefa estiver concluída, não antes.
  • O estado de login persiste. As abas vivem na partição de sessão persist:ai-browser, então cookies e sessões sobrevivem a um reinício; geralmente um humano só precisa intervir uma vez por site por máquina.

API WebSocket Bruta

MCP é um adaptador stdio fino. Qualquer coisa que o MCP pode fazer é alcançável diretamente via JSON-RPC 2.0 em ws://localhost:9223 de qualquer linguagem. Fonte da verdade: o switch de métodos em src/main/ws_server.js.

MétodoPropósitoMapeia para
ui.get_treeÁrvore semântica (+ contexto). Params: focusedOnly, ax, subset, mode, tabbrowse_get_tree
ui.actparams.action + params.target; argumentos por ação aninhados sob params.paramsbrowse_act
ui.navigateCarregar uma URL (params.url, tab opcional). Retorna {ok, error?} — {ok:false} sempre carrega um motivo.browse_navigate
ui.snapshot / ui.diffCapturar uma linha de base (params.js, label opcional) e fazer diff contra ela (params.snapshot, rearm, forget)browse_diff
ui.capabilitiesSondar a página: histograma da árvore, canvas, iframes, globais do app, endpoints, rota recomendadabrowse_capabilities
ui.repaintForçar o compositor a redesenhar uma aba; retorna repaint_count para que um repaint seja comprovávelbrowse_repaint
ui.evaluateExecutar JS no contexto da página, mesma proteção que o MCPbrowse_evaluate
ui.scrollRolar página ou elemento (direction, amount, target). Somente rolagem de documento — para apps de canvas use ui.act{action:'wheel'}.browse_scroll
ui.waitPoll até uma condição ou timeoutbrowse_wait
ui.new_tab / ui.close_tab / ui.list_tabs / ui.set_active_tabGerenciamento de abasbrowse_*_tab
ui.subscribe / ui.unsubscribeAssinatura de eventos; params.events, "*" para todos— (somente WS — veja abaixo)
ui.downloadsDownloads desta aba com caminhos em disco (state, received_bytes, dir)browse_downloads
ui.get_focusedA subárvore do elemento focado—
ui.peekPassar o mouse sobre um recurso recolhido/somente hover e retornar o diff, sem confirmar—
ui.network_bodyCorpo da resposta por substring da URL (url_pattern)— (substituído por browse_network)
ui.quitDesligamento graciosobrowse_quit

Note duas diferenças da grafia do MCP, ambas visíveis no switch acima: é focusedOnly (camelCase), não focused_only; e ui.act aninha seus argumentos por ação sob params.params. ui.subscribe / ui.unsubscribe também são válidos como notificações sem id.

ui.subscribe é deliberadamente não exposto como uma ferramenta MCP: o caminho stdio do MCP descarta notificações servidor→cliente, então um chamador MCP assinaria e nunca receberia um único evento. Eventos em tempo real são uma capacidade somente WebSocket bruto. Para qualquer coisa baseada em pull, use browse_network em vez disso.

import WebSocket from 'ws';

const ws = new WebSocket('ws://localhost:9223');
let id = 0;
const call = (method, params) => new Promise((resolve, reject) => {
  const myId = ++id;
  const onMsg = (raw) => {
    const m = JSON.parse(raw.toString());
    if (m.id === myId) { ws.off('message', onMsg); m.error ? reject(new Error(m.error.message)) : resolve(m.result); }
  };
  ws.on('message', onMsg);
  ws.send(JSON.stringify({ jsonrpc: '2.0', id: myId, method, params }));
});

ws.on('open', async () => {
  await call('ui.navigate', { url: 'https://example.com' });
  const { tree } = await call('ui.get_tree', { subset: 'interactive' });
  console.log(tree.role, tree.children?.length);
  ws.close();
});

Códigos de erro seguem JSON-RPC: -32700 erro de parse, -32601 método desconhecido, -32602 params inválidos (a proteção ui.evaluate retorna isso), -32603 erro interno.


Como funciona

   agent ── MCP stdio (17 browse_*) ──┐
   agent ── WS JSON-RPC :9223 (ui.*) ─┤
                                      ▼
 ┌──────────────────────────────────────────────────┐
 │ Electron main                                    │
 │   index.js        window · tab strip · watchdog  │
 │   ws_server.js    JSON-RPC routing, event stream │
 │   page_manager.js tabs · id-matched IPC · CDP    │
 └───────────────┬──────────────────────────────────┘
                 │ IPC (request-id matched)
 ┌───────────────▼──────────────────────────────────┐
 │ preload (contextIsolation:true, nodeIntegration:false)
 │   bridge.cjs · extractor.cjs · actions.cjs ·     │
 │   watcher.cjs  (captcha / message / state scan)  │
 └───────────────┬──────────────────────────────────┘
                 ▼
              live DOM → semantic tree

Leitura. Cada nó na árvore retornada carrega um data-ai-id — o próprio DOM id do elemento quando ele tem um, caso contrário um e:tag-N gerado. Os IDs são estáveis entre leituras: um elemento que já carrega um mantém o seu, e os IDs gerados vêm de um contador monotônico, então inserir um elemento acima de outros não os renumera. (Antes acontecia: um ID obsoleto então resolvia silenciosamente para um elemento diferente, e a ação relatava sucesso enquanto atingia a coisa errada.) Eles são redefinidos na navegação. Junto com o ID vêm role, label, value, states e actions, além de um bloco context — modais abertos, mensagens transitórias, dicas de campos obrigatórios, estatísticas aproximadas. Existem dois caminhos de leitura: o extrator de pré-carregamento padrão e a camada de acessibilidade por trás de ax: true (que adicionalmente dá aos links seus url).

A primeira leitura de uma página anota elementos com data-ai-id; essa é a única escrita que uma leitura realiza, e é o que torna as ações posteriores resolvíveis. Leituras repetidas não escrevem nada. Um atributo só é definido quando seu valor realmente muda, porque o Chromium real entrega um registro MutationObserver mesmo quando você define um atributo para o valor que ele já possui — medido em 4001 registros por leitura em uma página de 4000 elementos antes dessa proteção existir. Uma leitura que inunda os próprios observadores da página é uma leitura que perturba aquilo que está medindo.

Escrita. As ações são entrada de navegador confiável, não JS sintetizado. O processo principal dirige a página pelo Chrome DevTools Protocol — Input.dispatchMouseEvent para cliques e Input.dispatchKeyEvent por caractere (rawKeyDown → char → keyUp) para digitação — e então valida que o clique realmente ocorreu via elementFromPoint. Como os eventos chegam como entrada real de teclado e mouse, Draft.js, ProseMirror, CKEditor, Quill e <textarea> simples funcionam todos pelo mesmo caminho sem ramificações específicas por framework.

Onde é limitado. extractor.cjs é uma heurística sobre o DOM, não uma especificação de sala limpa: ele lida bem com páginas comuns e rotulará erroneamente as exóticas. A janela roda com aceleração de hardware desativada e três switches do Chromium (--disable-backgrounding-occluded-windows, --disable-renderer-backgrounding, --disable-background-timer-throttling) para que uma janela de IDE sobre o navegador não cause a entrada injetada a ser silenciosamente descartada.


Exemplos

Scripts executáveis vivem em examples/ e conectam direto ao ws://localhost:9223:

ArquivoO que mostra
mcp-config.jsonConfiguração mínima de cliente MCP; substitua o caminho do placeholder.
AGENT_INSTRUCTIONS.mdInstruções de copiar e colar para seu agente, incluindo o protocolo de transferência humana.
raw-ws.mjsMenor cliente WebSocket bruto possível.
read-page.mjsCarrega uma página, imprime seus elementos interativos.
with-human.mjsFluxo completo para quando um humano precisa intervir.
node examples/read-page.mjs

Solução de problemas

A página atualizou, mas a janela ainda mostra a antiga. Isso costumava exigir clicar nas abas de um lado para o outro, porque a única coisa que forçava o compositor a redesenhar um BrowserView era setActiveTab fazendo removeBrowserView + addBrowserView + setBounds — a navegação nunca fazia isso. Agora todo sinal de navegação o aciona: did-navigate, did-navigate-in-page (mudanças de rota SPA não disparam nenhum evento de load), did-frame-finish-load (um editor de canvas dentro de um iframe inicia muito depois que o documento superior terminou), além de show / restore / focus da janela — o que importa aqui porque esta janela é projetada para nunca roubar seu foco, então ela fica atrás do seu editor por minutos a fio. Se ainda parecer desatualizada, ui.repaint força isso sob demanda e retorna repaint_count para que você possa confirmar que um repaint realmente aconteceu.

Uma ação relatou sucesso, mas nada mudou. Duas causas que valem a pena conhecer:

  • Sobre WS bruto, ui.act aninha seus argumentos por ação sob params.params — {action:"type", target:"<id>", params:{text:"…"}}. Flattening it to {text:"…"} passa uma string vazia, que costumava atingir o curto-circuito idempotente e relatar success sem digitar nada. Agora retorna um erro explícito em vez disso.
  • Às vezes a ação genuinamente não fez nada (um overlay engoliu o clique, o app reverteu a mudança). É para isso que serve browse_diff — envolva a escrita em um par snapshot/diff em vez de confiar no valor de retorno.

Uma ação nunca retorna. Todo caminho que entra na página é limitado por um prazo, então uma página cujo thread principal está bloqueado (um recálculo pesado, um JSON.parse enorme, um script de terceiros em loop) nunca pode travar sua chamada de ferramenta. Medido contra uma página que ocupa seu thread principal por 120s:

ChamadaComportamento
ui.get_tree / ui.get_focusederros em 5s (getTree timeout)
ui.evaluateerros em 10s (CDP 5s, depois os 5s do fallback de pré-carregamento)
ui.actretorna em 15s com success:false, timed_out:true
ui.capabilitiesretorna normalmente (~15s)
ui.list_tabs / ui.network_listinstantâneo — nunca tocam a página

O orçamento de ui.act escala com o trabalho solicitado, então uma operação longa legítima não é morta: type /setContent orçam text.length × interval_ms, drag orça duration_ms + steps, press orça repeat × interval_ms, clear recebe 20s. Substitua com params.params.timeout_ms (piso de 1s, teto de 300s) ou globalmente com AI_BROWSER_ACTION_TIMEOUT_MS. Um timeout avisa explicitamente que a ação pode ter sido parcialmente aplicada — ponteiro já pressionado, metade dos caracteres digitados — então releia antes de tentar novamente em vez de disparar a mesma escrita duas vezes.

O app reinicia sozinho. O NetworkService do Electron pode travar como um todo: a porta ainda escuta, toda navegação falha com ERR_FAILED, e curl funciona bem. Um watchdog sonda a partir do processo principal a cada 30s; após 5 varreduras consecutivas com falha, ele transmite network_wedged e relança (o cliente MCP reconecta sozinho). Com um prazo de 5s por solicitação, isso coloca um travamento real em aproximadamente dois minutos.

  • Mude o que ele sonda: AI_BROWSER_WATCHDOG_URLS=https://www.baidu.com/,https://example.com/ (separado por vírgulas). O padrão é example.com mais www.baidu.com, e essa pluralidade é deliberada — a rede conta como viva se qualquer alvo responder, então um host instável ou com bloqueio geográfico não pode acionar um loop de reinicialização. Nunca defina um único alvo que sua rede não possa alcançar.
  • Desligue completamente: AI_BROWSER_WATCHDOG=0 (também aceita off / false / no).

A porta 9223 já está ocupada. Outra instância — muitas vezes uma obsoleta de uma sessão anterior — possui a porta, ou outra coisa possui. Ou saia dessa instância, ou mova o AI Browser para fora dela:

AI_BROWSER_PORT=9224 npm start

Mantenha a porta consistente em todos os clientes; tanto o app quanto o servidor MCP leem AI_BROWSER_PORT de src/shared/config.js.

O Electron está ausente ou a inicialização falha imediatamente. O mecanismo de ~100 MB nunca chegou. Com o pacote npm, basta iniciar novamente — o launcher baixa ele mesmo (defina ELECTRON_MIRROR para uma rede lenta, veja Instalação). A partir do código-fonte, verifique ls node_modules/electron/dist e re-execute npm install com o espelho. Definir AI_BROWSER_PORT não ajudará aqui — isso é um binário ausente, não um conflito de porta.

A entrada é aceita, mas nada acontece. Geralmente a janela está ocluída. Os três switches disable-*-backgrounding acima existem para evitar isso, mas se você ainda vir cliques que relatam sucesso enquanto nada se move, traga a janela do AI Browser para a frente e tente novamente.

O estado de login continua sendo perdido. As sessões persistem sob ~/.ai-browser. Se você substituir, note que diretórios AI_BROWSER_USER_DATA separados são armazenamentos de sessão separados — "logado" em um não é "logado" em outro:

AI_BROWSER_USER_DATA=~/.ai-browser-work npm start

Teste e desenvolvimento

npm test             # vitest — 411 tests across 27 files
npm run smoke        # Electron contract layer, offline fixture      — 31 checks
npm run capabilities # text fields, canvas, page source, network, DOM editing — 54 checks
npm run richtext     # rich-text editors, nested menus, forms, errors, tags, upload — 117 checks
npm run network      # request log, POST bodies, headers, pagination, redaction — 41 checks
npm run value        # value truncation contract + read-only get_value — 15 checks
npm run canvas       # canvas draw-call capture — 13 checks
npm run modeldiff    # canvas app data-model diff (the "unchanged" false green) — 12 checks
npm run audit        # every advertised action must exist; wait/downloads/permissions/quit — 35 checks
npm run focus        # no focus stealing + full capability while backgrounded — 32 checks
npm run interact     # trusted drag / key press / wheel (pointer gestures) — 31 checks
npm run view         # viewport repaint + snapshot/diff verification — 28 checks
npm run realsites    # navigate/read/act against a matrix of real sites

Todas as suítes e2e inicializam um Electron genuíno contra fixtures offline e saem com código não zero em falha. Juntas, elas cobrem o que o jsdom estruturalmente não pode: integridade do pré-carregamento, ui.evaluate sob um CSP estrito, teste de acerto de clique via CDP, ciclo de vida multi-abas, fan-out de eventos e as proteções evaluate.

Todas precisam da porta WS livre — pare um npm start em execução primeiro, ou aponte-as para outro lugar:

AI_BROWSER_PORT=9333 npm run smoke

Elas inicializam seu próprio Electron em um perfil isolado (/tmp/ai-browser-e2e-<port> por padrão), então nunca tocam seu perfil ~/.ai-browser real — aquele que guarda suas sessões logadas. Elas também limpam seu Electron na saída, incluindo em Ctrl-C / timeout / crash: um processo órfão de outra forma seguraria a porta e o bloqueio de instância única, e você encontraria npm start recusando iniciar com "outra instância do AI Browser já está em execução".

O que cada suíte de capacidade provou

npm run view — a suíte para as duas coisas que silenciosamente produzem falsa confiança.

Viewport. Um BrowserView pode estar totalmente carregado e ainda mostrar pixels desatualizados, e a única coisa que forçava um repaint era alternar abas — que é exatamente por que "clicar nas abas algumas vezes" costumava consertar. Agora dispara em todo sinal de navegação e em show/restore/focus da janela. A suíte prova isso a partir do contador cumulativo de repaints, não de uma captura de tela: uma captura de tela precisa de olhos, um contador falsifica. Também prova que o debounce não se autodispara (um loop de repaint piscaria para sempre) e que repintar não muda nenhum dado da página.

Verificação. ui.diff só é útil se estiver correto em ambas as direções, então cada asserção vem em pares: uma mudança real deve ser relatada e uma página inalterada não deve ser. Os controles negativos são o ponto — um diff que inventou mudanças, ou um snapshot que silenciosamente ficou vazio em erro (fazendo todo diff posterior relatar "inalterado"), transformaria toda verificação em verde falso. A suíte também fixa o modo de falha onde uma camada de parâmetro digitada errado fez type relatar sucesso sem digitar nada.

npm run richtext — a suíte mais profunda. Editores de rich text e formatação, menus aninhados de dois e três níveis, campos obrigatórios vs opcionais (required nativo, aria-required, asteriscos vermelhos), superfícies de erro e aviso (nível de campo, nível de formulário, toasts, diálogos modais), radio / checkbox / select / combobox, entradas de tag e inserção de tópicos estilo Weibo, e upload de imagem. Cada asserção de ação é verificada contra o DOM real, não apenas a árvore semântica.

Duas coisas que ela mudou no produto:

  • Um clique em um botão que se dispensa costumava disparar duas vezes (o teste de hit ocorria após o dispatch, encontrava o alvo ausente e recorria a um segundo clique). Envios duplicados, menções duplicadas. Cliques agora fazem o teste de hit antes do dispatch e não recorrem mais.
  • Erros em nível de campo costumavam ser invisíveis — eles são transportados por <span>s nus, sem papel ARIA, e eram podados como layout. Agora eles aparecem em context.errors / context.field_errors, escaneados em todo o documento, em vez de apenas dentro de contêineres de diálogo.
  • Três leituras costumavam depender do nome da classe de um contêiner. Asteriscos vermelhos eram escaneados apenas dentro de contêineres cuja classe continha modal/dialog/popup/…, então os campos obrigatórios de um formulário em página simples eram invisíveis; entradas modals[].fields[] não carregavam id, então uma leitura de erro para um campo não podia ser mapeada de volta para ele; e texto auxiliar em <span>/<div> nus (contadores de caracteres, dicas de formato) era podado da árvore semântica sem um segundo caminho. Todos os três agora ficam no nível superior de context: required_hints é escaneado em todo o documento, entradas fields[] são moldadas de forma idêntica em modals[] e forms[] (id incluído, verificado contra o DOM real), e texto de span nu adjacente a formulários cai em hints — a própria árvore semântica mantém sua poda, então o custo de tokens permanece inalterado.

npm run capabilities — digitar em todo tipo de campo de texto (inputs simples, password, number, textarea, contenteditable, além de readonly / disabled / maxlength — incluindo entrada CJK e texto de 1000 caracteres), ler a fonte da página, capturar corpos de resposta e editar a página por meio de ui.evaluate.

npm run network — que um POST pode ser capturado com seu corpo de requisição completo, que cabeçalhos são redigidos por padrão e que corpos paginam em vez de serem silenciosamente truncados.

npm run modeldiff — a suíte para o único lugar onde ui.diff costumava mentir. O DOM de um app renderizado em canvas é uma fotografia estática: arrastar um nó ou editar um widget repinta <canvas> e move nada que a varredura de elementos possa ver, então o snapshot padrão somente-DOM respondia unchanged a uma escrita que demonstravelmente aconteceu — incluindo o caminho de escrita documentado do ComfyUI, node.widgets[].value, que move o DOM em exatamente zero bytes. A suíte prova a impressão digital do modelo em um fixture construído por nós (nunca o projeto ao vivo de alguém, onde snapping e quantização tornam "mover de volta" falso):

  • adicionar um nó → verdict: changed, e os caminhos de diff são apenas model.count / model.fp — zero nodes.* e zero text*, que é como o teste prova que o crédito pertence ao modelo em vez de ao DOM que por acaso se move;
  • o caso ComfyUI — mudar um valor de widget com a contagem de nós inalterada → ainda changed, e especificamente model.fp sem model.count;
  • ambas as direções: uma página intocada deve permanecer unchanged, e uma página simples sem objeto de app nunca deve adquirir um campo model (sem falsos vermelhos de maquinário que inventou um candidato);
  • o sinal do modelo não deve mascarar mudanças no DOM — quando ambos se movem, ambos são relatados.

Dois pontos de design que a suíte fixa, ambos aprendidos da maneira difícil:

  • a seleção é por o que pode realmente ser lido de volta, não por quão promissor o nome parece. Um namespace de biblioteca pontua bem em nomes e não tem modelo para devolver, então retornar null é o que o desqualifica;
  • a classificação vem do acessador mais forte que o objeto tem, enquanto a leitura passa por serialize() quando um existe (ele retorna dados seguros para JSON; as coleções ao vivo são geralmente circulares). Colapsar os dois faz cada candidato relatar serialize, todos empatam, e um mero serializador pode superar o modelo real — esse bug foi pego pelo teste de desempate, não pela revisão.

Canvas: leia por chamadas de desenho, não por pixels

A árvore semântica relata um <canvas> como role: canvas e nada mais — sem texto, sem pixels. browse_canvas corrige isso sem tirar screenshot: um gravador é injetado no mundo principal da página e envolve a API do Canvas, capturando as próprias chamadas de desenho.

O ponto é que um canvas é desenhado por código, então o código já sabe o que desenhou. fillText nos entrega a string literal — texto dentro de um canvas não precisa de OCR:

// browse_canvas(operation: "read", canvas_id: "canvas:nav-…:frame-main:1")
{
  "kind": "2d", "readability": "semantic",
  "texts": [
    { "seq": 2, "method": "fillText", "text": "确认支付",
      "bounds_canvas": { "x": 20, "y": 40, "width": 120, "height": 30 },
      "style": { "font": "20px sans-serif", "fill_style": "#000000" } }
  ],
  "regions": [ { "seq": 3, "kind": "rect", "method": "fillRect", "bounds_canvas": {…} } ]
}

O gravador é um tee, nunca uma tomada de controle: ele chama a API nativa primeiro (Reflect.apply(original, …)) e só então grava, dentro de seu próprio try/catch. Uma falha de gravação nunca pode mudar um valor de retorno, uma exceção, this ou ordem de desenho.

Dois limites que você deve conhecer:

  • O hook instala após o primeiro carregamento de uma página, então qualquer coisa desenhada durante esse carregamento inicial não é recuperável. browse_canvas(operation: "configure", reload: true) recarrega com o hook no lugar e dá a você o histórico completo. (Causa raiz: comandos CDP aguardados travam em uma aba recém-criada no Electron, então a instalação tem que esperar por did-finish-load.)
  • WebGL é opaco. Vértices, texturas e shaders não são reversíveis para significado de negócio, e texto geralmente é um atlas de glifos. Tais canvases são marcados opaque em vez de adivinhados. Para esse caso apenas, operation: "capture" retorna um PNG da região do canvas — desligado por padrão, invocado explicitamente, e custa os tokens de visão do seu agente.

Desative completamente com AI_BROWSER_CANVAS_HOOK=0.

Limites que vale a pena conhecer antes de confiar neles

  • node.value é truncado em 200 caracteres. Esse é nosso limite, não do CDP. Quando trunca, o nó carrega value_truncated: true, value_full_length e uma dica value_fetch pronta para chamar — use browse_act(action: "get_value"), que é somente leitura e pagina.
  • Campos de senha não expõem nenhum valor — apenas value_sensitive: true. A árvore semântica é alimentada integralmente no contexto do seu agente, então ela não deve carregar segredos.
  • js_error é transmitido para cada aba (cada evento carrega tabId), então ordenação e índices não são garantidos. Filtre por tabId; não confie em "o primeiro erro é o que me importa".
  • Uma "alça de conexão" só é usada se um ponteiro real puder alcançá-la. Muitos sites renderizam alças como decorações pointer-events: none + opacity: 0 (verificado no canvas rhtv: cada alça é pe:none / op:0). Ancorar em tal elemento pressionaria o corpo do nó em vez disso — então o resolvedor se autoverifica com elementFromPoint e recorre ao ponto médio da borda do elemento, relatando via: "edge". Se você vir via: "edge" onde esperava uma alça, essa é a resposta honesta: arrastar-para-conectar não está disponível naquele ponto, e o site deve conectar nós de outra forma.
  • ui.scroll é rolagem de documento. Ela faz window.scrollBy, então é um no-op em editores baseados em canvas, mapas e gráficos. Use ui.act{action:'wheel'} lá.

npm run realsites é orientado por tabela a partir de e2e/realsites/sites.cjs, atualmente 17 sites em 8 buckets (busca, finanças, mídia, dev, ecommerce, spa, editor, marketplace). A execução imprime dois números, e deliberadamente nunca os mistura em um:

  • 行为正确率 / taxa de comportamento — o navegador se comportou como esperado? Sites que exigem login ou respondem com uma parede anti-bot contam como aprovados quando a parede é detectada corretamente, porque isso significa que a detecção funciona.
  • 内容成功率 / taxa de conteúdo — realmente lemos o conteúdo? Isso conta apenas sites onde a árvore voltou acima do minNodes do site; uma parede não é conteúdo.

Relatar um único "percentual de sucesso" deixaria uma parede de login corretamente detectada inflar o número de conteúdo. A execução também imprime o tamanho do corpus, execuções por site, contagem de execuções e timestamp, então qualquer percentual em um relatório pode ser rederivado em vez de aceito por fé.

Layout:

src/main/      Electron main — ws_server · page_manager (tabs, CDP) · mcp_server · mcp_tools · axExtractor
src/preload/   bridge · extractor (semantic tree, data-ai-id) · actions · watcher
src/renderer/  tab bar UI
src/shared/    config (AI_BROWSER_* env) · guards (evaluate safety) · protocol · watchdog
e2e/           smoke_ws.cjs · realsites/
tests/         vitest unit suites
skills/        optional higher-level recipes built on the tools

src/shared/config.js é a fonte única para porta, diretório de dados do usuário e substituições de UA; guards.js é a fonte única para limites de evaluate, aplicados identicamente em ambos os transportes.


Compatibilidade

DimensãoSuporte
AgentesQualquer cliente MCP stdio; alternativamente WS bruto de qualquer linguagem
Node≥ 18
SOmacOS e Linux — desenvolvido e testado nesses. Windows: experimental e não testado. É Electron puro mais CDP, então deve compilar, mas ninguém executou; espere depurar npm install e a camada de janela você mesmo. Por favor, relate de qualquer forma.
Frameworks de páginaReact / Vue / Angular / DOM vanilla — localização de elementos é baseada em DOM, não em framework
EditoresEditores ricos dirigidos por entrada confiável em vez de caminhos de código por framework
Tipos de páginaEstáticas e SPA; atualizações dinâmicas observáveis via árvore, eventos dom_change / state_changed

Contribuindo

Issues e PRs são bem-vindos, especialmente casos de reprodução onde a árvore semântica erra uma página — esses são os relatórios de bug mais úteis que este projeto pode receber.

O que tentamos manter firme:

  • Mantenha o núcleo genérico. Conhecimento específico de site pertence fora de src/main e src/preload.
  • IPC permanece correspondido por ID de requisição, e sessões CDP permanecem com contagem de referência — abas vazam caso contrário.
  • Sem screenshots. Se uma mudança precisa de pixels para funcionar, a questão de design está a montante dela.
  • evaluate permanece protegido (limite de comprimento, sem identificadores Node), em ambos os transportes ao mesmo tempo.
  • Instruções do agente mudam quando o comportamento muda. A lista de ferramentas MCP, métodos ws_server.js, e examples/AGENT_INSTRUCTIONS.md devem concordar; uma doc que se desvia de mcp_tools.js é um bug.

Licença

MIT © Glittering