找回密码
 立即注册
搜索
热搜: 活动 交友 discuz
查看: 16|回复: 1

APP控件树结构化控件信息爬取/APP内容结构化采集

[复制链接]
发表于 半小时前 | 显示全部楼层 |阅读模式
APP控件树结构化控件信息爬取/APP内容结构化采集

QQ截图20261029063339.png

QQ截图20261029063325.png

QQ截图20261029063314.png

  1. /**
  2. * 案例23:APP控件树结构化控件信息爬取/APP内容结构化采集
  3. *
  4. *经典实用案例"控件信息爬取/APP内容结构化采集",转换成 AIWROK 实现:
  5. *   auto.nodeSearch(超时毫秒) 拿当前屏全部控件 → 逐节点取 文本/类名/中心坐标/可点击
  6. *   → 结构化打印 → 关键词命中打 ⚠ → SQLite 入库(一个控件一行,ui_records 表)
  7. *   → 每轮自动切页(设置/浏览器循环),采集结果跟着页面一轮一变。
  8. *
  9. * 数据源故事线(15→23 一路升级):
  10. *   15-18 API → 19 设备状态 → 20 屏幕像素 → 21 通知流 → 22 剪贴板 →
  11. *   23 **无障碍控件树**——APP 界面对系统"摊开"的结构化数据。
  12. *   和案例20 是本案例的灵魂对照:同样是"读别人的APP",
  13. *   OCR 看的是**像素**(无坐标无类型,识别还耗 4~5 秒),
  14. *   控件树拿的是**结构化数据**(文本+精确坐标+类名+可点击属性,毫秒级返回)——
  15. *   铁律:有控件树可用时,永远优先控件树,OCR 只留给"读不出控件"的画面(游戏/视频/WebView壳)。
  16. *
  17. * 新知识点:
  18. * 1. auto.nodeSearch(1000) 返回 nodes 集合:.count() 数量、.getNode(i) 逐个拿 node、
  19. *    .getTexts() 一行代码拿全部文字(快速通道,和逐个遍历对比打印);
  20. * 2. node 结构化属性:getText()文本 / getClassName()类名 / getBounds()→rect.getCenterX/Y()中心坐标 /
  21. *    isClickable()可点击——这就是"自动化脚本知道该点哪"的底层原理;
  22. * 3. **无障碍服务权限**:控件树靠系统无障碍服务拿,没授权 nodeSearch 直接空(启动自检会提示);
  23. * 4. 入库粒度对照:案例20 一屏存一行大文本,本案例**一个控件一行**——
  24. *    结构化数据源才能这样建表,后续 SQL 按坐标/类名/关键词随便查。
  25. *
  26. * 踩坑预告(实测过的老坑,详见对应函数注释):
  27. * - node.getText() 可能返回 null(容器节点没文字),不判空直接 ""+null 变 "null" 脏数据;
  28. * - nodes 的过滤方法(textIncludes/clickable)会改集合本身,想留原始集合先 .copy();
  29. * - 精简模式(setSimpleModel)会隐藏"不重要"节点导致数量少,详细模式(setDetailedModel)数量全但慢。
  30. */

  31. // ========== 配置区(改这里) ==========
  32. var 识别关键词列表 = ["支付", "转账", "验证码", "密码"];   // 控件文字命中任一词打 ⚠
  33. var 采集间隔秒 = 15;             // 每 15 秒切页采集一轮(控件树毫秒级,节奏全留给页面渲染)
  34. var 开页等待秒 = 4;              // openApp 后等页面渲染完再采集(和案例20 同款)
  35. var 每页入库上限条数 = 40;       // 单轮最多入库多少个文字控件(防设置这种长页面刷太多行)
  36. var 打印最大条数 = 15;           // 单轮结构化打印前 N 条控件(防日志刷屏)
  37. // 演示页面:只用案例20 已验证 openApp 能切过去的两个(文件管理器 Intent 方案待复测,先不掺进来)
  38. var 演示页面列表 = [
  39.     { 名称: "设置",           包名: "com.android.settings" },
  40.     { 名称: "浏览器(百度首页)", 包名: "com.android.browser" }
  41. ];
  42. var SQLite路径 = "/sdcard/控件采集.db";
  43. var 表名 = "ui_records";

  44. // ========== 全局变量顶部声明 ==========
  45. var gStop = false;
  46. var 数据库连接 = null;
  47. var 数据库可用 = false;
  48. var 累计入库条数 = 0;
  49. var 累计采集轮数 = 0;
  50. var 上轮全部文字 = "";           // 画面去重基准(和案例20 同一思想)

  51. // ========== importClass 只能放最外层 ==========
  52. importClass(android.database.sqlite.SQLiteDatabase);
  53. importClass(java.io.File);

  54. // ========== 工具函数(先定义后调用) ==========

  55. // 连接 SQLite(零配置;库文件不存在会自动创建)
  56. function dbConnect() {
  57.     try {
  58.         数据库连接 = SQLiteDatabase.openOrCreateDatabase(new File(SQLite路径), null);
  59.         数据库可用 = true;
  60.         printl("[DB] ✅ SQLite 已打开: " + SQLite路径);
  61.     } catch (e) {
  62.         数据库可用 = false;
  63.         printl("[DB] ⚠ 打开失败(只打印不入库): " + e);
  64.     }
  65. }

  66. // 建表(存在则跳过):一个控件一行——结构化数据源才配这么建表(对照案例20 一屏一行)
  67. function dbEnsureTable() {
  68.     if (!数据库可用) return;
  69.     try {
  70.         var sql = "CREATE TABLE IF NOT EXISTS " + 表名 + " (" +
  71.             "id INTEGER PRIMARY KEY AUTOINCREMENT," +
  72.             "page TEXT, text TEXT, class_name TEXT, cx REAL, cy REAL, clickable INTEGER, collected_at INTEGER)";
  73.         数据库连接.execSQL(sql);
  74.         printl("[DB] ✅ 表已就绪: " + 表名);
  75.     } catch (e) {
  76.         printl("[DB] ⚠ 建表失败: " + e);
  77.         数据库可用 = false;
  78.     }
  79. }

  80. // 单个控件入库(clickable=1 表示该控件可点击)
  81. function dbInsertNode(页面名, 文本, 类名, cx, cy, clickable) {
  82.     if (!数据库可用) return false;
  83.     try {
  84.         var stmt = 数据库连接.compileStatement("INSERT INTO " + 表名 +
  85.             " (page, text, class_name, cx, cy, clickable, collected_at) VALUES (?,?,?,?,?,?,?)");
  86.         stmt.bindString(1, "" + 页面名);
  87.         stmt.bindString(2, "" + 文本);
  88.         stmt.bindString(3, "" + 类名);
  89.         stmt.bindDouble(4, cx);
  90.         stmt.bindDouble(5, cy);
  91.         stmt.bindLong(6, clickable);
  92.         stmt.bindLong(7, time.nowStamp());   // nowStamp=毫秒数字;time.now()是格式化字符串不能bindLong
  93.         stmt.executeInsert();
  94.         stmt.close();
  95.         return true;
  96.     } catch (e) {
  97.         printl("[DB] ❌ 入库失败: " + e);
  98.         return false;
  99.     }
  100. }

  101. function dbClose() {
  102.     if (数据库可用 && 数据库连接 != null) {
  103.         try { 数据库连接.close(); } catch (e) {}
  104.         数据库连接 = null;
  105.     }
  106. }

  107. // 打开演示页面(openApp + 校验前台包名,不对先杀后开重试一次;案例20 验证过的套路)
  108. function 启动页面(页面, 尝试) {
  109.     if (尝试 >= 2) {
  110.         try { app.closeApp(页面.包名); sleep.second(秒 = 1); } catch (eClose) { printl("⚠ closeApp 异常(忽略): " + eClose); }
  111.     }
  112.     app.openApp(页面.包名);
  113. }

  114. // 采集当前屏控件树 → { 控件数组:[{文本,类名,中心x,中心y,可点击}], 节点总数, 可点击数, 全部文字 }
  115. // 失败/无权限返回 null。踩坑实录:getText() 可能 null(容器节点),必须判空,否则脏数据"null"入库;
  116. // 过滤方法(clickable/textIncludes)会改集合本身,这里不链式过滤,统一逐个 getNode 读属性,最稳
  117. function 采集控件树() {
  118.     var 结果 = { 控件数组: [], 节点总数: 0, 可点击数: 0, 全部文字: "" };
  119.     try {
  120.         var 集合 = auto.nodeSearch(1000);   // 超时1秒:等控件树刷新稳定
  121.         if (集合 == null) {
  122.             printl("[控件] ❌ nodeSearch 返回 null(大概率是无障碍服务没授权)");
  123.             return null;
  124.         }
  125.         结果.节点总数 = 集合.count();
  126.         if (结果.节点总数 <= 0) {
  127.             printl("[控件] ❌ 控件树是空的——去 系统设置→无障碍(Accessibility) 给本APP开服务权限再重跑!");
  128.             return null;
  129.         }
  130.         for (var i = 0; i < 结果.节点总数; i++) {
  131.             try {
  132.                 var n = 集合.getNode(i);
  133.                 var 文本原始 = n.getText();
  134.                 var 文本 = (文本原始 == null) ? "" : ("" + 文本原始);   // 判空防"null"脏数据
  135.                 var 可点击 = false;
  136.                 try { 可点击 = n.isClickable(); } catch (eC) {}
  137.                 if (可点击) 结果.可点击数++;
  138.                 if (文本.length === 0) continue;   // 容器节点没文字,不进结构化数组
  139.                 var cx = 0;
  140.                 var cy = 0;
  141.                 try {
  142.                     var r = n.getBounds();
  143.                     cx = r.getCenterX();
  144.                     cy = r.getCenterY();
  145.                 } catch (eR) {}
  146.                 var 类名原始 = n.getClassName();
  147.                 var 类名 = (类名原始 == null) ? "" : ("" + 类名原始);
  148.                 结果.控件数组.push({ 文本: 文本, 类名: 类名, cx: cx, cy: cy, 可点击: 可点击 });
  149.                 结果.全部文字 = 结果.全部文字 + 文本;
  150.             } catch (eNode) {
  151.                 // 单节点读失败不拖垮整轮(节点可能已失效)
  152.             }
  153.         }
  154.         // 快速通道对照:getTexts() 一行拿全部文字数组(教学点:遍历慢但有完整属性,快通道只有文字)
  155.         try {
  156.             var 文字数组 = 集合.getTexts();
  157.             printl("[控件] 快速通道 getTexts() 返回 " + 文字数组.length + " 条文字(遍历法拿到 " + 结果.控件数组.length + " 个带属性控件,对照看)");
  158.         } catch (eT) {
  159.             printl("[控件] ⚠ getTexts 异常(不影响主流程): " + eT);
  160.         }
  161.         return 结果;
  162.     } catch (e) {
  163.         printl("[控件] ❌ 采集异常: " + e);
  164.         return null;
  165.     }
  166. }

  167. // 关键词检查:遍历控件文本,命中返回 {词, 文本},否则 null
  168. function 查找命中关键词(控件数组) {
  169.     for (var i = 0; i < 控件数组.length; i++) {
  170.         for (var k = 0; k < 识别关键词列表.length; k++) {
  171.             if (控件数组[i].文本.indexOf(识别关键词列表[k]) >= 0) {
  172.                 return { 词: 识别关键词列表[k], 文本: 控件数组[i].文本 };
  173.             }
  174.         }
  175.     }
  176.     return null;
  177. }

  178. // ========== 主流程 ==========
  179. printl("===== 案例23 APP控件树结构化采集入库 启动 =====");

  180. dbConnect();
  181. dbEnsureTable();
  182. printl("⚙ 配置:每 " + 采集间隔秒 + " 秒切页采集 · 监控词(" + 识别关键词列表.join("/") + ") · 单页入库上限 " + 每页入库上限条数 + " 条 · 库: " + SQLite路径);
  183. printl("ℹ 第6种数据源:无障碍控件树——APP 界面**摊开**给系统的结构化数据,文本+坐标+类名+可点击全都有");
  184. printl("ℹ 灵魂对照案例20:OCR 看像素(4~5秒,无坐标无类型),控件树直读(毫秒级,属性齐全)——有控件树永远优先控件树");
  185. printl("🔑 前提:系统设置→无障碍(Accessibility) 必须给本APP开服务权限,否则 nodeSearch 是空的(见自检)");
  186. printl("📱 启动时前台包名: " + app.getTopPackName());

  187. // 停止按钮清理(顺序:先置标志,stopAllRuning 放最后)
  188. event.onStopBtnEvent(function () {
  189.     gStop = true;
  190.     dbClose();
  191.     runTime.stopAllRuning();
  192. });

  193. // 步骤1:定时循环切页采集
  194. var 轮次 = 0;
  195. var 页面索引 = 0;
  196. while (!gStop) {
  197.     轮次++;
  198.     累计采集轮数++;
  199.     var 页面 = 演示页面列表[页面索引 % 演示页面列表.length];
  200.     页面索引++;
  201.     printl("\n---- 第 " + 轮次 + " 轮:切换页面【" + 页面.名称 + "】→ 控件树采集 ----");

  202.     // ① 开页+校验(和案例20 同款:不对就重试一次,失败也照常采当前屏)
  203.     var 打开成功 = false;
  204.     for (var 尝试 = 1; 尝试 <= 2 && !打开成功 && !gStop; 尝试++) {
  205.         try {
  206.             启动页面(页面, 尝试);
  207.             printl("🚪 第" + 尝试 + "次打开【" + 页面.名称 + "】(" + 页面.包名 + (尝试 >= 2 ? ",先关后开冷启动" : "") + "),等待 " + 开页等待秒 + " 秒渲染...");
  208.             sleep.second(秒 = 开页等待秒);
  209.             var 前台 = "" + app.getTopPackName();
  210.             if (前台.indexOf(页面.包名) >= 0) {
  211.                 打开成功 = true;
  212.                 printl("📱 校验通过:前台已是 " + 前台);
  213.             } else {
  214.                 printl("⚠ 页面没切过去(前台是 " + 前台 + ")" + (尝试 < 2 ? ",重试一次..." : ",照常采当前屏"));
  215.             }
  216.         } catch (e) {
  217.             printl("🚪 openApp 异常: " + e + ",照常采当前屏");
  218.             break;
  219.         }
  220.     }

  221.     // ② 采集控件树
  222.     var 开始 = time.nowStamp();
  223.     var 采集 = 采集控件树();
  224.     if (采集 == null) {
  225.         printl("⚠ 本轮采集失败(权限/异常),跳过入库");
  226.     } else {
  227.         var 耗时 = time.nowStamp() - 开始;
  228.         printl("🌳 控件树到手:节点 " + 采集.节点总数 + " 个 | 带文字控件 " + 采集.控件数组.length +
  229.             " 个 | 可点击 " + 采集.可点击数 + " 个 | 耗时 " + 耗时 + " 毫秒(对照案例20 OCR 的 4~5 秒)");

  230.         // ③ 结构化打印前 N 条(演示重点:每条都是"文本@坐标[类名,可否点击]",像素OCR给不了这些)
  231.         var 打印数 = 采集.控件数组.length < 打印最大条数 ? 采集.控件数组.length : 打印最大条数;
  232.         if (打印数 > 0) {
  233.             printl("📋 本轮控件明细(前 " + 打印数 + " 条):");
  234.             for (var p = 0; p < 打印数; p++) {
  235.                 var c = 采集.控件数组[p];
  236.                 printl("   │ [" + p + "] " + c.文本 + " @" + Math.round(c.cx) + "," + Math.round(c.cy) +
  237.                     " [" + c.类名 + (c.可点击 ? ",可点击" : "") + "]");
  238.             }
  239.             if (采集.控件数组.length > 打印数) {
  240.                 printl("   │ ...(共 " + 采集.控件数组.length + " 条,其余只入库不打印)");
  241.             }
  242.         } else {
  243.             printl("ℹ 本轮没有带文字的控件(纯图形界面?这正是该换 OCR 的场景——案例20)");
  244.         }

  245.         // ④ 关键词命中(结构化优势:能精确到是哪个控件命中)
  246.         var 命中 = 查找命中关键词(采集.控件数组);
  247.         if (命中 != null) {
  248.             printl("⚠ 报警:控件「" + 命中.文本 + "」命中关键词「" + 命中.词 + "」!");
  249.         }

  250.         // ⑤ 画面去重:全部文字和上一轮相同=页面没变,不重复入库(案例20/22 同款思想)
  251.         if (采集.控件数组.length > 0 && 采集.全部文字 !== 上轮全部文字) {
  252.             printl("💾 页面已变化,逐控件写入数据库(表: " + 表名 + ",上限 " + 每页入库上限条数 + " 条)...");
  253.             var 本轮入库 = 0;
  254.             for (var q = 0; q < 采集.控件数组.length && q < 每页入库上限条数; q++) {
  255.                 var item = 采集.控件数组[q];
  256.                 if (dbInsertNode(页面.名称, item.文本, item.类名, item.cx, item.cy, item.可点击 ? 1 : 0)) {
  257.                     本轮入库++;
  258.                 }
  259.             }
  260.             累计入库条数 = 累计入库条数 + 本轮入库;
  261.             printl("   ✓ 本轮入库 " + 本轮入库 + " 条控件 → 累计 " + 累计入库条数 + " 条 | " + SQLite路径);
  262.             上轮全部文字 = 采集.全部文字;
  263.         } else if (采集.控件数组.length > 0) {
  264.             printl("ℹ 控件文字与上一轮相同(页面没变),本轮不重复入库");
  265.         }
  266.     }

  267.     // ⑥ 分片等待下一轮:每 5 秒打一条进度(待机状态在日志可见,录像友好)
  268.     printl("⏳ 本轮完成," + 采集间隔秒 + " 秒后切下一页采集");
  269.     var 剩余 = 采集间隔秒;
  270.     while (剩余 > 0 && !gStop) {
  271.         var 步长 = 剩余 >= 5 ? 5 : 剩余;
  272.         sleep.second(秒 = 步长);
  273.         剩余 = 剩余 - 步长;
  274.         if (剩余 > 0) {
  275.             printl("⏳ 等待下一轮...剩余 " + 剩余 + " 秒(累计采集 " + 累计采集轮数 + " 轮/入库 " + 累计入库条数 + " 条)");
  276.         }
  277.     }
  278. }

  279. dbClose();
  280. printl("===== 流程结束:共 " + 累计采集轮数 + " 轮采集,控件入库 " + 累计入库条数 + " 条 =====");
复制代码


0

主题

2

回帖

4

积分

软件网站新手

积分
4
发表于 半小时前 | 显示全部楼层

APP控件树结构化控件信息爬取/APP内容结构化采集

楼主这个思路可以,关键流程留个手动兜底入口,自动化跑不通时人能接手比什么都强。权限那块要单独开无障碍和电池白名单,否则跑到一半会被系统杀掉,最常见的问题就是这个。要判断值不值得投入,就看这个场景一天重复多少次,一天十次的自动化维护成本可能比手工还高。
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

Archiver|手机版|小黑屋|

GMT+8, 2026-10-29 07:22

安卓脚本软件 · 键鼠盒子 · HID · 机械臂

快速回复 返回顶部 返回列表

随机推荐 · 热帖 / 新帖 / 随便看看

社区会员(随机展示)