>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 延伸阅读 更多一线实战笔记与深度复盘,助您持续精进