☰
Midscene.js视觉驱动:从APP页面采集数据到内部AI知识库构建
2026/10/13 7:43:45 网站建设 项目流程

你有没有遇到过这种需求:业务方突然丢过来一句“把 APP 里的所有商品信息整理出来,做成一个内部 AI 问答知识库”。听起来好像很简单,不就是把 APP 上的数据导出吗?等你真动手就会发现,这活又脏又麻烦。接口有签名校验,页面内容靠动态渲染,商品卡片密密麻麻堆在同一个列表里,传统自动化脚本写了几百行,页面一改版就全部报销。后来我把思路换成了 Midscene.js 这类以视觉理解为核心的自动化工具,用自然语言指挥 AI 去“看”页面、抽数据,再把这些数据清洗、向量化,最终搭出了一个能直接对话的知识库。

这篇文章适合两类人看:一类是要采集 APP 页面数据、但不想长期维护 UI 选择器的数据工程师;另一类是正在为内部知识库找数据入口、被“数据从哪来”卡住的产品技术同学。我会把选型逻辑、环境搭建、指令设计、知识库构建和踩坑记录完整过一遍,所有步骤都是我在真实项目里跑过的,直接复用的价值很高。

1. 为什么最后选了 Midscene.js:自然语言代替选择器

1.1 传统 APP 数据采集的三个坎

第一道坎是接口签名。现在稍微有点规模的 APP,对外接口基本都做了签名校验、参数加密,抓包看着简单,但你想绕过签名去构造请求,得先逆向客户端代码,光这一步就能拖垮绝大多数项目的工期。就算你侥幸拿到了明文接口,服务端随时可以换版本、改字段,你的模拟请求马上就失效。

第二道坎是 UI 自动化脚本的脆弱性。传统做法是基于控件树写定位,靠 id、xpath、text 去找元素。这套方案在 Web 页面上还算成熟,换到 APP 里就难受了:同一个页面在不同机型上层级不同,同一个按钮在 iOS 和安卓上控件属性完全不同。最要命的是 APP 发版频率高,产品经理今天加个引导弹窗,明天把按钮文案从“立即购买”改成“马上抢”,你的脚本就得跟着改一遍,时间长了维护成本比开发成本还高。

第三道坎是数据本身不规整。列表页的卡片只展示标题和部分摘要,完整内容藏在二级详情页里;同一款商品可能出现多个 SKU 卡片,价格在角标上只显示“¥199”这样的短文本。你辛辛苦苦抓回来的数据,还需要再写一套清洗逻辑才能进库。说白了,传统方案的核心矛盾是:你在为一个随时会变化的页面结构写死逻辑,而页面结构恰恰是业务方改动最频繁的东西。

1.2 Midscene.js 的定位与原理

Midscene.js 本质上是一个把“视觉感知”和“页面操作”串在一起的自动化 SDK。它不依赖页面控件树,而是直接拿页面截图交给视觉语言模型去理解。模型看到一张商品列表页,能自动识别出哪块是商品卡片、哪块是价格区域、哪块是“加载更多”按钮,然后根据你的自然语言指令去点击、滑动、输入或者提取数据。

它的工作方式可以粗暴理解成“给 AI 配了一个可以操作手机的虚拟手指”:你告诉它“把页面滚动到底部,提取所有商品名称和价格”,它就真的会先滚动,再对滚动后的新截图做分析,最后把结果整理成结构化 JSON 返回给你。整个过程用的是模型对视觉元素的理解,而不是某个 UI 元素的固定路径,所以页面结构轻度变化的时候,脚本往往还能继续跑。

Midscene.js 另外一个我很看重的点是它对“数据提取”做了专门优化。它不但能执行动作,还能把页面里的信息按你指定的字段结构抽取出来,比如姓名、价格、日期、描述这种列级别的数据。这正好命中知识库构建的第一环:我们缺的从来不是“能打开 APP 的机器人”,而是“能看懂页面并吐出结构化数据的机器人”。

1.3 适用边界:什么场景值得用它

我整理了一张对比表,方便你判断自己的场景到底适不适合切到 Midscene.js:

对比维度传统 UI 自动化Midscene.js 视觉驱动
定位方式控件 id / xpath / text自然语言 + 视觉模型
页面改版影响高,选择器失效频繁较低,视觉理解基本不受小改动影响
数据提取能力需逐字段取文本,再自行拼结构可直接输出 JSON 结构化数据
学习成本需要掌握控件树、等待策略主要成本在提示词设计
运行稳定性依赖控件树完整稳定依赖截图质量和模型理解
适用场景高频回归测试、crash 监控数据采集、页面理解、知识库构建

如果你的项目是做高频回归测试、对定位精准度要求极高,那传统自动化依然是主力;但如果目标是把 APP 页面变成结构化数据,Midscene.js 这种“让 AI 替你看页面”的思路明显更省力。我们后来把商品采集、公告采集、搜索结果采集全部切成这套方案,脚本量从原来的两千多行缩到了不到三百行。

2. 搭一个能“看见”APP 的最小采集环境

2.1 先接通设备:把手机交到脚本手里

Midscene.js 在移动端跑起来之前,得先解决“脚本怎么控制一台真实设备”的问题。通用的做法是走移动端 WebDriver 协议:设备端装好对应的自动化驱动,电脑端启动一个驱动服务,脚本通过这个服务向设备发送点击、滑动、截图等指令。

我这边用的是安卓环境,准备工作大概三块:

  1. 准备一台安卓设备,真机或模拟器都行,开启 USB 调试,保证在电脑终端里能通过设备连接命令看到设备序列号。
  2. 启动移动端驱动服务,默认监听 4723 端口,这个端口就是脚本与设备之间的桥梁。
  3. 确认设备上的目标 APP 已安装,并且版本号、包名都记录清楚,后续启动 APP 会用到。

第一次动手的同学最容易在这一步卡住:设备名、系统版本、APP 包名这三项配置必须和驱动服务里的 capability 完全对齐,否则会出现“服务启动成功但是连接不上设备”的玄学问题。我的建议是,先跑一个最小的“查设备信息”脚本,确认驱动能正常拿到设备型号、系统版本,再进入下一步,不要一上来就写复杂指令。

2.2 安装与初始化 Midscene 项目

环境准备这块,除了 Node 运行环境之外,核心就是把 Midscene 的移动端包和视觉模型服务的 Key 配好。整个过程不复杂,但也有几个容易忽略的细节。

npm init -y npm install midscene --save

安装完成之后,需要在项目根目录配置环境变量,把视觉模型服务的 API Key、模型名称填进去。Midscene.js 的视觉理解底层依赖一个视觉语言模型服务,你可以用云端的,也可以自建,只要实现了对应的视觉接口协议就能接入。

初始化脚本的核心逻辑是:先创建 Agent 实例,再和设备驱动服务建立连接,然后启动目标 APP。这个 Agent 就是后续所有自然语言指令的执行入口。我的建议是把连接过程封装成一个工具函数,因为后面每个采集任务启动时都要复用,单独写干净一点,省得每个脚本都重复一大段连接逻辑。

const { createAgent } = require('midscene'); async function connectApp({ deviceName, platformVersion, appPackage }) { const agent = await createAgent({ type: 'mobile', driver: { host: '127.0.0.1', port: 4723, capability: { deviceName, platformVersion, appPackage } } }); return agent; }

2.3 验证闭环:让 Agent 把当前页面“说”出来

环境搭好后的第一件事,不是急着写抓取脚本,而是先做一次“页面理解”验证。我习惯让 Agent 描述当前页面内容,比如导航栏有哪些按钮、主体区域展示了什么、底部有没有筛选选项。这一步的目的是确认两件事:一是设备和驱动服务连接正常,二是视觉模型对当前 APP 页面的理解能力符合预期。

const agent = await connectApp({ deviceName: 'emulator-5554', platformVersion: '12', appPackage: 'com.example.app' }); await agent.aiAction('打开 APP 首页'); const description = await agent.aiQuery('描述当前页面的布局和主要内容,包括顶部导航、中间信息流和底部菜单'); console.log(description);

如果这段脚本能返回一段有条理的页面描述,说明整条链路已经通了。我第一次跑通时,模型准确说出了一个商品列表页的卡片数量、角标信息、排序按钮位置,当时我就知道这条路能走通。如果返回结果很混乱,优先检查截图是否清晰、驱动连接是否稳定,再检查模型 Prompt 是否问得太泛。

3. 用自然语言指令提取结构化页面数据

3.1 设计一条合格的数据提取指令

环境通了之后,全部工作量基本都集中在提示词设计上。这句话我没有夸张,Midscene.js 的能力上限确实很大程度上取决于你怎么把需求描述清楚。我踩过几次坑之后,总结出一个“字段 + 来源 + 格式”的三段式指令模板。

字段指的是你要提取什么,比如商品名称、价格、销量;来源指的是这些信息在页面哪个区域,比如“商品卡片区域”;格式指的是你想要的输出结构,比如 JSON 数组。合起来就是一句话:“提取商品卡片区域里的商品名称、价格、销量,返回 JSON 数组”。这样模型既知道看哪里,也知道吐什么格式。

反例我也见过不少。有人直接写“提取所有商品信息”,模型就会自由发挥,今天给你返回个数组,明天给你返回个对象,后天把广告文案也算进去了。指令越模糊,模型发挥空间越大,越不可控。写提取指令的时候,把自己当成在带一个看不见屏幕的新人,告诉他页面长什么样、你要什么、用什么格式给你。

3.2 让结果严格符合 JSON Schema

光靠语言描述格式还不够,更稳妥的做法是在指令里直接给一个 JSON Schema 样例。Midscene.js 支持你定义输出结构,模型会尽量按照这个结构返回。我会把每个采集任务的字段结构先在本地定好,然后再把结构片段贴进指令里。

const result = await agent.aiQuery(` 提取当前页面上所有商品卡片的信息,严格按以下 JSON 数组格式返回: [ { "name": "商品名称", "price": "价格数值,只保留数字", "sales": "销量文本,如 1万+", "promotion": "促销信息,没有则填 null" } ] 如果页面没有商品卡片,返回空数组。 `);

这里有两个细节值得注意。第一,“价格只保留数字”这种约束一定要写进去,否则模型可能直接返回“¥199元”这种带符号的脏数据,后期清洗要多写一堆正则。第二,“没有则填 null”和“返回空数组”这类兜底指令很关键,它能让程序在异常情况下依然拿到结构一致的数据,而不是抛一个解析错误。

页面有多个信息层级时,建议拆成多条指令分别提取,别妄想一条提示词搞定所有事。比如列表页先抓商品基本信息和跳转入口,进入详情页后再单独抓详情文案、参数表、评价摘要。分开的好处是单条指令上下文短,模型理解的准确率更高,排错也更方便,哪一步出问题直接定位到那一步。

3.3 批量抓取与去重:游标、分页、无限滚动

APP 列表页不像是 Web 页面那样有清晰的翻页按钮,很多是无限滚动或者“加载更多”。Midscene.js 处理这类场景的思路是:在指令里加入滑动动作,滑到底部之后再做一次提取,如此循环,直到出现“没有更多了”或者触底标记。

const allItems = []; let previousCount = -1; while (allItems.length > previousCount) { previousCount = allItems.length; await agent.aiAction('向下滑动半屏,等待列表加载完成'); const batch = await agent.aiQuery('提取当前屏幕中所有商品卡片的名称和价格,返回 JSON 数组'); allItems.push(...batch); } const dedupeItems = [...new Map(allItems.map(item => [item.name, item])).values()];

这个“直到数量不再增长”的判断方式是我在实战中摸索出来的,比固定次数滑动更稳。因为不同网络环境下,页面加载速度不一样,固定滑动 10 次可能有的数据没加载出来,有的已经滑到了底部空转。用数量变化作为终止条件,逻辑上更符合真实场景:新增数量为 0 时,基本可以认定已经抓完。

但这里有一个隐蔽的问题:滑动过程中可能会有重复数据被反复提取,尤其是卡片在屏幕里残留的时候。我的做法是在内存里用名称作为唯一键做一次去重,跑完一批数据之后,再落一份带采集时间和页面来源的明细表,方便后续追溯。

4. 把页面杂数据变成知识库能用的干净语料

4.1 为什么原始采集结果不能直接进知识库

很多人以为 Midscene.js 返回了 JSON 就能直接灌进知识库,这是我在项目里见过的最大误解。实际上,从 APP 页面抽出来的数据,距离“能进知识库”至少还差着清洗、补全、归一化三道工序。

原始数据里最常见的几个问题,我用真实的观察结果来说:列表页的商品名称经常自带省略号;价格字段可能混在一起;同一个商家在不同卡片里的写法不完全一致;页面里夹杂着“广告”“为你推荐”这类非目标内容。如果把这些原样丢进向量库,检索的时候就会出现大量噪声,用户问一个精准问题,召回的全是半截信息。

另外,APP 页面数据是面向展示设计的,不是面向知识组织设计的。卡片文案只有一两句话,用户问“这个商品能不能用优惠券”的时候,知识库需要的是详情页里完整的规则说明,而不是卡片上那句“限时特惠”。所以清洗阶段必须考虑信息补全,把同一个实体的多个页面片段拼到一起。

4.2 拆条、补全、归一:LLM 参与的数据清洗

我把清洗流程拆成三步,每一步都有明确目标。第一步是拆条,把一条页面记录切分成可独立检索的知识片段。比如一条商品采集记录,最后我会把它拆成基本属性、价格信息、促销规则、详情描述、评价摘要这几个片段,每个片段都有独立的 ID 和类型标签。

第二步是补全。列表页抓到的是缩略信息,我会用详情页的采集结果去补全描述和参数。如果某些字段确实缺失,就用规则判断是空值还是模型漏提,区分开处理。这里强烈建议不要用“页面没有就乱填”的做法,null 就是 null,宁可检索不到也不要编造。

第三步是归一。品牌名、计量单位、日期格式都要统一。页面里“1万+”的销量和“10000”的销量,在向量检索里是两个完全不相关的表达,必须在入库前换算成同一个格式。归一化工作我大部分交给了 LLM 来做,因为它擅长理解别名的意思,但规范化的逻辑要写在提示词里,比如“把所有价格统一为数字类型,去掉货币符号”。

const cleanedItems = items.map(item => ({ id: item.name + '_' + item.price, title: item.name.trim(), content: buildKnowledgeContent(item), tags: classifyItem(item.name), sourcePage: item.page, collectedAt: new Date().toISOString() }));

4.3 向量化与入库:元数据是关键

清洗完的语料,还不能直接入库。知识库检索的效果,很大程度取决于向量化的分块策略和元数据设计。分块太大会导致检索召回的信息不精准,分块太小又丢失上下文,我的经验是每个知识片段控制在 300 到 500 字左右,按语义完整点切分,不要把一句话单独变成一个向量。

元数据是很多人容易忽略的部分。我在入库时会保留来源页面、业务类型、采集时间、所属分类,这些字段在检索阶段会发挥很大作用。举个例子,用户只想要“最近 30 天的新品”,如果没有时间元数据,你根本没法做时间过滤,只能靠向量相似度去猜。

async function vectorizeAndStore(cleanedItems, embedder, vectorStore) { for (const item of cleanedItems) { const embedding = await embedder.embed(item.title + '\n' + item.content); await vectorStore.upsert({ id: item.id, vector: embedding, metadata: { sourcePage: item.sourcePage, collectedAt: item.collectedAt, tags: item.tags } }); } }

嵌入模型的选择这里不多展开,核心原则是:你的语料是什么语言,就选擅长该语言理解的模型;知识库规模不大时,本地小模型和云端接口效果差距不会特别大,但成本差异明显,可以先跑通再优化成本。

5. 问答闭环:让知识库真正被用起来

5.1 最小检索问答链路

知识库入库只是开始,真正要让业务方满意,你得让知识库能“回答问题”。我采用的是一个很标准的检索增强生成链路:用户提问,先做向量检索召回相关片段,再把片段拼进上下文,交给语言模型生成回答。

async function answerQuestion(question, retriever, llm) { const queryVector = await embedder.embed(question); const docs = await retriever.search(queryVector, { topK: 5 }); const context = docs.map(d => d.content).join('\n\n'); const answer = await llm.complete(` 基于以下知识片段回答用户问题,回答要简洁、准确,不要编造片段里没有的信息。 知识片段: ${context} 问题:${question} `); return { answer, sources: docs }; }

这个链路第一版可以很朴素,不需要复杂的记忆和上下文管理。我的建议是先跑通、再优化。实际使用中发现,用户真正高频问的问题就集中在十几个类型上,把这些问题筛出来做测试集,每次调整检索参数时都跑一遍回归,能避免“这边修好了那边坏了”的坑。

5.2 带来源追溯的引用回答

内部知识库和闲聊机器人最大的区别是:用户要能核实答案来源。我会在问答返回结构里加上引用来源,把命中的知识片段标题和原始页面信息一并展示出来。这样做有三个好处:用户能确认答案不是模型编的;数据团队能根据用户反馈定位采集质量问题;产品方也更愿意在内部系统里放心使用。

引用信息在向量化阶段就要留好Trace,所以上一节强调的元数据设计在这里派上了用场。如果某条知识片段来源页面是点击率高的页面,它的可信度天然更高,检索排序时可以加一个小权重,这种细节对效果提升比调模型参数来得更快。

5.3 增量更新与缓存设计

知识库最怕的是“建成即过期”。APP 里的商品上下架、价格调整、活动规则更新,每天都在发生。我的做法是给每个知识片段建立“首次采集时间”和“最近验证时间”,配合定时任务做增量采集:每天跑一次列表页,逻辑比对字段是否有变化,变了才进详情页重新采集详情数据。

缓存策略也要跟上。用户反复问同一个问题,每次都做完整检索和模型生成,成本高且响应慢。我会把常见的“问题 + 检索结果 + 生成结果”缓存起来,命中缓存就直接返回,缓存过期时间根据业务数据更新频率来定,商品类数据一般设置一两个小时,活动类数据设成十分钟。

6. 移动端采集实战踩坑记录

6.1 设备和驱动服务的不稳定

移动端采集跑起来之后,第一个坑就是设备连接不稳定。模拟器还好,真机用久了会息屏、会断 USB、会被系统弹出充电提示,任意一个都可能让脚本挂在半路。我的防御方案是三层:脚本里加自动重连;任务调度层加失败重试;长时间无人值守时定时重启设备和驱动服务。

另一个常见问题是驱动服务的内存泄漏。跑了几个小时的采集任务,服务响应会越来越慢,最后直接超时。我现在会在每个批量任务结束后主动释放页面连接,而不是一直复用同一个会话。这个细节虽然简单,却能把长时间采集的稳定率提升一个明显档次。

6.2 页面改版对视觉指令的影响

Midscene.js 比传统选择器抗改版,但也不是完全不怕改版。小改动比如按钮文案、卡片间距,视觉模型基本不受影响;但如果页面布局大换血,比如商品卡片从两列变成三列、信息流模块全部重排,原有指令的提取准确率就会断崖式下跌。

我这边养成了一个习惯:每次 APP 发版后,先跑一遍核心采集脚本,把返回结果和昨天的做一次字段级比对,多一个少一个字段都能及时发现。别等到知识库内容越来越旧才注意到采集已经失效。视觉驱动的优势是修改成本低,布局变了,往往改一句指令描述就能恢复,但前提是你知道它变了。

6.3 数据校验与异常兜底

AI 提取不是 100% 准确的,这是所有同类型方案的共同现实。我的处理原则是“关键字段二次校验,非关键字段允许缺失”。价格、编号这类参与业务逻辑的字段,需要设计规则校验合法性,比如价格不能是负数、编号不能为空,发现异常字段时重新发起一次提取。

我还会保留原始数据。每次提取的原始页面截图、模型返回的原始 JSON、清洗后的最终结果,都会按任务 ID 存下来。出了数据质量问题,直接回放当时的截图和返回就能定位是模型理解错了,还是清洗逻辑错了,不用瞎猜。这个习惯在项目上线初期特别重要,数据团队能把问题定位到具体环节,而不是笼统一句“知识库不准”。

6.4 模型调用成本怎么降

视觉模型按截图调用计费,一个页面反复滑动抓取,成本累积非常快。我后来做了三件事把成本压下来了:一是页面结构没变时直接读缓存,不重复调用模型;二是列表页只做一次视觉理解,点击进详情页后的数据尽量用短指令提取;三是建设了页面指纹机制,截图内容一致就直接用上次的提取结果,只有指纹变化才重新调用模型。

针对数据量最大的详情页,还可以进一步优化:列表页已经能拿到大部分基本字段,详情页只需要补全描述和参数,这两类信息的提取指令做得精简一些,一次调用返回尽量多的字段,比多次小调用更省。

这里整理了一张我踩坑过程中沉淀的问题对照表,方便你对照自查:

问题现象根因方向处理建议
脚本超时无响应设备连接或服务异常增加自动重连和任务重试
提取结果夹杂广告推荐指令未限定卡片区域在指令里明确“排除广告区域”
列表只抓到前几屏滑动终止条件太早用数量不再增长作为终止条件
字段有时有有时没有指令未给兜底和空值定义强制 JSON Schema,空值填 null
价格带符号带小数位指令未限定格式指令里声明“只保留数字”
成本超出预算重复调用同一页面引入截图指纹缓存,命中直接复用

最后再聊一点我的个人体会。跑通这套方案之后,你会发现真正的瓶颈早已不是“能不能抓到数据”,而是“抓到的数据里有多少能放心用”。提前把数据校验、来源追溯、更新策略设计好,比无限优化抓取速度更重要。Midscene.js 最有价值的地方,不是代替你点了几下屏幕,而是把页面理解这个原本需要人工干预最多的环节,变成了可以快速迭代、随时重跑的数据管道组件。你现在花一下午搭出来的这套链路,后续扩展一个新采集任务,通常只需要半小时就够了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询