219行实现稳定元素拾取器:兼容Playwright MCP与AI工具
1. 这个拾取器到底解决了什么问题先说背景。最近在折腾 Playwright MCP 做浏览器自动化的时候遇到一个很具体的痛点我需要快速拿到页面上某个元素的稳定选择器然后喂给自动化脚本或者 AI 工具去执行后续操作。官方那套方案当时还没提供元素拾取的能力社区里能搜到的要么是 Chrome DevTools 里手动 Copy selector要么是写一段document.querySelector在控制台里试来试去。前者拿到的选择器经常是div div:nth-child(3) span这种一改版就废的路径后者效率太低点一下换个元素就得重来一遍。所以我花了一个晚上写了这个 219 行的元素拾取器。核心目标很明确鼠标悬停高亮、点击即复制、生成的选择器要足够稳定、输出格式直接兼容 Playwright MCP 和常见 AI 编码工具的输入要求。后来发现官方在 5 天后才放出类似功能社区里也有人问“browser use MCP 跟 playwright MCP 有什么区别”其实在元素拾取这个场景下区别就在于你能不能拿到一个语义化、可复用的选择器而不是一堆脆弱的 CSS 路径。这个工具适合几类人正在用 Playwright 做自动化测试或爬虫的开发者、需要给 AI Agent 提供页面元素定位能力的工程师、以及任何想快速获取网页元素选择器但不想装一堆浏览器插件的朋友。219 行不算多但每一行都是踩过坑之后留下来的。2. 整体设计思路与方案选型2.1 为什么不用 Chrome 扩展而选择注入脚本最直接的方案当然是写个 Chrome 扩展装上去就能用。但我实际试过之后放弃了原因有三个。第一扩展需要打包、加载、处理权限申请分享给别人用的时候对方还得开开发者模式门槛太高。第二扩展的 content script 和页面脚本之间存在隔离环境拿到的元素引用有时候和页面实际执行环境不一致调试起来很烦。第三我最终的使用场景是把拾取结果直接喂给 Playwright MCP而 MCP 本身就是在页面上下文里执行脚本的所以直接在页面里注入一段 JS 反而更自然。最终方案就是一段自包含的 JavaScript通过page.evaluate()或者直接在 DevTools Console 里粘贴执行。219 行的体积控制也是有意为之——太大了不好维护太小了功能不够。我试过压缩到 150 行以内但发现选择器生成逻辑和 UI 反馈逻辑必须分开写才清晰强行合并只会让后面改起来痛苦。2.2 选择器生成策略稳定优先于简短这是整个工具最核心的部分。很多人写元素拾取器的时候只想着“能选中就行”但实际用起来你会发现选择器的稳定性比简短重要得多。我见过太多人用nth-child生成的选择器页面一改版全部失效然后就得重新拾取一遍。我的策略是优先级从高到低>const picker (() { let active false; let overlay null; let currentEl null; const config { outputFormat: css, // css | json | playwright highlightColor: rgba(59, 130, 246, 0.3), borderColor: #3b82f6 }; // ... 后续逻辑 return { start, stop, getResult }; })(); picker.start();脚本注入后页面上会出现一个小的提示条告诉你拾取模式已激活。这时候移动鼠标元素会被蓝色半透明层高亮。点击任意元素选择器会被复制到剪贴板同时在 Console 里打印出来。4.2 选择器生成的核心代码拆解选择器生成函数是整个工具的心脏我把它拆成几个步骤。第一步是收集候选属性按优先级排序。第二步是逐个尝试看哪个能唯一定位。第三步是如果都不行就走路径兜底。function generateSelector(el) { const candidates []; // 优先级1data-testid 类属性 for (const attr of [data-testid, data-test, data-cy, data-qa]) { const val el.getAttribute(attr); if (val) candidates.push([${attr}${val}]); } // 优先级2唯一 id if (el.id document.querySelectorAll(#${CSS.escape(el.id)}).length 1) { candidates.push(#${CSS.escape(el.id)}); } // 优先级3语义化 class 组合 const semanticClasses Array.from(el.classList).filter(isSemanticClass); if (semanticClasses.length 0) { const classSelector el.tagName.toLowerCase() . semanticClasses.join(.); if (document.querySelectorAll(classSelector).length 1) { candidates.push(classSelector); } } // 优先级4路径兜底 candidates.push(buildPath(el)); return candidates[0]; }isSemanticClass的判断逻辑是过滤掉哈希类名和纯数字类名。我用的正则大致是/^[a-z][a-z0-9]*(-[a-z0-9])*$/同时排除长度超过 20 的太长的通常是自动生成的和包含连续数字超过 4 位的。4.3 路径兜底算法的实现当所有语义化选择器都失败时就需要构建一条从根节点到目标元素的路径。这个路径要尽量短同时保证唯一性。我的做法是从目标元素往上走每次尝试用当前节点的 tag class 组合来定位如果唯一就停止否则继续往上。function buildPath(el) { const parts []; let current el; while (current current ! document.body) { let part current.tagName.toLowerCase(); if (current.id) { part #${CSS.escape(current.id)}; parts.unshift(part); break; } const parent current.parentElement; if (parent) { const siblings Array.from(parent.children).filter( c c.tagName current.tagName ); if (siblings.length 1) { const index siblings.indexOf(current) 1; part :nth-of-type(${index}); } } parts.unshift(part); current current.parentElement; } return parts.join( ); }这个算法有个优化点如果中途遇到有 id 的祖先节点就直接用 id 作为锚点停止往上走。因为 id 通常是唯一的从 id 往下构建路径比从 body 往下要短得多可读性也好很多。4.4 输出格式切换与结果处理拾取完成后结果会以你配置的格式输出。CSS 格式就是纯字符串JSON 格式包含更多元信息Playwright 格式会尝试生成getByRole或getByText。function formatResult(el, selector) { if (config.outputFormat json) { return JSON.stringify({ selector, tag: el.tagName.toLowerCase(), text: el.textContent.trim().slice(0, 100), id: el.id || null, classes: Array.from(el.classList), rect: el.getBoundingClientRect().toJSON() }, null, 2); } if (config.outputFormat playwright) { const role el.getAttribute(role) || inferRole(el); const text el.textContent.trim().slice(0, 50); if (role text) { return getByRole(${role}, { name: ${text} }); } return locator(${selector}); } return selector; }inferRole是一个简单的推断函数根据标签名映射到 ARIA role。比如button映射到buttona映射到linkinput[typetext]映射到textbox。这个推断不追求 100% 准确够用就行。5. 常见问题与排查技巧实录5.1 拾取器不生效的几种情况最常见的问题是脚本注入后没有任何反应。排查顺序是这样的先看 Console 有没有报错如果有SyntaxError说明代码被截断了重新完整复制一遍。如果没有报错但也没反应检查页面是否有 CSP 限制某些严格 CSP 的页面会阻止内联脚本执行这时候需要通过 Playwright 的page.addScriptTag()来注入。还有一种情况是页面用了 Shadow DOM普通的事件监听拿不到 Shadow 内部的元素。这个场景我的工具没有覆盖因为实现成本太高而且大多数需要拾取元素的场景都是普通 DOM。如果你确实需要处理 Shadow DOM可以在事件监听里加composedPath()来判断。注意某些页面会监听mousemove并做自己的高亮或 tooltip可能会和我的覆盖层冲突。如果发现高亮层闪烁或者位置不对可以先把页面上其他高亮插件关掉再试。5.2 选择器不稳定的典型场景有时候拾取到的选择器当时能用过几天页面改版就失效了。这通常是因为选择器依赖了nth-child或者哈希类名。我的工具已经尽量避免了但有一种情况无法完全规避页面用 CSS-in-JS 且所有类名都是哈希同时元素没有 id 和 data 属性。这种情况下只能走路径兜底稳定性天然就差。应对策略是尽量拾取那些有语义锚点的元素。比如你要操作一个提交按钮不要直接拾取按钮本身而是拾取它所在的表单容器然后用form button[typesubmit]这种方式定位。这样即使按钮的类名变了只要表单结构没变选择器就还有效。5.3 与 AI 工具对接时的格式问题把拾取结果喂给 AI 工具时最常见的坑是 JSON 格式不对。有些工具要求严格的 JSON不能有尾逗号不能有单引号。我的JSON.stringify输出是标准格式但如果你手动修改过结果记得用json校验工具过一遍。热词里有人问json parse error: cannot deserialize value of type java.util.Date from String这类问题通常是日期格式不匹配和元素拾取本身无关但提醒我们在构造 JSON 时要注意字段类型的一致性。另一个坑是选择器里的特殊字符转义。如果元素的 id 或 class 里包含冒号、方括号、点号直接拼进选择器会解析失败。必须用CSS.escape()处理。我见过有人拾取一个 id 为user:name的元素生成的选择器是#user:name结果querySelector直接报错。加上CSS.escape之后就变成#user\:name正常工作。5.4 常见问题速查表问题现象可能原因解决方法脚本注入后无反应CSP 限制或代码截断用addScriptTag注入或重新复制完整代码高亮层位置偏移页面有 transform 或 zoom检查getBoundingClientRect是否受 CSS transform 影响点击后页面跳转事件拦截不完整确保mousedown、mouseup、click都拦截选择器不唯一页面有重复 id 或 class检查生成逻辑必要时手动加nth-of-type复制到剪贴板失败浏览器权限限制改用 Console 输出手动复制Shadow DOM 内元素选不中事件未穿透 Shadow 边界使用composedPath()或改用 Playwright 的 pierce 选择器6. 一些实操心得与扩展思路这个工具我前后改了七八版最大的体会是元素拾取器的价值不在于“能选中”而在于“选得稳”。一个只能选中一次的选择器毫无意义你需要的是那种页面小改版之后依然能用的选择器。所以我在选择器生成上花的精力远大于 UI 和高亮。另一个心得是关于输出格式的。一开始我只输出 CSS 选择器字符串后来发现很多人需要把拾取结果存下来做批量处理纯字符串不够用才加了 JSON 格式。再后来发现 AI 工具更喜欢语义化的定位方式又加了 Playwright 格式。如果你也在做类似工具建议一开始就把输出格式设计成可扩展的不然后面加格式要改很多地方。扩展方向有几个。一是支持批量拾取按住 Shift 点击多个元素一次性生成一组选择器。二是支持选择器验证拾取完成后自动在页面上跑一遍确认能唯一定位再输出。三是和 Playwright MCP 深度集成拾取完直接生成可执行的 MCP 命令。这几个我都在陆续做但核心的 219 行已经够用了。最后分享一个小技巧如果你经常需要拾取同一类元素可以在配置里预设一组属性优先级。比如你的项目里所有可测试元素都有>