简介:这份资源面向希望在 n8n 中实现浏览器自动化的开发者与运维人员,围绕通过 MCP 社区节点调用 playwright-mcp 项目展开,解决工作流中网页导航、截图、表单填写、按钮点击等自动化操作难以落地的问题。压缩包共 9 个文件,约 13KB,包含 2 个 json 工作流与配置、2 个 md 说明文档、1 个 yml 编排文件、1 个 html 页面、1 个 js 启动脚本及 gitignore、inscode 等辅助文件,覆盖 MCP 服务器启动、工作流定义与项目说明等模块。已有 577 人学习。读者可从中获得可直接导入的浏览器自动化工作流示例、独立 MCP 服务器启动脚本与依赖配置,并了解 MCP Client Tool node 与 n8n-nodes-mcp-client 的差异,以及浏览器交互不完整时的排错思路,适合具备一定 n8n 基础、希望快速搭建并调试 MCP 自动化链路的读者参考。
1. 当 n8n 遇上 playwright-mcp:把浏览器自动化塞进工作流里
如果你已经在用 n8n 编排 API、处理数据、发通知,但每次遇到需要「打开网页、点按钮、填表单、抓渲染后 DOM」的需求,就得单独写一段 Playwright 脚本再想办法挂回工作流,那 n8n 调用 playwright-mcp 这套组合就是冲这个断层来的。它把浏览器操作封装成 MCP 协议下的工具,n8n 通过 MCP 节点或 HTTP 调用这些工具,等于给工作流装了一双能操作真实浏览器的「手」。适合谁?已经熟悉 n8n 节点编排、又不想为每个网页任务单独维护一套脚本的自动化从业者。新手也能跟,但前提是你得先跑通 n8n 和 playwright-mcp 两个服务,再谈串联。
2. 拆开看:MCP 协议怎么把 n8n 和 Playwright 接起来
2.1 为什么不是直接在 n8n 里写 Playwright 代码
n8n 自带 Code 节点,理论上可以塞 Playwright 的 Node.js 脚本进去。但实际跑起来有几个硬伤:Code 节点运行在 n8n 的沙箱环境里,浏览器二进制、系统依赖、启动参数都不受你控制;每次执行都要冷启动浏览器,慢且容易超时;更麻烦的是,n8n 的 Code 节点不适合做长连接和状态保持,而浏览器操作天然是有状态的——打开页面、等元素、点击、再等跳转,中间任何一步断了,整个上下文就丢了。
MCP(Model Context Protocol)的思路是把「能力」从「编排」里拆出来。playwright-mcp 是一个独立进程,它对外暴露一组标准化的工具接口:导航、点击、输入、截图、提取文本、执行 JS。n8n 不需要知道浏览器怎么启动、依赖怎么装,它只需要按 MCP 协议发请求、收结果。这样编排层和执行层解耦,浏览器挂了不影响 n8n 主流程,n8n 重启也不用重新装浏览器。
常见做法是:playwright-mcp 以 SSE 或 stdio 模式运行在一个常驻容器里,n8n 通过 HTTP Request 节点或社区 MCP 节点去调用。选 SSE 模式的好处是 n8n 和 playwright-mcp 可以分开部署,n8n 在编排容器,playwright-mcp 在带浏览器的执行容器,各自升级互不干扰。
2.2 部署 playwright-mcp:容器与依赖的取舍
playwright-mcp 官方推荐用 Docker 跑,因为浏览器依赖在宿主机上装起来很折腾。下面是一个典型的 docker-compose 片段,把 playwright-mcp 以 SSE 模式暴露出来:
# docker-compose.yml services: playwright-mcp: image: mcr.microsoft.com/playwright:v1.49.0-jammy # 带浏览器依赖的基础镜像 container_name: playwright-mcp command: > npx -y @playwright/mcp@latest --port 8931 --host 0.0.0.0 --browser chromium --no-sandbox ports: - "8931:8931" shm_size: "2gb" # 关键:共享内存太小会导致浏览器崩溃 restart: unless-stopped这段配置里几个参数值得说清楚。--port 8931是 MCP 的 SSE 监听端口,n8n 后面就调这个地址。--browser chromium指定用 Chromium,如果你有 Firefox 或 WebKit 的特定需求可以换,但 Chromium 在容器里最稳。--no-sandbox在容器环境里基本是必须的,否则 Chromium 会因为权限问题起不来。shm_size: 2gb是血泪经验——默认 64MB 的共享内存跑几个页面就崩,日志里报的是「Target closed」或者「Browser closed unexpectedly」,很多人以为是代码问题,其实是 shm 不够。
启动后验证:
# 检查 SSE 端点是否存活 curl -N http://localhost:8931/sse # 正常会持续输出 event: endpoint 之类的心跳,Ctrl+C 退出如果 curl 卡住没输出,先看容器日志docker logs playwright-mcp,大概率是浏览器启动失败,常见原因是镜像版本和 npx 拉的 mcp 版本不匹配,或者宿主机缺少某些系统库。
2.3 n8n 侧调用:HTTP Request 节点的最小可用配置
n8n 原生没有 MCP 节点(社区节点质量参差),最稳的方式是用 HTTP Request 节点直接打 MCP 的 SSE 接口。MCP over SSE 的交互分两步:先 GET/sse建立事件流拿到 session 端点,再 POST 到该端点发工具调用请求。在 n8n 里完整复现这个握手比较绕,实操中更常见的做法是用一个 Code 节点封装 MCP 客户端逻辑,或者用 n8n 的「HTTP Request」节点配合「Wait」节点做简化调用。
下面是一个 Code 节点里的最小 MCP 调用封装,用 Node.js 的 fetch 实现:
// n8n Code 节点:调用 playwright-mcp 的 navigate 工具 const MCP_BASE = 'http://playwright-mcp:8931'; // 第一步:建立 SSE 连接,获取 session 消息端点 async function getSessionEndpoint() { const res = await fetch(`${MCP_BASE}/sse`, { headers: { 'Accept': 'text/event-stream' }, }); const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; // 读取直到拿到 endpoint 事件 while (true) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const match = buffer.match(/event: endpoint\ndata: (.+)\n/); if (match) { reader.cancel(); // 拿到端点就关掉这条流,避免占用 return match[1].trim(); } } throw new Error('未获取到 MCP session endpoint'); } // 第二步:向 session 端点 POST JSON-RPC 请求 async function callTool(endpoint, toolName, args) { const payload = { jsonrpc: '2.0', id: Date.now(), method: 'tools/call', params: { name: toolName, arguments: args }, }; const res = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); const json = await res.json(); if (json.error) throw new Error(JSON.stringify(json.error)); return json.result; } // 主流程:导航到目标页并提取标题 const endpoint = await getSessionEndpoint(); const result = await callTool(endpoint, 'browser_navigate', { url: 'https://example.com', }); const title = await callTool(endpoint, 'browser_evaluate', { function: '() => document.title', }); return [{ json: { title: title.content[0].text } }];逻辑说明:getSessionEndpoint负责 MCP 的 SSE 握手,拿到后续 POST 的目标地址;callTool是通用的 JSON-RPC 调用封装,method固定为tools/call,params.name是 playwright-mcp 暴露的工具名,params.arguments是工具参数。参数怎么改?browser_navigate的url换成你的目标地址;browser_evaluate的function是一个字符串形式的箭头函数,在页面上下文里执行,返回值通过result.content[0].text取。注意每次调用都会新建一个 session,如果你要连续操作同一个页面,得在同一个 session 里串行发多个tools/call,不能每次重新握手。
2.4 工具清单与参数映射
playwright-mcp 暴露的工具不止导航和求值,常用的还有点击、输入、截图、等待。下表是几个高频工具的参数对照,方便你在 n8n 里做映射:
| 工具名 | 关键参数 | 用途 | 返回值形态 |
|---|---|---|---|
| browser_navigate | url | 打开页面 | 页面快照文本 |
| browser_click | element, ref | 点击元素 | 操作后快照 |
| browser_type | element, ref, text | 输入文本 | 操作后快照 |
| browser_evaluate | function | 执行 JS | 执行结果 |
| browser_take_screenshot | name | 截图 | 图片 base64 |
| browser_wait_for | time 或 text | 等待 | 等待结果 |
element是人类可读描述(如「登录按钮」),ref是页面快照里带的可引用标识。实操中更稳的方式是先用browser_navigate拿到快照,从快照里解析出目标元素的 ref,再传给browser_click。这一步在 n8n 里可以用 Code 节点做字符串解析,也可以用正则从快照文本里抠。
3. 把浏览器操作串成工作流:从单步调用到多步编排
3.1 单次调用与有状态会话的区别
上面 2.3 的代码每次调用都重新握手,适合「打开一个页面、取一个值、结束」这种无状态场景。但真实任务往往是多步的:登录、跳转、填表、提交、等结果。如果每步都重新握手,页面上下文就丢了,第二步点击时页面已经回到初始状态。
有状态会话的做法是:在一次 SSE 连接的生命周期内,连续 POST 多个tools/call。playwright-mcp 会在服务端维护浏览器上下文,同一个 session 里的操作共享页面状态。在 n8n 里实现这个,需要把「建立连接」和「多次调用」放在同一个 Code 节点的执行周期里,不能拆成多个节点——因为 n8n 节点之间不共享内存中的 SSE 连接。
// 有状态会话:登录 -> 跳转 -> 提取数据 const endpoint = await getSessionEndpoint(); // 同一个 endpoint 下连续调用,页面状态保持 await callTool(endpoint, 'browser_navigate', { url: 'https://example.com/login' }); await callTool(endpoint, 'browser_type', { element: '用户名输入框', ref: 'input[name="user"]', text: 'demo_user', }); await callTool(endpoint, 'browser_type', { element: '密码输入框', ref: 'input[name="pass"]', text: 'demo_pass', }); await callTool(endpoint, 'browser_click', { element: '登录按钮', ref: 'button[type="submit"]', }); // 等待跳转后的目标元素出现,再提取 await callTool(endpoint, 'browser_wait_for', { text: '欢迎回来' }); const data = await callTool(endpoint, 'browser_evaluate', { function: '() => document.querySelector(".user-info").innerText', }); return [{ json: { userInfo: data.content[0].text } }];关键点:ref参数这里用了 CSS 选择器,playwright-mcp 对 ref 的解析比较宽松,CSS 选择器、文本描述、快照里的引用标识都能吃。但最稳的还是从快照里拿官方 ref,因为页面结构一变,CSS 选择器就可能失效。browser_wait_for的text参数是等页面上出现某段文字,比固定time等待更可靠,固定等待在慢网络下就是翻车现场。
3.2 错误处理与重试:别让一次超时毁掉整条流
浏览器操作天然不稳定:元素还没渲染出来、网络慢、弹窗遮挡。n8n 的节点默认失败就停,但浏览器任务需要「失败重试 + 降级」。常见做法是在 Code 节点里包一层重试逻辑:
async function callWithRetry(endpoint, toolName, args, maxRetry = 3) { for (let i = 0; i < maxRetry; i++) { try { return await callTool(endpoint, toolName, args); } catch (err) { if (i === maxRetry - 1) throw err; // 指数退避,避免密集重试把浏览器打挂 await new Promise(r => setTimeout(r, 1000 * Math.pow(2, i))); } } }参数说明:maxRetry默认 3 次,退避间隔 1s、2s、4s。对于browser_click这种幂等操作可以放心重试;但browser_type如果第一次其实成功了只是响应超时,重试会导致重复输入。所以重试策略要按工具区分:导航、点击、等待可以重试,输入类操作要么不重试,要么在重试前先清空输入框。
另一个坑是超时设置。n8n 的 Code 节点有执行超时,默认可能只有几十秒,而浏览器任务动辄几分钟。需要在节点设置里把超时调大,或者把长任务拆成多个节点用 Wait 节点串联。playwright-mcp 侧也有自己的操作超时,默认 30 秒左右,可以在启动参数里调--timeout。
3.3 截图与结果回传:让工作流有「证据」
很多场景不只是要数据,还要截图作为执行证据。browser_take_screenshot返回 base64 图片,在 n8n 里可以转成二进制再存到对象存储或发到消息渠道:
const shot = await callTool(endpoint, 'browser_take_screenshot', { name: 'result_page', }); // shot.content[0].data 是 base64,转成 Buffer 供 n8n 后续节点使用 const imageBuffer = Buffer.from(shot.content[0].data, 'base64'); return [{ json: { filename: 'result_page.png' }, binary: { data: { data: imageBuffer.toString('base64'), mimeType: 'image/png', fileName: 'result_page.png', }, }, }];这段代码把截图塞进 n8n 的 binary 字段,后续可以接「Write Binary File」节点存盘,或接 HTTP Request 节点上传。注意 base64 转 Buffer 再转回 base64 这一步看起来多余,但 n8n 的 binary 字段要求特定结构,直接传原始 base64 字符串会报格式错误。
4. 避坑与排查:那些让工作流半夜挂掉的细节
4.1 现象:容器日志报「Browser closed unexpectedly」
原因:最常见的是共享内存不足。Chromium 每个标签页都要占 shm,默认 64MB 跑两个页面就爆。其次是--no-sandbox没加,容器内权限不够。
解决:docker-compose 里加shm_size: "2gb",启动参数加--no-sandbox。如果还崩,把--browser chromium换成--browser chromium --headless=new,新版无头模式内存占用更低。
4.2 现象:n8n 调用返回 404 或连接被拒
原因:MCP 的 SSE 端点和 POST 端点不是同一个地址。GET/sse返回的endpoint事件里带的是一个带 session id 的路径,比如/messages?sessionId=xxx,POST 必须打到这个路径,不能直接 POST/sse。
解决:确认getSessionEndpoint解析出来的是完整路径,并且拼接时用MCP_BASE + endpoint。如果 endpoint 是相对路径,别漏了 base。另外检查 n8n 容器和 playwright-mcp 容器是否在同一网络,跨容器通信用服务名而不是 localhost。
4.3 现象:点击没反应,但日志显示调用成功
原因:ref指向的元素被遮挡,或者页面还没渲染完就点了。playwright-mcp 的点击是「尽力而为」,元素存在但不可交互时可能静默失败。
解决:点击前先browser_wait_for等目标文字出现,或者用browser_evaluate检查document.querySelector(sel).offsetParent !== null确认元素可见。对于 iframe 里的元素,ref 要带上 iframe 上下文,playwright-mcp 对跨 iframe 的支持有限,必要时用browser_evaluate直接操作。
4.4 现象:中文输入变成乱码或丢失
原因:browser_type默认逐字符输入,遇到输入法或特殊字符时可能丢字。另外页面如果有 input 事件监听,逐字符触发可能触发多次校验导致状态错乱。
解决:对中文或长文本,改用browser_evaluate直接设值再手动触发事件:
await callTool(endpoint, 'browser_evaluate', { function: `() => { const el = document.querySelector('input[name="content"]'); el.value = '中文内容'; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); }`, });这样绕过逐字符输入,直接设值并通知框架。注意 React/Vue 受控组件需要触发对应事件才能同步状态,只设 value 不触发事件,提交时还是空。
4.5 现象:工作流跑几次后越来越慢,最后超时
原因:每次调用都新建 session,旧 session 没释放,浏览器进程越积越多。playwright-mcp 默认不会自动回收空闲 session。
解决:在 Code 节点结束时显式关闭 session,或者给 playwright-mcp 加--max-sessions限制。更稳的做法是复用 session:把 endpoint 缓存到 n8n 的静态数据或外部存储,下次执行直接复用,但要注意 session 过期时间。
5. 进阶:把 MCP 调用封装成可复用子工作流
跑通单次调用后,下一步是把它变成团队能复用的东西。我一般会建一个 n8n 子工作流,输入是「工具名 + 参数 JSON」,输出是「结果 + 截图」,内部封装握手、重试、session 管理。主工作流只负责传参和消费结果,不关心 MCP 细节。
具体做法:子工作流用 Execute Workflow Trigger 接收输入,Code 节点里做 MCP 调用,最后用 Set 节点规整输出。参数校验放在最前面,工具名白名单、参数必填项检查,避免传错工具名导致整个流挂掉。验证方法很简单:用同一个子工作流连续跑 10 次导航任务,看成功率是否稳定在 9 次以上,低于这个数说明重试或超时策略还有问题。
一个容易忽略的技巧是给 MCP 调用加「干跑」模式:传一个dryRun: true参数,Code 节点只做参数校验和日志输出,不真正调浏览器。这样在开发新流程时可以先验证参数拼装对不对,不用每次都等浏览器启动。等参数确认无误再关掉干跑,能省不少调试时间。
从那以后我每次接新的浏览器自动化需求,都强制先跑一遍干跑模式确认参数,再开真实调用。这个习惯帮我省掉了至少一半的「调了半天发现是参数名拼错」的返工。希望帮到你。
本文还有配套的精品资源,点击获取