android2harmony 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/README.md +1 -407
  2. package/agents/self-tester.md +376 -376
  3. package/dist/index.js +134 -116
  4. package/dist/index.js.map +3 -3
  5. package/package.json +36 -32
  6. package/skills/a2h-resource-convert/SKILL.md +902 -0
  7. package/skills/a2h-resource-convert/references/code-vector-icon-rules.md +335 -0
  8. package/skills/{hmos-resources-convert → a2h-resource-convert}/references/conversion-rules.md +15 -2
  9. package/skills/{hmos-resources-convert → a2h-resource-convert}/references/dependency-analysis-rules.md +24 -2
  10. package/skills/a2h-resource-convert/references/lottie-conversion-rules.md +219 -0
  11. package/skills/{hmos-resources-convert → a2h-resource-convert}/references/resource-mapping-rules.md +27 -2
  12. package/skills/{hmos-resources-convert → a2h-resource-convert}/references/xml-drawable-to-svg-rules.md +118 -1
  13. package/skills/a2h-resource-convert/scripts/a2h_resource_convert.js +2166 -0
  14. package/skills/a2h-resource-convert/scripts/code_vector_icons.js +607 -0
  15. package/skills/a2h-resource-convert/scripts/package.json +3 -0
  16. package/skills/a2h-resource-convert/scripts/svg_fidelity_check.js +632 -0
  17. package/skills/a2h-ui-transfer/SKILL.md +420 -0
  18. package/skills/a2h-ui-transfer/references/conversion-procedure.md +572 -0
  19. package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/_/350/214/203/345/274/217/351/200/211/346/213/251/350/257/264/346/230/216.md +1 -1
  20. package/skills/a2h-ui-transfer/references/mvvm-v2/_/350/243/205/351/245/260/345/231/250/351/200/237/346/237/245.md +133 -0
  21. package/skills/{hmos-batch-ui-align/scripts/android_parse_fast.ts → a2h-ui-transfer/scripts/android_parse_fast.js} +397 -283
  22. package/skills/a2h-ui-transfer/scripts/arkts_static_check.js +1624 -0
  23. package/skills/a2h-ui-transfer/scripts/measure_pack.js +1005 -0
  24. package/skills/a2h-ui-transfer/scripts/package.json +3 -0
  25. package/skills/hmos-incremental-ui-align/README.md +251 -251
  26. package/skills/hmos-incremental-ui-align/SKILL.md +364 -364
  27. package/skills/hmos-incremental-ui-align/references/State_Model_Template.md +2 -2
  28. package/skills/hmos-incremental-ui-align/scripts/app_feature_verify.js +790 -0
  29. package/skills/hmos-incremental-ui-align/scripts/extract_checklist.js +285 -0
  30. package/skills/hmos-incremental-ui-align/scripts/navigation-capure.md +76 -76
  31. package/skills/hmos-incremental-ui-align/scripts/page_capture.js +756 -0
  32. package/skills/hmos-incremental-ui-align/scripts/page_capture_burst.js +155 -0
  33. package/skills/hmos-batch-ui-align/SKILL.md +0 -141
  34. package/skills/hmos-batch-ui-align/references/conversion-procedure.md +0 -217
  35. package/skills/hmos-incremental-ui-align/scripts/app_feature_verify.ts +0 -999
  36. package/skills/hmos-incremental-ui-align/scripts/extract_checklist.ts +0 -343
  37. package/skills/hmos-incremental-ui-align/scripts/page_capture.ts +0 -977
  38. package/skills/hmos-incremental-ui-align/scripts/page_capture_burst.ts +0 -188
  39. package/skills/hmos-resources-convert/SKILL.md +0 -654
  40. package/skills/hmos-resources-convert/template/AppScope/app.json5 +0 -10
  41. package/skills/hmos-resources-convert/template/AppScope/resources/base/element/string.json +0 -8
  42. package/skills/hmos-resources-convert/template/AppScope/resources/base/media/background.png +0 -0
  43. package/skills/hmos-resources-convert/template/AppScope/resources/base/media/foreground.png +0 -0
  44. package/skills/hmos-resources-convert/template/AppScope/resources/base/media/layered_image.json +0 -7
  45. package/skills/hmos-resources-convert/template/build-profile.json5 +0 -42
  46. package/skills/hmos-resources-convert/template/code-linter.json5 +0 -32
  47. package/skills/hmos-resources-convert/template/entry/build-profile.json5 +0 -33
  48. package/skills/hmos-resources-convert/template/entry/hvigorfile.ts +0 -6
  49. package/skills/hmos-resources-convert/template/entry/obfuscation-rules.txt +0 -23
  50. package/skills/hmos-resources-convert/template/entry/oh-package.json5 +0 -10
  51. package/skills/hmos-resources-convert/template/entry/src/main/ets/entryability/EntryAbility.ets +0 -48
  52. package/skills/hmos-resources-convert/template/entry/src/main/ets/entrybackupability/EntryBackupAbility.ets +0 -16
  53. package/skills/hmos-resources-convert/template/entry/src/main/ets/pages/Index.ets +0 -23
  54. package/skills/hmos-resources-convert/template/entry/src/main/module.json5 +0 -55
  55. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/element/color.json +0 -8
  56. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/element/float.json +0 -8
  57. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/element/string.json +0 -16
  58. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/media/background.png +0 -0
  59. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/media/foreground.png +0 -0
  60. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/media/layered_image.json +0 -7
  61. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/media/startIcon.png +0 -0
  62. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/profile/backup_config.json +0 -3
  63. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/profile/main_pages.json +0 -5
  64. package/skills/hmos-resources-convert/template/entry/src/main/resources/dark/element/color.json +0 -8
  65. package/skills/hmos-resources-convert/template/entry/src/mock/mock-config.json5 +0 -2
  66. package/skills/hmos-resources-convert/template/entry/src/ohosTest/ets/test/Ability.test.ets +0 -35
  67. package/skills/hmos-resources-convert/template/entry/src/ohosTest/ets/test/List.test.ets +0 -5
  68. package/skills/hmos-resources-convert/template/entry/src/ohosTest/module.json5 +0 -16
  69. package/skills/hmos-resources-convert/template/entry/src/test/List.test.ets +0 -5
  70. package/skills/hmos-resources-convert/template/entry/src/test/LocalUnit.test.ets +0 -33
  71. package/skills/hmos-resources-convert/template/hvigor/hvigor-config.json5 +0 -23
  72. package/skills/hmos-resources-convert/template/hvigorfile.ts +0 -6
  73. package/skills/hmos-resources-convert/template/oh-package-lock.json5 +0 -28
  74. package/skills/hmos-resources-convert/template/oh-package.json5 +0 -10
  75. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mappings/android-to-harmonyOS-ui-atomic-component-mapping-reference.md +0 -0
  76. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mappings/android-to-harmonyOS-ui-interaction-mapping-reference.md +0 -0
  77. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mappings/android-to-harmonyOS-ui-layout-mapping-reference.md +0 -0
  78. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/@Link/350/243/205/351/245/260/345/231/250/357/274/232/347/210/266/345/255/220/345/217/214/345/220/221/345/220/214/346/255/245.md" +0 -0
  79. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/@Observed/350/243/205/351/245/260/345/231/250/345/222/214@ObjectLink/350/243/205/351/245/260/345/231/250/357/274/232/345/265/214/345/245/227/347/261/273/345/257/271/350/261/241/345/261/236/346/200/247/345/217/230/345/214/226.md" +0 -0
  80. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/@Prop/350/243/205/351/245/260/345/231/250/357/274/232/347/210/266/345/255/220/345/215/225/345/220/221/345/220/214/346/255/245.md" +0 -0
  81. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/@Provide/350/243/205/351/245/260/345/231/250/345/222/214@Consume/350/243/205/351/245/260/345/231/250/357/274/232/344/270/216/345/220/216/344/273/243/347/273/204/344/273/266/345/217/214/345/220/221/345/220/214/346/255/245.md" +0 -0
  82. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/@State/350/243/205/351/245/260/345/231/250/357/274/232/347/273/204/344/273/266/345/206/205/347/212/266/346/200/201.md" +0 -0
  83. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/@Track/350/243/205/351/245/260/345/231/250/357/274/232class/345/257/271/350/261/241/345/261/236/346/200/247/347/272/247/346/233/264/346/226/260.md" +0 -0
  84. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/@Watch/350/243/205/351/245/260/345/231/250/357/274/232/347/212/266/346/200/201/345/217/230/351/207/217/346/233/264/346/224/271/351/200/232/347/237/245.md" +0 -0
  85. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/AppStorage/357/274/232/345/272/224/347/224/250/345/205/250/345/261/200/347/232/204UI/347/212/266/346/200/201/345/255/230/345/202/250.md" +0 -0
  86. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/Environment/357/274/232/350/256/276/345/244/207/347/216/257/345/242/203/346/237/245/350/257/242.md" +0 -0
  87. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/LocalStorage/357/274/232/351/241/265/351/235/242/347/272/247UI/347/212/266/346/200/201/345/255/230/345/202/250.md" +0 -0
  88. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/MVVM/346/250/241/345/274/217/357/274/210V1/357/274/211.md" +0 -0
  89. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/PersistentStorage/357/274/232/346/214/201/344/271/205/345/214/226/345/255/230/345/202/250UI/347/212/266/346/200/201.md" +0 -0
  90. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm//347/256/241/347/220/206/345/272/224/347/224/250/346/213/245/346/234/211/347/232/204/347/212/266/346/200/201/346/246/202/350/277/260.md" +0 -0
  91. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/!!/350/257/255/346/263/225/357/274/232/345/217/214/345/220/221/347/273/221/345/256/232.md" +0 -0
  92. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@Computed/350/243/205/351/245/260/345/231/250/357/274/232/350/256/241/347/256/227/345/261/236/346/200/247.md" +0 -0
  93. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@Event/350/243/205/351/245/260/345/231/250/357/274/232/350/247/204/350/214/203/347/273/204/344/273/266/350/276/223/345/207/272.md" +0 -0
  94. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@Local/350/243/205/351/245/260/345/231/250/357/274/232/347/273/204/344/273/266/345/206/205/351/203/250/347/212/266/346/200/201.md" +0 -0
  95. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@Monitor/350/243/205/351/245/260/345/231/250/357/274/232/347/212/266/346/200/201/345/217/230/351/207/217/344/277/256/346/224/271/345/274/202/346/255/245/347/233/221/345/220/254.md" +0 -0
  96. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@ObservedV2/350/243/205/351/245/260/345/231/250/345/222/214@Trace/350/243/205/351/245/260/345/231/250/357/274/232/347/261/273/345/261/236/346/200/247/345/217/230/345/214/226/350/247/202/346/265/213.md" +0 -0
  97. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@Once/350/243/205/351/245/260/345/231/250/357/274/232/345/210/235/345/247/213/345/214/226/345/220/214/346/255/245/344/270/200/346/254/241.md" +0 -0
  98. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@Param/350/243/205/351/245/260/345/231/250/357/274/232/347/273/204/344/273/266/345/244/226/351/203/250/350/276/223/345/205/245.md" +0 -0
  99. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@Provider/350/243/205/351/245/260/345/231/250/345/222/214@Consumer/350/243/205/351/245/260/345/231/250/357/274/232/350/267/250/347/273/204/344/273/266/345/261/202/347/272/247/345/217/214/345/220/221/345/220/214/346/255/245.md" +0 -0
  100. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@Type/350/243/205/351/245/260/345/231/250/357/274/232/346/240/207/350/256/260/347/261/273/345/261/236/346/200/247/347/232/204/347/261/273/345/236/213.md" +0 -0
  101. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/AppStorageV2/357/274/232/345/272/224/347/224/250/345/205/250/345/261/200UI/347/212/266/346/200/201/345/255/230/345/202/250.md" +0 -0
  102. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/MVVM/346/250/241/345/274/217/357/274/210V2/357/274/211.md" +0 -0
  103. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/PersistenceV2/357/274/232/346/214/201/344/271/205/345/214/226/345/255/230/345/202/250UI/347/212/266/346/200/201.md" +0 -0
  104. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2//347/212/266/346/200/201/347/256/241/347/220/206V1/345/220/221V2/350/277/201/347/247/273/344/270/216/346/267/267/347/224/250/346/214/207/345/257/274.md" +0 -0
@@ -1,17 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Android 模拟器页面遍历工具(加速版, TypeScript 版)
3
+ * Android 模拟器页面遍历工具(加速版)
4
4
  * 功能:BFS 遍历每个页面,保存截图、View XML、Activity/Window 信息,生成 index.json + report.html。
5
5
  *
6
6
  * 零第三方依赖,仅使用 Node 内置模块,可独立分发。
7
7
  * 等价于原 android_parse_fast.py(functional equivalence)。
8
8
  *
9
9
  * 用法:
10
- * node android_parse_fast.ts --package <pkg> --output <dir> [--device serial] [--adb-path adb]
11
- * [--max-pages 500] [--max-scrolls 10] [--manual]
10
+ * node android_parse_fast.js --package <pkg> --output <dir> [--device serial] [--adb-path adb]
11
+ * [--max-pages 500] [--max-scrolls 10] [--manual] [--page "<描述>"]
12
12
  */
13
13
 
14
- import { spawnSync, type SpawnSyncReturns } from 'node:child_process';
14
+ import { spawnSync, } from 'node:child_process';
15
15
  import * as fs from 'node:fs';
16
16
  import * as path from 'node:path';
17
17
  import * as readline from 'node:readline';
@@ -37,7 +37,7 @@ const SKIP_RESOURCE_ID_PATTERNS_DEFAULT = [
37
37
  ];
38
38
  const HORIZONTAL_SCROLL_CLASSES = new Set(['android.widget.HorizontalScrollView']);
39
39
 
40
- function sleep(seconds: number): void {
40
+ function sleep(seconds) {
41
41
  const end = Date.now() + seconds * 1000;
42
42
  const ia = new Int32Array(new SharedArrayBuffer(4));
43
43
  while (Date.now() < end) Atomics.wait(ia, 0, 0, Math.min(50, end - Date.now()));
@@ -46,16 +46,16 @@ function sleep(seconds: number): void {
46
46
  // ─────────────────────────────────────────────
47
47
  // 最小 XML 解析器(等价 xml.etree 的 root.iter("node") + get 属性)
48
48
  // ─────────────────────────────────────────────
49
- interface XmlNode {
50
- tag: string;
51
- attrs: Record<string, string>;
52
- children: XmlNode[];
53
- }
49
+ // interface XmlNode {
50
+ // tag: string;
51
+ // attrs: Record<string, string>;
52
+ // children: XmlNode[];
53
+ // }
54
54
 
55
- const XML_ENTITIES: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
55
+ const XML_ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
56
56
 
57
- function decodeEntities(s: string): string {
58
- return s.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (m, ent: string) => {
57
+ function decodeEntities(s) {
58
+ return s.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (m, ent) => {
59
59
  if (ent[0] === '#') {
60
60
  const code = ent[1] === 'x' || ent[1] === 'X' ? parseInt(ent.slice(2), 16) : parseInt(ent.slice(1), 10);
61
61
  return Number.isNaN(code) ? m : String.fromCodePoint(code);
@@ -66,16 +66,16 @@ function decodeEntities(s: string): string {
66
66
 
67
67
  class XmlParseError extends Error {}
68
68
 
69
- function parseXml(xml: string): XmlNode {
69
+ function parseXml(xml) {
70
70
  let i = 0;
71
71
  const n = xml.length;
72
- const stack: XmlNode[] = [];
73
- let root: XmlNode | null = null;
72
+ const stack = [];
73
+ let root = null;
74
74
 
75
- const readAttrs = (raw: string): Record<string, string> => {
76
- const attrs: Record<string, string> = {};
75
+ const readAttrs = (raw) => {
76
+ const attrs = {};
77
77
  const re = /([^\s=/>]+)\s*=\s*"([^"]*)"|([^\s=/>]+)\s*=\s*'([^']*)'/g;
78
- let m: RegExpExecArray | null;
78
+ let m;
79
79
  while ((m = re.exec(raw)) !== null) {
80
80
  if (m[1] !== undefined) attrs[m[1]] = decodeEntities(m[2]);
81
81
  else if (m[3] !== undefined) attrs[m[3]] = decodeEntities(m[4]);
@@ -106,7 +106,7 @@ function parseXml(xml: string): XmlNode {
106
106
  continue;
107
107
  }
108
108
  let j = i + 1;
109
- let quote: string | null = null;
109
+ let quote = null;
110
110
  for (; j < n; j++) {
111
111
  const c = xml[j];
112
112
  if (quote) {
@@ -126,7 +126,7 @@ function parseXml(xml: string): XmlNode {
126
126
  const spaceIdx = body.search(/[\s/]/);
127
127
  const tag = spaceIdx < 0 ? body : body.slice(0, spaceIdx);
128
128
  const attrRaw = spaceIdx < 0 ? '' : body.slice(spaceIdx);
129
- const node: XmlNode = { tag, attrs: readAttrs(attrRaw), children: [] };
129
+ const node = { tag, attrs: readAttrs(attrRaw), children: [] };
130
130
  if (stack.length > 0) stack[stack.length - 1].children.push(node);
131
131
  else if (root === null) root = node;
132
132
  if (!selfClosing) stack.push(node);
@@ -135,16 +135,16 @@ function parseXml(xml: string): XmlNode {
135
135
  return root;
136
136
  }
137
137
 
138
- function* iterNodes(root: XmlNode, tag: string): Generator<XmlNode> {
138
+ function* iterNodes(root, tag) {
139
139
  if (root.tag === tag) yield root;
140
140
  for (const child of root.children) yield* iterNodes(child, tag);
141
141
  }
142
142
 
143
- function attr(node: XmlNode, key: string, dflt = ''): string {
143
+ function attr(node, key, dflt = '') {
144
144
  return key in node.attrs ? node.attrs[key] : dflt;
145
145
  }
146
146
 
147
- function extractInts(s: string): number[] {
147
+ function extractInts(s) {
148
148
  const m = s.match(/\d+/g);
149
149
  return m ? m.map((x) => parseInt(x, 10)) : [];
150
150
  }
@@ -152,24 +152,24 @@ function extractInts(s: string): number[] {
152
152
  // ─────────────────────────────────────────────
153
153
  // 元素/签名 纯函数(等价 PageCrawler 的 @staticmethod)
154
154
  // ─────────────────────────────────────────────
155
- interface ClickableElement {
156
- class: string;
157
- text: string;
158
- 'resource-id': string;
159
- 'content-desc': string;
160
- bounds: string;
161
- center: [number, number] | null;
162
- 'long-clickable': boolean;
163
- scroll_index?: number;
164
- }
165
-
166
- interface ScrollContainer {
167
- bounds: [number, number, number, number];
168
- class: string;
169
- 'resource-id': string;
170
- }
171
-
172
- function parseBoundsCenter(bounds: string): [number, number] | null {
155
+ // interface ClickableElement {
156
+ // class: string;
157
+ // text: string;
158
+ // 'resource-id': string;
159
+ // 'content-desc': string;
160
+ // bounds: string;
161
+ // center: [number, number] | null;
162
+ // 'long-clickable': boolean;
163
+ // scroll_index?: number;
164
+ // }
165
+
166
+ // interface ScrollContainer {
167
+ // bounds: [number, number, number, number];
168
+ // class: string;
169
+ // 'resource-id': string;
170
+ // }
171
+
172
+ function parseBoundsCenter(bounds) {
173
173
  const nums = extractInts(bounds);
174
174
  if (nums.length === 4) {
175
175
  const [x1, y1, x2, y2] = nums;
@@ -178,18 +178,18 @@ function parseBoundsCenter(bounds: string): [number, number] | null {
178
178
  return null;
179
179
  }
180
180
 
181
- function getClickableElements(xmlContent: string): ClickableElement[] {
182
- const elements: ClickableElement[] = [];
181
+ function getClickableElements(xmlContent) {
182
+ const elements = [];
183
183
  if (!xmlContent) return elements;
184
- let root: XmlNode;
184
+ let root;
185
185
  try {
186
186
  root = parseXml(xmlContent);
187
187
  } catch (e) {
188
- console.log(` [WARN] XML 解析失败: ${(e as Error).message}`);
188
+ console.log(` [WARN] XML 解析失败: ${(e).message}`);
189
189
  return elements;
190
190
  }
191
191
 
192
- const listviewChildSet = new Set<XmlNode>();
192
+ const listviewChildSet = new Set();
193
193
  for (const node of iterNodes(root, 'node')) {
194
194
  const cls = attr(node, 'class');
195
195
  if (cls.includes('ListView') || cls.includes('GridView')) {
@@ -197,7 +197,7 @@ function getClickableElements(xmlContent: string): ClickableElement[] {
197
197
  }
198
198
  }
199
199
 
200
- const extractText = (node: XmlNode): string => {
200
+ const extractText = (node) => {
201
201
  const t = attr(node, 'text');
202
202
  if (t) return t;
203
203
  for (const child of iterNodes(node, 'node')) {
@@ -231,10 +231,10 @@ function getClickableElements(xmlContent: string): ClickableElement[] {
231
231
  return elements;
232
232
  }
233
233
 
234
- function extractNodeKeys(xmlContent: string): Set<string> {
235
- const keys = new Set<string>();
234
+ function extractNodeKeys(xmlContent) {
235
+ const keys = new Set();
236
236
  if (!xmlContent) return keys;
237
- let root: XmlNode;
237
+ let root;
238
238
  try {
239
239
  root = parseXml(xmlContent);
240
240
  } catch {
@@ -248,16 +248,16 @@ function extractNodeKeys(xmlContent: string): Set<string> {
248
248
  return keys;
249
249
  }
250
250
 
251
- function setsEqual(a: Set<string>, b: Set<string>): boolean {
251
+ function setsEqual(a, b) {
252
252
  if (a.size !== b.size) return false;
253
253
  for (const v of a) if (!b.has(v)) return false;
254
254
  return true;
255
255
  }
256
256
 
257
- function findScrollableContainers(xmlContent: string): ScrollContainer[] {
258
- const containers: ScrollContainer[] = [];
257
+ function findScrollableContainers(xmlContent) {
258
+ const containers = [];
259
259
  if (!xmlContent) return containers;
260
- let root: XmlNode;
260
+ let root;
261
261
  try {
262
262
  root = parseXml(xmlContent);
263
263
  } catch {
@@ -279,7 +279,7 @@ function findScrollableContainers(xmlContent: string): ScrollContainer[] {
279
279
  return containers;
280
280
  }
281
281
 
282
- function normalizeComponent(component: string): string {
282
+ function normalizeComponent(component) {
283
283
  if (!component.includes('/')) return component;
284
284
  const idx = component.indexOf('/');
285
285
  const pkg = component.slice(0, idx);
@@ -288,7 +288,7 @@ function normalizeComponent(component: string): string {
288
288
  return `${pkg}/${cls}`;
289
289
  }
290
290
 
291
- function stableActivityKey(activityInfo: Record<string, string>): string {
291
+ function stableActivityKey(activityInfo) {
292
292
  const focus = activityInfo['current_focus'] ?? '';
293
293
  const resumed = activityInfo['resumed_activity'] ?? '';
294
294
  const m = focus.match(/[\w.]+\/[\w.]+/);
@@ -300,7 +300,7 @@ function stableActivityKey(activityInfo: Record<string, string>): string {
300
300
  return `${actPart}|${winType}`;
301
301
  }
302
302
 
303
- function pageSignature(activityInfo: Record<string, string>, xmlContent: string): string {
303
+ function pageSignature(activityInfo, xmlContent) {
304
304
  const actKey = stableActivityKey(activityInfo);
305
305
  if (!xmlContent) return `${actKey}|empty`;
306
306
  const clickable = getClickableElements(xmlContent);
@@ -313,15 +313,15 @@ function pageSignature(activityInfo: Record<string, string>, xmlContent: string)
313
313
  return `${actKey}|${featureHash}`;
314
314
  }
315
315
 
316
- type RootIdentity = [string, Set<string>];
316
+ // type RootIdentity = [string, Set<string>];
317
317
 
318
- function rootIdentifiedSet(activityInfo: Record<string, string>, xmlContent: string): RootIdentity {
318
+ function rootIdentifiedSet(activityInfo, xmlContent) {
319
319
  const resumed = activityInfo['resumed_activity'] ?? '';
320
320
  const m = resumed.match(/[\w.]+\/[\w.]+/);
321
321
  const actKey = m ? normalizeComponent(m[0]) : 'unknown';
322
322
  if (!xmlContent) return [actKey, new Set()];
323
323
  const clickable = getClickableElements(xmlContent);
324
- const elems = new Set<string>();
324
+ const elems = new Set();
325
325
  for (const e of clickable) {
326
326
  const desc = e['content-desc'] ?? '';
327
327
  const rid = e['resource-id'] ?? '';
@@ -331,7 +331,7 @@ function rootIdentifiedSet(activityInfo: Record<string, string>, xmlContent: str
331
331
  return [actKey, elems];
332
332
  }
333
333
 
334
- function isSameRootPage(rootInfo: RootIdentity, currentInfo: RootIdentity, threshold = 0.6): boolean {
334
+ function isSameRootPage(rootInfo, currentInfo, threshold = 0.6) {
335
335
  const [rootAct, rootElems] = rootInfo;
336
336
  const [curAct, curElems] = currentInfo;
337
337
  if (rootAct !== curAct) return false;
@@ -344,7 +344,7 @@ function isSameRootPage(rootInfo: RootIdentity, currentInfo: RootIdentity, thres
344
344
  }
345
345
 
346
346
  /** current_focus 是否含 `package/activity` 分量(弹窗/overlay 焦点没有)。 */
347
- function focusHasActivityComponent(activityInfo: Record<string, string>): boolean {
347
+ function focusHasActivityComponent(activityInfo) {
348
348
  return /[\w.]+\/[\w.]+/.test(activityInfo['current_focus'] ?? '');
349
349
  }
350
350
 
@@ -353,7 +353,7 @@ function focusHasActivityComponent(activityInfo: Record<string, string>): boolea
353
353
  * 优先 current_focus(通常是全限定 Activity),回退 resumed_activity(可能是相对名 `.ui.Main`,
354
354
  * am start 同样接受)。都取不到返回 null。
355
355
  */
356
- function extractLaunchComponent(activityInfo: Record<string, string>): [string, string] | null {
356
+ function extractLaunchComponent(activityInfo) {
357
357
  for (const key of ['current_focus', 'resumed_activity']) {
358
358
  const s = activityInfo[key] ?? '';
359
359
  const m = s.match(/([\w.]+)\/([\w.]+)/);
@@ -362,14 +362,14 @@ function extractLaunchComponent(activityInfo: Record<string, string>): [string,
362
362
  return null;
363
363
  }
364
364
 
365
- type ElemId = [string, string, string, string, boolean];
365
+ // type ElemId = [string, string, string, string, boolean];
366
366
 
367
- function findElementOnScreen(xmlContent: string, elemId: ElemId | [string, string, string, string]): ClickableElement | null {
367
+ function findElementOnScreen(xmlContent, elemId) {
368
368
  const rid = elemId[0];
369
369
  const text = elemId[1];
370
370
  const desc = elemId[2];
371
371
  const bounds = elemId[3];
372
- const isLong = elemId.length >= 5 ? (elemId[4] as boolean) : null;
372
+ const isLong = elemId.length >= 5 ? (elemId[4]) : null;
373
373
  const clickableElements = getClickableElements(xmlContent);
374
374
 
375
375
  if (bounds) {
@@ -398,28 +398,55 @@ function findElementOnScreen(xmlContent: string, elemId: ElemId | [string, strin
398
398
  return null;
399
399
  }
400
400
 
401
+ // ─────────────────────────────────────────────
402
+ // 设备常量:密度与物理分辨率
403
+ // ─────────────────────────────────────────────
404
+ /**
405
+ * 采集设备的密度与分辨率 —— 每页写进 meta.json 的 `device` 字段。
406
+ *
407
+ * 为什么必须在采集期记:密度是「同一台设备上每页同值」的常量,而下游 measure_pack.js
408
+ * 只能拿 view tree 里正方形节点的边长去**反解**它。反解在数学上不唯一 —— 72px 既能解释成
409
+ * 24dp@3.0,也能解释成 36dp@2.0 / 48dp@1.5,整个谐波族全部自洽;工具栏图标尺寸单一的页面
410
+ * 更是必然平局。一旦解错,该页所有 vp 数值整体差 2~3 倍,而编译、引用检查、跨页一致性
411
+ * 全都照样通过(因为每页错得一样)。`wm density` 一行就能拿到真值,采集期不记 = 主动丢弃它。
412
+ */
413
+ // interface DeviceInfo {
414
+ // /** 生效 dpi(覆盖优先)。取不到为 null。 */
415
+ // dpi: number | null;
416
+ // /** dpi / 160,即 ArkUI 侧 px→vp 的换算系数。取不到为 null。 */
417
+ // density: number | null;
418
+ // /** 密度取自哪条来源,便于复核。 */
419
+ // density_source: 'wm density (override)' | 'wm density (physical)' | 'unavailable';
420
+ // width_px: number | null;
421
+ // height_px: number | null;
422
+ // /** 原始输出,供人工核对(`wm density` 改过的设备两行值不同)。 */
423
+ // raw: { wm_density: string; wm_size: string };
424
+ // }
425
+
401
426
  // ─────────────────────────────────────────────
402
427
  // ADBHelper:封装所有 ADB 设备通信与交互操作
403
428
  // ─────────────────────────────────────────────
404
429
  class ADBHelper {
405
- adbPath: string;
406
- deviceSerial: string | null;
407
- idlePollInterval: number;
408
- idleTimeout: number;
409
- scrollDurationMs: number;
430
+ adbPath;
431
+ deviceSerial;
432
+ idlePollInterval;
433
+ idleTimeout;
434
+ scrollDurationMs;
410
435
  /** 目标 App 包名;多屏设备(车机等)上用于从多条 focus/resumed 行中挑出正确的那条。 */
411
- targetPackage: string | null;
436
+ targetPackage;
412
437
  /** screencap -d 用的物理 display id 缓存;undefined=未解析,null=解析失败(退回无 -d)。 */
413
- private cachedPhysicalDisplayId: string | null | undefined = undefined;
414
-
415
- constructor(opts: {
416
- adbPath?: string;
417
- deviceSerial?: string | null;
418
- idlePollInterval?: number;
419
- idleTimeout?: number;
420
- scrollDurationMs?: number;
421
- targetPackage?: string | null;
422
- } = {}) {
438
+ cachedPhysicalDisplayId = undefined;
439
+ /** 设备密度/分辨率缓存;整轮遍历只查一次(密度不会中途变)。 */
440
+ cachedDeviceInfo = undefined;
441
+
442
+ constructor(opts
443
+ // adbPath?: string;
444
+ // deviceSerial?: string | null;
445
+ // idlePollInterval?: number;
446
+ // idleTimeout?: number;
447
+ // scrollDurationMs?: number;
448
+ // targetPackage?: string | null;
449
+ = {}) {
423
450
  this.adbPath = opts.adbPath ?? ADB_PATH_DEFAULT;
424
451
  this.deviceSerial = opts.deviceSerial ?? DEVICE_SERIAL_DEFAULT;
425
452
  this.targetPackage = opts.targetPackage ?? null;
@@ -428,25 +455,25 @@ class ADBHelper {
428
455
  this.scrollDurationMs = opts.scrollDurationMs ?? SCROLL_DURATION_MS_DEFAULT;
429
456
  }
430
457
 
431
- private buildArgs(args: string[]): string[] {
432
- const full: string[] = [];
458
+ buildArgs(args) {
459
+ const full = [];
433
460
  if (this.deviceSerial) full.push('-s', this.deviceSerial);
434
461
  full.push(...args);
435
462
  return full;
436
463
  }
437
464
 
438
- private spawn(args: string[], timeout: number, binary: boolean): SpawnSyncReturns<Buffer> {
465
+ spawn(args, timeout, binary) {
439
466
  return spawnSync(this.adbPath, this.buildArgs(args), {
440
467
  timeout: timeout * 1000,
441
468
  maxBuffer: 128 * 1024 * 1024,
442
469
  windowsHide: true,
443
- }) as SpawnSyncReturns<Buffer>;
470
+ });
444
471
  }
445
472
 
446
- run(args: string[], timeout = 15): string {
473
+ run(args, timeout = 15) {
447
474
  const res = this.spawn(args, timeout, false);
448
475
  if (res.error) {
449
- const code = (res.error as NodeJS.ErrnoException).code;
476
+ const code = (res.error).code;
450
477
  if (code === 'ETIMEDOUT') {
451
478
  console.log(` [WARN] adb 命令超时: ${args.join(' ')}`);
452
479
  return '';
@@ -459,13 +486,49 @@ class ADBHelper {
459
486
  return out.toString('utf-8').trim();
460
487
  }
461
488
 
462
- shell(args: string[], timeout = 15): string {
489
+ shell(args, timeout = 15) {
463
490
  return this.run(['shell', ...args], timeout);
464
491
  }
465
492
 
466
- waitForIdle(): void {
493
+ /**
494
+ * 读取设备密度与物理分辨率(整轮只查一次,结果缓存)。
495
+ *
496
+ * 覆盖密度(`wm density N` 改过的设备)**优先于**物理密度 —— UI 实际按覆盖值布局,
497
+ * 拿物理值去换算会得到一套系统性偏移的 vp。
498
+ */
499
+ deviceInfo() {
500
+ if (this.cachedDeviceInfo !== undefined) return this.cachedDeviceInfo;
501
+ const densRaw = this.shell(['wm', 'density']);
502
+ const sizeRaw = this.shell(['wm', 'size']);
503
+ const ovD = /Override density:\s*(\d+)/.exec(densRaw);
504
+ const phD = /Physical density:\s*(\d+)/.exec(densRaw);
505
+ const dpi = ovD ? Number(ovD[1]) : phD ? Number(phD[1]) : null;
506
+ const ovS = /Override size:\s*(\d+)x(\d+)/.exec(sizeRaw);
507
+ const phS = /Physical size:\s*(\d+)x(\d+)/.exec(sizeRaw);
508
+ const sz = ovS ?? phS;
509
+ const info = {
510
+ dpi,
511
+ density: dpi ? Math.round((dpi / 160) * 1000) / 1000 : null,
512
+ density_source: dpi === null
513
+ ? 'unavailable'
514
+ : ovD ? 'wm density (override)' : 'wm density (physical)',
515
+ width_px: sz ? Number(sz[1]) : null,
516
+ height_px: sz ? Number(sz[2]) : null,
517
+ raw: { wm_density: densRaw, wm_size: sizeRaw },
518
+ };
519
+ if (info.density === null) {
520
+ console.log(' [WARN] `wm density` 未返回可解析的密度,下游 measure_pack 只能反解(结果可能不唯一)');
521
+ } else {
522
+ console.log(` 设备密度: ${info.density} (${info.dpi}dpi, ${info.density_source})`
523
+ + (info.width_px ? ` 分辨率 ${info.width_px}x${info.height_px}` : ''));
524
+ }
525
+ this.cachedDeviceInfo = info;
526
+ return info;
527
+ }
528
+
529
+ waitForIdle() {
467
530
  const deadline = Date.now() + this.idleTimeout * 1000;
468
- let prevFocus: string | null = null;
531
+ let prevFocus = null;
469
532
  let interval = this.idlePollInterval;
470
533
  while (Date.now() < deadline) {
471
534
  sleep(interval);
@@ -486,7 +549,7 @@ class ADBHelper {
486
549
  console.log(` [WARN] 等待 UI 空闲超时 (${this.idleTimeout}s),继续执行`);
487
550
  }
488
551
 
489
- checkDevice(): boolean {
552
+ checkDevice() {
490
553
  const out = this.run(['devices']);
491
554
  const lines = out.split(/\r?\n/).filter((l) => l.trim() && !l.includes('List of devices'));
492
555
  const online = lines.filter((l) => l.includes('device') && !l.includes('offline'));
@@ -516,30 +579,30 @@ class ADBHelper {
516
579
  * launcher。因此不能取第一条就 break:优先取包含目标包名的那条,
517
580
  * 其次取任意带 `pkg/activity` 组件名的非 null 行,最后兜底第一条。
518
581
  */
519
- private pickBestLine(lines: string[]): string | undefined {
582
+ pickBestLine(lines) {
520
583
  if (lines.length === 0) return undefined;
521
584
  if (this.targetPackage) {
522
- const hit = lines.find((l) => l.includes(this.targetPackage!));
585
+ const hit = lines.find((l) => l.includes(this.targetPackage));
523
586
  if (hit) return hit;
524
587
  }
525
588
  const withComponent = lines.find((l) => !/=null\b/.test(l) && /[\w.]+\/[\w.]+/.test(l));
526
589
  return withComponent ?? lines[0];
527
590
  }
528
591
 
529
- getCurrentActivity(): Record<string, string> {
530
- const info: Record<string, string> = {};
592
+ getCurrentActivity() {
593
+ const info = {};
531
594
  const actOut = this.shell(['dumpsys', 'activity', 'activities']);
532
595
  const winOut = this.shell(['dumpsys', 'window']);
533
596
 
534
- const resumedLines: string[] = [];
597
+ const resumedLines = [];
535
598
  for (const line of actOut.split(/\r?\n/)) {
536
599
  if (/ResumedActivity|topActivity/i.test(line)) resumedLines.push(line.trim());
537
600
  }
538
601
  const resumed = this.pickBestLine(resumedLines);
539
602
  if (resumed !== undefined) info['resumed_activity'] = resumed;
540
603
 
541
- const focusLines: string[] = [];
542
- const focusedAppLines: string[] = [];
604
+ const focusLines = [];
605
+ const focusedAppLines = [];
543
606
  for (const line of winOut.split(/\r?\n/)) {
544
607
  if (line.includes('mCurrentFocus')) focusLines.push(line.trim());
545
608
  else if (line.includes('mFocusedApp')) focusedAppLines.push(line.trim());
@@ -552,27 +615,27 @@ class ADBHelper {
552
615
  return info;
553
616
  }
554
617
 
555
- tap(x: number, y: number): void {
618
+ tap(x, y) {
556
619
  this.shell(['input', 'tap', String(x), String(y)]);
557
620
  this.waitForIdle();
558
621
  }
559
622
 
560
- longPress(x: number, y: number, durationMs = 1000): void {
623
+ longPress(x, y, durationMs = 1000) {
561
624
  this.shell(['input', 'swipe', String(x), String(y), String(x), String(y), String(durationMs)]);
562
625
  this.waitForIdle();
563
626
  }
564
627
 
565
- tapOrLongPress(x: number, y: number, isLongClick = false): void {
628
+ tapOrLongPress(x, y, isLongClick = false) {
566
629
  if (isLongClick) this.longPress(x, y);
567
630
  else this.tap(x, y);
568
631
  }
569
632
 
570
- pressBack(): void {
633
+ pressBack() {
571
634
  this.shell(['input', 'keyevent', 'KEYCODE_BACK']);
572
635
  this.waitForIdle();
573
636
  }
574
637
 
575
- dumpXmlQuick(): string {
638
+ dumpXmlQuick() {
576
639
  const remotePath = '/sdcard/ui_dump.xml';
577
640
  this.shell(['rm -f /sdcard/ui_dump.xml && uiautomator dump /sdcard/ui_dump.xml'], 20);
578
641
  for (let k = 0; k < 6; k++) {
@@ -583,7 +646,7 @@ class ADBHelper {
583
646
  return this.shell(['cat', remotePath], 15);
584
647
  }
585
648
 
586
- getViewXmlFast(pageDir: string): string {
649
+ getViewXmlFast(pageDir) {
587
650
  const xmlContent = this.dumpXmlQuick();
588
651
  if (xmlContent) fs.writeFileSync(path.join(pageDir, 'view.xml'), xmlContent, 'utf-8');
589
652
  return xmlContent;
@@ -601,14 +664,14 @@ class ADBHelper {
601
664
  * → dumpsys display 里用 `displayId N,` 所在行的 uniqueId "local:<physId>" 取物理 id。
602
665
  * 单屏设备解析失败时返回 null,退回不带 -d 的老行为(此时也不会有警告)。
603
666
  */
604
- private resolveTargetPhysicalDisplayId(): string | null {
667
+ resolveTargetPhysicalDisplayId() {
605
668
  if (this.cachedPhysicalDisplayId !== undefined) return this.cachedPhysicalDisplayId;
606
- let result: string | null = null;
669
+ let result = null;
607
670
  try {
608
671
  if (this.targetPackage) {
609
672
  const actOut = this.shell(['dumpsys', 'activity', 'activities']);
610
- let curDisplay: string | null = null;
611
- let logicalId: string | null = null;
673
+ let curDisplay = null;
674
+ let logicalId = null;
612
675
  for (const line of actOut.split(/\r?\n/)) {
613
676
  const m = line.match(/^Display #(\d+)/);
614
677
  if (m) curDisplay = m[1];
@@ -638,7 +701,7 @@ class ADBHelper {
638
701
  return result;
639
702
  }
640
703
 
641
- screencapBytes(): Buffer | null {
704
+ screencapBytes() {
642
705
  const physId = this.resolveTargetPhysicalDisplayId();
643
706
  const capArgs = physId
644
707
  ? ['exec-out', 'screencap', '-d', physId, '-p']
@@ -647,8 +710,8 @@ class ADBHelper {
647
710
  timeout: 15 * 1000,
648
711
  maxBuffer: 128 * 1024 * 1024,
649
712
  windowsHide: true,
650
- }) as SpawnSyncReturns<Buffer>;
651
- if (res.error && (res.error as NodeJS.ErrnoException).code === 'ETIMEDOUT') {
713
+ });
714
+ if (res.error && (res.error).code === 'ETIMEDOUT') {
652
715
  console.log(' [WARN] 截图超时');
653
716
  return null;
654
717
  }
@@ -664,7 +727,7 @@ class ADBHelper {
664
727
  return at === 0 ? out : out.subarray(at);
665
728
  }
666
729
 
667
- takeScreenshot(pageDir: string): void {
730
+ takeScreenshot(pageDir) {
668
731
  const localPath = path.join(pageDir, 'screenshot.png');
669
732
  const data = this.screencapBytes();
670
733
  if (data) {
@@ -680,7 +743,7 @@ class ADBHelper {
680
743
  }
681
744
  }
682
745
 
683
- scrollToPosition(containerBounds: [number, number, number, number], scrollIndex: number): void {
746
+ scrollToPosition(containerBounds, scrollIndex) {
684
747
  if (scrollIndex <= 0) return;
685
748
  const [x1, y1, , y2] = containerBounds;
686
749
  const x2 = containerBounds[2];
@@ -694,7 +757,7 @@ class ADBHelper {
694
757
  }
695
758
  }
696
759
 
697
- flushPendingPulls(): void {
760
+ flushPendingPulls() {
698
761
  // TS 版截图为同步写入,无异步 pull 队列,保留空实现以对齐调用点。
699
762
  }
700
763
  }
@@ -702,44 +765,46 @@ class ADBHelper {
702
765
  // ─────────────────────────────────────────────
703
766
  // 队列项 / 记录类型
704
767
  // ─────────────────────────────────────────────
705
- interface ScrollInfo {
706
- scroll_index: number;
707
- container_bounds: number[];
708
- }
709
- interface ClickStep {
710
- from: string;
711
- element: string;
712
- center: number[];
713
- 'resource-id': string;
714
- text: string;
715
- 'content-desc': string;
716
- 'long-clickable': boolean;
717
- scroll_info: ScrollInfo | null;
718
- }
719
- type QueueItem = [ClickStep[], ElemId, string[], ScrollInfo | null];
720
-
721
- interface PageRecord {
722
- page_id: string | null;
723
- label: string;
724
- timestamp: string;
725
- activity_info: Record<string, string>;
726
- clickable_count: number;
727
- clickable_elements: ClickableElement[];
728
- scroll_container_bounds: number[] | null;
729
- _tmp_dir?: string;
730
- _screenshots?: string[];
731
- _view_xmls?: string[];
732
- screenshot?: string;
733
- view_xml?: string;
734
- screenshots?: string[];
735
- view_xmls?: string[];
736
- click_path?: ClickStep[] | 'manual';
737
- came_from?: string;
738
- trigger_element?: string;
739
- description?: string;
740
- }
741
-
742
- function escapeHtml(s: string): string {
768
+ // interface ScrollInfo {
769
+ // scroll_index: number;
770
+ // container_bounds: number[];
771
+ // }
772
+ // interface ClickStep {
773
+ // from: string;
774
+ // element: string;
775
+ // center: number[];
776
+ // 'resource-id': string;
777
+ // text: string;
778
+ // 'content-desc': string;
779
+ // 'long-clickable': boolean;
780
+ // scroll_info: ScrollInfo | null;
781
+ // }
782
+ // type QueueItem = [ClickStep[], ElemId, string[], ScrollInfo | null];
783
+
784
+ // interface PageRecord {
785
+ // page_id: string | null;
786
+ // label: string;
787
+ // timestamp: string;
788
+ // /** 采集设备的密度/分辨率真值;measure_pack.js 优先采用,避免反解歧义。 */
789
+ // device?: DeviceInfo;
790
+ // activity_info: Record<string, string>;
791
+ // clickable_count: number;
792
+ // clickable_elements: ClickableElement[];
793
+ // scroll_container_bounds: number[] | null;
794
+ // _tmp_dir?: string;
795
+ // _screenshots?: string[];
796
+ // _view_xmls?: string[];
797
+ // screenshot?: string;
798
+ // view_xml?: string;
799
+ // screenshots?: string[];
800
+ // view_xmls?: string[];
801
+ // click_path?: ClickStep[] | 'manual';
802
+ // came_from?: string;
803
+ // trigger_element?: string;
804
+ // description?: string;
805
+ // }
806
+
807
+ function escapeHtml(s) {
743
808
  return s
744
809
  .replace(/&/g, '&amp;')
745
810
  .replace(/</g, '&lt;')
@@ -752,30 +817,30 @@ function escapeHtml(s: string): string {
752
817
  // PageCrawler:UI 遍历逻辑、页面采集、断点续跑、报告生成
753
818
  // ─────────────────────────────────────────────
754
819
  class PageCrawler {
755
- adb: ADBHelper;
756
- package: string;
757
- outputRoot: string;
758
- maxPages: number;
759
- maxScrolls: number;
760
- skipPatterns: string[];
820
+ adb;
821
+ package;
822
+ outputRoot;
823
+ maxPages;
824
+ maxScrolls;
825
+ skipPatterns;
761
826
 
762
827
  // 运行时状态
763
- pagesIndex: PageRecord[] = [];
764
- visitedSignatures = new Set<string>();
765
- pathSignatures: Record<string, string> = {};
766
- blockedElements = new Set<string>();
767
- queue: QueueItem[] = [];
828
+ pagesIndex = [];
829
+ visitedSignatures = new Set();
830
+ pathSignatures = {};
831
+ blockedElements = new Set();
832
+ queue = [];
768
833
  pageCounter = 0;
769
834
  stats = { skipped_elements: 0, probe_skipped: 0 };
770
835
 
771
- constructor(opts: {
772
- adb: ADBHelper;
773
- package: string;
774
- outputDir: string;
775
- maxPages?: number;
776
- maxScrolls?: number;
777
- skipPatterns?: string[];
778
- }) {
836
+ constructor(opts
837
+ // adb: ADBHelper;
838
+ // package: string;
839
+ // outputDir: string;
840
+ // maxPages?: number;
841
+ // maxScrolls?: number;
842
+ // skipPatterns?: string[];
843
+ ) {
779
844
  this.adb = opts.adb;
780
845
  this.package = opts.package;
781
846
  this.outputRoot = opts.outputDir;
@@ -784,7 +849,7 @@ class PageCrawler {
784
849
  this.skipPatterns = opts.skipPatterns ?? SKIP_RESOURCE_ID_PATTERNS_DEFAULT;
785
850
  }
786
851
 
787
- static elemIdOf(elem: ClickableElement): ElemId {
852
+ static elemIdOf(elem) {
788
853
  return [
789
854
  elem['resource-id'] ?? '',
790
855
  elem.text ?? '',
@@ -794,7 +859,7 @@ class PageCrawler {
794
859
  ];
795
860
  }
796
861
 
797
- shouldSkipElement(elem: ClickableElement): boolean {
862
+ shouldSkipElement(elem) {
798
863
  const rid = elem['resource-id'] ?? '';
799
864
  const text = elem.text ?? '';
800
865
  const desc = elem['content-desc'] ?? '';
@@ -804,7 +869,7 @@ class PageCrawler {
804
869
  return false;
805
870
  }
806
871
 
807
- isInTargetApp(activityInfo: Record<string, string>): boolean {
872
+ isInTargetApp(activityInfo) {
808
873
  const focus = activityInfo['current_focus'] ?? '';
809
874
  const resumed = activityInfo['resumed_activity'] ?? '';
810
875
  console.log(focus, resumed);
@@ -812,10 +877,10 @@ class PageCrawler {
812
877
  }
813
878
 
814
879
  scrollAndDump(
815
- pageDir: string,
816
- containerBounds: [number, number, number, number],
880
+ pageDir,
881
+ containerBounds,
817
882
  initialXml = '',
818
- ): { xmlList: string[]; xmlPaths: string[]; screenshotPaths: string[] } {
883
+ ) {
819
884
  const [x1, y1, , y2b] = containerBounds;
820
885
  const x2 = containerBounds[2];
821
886
  const y2 = y2b;
@@ -824,10 +889,10 @@ class PageCrawler {
824
889
  const swipeFromY = y1 + Math.trunc(height * 0.7);
825
890
  const swipeToY = y1 + Math.trunc(height * 0.3);
826
891
 
827
- const xmlList: string[] = [];
828
- const xmlPaths: string[] = [];
829
- const screenshotPaths: string[] = [];
830
- let prevKeys: Set<string> | null = initialXml ? extractNodeKeys(initialXml) : null;
892
+ const xmlList = [];
893
+ const xmlPaths = [];
894
+ const screenshotPaths = [];
895
+ let prevKeys = initialXml ? extractNodeKeys(initialXml) : null;
831
896
 
832
897
  for (let i = 0; i < this.maxScrolls; i++) {
833
898
  this.adb.shell(['input', 'swipe', String(cx), String(swipeFromY), String(cx), String(swipeToY), String(this.adb.scrollDurationMs)]);
@@ -854,12 +919,12 @@ class PageCrawler {
854
919
  return { xmlList, xmlPaths, screenshotPaths };
855
920
  }
856
921
 
857
- capturePage(label: string): {
858
- record: PageRecord;
859
- xmlContent: string;
860
- clickable: ClickableElement[];
861
- scrollContainerBounds: [number, number, number, number] | null;
862
- } {
922
+ capturePage(label)
923
+ // record: PageRecord;
924
+ // xmlContent: string;
925
+ // clickable: ClickableElement[];
926
+ // scrollContainerBounds: [number, number, number, number] | null;
927
+ {
863
928
  const tmpDir = path.join(this.outputRoot, '_tmp_capture');
864
929
  if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true });
865
930
  fs.mkdirSync(tmpDir, { recursive: true });
@@ -890,8 +955,8 @@ class PageCrawler {
890
955
  }
891
956
  }
892
957
 
893
- const seenClickable = new Set<string>();
894
- const clickable: ClickableElement[] = [];
958
+ const seenClickable = new Set();
959
+ const clickable = [];
895
960
  allXmlContents.forEach((xc, idx) => {
896
961
  for (const elem of getClickableElements(xc)) {
897
962
  const key = [
@@ -912,10 +977,11 @@ class PageCrawler {
912
977
  console.log(` 可点击元素: ${clickable.length} 个`);
913
978
 
914
979
  const scrollContainerBounds = scrollContainers.length > 0 ? scrollContainers[0].bounds : null;
915
- const record: PageRecord = {
980
+ const record = {
916
981
  page_id: null,
917
982
  label,
918
983
  timestamp: new Date().toISOString(),
984
+ device: this.adb.deviceInfo(),
919
985
  activity_info: actInfo,
920
986
  clickable_count: clickable.length,
921
987
  clickable_elements: clickable,
@@ -928,18 +994,18 @@ class PageCrawler {
928
994
  }
929
995
 
930
996
  restartToRoot(
931
- launchPackage: string | null,
932
- launchActivity: string | null,
933
- rootActivityKeyword: string | null = null,
934
- rootIdentity: RootIdentity | null = null,
935
- ): boolean {
997
+ launchPackage,
998
+ launchActivity,
999
+ rootActivityKeyword = null,
1000
+ rootIdentity = null,
1001
+ ) {
936
1002
  if (!launchPackage || !launchActivity) {
937
1003
  console.log(' [WARN] 包名或 Activity 名为空,无法重启');
938
1004
  return false;
939
1005
  }
940
1006
  const component = `${launchPackage}/${launchActivity}`;
941
1007
 
942
- const isAtRoot = (): boolean => {
1008
+ const isAtRoot = () => {
943
1009
  const activityInfo = this.adb.getCurrentActivity();
944
1010
  const focus = activityInfo['current_focus'] ?? '';
945
1011
  const resumed = activityInfo['resumed_activity'] ?? '';
@@ -984,13 +1050,13 @@ class PageCrawler {
984
1050
  }
985
1051
 
986
1052
  navigateViaRestart(
987
- clickPath: ClickStep[],
988
- launchPackage: string | null,
989
- launchActivity: string | null,
990
- rootActivityKeyword: string | null = null,
991
- stepSignatures: string[] | null = null,
992
- rootIdentity: RootIdentity | null = null,
993
- ): boolean {
1053
+ clickPath,
1054
+ launchPackage,
1055
+ launchActivity,
1056
+ rootActivityKeyword = null,
1057
+ stepSignatures = null,
1058
+ rootIdentity = null,
1059
+ ) {
994
1060
  if (!launchPackage || !launchActivity) {
995
1061
  console.log(' [WARN] 包名或 Activity 名为空,无法导航');
996
1062
  return false;
@@ -1006,10 +1072,10 @@ class PageCrawler {
1006
1072
  let xml = this.adb.dumpXmlQuick();
1007
1073
  const stepScroll = step.scroll_info;
1008
1074
  if (stepScroll && stepScroll.scroll_index > 0) {
1009
- this.adb.scrollToPosition(stepScroll.container_bounds as [number, number, number, number], stepScroll.scroll_index);
1075
+ this.adb.scrollToPosition(stepScroll.container_bounds, stepScroll.scroll_index);
1010
1076
  xml = this.adb.dumpXmlQuick();
1011
1077
  }
1012
- const elemId: [string, string, string, string] = [step['resource-id'] ?? '', step.text ?? '', step['content-desc'] ?? '', ''];
1078
+ const elemId = [step['resource-id'] ?? '', step.text ?? '', step['content-desc'] ?? '', ''];
1013
1079
  const found = findElementOnScreen(xml, elemId);
1014
1080
  const isLong = step['long-clickable'] ?? false;
1015
1081
  if (found && found.center) {
@@ -1040,43 +1106,43 @@ class PageCrawler {
1040
1106
  }
1041
1107
 
1042
1108
  // ── 断点续跑 ──
1043
- private saveQueue(): void {
1109
+ saveQueue() {
1044
1110
  const items = this.queue.map(([clickPath, elemId, stepSigs, scrollInfo]) => [clickPath, [...elemId], stepSigs, scrollInfo]);
1045
1111
  try {
1046
1112
  fs.writeFileSync(path.join(this.outputRoot, 'queue_checkpoint.json'), JSON.stringify(items), 'utf-8');
1047
1113
  } catch (e) {
1048
- console.log(` [WARN] 保存队列检查点失败: ${(e as Error).message}`);
1114
+ console.log(` [WARN] 保存队列检查点失败: ${(e).message}`);
1049
1115
  }
1050
1116
  }
1051
1117
 
1052
- private loadQueue(): QueueItem[] | null {
1118
+ loadQueue() {
1053
1119
  const qfile = path.join(this.outputRoot, 'queue_checkpoint.json');
1054
1120
  if (!fs.existsSync(qfile)) return null;
1055
1121
  try {
1056
- const items = JSON.parse(fs.readFileSync(qfile, 'utf-8')) as [ClickStep[], unknown[], string[], ScrollInfo | null][];
1122
+ const items = JSON.parse(fs.readFileSync(qfile, 'utf-8'));
1057
1123
  return items.map(([clickPath, elemIdList, stepSigs, scrollInfo]) => [
1058
1124
  clickPath,
1059
- elemIdList as ElemId,
1125
+ elemIdList,
1060
1126
  stepSigs,
1061
1127
  scrollInfo,
1062
1128
  ]);
1063
1129
  } catch (e) {
1064
- console.log(` [WARN] 加载队列检查点失败: ${(e as Error).message},将重建队列`);
1130
+ console.log(` [WARN] 加载队列检查点失败: ${(e).message},将重建队列`);
1065
1131
  return null;
1066
1132
  }
1067
1133
  }
1068
1134
 
1069
- private rebuildQueueFromPages(pagesIndex: PageRecord[]): QueueItem[] {
1070
- const queue: QueueItem[] = [];
1135
+ rebuildQueueFromPages(pagesIndex) {
1136
+ const queue = [];
1071
1137
  for (const page of pagesIndex) {
1072
- const clickPath = (Array.isArray(page.click_path) ? page.click_path : []) as ClickStep[];
1138
+ const clickPath = (Array.isArray(page.click_path) ? page.click_path : []);
1073
1139
  const clickable = page.clickable_elements ?? [];
1074
1140
  const scrollContainerBounds = page.scroll_container_bounds;
1075
1141
  for (const elem of clickable) {
1076
1142
  if (this.shouldSkipElement(elem)) continue;
1077
1143
  const elemId = PageCrawler.elemIdOf(elem);
1078
1144
  const si = elem.scroll_index ?? 0;
1079
- let scrollInfo: ScrollInfo | null = null;
1145
+ let scrollInfo = null;
1080
1146
  if (si > 0 && scrollContainerBounds) scrollInfo = { scroll_index: si, container_bounds: [...scrollContainerBounds] };
1081
1147
  queue.push([clickPath, elemId, [], scrollInfo]);
1082
1148
  }
@@ -1084,9 +1150,9 @@ class PageCrawler {
1084
1150
  return queue;
1085
1151
  }
1086
1152
 
1087
- private tryLoadCheckpoint():
1088
- | { pagesIndex: PageRecord[]; visited: Set<string>; pageCounter: number; queue: QueueItem[]; blocked: Set<string> }
1089
- | null {
1153
+ tryLoadCheckpoint()
1154
+ // | { pagesIndex: PageRecord[]; visited: Set<string>; pageCounter: number; queue: QueueItem[]; blocked: Set<string> }
1155
+ {
1090
1156
  const indexFile = path.join(this.outputRoot, 'index.json');
1091
1157
  if (!fs.existsSync(indexFile)) return null;
1092
1158
  const pageDirs = fs.existsSync(this.outputRoot)
@@ -1094,17 +1160,17 @@ class PageCrawler {
1094
1160
  : [];
1095
1161
  if (pageDirs.length === 0) return null;
1096
1162
 
1097
- let pagesIndex: PageRecord[];
1163
+ let pagesIndex;
1098
1164
  try {
1099
- pagesIndex = JSON.parse(fs.readFileSync(indexFile, 'utf-8')) as PageRecord[];
1165
+ pagesIndex = JSON.parse(fs.readFileSync(indexFile, 'utf-8'));
1100
1166
  } catch (e) {
1101
- console.log(` [WARN] 读取 index.json 失败: ${(e as Error).message},重新开始`);
1167
+ console.log(` [WARN] 读取 index.json 失败: ${(e).message},重新开始`);
1102
1168
  return null;
1103
1169
  }
1104
1170
  if (!pagesIndex || pagesIndex.length === 0) return null;
1105
1171
 
1106
1172
  console.log(`\n⏩ 发现已有检查点:${pagesIndex.length} 个页面,尝试断点续跑...`);
1107
- const visited = new Set<string>();
1173
+ const visited = new Set();
1108
1174
  let pageCounter = 0;
1109
1175
  for (const page of pagesIndex) {
1110
1176
  const pageId = page.page_id ?? '';
@@ -1119,7 +1185,7 @@ class PageCrawler {
1119
1185
  }
1120
1186
 
1121
1187
  const savedQueue = this.loadQueue();
1122
- let queue: QueueItem[];
1188
+ let queue;
1123
1189
  if (savedQueue !== null) {
1124
1190
  queue = savedQueue;
1125
1191
  console.log(` 队列已从检查点恢复:${queue.length} 个待处理元素(精确续跑)`);
@@ -1127,15 +1193,15 @@ class PageCrawler {
1127
1193
  queue = this.rebuildQueueFromPages(pagesIndex);
1128
1194
  console.log(` 队列已重建(fallback):${queue.length} 个元素(含已探索项,BFS 会自动跳过)`);
1129
1195
  }
1130
- const blocked = new Set<string>();
1196
+ const blocked = new Set();
1131
1197
  console.log(` 已恢复 ${visited.size} 个已访问签名,队列 ${queue.length} 个待处理元素`);
1132
1198
  return { pagesIndex, visited, pageCounter, queue, blocked };
1133
1199
  }
1134
1200
 
1135
1201
  // ── 页面提交 ──
1136
- private finalizePage(record: PageRecord, pageId: string): void {
1202
+ finalizePage(record, pageId) {
1137
1203
  const pageDir = path.join(this.outputRoot, pageId);
1138
- const tmpDir = record._tmp_dir!;
1204
+ const tmpDir = record._tmp_dir;
1139
1205
  if (fs.existsSync(tmpDir)) fs.renameSync(tmpDir, pageDir);
1140
1206
  else fs.mkdirSync(pageDir, { recursive: true });
1141
1207
 
@@ -1165,7 +1231,7 @@ class PageCrawler {
1165
1231
  console.log(` ✅ 已保存为 [${pageId}]`);
1166
1232
  }
1167
1233
 
1168
- private activityNameSuffix(record: PageRecord): string {
1234
+ activityNameSuffix(record) {
1169
1235
  const resumed = record.activity_info?.['resumed_activity'] ?? '';
1170
1236
  let mAct = resumed.match(/[\w.]+\/([\w.]+)/);
1171
1237
  if (!mAct) {
@@ -1180,7 +1246,7 @@ class PageCrawler {
1180
1246
  return activityName ? `_${activityName}` : '';
1181
1247
  }
1182
1248
 
1183
- commitPage(record: PageRecord): string {
1249
+ commitPage(record) {
1184
1250
  this.pageCounter += 1;
1185
1251
  const suffix = this.activityNameSuffix(record);
1186
1252
  const pageId = `page_${String(this.pageCounter).padStart(4, '0')}${suffix}`;
@@ -1188,7 +1254,7 @@ class PageCrawler {
1188
1254
  return pageId;
1189
1255
  }
1190
1256
 
1191
- commitManualPage(record: PageRecord, pageCounter: number): string {
1257
+ commitManualPage(record, pageCounter) {
1192
1258
  const suffix = this.activityNameSuffix(record);
1193
1259
  const pageId = `manual_${String(pageCounter).padStart(4, '0')}${suffix}`;
1194
1260
  this.finalizePage(record, pageId);
@@ -1196,13 +1262,13 @@ class PageCrawler {
1196
1262
  }
1197
1263
 
1198
1264
  // ── HTML 报告生成 ──
1199
- generateHtmlReport(pages: PageRecord[]): void {
1265
+ generateHtmlReport(pages) {
1200
1266
  let cards = '';
1201
1267
  for (const p of pages) {
1202
1268
  const activity = escapeHtml(p.activity_info?.['current_focus'] ?? 'N/A');
1203
1269
  const resumed = escapeHtml(p.activity_info?.['resumed_activity'] ?? 'N/A');
1204
1270
  const clickPath = p.click_path ?? [];
1205
- let pathDisplay: string;
1271
+ let pathDisplay;
1206
1272
  if (clickPath === 'manual') {
1207
1273
  pathDisplay = `(手动采集:${escapeHtml(p.description ?? '手动采集')})`;
1208
1274
  } else if (Array.isArray(clickPath) && clickPath.length > 0) {
@@ -1220,7 +1286,7 @@ class PageCrawler {
1220
1286
  const screenshotPath = escapeHtml(p.screenshot ?? '');
1221
1287
  const screenshots = p.screenshots ?? [p.screenshot ?? ''];
1222
1288
  const viewXmls = p.view_xmls ?? [p.view_xml ?? ''];
1223
- let screenshotsHtml: string;
1289
+ let screenshotsHtml;
1224
1290
  if (screenshots.length > 1) {
1225
1291
  let pairsHtml = '';
1226
1292
  screenshots.forEach((ss, idx) => {
@@ -1240,7 +1306,7 @@ class PageCrawler {
1240
1306
  const labelEscaped = escapeHtml(p.label);
1241
1307
  const pageIdEscaped = escapeHtml(p.page_id ?? '');
1242
1308
  const viewXmlEscaped = escapeHtml(p.view_xml ?? '');
1243
- let xmlLinksHtml: string;
1309
+ let xmlLinksHtml;
1244
1310
  if (viewXmls.length > 1) {
1245
1311
  xmlLinksHtml = viewXmls
1246
1312
  .map((vx, i) => `<a href="${escapeHtml(vx)}" target="_blank">📄 XML${i === 0 ? '' : `_${i}`}</a>`)
@@ -1273,7 +1339,7 @@ class PageCrawler {
1273
1339
  }
1274
1340
 
1275
1341
  const now = new Date();
1276
- const pad = (x: number): string => String(x).padStart(2, '0');
1342
+ const pad = (x) => String(x).padStart(2, '0');
1277
1343
  const genTime = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
1278
1344
 
1279
1345
  const html = `<!DOCTYPE html>
@@ -1321,17 +1387,17 @@ ${cards}
1321
1387
  fs.writeFileSync(path.join(this.outputRoot, 'report.html'), html, 'utf-8');
1322
1388
  }
1323
1389
 
1324
- private flushReport(): void {
1390
+ flushReport() {
1325
1391
  try {
1326
1392
  fs.writeFileSync(path.join(this.outputRoot, 'index.json'), JSON.stringify(this.pagesIndex, null, 2), 'utf-8');
1327
1393
  this.generateHtmlReport(this.pagesIndex);
1328
1394
  this.saveQueue();
1329
1395
  } catch (e) {
1330
- console.log(` [WARN] 写入报告失败: ${(e as Error).message}`);
1396
+ console.log(` [WARN] 写入报告失败: ${(e).message}`);
1331
1397
  }
1332
1398
  }
1333
1399
 
1334
- crawl(): void {
1400
+ crawl() {
1335
1401
  if (!this.adb.checkDevice()) return;
1336
1402
 
1337
1403
  let activityInfo = this.adb.getCurrentActivity();
@@ -1361,8 +1427,8 @@ ${cards}
1361
1427
  }
1362
1428
  }
1363
1429
 
1364
- let launchPackage: string | null = null;
1365
- let launchActivity: string | null = null;
1430
+ let launchPackage = null;
1431
+ let launchActivity = null;
1366
1432
  const comp = extractLaunchComponent(activityInfo);
1367
1433
  if (comp) {
1368
1434
  [launchPackage, launchActivity] = comp;
@@ -1373,7 +1439,7 @@ ${cards}
1373
1439
  );
1374
1440
  }
1375
1441
 
1376
- let rootActivityKeyword: string | null = null;
1442
+ let rootActivityKeyword = null;
1377
1443
  if (launchActivity) {
1378
1444
  const parts = launchActivity.split('.');
1379
1445
  rootActivityKeyword = parts[parts.length - 1];
@@ -1386,7 +1452,7 @@ ${cards}
1386
1452
  console.log('开始遍历(加速版:BFS + 轻量探测)...');
1387
1453
  console.log('='.repeat(60));
1388
1454
 
1389
- let rootIdentity: RootIdentity | null = null;
1455
+ let rootIdentity = null;
1390
1456
  const checkpoint = this.tryLoadCheckpoint();
1391
1457
  if (checkpoint) {
1392
1458
  this.pagesIndex = checkpoint.pagesIndex;
@@ -1397,7 +1463,7 @@ ${cards}
1397
1463
  console.log(`⏩ 断点续跑:从第 ${this.pageCounter + 1} 个页面继续\n`);
1398
1464
  for (const p of this.pagesIndex) {
1399
1465
  if (!p.click_path || (Array.isArray(p.click_path) && p.click_path.length === 0)) {
1400
- const rootXmlPath = path.join(this.outputRoot, p.page_id!, 'view.xml');
1466
+ const rootXmlPath = path.join(this.outputRoot, p.page_id, 'view.xml');
1401
1467
  if (fs.existsSync(rootXmlPath)) {
1402
1468
  const rootXml = fs.readFileSync(rootXmlPath, 'utf-8');
1403
1469
  rootIdentity = rootIdentifiedSet(p.activity_info ?? {}, rootXml);
@@ -1424,15 +1490,15 @@ ${cards}
1424
1490
  }
1425
1491
  const elemId = PageCrawler.elemIdOf(elem);
1426
1492
  const si = elem.scroll_index ?? 0;
1427
- let scrollInfo: ScrollInfo | null = null;
1493
+ let scrollInfo = null;
1428
1494
  if (si > 0 && scrollContainerBounds) scrollInfo = { scroll_index: si, container_bounds: [...scrollContainerBounds] };
1429
1495
  this.queue.push([[], elemId, [], scrollInfo]);
1430
1496
  }
1431
1497
  }
1432
1498
 
1433
- const failedPaths = new Set<string>();
1499
+ const failedPaths = new Set();
1434
1500
  while (this.queue.length > 0 && this.pageCounter < this.maxPages) {
1435
- const [clickPath, elemId, stepSignatures, scrollInfo] = this.queue.shift()!;
1501
+ const [clickPath, elemId, stepSignatures, scrollInfo] = this.queue.shift();
1436
1502
  const elemKey = JSON.stringify(elemId);
1437
1503
  if (this.blockedElements.has(elemKey)) continue;
1438
1504
 
@@ -1470,7 +1536,7 @@ ${cards}
1470
1536
  let freshXml = this.adb.dumpXmlQuick();
1471
1537
  if (scrollInfo && scrollInfo.scroll_index > 0) {
1472
1538
  console.log(` 📜 滚动 ${scrollInfo.scroll_index} 次到达元素位置`);
1473
- this.adb.scrollToPosition(scrollInfo.container_bounds as [number, number, number, number], scrollInfo.scroll_index);
1539
+ this.adb.scrollToPosition(scrollInfo.container_bounds, scrollInfo.scroll_index);
1474
1540
  freshXml = this.adb.dumpXmlQuick();
1475
1541
  }
1476
1542
 
@@ -1508,7 +1574,7 @@ ${cards}
1508
1574
  this.visitedSignatures.add(newSig);
1509
1575
  const pageId = this.commitPage(newRecord);
1510
1576
 
1511
- const newClickPath: ClickStep[] = [
1577
+ const newClickPath = [
1512
1578
  ...clickPath,
1513
1579
  {
1514
1580
  from: pathDesc,
@@ -1538,7 +1604,7 @@ ${cards}
1538
1604
  }
1539
1605
  const newEid = PageCrawler.elemIdOf(ne);
1540
1606
  const si = ne.scroll_index ?? 0;
1541
- let neScrollInfo: ScrollInfo | null = null;
1607
+ let neScrollInfo = null;
1542
1608
  if (si > 0 && newScrollBounds) neScrollInfo = { scroll_index: si, container_bounds: [...newScrollBounds] };
1543
1609
  this.queue.push([newClickPath, newEid, newStepSigs, neScrollInfo]);
1544
1610
  }
@@ -1563,26 +1629,65 @@ ${cards}
1563
1629
  console.log('='.repeat(60));
1564
1630
  }
1565
1631
 
1566
- async manualCrawl(): Promise<void> {
1567
- if (!this.adb.checkDevice()) return;
1568
- fs.mkdirSync(this.outputRoot, { recursive: true });
1569
-
1570
- let pagesIndex: PageRecord[] = [];
1632
+ /** 读取已有 index.json(不存在或损坏时返回空数组)。手动/单页采集共用。 */
1633
+ loadExistingIndex() {
1571
1634
  const indexFile = path.join(this.outputRoot, 'index.json');
1572
- if (fs.existsSync(indexFile)) {
1573
- try {
1574
- pagesIndex = JSON.parse(fs.readFileSync(indexFile, 'utf-8')) as PageRecord[];
1575
- console.log(`📂 已加载 ${pagesIndex.length} 个已有页面`);
1576
- } catch (e) {
1577
- console.log(`[WARN] 读取 index.json 失败: ${(e as Error).message},将创建新索引`);
1578
- }
1635
+ if (!fs.existsSync(indexFile)) return [];
1636
+ try {
1637
+ return JSON.parse(fs.readFileSync(indexFile, 'utf-8'));
1638
+ } catch (e) {
1639
+ console.log(`[WARN] 读取 index.json 失败: ${(e).message},将创建新索引`);
1640
+ return [];
1579
1641
  }
1642
+ }
1580
1643
 
1581
- let manualCounter = 0;
1644
+ /** 已有页面里最大的 manual_NNNN 编号,用于续编号。 */
1645
+ static lastManualCounter(pagesIndex) {
1646
+ let counter = 0;
1582
1647
  for (const p of pagesIndex) {
1583
1648
  const mm = (p.page_id ?? '').match(/manual_(\d+)/);
1584
- if (mm) manualCounter = Math.max(manualCounter, parseInt(mm[1], 10));
1649
+ if (mm) counter = Math.max(counter, parseInt(mm[1], 10));
1585
1650
  }
1651
+ return counter;
1652
+ }
1653
+
1654
+ /**
1655
+ * 单页采集模式(--page "<描述>"):不做任何导航,直接把设备**当前**页面
1656
+ * 当作待采集页抓一次(截图 + view.xml + 可滚动容器滚动采集),写入 index.json /
1657
+ * report.html 后退出。等价于 manualCrawl 里输入一次描述、再输入 q,但无需交互,
1658
+ * 便于外部脚本按自己的 UI 序列逐页调用。
1659
+ */
1660
+ capturePageOnce(description) {
1661
+ if (!this.adb.checkDevice()) return;
1662
+ fs.mkdirSync(this.outputRoot, { recursive: true });
1663
+
1664
+ const pagesIndex = this.loadExistingIndex();
1665
+ if (pagesIndex.length > 0) console.log(`📂 已加载 ${pagesIndex.length} 个已有页面`);
1666
+ const manualCounter = PageCrawler.lastManualCounter(pagesIndex) + 1;
1667
+
1668
+ console.log(`\n📂 输出目录: ${path.resolve(this.outputRoot)}`);
1669
+ console.log(`📝 单页采集模式:${description}`);
1670
+
1671
+ const { record } = this.capturePage(description);
1672
+ record.click_path = 'manual';
1673
+ record.description = description;
1674
+ const pageId = this.commitManualPage(record, manualCounter);
1675
+ pagesIndex.push(record);
1676
+ fs.writeFileSync(path.join(this.outputRoot, 'index.json'), JSON.stringify(pagesIndex, null, 2), 'utf-8');
1677
+ this.pagesIndex = pagesIndex;
1678
+ this.generateHtmlReport(pagesIndex);
1679
+
1680
+ console.log(`\n✅ 单页采集完成:[${pageId}](index.json 共 ${pagesIndex.length} 个页面)`);
1681
+ console.log(`📂 输出目录: ${path.resolve(this.outputRoot)}`);
1682
+ }
1683
+
1684
+ async manualCrawl() {
1685
+ if (!this.adb.checkDevice()) return;
1686
+ fs.mkdirSync(this.outputRoot, { recursive: true });
1687
+
1688
+ const pagesIndex = this.loadExistingIndex();
1689
+ if (pagesIndex.length > 0) console.log(`📂 已加载 ${pagesIndex.length} 个已有页面`);
1690
+ let manualCounter = PageCrawler.lastManualCounter(pagesIndex);
1586
1691
 
1587
1692
  console.log(`\n📂 输出目录: ${path.resolve(this.outputRoot)}`);
1588
1693
  console.log(`📝 手动采集模式已启动(manual 编号从 ${manualCounter + 1} 开始)`);
@@ -1594,7 +1699,7 @@ ${cards}
1594
1699
  console.log('='.repeat(60));
1595
1700
 
1596
1701
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1597
- const question = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
1702
+ const question = (q) => new Promise((resolve) => rl.question(q, resolve));
1598
1703
 
1599
1704
  try {
1600
1705
  for (;;) {
@@ -1611,7 +1716,7 @@ ${cards}
1611
1716
  record.description = desc;
1612
1717
  this.commitManualPage(record, manualCounter);
1613
1718
  pagesIndex.push(record);
1614
- fs.writeFileSync(indexFile, JSON.stringify(pagesIndex, null, 2), 'utf-8');
1719
+ fs.writeFileSync(path.join(this.outputRoot, 'index.json'), JSON.stringify(pagesIndex, null, 2), 'utf-8');
1615
1720
  this.pagesIndex = pagesIndex;
1616
1721
  this.generateHtmlReport(pagesIndex);
1617
1722
  console.log(` 📋 index.json 和 report.html 已更新(共 ${pagesIndex.length} 个页面)`);
@@ -1627,26 +1732,28 @@ ${cards}
1627
1732
  // ─────────────────────────────────────────────
1628
1733
  // CLI
1629
1734
  // ─────────────────────────────────────────────
1630
- interface CliArgs {
1631
- package: string;
1632
- output: string;
1633
- manual: boolean;
1634
- device: string;
1635
- adbPath: string;
1636
- maxPages: number;
1637
- maxScrolls: number;
1638
- }
1639
-
1640
- function printHelp(): void {
1735
+ // interface CliArgs {
1736
+ // package: string;
1737
+ // output: string;
1738
+ // manual: boolean;
1739
+ // page: string | null;
1740
+ // device: string;
1741
+ // adbPath: string;
1742
+ // maxPages: number;
1743
+ // maxScrolls: number;
1744
+ // }
1745
+
1746
+ function printHelp() {
1641
1747
  console.log(
1642
1748
  [
1643
1749
  'Android 模拟器页面遍历工具(加速版)',
1644
1750
  '',
1645
- 'Usage: node android_parse_fast.ts --package <pkg> --output <dir> [options]',
1751
+ 'Usage: node android_parse_fast.js --package <pkg> --output <dir> [options]',
1646
1752
  '',
1647
1753
  ' --package, -p 目标 App 包名(必选)',
1648
1754
  ' --output, -o 输出目录(必选)',
1649
1755
  ' --manual 手动采集模式:手动导航到页面后按回车触发采集',
1756
+ ' --page "<描述>" 单页采集模式:直接用该描述采集设备当前页面,抓完一页即退出(不遍历)',
1650
1757
  ` --device ADB 设备 serial(默认: ${DEVICE_SERIAL_DEFAULT})`,
1651
1758
  ` --adb-path adb 可执行文件路径(默认: ${ADB_PATH_DEFAULT})`,
1652
1759
  ` --max-pages 最多遍历页面数(默认: ${MAX_PAGES_DEFAULT})`,
@@ -1655,11 +1762,12 @@ function printHelp(): void {
1655
1762
  );
1656
1763
  }
1657
1764
 
1658
- function parseArgs(argv: string[]): CliArgs {
1659
- const args: CliArgs = {
1765
+ function parseArgs(argv) {
1766
+ const args = {
1660
1767
  package: '',
1661
1768
  output: '',
1662
1769
  manual: false,
1770
+ page: null,
1663
1771
  device: DEVICE_SERIAL_DEFAULT,
1664
1772
  adbPath: ADB_PATH_DEFAULT,
1665
1773
  maxPages: MAX_PAGES_DEFAULT,
@@ -1667,7 +1775,7 @@ function parseArgs(argv: string[]): CliArgs {
1667
1775
  };
1668
1776
  for (let i = 0; i < argv.length; i++) {
1669
1777
  const a = argv[i];
1670
- const next = (): string => {
1778
+ const next = () => {
1671
1779
  if (i + 1 >= argv.length) {
1672
1780
  console.error(`Missing value for ${a}`);
1673
1781
  process.exit(2);
@@ -1677,6 +1785,7 @@ function parseArgs(argv: string[]): CliArgs {
1677
1785
  if (a === '--package' || a === '-p') args.package = next();
1678
1786
  else if (a === '--output' || a === '-o') args.output = next();
1679
1787
  else if (a === '--manual') args.manual = true;
1788
+ else if (a === '--page') args.page = next();
1680
1789
  else if (a === '--device') args.device = next();
1681
1790
  else if (a === '--adb-path') args.adbPath = next();
1682
1791
  else if (a === '--max-pages') args.maxPages = parseInt(next(), 10);
@@ -1697,10 +1806,14 @@ function parseArgs(argv: string[]): CliArgs {
1697
1806
  console.error('error: --output/-o is required');
1698
1807
  process.exit(2);
1699
1808
  }
1809
+ if (args.page !== null && args.manual) {
1810
+ console.error('error: --page 与 --manual 互斥');
1811
+ process.exit(2);
1812
+ }
1700
1813
  return args;
1701
1814
  }
1702
1815
 
1703
- async function main(): Promise<number> {
1816
+ async function main() {
1704
1817
  const args = parseArgs(process.argv.slice(2));
1705
1818
  const adb = new ADBHelper({ adbPath: args.adbPath, deviceSerial: args.device, targetPackage: args.package });
1706
1819
  const crawler = new PageCrawler({
@@ -1710,7 +1823,8 @@ async function main(): Promise<number> {
1710
1823
  maxPages: args.maxPages,
1711
1824
  maxScrolls: args.maxScrolls,
1712
1825
  });
1713
- if (args.manual) await crawler.manualCrawl();
1826
+ if (args.page !== null) crawler.capturePageOnce(args.page.trim() || '单页采集');
1827
+ else if (args.manual) await crawler.manualCrawl();
1714
1828
  else crawler.crawl();
1715
1829
  return 0;
1716
1830
  }