1. 项目概述当自动化脚本遇上你的手动操作在浏览器自动化测试和爬虫开发的日常里我们常常面临一个尴尬的割裂一边是精心编写的Playwright脚本在无头模式下高效、稳定地执行任务另一边则是我们自己手动打开浏览器进行探索、调试或处理那些脚本难以覆盖的复杂交互。这两者就像两条平行线数据、登录状态、页面上下文完全不通。你刚用脚本登录的网站手动打开浏览器还得重新输密码脚本执行到一半卡住了你想切过去手动点两下看看却发现根本无从下手。这种“自动化”与“手动”之间的壁垒不仅降低了效率更让调试和复杂流程的构建变得异常痛苦。“突破浏览器会话壁垒”这个项目正是为了解决这个核心痛点。它并非要创造一个全新的自动化框架而是基于现有的明星工具——Playwright并引入一个关键协议MCPModel Context Protocol来构建一座桥梁。这座桥梁能让你的自动化脚本与你实时的手动浏览器操作共享同一个会话、同一份上下文。你可以理解为它让AI驱动的自动化助手通过Playwright脚本和你这个“人类操作员”坐在了同一台电脑前共同操作同一个浏览器窗口。脚本可以接着你手动操作的进度继续执行你也可以随时中断脚本手动处理一些异常然后让脚本无缝接上。这背后的三大创新点直指当前自动化工作流的顽疾会话的实时同步与接管、双向指令与事件通信机制以及基于上下文的智能协同决策。对于需要处理复杂登录验证如动态图形验证码、探索性测试、或是需要人机结合完成业务流程如电商抢购中脚本负责监控和点击人工负责最终确认的场景来说这无疑是一次工作流的革命。接下来我将为你彻底拆解这三大创新是如何实现的并提供一个从零开始搭建、可直接复现的实战指南。2. 核心思路与架构设计为什么是Playwright MCP在深入细节之前我们必须先理解为什么这个组合能成为“破壁”的关键。这涉及到对现有工具局限性的深刻认识和对新协议潜力的挖掘。2.1 Playwright我们选择的“浏览器操作手”在Selenium、Puppeteer、Pyppeteer等众多浏览器自动化工具中我们选择Playwright作为基石是基于其几项无可替代的优势跨浏览器与网络协议的统一性Playwright原生支持Chromium、Firefox和WebKit并提供高度一致的API。更重要的是它不仅仅能模拟点击和跳转还能拦截和修改网络请求page.route这对于处理动态加载内容、模拟特定网络环境或绕过一些前端检测至关重要。在我们的协同场景中无论是脚本还是手动操作触发的网络请求都需要被统一管理和监控。强大的上下文BrowserContext隔离与共享能力Playwright的BrowserContext概念是其精髓之一。每个Context都像是一个独立的浏览器会话拥有独立的cookie、localStorage和缓存。这为我们设计“共享会话”提供了完美的抽象层。我们可以让手动操作的浏览器窗口绑定到一个特定的、可被脚本访问和控制的BrowserContext上。可靠的元素选择器与自动等待机制Playwright的定位器LocatorAPI非常健壮支持文本、CSS、XPath等多种方式且内置了自动等待元素可见、可操作的状态。在协同工作中当脚本需要接替手动操作时它必须能稳定地找到页面上的元素Playwright在这方面的可靠性远超许多同类工具。2.2 MCPModel Context Protocol关键的“协同通信官”MCP即模型上下文协议最初是为了让大型语言模型如Claude能够更安全、更结构化地访问和使用外部工具、数据源而设计的。你可以把它理解为一套标准的“插拔”协议。它的核心价值在于标准化工具定义与发现MCP通过一个清晰的协议通常是基于JSON-RPC或SSE定义了Server提供能力的服务端和Client使用能力的客户端如AI助手之间如何通信。Server可以声明自己提供哪些“工具”Tools每个工具需要什么参数。这完美契合了我们的需求我们需要将“控制浏览器”的能力封装成一套标准的工具暴露给一个协同控制器可以是AI也可以是一个简单的控制台程序。双向、结构化的通信通道MCP协议建立了双向通信。Server不仅可以执行Client的指令还可以主动向Client推送事件Notifications。例如当用户在手动操作的浏览器里点击了一个按钮这个事件可以通过MCP Server推送给ClientClient可能是另一个脚本或AI可以据此做出反应。这为实现真正的“协同”提供了通信基础。与AI智能体的天然结合虽然本项目不强制要求AI参与但MCP的设计使其能轻松与Claude Code、Cursor等集成AI编码助手的工具结合。这意味着你可以用自然语言对AI说“帮我点击登录按钮然后截图保存”AI通过MCP调用我们封装的Playwright工具即可完成。这大大降低了自动化脚本编写的门槛让协同操作更加智能和灵活。2.3 整体架构蓝图基于以上分析我们设计的系统架构分为三层MCP Server层能力提供者这是核心。我们编写一个MCP Server它内部启动并管理一个Playwright浏览器实例通常是Chromium。这个Server将Playwright的核心操作如打开页面、点击、输入、截图、获取元素信息封装成一个个MCP “Tool”。同时它需要有能力将这个浏览器实例以“可调试”模式启动并暴露出WebSocket调试端口--remote-debugging-port或直接允许外部连接。浏览器会话层共享状态载体Playwright启动的浏览器进程。关键在于我们不是启动一个完全无头的浏览器而是启动一个带有图形界面headless: false的浏览器或者通过特定标志允许远程连接。这个浏览器的BrowserContext就是我们的共享会话。所有状态Cookie、LocalStorage都保存在这里。Client/控制层指令发出与接收者这可以是多种形态MCP Client一个标准的MCP客户端程序可以调用Server提供的工具。这可以是AI助手如Claude in Cursor也可以是你自己写的控制脚本。手动操作你直接操作上述第2层中打开的浏览器图形界面。你的所有操作都会实时改变页面状态。混合控制器一个更高级的模块它既监听MCP Server推送的浏览器事件如页面跳转、弹窗出现也接收来自AI或预定脚本的指令并能根据当前上下文智能决定是自动执行还是等待人工干预。这个架构的核心创新在于MCP Server 作为中间件同时服务自动化脚本和人工操作并维护着唯一的真相来源——浏览器会话状态。3. 三大创新实现细节拆解理解了架构我们再来深入看看标题中提到的三大创新具体是如何落地的。3.1 创新一会话的实时同步与接管这是打破壁垒的基础。目标很简单让自动化脚本和手动操作看到并操作完全相同的页面DOM和JavaScript执行环境。实现方案远程调试协议CDP连接Playwright底层基于Chrome DevTools Protocol (CDP)。我们可以利用这一点。MCP Server在启动浏览器时添加关键的启动参数chromium.launch({ headless: false, // 必须为false才能有人机界面供手动操作 args: [--remote-debugging-port9222] // 暴露CDP调试端口 })这样浏览器会启动一个WebSocket服务在ws://localhost:9222。任何能理解CDP协议的工具包括另一个Playwright实例都可以连接并控制这个浏览器。在MCP Server中我们实现一个核心Tool例如connect_to_existing_browser 这个Tool接收一个调试端口号然后使用playwright.chromium.connectOverCDP方法去连接已经存在的浏览器实例并获取到对应的BrowserContext和Page对象。一旦连接成功后续的所有自动化操作都将施加于这个正在被手动操作的浏览器窗口上。实操心得这里最大的坑是端口的冲突和管理。如果手动刷新浏览器或者浏览器崩溃CDP连接可能会断开。一个稳健的实现需要在MCP Server里加入重连机制和心跳检测。同时要确保浏览器启动时用户数据目录--user-data-dir是固定的这样即使浏览器进程重启登录状态也能保留。同步的不仅仅是DOM通过CDP连接我们不仅能操作页面还能同步执行JavaScript环境。这意味着脚本可以在页面上下文中注入并执行JS代码读取全局变量这与手动操作时通过Console面板执行的效果是一致的。3.2 创新二双向指令与事件通信机制仅有连接还不够我们需要一套高效的“对话”机制。手动操作时我们需要通知自动化脚本“我完成了某一步”自动化脚本执行时也可能需要询问“是否遇到验证码需要人工处理”。实现方案MCP Tools Notifications指令下行Client - Server这是MCP的基础功能。我们将Playwright操作封装成Tools。navigate_to(url: string): 跳转到指定URL。click(selector: string): 点击元素。fill(selector: string, text: string): 输入文本。evaluate(js_expression: string): 在页面上下文中执行JS并返回结果。wait_for_event(event: string, selector?: string): 等待特定事件如弹窗dialog、导航framenavigated。 Client如AI调用这些ToolsMCP Server执行对应的Playwright操作。事件上行Server - Client这是实现协同的关键。MCP支持Server主动向Client发送Notification。我们在MCP Server中为Page对象监听一系列关键事件load 页面加载完成。通知Client可以开始执行下一步操作了。dialog 出现JS弹窗alert, confirm, prompt。这是典型的需要人工干预的场景。Server会发送一个包含弹窗类型和文本的NotificationClient可以决定是自动处理调用dialog.accept()还是等待人工处理。framenavigated 页面发生跳转。通知Client当前URL已变更。自定义事件我们甚至可以监听页面上特定元素的出现或变化。例如通过page.waitForSelector结合MutationObserver当检测到“验证码图片”加载完成时主动推送事件给Client触发截图并提示人工识别。一个协同流程示例AI通过MCP调用navigate_to(‘login_page’)。MCP Server执行浏览器跳转到登录页。完成后发送page_loaded通知。AI调用fill(‘#username’, ‘user’)和fill(‘#password’, ‘pass’)。点击登录按钮后页面出现图形验证码。Playwright脚本无法识别。MCP Server监听到验证码图片元素加载完成发送captcha_detected通知并附带截图数据。Client或一个中间控制器收到通知暂停自动化流程在UI界面向用户展示截图。用户手动输入验证码。用户点击“继续”按钮或者通过另一个Tool如input_captcha(code: string)将验证码回填。AI继续执行后续的登录后操作。3.3 创新三基于上下文的智能协同决策这是将简单协同升级为“智能”协同的一步。系统需要有一定的决策能力知道什么时候该自动执行什么时候该等待人工。实现方案状态机 规则引擎 / AI Agent我们可以在MCP Client层或一个专门的“协同决策器”中实现一个轻量级的状态机。系统的状态包括IDLE空闲、AUTO_RUNNING自动执行中、WAITING_FOR_HUMAN等待人工、ERROR出错。决策规则可以预先定义规则1当遇到包含“verify”、“captcha”、“security check”等关键词的页面标题或URL时自动切换到WAITING_FOR_HUMAN状态并通知用户。规则2当自动化脚本连续3次定位同一个元素失败时切换到WAITING_FOR_HUMAN状态并发送当前截图和错误信息。规则3在WAITING_FOR_HUMAN状态下如果接收到用户通过特定Tool如human_resume发送的指令则切换回AUTO_RUNNING状态并从上次中断的步骤继续执行。更高级的实现可以引入一个AI Agent如基于Claude的智能体。这个Agent持续接收来自MCP Server的页面截图、DOM快照、网络请求日志和事件通知。它利用多模态能力“观察”当前浏览器状态并结合预设的目标例如“完成商品下单”自主决定下一步是调用哪个Playwright Tool还是生成一段自然语言描述向用户求助。例如Agent看到页面是一个标准的商品列表它会自动调用click工具选择第一个商品。但如果它看到一个设计独特、从未见过的验证滑块它可能会生成提示“检测到未知交互式验证已暂停。请手动完成滑块验证然后告诉我‘继续’。”4. 从零搭建实战构建你的Playwright MCP协同系统理论说再多不如动手做一遍。下面我将带你一步步搭建一个最小可行系统。4.1 环境准备与依赖安装首先确保你的开发环境已经就绪。我们使用Node.js环境因为Playwright对Node.js的支持最全面且MCP的生态也以Node.js/TypeScript为主。# 1. 初始化项目 mkdir playwright-mcp-bridge cd playwright-mcp-bridge npm init -y # 2. 安装核心依赖 npm install playwright modelcontextprotocol/sdk # Playwright浏览器本体推荐安装chromium npx playwright install chromium # 3. 安装TypeScript及相关类型定义可选但推荐 npm install --save-dev typescript types/node ts-node npx tsc --init4.2 构建MCP Server封装Playwright能力我们将创建一个server.ts文件实现一个提供基本浏览器操作工具的MCP Server。// server.ts import { Server } from modelcontextprotocol/sdk/server/index.js; import { StdioServerTransport } from modelcontextprotocol/sdk/server/stdio.js; import { chromium, Browser, Page } from playwright; // 定义工具参数的类型 interface NavigateParams { url: string; } interface ClickParams { selector: string; } interface FillParams { selector: string; text: string; } interface EvaluateParams { expression: string; } class PlaywrightMCPServer { private server: Server; private browser: Browser | null null; private page: Page | null null; private cdpPort: number 9222; constructor() { this.server new Server( { name: playwright-mcp-server, version: 0.1.0, }, { capabilities: { tools: {}, }, } ); this.setupToolHandlers(); this.setupLifecycle(); } private setupToolHandlers() { // 工具1: 启动/连接浏览器 this.server.setRequestHandler(tools/call, async (request) { if (request.params.name launch_browser) { return await this.handleLaunchBrowser(); } if (request.params.name connect_to_browser) { const args request.params.arguments as { port?: number }; return await this.handleConnectToBrowser(args.port || this.cdpPort); } if (!this.page) { throw new Error(Browser not connected. Call launch_browser or connect_to_browser first.); } switch (request.params.name) { case navigate: return await this.handleNavigate(request.params.arguments as NavigateParams); case click: return await this.handleClick(request.params.arguments as ClickParams); case fill: return await this.handleFill(request.params.arguments as FillParams); case evaluate: return await this.handleEvaluate(request.params.arguments as EvaluateParams); case screenshot: return await this.handleScreenshot(); default: throw new Error(Unknown tool: ${request.params.name}); } }); } private async handleLaunchBrowser() { if (this.browser) { await this.browser.close(); } // 关键以非无头模式启动并暴露调试端口 this.browser await chromium.launch({ headless: false, args: [--remote-debugging-port${this.cdpPort}] }); const context await this.browser.newContext(); this.page await context.newPage(); // 监听页面事件用于推送通知示例监听弹窗 this.page.on(dialog, async dialog { await this.server.sendNotification(tools/call, { name: dialog_opened, arguments: { type: dialog.type(), message: dialog.message(), } }); // 注意这里不自动处理弹窗等待Client指令 }); return { content: [{ type: text, text: Browser launched successfully with CDP port ${this.cdpPort}. You can now manually operate it. }] }; } private async handleConnectToBrowser(port: number) { try { // 连接到已存在的浏览器实例 this.browser await chromium.connectOverCDP(http://localhost:${port}); const contexts this.browser.contexts(); this.page contexts[0]?.pages()[0] || await contexts[0].newPage(); return { content: [{ type: text, text: Successfully connected to existing browser on port ${port}. }] }; } catch (error) { throw new Error(Failed to connect to browser on port ${port}: ${error}); } } private async handleNavigate(params: NavigateParams) { await this.page!.goto(params.url, { waitUntil: networkidle }); // 发送页面加载完成通知 await this.server.sendNotification(tools/call, { name: navigated, arguments: { url: params.url } }); return { content: [{ type: text, text: Navigated to ${params.url} }] }; } private async handleClick(params: ClickParams) { await this.page!.click(params.selector); return { content: [{ type: text, text: Clicked element: ${params.selector} }] }; } private async handleFill(params: FillParams) { await this.page!.fill(params.selector, params.text); return { content: [{ type: text, text: Filled text into ${params.selector} }] }; } private async handleEvaluate(params: EvaluateParams) { const result await this.page!.evaluate(params.expression); return { content: [{ type: text, text: Evaluation result: ${JSON.stringify(result)} }] }; } private async handleScreenshot() { const screenshotBuffer await this.page!.screenshot({ fullPage: true }); const base64Image screenshotBuffer.toString(base64); return { content: [{ type: image, data: base64Image, mimeType: image/png }] }; } private setupLifecycle() { // 服务器关闭时清理浏览器资源 process.on(SIGINT, async () { if (this.browser) { await this.browser.close(); } process.exit(0); }); } async run() { const transport new StdioServerTransport(); await this.server.connect(transport); console.error(Playwright MCP Server running on stdio...); } } const server new PlaywrightMCPServer(); server.run().catch(console.error);这个Server提供了启动/连接浏览器、导航、点击、输入、执行JS和截图等核心工具并实现了弹窗事件的通知。4.3 配置与运行连接AI助手以Cursor为例MCP Server通常通过标准输入输出stdio与Client通信。我们需要创建一个配置文件告诉Cursor或其他支持MCP的客户端如何调用我们的Server。在项目根目录创建cursor-mcp.json{ mcpServers: { playwright: { command: npx, args: [ts-node, server.ts], env: { NODE_ENV: development } } } }然后你需要将Cursor配置为使用这个MCP配置文件。具体路径通常在Cursor的设置中。配置成功后在Cursor的聊天界面你就可以直接使用自然语言指挥浏览器了“请让playwright打开百度首页。”“在搜索框里输入‘Playwright MCP’。”“点击搜索按钮。”“现在页面加载完了帮我截个图看看。”Cursor内部的AI如Claude会将这些指令转化为对MCP Server的工具调用。4.4 实现一个简单的协同决策Client除了依赖AI我们也可以自己写一个Client脚本实现简单的状态机和规则。下面是一个简化的client.ts// client.ts - 一个简单的协同控制器 import { Client } from modelcontextprotocol/sdk/client/index.js; import { StdioClientTransport } from modelcontextprotocol/sdk/client/stdio.js; import * as fs from fs; async function main() { const transport new StdioClientTransport({ command: npx, args: [ts-node, server.ts] }); const client new Client( { name: playwright-coordinator, version: 0.1.0 }, { capabilities: {} } ); await client.connect(transport); // 定义工作流 const workflow [ { tool: launch_browser, args: {} }, { tool: navigate, args: { url: https://example.com/login } }, { tool: fill, args: { selector: #username, text: test_user } }, { tool: fill, args: { selector: #password, text: password123 } }, { tool: click, args: { selector: #submit-btn } }, // 假设这里可能会遇到验证码我们设置一个检查点 { tool: screenshot, args: {}, isCheckpoint: true } ]; for (const step of workflow) { console.log(Executing: ${step.tool}); try { const result await client.request(tools/call, { name: step.tool, arguments: step.args }); console.log(Result:, result); // 如果是检查点则暂停等待人工确认 if (step.isCheckpoint) { console.log(--- Checkpoint reached. Please manually verify the screenshot (saved as checkpoint.png). Type continue to proceed...); // 这里可以保存截图 if (result.contents?.[0]?.type image) { fs.writeFileSync(checkpoint.png, Buffer.from(result.contents[0].data, base64)); } // 等待用户输入 await waitForHumanInput(); } } catch (error) { console.error(Error at step ${step.tool}:, error); console.log(Pausing for manual intervention...); // 进入人工处理模式可以打开浏览器手动操作 await waitForHumanInput(Step failed. Please fix manually in the browser and type retry to retry this step, or skip to continue.); } } await client.close(); } function waitForHumanInput(prompt?: string): Promisevoid { return new Promise((resolve) { const readline require(readline).createInterface({ input: process.stdin, output: process.stdout }); readline.question(prompt || Press Enter after manual action..., () { readline.close(); resolve(); }); }); } main().catch(console.error);这个Client按预定流程执行在关键步骤检查点或出错时暂停等待人工干预实现了最基本的协同。5. 常见问题、排查技巧与进阶优化在实际搭建和运行过程中你肯定会遇到各种问题。下面是我踩过坑后总结的一些经验。5.1 连接与端口问题问题connectOverCDP失败提示无法连接或超时。排查1检查端口是否正确。确保启动浏览器时指定的--remote-debugging-port和连接时使用的端口一致。默认9222可能被其他进程占用可以尝试改为9223、9224等。排查2检查浏览器是否以可调试模式运行。如果浏览器是通过其他方式启动的如手动点击图标需要关闭所有实例然后通过我们的launch_browser工具启动或者手动启动时加上--remote-debugging-port9222 --user-data-dir/tmp/chrome-profile参数。排查3防火墙或安全软件。某些系统设置可能阻止本地回环地址localhost的特定端口连接。暂时禁用防火墙试试。实操心得在launch_browser工具中将实际使用的CDP端口号返回给Client并建议Client在连接时使用这个动态端口而不是写死的端口这样可以避免冲突。5.2 元素定位失败问题脚本点击或输入时经常报错“Element not found”或“Timeout”。排查1页面尚未加载完成。Playwright操作虽然自带等待但networkidle状态有时不准确。在关键操作前可以增加一个page.waitForSelector(selector, { state: visible })的显式等待。排查2选择器不稳定。手动操作时页面结构可能因JS动态加载而改变。优先使用>