做数据采集和知识库项目的朋友,应该都经历过这种循环:需求说起来特别简单,就是把APP页面上的数据抓下来喂给AI做问答,可真动手的时候,光是“把页面数据拿到手”这一步就能磨掉半条命。手工采集效率太低,Appium脚本写起来痛苦且测试环境一换就崩,抓包方案遇到加密参数直接劝退。我前段时间正好用 Midscene.js 把这条链路完整跑通了,从APP页面数据采集、数据清洗,再到构建AI知识库,整个过程从痛苦的“反复修脚本”变成了“用自然语言告诉AI要什么,它去页面上自己拿”。这篇就是我落地这套方案的完整记录,包含连接方式、代码骨架、清洗策略和知识库搭建细节,适合正在做APP数据采集、RAG知识库、AI问答系统的朋友参考。
1. 方案整体拆解:Midscene.js为什么适合APP页面采集
1.1 从“定位元素”到“描述意图”的转变
我最早做UI自动化用的是传统selector方案,每个按钮都得写id、class、xpath。这套东西在网页管理后台勉强能用,一碰到APP页面就难受:页面改版频繁、WebView里的元素结构变化无常,写好的定位器隔两天就失效,脚本维护成本比采集本身还高。
Midscene.js 的思路完全不一样。它把“操作界面”这件事交给了大模型:执行时先截一张当前页面的截图,模型通过视觉理解页面结构和内容,找到目标元素,再模拟点击或输入;动作完成之后又截一张新图,继续判断下一步动作。整个过程像“一个看着屏幕干活的人”,它不依赖DOM结构,而是依赖“眼睛”看到的东西。
这个转变对APP页面采集意义很大。很多APP的页面是H5或WebView,元素结构经常变,但视觉语义是稳定的。比如“商品卡片”“搜索框”“筛选按钮”这些概念,用户看得懂,模型通过截图也看得懂,页面样式再怎么换,只要语义不变,脚本就能继续跑。实测下来,页面改版导致采集失效的情况少了很多,这对长期维护来说太重要了。
1.2 动态页面和WebView场景下的优势
第二个让Midscene.js省心的点,是它天然适配动态渲染和懒加载。APP页面多半要等网络请求、图片加载、滚动触底加载,传统脚本经常在元素还没出现时就去拿数据,拿回来一大片空值。Midscene.js每次判断都基于最新截图,页面没加载完毕它会继续等,看到内容了才执行下一步,对列表页、详情页和滚动加载场景明显更稳。
数据提取也是它的一大亮点。普通自动化从DOM里拿数据需要逐字段去定位,Midscene.js可以直接让模型输出JSON。比如页面是一堆商品卡片,我只需要写“提取当前页面所有商品的名称、价格、销量,输出为JSON数组”,模型会把非结构化页面文字整理成结构化数据返回。虽然这个结果不能直接用,还得清洗,但复杂度已经降了一个量级。
1.3 知识库构建的技术选型思考
数据拿回来之后,紧接着的问题是“放进什么知识库”。热词里有个很典型的问题:“KG知识库、RAG知识库和结构化知识库怎么选”。以APP页面数据为例,情况通常是这样的:固定字段像商品标题、价格、编码,适合放结构化库;长文本像详情描述、用户评论,适合走RAG向量检索;实体关系很重的场景,比如知识图谱类的百科,才需要上KG。
APP页面采集出来的内容,绝大多数是自然语言加固定字段的混合体,用户真实诉求是“随便问,系统能答”。所以我最终选了RAG为主、结构化字段为辅的混合方案。这个选择背后的逻辑很简单:不需要精确的实体关系推理,又要求能回答开放问题,RAG的性价比最高。后文会详细讲怎么落地。
2. 环境搭建:连接APP页面的三种路径
2.1 安装依赖与基础初始化
Midscene.js本身是一个Node.js库,装起来很轻。我在工作机上跑通只需要两个依赖:
npm init -y npm install @midscene/web playwright装完依赖之后,需要确认你的大模型接口。Midscene.js把自然语言指令翻译成视觉操作和页面描述,这步由大模型完成,而且必须是有视觉能力的模型,因为它的判断依据是截图。OpenAI系列、Claude,或者本地部署的视觉模型都可以。如果你在意的数据不能出内网,在本地部署一个兼容OpenAI协议的视觉模型,也是个可行选择,代码里指一下baseUrl就行,用法完全一样。
配置层面对我来说最省心的是:只要把模型相关配置放进环境变量或连接参数,代码主体就不用改。这样我可以在开发环境用能力强的模型调脚本,在批量采集时切到成本低的模型,切换成本几乎为零。
2.2 连接APP页面的三种方式
Midscene.js本身是浏览器侧的自动化,要“摸到”APP页面,核心思路是借助Chrome DevTools协议做桥接。我实际跑过的场景有三类:
| 场景 | 连接路径 | 前提条件 |
|---|---|---|
| Android系统浏览器访问H5页面 | adb端口转发 + CDP | 手机开启USB调试 |
| APP内WebView内嵌页面 | WebView远程调试 + 端口映射 | WebView开启调试开关 |
| 桌面端/模拟器访问网页 | Playwright连接Chrome调试端口 | 以调试模式启动Chromium |
第一种最常用,也最容易上手。手机通过USB连电脑,开启开发者选项里的USB调试,然后执行:
adb devices adb forward tcp:9222 localabstract:chrome_devtools_remote端口转发成功后,Chrome的DevTools协议会暴露在电脑的9222端口。打开浏览器访问chrome://inspect,等一会儿就能看到待调试页面,页面会生成一个ws://格式的地址,把这个地址传给Midscene.js的connectTo,就可以开始操作了。
第二种是APP内WebView,原理一样,只是DevTools的abstract名称不同,比如webview_devtools_remote_xxx。这里有个关键提醒:线上发布的正式版APP通常会把WebView调试关掉,拿不到连接端点,最好请开发打一个debug包或测试包,否则这条路走不通。采集APP数据之前先确认这一点,能省掉大量排查时间。
2.3 建立连接的代码骨架
连接代码很简单,核心就几行:
import { connectTo } from '@midscene/web'; const bridge = await connectTo('ws://127.0.0.1:9222/devtools/page/xxxx', { llm: { model: 'gpt-4o', apiKey: process.env.OPENAI_API_KEY, }, }); const page = bridge.page; const agent = bridge.agent;连接成功以后,agent就是和页面交互的入口,所有自然语言指令都通过它执行。我自己的经验是:一开始别急着上复杂逻辑,先连上页面,执行一个简单的点击指令,确认截图反馈正常,再开始写采集流程。环境通了,后面的事情都是顺势而为。如果你暂时没有连接APP的环境,也可以先用Playwright启动一个本地Chromium页面来验证,很多技巧是通用的。
3. 实操:从APP页面提取数据并清洗入库
3.1 第一个采集脚本:截图、理解、输出JSON
连接建立之后,采集的核心就两步:动作和提取。
await agent.aiTap('点击商品列表的第一个商品'); const itemDetail = await agent.aiQuery(`提取当前页面的以下字段: - 商品名称 - 价格 - 已售数量 - 商品描述 请以JSON对象形式返回`);aiTap负责动作,aiQuery负责读取。Midscene.js的机制是:先截一张图,把截图和指令一起发给大模型,模型定位目标后返回动作坐标,Midscene.js执行操作;操作完成后再截图,继续判断,直到动作结束。aiQuery也是同理,模型看到截图后,把页面内容整理成指定JSON返回。
第一次跑通之后,你大概率会发现返回结果和想象不太一样。字段名可能带中文,价格可能是字符串“¥ 299.00”,也可能漏掉字段。这完全正常,模型不是数据库,它是在“阅读”页面后按理解输出,所以下一步必须做格式化。我这里把常用的指令整理成了一个速查表,方便随时翻阅:
| 指令 | 作用 | 适用场景 |
|---|---|---|
aiTap | 点击目标元素 | 按钮、菜单、商品卡片 |
aiQuery | 提取结构化数据 | 列表、详情页 |
aiScrollUntilVisible | 滚动直到条件满足 | 无限列表、加载更多 |
aiInput | 在输入框输入文本 | 搜索框、表单 |
3.2 清洗与格式化:把模型输出变成可靠数据
我的做法是准备一个清洗函数,把模型返回的内容统一转换成标准结构:
function normalizeItems(list) { return list .filter((item) => item && (item.name || item['商品名'])) .map((item) => ({ name: String(item.name || item['商品名'] || '').trim(), price: Number.parseFloat(item.price || item['价格']) || 0, sales: Number.parseInt(item.sales || item['销量'], 10) || 0, description: String(item.description || item['商品描述'] || '').trim(), })) .filter((item) => item.name.length > 0); }这里有个关键原则我必须强调:不要指望在aiQuery那一步就把字段名、类型、去重全部搞定。模型输出天然不稳定,同样的指令跑十次可能有八次正确、两次乱掉。所以我的分工很明确——模型负责“从页面里找出信息”,代码负责“把信息转换成统一格式”。这条边界划清楚,整个流水线才稳。如果你让AI顺手做清洗,等于把两个不确定环节叠在一起,结果很难收敛,线上排错会变成一场灾难。
3.3 处理滚动加载、弹窗与“查看更多”
APP列表页几乎都是无限滚动,采集不能只执行一次。我常用的完整采集片段是这样的:
await agent.aiTap('关闭非必要弹窗'); await agent.aiScrollUntilVisible('页面底部出现"没有更多了"'); const pageData = await agent.aiQuery('提取当前页面所有商品信息,输出JSON数组');经验之谈:滚动目标最好描述成“出现某个结束标志”,而不是“滚动到底”。移动端页面底部经常被悬浮按钮、推荐内容或者空白占位符干扰,“滚动到底”这个描述太模糊,模型容易判断失误。弹窗处理同理,写一句“关闭非必要弹窗”就够了,模型看到弹窗就点关闭,没有就跳过,不会出错。
滚动后采集的数据和之前的部分很可能重叠。去重不要只按标题去,尽量找唯一ID字段;如果页面没有ID,就把title+price+description拼起来算一个哈希字段再对撞,这样能去掉重复又不误杀同类商品。实际跑下来,这种去重方式最稳。
3.4 把采集任务组织成可重跑的流水线
做一个可复用的采集流程,比写一次性脚本更有价值。我会把页面列表放进循环,分段采集、分段清洗、分段落库:
async function runCollection(bridge, urls) { const results = []; for (const url of urls) { await bridge.page.goto(url); await agent.aiScrollUntilVisible('页面底部出现"没有更多了"'); const chunk = await agent.aiQuery('提取当前页面商品列表,输出JSON数组'); results.push(...normalizeItems(chunk)); console.log(`已完成 ${url}, 累计 ${results.length} 条`); } return results; }生产环境我还会加两个东西:断点续跑和任务队列。某个页面突然出现异常,或者网络请求失败导致截图全是空白,脚本失败之后如果从头再跑一遍,会很浪费大模型调用。所以任务入库时按URL分片,失败重试三次后标记为未完成,下次启动只跑剩余部分。账单会好看很多,维护心态也会好很多。
4. 构建AI知识库:切分、向量化与RAG问答
4.1 什么样的数据适合进RAG知识库
采集回来的数据通常要分分类再决定去向。固定字段比如标题、价格、编码,适合放传统数据库,做精确查询和条件过滤;长文本比如描述、评论、活动规则,适合走RAG,做语义检索。
但多数业务场景里没人会二选一,最佳实践是混用:向量库负责语义理解,结构化字段作为过滤条件。举个例子,假设用户问“500块以内的降噪耳机推荐”,系统可以先用价格字段过滤出候选,再做向量检索“降噪”和“推荐”相关语义,这样既有速度又有精度。这个组合在业界已经很成熟,直接抄就好。
这里也回应一下很多人纠结“KG知识库、RAG知识库和结构化知识库怎么选”:如果目的是开放问答,选RAG;如果需要实体关系推理,比如“A公司旗下有哪些品牌的B品类产品”,考虑KG;如果只是查表值,用结构化库。APP页面数据通常不是KG的重场景,别为了技术栈好看硬上图谱。
4.2 切分、向量化与入库
数据清洗完成后,下一步是切分和向量化。我用LangChain做这一步,向量库存Chroma,本地就能跑,调试方便:
from langchain.text_splitter import RecursiveCharacterTextSplitter from langchain_community.embeddings import OpenAIEmbeddings from langchain_community.vectorstores import Chroma from langchain_core.documents import Document splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=50) docs = [ Document(page_content=item["description"], metadata={"title": item["name"]}) for item in normalized_items if item["description"] ] chunks = splitter.split_documents(docs) vector_store = Chroma.from_documents(chunks, OpenAIEmbeddings(model="text-embedding-3-small"))切分参数我用了500字符加50字符重叠,这是跑过几个项目后觉得比较稳的组合。切太长,向量检索会把整段话当成一个意思,细粒度信息丢失;切太短,上下文断裂,大模型回答时缺背景,容易答非所问。500字符配合50字符重叠,能保住段落边界和语义连续性,对中文长文本为主的APP数据很合适。当然这个参数不是死的,如果数据源是短文案,可以调小到300,如果整页都是长文章,可以调到800,关键是观察检索命中质量。
4.3 用Dify这类平台快速搭出问答入口
如果你的目标是先快速给业务方一个能用的问答页面,并不需要从零搭检索后端。热词里频繁出现“dify知识库流水线”,确实是个捷径。Dify这类开源知识库平台把“上传文档、切分、向量化、检索、对话应用”串成了一条可视化流水线。
我用它时的步骤很简单:先把我清洗好的结构化数据转成Markdown或JSON文件,批量传到知识库里;然后选一个Embedding模型,建一个向量索引;再创建一个对话应用,把知识库挂上去;最后配一段问答提示词,一个能回答APP相关问题的机器人就上线了。整个过程一小时以内能跑通,特别适合前期验证业务效果。如果后续有自研需求,再把这套逻辑拆成独立API也不迟。
4.4 提示词模板与幻觉控制
知识库底层搭好之后,回答质量还受提示词影响。我一直用的核心模板长这样:
你是该APP的用户助手。请根据下面的资料回答用户问题。 资料中没提到的事情,直接说“当前资料中没有覆盖”,不要编造。 资料: {context} 用户问题: {question}别看模板简单,“没覆盖就直说”这句是关键。RAG虽然能大幅降低幻觉,但大模型被用户问题引导时,还是会忍不住“脑补”。把这条限制写死到系统提示里,能拦住相当一部分错误回答。实测中,加了这句之后,用户抱怨“AI胡说”的数量明显下降,强烈建议保留。想再稳一点,还可以把“只允许使用资料中出现的事实”加进去,并把温度调到0,让输出更保守。
5. 常见问题与避坑指南
5.1 页面元素识别不准、点击错位置
遇到aiTap点错,我第一反应是检查指令描述是不是太笼统。不要写“点击商品”,要写“点击页面左上角第一个商品卡片,白色底,圆角,标题是XX”。模型对视觉特征敏感,描述越具体,命中率越高。另一个常见问题是页面上存在多个相似目标,比如列表页里每个商品卡片都有“加入购物车”按钮,此时直接点会乱套。我的办法是先缩小范围,比如“进入商品详情页后再点击加入购物车”,把动作拆成两步,成功率会高很多。
5.2 抽取结果漏字段、格式不稳定
aiQuery最常见的坑是字段漏掉。建议把查询字段列成清单,并明确要求缺失时填null:
await agent.aiQuery(`提取以下字段,缺失则填null: - 商品名称(name) - 价格(price) - 销量(sales) - 品牌(brand) 只返回一个JSON对象。`);这样模型至少会保证每个key都出现,后续清洗逻辑不会因为字段缺失而报错。另外,一次查询里字段太多模型容易看漏,超过8个字段建议拆成两次查询,中间加一次轻量滚动,让模型看到不同区域的内容,再合并结果。合并时同样用哈希字段去重,别嫌麻烦。
5.3 环境连接不上、CDP地址拿不到
连接不上时,按这个顺序排查:手机有没有开USB调试、adb devices能不能看到设备、端口转发有没有执行成功、chrome://inspect里能不能看到目标页。如果是APP里的WebView,注意localabstract名称不是固定的,要在adb shell里查当前有哪些abstract可用,别想当然用网上抄来的固定别名。另外一个容易被忽视的地方是代理环境会干扰CDP连接,排查时先把这些干扰项关掉,再逐步开启。
注意:生产采集前一定要确认WebView调试开关是否打开,以及连接设备的稳定性。USB线接触不良、Android系统弹窗都会导致会话中断,建议开启ADB的keep-alive或定时重连机制。
5.4 成本与性能优化
大模型调用费用是这套方案的大头,做批量采集时尤其明显。我的做法有几个:数据量大时先用低成本模型做初采,再用强模型抽查校验;每个采集任务加上缓存键,同一页面短时间内的重复任务直接读缓存;调度上把页面级操作串行,避免并发过多导致截图和动作互相干扰。按我之前某个项目的账单看,这样优化下来成本能压缩到原来的四成左右,效果很可观。
还有一个容易被忽略的点:截图分辨率会影响模型识别准确率,也会影响token消耗。太小的截图看不清细节,太大的截图浪费token。默认参数通常已经平衡得不错,但如果你的页面信息密度特别高,可以适当放大截图再裁剪局部区域做识别,效果比直接处理整页好。
5.5 一个必须记住的架构原则
最后分享一个我踩过很多坑之后总结出来的架构原则:AI驱动UI操作适合做“入口”,不适合做“仓库”。页面理解交给AI,数据清洗用确定性代码,知识库构建用成熟的RAG框架,每个环节只用AI做它最擅长的事,系统出问题的概率才会小。这套组合我已经跑了两个多月,整体稳定,日常维护成本主要集中在大模型偶尔的识别波动上,而不是数据逻辑本身。根据我的实际体会,如果你想复制这套方案,最重要的一件事就是先拿几十条数据把链路跑通,再考虑铺量。别看Midscene.js写起来像聊天一样轻松,模型调用成本、异常重试、去重逻辑这些都要提前设计好。APP页面变化快,数据采集不能写完一次就丢在那里,定时任务、断点续跑、结果告警都要跟上。希望这篇记录能帮你少走一些弯路,也欢迎在实践中多试试不同的模型和切分参数,找到最适合你业务的那组配置。