browser-automation-mcp-server-v2.ts•17 kB
#!/usr/bin/env node
/**
* Browser Automation MCP Server v2 - Estado Limpo
* Versão melhorada com gestão de estado entre execuções
* @version 2.1.0
*/
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
ErrorCode,
McpError
} from '@modelcontextprotocol/sdk/types.js';
import { chromium, Browser, Page, BrowserContext } from 'playwright';
import { readFileSync } from 'fs';
import { join } from 'path';
import { cwd } from 'process';
// Usar console.error para todo logging
const log = (...args: any[]) => console.error('[MCP-v2]', new Date().toISOString(), ...args);
class BrowserAutomationMCPServerV2 {
private server: Server;
private browser: Browser | null = null;
private jwtToken: string | null = null;
private composerBaseUrl = 'https://composer.euconquisto.com';
private activePages: Set<Page> = new Set();
constructor() {
this.server = new Server(
{
name: 'euconquisto-composer-browser-automation',
version: '2.1.0',
},
{
capabilities: {
tools: {},
},
}
);
this.setupToolHandlers();
this.loadJWTToken();
// Limpar recursos ao encerrar
process.on('SIGINT', async () => {
log('Encerrando servidor...');
await this.cleanup();
process.exit(0);
});
// Limpar páginas antigas periodicamente
setInterval(() => this.cleanupOldPages(), 60000); // A cada minuto
}
private async cleanup() {
// Fechar todas as páginas abertas
for (const page of this.activePages) {
try {
await page.close();
} catch (e) {
// Ignorar erros ao fechar
}
}
this.activePages.clear();
// Fechar o browser
if (this.browser) {
try {
await this.browser.close();
} catch (e) {
// Ignorar erros ao fechar
}
this.browser = null;
}
}
private async cleanupOldPages() {
const pagesToRemove: Page[] = [];
for (const page of this.activePages) {
try {
// Verificar se a página ainda está aberta
await page.title();
} catch (e) {
// Página já fechada
pagesToRemove.push(page);
}
}
// Remover páginas fechadas do conjunto
pagesToRemove.forEach(page => this.activePages.delete(page));
if (pagesToRemove.length > 0) {
log(`Limpeza: removidas ${pagesToRemove.length} páginas fechadas`);
}
}
private loadJWTToken() {
try {
const jwtPath = join(cwd(), 'correct-jwt-new.txt');
this.jwtToken = readFileSync(jwtPath, 'utf8').trim();
log('Token JWT carregado com sucesso');
} catch (error: any) {
log('Aviso: Não foi possível carregar token JWT:', error.message);
log('Continuando em modo demo...');
}
}
private async ensureBrowser(): Promise<Browser> {
if (!this.browser || !this.browser.isConnected()) {
log('Iniciando navegador...');
this.browser = await chromium.launch({
headless: false,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-blink-features=AutomationControlled'
]
});
log('Navegador iniciado com sucesso');
}
return this.browser;
}
private async createNewContext(): Promise<BrowserContext> {
const browser = await this.ensureBrowser();
// Criar um novo contexto isolado para cada composição
const context = await browser.newContext({
viewport: { width: 1280, height: 720 },
locale: 'pt-BR'
});
return context;
}
private async createCompositionInBrowser(prompt: string, title?: string): Promise<any> {
log('=== Iniciando nova criação de composição ===');
// Criar novo contexto isolado
const context = await this.createNewContext();
const page = await context.newPage();
this.activePages.add(page);
try {
// URL de autenticação
const authUrl = this.jwtToken
? `${this.composerBaseUrl}/#/embed/auth-with-token/pt_br/home/36c92686-c494-ec11-a22a-dc984041c95d/${this.jwtToken}`
: `${this.composerBaseUrl}?demo=true`;
log('Navegando para o Composer...');
log('URL:', authUrl.substring(0, 50) + '...');
// Navegar com timeout maior
await page.goto(authUrl, {
waitUntil: 'networkidle',
timeout: 30000
});
log('Página carregada, aguardando interface...');
await page.waitForTimeout(5000);
// Tirar screenshot para debug
const screenshotPath = join(cwd(), `debug-${Date.now()}.png`);
await page.screenshot({ path: screenshotPath });
log(`Screenshot salva em: ${screenshotPath}`);
// Gerar conteúdo
const composition = this.generateComposition(prompt, title);
// Tentar criar nova composição
const created = await this.tryCreateNewComposition(page);
if (created) {
log('Nova composição criada, tentando adicionar widgets...');
// Tentar adicionar widgets
await this.tryAddWidgets(page, composition);
}
// Resultado final
const result = {
success: true,
message: 'Composição processada com sucesso',
url: page.url(),
composition: composition,
timestamp: new Date().toISOString(),
sessionId: Date.now()
};
log('=== Composição concluída ===');
log('URL final:', page.url());
// Manter página aberta por 45 segundos para visualização
setTimeout(async () => {
try {
this.activePages.delete(page);
await page.close();
await context.close();
log('Contexto e página fechados após timeout');
} catch (e) {
// Ignorar erros
}
}, 45000);
return result;
} catch (error: any) {
log('ERRO na criação da composição:', error.message);
// Limpar em caso de erro
this.activePages.delete(page);
try {
await page.close();
await context.close();
} catch (e) {
// Ignorar erros ao fechar
}
throw new Error(`Falha ao criar composição: ${error.message}`);
}
}
private async tryCreateNewComposition(page: Page): Promise<boolean> {
// Lista expandida de seletores possíveis
const selectors = [
// Seletores por data-testid
'[data-testid="new-composition"]',
'[data-testid="create-composition"]',
'[data-testid="add-composition"]',
// Seletores por texto
'button:has-text("Nova Composição")',
'button:has-text("Nova composição")',
'button:has-text("New Composition")',
'button:has-text("Criar Composição")',
'button:has-text("+")',
// Seletores por aria-label
'[aria-label="Nova composição"]',
'[aria-label="Criar nova composição"]',
'[aria-label="Add new composition"]',
// Seletores por classe
'.new-composition',
'.new-composition-button',
'.create-composition',
'.add-composition',
// Seletores genéricos
'button[title*="Nova"]',
'button[title*="Criar"]',
'a[href*="new"]',
'a[href*="create"]'
];
for (const selector of selectors) {
try {
log(`Tentando selector: ${selector}`);
const element = await page.waitForSelector(selector, {
timeout: 2000,
state: 'visible'
});
if (element) {
await element.click();
log(`✓ Clicado com sucesso usando: ${selector}`);
await page.waitForTimeout(2000);
return true;
}
} catch (e) {
// Continuar com próximo selector
}
}
log('⚠️ Não foi possível encontrar botão de nova composição');
return false;
}
private async tryAddWidgets(page: Page, composition: any): Promise<void> {
// Seletores para adicionar widgets
const addWidgetSelectors = [
'[data-testid="add-widget"]',
'button:has-text("Add Widget")',
'button:has-text("Adicionar Widget")',
'[aria-label="Add widget"]',
'.add-widget-button',
'button[title*="widget"]'
];
for (const widget of composition.widgets) {
let added = false;
for (const selector of addWidgetSelectors) {
try {
const button = await page.waitForSelector(selector, {
timeout: 1000,
state: 'visible'
});
if (button) {
await button.click();
log(`Widget adicionado usando: ${selector}`);
added = true;
await page.waitForTimeout(1000);
break;
}
} catch (e) {
// Tentar próximo selector
}
}
if (!added) {
log(`⚠️ Não foi possível adicionar widget: ${widget.type}`);
}
}
}
private generateComposition(prompt: string, title?: string): any {
const subject = this.detectSubject(prompt);
const gradeLevel = this.detectGradeLevel(prompt);
const topic = this.extractTopic(prompt);
log(`Gerando composição - Matéria: ${subject}, Nível: ${gradeLevel}, Tópico: ${topic}`);
return {
title: title || `Aula: ${topic}`,
subject: subject,
gradeLevel: gradeLevel,
widgets: [
{
type: 'header-1',
content: {
text: `<h1>${title || `Aula sobre ${topic}`}</h1>`
}
},
{
type: 'text-1',
content: {
text: this.generateEducationalContent(prompt, subject, gradeLevel, topic)
}
}
]
};
}
private generateEducationalContent(prompt: string, subject: string, gradeLevel: string, topic: string): string {
const intro = `<h2>Introdução</h2>
<p>Esta aula sobre <strong>${topic}</strong> foi desenvolvida especialmente para estudantes do nível ${this.translateGradeLevel(gradeLevel)}.</p>`;
const objectives = `<h2>Objetivos de Aprendizagem</h2>
<ul>
<li>Compreender os conceitos fundamentais de ${topic}</li>
<li>Aplicar o conhecimento em situações práticas</li>
<li>Desenvolver habilidades de análise e pensamento crítico</li>
</ul>`;
const content = `<h2>Conteúdo Principal</h2>
<p>${this.generateSubjectSpecificContent(subject, topic, gradeLevel)}</p>`;
const activities = `<h2>Atividades Práticas</h2>
<p>Para consolidar o aprendizado, realize as seguintes atividades:</p>
<ol>
<li>Exercício de fixação sobre ${topic}</li>
<li>Discussão em grupo sobre aplicações práticas</li>
<li>Projeto individual de pesquisa</li>
</ol>`;
return `${intro}\n\n${objectives}\n\n${content}\n\n${activities}`;
}
private generateSubjectSpecificContent(subject: string, topic: string, gradeLevel: string): string {
const contentMap: Record<string, string> = {
'matematica': `No estudo de ${topic}, é fundamental compreender os conceitos matemáticos base, as fórmulas aplicáveis e a resolução prática de problemas. A matemática nos permite modelar e resolver situações do mundo real.`,
'fisica': `A física de ${topic} envolve a compreensão das leis naturais, forças e interações. Através de experimentos e cálculos, podemos prever e explicar fenômenos físicos.`,
'quimica': `Em química, ${topic} abrange reações, propriedades moleculares e transformações da matéria. O conhecimento químico é essencial para compreender processos naturais e industriais.`,
'biologia': `O estudo biológico de ${topic} revela a complexidade dos sistemas vivos, desde células até ecossistemas. A biologia nos ajuda a entender a vida em todas as suas formas.`,
'historia': `A análise histórica de ${topic} nos permite compreender eventos passados, suas causas e consequências. O estudo da história é fundamental para entender o presente e planejar o futuro.`,
'portugues': `No contexto da língua portuguesa, ${topic} envolve análise textual, gramática e expressão. O domínio do idioma é essencial para comunicação efetiva.`,
'geral': `O estudo de ${topic} proporciona conhecimentos fundamentais e habilidades práticas aplicáveis em diversas áreas do conhecimento e da vida cotidiana.`
};
return contentMap[subject] || contentMap['geral'];
}
private detectSubject(prompt: string): string {
const lowerPrompt = prompt.toLowerCase();
const subjects = {
'matemática': 'matematica',
'matemática': 'matematica',
'math': 'matematica',
'física': 'fisica',
'fisica': 'fisica',
'physics': 'fisica',
'química': 'quimica',
'quimica': 'quimica',
'chemistry': 'quimica',
'biologia': 'biologia',
'biology': 'biologia',
'história': 'historia',
'historia': 'historia',
'history': 'historia',
'português': 'portugues',
'portugues': 'portugues',
'portuguese': 'portugues'
};
for (const [key, value] of Object.entries(subjects)) {
if (lowerPrompt.includes(key)) {
return value;
}
}
return 'geral';
}
private detectGradeLevel(prompt: string): string {
const lowerPrompt = prompt.toLowerCase();
if (lowerPrompt.match(/\b(fundamental|elementar|básico|criança|6º|7º|8º|9º)\b/)) {
return 'fundamental';
}
if (lowerPrompt.match(/\b(médio|secundário|adolescente|1º ano|2º ano|3º ano)\b/)) {
return 'medio';
}
if (lowerPrompt.match(/\b(superior|universitário|faculdade|graduação)\b/)) {
return 'superior';
}
return 'medio'; // Padrão
}
private extractTopic(prompt: string): string {
// Remover palavras comuns para extrair o tópico principal
const stopWords = ['criar', 'crie', 'uma', 'aula', 'sobre', 'de', 'para', 'com', 'em', 'o', 'a', 'os', 'as'];
const words = prompt.toLowerCase()
.split(/\s+/)
.filter(word => word.length > 3 && !stopWords.includes(word));
// Pegar as 2-3 palavras mais relevantes
return words.slice(0, 3).join(' ') || 'conteúdo educacional';
}
private translateGradeLevel(level: string): string {
const translations: Record<string, string> = {
'fundamental': 'Ensino Fundamental',
'medio': 'Ensino Médio',
'superior': 'Ensino Superior'
};
return translations[level] || level;
}
private setupToolHandlers() {
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'create-intelligent-composition',
description: 'Criar uma composição educacional com automação de navegador no EuConquisto Composer',
inputSchema: {
type: 'object',
properties: {
prompt: {
type: 'string',
description: 'O prompt de conteúdo educacional em linguagem natural'
},
title: {
type: 'string',
description: 'Título opcional para a composição'
}
},
required: ['prompt']
}
}
]
}));
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name === 'create-intelligent-composition') {
try {
const args = request.params.arguments as any;
log('=== Nova solicitação de composição ===');
log('Prompt:', args.prompt);
log('Título:', args.title || '(gerado automaticamente)');
const result = await this.createCompositionInBrowser(
args.prompt,
args.title
);
return {
content: [
{
type: 'text',
text: JSON.stringify(result, null, 2)
}
]
};
} catch (error: any) {
log('ERRO no handler:', error);
throw new McpError(
ErrorCode.InternalError,
`Falha ao criar composição: ${error.message}`
);
}
}
throw new McpError(
ErrorCode.MethodNotFound,
`Ferramenta desconhecida: ${request.params.name}`
);
});
}
async run() {
const transport = new StdioServerTransport();
await this.server.connect(transport);
log('Servidor MCP de Automação de Navegador v2 em execução');
log('Recursos: Gestão de estado aprimorada, contextos isolados, limpeza automática');
}
}
// Iniciar o servidor
const server = new BrowserAutomationMCPServerV2();
server.run().catch((error) => {
console.error('Falha ao iniciar servidor:', error);
process.exit(1);
});