UNPKG

@ai-coding-labs/playwright-mcp-plus

Version:

Enhanced Playwright Tools for MCP with Project Session Isolation

239 lines (238 loc) 10.3 kB
/** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import fs from 'fs'; import path from 'path'; import os from 'os'; import crypto from 'crypto'; /** * 用户会话目录管理器 * 支持多种存放策略,减少与原有代码的冲突 */ export class SessionDirectoryManager { static SESSION_DIR_NAME = '.user-session-data-directory'; static GITIGNORE_COMMENT = '# Playwright MCP session data (auto-generated)'; static MAX_PATH_LENGTH_WINDOWS = 260; /** * 根据配置选项创建用户数据目录路径 */ static createUserDataDir(options) { try { switch (options.strategy) { case 'system': return this.createSystemUserDataDir(options); case 'project': return this.createProjectUserDataDir(options); case 'custom': return this.createCustomUserDataDir(options); default: throw new Error(`Unknown session directory strategy: ${options.strategy}`); } } catch (error) { return undefined; } } /** * 系统默认位置 + 项目标识符后缀 */ static createSystemUserDataDir(options) { const systemDataDir = this.getSystemDataDirectory(); const projectId = this.generateProjectIdentifier(options.projectDrive, options.projectPath); // 构建与Playwright官方一致的浏览器配置文件名 const browserName = options.browserName || 'chromium'; const channel = options.browserChannel; const browserProfile = `mcp-${channel || browserName}-profile`; // 使用与Playwright官方一致的路径结构: // Windows: %LOCALAPPDATA%/ms-playwright/mcp-浏览器名-profile/playwright-plus-mcp/项目名-哈希值/ // macOS: ~/Library/Caches/ms-playwright/mcp-浏览器名-profile/playwright-plus-mcp/项目名-哈希值/ // Linux: ~/.cache/ms-playwright/mcp-浏览器名-profile/playwright-plus-mcp/项目名-哈希值/ const sessionDir = path.join(systemDataDir, 'ms-playwright', browserProfile, 'playwright-plus-mcp', projectId); this.ensureDirectoryExists(sessionDir); return sessionDir; } /** * 项目目录下存放 */ static createProjectUserDataDir(options) { if (!options.projectPath) throw new Error('Project path is required for project strategy'); const sessionDir = path.join(options.projectPath, this.SESSION_DIR_NAME); this.ensureDirectoryExists(sessionDir); this.ensureGitignore(options.projectPath); return sessionDir; } /** * 自定义根目录下存放 */ static createCustomUserDataDir(options) { if (!options.customRootDir) throw new Error('Custom root directory is required for custom strategy'); const projectId = this.generateProjectIdentifier(options.projectDrive, options.projectPath); // 构建与Playwright官方一致的浏览器配置文件名 const browserName = options.browserName || 'chromium'; const channel = options.browserChannel; const browserProfile = `mcp-${channel || browserName}-profile`; // 在自定义根目录下也使用相同的路径结构 const sessionDir = path.join(options.customRootDir, 'ms-playwright', browserProfile, 'playwright-plus-mcp', projectId); this.ensureDirectoryExists(sessionDir); return sessionDir; } /** * 获取系统数据目录 * 使用与Playwright官方一致的缓存目录 */ static getSystemDataDirectory() { const platform = os.platform(); switch (platform) { case 'win32': // Windows: 使用LOCALAPPDATA,与Playwright官方一致 return process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'); case 'darwin': // macOS: 使用Library/Caches,与Playwright官方一致 return path.join(os.homedir(), 'Library', 'Caches'); case 'linux': // Linux: 使用XDG_CACHE_HOME或~/.cache,与Playwright官方一致 return process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache'); default: // 其他Unix-like系统,使用缓存目录 return path.join(os.homedir(), '.cache'); } } /** * 生成项目唯一标识符 * 基于驱动器和项目路径的MD5哈希 */ static generateProjectIdentifier(projectDrive, projectPath) { if (!projectDrive || !projectPath) return 'default'; // 规范化路径分隔符 const normalizedPath = projectPath.replace(/\\/g, '/'); const identifierSource = `${projectDrive}${normalizedPath}`; // 生成MD5哈希 const hash = crypto.createHash('md5').update(identifierSource).digest('hex'); // 取前12位哈希值,加上路径的最后一部分作为可读标识 const baseName = path.basename(normalizedPath) || 'root'; const sanitizedBaseName = this.sanitizeForFilePath(baseName); return `${sanitizedBaseName}-${hash.substring(0, 12)}`; } /** * 清理文件路径中的非法字符 */ static sanitizeForFilePath(name) { // Windows保留名称列表 const windowsReservedNames = [ 'CON', 'PRN', 'AUX', 'NUL', 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9', 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9' ]; let sanitized = name .replace(/[<>:"/\\|?*]/g, '-') // 替换Windows非法字符 .replace(/[\x00-\x1f\x80-\x9f]/g, '-') // 替换控制字符 .replace(/\s+/g, '-') // 替换空格 .replace(/\.+$/g, '') // 移除末尾的点(Windows不允许) .replace(/-+/g, '-') // 合并多个连字符 .replace(/^-|-$/g, '') // 移除首尾连字符 .toLowerCase(); // 处理空字符串 if (!sanitized) sanitized = 'default'; // 检查是否是Windows保留名称 if (windowsReservedNames.includes(sanitized.toUpperCase())) sanitized = `${sanitized}-dir`; // 限制长度(考虑到后面还要加哈希值) const maxLength = 50; if (sanitized.length > maxLength) sanitized = sanitized.substring(0, maxLength).replace(/-+$/, ''); return sanitized; } /** * 确保目录存在 */ static ensureDirectoryExists(dirPath) { if (!fs.existsSync(dirPath)) fs.mkdirSync(dirPath, { recursive: true }); } /** * 确保项目根目录有.gitignore条目 */ static ensureGitignore(projectPath) { const gitignorePath = path.join(projectPath, '.gitignore'); const ignoreEntry = this.SESSION_DIR_NAME; try { let gitignoreContent = ''; if (fs.existsSync(gitignorePath)) gitignoreContent = fs.readFileSync(gitignorePath, 'utf-8'); // 检查是否已经包含ignore条目 const lines = gitignoreContent.split('\n'); const hasIgnoreEntry = lines.some(line => line.trim() === ignoreEntry); const hasComment = lines.some(line => line.trim() === this.GITIGNORE_COMMENT); if (!hasIgnoreEntry) { // 添加注释和ignore条目 const newLines = []; if (!hasComment) newLines.push('', this.GITIGNORE_COMMENT); newLines.push(ignoreEntry); const updatedContent = gitignoreContent + newLines.join('\n') + '\n'; fs.writeFileSync(gitignorePath, updatedContent, 'utf-8'); } } catch (error) { // 忽略gitignore更新错误,不影响主要功能 } } /** * 检查路径长度是否在Windows限制内 */ static isPathTooLong(filePath) { return os.platform() === 'win32' && filePath.length > this.MAX_PATH_LENGTH_WINDOWS; } /** * 验证会话目录配置 */ static validateOptions(options) { if (!options.strategy) return { valid: false, error: 'Strategy is required' }; if (options.strategy === 'custom' && !options.customRootDir) return { valid: false, error: 'Custom root directory is required for custom strategy' }; if ((options.strategy === 'system' || options.strategy === 'project') && (!options.projectPath || !options.projectDrive)) return { valid: false, error: 'Project path and drive are required for system/project strategy' }; return { valid: true }; } /** * 清理旧的会话数据(可选功能) */ static cleanupOldSessions(sessionDir, maxAgeInDays = 30) { try { if (!fs.existsSync(sessionDir)) return; const cutoffTime = Date.now() - (maxAgeInDays * 24 * 60 * 60 * 1000); const entries = fs.readdirSync(sessionDir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(sessionDir, entry.name); const stats = fs.statSync(fullPath); if (stats.mtime.getTime() < cutoffTime) { if (entry.isDirectory()) fs.rmSync(fullPath, { recursive: true, force: true }); else fs.unlinkSync(fullPath); } } } catch (error) { } } }