|
|
APP控件树结构化控件信息爬取/APP内容结构化采集
- /**
- * 案例23:APP控件树结构化控件信息爬取/APP内容结构化采集
- *
- *经典实用案例"控件信息爬取/APP内容结构化采集",转换成 AIWROK 实现:
- * auto.nodeSearch(超时毫秒) 拿当前屏全部控件 → 逐节点取 文本/类名/中心坐标/可点击
- * → 结构化打印 → 关键词命中打 ⚠ → SQLite 入库(一个控件一行,ui_records 表)
- * → 每轮自动切页(设置/浏览器循环),采集结果跟着页面一轮一变。
- *
- * 数据源故事线(15→23 一路升级):
- * 15-18 API → 19 设备状态 → 20 屏幕像素 → 21 通知流 → 22 剪贴板 →
- * 23 **无障碍控件树**——APP 界面对系统"摊开"的结构化数据。
- * 和案例20 是本案例的灵魂对照:同样是"读别人的APP",
- * OCR 看的是**像素**(无坐标无类型,识别还耗 4~5 秒),
- * 控件树拿的是**结构化数据**(文本+精确坐标+类名+可点击属性,毫秒级返回)——
- * 铁律:有控件树可用时,永远优先控件树,OCR 只留给"读不出控件"的画面(游戏/视频/WebView壳)。
- *
- * 新知识点:
- * 1. auto.nodeSearch(1000) 返回 nodes 集合:.count() 数量、.getNode(i) 逐个拿 node、
- * .getTexts() 一行代码拿全部文字(快速通道,和逐个遍历对比打印);
- * 2. node 结构化属性:getText()文本 / getClassName()类名 / getBounds()→rect.getCenterX/Y()中心坐标 /
- * isClickable()可点击——这就是"自动化脚本知道该点哪"的底层原理;
- * 3. **无障碍服务权限**:控件树靠系统无障碍服务拿,没授权 nodeSearch 直接空(启动自检会提示);
- * 4. 入库粒度对照:案例20 一屏存一行大文本,本案例**一个控件一行**——
- * 结构化数据源才能这样建表,后续 SQL 按坐标/类名/关键词随便查。
- *
- * 踩坑预告(实测过的老坑,详见对应函数注释):
- * - node.getText() 可能返回 null(容器节点没文字),不判空直接 ""+null 变 "null" 脏数据;
- * - nodes 的过滤方法(textIncludes/clickable)会改集合本身,想留原始集合先 .copy();
- * - 精简模式(setSimpleModel)会隐藏"不重要"节点导致数量少,详细模式(setDetailedModel)数量全但慢。
- */
- // ========== 配置区(改这里) ==========
- var 识别关键词列表 = ["支付", "转账", "验证码", "密码"]; // 控件文字命中任一词打 ⚠
- var 采集间隔秒 = 15; // 每 15 秒切页采集一轮(控件树毫秒级,节奏全留给页面渲染)
- var 开页等待秒 = 4; // openApp 后等页面渲染完再采集(和案例20 同款)
- var 每页入库上限条数 = 40; // 单轮最多入库多少个文字控件(防设置这种长页面刷太多行)
- var 打印最大条数 = 15; // 单轮结构化打印前 N 条控件(防日志刷屏)
- // 演示页面:只用案例20 已验证 openApp 能切过去的两个(文件管理器 Intent 方案待复测,先不掺进来)
- var 演示页面列表 = [
- { 名称: "设置", 包名: "com.android.settings" },
- { 名称: "浏览器(百度首页)", 包名: "com.android.browser" }
- ];
- var SQLite路径 = "/sdcard/控件采集.db";
- var 表名 = "ui_records";
- // ========== 全局变量顶部声明 ==========
- var gStop = false;
- var 数据库连接 = null;
- var 数据库可用 = false;
- var 累计入库条数 = 0;
- var 累计采集轮数 = 0;
- var 上轮全部文字 = ""; // 画面去重基准(和案例20 同一思想)
- // ========== importClass 只能放最外层 ==========
- importClass(android.database.sqlite.SQLiteDatabase);
- importClass(java.io.File);
- // ========== 工具函数(先定义后调用) ==========
- // 连接 SQLite(零配置;库文件不存在会自动创建)
- function dbConnect() {
- try {
- 数据库连接 = SQLiteDatabase.openOrCreateDatabase(new File(SQLite路径), null);
- 数据库可用 = true;
- printl("[DB] ✅ SQLite 已打开: " + SQLite路径);
- } catch (e) {
- 数据库可用 = false;
- printl("[DB] ⚠ 打开失败(只打印不入库): " + e);
- }
- }
- // 建表(存在则跳过):一个控件一行——结构化数据源才配这么建表(对照案例20 一屏一行)
- function dbEnsureTable() {
- if (!数据库可用) return;
- try {
- var sql = "CREATE TABLE IF NOT EXISTS " + 表名 + " (" +
- "id INTEGER PRIMARY KEY AUTOINCREMENT," +
- "page TEXT, text TEXT, class_name TEXT, cx REAL, cy REAL, clickable INTEGER, collected_at INTEGER)";
- 数据库连接.execSQL(sql);
- printl("[DB] ✅ 表已就绪: " + 表名);
- } catch (e) {
- printl("[DB] ⚠ 建表失败: " + e);
- 数据库可用 = false;
- }
- }
- // 单个控件入库(clickable=1 表示该控件可点击)
- function dbInsertNode(页面名, 文本, 类名, cx, cy, clickable) {
- if (!数据库可用) return false;
- try {
- var stmt = 数据库连接.compileStatement("INSERT INTO " + 表名 +
- " (page, text, class_name, cx, cy, clickable, collected_at) VALUES (?,?,?,?,?,?,?)");
- stmt.bindString(1, "" + 页面名);
- stmt.bindString(2, "" + 文本);
- stmt.bindString(3, "" + 类名);
- stmt.bindDouble(4, cx);
- stmt.bindDouble(5, cy);
- stmt.bindLong(6, clickable);
- stmt.bindLong(7, time.nowStamp()); // nowStamp=毫秒数字;time.now()是格式化字符串不能bindLong
- stmt.executeInsert();
- stmt.close();
- return true;
- } catch (e) {
- printl("[DB] ❌ 入库失败: " + e);
- return false;
- }
- }
- function dbClose() {
- if (数据库可用 && 数据库连接 != null) {
- try { 数据库连接.close(); } catch (e) {}
- 数据库连接 = null;
- }
- }
- // 打开演示页面(openApp + 校验前台包名,不对先杀后开重试一次;案例20 验证过的套路)
- function 启动页面(页面, 尝试) {
- if (尝试 >= 2) {
- try { app.closeApp(页面.包名); sleep.second(秒 = 1); } catch (eClose) { printl("⚠ closeApp 异常(忽略): " + eClose); }
- }
- app.openApp(页面.包名);
- }
- // 采集当前屏控件树 → { 控件数组:[{文本,类名,中心x,中心y,可点击}], 节点总数, 可点击数, 全部文字 }
- // 失败/无权限返回 null。踩坑实录:getText() 可能 null(容器节点),必须判空,否则脏数据"null"入库;
- // 过滤方法(clickable/textIncludes)会改集合本身,这里不链式过滤,统一逐个 getNode 读属性,最稳
- function 采集控件树() {
- var 结果 = { 控件数组: [], 节点总数: 0, 可点击数: 0, 全部文字: "" };
- try {
- var 集合 = auto.nodeSearch(1000); // 超时1秒:等控件树刷新稳定
- if (集合 == null) {
- printl("[控件] ❌ nodeSearch 返回 null(大概率是无障碍服务没授权)");
- return null;
- }
- 结果.节点总数 = 集合.count();
- if (结果.节点总数 <= 0) {
- printl("[控件] ❌ 控件树是空的——去 系统设置→无障碍(Accessibility) 给本APP开服务权限再重跑!");
- return null;
- }
- for (var i = 0; i < 结果.节点总数; i++) {
- try {
- var n = 集合.getNode(i);
- var 文本原始 = n.getText();
- var 文本 = (文本原始 == null) ? "" : ("" + 文本原始); // 判空防"null"脏数据
- var 可点击 = false;
- try { 可点击 = n.isClickable(); } catch (eC) {}
- if (可点击) 结果.可点击数++;
- if (文本.length === 0) continue; // 容器节点没文字,不进结构化数组
- var cx = 0;
- var cy = 0;
- try {
- var r = n.getBounds();
- cx = r.getCenterX();
- cy = r.getCenterY();
- } catch (eR) {}
- var 类名原始 = n.getClassName();
- var 类名 = (类名原始 == null) ? "" : ("" + 类名原始);
- 结果.控件数组.push({ 文本: 文本, 类名: 类名, cx: cx, cy: cy, 可点击: 可点击 });
- 结果.全部文字 = 结果.全部文字 + 文本;
- } catch (eNode) {
- // 单节点读失败不拖垮整轮(节点可能已失效)
- }
- }
- // 快速通道对照:getTexts() 一行拿全部文字数组(教学点:遍历慢但有完整属性,快通道只有文字)
- try {
- var 文字数组 = 集合.getTexts();
- printl("[控件] 快速通道 getTexts() 返回 " + 文字数组.length + " 条文字(遍历法拿到 " + 结果.控件数组.length + " 个带属性控件,对照看)");
- } catch (eT) {
- printl("[控件] ⚠ getTexts 异常(不影响主流程): " + eT);
- }
- return 结果;
- } catch (e) {
- printl("[控件] ❌ 采集异常: " + e);
- return null;
- }
- }
- // 关键词检查:遍历控件文本,命中返回 {词, 文本},否则 null
- function 查找命中关键词(控件数组) {
- for (var i = 0; i < 控件数组.length; i++) {
- for (var k = 0; k < 识别关键词列表.length; k++) {
- if (控件数组[i].文本.indexOf(识别关键词列表[k]) >= 0) {
- return { 词: 识别关键词列表[k], 文本: 控件数组[i].文本 };
- }
- }
- }
- return null;
- }
- // ========== 主流程 ==========
- printl("===== 案例23 APP控件树结构化采集入库 启动 =====");
- dbConnect();
- dbEnsureTable();
- printl("⚙ 配置:每 " + 采集间隔秒 + " 秒切页采集 · 监控词(" + 识别关键词列表.join("/") + ") · 单页入库上限 " + 每页入库上限条数 + " 条 · 库: " + SQLite路径);
- printl("ℹ 第6种数据源:无障碍控件树——APP 界面**摊开**给系统的结构化数据,文本+坐标+类名+可点击全都有");
- printl("ℹ 灵魂对照案例20:OCR 看像素(4~5秒,无坐标无类型),控件树直读(毫秒级,属性齐全)——有控件树永远优先控件树");
- printl("🔑 前提:系统设置→无障碍(Accessibility) 必须给本APP开服务权限,否则 nodeSearch 是空的(见自检)");
- printl("📱 启动时前台包名: " + app.getTopPackName());
- // 停止按钮清理(顺序:先置标志,stopAllRuning 放最后)
- event.onStopBtnEvent(function () {
- gStop = true;
- dbClose();
- runTime.stopAllRuning();
- });
- // 步骤1:定时循环切页采集
- var 轮次 = 0;
- var 页面索引 = 0;
- while (!gStop) {
- 轮次++;
- 累计采集轮数++;
- var 页面 = 演示页面列表[页面索引 % 演示页面列表.length];
- 页面索引++;
- printl("\n---- 第 " + 轮次 + " 轮:切换页面【" + 页面.名称 + "】→ 控件树采集 ----");
- // ① 开页+校验(和案例20 同款:不对就重试一次,失败也照常采当前屏)
- var 打开成功 = false;
- for (var 尝试 = 1; 尝试 <= 2 && !打开成功 && !gStop; 尝试++) {
- try {
- 启动页面(页面, 尝试);
- printl("🚪 第" + 尝试 + "次打开【" + 页面.名称 + "】(" + 页面.包名 + (尝试 >= 2 ? ",先关后开冷启动" : "") + "),等待 " + 开页等待秒 + " 秒渲染...");
- sleep.second(秒 = 开页等待秒);
- var 前台 = "" + app.getTopPackName();
- if (前台.indexOf(页面.包名) >= 0) {
- 打开成功 = true;
- printl("📱 校验通过:前台已是 " + 前台);
- } else {
- printl("⚠ 页面没切过去(前台是 " + 前台 + ")" + (尝试 < 2 ? ",重试一次..." : ",照常采当前屏"));
- }
- } catch (e) {
- printl("🚪 openApp 异常: " + e + ",照常采当前屏");
- break;
- }
- }
- // ② 采集控件树
- var 开始 = time.nowStamp();
- var 采集 = 采集控件树();
- if (采集 == null) {
- printl("⚠ 本轮采集失败(权限/异常),跳过入库");
- } else {
- var 耗时 = time.nowStamp() - 开始;
- printl("🌳 控件树到手:节点 " + 采集.节点总数 + " 个 | 带文字控件 " + 采集.控件数组.length +
- " 个 | 可点击 " + 采集.可点击数 + " 个 | 耗时 " + 耗时 + " 毫秒(对照案例20 OCR 的 4~5 秒)");
- // ③ 结构化打印前 N 条(演示重点:每条都是"文本@坐标[类名,可否点击]",像素OCR给不了这些)
- var 打印数 = 采集.控件数组.length < 打印最大条数 ? 采集.控件数组.length : 打印最大条数;
- if (打印数 > 0) {
- printl("📋 本轮控件明细(前 " + 打印数 + " 条):");
- for (var p = 0; p < 打印数; p++) {
- var c = 采集.控件数组[p];
- printl(" │ [" + p + "] " + c.文本 + " @" + Math.round(c.cx) + "," + Math.round(c.cy) +
- " [" + c.类名 + (c.可点击 ? ",可点击" : "") + "]");
- }
- if (采集.控件数组.length > 打印数) {
- printl(" │ ...(共 " + 采集.控件数组.length + " 条,其余只入库不打印)");
- }
- } else {
- printl("ℹ 本轮没有带文字的控件(纯图形界面?这正是该换 OCR 的场景——案例20)");
- }
- // ④ 关键词命中(结构化优势:能精确到是哪个控件命中)
- var 命中 = 查找命中关键词(采集.控件数组);
- if (命中 != null) {
- printl("⚠ 报警:控件「" + 命中.文本 + "」命中关键词「" + 命中.词 + "」!");
- }
- // ⑤ 画面去重:全部文字和上一轮相同=页面没变,不重复入库(案例20/22 同款思想)
- if (采集.控件数组.length > 0 && 采集.全部文字 !== 上轮全部文字) {
- printl("💾 页面已变化,逐控件写入数据库(表: " + 表名 + ",上限 " + 每页入库上限条数 + " 条)...");
- var 本轮入库 = 0;
- for (var q = 0; q < 采集.控件数组.length && q < 每页入库上限条数; q++) {
- var item = 采集.控件数组[q];
- if (dbInsertNode(页面.名称, item.文本, item.类名, item.cx, item.cy, item.可点击 ? 1 : 0)) {
- 本轮入库++;
- }
- }
- 累计入库条数 = 累计入库条数 + 本轮入库;
- printl(" ✓ 本轮入库 " + 本轮入库 + " 条控件 → 累计 " + 累计入库条数 + " 条 | " + SQLite路径);
- 上轮全部文字 = 采集.全部文字;
- } else if (采集.控件数组.length > 0) {
- printl("ℹ 控件文字与上一轮相同(页面没变),本轮不重复入库");
- }
- }
- // ⑥ 分片等待下一轮:每 5 秒打一条进度(待机状态在日志可见,录像友好)
- printl("⏳ 本轮完成," + 采集间隔秒 + " 秒后切下一页采集");
- var 剩余 = 采集间隔秒;
- while (剩余 > 0 && !gStop) {
- var 步长 = 剩余 >= 5 ? 5 : 剩余;
- sleep.second(秒 = 步长);
- 剩余 = 剩余 - 步长;
- if (剩余 > 0) {
- printl("⏳ 等待下一轮...剩余 " + 剩余 + " 秒(累计采集 " + 累计采集轮数 + " 轮/入库 " + 累计入库条数 + " 条)");
- }
- }
- }
- dbClose();
- printl("===== 流程结束:共 " + 累计采集轮数 + " 轮采集,控件入库 " + 累计入库条数 + " 条 =====");
复制代码
|
|