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 = []; // 优先级1:data-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映射到button,a映射到link,input[type=text]映射到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[type=submit]这种方式定位。这样即使按钮的类名变了,只要表单结构没变,选择器就还有效。
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 行已经够用了。
最后分享一个小技巧:如果你经常需要拾取同一类元素,可以在配置里预设一组属性优先级。比如你的项目里所有可测试元素都有>