ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

219行代码手写Playwright元素拾取器:自动生成稳定选择器

219行代码手写Playwright元素拾取器:自动生成稳定选择器

1. 从零造一个元素拾取器:为什么我要自己动手

浏览器里点一下页面元素,就能自动生成稳定的选择器,这个需求做自动化测试和爬虫的人应该都不陌生。Chrome DevTools 自带的那个拾取工具,用过的人都知道,生成的选择器经常是一长串div > div:nth-child(3) > span这种脆弱到不行的路径,页面结构稍微一变就全废了。Playwright 官方有 codegen 可以录制操作,但它更偏向于录制整个操作流程,单独想拿一个元素的定位表达式,还是得手动去写。

我平时做 Playwright 自动化比较多,写测试用例的时候最烦的就是定位元素这一步。一个列表页几十个元素,每个都要手动去查 DOM、写选择器、验证唯一性,重复劳动特别消耗精力。后来看到 Kimi Code 官方预告要出一个元素拾取功能,等了几天没等到,索性自己动手写了一个。最终成品压缩下来 219 行,功能上覆盖了我在实际项目里 90% 以上的拾取场景,比官方发布还早了 5 天。

这个东西解决的核心问题就一个:在浏览器里可视化地点击任意元素,自动生成一条在 Playwright 里可直接使用的、尽可能稳定的定位表达式。它适合所有用 Playwright 做自动化测试、做数据采集、做页面监控的开发者,哪怕你刚接触 Playwright,只要会复制粘贴选择器就能用。下面我把整个设计思路、核心实现、踩过的坑和实操方法完整拆一遍,代码层面的东西我会尽量讲透,你照着改改就能变成自己的工具。

2. 整体设计与技术选型:为什么是这套组合

2.1 核心需求拆解与方案取舍

先把这个工具要干的事情拆清楚。输入是用户在页面上的点击行为,输出是一条选择器字符串。中间要解决几个关键问题:怎么捕获点击、怎么从点击目标反推选择器、怎么保证选择器稳定且唯一、怎么把结果交付给用户。

捕获点击这件事,最直接的做法是注入一段脚本到页面里,监听click事件。但这里有个坑,如果直接监听,用户点任何东西都会触发拾取逻辑,包括点按钮、点链接,会干扰页面本身的交互。所以需要一个"拾取模式"的开关,开启后才拦截点击,并且要preventDefault和stopPropagation,防止误触页面功能。

选择器生成是核心难点。我对比了几种策略:纯 CSS 路径(nth-child那种)最脆弱,页面一改就失效;纯属性选择器([data-testid="xxx"])最稳定,但很多页面根本没有测试属性;XPath 灵活但 Playwright 里用起来不如 CSS 直观。最终我采用的是优先级降级策略:先看有没有稳定的测试属性,没有就看 id,再看有没有语义化的 class,最后才退化到带nth-of-type的路径。这个顺序不是拍脑袋定的,后面会详细讲每一层的判断逻辑。

交付方式上,我一开始想做成浏览器扩展,但扩展开发调试麻烦,还要处理权限和安装问题。后来改成Playwright 脚本注入 + 本地服务回传的方案:用 Playwright 启动浏览器,注入拾取脚本,用户点击后通过 WebSocket 或 HTTP 把选择器传回本地终端,直接打印出来。这样零安装成本,改代码也方便。

2.2 为什么选 Playwright 而不是 Puppeteer 或 Selenium

这个选择其实很直接。Playwright 的page.evaluate和page.exposeFunction配合得天衣无缝,注入脚本和回传数据都非常顺畅。exposeFunction可以在页面上下文里暴露一个 Node 端的函数,页面里直接调用就能把数据传出来,不用自己搭 WebSocket 服务,省了一大堆事。

另外 Playwright 对多浏览器的支持也更好,Chromium、Firefox、WebKit 都能跑,虽然拾取逻辑主要针对 Chromium 调试,但生成的 CSS 选择器是通用的。Puppeteer 只支持 Chromium,Selenium 的注入方式又比较笨重,综合下来 Playwright 是最优解。

还有一点,Playwright 自带的locatorAPI 对选择器的容错性很好,支持 CSS、XPath、文本选择器等多种语法,生成的表达式直接丢给page.locator()就能用,不需要额外转换。

2.3 219 行是怎么控制住的

很多人可能觉得 219 行做不了什么,但实际写下来,核心逻辑就那么几块:事件监听、选择器生成、数据回传、模式切换。我刻意避免了过度设计,没有做 UI 面板,没有做历史记录,没有做选择器编辑,就聚焦在"点一下、出一条选择器"这一件事上。

代码结构上分三部分:注入到页面的拾取脚本(约 120 行)、Node 端的启动和回传逻辑(约 60 行)、辅助工具函数(约 40 行)。每一部分都尽量用最直白的写法,不引入任何第三方依赖,纯原生 JS 加 Playwright 自带 API。这样代码可读性高,你想改哪里一眼就能找到。

提示:控制代码行数不是为了炫技,而是为了降低维护成本。工具类脚本一旦超过 500 行,改起来就容易牵一发而动全身,219 行是我实测下来既能覆盖功能又足够好维护的平衡点。

3. 核心细节解析:选择器生成的优先级策略

3.1 第一优先级:测试属性与稳定标识

选择器稳不稳定,关键看它依赖的属性会不会变。>mkdir element-picker && cd element-picker npm init -y npm install playwright npx playwright install chromium

npx playwright install chromium这一步是下载浏览器内核,第一次执行会花几分钟,取决于网络情况。如果下载慢,可以设置国内镜像源,具体方法搜一下就有,这里不展开。

安装完成后,目录下会有node_modules和package.json。新建一个picker.js文件,这就是我们的主脚本。整个工具就这一个文件,不需要其他配置。

4.2 注入拾取脚本的核心代码

拾取脚本的主体是一个自执行函数,通过page.evaluate注入。核心逻辑是监听click事件,在捕获阶段拦截,然后调用选择器生成函数。下面是我实际用的代码结构,关键部分我都加了注释:

// 注入到页面的拾取逻辑 function injectPicker() { let picking = false; // 选择器生成主函数 function generateSelector(el) { // 第一优先级:测试属性 const testAttrs = ['data-testid', 'data-test', 'data-cy']; for (const attr of testAttrs) { const val = el.getAttribute(attr); if (val) return `[${attr}="${val}"]`; } // 第二优先级:ID(过滤动态ID) if (el.id && !/^\d+$/.test(el.id) && !/^el-\d+/.test(el.id)) { return `#${el.id}`; } // 第三优先级:语义化 class const classes = Array.from(el.classList).filter(c => !/^(mt-|mb-|px-|py-|flex|grid|text-|bg-)/.test(c) ); if (classes.length > 0) { const sel = `${el.tagName.toLowerCase()}.${classes[0]}`; if (document.querySelectorAll(sel).length === 1) return sel; } // 第四优先级:文本内容 const text = el.textContent.trim(); if (text.length >= 2 && text.length <= 20 && !/\d/.test(text)) { return `text=${text}`; } // 兜底:层级路径 return buildPath(el); } // 层级路径构建 function buildPath(el) { const parts = []; while (el && el.nodeType === 1) { let part = el.tagName.toLowerCase(); const siblings = Array.from(el.parentNode.children) .filter(c => c.tagName === el.tagName); if (siblings.length > 1) { part += `:nth-of-type(${siblings.indexOf(el) + 1})`; } parts.unshift(part); if (el.id) { parts.unshift(`#${el.id}`); break; } el = el.parentElement; } return parts.join(' > '); } // 点击拦截 document.addEventListener('click', function handler(e) { if (!picking) return; e.preventDefault(); e.stopPropagation(); const selector = generateSelector(e.target); window.__pickerCallback(selector); }, true); // 暴露开关方法 window.__togglePicker = (state) => { picking = state; }; }

这段代码里,picking变量控制拾取模式是否开启,generateSelector是核心的选择器生成函数,buildPath是兜底的层级路径构建。点击监听用了捕获阶段(第三个参数true),这样能在页面自身的事件处理之前拦截。

4.3 Node 端启动与数据回传

Node 端的逻辑更简单,启动浏览器、注入脚本、暴露回传函数、开启拾取模式,然后等待用户点击。关键代码如下:

const { chromium } = require('playwright'); (async () => { const browser = await chromium.launch({ headless: false }); const page = await browser.newPage(); // 暴露回传函数 await page.exposeFunction('__pickerCallback', (selector) => { console.log('\n生成的选择器:'); console.log(selector); console.log('\n可以直接在 Playwright 里这样用:'); console.log(`page.locator('${selector}')`); }); // 注入拾取脚本 await page.evaluate(injectPicker); // 开启拾取模式 await page.evaluate(() => window.__togglePicker(true)); // 打开目标页面 await page.goto('https://example.com'); console.log('拾取模式已开启,点击页面元素即可生成选择器'); })();

exposeFunction是 Playwright 提供的一个非常实用的 API,它能在页面上下文里挂一个函数,页面里调用这个函数时,实际执行的是 Node 端的逻辑。这样数据就能从浏览器传到终端,不需要自己搭通信通道。

4.4 参数选择与配置说明

启动浏览器时,headless: false是必须的,因为要可视化点击。slowMo参数可以加,让操作慢一点,方便观察,但拾取场景下不是必须的。视口大小建议设置成 1280x800 以上,避免元素被遮挡。

如果目标页面有反爬检测,比如检测navigator.webdriver,可以在启动时加参数隐藏这个特征:

const browser = await chromium.launch({ headless: false, args: ['--disable-blink-features=AutomationControlled'] });

这个参数能去掉大部分基础检测,但更复杂的检测需要额外处理,不在这个工具的范围内。拾取器本身是辅助开发的,不是用来绕过什么限制的,所以这部分点到为止。

提示:如果你要拾取的元素在 iframe 里,需要先page.frameLocator()定位到 iframe,再注入脚本。主页面和 iframe 的上下文是隔离的,脚本不会自动穿透。

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

5.1 点击没反应或选择器不生成

最常见的原因是拾取模式没开启,或者脚本注入失败。排查步骤很简单:先在浏览器控制台输入window.__togglePicker,如果返回 undefined,说明脚本没注入成功,检查page.evaluate是否在goto之前执行了。正确顺序是先goto再注入,因为页面加载会清空之前注入的脚本。

如果函数存在但点击没反应,检查picking变量是否为 true。可以在控制台手动执行window.__togglePicker(true)再试。还有一种情况是页面用了 Shadow DOM,点击事件被 shadow root 拦截了,需要在generateSelector里加composedPath()处理,取e.composedPath()[0]作为目标元素。

5.2 生成的选择器匹配到多个元素

这是唯一性校验没通过的情况。我的代码里做了校验,但有些边界情况会漏掉,比如动态加载的列表,初始只有 3 个元素,滚动后变成 10 个,选择器就歧义了。解决办法是在选择器里加上更具体的限定,比如父级 class 或者nth-of-type。

还有一种情况是文本选择器匹配到多个,比如页面上有多个"确定"按钮。这时候需要加上父级限定,比如form >> text=确定。Playwright 的>>语法可以链式组合选择器,非常灵活。

5.3 特殊字符导致选择器语法错误

class 名或属性值里包含冒号、点号、方括号时,CSS 选择器会解析失败。比如class="btn:primary",直接写.btn:primary会被当成伪类。解决办法是转义,把特殊字符前面加反斜杠:.btn\:primary。

我写了一个转义函数处理这种情况:

function escapeCSS(str) { return str.replace(/([:.[\]()#])/g, '\\$1'); }

这个函数在生成 class 选择器和属性选择器时都会调用,保证输出的表达式合法。

5.4 动态 ID 和随机 class 的识别

有些框架生成的 ID 是el-1234这种,每次渲染都变。我的过滤规则是:纯数字 ID、以el-开头且后面跟数字的、包含连续 4 位以上数字的,都跳过。class 的过滤规则类似,包含随机字符串特征的也跳过。

但这个规则不是万能的,有些项目的 ID 就是有意义的,比如user-profile-123,这里的 123 是用户 ID,虽然会变,但选择器本身是稳定的。这种情况需要人工判断,工具没法自动区分。我的做法是在生成的选择器后面加一个注释,提示"此选择器包含数字,请确认是否稳定"。

问题现象可能原因解决方法
点击无反应拾取模式未开启控制台执行__togglePicker(true)
选择器匹配多个唯一性不足加父级限定或nth-of-type
语法错误特殊字符未转义使用escapeCSS函数处理
动态 ID 被选中过滤规则未覆盖手动检查并调整过滤正则
iframe 内元素无法拾取上下文隔离使用frameLocator单独注入

5.5 实操心得:几个提升效率的小技巧

第一个技巧是批量拾取。如果页面上有多个相似元素,可以开启拾取模式后连续点击,每次点击都会输出一条选择器,最后统一复制。我在代码里加了一个数组缓存,把所有生成的选择器存起来,按Ctrl+C时一次性打印。

第二个技巧是选择器预览。生成选择器后,立即用page.locator(selector).highlight()在页面上高亮一下,确认选中的是不是目标元素。这个反馈非常直观,比看控制台输出快得多。

第三个技巧是保存到文件。拾取的选择器可以直接追加写入一个.json文件,格式是{ "元素名": "选择器" },后续写测试用例时直接读取这个文件,不用手动复制粘贴。这个功能我加了 10 行代码,但节省的时间非常可观。

注意:拾取器生成的只是定位表达式,不包含操作逻辑。实际写测试时,还需要配合click()、fill()、expect()等方法。选择器只是第一步,后面的断言和等待策略同样重要。

6. 与官方方案的对比及后续扩展思路

6.1 和 Kimi Code 官方拾取器的差异

官方方案我没等到,但从预告来看,它应该是集成在 IDE 里的,拾取后直接生成代码片段。我的方案更轻量,就是一个独立脚本,输出的是纯选择器字符串,不绑定任何编辑器。两者定位不同:官方追求开箱即用,我追求灵活可控。

从实际使用角度看,独立脚本的好处是可以随时改逻辑。比如你的项目有一套自己的测试属性规范,我可以直接在generateSelector里加一条规则,优先匹配你们的属性。官方工具一般不会开放这种定制能力。

另外,我的方案不依赖任何云服务,所有逻辑都在本地跑,数据不出本机。对于有代码保密要求的团队,这一点比较重要。

6.2 可以继续扩展的方向

第一个方向是支持 XPath 输出。有些老项目还在用 XPath,加一个开关切换输出格式就行。XPath 的生成逻辑和 CSS 类似,只是语法不同,改起来不难。

第二个方向是选择器质量评分。给每条生成的选择器打个分,比如用了>

RELATED READING

延伸阅读

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