android2harmony 0.1.4 → 0.1.6

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 (116) hide show
  1. package/README.md +1 -407
  2. package/agents/self-tester.md +55 -376
  3. package/dist/index.js +238 -124
  4. package/dist/index.js.map +4 -4
  5. package/package.json +36 -32
  6. package/skills/a2h-resource-convert/SKILL.md +931 -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 +2186 -0
  14. package/skills/a2h-resource-convert/scripts/app_identity.js +741 -0
  15. package/skills/a2h-resource-convert/scripts/code_vector_icons.js +607 -0
  16. package/skills/a2h-resource-convert/scripts/package.json +3 -0
  17. package/skills/a2h-resource-convert/scripts/svg_fidelity_check.js +632 -0
  18. package/skills/a2h-ui-transfer/SKILL.md +431 -0
  19. package/skills/a2h-ui-transfer/references/conversion-procedure.md +547 -0
  20. 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
  21. 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
  22. package/skills/{hmos-batch-ui-align/scripts/android_parse_fast.ts → a2h-ui-transfer/scripts/android_parse_fast.js} +532 -301
  23. package/skills/a2h-ui-transfer/scripts/arkts_static_check.js +1624 -0
  24. package/skills/a2h-ui-transfer/scripts/measure_pack.js +1005 -0
  25. package/skills/a2h-ui-transfer/scripts/package.json +3 -0
  26. package/skills/hmos-fix-build-errors/SKILL.md +1 -1
  27. package/skills/hmos-incremental-ui-align/README.md +15 -15
  28. package/skills/hmos-incremental-ui-align/SKILL.md +15 -15
  29. package/skills/hmos-incremental-ui-align/references/State_Model_Template.md +2 -2
  30. package/skills/hmos-incremental-ui-align/scripts/app_feature_verify.js +790 -0
  31. package/skills/hmos-incremental-ui-align/scripts/extract_checklist.js +285 -0
  32. package/skills/hmos-incremental-ui-align/scripts/navigation-capure.md +76 -76
  33. package/skills/hmos-incremental-ui-align/scripts/page_capture.js +756 -0
  34. package/skills/hmos-incremental-ui-align/scripts/page_capture_burst.js +155 -0
  35. package/skills/hmos-integration-test/README.md +341 -0
  36. package/skills/hmos-integration-test/SKILL.md +446 -0
  37. package/skills/hmos-integration-test/scripts/report-tool.mjs +646 -0
  38. package/skills/hmos-integration-test/scripts/resolve-metadata-tool.mjs +147 -0
  39. package/skills/hmos-integration-test/scripts/self-test-runner.mjs +1006 -0
  40. package/skills/hmos-integration-test/scripts/testcases-tool.mjs +189 -0
  41. package/skills/hmos-spec-generate/SKILL.md +26 -24
  42. package/skills/hmos-spec-generate/scripts/parse_requirements.ts +515 -0
  43. package/skills/hmos-spec-generate/template/REQ.txt +22 -0
  44. package/skills/hmos-spec-generate/template/REQ.xlsx +0 -0
  45. package/skills/hmos-batch-ui-align/SKILL.md +0 -141
  46. package/skills/hmos-batch-ui-align/references/conversion-procedure.md +0 -217
  47. package/skills/hmos-incremental-ui-align/scripts/app_feature_verify.ts +0 -999
  48. package/skills/hmos-incremental-ui-align/scripts/extract_checklist.ts +0 -343
  49. package/skills/hmos-incremental-ui-align/scripts/page_capture.ts +0 -977
  50. package/skills/hmos-incremental-ui-align/scripts/page_capture_burst.ts +0 -188
  51. package/skills/hmos-resources-convert/SKILL.md +0 -654
  52. package/skills/hmos-resources-convert/template/AppScope/app.json5 +0 -10
  53. package/skills/hmos-resources-convert/template/AppScope/resources/base/element/string.json +0 -8
  54. package/skills/hmos-resources-convert/template/AppScope/resources/base/media/background.png +0 -0
  55. package/skills/hmos-resources-convert/template/AppScope/resources/base/media/foreground.png +0 -0
  56. package/skills/hmos-resources-convert/template/AppScope/resources/base/media/layered_image.json +0 -7
  57. package/skills/hmos-resources-convert/template/build-profile.json5 +0 -42
  58. package/skills/hmos-resources-convert/template/code-linter.json5 +0 -32
  59. package/skills/hmos-resources-convert/template/entry/build-profile.json5 +0 -33
  60. package/skills/hmos-resources-convert/template/entry/hvigorfile.ts +0 -6
  61. package/skills/hmos-resources-convert/template/entry/obfuscation-rules.txt +0 -23
  62. package/skills/hmos-resources-convert/template/entry/oh-package.json5 +0 -10
  63. package/skills/hmos-resources-convert/template/entry/src/main/ets/entryability/EntryAbility.ets +0 -48
  64. package/skills/hmos-resources-convert/template/entry/src/main/ets/entrybackupability/EntryBackupAbility.ets +0 -16
  65. package/skills/hmos-resources-convert/template/entry/src/main/ets/pages/Index.ets +0 -23
  66. package/skills/hmos-resources-convert/template/entry/src/main/module.json5 +0 -55
  67. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/element/color.json +0 -8
  68. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/element/float.json +0 -8
  69. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/element/string.json +0 -16
  70. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/media/background.png +0 -0
  71. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/media/foreground.png +0 -0
  72. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/media/layered_image.json +0 -7
  73. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/media/startIcon.png +0 -0
  74. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/profile/backup_config.json +0 -3
  75. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/profile/main_pages.json +0 -5
  76. package/skills/hmos-resources-convert/template/entry/src/main/resources/dark/element/color.json +0 -8
  77. package/skills/hmos-resources-convert/template/entry/src/mock/mock-config.json5 +0 -2
  78. package/skills/hmos-resources-convert/template/entry/src/ohosTest/ets/test/Ability.test.ets +0 -35
  79. package/skills/hmos-resources-convert/template/entry/src/ohosTest/ets/test/List.test.ets +0 -5
  80. package/skills/hmos-resources-convert/template/entry/src/ohosTest/module.json5 +0 -16
  81. package/skills/hmos-resources-convert/template/entry/src/test/List.test.ets +0 -5
  82. package/skills/hmos-resources-convert/template/entry/src/test/LocalUnit.test.ets +0 -33
  83. package/skills/hmos-resources-convert/template/hvigor/hvigor-config.json5 +0 -23
  84. package/skills/hmos-resources-convert/template/hvigorfile.ts +0 -6
  85. package/skills/hmos-resources-convert/template/oh-package-lock.json5 +0 -28
  86. package/skills/hmos-resources-convert/template/oh-package.json5 +0 -10
  87. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mappings/android-to-harmonyOS-ui-atomic-component-mapping-reference.md +0 -0
  88. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mappings/android-to-harmonyOS-ui-interaction-mapping-reference.md +0 -0
  89. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mappings/android-to-harmonyOS-ui-layout-mapping-reference.md +0 -0
  90. /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
  91. /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
  92. /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
  93. /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
  94. /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
  95. /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
  96. /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
  97. /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
  98. /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
  99. /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
  100. /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
  101. /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
  102. /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
  103. /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
  104. /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
  105. /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
  106. /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
  107. /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
  108. /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
  109. /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
  110. /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
  111. /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
  112. /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
  113. /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
  114. /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
  115. /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
  116. /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,16 +344,36 @@ 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
 
351
+ /**
352
+ * 从已采集的首屏 activity_info 里取"根页面 Activity"的短名,用于 restartToRoot 判定是否已回到根页。
353
+ *
354
+ * 必须用**实际落地的首屏**,而不是启动组件:入口可能是 trampoline
355
+ * (com.ichi2.anki/.IntentHandler),落地页却是 DeckPicker,用入口短名做关键字会永远判不到根页。
356
+ */
357
+ function rootKeywordFromActivityInfo(activityInfo) {
358
+ for (const key of ['resumed_activity', 'current_focus', 'focused_app']) {
359
+ const s = activityInfo?.[key] ?? '';
360
+ const m = s.match(/[\w.]+\/[\w.$]+/);
361
+ if (!m) continue;
362
+ const comp = normalizeComponent(m[0]);
363
+ const act = comp.split('/')[1] ?? '';
364
+ const parts = act.split('.');
365
+ const short = parts[parts.length - 1];
366
+ if (short) return short;
367
+ }
368
+ return null;
369
+ }
370
+
351
371
  /**
352
372
  * 从 activity 信息提取启动组件 [package, activity]。
353
373
  * 优先 current_focus(通常是全限定 Activity),回退 resumed_activity(可能是相对名 `.ui.Main`,
354
374
  * am start 同样接受)。都取不到返回 null。
355
375
  */
356
- function extractLaunchComponent(activityInfo: Record<string, string>): [string, string] | null {
376
+ function extractLaunchComponent(activityInfo) {
357
377
  for (const key of ['current_focus', 'resumed_activity']) {
358
378
  const s = activityInfo[key] ?? '';
359
379
  const m = s.match(/([\w.]+)\/([\w.]+)/);
@@ -362,14 +382,14 @@ function extractLaunchComponent(activityInfo: Record<string, string>): [string,
362
382
  return null;
363
383
  }
364
384
 
365
- type ElemId = [string, string, string, string, boolean];
385
+ // type ElemId = [string, string, string, string, boolean];
366
386
 
367
- function findElementOnScreen(xmlContent: string, elemId: ElemId | [string, string, string, string]): ClickableElement | null {
387
+ function findElementOnScreen(xmlContent, elemId) {
368
388
  const rid = elemId[0];
369
389
  const text = elemId[1];
370
390
  const desc = elemId[2];
371
391
  const bounds = elemId[3];
372
- const isLong = elemId.length >= 5 ? (elemId[4] as boolean) : null;
392
+ const isLong = elemId.length >= 5 ? (elemId[4]) : null;
373
393
  const clickableElements = getClickableElements(xmlContent);
374
394
 
375
395
  if (bounds) {
@@ -398,28 +418,55 @@ function findElementOnScreen(xmlContent: string, elemId: ElemId | [string, strin
398
418
  return null;
399
419
  }
400
420
 
421
+ // ─────────────────────────────────────────────
422
+ // 设备常量:密度与物理分辨率
423
+ // ─────────────────────────────────────────────
424
+ /**
425
+ * 采集设备的密度与分辨率 —— 每页写进 meta.json 的 `device` 字段。
426
+ *
427
+ * 为什么必须在采集期记:密度是「同一台设备上每页同值」的常量,而下游 measure_pack.js
428
+ * 只能拿 view tree 里正方形节点的边长去**反解**它。反解在数学上不唯一 —— 72px 既能解释成
429
+ * 24dp@3.0,也能解释成 36dp@2.0 / 48dp@1.5,整个谐波族全部自洽;工具栏图标尺寸单一的页面
430
+ * 更是必然平局。一旦解错,该页所有 vp 数值整体差 2~3 倍,而编译、引用检查、跨页一致性
431
+ * 全都照样通过(因为每页错得一样)。`wm density` 一行就能拿到真值,采集期不记 = 主动丢弃它。
432
+ */
433
+ // interface DeviceInfo {
434
+ // /** 生效 dpi(覆盖优先)。取不到为 null。 */
435
+ // dpi: number | null;
436
+ // /** dpi / 160,即 ArkUI 侧 px→vp 的换算系数。取不到为 null。 */
437
+ // density: number | null;
438
+ // /** 密度取自哪条来源,便于复核。 */
439
+ // density_source: 'wm density (override)' | 'wm density (physical)' | 'unavailable';
440
+ // width_px: number | null;
441
+ // height_px: number | null;
442
+ // /** 原始输出,供人工核对(`wm density` 改过的设备两行值不同)。 */
443
+ // raw: { wm_density: string; wm_size: string };
444
+ // }
445
+
401
446
  // ─────────────────────────────────────────────
402
447
  // ADBHelper:封装所有 ADB 设备通信与交互操作
403
448
  // ─────────────────────────────────────────────
404
449
  class ADBHelper {
405
- adbPath: string;
406
- deviceSerial: string | null;
407
- idlePollInterval: number;
408
- idleTimeout: number;
409
- scrollDurationMs: number;
450
+ adbPath;
451
+ deviceSerial;
452
+ idlePollInterval;
453
+ idleTimeout;
454
+ scrollDurationMs;
410
455
  /** 目标 App 包名;多屏设备(车机等)上用于从多条 focus/resumed 行中挑出正确的那条。 */
411
- targetPackage: string | null;
456
+ targetPackage;
412
457
  /** 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
- } = {}) {
458
+ cachedPhysicalDisplayId = undefined;
459
+ /** 设备密度/分辨率缓存;整轮遍历只查一次(密度不会中途变)。 */
460
+ cachedDeviceInfo = undefined;
461
+
462
+ constructor(opts
463
+ // adbPath?: string;
464
+ // deviceSerial?: string | null;
465
+ // idlePollInterval?: number;
466
+ // idleTimeout?: number;
467
+ // scrollDurationMs?: number;
468
+ // targetPackage?: string | null;
469
+ = {}) {
423
470
  this.adbPath = opts.adbPath ?? ADB_PATH_DEFAULT;
424
471
  this.deviceSerial = opts.deviceSerial ?? DEVICE_SERIAL_DEFAULT;
425
472
  this.targetPackage = opts.targetPackage ?? null;
@@ -428,25 +475,25 @@ class ADBHelper {
428
475
  this.scrollDurationMs = opts.scrollDurationMs ?? SCROLL_DURATION_MS_DEFAULT;
429
476
  }
430
477
 
431
- private buildArgs(args: string[]): string[] {
432
- const full: string[] = [];
478
+ buildArgs(args) {
479
+ const full = [];
433
480
  if (this.deviceSerial) full.push('-s', this.deviceSerial);
434
481
  full.push(...args);
435
482
  return full;
436
483
  }
437
484
 
438
- private spawn(args: string[], timeout: number, binary: boolean): SpawnSyncReturns<Buffer> {
485
+ spawn(args, timeout, binary) {
439
486
  return spawnSync(this.adbPath, this.buildArgs(args), {
440
487
  timeout: timeout * 1000,
441
488
  maxBuffer: 128 * 1024 * 1024,
442
489
  windowsHide: true,
443
- }) as SpawnSyncReturns<Buffer>;
490
+ });
444
491
  }
445
492
 
446
- run(args: string[], timeout = 15): string {
493
+ run(args, timeout = 15) {
447
494
  const res = this.spawn(args, timeout, false);
448
495
  if (res.error) {
449
- const code = (res.error as NodeJS.ErrnoException).code;
496
+ const code = (res.error).code;
450
497
  if (code === 'ETIMEDOUT') {
451
498
  console.log(` [WARN] adb 命令超时: ${args.join(' ')}`);
452
499
  return '';
@@ -456,16 +503,108 @@ class ADBHelper {
456
503
  }
457
504
  }
458
505
  const out = res.stdout ?? Buffer.alloc(0);
506
+ // adb 的 `am start` 等子命令失败时把原因写到 stderr(如 SecurityException:
507
+ // Permission Denial —— 目标 Activity 未 exported)。只读 stdout 会把整条原因丢掉,
508
+ // 上层只能看到"没到达目标页面"这种误导性结论。
509
+ const err = (res.stderr ?? Buffer.alloc(0)).toString('utf-8').trim();
510
+ if (err && res.status !== 0) {
511
+ console.log(` [ADB-ERR] ${args.join(' ')}`);
512
+ for (const line of err.split(/\r?\n/).slice(0, 4)) console.log(` ${line}`);
513
+ }
459
514
  return out.toString('utf-8').trim();
460
515
  }
461
516
 
462
- shell(args: string[], timeout = 15): string {
517
+ /**
518
+ * 问系统要目标包真正可启动的 launcher 入口组件。
519
+ *
520
+ * 不能用"当前前台窗口的 Activity"代替:很多 App 的首屏 Activity 是
521
+ * `exported="false"` 的(AnkiDroid 的 DeckPicker 就是),真正的入口是一个
522
+ * exported 的 trampoline(IntentHandler)。拿首屏 Activity 去 `am start -n`
523
+ * 会被系统以 SecurityException 拒绝,App 根本起不来。
524
+ *
525
+ * @returns {string|null} 形如 "com.ichi2.anki/.IntentHandler",解析失败返回 null
526
+ */
527
+ resolveLauncherActivity(pkg) {
528
+ const out = this.shell(['cmd', 'package', 'resolve-activity', '--brief', pkg], 20);
529
+ const lines = out.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
530
+ for (let i = lines.length - 1; i >= 0; i--) {
531
+ const m = lines[i].match(/^([\w.]+)\/([\w.$]+)$/);
532
+ if (m && m[1] === pkg) return lines[i];
533
+ }
534
+ return null;
535
+ }
536
+
537
+ shell(args, timeout = 15) {
463
538
  return this.run(['shell', ...args], timeout);
464
539
  }
465
540
 
466
- waitForIdle(): void {
541
+ /**
542
+ * 读取设备密度与物理分辨率(整轮只查一次,结果缓存)。
543
+ *
544
+ * 覆盖密度(`wm density N` 改过的设备)**优先于**物理密度 —— UI 实际按覆盖值布局,
545
+ * 拿物理值去换算会得到一套系统性偏移的 vp。
546
+ */
547
+ deviceInfo() {
548
+ if (this.cachedDeviceInfo !== undefined) return this.cachedDeviceInfo;
549
+ const densRaw = this.shell(['wm', 'density']);
550
+ const sizeRaw = this.shell(['wm', 'size']);
551
+ const ovD = /Override density:\s*(\d+)/.exec(densRaw);
552
+ const phD = /Physical density:\s*(\d+)/.exec(densRaw);
553
+ const dpi = ovD ? Number(ovD[1]) : phD ? Number(phD[1]) : null;
554
+ const ovS = /Override size:\s*(\d+)x(\d+)/.exec(sizeRaw);
555
+ const phS = /Physical size:\s*(\d+)x(\d+)/.exec(sizeRaw);
556
+ const sz = ovS ?? phS;
557
+ const info = {
558
+ dpi,
559
+ density: dpi ? Math.round((dpi / 160) * 1000) / 1000 : null,
560
+ density_source: dpi === null
561
+ ? 'unavailable'
562
+ : ovD ? 'wm density (override)' : 'wm density (physical)',
563
+ width_px: sz ? Number(sz[1]) : null,
564
+ height_px: sz ? Number(sz[2]) : null,
565
+ raw: { wm_density: densRaw, wm_size: sizeRaw },
566
+ };
567
+ if (info.density === null) {
568
+ console.log(' [WARN] `wm density` 未返回可解析的密度,下游 measure_pack 只能反解(结果可能不唯一)');
569
+ } else {
570
+ console.log(` 设备密度: ${info.density} (${info.dpi}dpi, ${info.density_source})`
571
+ + (info.width_px ? ` 分辨率 ${info.width_px}x${info.height_px}` : ''));
572
+ }
573
+ this.cachedDeviceInfo = info;
574
+ return info;
575
+ }
576
+
577
+ /**
578
+ * 等目标包在**当前焦点窗口**里稳定下来,返回是否等到。
579
+ *
580
+ * 与 waitForIdle() 的区别:这里要求焦点窗口非 null 且属于目标包。
581
+ * 透明 trampoline(如 AnkiDroid 的 IntentHandler)过场时 mCurrentFocus=null,
582
+ * 对 waitForIdle() 来说"稳定",但对 uiautomator 来说不可 dump。
583
+ */
584
+ waitForPackageForeground(pkg, timeoutSec = 12) {
585
+ const deadline = Date.now() + timeoutSec * 1000;
586
+ let stableCount = 0;
587
+ let prev = null;
588
+ while (Date.now() < deadline) {
589
+ const info = this.getCurrentActivity();
590
+ const focus = info['current_focus'] ?? '';
591
+ // 必须是"有焦点窗口 + 焦点属于目标包",null 焦点不算落地。
592
+ const ok = !/=null\b/.test(focus) && focus.includes(pkg) && /[\w.]+\/[\w.$]+/.test(focus);
593
+ if (ok && focus === prev) {
594
+ stableCount += 1;
595
+ if (stableCount >= 2) return true;
596
+ } else {
597
+ stableCount = ok ? 1 : 0;
598
+ }
599
+ prev = ok ? focus : null;
600
+ sleep(0.4);
601
+ }
602
+ return false;
603
+ }
604
+
605
+ waitForIdle() {
467
606
  const deadline = Date.now() + this.idleTimeout * 1000;
468
- let prevFocus: string | null = null;
607
+ let prevFocus = null;
469
608
  let interval = this.idlePollInterval;
470
609
  while (Date.now() < deadline) {
471
610
  sleep(interval);
@@ -486,7 +625,7 @@ class ADBHelper {
486
625
  console.log(` [WARN] 等待 UI 空闲超时 (${this.idleTimeout}s),继续执行`);
487
626
  }
488
627
 
489
- checkDevice(): boolean {
628
+ checkDevice() {
490
629
  const out = this.run(['devices']);
491
630
  const lines = out.split(/\r?\n/).filter((l) => l.trim() && !l.includes('List of devices'));
492
631
  const online = lines.filter((l) => l.includes('device') && !l.includes('offline'));
@@ -516,30 +655,32 @@ class ADBHelper {
516
655
  * launcher。因此不能取第一条就 break:优先取包含目标包名的那条,
517
656
  * 其次取任意带 `pkg/activity` 组件名的非 null 行,最后兜底第一条。
518
657
  */
519
- private pickBestLine(lines: string[]): string | undefined {
658
+ pickBestLine(lines) {
520
659
  if (lines.length === 0) return undefined;
521
- if (this.targetPackage) {
522
- const hit = lines.find((l) => l.includes(this.targetPackage!));
523
- if (hit) return hit;
524
- }
660
+ // 不要"优先挑含目标包的行":目标包不在前台时,那样会翻出一条陈旧或次要的
661
+ // 含包名行,伪造出"目标 App 在前台"的假象,使 isInTargetApp() 误判为真,
662
+ // 于是把别的 App 的界面当目标页面采下来。前台判定必须忠实反映当前状态。
525
663
  const withComponent = lines.find((l) => !/=null\b/.test(l) && /[\w.]+\/[\w.]+/.test(l));
526
664
  return withComponent ?? lines[0];
527
665
  }
528
666
 
529
- getCurrentActivity(): Record<string, string> {
530
- const info: Record<string, string> = {};
667
+ getCurrentActivity() {
668
+ const info = {};
531
669
  const actOut = this.shell(['dumpsys', 'activity', 'activities']);
532
- const winOut = this.shell(['dumpsys', 'window']);
670
+ // 必须用 `window displays` 而不是 `window`:后者的输出里可能夹着
671
+ // `Last ANR continued` 段落中的历史 DISPLAY CONTENTS 快照,pickBestLine()
672
+ // 会取到那份陈旧的 mCurrentFocus(实测为一个无关 App)。
673
+ const winOut = this.shell(['dumpsys', 'window', 'displays']);
533
674
 
534
- const resumedLines: string[] = [];
675
+ const resumedLines = [];
535
676
  for (const line of actOut.split(/\r?\n/)) {
536
677
  if (/ResumedActivity|topActivity/i.test(line)) resumedLines.push(line.trim());
537
678
  }
538
679
  const resumed = this.pickBestLine(resumedLines);
539
680
  if (resumed !== undefined) info['resumed_activity'] = resumed;
540
681
 
541
- const focusLines: string[] = [];
542
- const focusedAppLines: string[] = [];
682
+ const focusLines = [];
683
+ const focusedAppLines = [];
543
684
  for (const line of winOut.split(/\r?\n/)) {
544
685
  if (line.includes('mCurrentFocus')) focusLines.push(line.trim());
545
686
  else if (line.includes('mFocusedApp')) focusedAppLines.push(line.trim());
@@ -552,27 +693,27 @@ class ADBHelper {
552
693
  return info;
553
694
  }
554
695
 
555
- tap(x: number, y: number): void {
696
+ tap(x, y) {
556
697
  this.shell(['input', 'tap', String(x), String(y)]);
557
698
  this.waitForIdle();
558
699
  }
559
700
 
560
- longPress(x: number, y: number, durationMs = 1000): void {
701
+ longPress(x, y, durationMs = 1000) {
561
702
  this.shell(['input', 'swipe', String(x), String(y), String(x), String(y), String(durationMs)]);
562
703
  this.waitForIdle();
563
704
  }
564
705
 
565
- tapOrLongPress(x: number, y: number, isLongClick = false): void {
706
+ tapOrLongPress(x, y, isLongClick = false) {
566
707
  if (isLongClick) this.longPress(x, y);
567
708
  else this.tap(x, y);
568
709
  }
569
710
 
570
- pressBack(): void {
711
+ pressBack() {
571
712
  this.shell(['input', 'keyevent', 'KEYCODE_BACK']);
572
713
  this.waitForIdle();
573
714
  }
574
715
 
575
- dumpXmlQuick(): string {
716
+ dumpXmlQuick() {
576
717
  const remotePath = '/sdcard/ui_dump.xml';
577
718
  this.shell(['rm -f /sdcard/ui_dump.xml && uiautomator dump /sdcard/ui_dump.xml'], 20);
578
719
  for (let k = 0; k < 6; k++) {
@@ -583,7 +724,7 @@ class ADBHelper {
583
724
  return this.shell(['cat', remotePath], 15);
584
725
  }
585
726
 
586
- getViewXmlFast(pageDir: string): string {
727
+ getViewXmlFast(pageDir) {
587
728
  const xmlContent = this.dumpXmlQuick();
588
729
  if (xmlContent) fs.writeFileSync(path.join(pageDir, 'view.xml'), xmlContent, 'utf-8');
589
730
  return xmlContent;
@@ -601,14 +742,14 @@ class ADBHelper {
601
742
  * → dumpsys display 里用 `displayId N,` 所在行的 uniqueId "local:<physId>" 取物理 id。
602
743
  * 单屏设备解析失败时返回 null,退回不带 -d 的老行为(此时也不会有警告)。
603
744
  */
604
- private resolveTargetPhysicalDisplayId(): string | null {
745
+ resolveTargetPhysicalDisplayId() {
605
746
  if (this.cachedPhysicalDisplayId !== undefined) return this.cachedPhysicalDisplayId;
606
- let result: string | null = null;
747
+ let result = null;
607
748
  try {
608
749
  if (this.targetPackage) {
609
750
  const actOut = this.shell(['dumpsys', 'activity', 'activities']);
610
- let curDisplay: string | null = null;
611
- let logicalId: string | null = null;
751
+ let curDisplay = null;
752
+ let logicalId = null;
612
753
  for (const line of actOut.split(/\r?\n/)) {
613
754
  const m = line.match(/^Display #(\d+)/);
614
755
  if (m) curDisplay = m[1];
@@ -638,7 +779,7 @@ class ADBHelper {
638
779
  return result;
639
780
  }
640
781
 
641
- screencapBytes(): Buffer | null {
782
+ screencapBytes() {
642
783
  const physId = this.resolveTargetPhysicalDisplayId();
643
784
  const capArgs = physId
644
785
  ? ['exec-out', 'screencap', '-d', physId, '-p']
@@ -647,8 +788,8 @@ class ADBHelper {
647
788
  timeout: 15 * 1000,
648
789
  maxBuffer: 128 * 1024 * 1024,
649
790
  windowsHide: true,
650
- }) as SpawnSyncReturns<Buffer>;
651
- if (res.error && (res.error as NodeJS.ErrnoException).code === 'ETIMEDOUT') {
791
+ });
792
+ if (res.error && (res.error).code === 'ETIMEDOUT') {
652
793
  console.log(' [WARN] 截图超时');
653
794
  return null;
654
795
  }
@@ -664,7 +805,7 @@ class ADBHelper {
664
805
  return at === 0 ? out : out.subarray(at);
665
806
  }
666
807
 
667
- takeScreenshot(pageDir: string): void {
808
+ takeScreenshot(pageDir) {
668
809
  const localPath = path.join(pageDir, 'screenshot.png');
669
810
  const data = this.screencapBytes();
670
811
  if (data) {
@@ -680,7 +821,7 @@ class ADBHelper {
680
821
  }
681
822
  }
682
823
 
683
- scrollToPosition(containerBounds: [number, number, number, number], scrollIndex: number): void {
824
+ scrollToPosition(containerBounds, scrollIndex) {
684
825
  if (scrollIndex <= 0) return;
685
826
  const [x1, y1, , y2] = containerBounds;
686
827
  const x2 = containerBounds[2];
@@ -694,7 +835,7 @@ class ADBHelper {
694
835
  }
695
836
  }
696
837
 
697
- flushPendingPulls(): void {
838
+ flushPendingPulls() {
698
839
  // TS 版截图为同步写入,无异步 pull 队列,保留空实现以对齐调用点。
699
840
  }
700
841
  }
@@ -702,44 +843,46 @@ class ADBHelper {
702
843
  // ─────────────────────────────────────────────
703
844
  // 队列项 / 记录类型
704
845
  // ─────────────────────────────────────────────
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 {
846
+ // interface ScrollInfo {
847
+ // scroll_index: number;
848
+ // container_bounds: number[];
849
+ // }
850
+ // interface ClickStep {
851
+ // from: string;
852
+ // element: string;
853
+ // center: number[];
854
+ // 'resource-id': string;
855
+ // text: string;
856
+ // 'content-desc': string;
857
+ // 'long-clickable': boolean;
858
+ // scroll_info: ScrollInfo | null;
859
+ // }
860
+ // type QueueItem = [ClickStep[], ElemId, string[], ScrollInfo | null];
861
+
862
+ // interface PageRecord {
863
+ // page_id: string | null;
864
+ // label: string;
865
+ // timestamp: string;
866
+ // /** 采集设备的密度/分辨率真值;measure_pack.js 优先采用,避免反解歧义。 */
867
+ // device?: DeviceInfo;
868
+ // activity_info: Record<string, string>;
869
+ // clickable_count: number;
870
+ // clickable_elements: ClickableElement[];
871
+ // scroll_container_bounds: number[] | null;
872
+ // _tmp_dir?: string;
873
+ // _screenshots?: string[];
874
+ // _view_xmls?: string[];
875
+ // screenshot?: string;
876
+ // view_xml?: string;
877
+ // screenshots?: string[];
878
+ // view_xmls?: string[];
879
+ // click_path?: ClickStep[] | 'manual';
880
+ // came_from?: string;
881
+ // trigger_element?: string;
882
+ // description?: string;
883
+ // }
884
+
885
+ function escapeHtml(s) {
743
886
  return s
744
887
  .replace(/&/g, '&amp;')
745
888
  .replace(/</g, '&lt;')
@@ -752,30 +895,30 @@ function escapeHtml(s: string): string {
752
895
  // PageCrawler:UI 遍历逻辑、页面采集、断点续跑、报告生成
753
896
  // ─────────────────────────────────────────────
754
897
  class PageCrawler {
755
- adb: ADBHelper;
756
- package: string;
757
- outputRoot: string;
758
- maxPages: number;
759
- maxScrolls: number;
760
- skipPatterns: string[];
898
+ adb;
899
+ package;
900
+ outputRoot;
901
+ maxPages;
902
+ maxScrolls;
903
+ skipPatterns;
761
904
 
762
905
  // 运行时状态
763
- pagesIndex: PageRecord[] = [];
764
- visitedSignatures = new Set<string>();
765
- pathSignatures: Record<string, string> = {};
766
- blockedElements = new Set<string>();
767
- queue: QueueItem[] = [];
906
+ pagesIndex = [];
907
+ visitedSignatures = new Set();
908
+ pathSignatures = {};
909
+ blockedElements = new Set();
910
+ queue = [];
768
911
  pageCounter = 0;
769
912
  stats = { skipped_elements: 0, probe_skipped: 0 };
770
913
 
771
- constructor(opts: {
772
- adb: ADBHelper;
773
- package: string;
774
- outputDir: string;
775
- maxPages?: number;
776
- maxScrolls?: number;
777
- skipPatterns?: string[];
778
- }) {
914
+ constructor(opts
915
+ // adb: ADBHelper;
916
+ // package: string;
917
+ // outputDir: string;
918
+ // maxPages?: number;
919
+ // maxScrolls?: number;
920
+ // skipPatterns?: string[];
921
+ ) {
779
922
  this.adb = opts.adb;
780
923
  this.package = opts.package;
781
924
  this.outputRoot = opts.outputDir;
@@ -784,7 +927,7 @@ class PageCrawler {
784
927
  this.skipPatterns = opts.skipPatterns ?? SKIP_RESOURCE_ID_PATTERNS_DEFAULT;
785
928
  }
786
929
 
787
- static elemIdOf(elem: ClickableElement): ElemId {
930
+ static elemIdOf(elem) {
788
931
  return [
789
932
  elem['resource-id'] ?? '',
790
933
  elem.text ?? '',
@@ -794,7 +937,7 @@ class PageCrawler {
794
937
  ];
795
938
  }
796
939
 
797
- shouldSkipElement(elem: ClickableElement): boolean {
940
+ shouldSkipElement(elem) {
798
941
  const rid = elem['resource-id'] ?? '';
799
942
  const text = elem.text ?? '';
800
943
  const desc = elem['content-desc'] ?? '';
@@ -804,7 +947,7 @@ class PageCrawler {
804
947
  return false;
805
948
  }
806
949
 
807
- isInTargetApp(activityInfo: Record<string, string>): boolean {
950
+ isInTargetApp(activityInfo) {
808
951
  const focus = activityInfo['current_focus'] ?? '';
809
952
  const resumed = activityInfo['resumed_activity'] ?? '';
810
953
  console.log(focus, resumed);
@@ -812,10 +955,10 @@ class PageCrawler {
812
955
  }
813
956
 
814
957
  scrollAndDump(
815
- pageDir: string,
816
- containerBounds: [number, number, number, number],
958
+ pageDir,
959
+ containerBounds,
817
960
  initialXml = '',
818
- ): { xmlList: string[]; xmlPaths: string[]; screenshotPaths: string[] } {
961
+ ) {
819
962
  const [x1, y1, , y2b] = containerBounds;
820
963
  const x2 = containerBounds[2];
821
964
  const y2 = y2b;
@@ -824,10 +967,10 @@ class PageCrawler {
824
967
  const swipeFromY = y1 + Math.trunc(height * 0.7);
825
968
  const swipeToY = y1 + Math.trunc(height * 0.3);
826
969
 
827
- const xmlList: string[] = [];
828
- const xmlPaths: string[] = [];
829
- const screenshotPaths: string[] = [];
830
- let prevKeys: Set<string> | null = initialXml ? extractNodeKeys(initialXml) : null;
970
+ const xmlList = [];
971
+ const xmlPaths = [];
972
+ const screenshotPaths = [];
973
+ let prevKeys = initialXml ? extractNodeKeys(initialXml) : null;
831
974
 
832
975
  for (let i = 0; i < this.maxScrolls; i++) {
833
976
  this.adb.shell(['input', 'swipe', String(cx), String(swipeFromY), String(cx), String(swipeToY), String(this.adb.scrollDurationMs)]);
@@ -854,12 +997,12 @@ class PageCrawler {
854
997
  return { xmlList, xmlPaths, screenshotPaths };
855
998
  }
856
999
 
857
- capturePage(label: string): {
858
- record: PageRecord;
859
- xmlContent: string;
860
- clickable: ClickableElement[];
861
- scrollContainerBounds: [number, number, number, number] | null;
862
- } {
1000
+ capturePage(label)
1001
+ // record: PageRecord;
1002
+ // xmlContent: string;
1003
+ // clickable: ClickableElement[];
1004
+ // scrollContainerBounds: [number, number, number, number] | null;
1005
+ {
863
1006
  const tmpDir = path.join(this.outputRoot, '_tmp_capture');
864
1007
  if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true });
865
1008
  fs.mkdirSync(tmpDir, { recursive: true });
@@ -890,8 +1033,8 @@ class PageCrawler {
890
1033
  }
891
1034
  }
892
1035
 
893
- const seenClickable = new Set<string>();
894
- const clickable: ClickableElement[] = [];
1036
+ const seenClickable = new Set();
1037
+ const clickable = [];
895
1038
  allXmlContents.forEach((xc, idx) => {
896
1039
  for (const elem of getClickableElements(xc)) {
897
1040
  const key = [
@@ -912,10 +1055,11 @@ class PageCrawler {
912
1055
  console.log(` 可点击元素: ${clickable.length} 个`);
913
1056
 
914
1057
  const scrollContainerBounds = scrollContainers.length > 0 ? scrollContainers[0].bounds : null;
915
- const record: PageRecord = {
1058
+ const record = {
916
1059
  page_id: null,
917
1060
  label,
918
1061
  timestamp: new Date().toISOString(),
1062
+ device: this.adb.deviceInfo(),
919
1063
  activity_info: actInfo,
920
1064
  clickable_count: clickable.length,
921
1065
  clickable_elements: clickable,
@@ -928,18 +1072,18 @@ class PageCrawler {
928
1072
  }
929
1073
 
930
1074
  restartToRoot(
931
- launchPackage: string | null,
932
- launchActivity: string | null,
933
- rootActivityKeyword: string | null = null,
934
- rootIdentity: RootIdentity | null = null,
935
- ): boolean {
1075
+ launchPackage,
1076
+ launchActivity,
1077
+ rootActivityKeyword = null,
1078
+ rootIdentity = null,
1079
+ ) {
936
1080
  if (!launchPackage || !launchActivity) {
937
1081
  console.log(' [WARN] 包名或 Activity 名为空,无法重启');
938
1082
  return false;
939
1083
  }
940
1084
  const component = `${launchPackage}/${launchActivity}`;
941
1085
 
942
- const isAtRoot = (): boolean => {
1086
+ const isAtRoot = () => {
943
1087
  const activityInfo = this.adb.getCurrentActivity();
944
1088
  const focus = activityInfo['current_focus'] ?? '';
945
1089
  const resumed = activityInfo['resumed_activity'] ?? '';
@@ -967,6 +1111,8 @@ class PageCrawler {
967
1111
  sleep(1.0);
968
1112
  this.adb.shell(['am', 'start', '-n', component, '--activity-clear-task', '--activity-clear-top']);
969
1113
  this.adb.waitForIdle();
1114
+ // 入口是 trampoline 时,落地页要再等一拍才可 dump(同 crawl() 里的首次启动)。
1115
+ this.adb.waitForPackageForeground(launchPackage);
970
1116
 
971
1117
  for (let retry = 0; retry < 4; retry++) {
972
1118
  if (isAtRoot()) return true;
@@ -974,7 +1120,7 @@ class PageCrawler {
974
1120
  sleep(2);
975
1121
  }
976
1122
 
977
- console.log(' [WARN] force-stop 后未到达根页面(App 恢复了上次的 Tab 状态)');
1123
+ console.log(' [WARN] force-stop 后未到达根页面(App 未起来 / 停在非根页面,见上方 [ADB-ERR])');
978
1124
  console.log(' [INFO] 尝试 back + 点击路径恢复...');
979
1125
  this.adb.pressBack();
980
1126
  if (isAtRoot()) return true;
@@ -984,13 +1130,13 @@ class PageCrawler {
984
1130
  }
985
1131
 
986
1132
  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 {
1133
+ clickPath,
1134
+ launchPackage,
1135
+ launchActivity,
1136
+ rootActivityKeyword = null,
1137
+ stepSignatures = null,
1138
+ rootIdentity = null,
1139
+ ) {
994
1140
  if (!launchPackage || !launchActivity) {
995
1141
  console.log(' [WARN] 包名或 Activity 名为空,无法导航');
996
1142
  return false;
@@ -1006,10 +1152,10 @@ class PageCrawler {
1006
1152
  let xml = this.adb.dumpXmlQuick();
1007
1153
  const stepScroll = step.scroll_info;
1008
1154
  if (stepScroll && stepScroll.scroll_index > 0) {
1009
- this.adb.scrollToPosition(stepScroll.container_bounds as [number, number, number, number], stepScroll.scroll_index);
1155
+ this.adb.scrollToPosition(stepScroll.container_bounds, stepScroll.scroll_index);
1010
1156
  xml = this.adb.dumpXmlQuick();
1011
1157
  }
1012
- const elemId: [string, string, string, string] = [step['resource-id'] ?? '', step.text ?? '', step['content-desc'] ?? '', ''];
1158
+ const elemId = [step['resource-id'] ?? '', step.text ?? '', step['content-desc'] ?? '', ''];
1013
1159
  const found = findElementOnScreen(xml, elemId);
1014
1160
  const isLong = step['long-clickable'] ?? false;
1015
1161
  if (found && found.center) {
@@ -1040,43 +1186,43 @@ class PageCrawler {
1040
1186
  }
1041
1187
 
1042
1188
  // ── 断点续跑 ──
1043
- private saveQueue(): void {
1189
+ saveQueue() {
1044
1190
  const items = this.queue.map(([clickPath, elemId, stepSigs, scrollInfo]) => [clickPath, [...elemId], stepSigs, scrollInfo]);
1045
1191
  try {
1046
1192
  fs.writeFileSync(path.join(this.outputRoot, 'queue_checkpoint.json'), JSON.stringify(items), 'utf-8');
1047
1193
  } catch (e) {
1048
- console.log(` [WARN] 保存队列检查点失败: ${(e as Error).message}`);
1194
+ console.log(` [WARN] 保存队列检查点失败: ${(e).message}`);
1049
1195
  }
1050
1196
  }
1051
1197
 
1052
- private loadQueue(): QueueItem[] | null {
1198
+ loadQueue() {
1053
1199
  const qfile = path.join(this.outputRoot, 'queue_checkpoint.json');
1054
1200
  if (!fs.existsSync(qfile)) return null;
1055
1201
  try {
1056
- const items = JSON.parse(fs.readFileSync(qfile, 'utf-8')) as [ClickStep[], unknown[], string[], ScrollInfo | null][];
1202
+ const items = JSON.parse(fs.readFileSync(qfile, 'utf-8'));
1057
1203
  return items.map(([clickPath, elemIdList, stepSigs, scrollInfo]) => [
1058
1204
  clickPath,
1059
- elemIdList as ElemId,
1205
+ elemIdList,
1060
1206
  stepSigs,
1061
1207
  scrollInfo,
1062
1208
  ]);
1063
1209
  } catch (e) {
1064
- console.log(` [WARN] 加载队列检查点失败: ${(e as Error).message},将重建队列`);
1210
+ console.log(` [WARN] 加载队列检查点失败: ${(e).message},将重建队列`);
1065
1211
  return null;
1066
1212
  }
1067
1213
  }
1068
1214
 
1069
- private rebuildQueueFromPages(pagesIndex: PageRecord[]): QueueItem[] {
1070
- const queue: QueueItem[] = [];
1215
+ rebuildQueueFromPages(pagesIndex) {
1216
+ const queue = [];
1071
1217
  for (const page of pagesIndex) {
1072
- const clickPath = (Array.isArray(page.click_path) ? page.click_path : []) as ClickStep[];
1218
+ const clickPath = (Array.isArray(page.click_path) ? page.click_path : []);
1073
1219
  const clickable = page.clickable_elements ?? [];
1074
1220
  const scrollContainerBounds = page.scroll_container_bounds;
1075
1221
  for (const elem of clickable) {
1076
1222
  if (this.shouldSkipElement(elem)) continue;
1077
1223
  const elemId = PageCrawler.elemIdOf(elem);
1078
1224
  const si = elem.scroll_index ?? 0;
1079
- let scrollInfo: ScrollInfo | null = null;
1225
+ let scrollInfo = null;
1080
1226
  if (si > 0 && scrollContainerBounds) scrollInfo = { scroll_index: si, container_bounds: [...scrollContainerBounds] };
1081
1227
  queue.push([clickPath, elemId, [], scrollInfo]);
1082
1228
  }
@@ -1084,9 +1230,9 @@ class PageCrawler {
1084
1230
  return queue;
1085
1231
  }
1086
1232
 
1087
- private tryLoadCheckpoint():
1088
- | { pagesIndex: PageRecord[]; visited: Set<string>; pageCounter: number; queue: QueueItem[]; blocked: Set<string> }
1089
- | null {
1233
+ tryLoadCheckpoint()
1234
+ // | { pagesIndex: PageRecord[]; visited: Set<string>; pageCounter: number; queue: QueueItem[]; blocked: Set<string> }
1235
+ {
1090
1236
  const indexFile = path.join(this.outputRoot, 'index.json');
1091
1237
  if (!fs.existsSync(indexFile)) return null;
1092
1238
  const pageDirs = fs.existsSync(this.outputRoot)
@@ -1094,17 +1240,17 @@ class PageCrawler {
1094
1240
  : [];
1095
1241
  if (pageDirs.length === 0) return null;
1096
1242
 
1097
- let pagesIndex: PageRecord[];
1243
+ let pagesIndex;
1098
1244
  try {
1099
- pagesIndex = JSON.parse(fs.readFileSync(indexFile, 'utf-8')) as PageRecord[];
1245
+ pagesIndex = JSON.parse(fs.readFileSync(indexFile, 'utf-8'));
1100
1246
  } catch (e) {
1101
- console.log(` [WARN] 读取 index.json 失败: ${(e as Error).message},重新开始`);
1247
+ console.log(` [WARN] 读取 index.json 失败: ${(e).message},重新开始`);
1102
1248
  return null;
1103
1249
  }
1104
1250
  if (!pagesIndex || pagesIndex.length === 0) return null;
1105
1251
 
1106
1252
  console.log(`\n⏩ 发现已有检查点:${pagesIndex.length} 个页面,尝试断点续跑...`);
1107
- const visited = new Set<string>();
1253
+ const visited = new Set();
1108
1254
  let pageCounter = 0;
1109
1255
  for (const page of pagesIndex) {
1110
1256
  const pageId = page.page_id ?? '';
@@ -1119,7 +1265,7 @@ class PageCrawler {
1119
1265
  }
1120
1266
 
1121
1267
  const savedQueue = this.loadQueue();
1122
- let queue: QueueItem[];
1268
+ let queue;
1123
1269
  if (savedQueue !== null) {
1124
1270
  queue = savedQueue;
1125
1271
  console.log(` 队列已从检查点恢复:${queue.length} 个待处理元素(精确续跑)`);
@@ -1127,15 +1273,15 @@ class PageCrawler {
1127
1273
  queue = this.rebuildQueueFromPages(pagesIndex);
1128
1274
  console.log(` 队列已重建(fallback):${queue.length} 个元素(含已探索项,BFS 会自动跳过)`);
1129
1275
  }
1130
- const blocked = new Set<string>();
1276
+ const blocked = new Set();
1131
1277
  console.log(` 已恢复 ${visited.size} 个已访问签名,队列 ${queue.length} 个待处理元素`);
1132
1278
  return { pagesIndex, visited, pageCounter, queue, blocked };
1133
1279
  }
1134
1280
 
1135
1281
  // ── 页面提交 ──
1136
- private finalizePage(record: PageRecord, pageId: string): void {
1282
+ finalizePage(record, pageId) {
1137
1283
  const pageDir = path.join(this.outputRoot, pageId);
1138
- const tmpDir = record._tmp_dir!;
1284
+ const tmpDir = record._tmp_dir;
1139
1285
  if (fs.existsSync(tmpDir)) fs.renameSync(tmpDir, pageDir);
1140
1286
  else fs.mkdirSync(pageDir, { recursive: true });
1141
1287
 
@@ -1165,7 +1311,7 @@ class PageCrawler {
1165
1311
  console.log(` ✅ 已保存为 [${pageId}]`);
1166
1312
  }
1167
1313
 
1168
- private activityNameSuffix(record: PageRecord): string {
1314
+ activityNameSuffix(record) {
1169
1315
  const resumed = record.activity_info?.['resumed_activity'] ?? '';
1170
1316
  let mAct = resumed.match(/[\w.]+\/([\w.]+)/);
1171
1317
  if (!mAct) {
@@ -1180,7 +1326,7 @@ class PageCrawler {
1180
1326
  return activityName ? `_${activityName}` : '';
1181
1327
  }
1182
1328
 
1183
- commitPage(record: PageRecord): string {
1329
+ commitPage(record) {
1184
1330
  this.pageCounter += 1;
1185
1331
  const suffix = this.activityNameSuffix(record);
1186
1332
  const pageId = `page_${String(this.pageCounter).padStart(4, '0')}${suffix}`;
@@ -1188,7 +1334,7 @@ class PageCrawler {
1188
1334
  return pageId;
1189
1335
  }
1190
1336
 
1191
- commitManualPage(record: PageRecord, pageCounter: number): string {
1337
+ commitManualPage(record, pageCounter) {
1192
1338
  const suffix = this.activityNameSuffix(record);
1193
1339
  const pageId = `manual_${String(pageCounter).padStart(4, '0')}${suffix}`;
1194
1340
  this.finalizePage(record, pageId);
@@ -1196,13 +1342,13 @@ class PageCrawler {
1196
1342
  }
1197
1343
 
1198
1344
  // ── HTML 报告生成 ──
1199
- generateHtmlReport(pages: PageRecord[]): void {
1345
+ generateHtmlReport(pages) {
1200
1346
  let cards = '';
1201
1347
  for (const p of pages) {
1202
1348
  const activity = escapeHtml(p.activity_info?.['current_focus'] ?? 'N/A');
1203
1349
  const resumed = escapeHtml(p.activity_info?.['resumed_activity'] ?? 'N/A');
1204
1350
  const clickPath = p.click_path ?? [];
1205
- let pathDisplay: string;
1351
+ let pathDisplay;
1206
1352
  if (clickPath === 'manual') {
1207
1353
  pathDisplay = `(手动采集:${escapeHtml(p.description ?? '手动采集')})`;
1208
1354
  } else if (Array.isArray(clickPath) && clickPath.length > 0) {
@@ -1220,7 +1366,7 @@ class PageCrawler {
1220
1366
  const screenshotPath = escapeHtml(p.screenshot ?? '');
1221
1367
  const screenshots = p.screenshots ?? [p.screenshot ?? ''];
1222
1368
  const viewXmls = p.view_xmls ?? [p.view_xml ?? ''];
1223
- let screenshotsHtml: string;
1369
+ let screenshotsHtml;
1224
1370
  if (screenshots.length > 1) {
1225
1371
  let pairsHtml = '';
1226
1372
  screenshots.forEach((ss, idx) => {
@@ -1240,7 +1386,7 @@ class PageCrawler {
1240
1386
  const labelEscaped = escapeHtml(p.label);
1241
1387
  const pageIdEscaped = escapeHtml(p.page_id ?? '');
1242
1388
  const viewXmlEscaped = escapeHtml(p.view_xml ?? '');
1243
- let xmlLinksHtml: string;
1389
+ let xmlLinksHtml;
1244
1390
  if (viewXmls.length > 1) {
1245
1391
  xmlLinksHtml = viewXmls
1246
1392
  .map((vx, i) => `<a href="${escapeHtml(vx)}" target="_blank">📄 XML${i === 0 ? '' : `_${i}`}</a>`)
@@ -1273,7 +1419,7 @@ class PageCrawler {
1273
1419
  }
1274
1420
 
1275
1421
  const now = new Date();
1276
- const pad = (x: number): string => String(x).padStart(2, '0');
1422
+ const pad = (x) => String(x).padStart(2, '0');
1277
1423
  const genTime = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
1278
1424
 
1279
1425
  const html = `<!DOCTYPE html>
@@ -1321,17 +1467,17 @@ ${cards}
1321
1467
  fs.writeFileSync(path.join(this.outputRoot, 'report.html'), html, 'utf-8');
1322
1468
  }
1323
1469
 
1324
- private flushReport(): void {
1470
+ flushReport() {
1325
1471
  try {
1326
1472
  fs.writeFileSync(path.join(this.outputRoot, 'index.json'), JSON.stringify(this.pagesIndex, null, 2), 'utf-8');
1327
1473
  this.generateHtmlReport(this.pagesIndex);
1328
1474
  this.saveQueue();
1329
1475
  } catch (e) {
1330
- console.log(` [WARN] 写入报告失败: ${(e as Error).message}`);
1476
+ console.log(` [WARN] 写入报告失败: ${(e).message}`);
1331
1477
  }
1332
1478
  }
1333
1479
 
1334
- crawl(): void {
1480
+ crawl() {
1335
1481
  if (!this.adb.checkDevice()) return;
1336
1482
 
1337
1483
  let activityInfo = this.adb.getCurrentActivity();
@@ -1344,7 +1490,7 @@ ${cards}
1344
1490
  const notInApp = !this.isInTargetApp(activityInfo);
1345
1491
  if (notInApp || !focusHasComponent) {
1346
1492
  if (notInApp) {
1347
- console.log(`当前不在目标 App (${this.package}) 内,清空数据并启动...`);
1493
+ console.log(`当前不在目标 App (${this.package}) 内,force-stop 后启动...`);
1348
1494
  } else {
1349
1495
  console.log(
1350
1496
  `当前前台为弹窗/overlay(${activityInfo['current_focus'] ?? 'N/A'}),force-stop 后重新启动以回到干净根页面...`,
@@ -1354,6 +1500,11 @@ ${cards}
1354
1500
  sleep(0.5);
1355
1501
  this.adb.shell(['monkey', '-p', this.package, '-c', 'android.intent.category.LAUNCHER', '1']);
1356
1502
  this.adb.waitForIdle();
1503
+ // trampoline 过场期间 mCurrentFocus=null,waitForIdle() 会误判为已空闲;
1504
+ // 必须再等到目标包真正持有焦点窗口,否则采到的是不可 dump 的中间态。
1505
+ if (!this.adb.waitForPackageForeground(this.package)) {
1506
+ console.log(` [WARN] 等待 ${this.package} 取得焦点窗口超时,继续尝试`);
1507
+ }
1357
1508
  activityInfo = this.adb.getCurrentActivity();
1358
1509
  if (!this.isInTargetApp(activityInfo)) {
1359
1510
  console.log(`[ERR] 启动后仍未进入目标 App (${this.package}),请检查包名是否正确`);
@@ -1361,24 +1512,32 @@ ${cards}
1361
1512
  }
1362
1513
  }
1363
1514
 
1364
- let launchPackage: string | null = null;
1365
- let launchActivity: string | null = null;
1366
- const comp = extractLaunchComponent(activityInfo);
1367
- if (comp) {
1368
- [launchPackage, launchActivity] = comp;
1369
- console.log(`启动 Activity: ${launchPackage}/${launchActivity}`);
1515
+ // launchPackage 只认 --package 传入值。绝不从前台窗口反推:
1516
+ // pickBestLine() 在目标包不在前台时会退回"任何带 包名/Activity 的行",
1517
+ // 于是 launchPackage 可能变成一个无关 App —— 而它会被 `am force-stop` 强杀。
1518
+ const launchPackage = this.package;
1519
+ let launchActivity = null;
1520
+
1521
+ const resolved = this.adb.resolveLauncherActivity(launchPackage);
1522
+ if (resolved) {
1523
+ launchActivity = resolved.split('/')[1];
1524
+ console.log(`启动 Activity: ${resolved} (来自 resolve-activity)`);
1370
1525
  } else {
1371
- console.log(
1372
- `[WARN] 无法提取启动 Activity: focus=${activityInfo['current_focus'] ?? ''} resumed=${activityInfo['resumed_activity'] ?? ''}`,
1373
- );
1526
+ // 兜底:resolve-activity 没给出结果时才退回前台窗口,且必须同包。
1527
+ const comp = extractLaunchComponent(activityInfo);
1528
+ if (comp && comp[0] === launchPackage) {
1529
+ launchActivity = comp[1];
1530
+ console.log(`启动 Activity: ${launchPackage}/${launchActivity} (来自前台窗口,兜底)`);
1531
+ } else {
1532
+ console.log(
1533
+ `[WARN] 无法确定 ${launchPackage} 的启动 Activity: focus=${activityInfo['current_focus'] ?? ''} resumed=${activityInfo['resumed_activity'] ?? ''}`,
1534
+ );
1535
+ }
1374
1536
  }
1375
1537
 
1376
- let rootActivityKeyword: string | null = null;
1377
- if (launchActivity) {
1378
- const parts = launchActivity.split('.');
1379
- rootActivityKeyword = parts[parts.length - 1];
1380
- console.log(`根 Activity 关键字: ${rootActivityKeyword}`);
1381
- }
1538
+ // 根页面关键字在首屏采集之后才能确定(入口可能是 trampoline,落地页另有其名),
1539
+ // 这里先声明,赋值见下方 checkpoint / 首屏采集两个分支。
1540
+ let rootActivityKeyword = null;
1382
1541
 
1383
1542
  fs.mkdirSync(this.outputRoot, { recursive: true });
1384
1543
  console.log(`\n📂 输出目录: ${path.resolve(this.outputRoot)}\n`);
@@ -1386,7 +1545,7 @@ ${cards}
1386
1545
  console.log('开始遍历(加速版:BFS + 轻量探测)...');
1387
1546
  console.log('='.repeat(60));
1388
1547
 
1389
- let rootIdentity: RootIdentity | null = null;
1548
+ let rootIdentity = null;
1390
1549
  const checkpoint = this.tryLoadCheckpoint();
1391
1550
  if (checkpoint) {
1392
1551
  this.pagesIndex = checkpoint.pagesIndex;
@@ -1397,21 +1556,36 @@ ${cards}
1397
1556
  console.log(`⏩ 断点续跑:从第 ${this.pageCounter + 1} 个页面继续\n`);
1398
1557
  for (const p of this.pagesIndex) {
1399
1558
  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');
1559
+ const rootXmlPath = path.join(this.outputRoot, p.page_id, 'view.xml');
1401
1560
  if (fs.existsSync(rootXmlPath)) {
1402
1561
  const rootXml = fs.readFileSync(rootXmlPath, 'utf-8');
1403
1562
  rootIdentity = rootIdentifiedSet(p.activity_info ?? {}, rootXml);
1404
1563
  }
1564
+ rootActivityKeyword = rootKeywordFromActivityInfo(p.activity_info ?? {});
1565
+ if (rootActivityKeyword) console.log(`根 Activity 关键字: ${rootActivityKeyword} (来自 ${p.page_id})`);
1405
1566
  break;
1406
1567
  }
1407
1568
  }
1408
1569
  } else {
1570
+ // 首屏必须在"目标包持有焦点窗口"时采。只用 isInTargetApp() 不够:
1571
+ // 它对 focus=null + resumed=<trampoline> 也返回 true,而那一刻 uiautomator
1572
+ // dump 不出任何东西(view.xml 0 字节、0 个可点击元素)。
1573
+ if (!this.adb.waitForPackageForeground(this.package)) {
1574
+ const preInfo = this.adb.getCurrentActivity();
1575
+ console.log(
1576
+ `[ERR] 采集首屏前目标 App (${this.package}) 未取得焦点窗口:focus=${preInfo['current_focus'] ?? 'N/A'} resumed=${preInfo['resumed_activity'] ?? 'N/A'}`,
1577
+ );
1578
+ console.log(' 首屏是后续所有页面的基准,采错会污染整轮结果,已中止。');
1579
+ return;
1580
+ }
1409
1581
  const { record, xmlContent, clickable, scrollContainerBounds } = this.capturePage('初始页面');
1410
1582
  const pageId = this.commitPage(record);
1411
1583
  record.click_path = [];
1412
1584
  fs.writeFileSync(path.join(this.outputRoot, pageId, 'meta.json'), JSON.stringify(record, null, 2), 'utf-8');
1413
1585
  const sig = pageSignature(record.activity_info, xmlContent);
1414
1586
  rootIdentity = rootIdentifiedSet(record.activity_info, xmlContent);
1587
+ rootActivityKeyword = rootKeywordFromActivityInfo(record.activity_info);
1588
+ if (rootActivityKeyword) console.log(`根 Activity 关键字: ${rootActivityKeyword} (来自首屏)`);
1415
1589
  this.visitedSignatures.add(sig);
1416
1590
  this.pathSignatures[JSON.stringify([])] = sig;
1417
1591
  this.pagesIndex.push(record);
@@ -1424,15 +1598,15 @@ ${cards}
1424
1598
  }
1425
1599
  const elemId = PageCrawler.elemIdOf(elem);
1426
1600
  const si = elem.scroll_index ?? 0;
1427
- let scrollInfo: ScrollInfo | null = null;
1601
+ let scrollInfo = null;
1428
1602
  if (si > 0 && scrollContainerBounds) scrollInfo = { scroll_index: si, container_bounds: [...scrollContainerBounds] };
1429
1603
  this.queue.push([[], elemId, [], scrollInfo]);
1430
1604
  }
1431
1605
  }
1432
1606
 
1433
- const failedPaths = new Set<string>();
1607
+ const failedPaths = new Set();
1434
1608
  while (this.queue.length > 0 && this.pageCounter < this.maxPages) {
1435
- const [clickPath, elemId, stepSignatures, scrollInfo] = this.queue.shift()!;
1609
+ const [clickPath, elemId, stepSignatures, scrollInfo] = this.queue.shift();
1436
1610
  const elemKey = JSON.stringify(elemId);
1437
1611
  if (this.blockedElements.has(elemKey)) continue;
1438
1612
 
@@ -1470,7 +1644,7 @@ ${cards}
1470
1644
  let freshXml = this.adb.dumpXmlQuick();
1471
1645
  if (scrollInfo && scrollInfo.scroll_index > 0) {
1472
1646
  console.log(` 📜 滚动 ${scrollInfo.scroll_index} 次到达元素位置`);
1473
- this.adb.scrollToPosition(scrollInfo.container_bounds as [number, number, number, number], scrollInfo.scroll_index);
1647
+ this.adb.scrollToPosition(scrollInfo.container_bounds, scrollInfo.scroll_index);
1474
1648
  freshXml = this.adb.dumpXmlQuick();
1475
1649
  }
1476
1650
 
@@ -1480,6 +1654,15 @@ ${cards}
1480
1654
  continue;
1481
1655
  }
1482
1656
 
1657
+ // input tap/swipe 是全局事件,打到的是**当时的前台窗口**,不绑定 App。
1658
+ // 若此刻前台已不是目标 App(导航半路跳走、弹窗被系统抢焦点等),
1659
+ // 点下去就是点在别人家界面上,且随后的 dump/截图也会把它录进 ui_info。
1660
+ const preTapInfo = this.adb.getCurrentActivity();
1661
+ if (!this.isInTargetApp(preTapInfo)) {
1662
+ console.log(` [SKIP] 点击前前台已不是目标 App(${preTapInfo['current_focus'] ?? 'N/A'}),跳过`);
1663
+ continue;
1664
+ }
1665
+
1483
1666
  const center = foundElem.center;
1484
1667
  const labelText = foundElem.text || foundElem['content-desc'] || foundElem['resource-id'] || 'unknown';
1485
1668
  console.log(`\n👆 点击: [${labelText}] @ ${JSON.stringify(center)}`);
@@ -1508,7 +1691,7 @@ ${cards}
1508
1691
  this.visitedSignatures.add(newSig);
1509
1692
  const pageId = this.commitPage(newRecord);
1510
1693
 
1511
- const newClickPath: ClickStep[] = [
1694
+ const newClickPath = [
1512
1695
  ...clickPath,
1513
1696
  {
1514
1697
  from: pathDesc,
@@ -1538,7 +1721,7 @@ ${cards}
1538
1721
  }
1539
1722
  const newEid = PageCrawler.elemIdOf(ne);
1540
1723
  const si = ne.scroll_index ?? 0;
1541
- let neScrollInfo: ScrollInfo | null = null;
1724
+ let neScrollInfo = null;
1542
1725
  if (si > 0 && newScrollBounds) neScrollInfo = { scroll_index: si, container_bounds: [...newScrollBounds] };
1543
1726
  this.queue.push([newClickPath, newEid, newStepSigs, neScrollInfo]);
1544
1727
  }
@@ -1563,26 +1746,65 @@ ${cards}
1563
1746
  console.log('='.repeat(60));
1564
1747
  }
1565
1748
 
1566
- async manualCrawl(): Promise<void> {
1567
- if (!this.adb.checkDevice()) return;
1568
- fs.mkdirSync(this.outputRoot, { recursive: true });
1569
-
1570
- let pagesIndex: PageRecord[] = [];
1749
+ /** 读取已有 index.json(不存在或损坏时返回空数组)。手动/单页采集共用。 */
1750
+ loadExistingIndex() {
1571
1751
  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
- }
1752
+ if (!fs.existsSync(indexFile)) return [];
1753
+ try {
1754
+ return JSON.parse(fs.readFileSync(indexFile, 'utf-8'));
1755
+ } catch (e) {
1756
+ console.log(`[WARN] 读取 index.json 失败: ${(e).message},将创建新索引`);
1757
+ return [];
1579
1758
  }
1759
+ }
1580
1760
 
1581
- let manualCounter = 0;
1761
+ /** 已有页面里最大的 manual_NNNN 编号,用于续编号。 */
1762
+ static lastManualCounter(pagesIndex) {
1763
+ let counter = 0;
1582
1764
  for (const p of pagesIndex) {
1583
1765
  const mm = (p.page_id ?? '').match(/manual_(\d+)/);
1584
- if (mm) manualCounter = Math.max(manualCounter, parseInt(mm[1], 10));
1766
+ if (mm) counter = Math.max(counter, parseInt(mm[1], 10));
1585
1767
  }
1768
+ return counter;
1769
+ }
1770
+
1771
+ /**
1772
+ * 单页采集模式(--page "<描述>"):不做任何导航,直接把设备**当前**页面
1773
+ * 当作待采集页抓一次(截图 + view.xml + 可滚动容器滚动采集),写入 index.json /
1774
+ * report.html 后退出。等价于 manualCrawl 里输入一次描述、再输入 q,但无需交互,
1775
+ * 便于外部脚本按自己的 UI 序列逐页调用。
1776
+ */
1777
+ capturePageOnce(description) {
1778
+ if (!this.adb.checkDevice()) return;
1779
+ fs.mkdirSync(this.outputRoot, { recursive: true });
1780
+
1781
+ const pagesIndex = this.loadExistingIndex();
1782
+ if (pagesIndex.length > 0) console.log(`📂 已加载 ${pagesIndex.length} 个已有页面`);
1783
+ const manualCounter = PageCrawler.lastManualCounter(pagesIndex) + 1;
1784
+
1785
+ console.log(`\n📂 输出目录: ${path.resolve(this.outputRoot)}`);
1786
+ console.log(`📝 单页采集模式:${description}`);
1787
+
1788
+ const { record } = this.capturePage(description);
1789
+ record.click_path = 'manual';
1790
+ record.description = description;
1791
+ const pageId = this.commitManualPage(record, manualCounter);
1792
+ pagesIndex.push(record);
1793
+ fs.writeFileSync(path.join(this.outputRoot, 'index.json'), JSON.stringify(pagesIndex, null, 2), 'utf-8');
1794
+ this.pagesIndex = pagesIndex;
1795
+ this.generateHtmlReport(pagesIndex);
1796
+
1797
+ console.log(`\n✅ 单页采集完成:[${pageId}](index.json 共 ${pagesIndex.length} 个页面)`);
1798
+ console.log(`📂 输出目录: ${path.resolve(this.outputRoot)}`);
1799
+ }
1800
+
1801
+ async manualCrawl() {
1802
+ if (!this.adb.checkDevice()) return;
1803
+ fs.mkdirSync(this.outputRoot, { recursive: true });
1804
+
1805
+ const pagesIndex = this.loadExistingIndex();
1806
+ if (pagesIndex.length > 0) console.log(`📂 已加载 ${pagesIndex.length} 个已有页面`);
1807
+ let manualCounter = PageCrawler.lastManualCounter(pagesIndex);
1586
1808
 
1587
1809
  console.log(`\n📂 输出目录: ${path.resolve(this.outputRoot)}`);
1588
1810
  console.log(`📝 手动采集模式已启动(manual 编号从 ${manualCounter + 1} 开始)`);
@@ -1594,7 +1816,7 @@ ${cards}
1594
1816
  console.log('='.repeat(60));
1595
1817
 
1596
1818
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1597
- const question = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
1819
+ const question = (q) => new Promise((resolve) => rl.question(q, resolve));
1598
1820
 
1599
1821
  try {
1600
1822
  for (;;) {
@@ -1611,7 +1833,7 @@ ${cards}
1611
1833
  record.description = desc;
1612
1834
  this.commitManualPage(record, manualCounter);
1613
1835
  pagesIndex.push(record);
1614
- fs.writeFileSync(indexFile, JSON.stringify(pagesIndex, null, 2), 'utf-8');
1836
+ fs.writeFileSync(path.join(this.outputRoot, 'index.json'), JSON.stringify(pagesIndex, null, 2), 'utf-8');
1615
1837
  this.pagesIndex = pagesIndex;
1616
1838
  this.generateHtmlReport(pagesIndex);
1617
1839
  console.log(` 📋 index.json 和 report.html 已更新(共 ${pagesIndex.length} 个页面)`);
@@ -1627,26 +1849,28 @@ ${cards}
1627
1849
  // ─────────────────────────────────────────────
1628
1850
  // CLI
1629
1851
  // ─────────────────────────────────────────────
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 {
1852
+ // interface CliArgs {
1853
+ // package: string;
1854
+ // output: string;
1855
+ // manual: boolean;
1856
+ // page: string | null;
1857
+ // device: string;
1858
+ // adbPath: string;
1859
+ // maxPages: number;
1860
+ // maxScrolls: number;
1861
+ // }
1862
+
1863
+ function printHelp() {
1641
1864
  console.log(
1642
1865
  [
1643
1866
  'Android 模拟器页面遍历工具(加速版)',
1644
1867
  '',
1645
- 'Usage: node android_parse_fast.ts --package <pkg> --output <dir> [options]',
1868
+ 'Usage: node android_parse_fast.js --package <pkg> --output <dir> [options]',
1646
1869
  '',
1647
1870
  ' --package, -p 目标 App 包名(必选)',
1648
1871
  ' --output, -o 输出目录(必选)',
1649
1872
  ' --manual 手动采集模式:手动导航到页面后按回车触发采集',
1873
+ ' --page "<描述>" 单页采集模式:直接用该描述采集设备当前页面,抓完一页即退出(不遍历)',
1650
1874
  ` --device ADB 设备 serial(默认: ${DEVICE_SERIAL_DEFAULT})`,
1651
1875
  ` --adb-path adb 可执行文件路径(默认: ${ADB_PATH_DEFAULT})`,
1652
1876
  ` --max-pages 最多遍历页面数(默认: ${MAX_PAGES_DEFAULT})`,
@@ -1655,11 +1879,12 @@ function printHelp(): void {
1655
1879
  );
1656
1880
  }
1657
1881
 
1658
- function parseArgs(argv: string[]): CliArgs {
1659
- const args: CliArgs = {
1882
+ function parseArgs(argv) {
1883
+ const args = {
1660
1884
  package: '',
1661
1885
  output: '',
1662
1886
  manual: false,
1887
+ page: null,
1663
1888
  device: DEVICE_SERIAL_DEFAULT,
1664
1889
  adbPath: ADB_PATH_DEFAULT,
1665
1890
  maxPages: MAX_PAGES_DEFAULT,
@@ -1667,7 +1892,7 @@ function parseArgs(argv: string[]): CliArgs {
1667
1892
  };
1668
1893
  for (let i = 0; i < argv.length; i++) {
1669
1894
  const a = argv[i];
1670
- const next = (): string => {
1895
+ const next = () => {
1671
1896
  if (i + 1 >= argv.length) {
1672
1897
  console.error(`Missing value for ${a}`);
1673
1898
  process.exit(2);
@@ -1677,6 +1902,7 @@ function parseArgs(argv: string[]): CliArgs {
1677
1902
  if (a === '--package' || a === '-p') args.package = next();
1678
1903
  else if (a === '--output' || a === '-o') args.output = next();
1679
1904
  else if (a === '--manual') args.manual = true;
1905
+ else if (a === '--page') args.page = next();
1680
1906
  else if (a === '--device') args.device = next();
1681
1907
  else if (a === '--adb-path') args.adbPath = next();
1682
1908
  else if (a === '--max-pages') args.maxPages = parseInt(next(), 10);
@@ -1697,10 +1923,14 @@ function parseArgs(argv: string[]): CliArgs {
1697
1923
  console.error('error: --output/-o is required');
1698
1924
  process.exit(2);
1699
1925
  }
1926
+ if (args.page !== null && args.manual) {
1927
+ console.error('error: --page 与 --manual 互斥');
1928
+ process.exit(2);
1929
+ }
1700
1930
  return args;
1701
1931
  }
1702
1932
 
1703
- async function main(): Promise<number> {
1933
+ async function main() {
1704
1934
  const args = parseArgs(process.argv.slice(2));
1705
1935
  const adb = new ADBHelper({ adbPath: args.adbPath, deviceSerial: args.device, targetPackage: args.package });
1706
1936
  const crawler = new PageCrawler({
@@ -1710,7 +1940,8 @@ async function main(): Promise<number> {
1710
1940
  maxPages: args.maxPages,
1711
1941
  maxScrolls: args.maxScrolls,
1712
1942
  });
1713
- if (args.manual) await crawler.manualCrawl();
1943
+ if (args.page !== null) crawler.capturePageOnce(args.page.trim() || '单页采集');
1944
+ else if (args.manual) await crawler.manualCrawl();
1714
1945
  else crawler.crawl();
1715
1946
  return 0;
1716
1947
  }