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.
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.

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.
| Ferramenta | Uma linha |
|---|---|
browse_navigate | Carregar uma URL na aba ativa (ou na aba fornecida). |
browse_get_tree | Ler a página como uma árvore semântica + contexto da página (modais, mensagens, estatísticas). |
browse_act | click / 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_diff | snapshot 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_capabilities | Sondar o que uma página oferece e qual rota seguir nela (veja abaixo). |
browse_repaint | Forçar o compositor a redesenhar a aba quando a janela mostra uma página desatualizada; retorna repaint_count. |
browse_downloads | Listar 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_canvas | Ler <canvas> por meio de chamadas de desenho capturadas: list / read / configure / capture. Experimental — veja a seção de canvas abaixo. |
browse_evaluate | Executar uma expressão JS no contexto da página (limite de 5000 caracteres, identificadores de Node rejeitados). |
browse_scroll | Rolar a página, ou rolar um elemento para a visualização. |
browse_wait | Consultar até button_enabled / modal_appeared / text_contains / url_contains. |
browse_list_tabs | Listar abas abertas (id, url, título, ativa). |
browse_new_tab | Abrir uma nova aba, opcionalmente em uma URL. |
browse_close_tab | Fechar uma aba por id. |
browse_set_active_tab | Alternar em qual aba as chamadas subsequentes atuam. |
browse_network | Inspeçã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_quit | Desligar 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
pathmais 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.48743718592vs...1859não é uma mudança), mas uma mudança real de 22 unidades de coordenada é. - Omita
jspara 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
jse ele também captura a impressão digital do modelo de dados do próprio aplicativo quando a página pendura um emwindow. 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 respondeunchangeda 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 comomodel.fp(emodel.countquando 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: truemove a linha de base para frente para o próximo diff, então você pode repetir "confirmar que nada mudou" em muitas etapas;forget: truea 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 pordata-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 combrowse_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_treeaceitasubset: "interactive"para descartar ramificações apenas de layout,mode: "diff"para retornar apenas nós que apareceram/desapareceram desde a última leitura, eax: truepara ler através da camada de acessibilidade (links então carregamurl).typeanexa;setContentsubstitui. 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óximotypea apagava. Agoratypeescreve 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, usesetContent(ouclearprimeiro).browse_acttambém aceitaaction: "get_value"— uma forma somente leitura de buscar o texto completo de um elemento, o que importa porquebrowse_get_treetrunca valores longos em 200 caracteres (veja abaixo).- Gestos de ponteiro são cidadãos de primeira classe:
drag,pressewheel. UIs de canvas / quadro / ordenável são construídas sobre eles, e nenhuma quantidade deevaluatepode 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, outo_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_ycomeçam de um ponto vazio (panorâmica de canvas vazio), caso em que nenhumtargeté necessário.press:key(Delete,Escape,ArrowRight,Enter, … ou uma combinação como"Meta+a"), oukeyspara uma sequência. Excluir um nó, desfazer, desmarcar — tudo apenas por teclado.wheel:delta_x/delta_ymaishold: ["Control"]para zoom do canvas.browse_scrollnão consegue fazer isso — ele chamawindow.scrollBy, que um canvas infinito ignora completamente.- O resultado do arrasto relata
drag_from/drag_toe qual elemento realmente está em cada ponto (from_at_id), além dewarning+start_hit_target: falsequando 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 derole. Um elemento cujocursorcalculado é um cursor de arrasto, ou que temdraggable="true"/cursor: pointer/ umonclick, agora anunciadrag/clickna árvore mesmo quando seu papel égroup. Sem isso, um canvas cheio de nósrole: grouprelatavaactions: []e o agente não conseguia dizer que eles eram interativos.
browse_networkcaptura 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]); passeinclude_sensitive_headers: truepara vê-los.- A maioria das ferramentas aceita um
tabopcional 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_tabnão roubam mais o foco da janela. A página ainda pensa que está focada —Emulation.setFocusEmulationEnabledmantémdocument.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ê executanpm 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 retornatruemesmo quando a janela está em segundo plano, porqueEmulation.setFocusEmulationEnabledestá 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:
- O agente encontra algo que não consegue fazer.
- Ele diz isso no chat, claramente: "Por favor, escaneie o código QR na janela do navegador e responda 'done'."
- Ele para e espera a resposta — sem loops de tentativa em cliques que não podem funcionar.
- Após "done", ele relê com
browse_get_treepara 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_quitenquanto 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étodo | Propósito | Mapeia para |
|---|---|---|
ui.get_tree | Árvore semântica (+ contexto). Params: focusedOnly, ax, subset, mode, tab | browse_get_tree |
ui.act | params.action + params.target; argumentos por ação aninhados sob params.params | browse_act |
ui.navigate | Carregar uma URL (params.url, tab opcional). Retorna {ok, error?} — {ok:false} sempre carrega um motivo. | browse_navigate |
ui.snapshot / ui.diff | Capturar uma linha de base (params.js, label opcional) e fazer diff contra ela (params.snapshot, rearm, forget) | browse_diff |
ui.capabilities | Sondar a página: histograma da árvore, canvas, iframes, globais do app, endpoints, rota recomendada | browse_capabilities |
ui.repaint | Forçar o compositor a redesenhar uma aba; retorna repaint_count para que um repaint seja comprovável | browse_repaint |
ui.evaluate | Executar JS no contexto da página, mesma proteção que o MCP | browse_evaluate |
ui.scroll | Rolar página ou elemento (direction, amount, target). Somente rolagem de documento — para apps de canvas use ui.act{action:'wheel'}. | browse_scroll |
ui.wait | Poll até uma condição ou timeout | browse_wait |
ui.new_tab / ui.close_tab / ui.list_tabs / ui.set_active_tab | Gerenciamento de abas | browse_*_tab |
ui.subscribe / ui.unsubscribe | Assinatura de eventos; params.events, "*" para todos | — (somente WS — veja abaixo) |
ui.downloads | Downloads desta aba com caminhos em disco (state, received_bytes, dir) | browse_downloads |
ui.get_focused | A subárvore do elemento focado | — |
ui.peek | Passar o mouse sobre um recurso recolhido/somente hover e retornar o diff, sem confirmar | — |
ui.network_body | Corpo da resposta por substring da URL (url_pattern) | — (substituído por browse_network) |
ui.quit | Desligamento gracioso | browse_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:
| Arquivo | O que mostra |
|---|---|
mcp-config.json | Configuração mínima de cliente MCP; substitua o caminho do placeholder. |
AGENT_INSTRUCTIONS.md | Instruções de copiar e colar para seu agente, incluindo o protocolo de transferência humana. |
raw-ws.mjs | Menor cliente WebSocket bruto possível. |
read-page.mjs | Carrega uma página, imprime seus elementos interativos. |
with-human.mjs | Fluxo 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.actaninha seus argumentos por ação sobparams.params—{action:"type", target:"<id>", params:{text:"…"}}. Flattening it to{text:"…"}passa uma string vazia, que costumava atingir o curto-circuito idempotente e relatarsuccesssem 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:
| Chamada | Comportamento |
|---|---|
ui.get_tree / ui.get_focused | erros em 5s (getTree timeout) |
ui.evaluate | erros em 10s (CDP 5s, depois os 5s do fallback de pré-carregamento) |
ui.act | retorna em 15s com success:false, timed_out:true |
ui.capabilities | retorna normalmente (~15s) |
ui.list_tabs / ui.network_list | instantâ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.commaiswww.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 aceitaoff/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 emcontext.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; entradasmodals[].fields[]não carregavamid, 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 decontext:required_hintsé escaneado em todo o documento, entradasfields[]são moldadas de forma idêntica emmodals[]eforms[](idincluído, verificado contra o DOM real), e texto de span nu adjacente a formulários cai emhints— 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 apenasmodel.count/model.fp— zeronodes.*e zerotext*, 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 especificamentemodel.fpsemmodel.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 campomodel(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 relatarserialize, 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 pordid-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
opaqueem 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ó carregavalue_truncated: true,value_full_lengthe uma dicavalue_fetchpronta para chamar — usebrowse_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 carregatabId), então ordenação e índices não são garantidos. Filtre portabId; 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 comelementFromPointe recorre ao ponto médio da borda do elemento, relatandovia: "edge". Se você virvia: "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 fazwindow.scrollBy, então é um no-op em editores baseados em canvas, mapas e gráficos. Useui.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
minNodesdo 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ão | Suporte |
|---|---|
| Agentes | Qualquer cliente MCP stdio; alternativamente WS bruto de qualquer linguagem |
| Node | ≥ 18 |
| SO | macOS 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ágina | React / Vue / Angular / DOM vanilla — localização de elementos é baseada em DOM, não em framework |
| Editores | Editores ricos dirigidos por entrada confiável em vez de caminhos de código por framework |
| Tipos de página | Está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/mainesrc/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.
evaluatepermanece 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, eexamples/AGENT_INSTRUCTIONS.mddevem concordar; uma doc que se desvia demcp_tools.jsé um bug.
Licença
MIT © Glittering