ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

219行JS元素拾取器:快速生成Playwright定位信息

219行JS元素拾取器:快速生成Playwright定位信息

1. 一个 219 行脚本的由来

先说清楚这东西是什么。它是一个浏览器端的元素拾取器,总共 219 行 JavaScript,跑在 Chrome DevTools 的 Console 里或者通过 Playwright 的page.evaluate()注入,作用就一件事:你在页面上移动鼠标,它实时高亮当前悬停的元素,点击一下就把这个元素的完整定位信息吐出来——选择器、XPath、标签结构、关键属性、文本内容,一整套。

为什么我会自己写这个?因为我在做 Playwright 自动化的时候,最烦的环节永远是"定位元素"。Playwright 自带的codegen能录制,但录制出来的选择器经常是div > div:nth-child(3) > span这种一改版就废的东西。Chrome DevTools 的 Elements 面板能右键 Copy selector,但一次只能复制一个,而且它给的是 CSS 路径,不带 XPath,也不带属性快照。我需要的是一个能批量、快速、带上下文地把元素信息捞出来的工具。

后来听说 Kimi Code 要出类似的功能,我等了几天没等到,索性自己动手。219 行,一个下午,写完就能用。这篇文章就把这 219 行拆开讲透,包括我为什么这么设计、每一步在干什么、踩了哪些坑、以及你可以怎么改造成适合自己项目的样子。

适合谁看?做 Web 自动化的、写爬虫的、搞前端调试的、以及任何需要频繁跟 DOM 打交道的人。不需要你是 JS 高手,能看懂基本的 DOM API 就行。我会把每个关键决策的理由讲清楚,让你不只是抄代码,而是理解为什么这么写。

核心关键词先摆出来:元素拾取器、Kimi Code、JS、JSON、Playwright。这五个词贯穿全文,后面每一节都会围绕它们展开。

2. 整体设计思路:为什么是 219 行而不是 2000 行

2.1 需求拆解:一个拾取器到底要解决什么

在动手之前,我把需求列了一遍。一个合格的元素拾取器,至少要满足这几条:

  • 实时高亮:鼠标移到哪,哪个元素亮起来,边框、背景色要明显但不遮挡内容。
  • 点击锁定:点一下就把信息固定下来,不能点完就没了。
  • 信息完整:选择器、XPath、标签名、id、class、name、type、文本、位置尺寸,一个都不能少。
  • 格式友好:输出要是 JSON,方便直接粘到 Playwright 脚本里用。
  • 可退出:按 ESC 能关掉,不能关不掉的工具都是耍流氓。

这五条是底线。再往上加,比如"自动生成 Playwright 代码"、"支持 iframe 穿透"、"支持 Shadow DOM",那就是加分项,但不是必须。我先把底线做扎实,加分项留了扩展接口。

2.2 技术选型:为什么不用现成的库

市面上不是没有类似工具。Chrome 有 SelectorGadget,有各种浏览器插件。但我不用它们,原因有三个:

第一,插件要装。我在客户现场、在 CI 环境、在别人的机器上调试,不可能每次都装插件。一个能直接粘进 Console 的脚本,零依赖,这才是刚需。

第二,插件不透明。SelectorGadget 给你的选择器是它算出来的,你不知道它为什么选这个。我自己写的,每一行逻辑我都清楚,出问题能改。

第三,要跟 Playwright 打通。插件输出的格式五花八门,我还要手动转换。我自己写的,直接输出 Playwright 能用的page.locator()参数,省一道工序。

所以技术栈就定了:纯原生 JS,零依赖,单文件,可直接注入。不用 jQuery,不用任何框架,因为注入环境不一定有这些。219 行里,核心逻辑大概 150 行,剩下的是注释和边界处理。

2.3 架构分层:三层结构

整个脚本我分成三层:

  • 事件层:监听mousemove、click、keydown,负责捕获用户操作。
  • 计算层:拿到元素后,计算选择器、XPath、属性快照。
  • 输出层:把结果格式化成 JSON,打印到 Console,同时复制到剪贴板。

三层之间用简单的函数调用连接,没有复杂的发布订阅。为什么?因为 219 行的体量,上架构就是过度设计。我见过太多人写个小工具先搭个 EventEmitter,最后代码比功能还长。工具类脚本的第一原则是能跑、好改,不是好扩展。

提示:如果你打算把这个拾取器集成到自己的平台里,那可以把它拆成模块。但如果你只是自己用,单文件就是最优解。

3. 核心细节解析:219 行里的关键实现

3.1 高亮层:怎么做到不遮挡、不卡顿

高亮看起来简单,其实有坑。最朴素的做法是给元素加outline,但outline会被某些元素的overflow: hidden裁掉。用box-shadow呢?又可能被z-index压住。

我的方案是创建一个独立的覆盖层:一个position: fixed的div,pointer-events: none,z-index拉到 2147483647(32 位有符号整数的最大值)。每次mousemove时,用getBoundingClientRect()拿到目标元素的位置,然后更新这个覆盖层的位置和尺寸。

const overlay = document.createElement('div'); overlay.style.cssText = ` position: fixed; pointer-events: none; z-index: 2147483647; border: 2px solid #ff4757; background: rgba(255, 71, 87, 0.1); transition: all 0.05s ease-out; box-sizing: border-box; `; document.body.appendChild(overlay);

这里有几个细节值得说:

pointer-events: none是必须的。否则覆盖层会挡住鼠标事件,导致mousemove永远停在覆盖层上,拾取器直接失效。这是新手最容易踩的坑。

transition: 0.05s是为了视觉平滑。不加的话,鼠标快速移动时高亮框会一跳一跳的。但也不能太长,0.05s 是我实测下来最舒服的值,再长就跟不上鼠标了。

box-sizing: border-box保证边框不会撑大覆盖层,否则高亮框会比实际元素大 4px,看着别扭。

为什么用fixed而不是absolute?因为getBoundingClientRect()返回的是相对于视口的坐标,用fixed可以直接对应,不用管页面滚动。如果用absolute,还得加上scrollX和scrollY,多一步计算,还容易出错。

3.2 选择器生成:从"能用"到"好用"

选择器生成是整个脚本的核心。我见过很多实现,直接拼tagName + nth-child,出来的东西又长又脆。我的策略是优先级递进:

  1. 有id且唯一 → 直接用#id
  2. 有>function getSelector(el) { if (el.id && document.querySelectorAll('#' + CSS.escape(el.id)).length === 1) { return '#' + CSS.escape(el.id); } for (const attr of ['data-testid', 'data-test', 'data-cy']) { const val = el.getAttribute(attr); if (val) return `[${attr}="${val}"]`; } if (el.name) { const sel = `${el.tagName.toLowerCase()}[name="${el.name}"]`; if (document.querySelectorAll(sel).length === 1) return sel; } // 回退到路径 return buildPath(el); }

    CSS.escape()不能省。有些 id 里带特殊字符,比如#user:123,不转义的话querySelector直接报错。这个坑我在一个后台管理系统上踩过,id 是form:input:name,冒号在 CSS 选择器里是伪类分隔符,不转义必炸。

    为什么优先>function getXPath(el) { if (el.id) return `//*[@id="${el.id}"]`; const parts = []; while (el && el.nodeType === 1) { let idx = 1; let sib = el.previousSibling; while (sib) { if (sib.nodeType === 1 && sib.tagName === el.tagName) idx++; sib = sib.previousSibling; } parts.unshift(`${el.tagName.toLowerCase()}[${idx}]`); el = el.parentNode; } return '/' + parts.join('/'); }

    注意previousSibling和previousElementSibling的区别。我用previousSibling然后判断nodeType === 1,是因为要跳过文本节点和注释节点。直接用previousElementSibling也行,但老版本浏览器兼容性略差,虽然现在无所谓了。

    索引从 1 开始,这是 XPath 的规范,不是 0。写惯了 JS 数组的人容易搞错。

    3.4 属性快照:哪些属性值得抓

    不是所有属性都有用。我筛选了一批"高价值属性":

    属性名用途是否必抓
    id唯一定位是
    class样式定位是
    name表单元素定位是
    type输入框类型判断是
    value当前值快照是
    href链接目标是
    src资源地址是
    placeholder输入提示否
    disabled状态判断否
    >const text = (el.textContent || '').trim().slice(0, 100);

    3.5 输出格式:为什么是 JSON

    输出用 JSON,理由很直接:Playwright 脚本里可以直接用。

    { "selector": "#submit-btn", "xpath": "//*[@id=\"submit-btn\"]", "tag": "button", "id": "submit-btn", "class": "btn btn-primary", "text": "提交", "attributes": { "type": "submit", "data-testid": "submit" }, "rect": { "x": 100, "y": 200, "width": 80, "height": 32 } }

    拿到这个 JSON,你在 Playwright 里可以这样写:

    await page.locator('#submit-btn').click(); // 或者 await page.locator('//*[@id="submit-btn"]').click();

    rect字段是干嘛的?调试用。有时候元素定位对了但点不到,一看rect发现width: 0,就知道是被隐藏了。这个信息在排查问题时特别有用。

    提示:JSON 输出我用JSON.stringify(obj, null, 2),带缩进,方便阅读。如果你要粘到代码里,可以改成不带缩进的版本,省空间。

    4. 实操过程:从零到能跑

    4.1 环境准备:你只需要一个浏览器

    这个脚本不需要 Node.js,不需要 npm,不需要任何构建工具。你只需要:

    • 一个 Chrome 或 Edge 浏览器(Firefox 也行,但 DevTools 的 Console 体验略差)
    • 一个想调试的网页

    打开网页,按 F12 打开 DevTools,切到 Console 标签页,把脚本粘进去,回车。完事。

    如果你要用 Playwright 注入,那需要 Node.js 环境,但那是 Playwright 的要求,不是这个脚本的要求。

    4.2 完整脚本:219 行的全貌

    下面是我实际用的版本,带注释。你可以直接复制使用。

    (function () { 'use strict'; // ========== 配置 ========== const CONFIG = { highlightColor: '#ff4757', highlightBg: 'rgba(255, 71, 87, 0.1)', maxTextLength: 100, zIndex: 2147483647 }; // ========== 状态 ========== let overlay = null; let currentEl = null; let locked = false; // ========== 工具函数 ========== function getSelector(el) { if (el.id && document.querySelectorAll('#' + CSS.escape(el.id)).length === 1) { return '#' + CSS.escape(el.id); } for (const attr of ['data-testid', 'data-test', 'data-cy']) { const val = el.getAttribute(attr); if (val) return `[${attr}="${val}"]`; } if (el.name) { const sel = `${el.tagName.toLowerCase()}[name="${el.name}"]`; if (document.querySelectorAll(sel).length === 1) return sel; } return buildPath(el); } function buildPath(el) { const parts = []; while (el && el.nodeType === 1 && el.tagName !== 'HTML') { let part = el.tagName.toLowerCase(); if (el.id) { part = '#' + CSS.escape(el.id); parts.unshift(part); break; } const parent = el.parentNode; if (parent) { const siblings = Array.from(parent.children).filter( c => c.tagName === el.tagName ); if (siblings.length > 1) { part += `:nth-of-type(${siblings.indexOf(el) + 1})`; } } parts.unshift(part); el = el.parentNode; } return parts.join(' > '); } function getXPath(el) { if (el.id) return `//*[@id="${el.id}"]`; const parts = []; while (el && el.nodeType === 1) { let idx = 1; let sib = el.previousSibling; while (sib) { if (sib.nodeType === 1 && sib.tagName === el.tagName) idx++; sib = sib.previousSibling; } parts.unshift(`${el.tagName.toLowerCase()}[${idx}]`); el = el.parentNode; } return '/' + parts.join('/'); } function getAttributes(el) { const attrs = {}; for (const attr of el.attributes) { if (attr.name === 'class' || attr.name === 'id') continue; attrs[attr.name] = attr.value; } return attrs; } function buildInfo(el) { const rect = el.getBoundingClientRect(); return { selector: getSelector(el), xpath: getXPath(el), tag: el.tagName.toLowerCase(), id: el.id || null, class: el.className || null, name: el.name || null, type: el.type || null, text: (el.textContent || '').trim().slice(0, CONFIG.maxTextLength), value: el.value || null, href: el.href || null, src: el.src || null, attributes: getAttributes(el), rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) } }; } // ========== 高亮 ========== function createOverlay() { overlay = document.createElement('div'); overlay.style.cssText = ` position: fixed; pointer-events: none; z-index: ${CONFIG.zIndex}; border: 2px solid ${CONFIG.highlightColor}; background: ${CONFIG.highlightBg}; transition: all 0.05s ease-out; box-sizing: border-box; `; document.body.appendChild(overlay); } function updateOverlay(el) { if (!overlay || !el) return; const rect = el.getBoundingClientRect(); overlay.style.left = rect.left + 'px'; overlay.style.top = rect.top + 'px'; overlay.style.width = rect.width + 'px'; overlay.style.height = rect.height + 'px'; } // ========== 事件处理 ========== function onMouseMove(e) { if (locked) return; const el = e.target; if (el === overlay) return; currentEl = el; updateOverlay(el); } function onClick(e) { if (locked) return; e.preventDefault(); e.stopPropagation(); locked = true; const info = buildInfo(currentEl); console.log('%c[元素拾取器] 已锁定', 'color: #ff4757; font-weight: bold;'); console.log(JSON.stringify(info, null, 2)); // 复制到剪贴板 if (navigator.clipboard) { navigator.clipboard.writeText(JSON.stringify(info, null, 2)) .then(() => console.log('%c已复制到剪贴板', 'color: #2ed573;')) .catch(() => console.log('复制失败,请手动复制')); } } function onKeyDown(e) { if (e.key === 'Escape') { destroy(); console.log('%c[元素拾取器] 已退出', 'color: #ff4757;'); } if (e.key === 'Enter' && locked) { locked = false; console.log('%c[元素拾取器] 已解锁,可继续拾取', 'color: #2ed573;'); } } // ========== 生命周期 ========== function destroy() { document.removeEventListener('mousemove', onMouseMove, true); document.removeEventListener('click', onClick, true); document.removeEventListener('keydown', onKeyDown, true); if (overlay && overlay.parentNode) { overlay.parentNode.removeChild(overlay); } overlay = null; currentEl = null; locked = false; } function init() { if (overlay) { console.log('拾取器已在运行'); return; } createOverlay(); document.addEventListener('mousemove', onMouseMove, true); document.addEventListener('click', onClick, true); document.addEventListener('keydown', onKeyDown, true); console.log('%c[元素拾取器] 已启动', 'color: #2ed573; font-weight: bold;'); console.log('移动鼠标高亮元素,点击锁定,ESC 退出,Enter 解锁'); } init(); })();

    4.3 关键步骤拆解:为什么用捕获阶段

    注意addEventListener的第三个参数是true,也就是捕获阶段监听。这是关键决策。

    如果用冒泡阶段(默认),那么当页面上的元素有自己的click处理器时,它可能先执行stopPropagation(),导致我的onClick根本收不到事件。用捕获阶段,事件从document往下传,我先拿到,先处理,先preventDefault(),页面自己的逻辑就被拦住了。

    这是拾取器能"点哪抓哪"而不是"点哪触发哪"的核心。我早期版本用冒泡,结果在一个按钮上点击,直接触发了表单提交,页面刷新,拾取器没了。改成捕获阶段后,这个问题彻底解决。

    4.4 在 Playwright 里注入

    如果你要在 Playwright 里用,有两种方式。

    方式一:直接 evaluate。把脚本读成字符串,然后page.evaluate():

    const fs = require('fs'); const pickerScript = fs.readFileSync('./picker.js', 'utf-8'); await page.evaluate(pickerScript); // 然后手动操作页面,拾取器会在 Console 输出

    方式二:用addInitScript。这样每次页面加载都会自动注入:

    await page.addInitScript({ path: './picker.js' });

    方式二更适合调试,因为页面跳转后拾取器还在。方式一适合一次性使用。

    注意:addInitScript注入的脚本在页面加载前执行,此时document.body可能还不存在。我的脚本里createOverlay用了document.body.appendChild,如果 body 还没创建会报错。稳妥的做法是加个判断,或者用DOMContentLoaded包一层。我在实际使用中是在页面加载完再注入的,所以没这个问题,但你要用addInitScript的话记得改。

    4.5 输出结果怎么用

    拿到 JSON 后,转成 Playwright 代码很简单。我写了个小函数:

    function toPlaywright(info) { const locator = info.selector.startsWith('#') || info.selector.startsWith('[') ? `page.locator('${info.selector}')` : `page.locator('${info.selector}')`; return `${locator}.click();`; }

    其实不用这么麻烦,直接page.locator(info.selector)就行。选择器字符串本身就是合法的 Playwright 参数。

    如果你要用 XPath,加个前缀:

    await page.locator(`xpath=${info.xpath}`).click();

    实测下来,优先用selector字段,XPath 作为备选。因为 CSS 选择器在 Playwright 里性能更好,而且可读性更强。

    5. 常见问题与排查技巧实录

    5.1 高亮框位置不对

    现象:高亮框比实际元素偏移,或者大小不对。

    原因:通常是页面有 CSStransform或者zoom。getBoundingClientRect()返回的是变换后的坐标,但如果页面整体缩放了,覆盖层的fixed定位可能对不上。

    解决:检查页面是否有body { zoom: 0.9 }之类的样式。如果有,要么去掉,要么在计算时除以缩放比例。我在一个后台系统上遇到过zoom: 0.8,高亮框整体偏大,后来在updateOverlay里加了缩放补偿。

    5.2 点击没反应

    现象:鼠标移动有高亮,但点击没输出。

    原因:大概率是页面自己的click处理器在捕获阶段就stopPropagation()了,或者用了pointer-events拦截。

    解决:确认我的监听器也是捕获阶段(true)。如果还不行,检查是否有iframe——iframe里的事件不会冒泡到父文档,需要在iframe内部单独注入。

    5.3 选择器太长

    现象:输出的选择器是div > div > div > span:nth-of-type(3)这种。

    原因:目标元素没有 id、没有>function injectToIframes() { document.querySelectorAll('iframe').forEach(iframe => { try { const doc = iframe.contentDocument; if (doc) { doc.addEventListener('mousemove', onMouseMove, true); doc.addEventListener('click', onClick, true); } } catch (e) { // 跨域 iframe,忽略 } }); }

    注意跨域限制。跨域 iframe 的contentDocument访问会抛异常,必须 try-catch 包住。

    6.2 生成 Playwright 代码片段

    现在的输出是 JSON,你可以再加一步,直接生成可执行的 Playwright 代码:

    function toPlaywrightCode(info) { const lines = []; lines.push(`const el = page.locator('${info.selector}');`); if (info.tag === 'input' || info.tag === 'textarea') { lines.push(`await el.fill('${info.value || ''}');`); } else if (info.tag === 'a') { lines.push(`await el.click();`); } else { lines.push(`await el.click();`); } return lines.join('\n'); }

    这个功能我加了,但用得不多。因为实际场景里,点击之后往往还要等待、断言,不是一行代码能搞定的。JSON 给你原料,怎么炒是你的事。

    6.3 支持 Shadow DOM

    现代 Web 组件大量使用 Shadow DOM,普通选择器穿不透。要支持的话,得递归遍历shadowRoot:

    function deepQuerySelector(root, selector) { const found = root.querySelector(selector); if (found) return found; const hosts = root.querySelectorAll('*'); for (const host of hosts) { if (host.shadowRoot) { const result = deepQuerySelector(host.shadowRoot, selector); if (result) return result; } } return null; }

    这个我没集成到主脚本里,因为会增加复杂度,而且我目前的项目没用到 Shadow DOM。按需扩展,不要为了炫技加功能。

    6.4 批量拾取模式

    现在的模式是"点一次锁一次"。如果你要抓一批元素,可以加个批量模式:按住 Shift 点击,把结果累积到一个数组里,最后一次性输出。

    let batchMode = false; let batchResults = []; // 在 onClick 里 if (e.shiftKey) { batchResults.push(buildInfo(currentEl)); console.log(`已收集 ${batchResults.length} 个元素`); return; }

    这个功能我在做表单自动化的时候用过,一次性抓十几个输入框,效率提升明显。

    7. 一些个人体会

    写这个拾取器的过程,最大的感受是:工具的价值不在于功能多,而在于顺手。219 行,没有花哨的 UI,没有复杂的配置,粘进去就能用,用完 ESC 退出。这种"零摩擦"的体验,比任何功能都重要。

    我见过太多人花几天时间搭一个"完美的"调试工具,结果用两次就扔了。因为工具本身成了负担。好的工具应该像一把螺丝刀,拿起来就用,用完就放下,不需要你记住它的存在。

    另外一点,关于"官方没做我自己造"这件事。我不是鼓励大家重复造轮子,而是想说:当你等一个功能等得不耐烦的时候,自己动手往往比继续等更快。219 行,一个下午,我得到了一个完全符合自己需求的工具,而且每一行我都懂。这种掌控感,是等不来的。

    最后分享一个小技巧:如果你经常用这个脚本,可以把它存成 Chrome 的 Snippet。DevTools 的 Sources 面板里有 Snippets 标签,新建一个,把代码粘进去,以后右键 Run 就行,不用每次翻文件。我现在的用法就是这样,按Ctrl+Shift+P,输入!picker,回车,两秒钟启动。

    这个脚本后续还可以这样扩展:加个"复制为 Python 代码"的按钮,适配 Selenium 用户;或者加个"高亮所有匹配元素"的功能,输入选择器后把所有匹配项都框出来,方便检查选择器是否正确。这些都不难,看你自己的需求。

    RELATED READING

    延伸阅读

    更多一线实战笔记与深度复盘,助您持续精进