Midscene.js 原理解析:8K+ Star 的 AI 驱动浏览器自动化测试框架

3,005 阅读17分钟

Midscene.js 简介

继上一篇 Browser Use 之后,我又去阅读了 Midscene.js 的源码,对比 Browser Use、Midscene.js 的第一感觉就是很省钱,但是深入体验下来发现两者在功能定位和应用场景上有着本质的区别

官方介绍 Midscene.js 也是一个浏览器的 AI 操作助手,适用于 Web、Android、自动化和测试。

抛开模型决策比较慢和费钱这两项,Midscene.js 体验下来,在自动化测试方面相比传统前端单测有显著优势,减少测试代码的维护成本,能够处理更复杂的交互场景,官方的迭代非常高频,最近刚刚支持了安卓MCP

Midscene 提供 Chrome 插件形式,在开始使用的时候,我没有仔细阅读 Midscene 官方文档,以为 Chrome 插件就是 Midscene 的核心,脑子就在想这个东西怎么做自动化测试,还那么多 star,后面使用和阅读文档后,才知道 Chrome 插件只是让用户快速体验核心功能入口,惭愧惭愧。

如上图官方的 playwright 示例 所示,Midscene 将复杂的自动化流程分解为一系列精确的微操作指令(如 aiAction、aiQuery、aiTap、aiInput等),官方文档将这些 API分为两类:

agent.ai() 实际对应的就是 agent.aiAction()

image.png

为了更直观地展示区别,我绘制了两张对比图,快速对比了自动规划即时操作的不同工作流程。

在了解 Midscene.js 的基本功能后,我们就该探索其内部工作原理。对于 AI 工具,如何让LLM 理解用户的输入并返回符合用户预期输出,这就是最重要的。

对应 Midscene.js 的核心就在于如何让 LLM(大语言模型)准确理解用户意图并执行相应操作。这涉及到两个关键环节:一是如何构建系统提示词和处理用户输入,二是如何将用户意图转化为具体的浏览器操作。

通过对比 Browser Use 的源码实现,我们可以看到在浏览器自动化领域,DOM 信息的提取和传递方式对于 LLM 的理解和决策至关重要。Midscene.js 中 自动规划还是即时操作,对 DOM 信息提取的方式都是相同,我们先来看一下 Midscene.js 是如何提取 DOM 信息的?

提取 DOM 信息

在 Midscene.js 中 自动规划还是即时操作,对 DOM 信息提取的方式都是相同。

image.png

继续以百度为例,介绍一些前端的概念,网页展示基于 HTML 文档,该文档由各种标签元素(如<div>, <a>, <img>等)组成层级结构。浏览器解析这些 HTML 元素后,将其转换为 DOM (文档对象模型) 树,其中每个 HTML 元素、文本和属性都变成了 DOM 树中的节点。JavaScript 可以通过这个 DOM 树动态操作和修改网页内容,实现交互功能。

获取哪些类型元素

相比 Browser Use 的 DOM 获取 ,Midscene.js 对 DOM 获取逻辑要简单很多

Midscene.js 只会获取 Midscene.js 定义一下五种类型的元素:

  1. 按钮元素 (NodeType.BUTTON)
export function isButtonElement(
  node: globalThis.Node,
): node is globalThis.HTMLButtonElement {
  return node instanceof HTMLElement && node.tagName.toLowerCase() === 'button';
}
  1. 图片元素 (NodeType.IMG)
export function isImgElement(
  node: globalThis.Node,
): node is globalThis.HTMLImageElement {
  // check if the node is an image element
  if (!includeBaseElement(node) && node instanceof Element) {
    const computedStyle = window.getComputedStyle(node);
    const backgroundImage = computedStyle.getPropertyValue('background-image');
    if (backgroundImage !== 'none') {
      return true;
    }
  }

  if (isIconfont(node)) {
    return true;
  }

  return (
    (node instanceof HTMLElement && node.tagName.toLowerCase() === 'img') ||
    (node instanceof SVGElement && node.tagName.toLowerCase() === 'svg')
  );
}
  1. 文本元素 (NodeType.TEXT)
export function isTextElement(
  node: globalThis.Node,
): node is globalThis.HTMLTextAreaElement {
  return node.nodeName.toLowerCase() === '#text' && !isIconfont(node);
}
  1. 表单项元素 (NodeType.FORM_ITEM)
export function isFormElement(node: globalThis.Node) {
  return (
    node instanceof HTMLElement &&
    (node.tagName.toLowerCase() === 'input' ||
      node.tagName.toLowerCase() === 'textarea' ||
      node.tagName.toLowerCase() === 'select' ||
      node.tagName.toLowerCase() === 'option')
  );
}

  1. 容器元素 (NodeType.CONTAINER)
export function isContainerElement(
  node: globalThis.Node,
): node is globalThis.HTMLElement {
  if (!(node instanceof HTMLElement)) return false;

  // include other base elements
  if (includeBaseElement(node)) {
    return false;
  }

  const computedStyle = window.getComputedStyle(node);
  const backgroundColor = computedStyle.getPropertyValue('background-color');
  if (backgroundColor) {
    return true;
  }

  return false;
}

在这个基础上,宽或者高小于 3 px 以及宽度和高度大于屏幕会被忽略掉。

对于 图片元素 (NodeType.IMG)容器元素 (NodeType.CONTAINER) 不像其他类型的元素一眼就能理解,它们都额外包含了 includeBaseElement 的方法,

function includeBaseElement(node: globalThis.Node) {
  if (!(node instanceof HTMLElement)) return false;

  // include text
  if (node.innerText) {
    return true;
  }

  const includeList = [
    'svg',
    'button',
    'input',
    'textarea',
    'select',
    'option',
    'img',
  ];

  for (const tagName of includeList) {
    const element = node.querySelectorAll(tagName);
    if (element.length > 0) {
      return true;
    }
  }

  return false;
}

阅读 includeBaseElement 方法后,可以看出

  • 图片元素 (NodeType.IMG) 不仅提取常规的图标、<img> 和 <svg> 标签,还提取具有背景图片的纯背景元素。
const divWithBgImage = document.createElement('div');
divWithBgImage.innerText = '';
divWithBgImage.style.backgroundImage = 'url("image.jpg")';
  • 容器元素 (NodeType.CONTAINER) 提取的是纯容器元素,没有文本内容但有背景色的元素。这类元素通常作为布局容器或视觉分隔元素使用。

获取元素哪些信息

interface WebElementInfo {
  id: string;
  indexId: number;
  nodeHashId: string;
  locator: string;
  attributes: {
    nodeType: NodeType;
    [key: string]: string;
  };
  nodeType: NodeType;
  content: string;
  rect: { left: number; top: number; width: number; height: number };
  center: [number, number];
  zoom: number;
  screenWidth?: number;
  screenHeight?: number;
`

对于每种元素,Midscene 提取统一的 WebElementInfo 结构,包含元素ID、位置、尺寸等关键信息

image.png

值得注意的是,除 表单项元素 (NodeType.FORM_ITEM) 会使用当前元素的 tagName,其他类型元素一般都会通过 tagNameOfNode 这方法去获取,获取的结果一般为父元素的 tagName(比如文本节点)。

function tagNameOfNode(node: globalThis.Node): string {
  let tagName = '';
  if (node instanceof HTMLElement) {
    tagName = node.tagName.toLowerCase();
  }

  const parentElement = node.parentElement;
  if (parentElement && parentElement instanceof HTMLElement) {
    tagName = parentElement.tagName.toLowerCase();
  }

  return tagName ? `<${tagName}>` : '';
}

以下图的 HTML 为例

image.png

Midscene.js 通过 window.midscene_element_inspector.webExtractNodeTree() 也就是 extractTreeNode 方法将HTML 文档转换为结构化树

image.png

再通过 descriptionOfTree 方法将上述结构站换成如下文本,做为上下文传递给 LLM

image.png

到这里可以看到,包裹内容的标签,不再是 button,而是 button 的父元素 div, button 的子元素也只会保留文本内容

从这个示例来看,Midscene.js 提取 DOM 信息的逻辑,对于一些 HMTL 编写不规范的网站,处理还是有问题的

截图

  1. DPR 适配:处理高分辨率屏幕(DPR>1)的截图。它将截图调整为实际像素尺寸,确保屏幕元素位置和尺寸与DOM元素信息一致
  if (size.dpr && size.dpr > 1) {
    screenshotBase64 = await resizeImgBase64(screenshotBase64, {
      width: size.width,
      height: size.height,
    });
  }
  1. 添加元素标记:在截图上添加可交互元素的标记或边框,帮助LLM识别可操作的UI元素
else if (!vlLocateMode()) {
    imagePayload = await markupImageForLLM(
      screenshotBase64,
      context.tree,
      context.size,
    );
  }
  1. 模型适配填充:为特定视觉模型(qwen-vl)优化图像,paddingToMatchBlockByBase64方法将图像尺寸的宽和高都调整为28像素的整数倍,会在右侧和底部添加白色填充,确保原始内容不变形。
if (vlLocateMode() === 'qwen-vl') {
   imagePayload = await paddingToMatchBlockByBase64(imagePayload);
}

即时操作

在 Midscene 中自动规划 agent.ai(),最后也会拆解成一个又一个的即时操作,我们先来看下 Midscene 即时操作 agent.aiTap()的工作原理,这样更好理解自动规划 agent.ai()

agent.aiTap()

image.png

aiTap() 方法是 PageAgent 类中用于模拟点击网页元素,其执行流程如下

  1. 创建定位参数:首先,aiTap() 通过 buildDetailedLocateParam() 方法构建元素定位参数,包含定位提示和可选配置
  2. 构建计划 (plans):调用 buildPlans('Tap', detailedLocateParam) 创建点击操作的计划,
  3. 执行计划:再调用 taskExecutor.runPlans() 执行这些计划

image.png

在 runPlans 方法中,会将抽象的计划转换为具体的可执行任务:

  1. 定位任务 (Locate):
    • 使用大模型(如 VLM)分析页面截图,理解用户的自然语言描述
    • 在页面中识别并定位到匹配描述的元素
    • 返回元素的信息(通用模型返回 id, 视觉模型返回坐标)
  2. 点击任务 (Tap)

定位元素

在 Midscene 中定位元素是一个通用的方法,自动规划和即时操作,都会用到这个方法

系统提示词

image.png

在 Midscene 对于视觉语言模型(比如 UI-TARS)类型,会给出其他通用模型(比如 GPT-4o)不一样的系统提示词。

image.png

上图左侧是通用模型提示词,上图右侧则是视觉模型提示词,从系统提示词来看两类模型的输入(用户消息)输出(AI消息)差距还是很大,在下文中我对比了输入和输出的差别

用户消息

image.png

用户消息主要就分为两项,页面截图和文本描述,对于不同模型传递的用户消息也不同

  • 视觉模型
    • 截图直接传递原图
    • 不会传递提取的 DOM 信息

image.png

  • 通用模型
    • 会在截图上添加可交互元素的标记边框
    • 会传递提取的 DOM 信息

Here is the item user want to find:
=====================================
点击百度一下
=====================================

The size of the page: 1284 x 916 
 Some of the elements are marked with a rectangle in the screenshot, some are not. 
 The page elements tree:
          <>
            <a id="fjcpl"   left="1086" top="29" width="52" height="15">
              百度首页
            </a>
          </>
          <>
            <a id="lclfb" markerId="1"  left="1162" top="29" width="26" height="15">
              设置
            </a>
          </>
          <>
            <a id="heooo" markerId="2"  left="1223" top="27" width="26" height="15">
              登录
            </a>
          </>
            ...省略

AI 消息

AI 消息返回结果上,通用模型返回元素的唯一标识 id,视觉模型返回的元素盒子左上角坐标和右下角坐标

  • 通用模型
{
  "elements": [
    {
      "id": "kghio",
      "reason": "The element matches the description '点击百度一下' as it is an input element with the value '百度一下' and is located prominently in the screenshot.",
      "text": "百度一下"
    }
  ],
  "errors": []
}
  • 视觉模型
{  
  "bbox": [587, 41, 587, 41],  
  "errors": []
}

自动规划

在 Midscene 中自动规划 agent.ai(),实际是有两种方式,一种是其他模型的方法 action,另一种则是专为 UI-TARS 模型设计的方法actionToGoal,两种方法的工作原理十分相似,但在提示词设计和消息管理上有明显差异

  • action方法:每次只保留一条用户消息,历史操作通过总结添加

  • actionToGoal方法:保留多轮消息历史,但限制最多保留4张历史截图

image.png

通用模型的方法 action

系统提示词

image.png

image.png

如上图代码 Midscene action 方法中对于视觉语言模型(VLM)类型,会给出其他通用模型不一样的系统提示词、输入和输出,与即时操作一样

image.png

Midscene 最近支持了安卓的自动化,新增了关于安卓的操作,AndroidBackButton(安卓返回按钮操作)、AndroidHomeButton(模拟安卓主页按钮操作)、AndroidRecentAppsButton(模拟安卓最近应用按钮操作)

image.png

image.png

由于 Gemini 坐标顺序不同,大多数视觉模型使用 [xmin, ymin, xmax, ymax] 坐标格式,Gemini使用 [ymin, xmin, ymax, xmax] 格式,先垂直后水平,在这里也进行了特殊的处理

用户消息

image.png

action 方法 ,没有复杂的消息管理,每次只会传递一条用户消息

image.png

执行过历史操作由模型总结生成,添加到这条用户消息中

image.png

与通用模型相比较,视觉模型的用户消息中没有传递 pageDescription

pageDescription:
=====================================
The size of the page: 1541 x 916 
 Some of the elements are marked with a rectangle in the screenshot, some are not. 
 The page elements tree:
          <>
            <a id="mfgdf"   left="1343" top="29" width="52" height="15">
              百度首页
            </a>
          </>
          ...省略其他节点
          <>
            <a id="ilejc" markerId="36"  left="739" top="70" width="32" height="16">
              文库
            </a>
          </>
          <>
            <a id="pgieh" markerId="37"  left="798" top="70" width="28" height="16">
              更多
            </a>
          </>

=====================================


Here is the user's instruction:
<instruction>
  <high_priority_knowledge>
    undefined
  </high_priority_knowledge>

  搜香蕉
</instruction>

在 GTP-4o 模型下,百度页面,指令搜香蕉为例,最后用户消息展示如上

AI 消息

{
  "actions": [
    {
      "locate": {
        "id": "febfj",
        "prompt": "The search input field"
      },
      "param": {
        "value": "香蕉"
      },
      "thought": "Input the search term '香蕉' into the search field.",
      "type": "Input"
    },
    {
      "locate": {
        "id": "phgld", 
        "prompt": "The search button"
      },
      "param": null,
      "thought": "Click the search button to execute the search.",
      "type": "Tap"
    }
  ],
  "error": null,
  "log": "Input the term '香蕉' into the search field and click the search button to perform the search.",
  "more_actions_needed_by_instruction": false
}

在百度页面,输入搜香蕉的例子中,AI 返回如上结构的信息,

  • actions - 操作执行计划
  • log - 操作总结
  • error - 错误信息
  • more_actions_needed_by_instruction 是否需要更多操作指令
    • 比如:下拉选择框这种页面元素有变动的情况下,就需要重新获取页面的元素,在进行点击操作,more_actions_needed_by_instruction 就应该为 true

这里我觉得的问题就是 log、more_actions_needed_by_instruction 都是操作未执行前生成的预判,而不是对执行结果的总结和验证,不过体验下来感觉 自动规划 也不是 Midscene 在自动化测试的核心

image.png

在 Midscene 中,自动规划(aiAction)功能会将复杂指令拆解成一系列即时操作(如aiTap、aiInput等)。

image.png

但是和即时操作不同的是,在进行定位操作时我是已经获取到 id(或坐标信息 bbox)查找对应的元素,大概率不需要再调用AI模型进行识别

专为 UI-TARS 模型设计的方法actionToGoal

系统提示词

image.png

这类视觉模型的提示词 Midscene 中写的都比较简洁,这里唯一特殊的就是将用户指令放到系统提示词, actionToGoal 只是单次任务,不涉及多轮对话,这么做也没啥影响

我觉得再有一点 actionToGoal 和 action 用户消息处理方式不一样,不再是只保留一条用户消息,这种设计让大模型能够更好地理解用户的完整意图,而不会因为历史消息的堆积而影响模型的判断

用户消息

image.png

在 actionToGoal 用户消息只有截图,并且也会将上一轮返回的 AI 消息也添加到消息队列中

image.png

额外的优化操作就是,截图超过 4 张后就会将最前面的截图删除掉,至于 4 这个值怎么测试出来的?我也挺好奇的

AI 消息

Thought: 我看到百度首页的搜索框就在页面中央,现在要搜索"香蕉"这个关键词。让我先点击一下搜索框,这样就能开始输入了。
Action: click(start_box='<bbox>334 257 334 257</bbox>')

image.png

image.png

这 UI-TARS 多少有点太发散了,我就搜个苹果和香蕉这种简单的案例,一般都得发个4~7次请求

一些浅浅的思考

Midscene 将复杂操作分解为一系列精确的微操作指令(如 aiTap、aiInput、aiScroll 等),我觉得这种方式更加的符合我的理想化,的方式在自动化测试中更加理想和实用,这种设计让我能更好地干预和控制每个操作步骤,最近刷到的一个开源自动化项目stagehand,也采用了类似即时操作的方式

花钱可以,但是我只想花一次钱,虽然 Midscene 是有做缓存操作的(浏览器插件不支持),但是缓存命中的条件其中一条需要相同的屏幕尺寸,写单测还好 playwright 可以控制窗口可以固定,但如果浏览器自动化操作,拖拽一下窗口缓存就失效了

如果采用 XPath 的形式做缓存,感觉会更稳定一点。但如果这个网站是个响应式网站(不同屏幕展示不同元素),或者下拉选择框这种 XPath 没变但是内容变了,嗯🤔???

再就是从 Midscene 的视觉模型用法来看,其提示词和输入都完全基于视觉信息,不依赖于页面的 DOM 结构信息。UI-TARS 也在电脑自动化领域推出了 UI-TARS-desktop桌面自动化软件,我使用 doubao-1-5-ui-tars-250328 模型,体验下来效果还不是很好,在相同的用例打开计算器计算 1+1,感觉不如macOS-use 通过 Apple 的辅助功能 API (Accessibility API) 获取 UI 树,再结合 GPT-4 进行操作规划的效果稳定。从目前的体验来看感觉视觉模型的路还很长,不过我这里只体验了 ui-tars 一个视觉模型,有点以偏概全。我还需要去读一读,更具体实现细节,以及更多场景下更多模型的具体对比测试。