n8n 集成 playwright-mcp:浏览器自动化工作流实战 简介这份资源面向希望在 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 节点不适合做长连接和状态保持而浏览器操作天然是有状态的——打开页面、等元素、点击、再等跳转中间任何一步断了整个上下文就丢了。MCPModel 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/mcplatest --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 之类的心跳CtrlC 退出如果 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/callparams.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_navigateurl打开页面页面快照文本browser_clickelement, ref点击元素操作后快照browser_typeelement, ref, text输入文本操作后快照browser_evaluatefunction执行 JS执行结果browser_take_screenshotname截图图片 base64browser_wait_fortime 或 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[nameuser], text: demo_user, }); await callTool(endpoint, browser_type, { element: 密码输入框, ref: input[namepass], text: demo_pass, }); await callTool(endpoint, browser_click, { element: 登录按钮, ref: button[typesubmit], }); // 等待跳转后的目标元素出现再提取 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 --headlessnew新版无头模式内存占用更低。4.2 现象n8n 调用返回 404 或连接被拒原因MCP 的 SSE 端点和 POST 端点不是同一个地址。GET/sse返回的endpoint事件里带的是一个带 session id 的路径比如/messages?sessionIdxxxPOST 必须打到这个路径不能直接 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[namecontent]); 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 节点只做参数校验和日志输出不真正调浏览器。这样在开发新流程时可以先验证参数拼装对不对不用每次都等浏览器启动。等参数确认无误再关掉干跑能省不少调试时间。从那以后我每次接新的浏览器自动化需求都强制先跑一遍干跑模式确认参数再开真实调用。这个习惯帮我省掉了至少一半的「调了半天发现是参数名拼错」的返工。希望帮到你。本文还有配套的精品资源点击获取