android2harmony 0.1.0

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 (82) hide show
  1. package/README.md +407 -0
  2. package/agents/build-fixer.md +385 -0
  3. package/agents/code-reviewer.md +478 -0
  4. package/agents/logic-coder.md +212 -0
  5. package/agents/logic-context-builder.md +199 -0
  6. package/agents/scripts/hmos-precheck/arkts.mjs +534 -0
  7. package/agents/scripts/hmos-precheck/codelinter.mjs +362 -0
  8. package/agents/scripts/hmos-precheck/index.mjs +926 -0
  9. package/agents/scripts/platform_query.mjs +535 -0
  10. package/dist/index.js +179 -0
  11. package/dist/index.js.map +7 -0
  12. package/package.json +29 -0
  13. package/skills/hmos-batch-ui-align/SKILL.md +141 -0
  14. package/skills/hmos-batch-ui-align/references/conversion-procedure.md +217 -0
  15. package/skills/hmos-batch-ui-align/references/mappings/android-to-harmonyOS-ui-atomic-component-mapping-reference.md +2533 -0
  16. package/skills/hmos-batch-ui-align/references/mappings/android-to-harmonyOS-ui-interaction-mapping-reference.md +555 -0
  17. package/skills/hmos-batch-ui-align/references/mappings/android-to-harmonyOS-ui-layout-mapping-reference.md +117 -0
  18. package/skills/hmos-batch-ui-align/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 +648 -0
  19. package/skills/hmos-batch-ui-align/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 +2089 -0
  20. package/skills/hmos-batch-ui-align/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 +1033 -0
  21. package/skills/hmos-batch-ui-align/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 +1183 -0
  22. package/skills/hmos-batch-ui-align/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 +576 -0
  23. package/skills/hmos-batch-ui-align/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 +297 -0
  24. package/skills/hmos-batch-ui-align/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 +395 -0
  25. package/skills/hmos-batch-ui-align/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 +903 -0
  26. package/skills/hmos-batch-ui-align/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 +106 -0
  27. package/skills/hmos-batch-ui-align/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 +1178 -0
  28. package/skills/hmos-batch-ui-align/references/mvvm/MVVM/346/250/241/345/274/217/357/274/210V1/357/274/211.md +911 -0
  29. package/skills/hmos-batch-ui-align/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 +355 -0
  30. package/skills/hmos-batch-ui-align/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 +11 -0
  31. package/skills/hmos-batch-ui-align/scripts/android_parse_fast.ts +1750 -0
  32. package/skills/hmos-convert-pipeline/SKILL.md +446 -0
  33. package/skills/hmos-fix-build-errors/SKILL.md +281 -0
  34. package/skills/hmos-fix-build-errors/references/arkts-strict-patterns.md +219 -0
  35. package/skills/hmos-fix-build-errors/references/known-patterns.md +157 -0
  36. package/skills/hmos-fix-build-errors/references/rdb-entity-pattern.md +131 -0
  37. package/skills/hmos-resources-convert/SKILL.md +654 -0
  38. package/skills/hmos-resources-convert/references/conversion-rules.md +663 -0
  39. package/skills/hmos-resources-convert/references/dependency-analysis-rules.md +388 -0
  40. package/skills/hmos-resources-convert/references/resource-mapping-rules.md +457 -0
  41. package/skills/hmos-resources-convert/references/xml-drawable-to-svg-rules.md +513 -0
  42. package/skills/hmos-resources-convert/template/AppScope/app.json5 +10 -0
  43. package/skills/hmos-resources-convert/template/AppScope/resources/base/element/string.json +8 -0
  44. package/skills/hmos-resources-convert/template/AppScope/resources/base/media/background.png +0 -0
  45. package/skills/hmos-resources-convert/template/AppScope/resources/base/media/foreground.png +0 -0
  46. package/skills/hmos-resources-convert/template/AppScope/resources/base/media/layered_image.json +7 -0
  47. package/skills/hmos-resources-convert/template/build-profile.json5 +42 -0
  48. package/skills/hmos-resources-convert/template/code-linter.json5 +32 -0
  49. package/skills/hmos-resources-convert/template/entry/build-profile.json5 +33 -0
  50. package/skills/hmos-resources-convert/template/entry/hvigorfile.ts +6 -0
  51. package/skills/hmos-resources-convert/template/entry/obfuscation-rules.txt +23 -0
  52. package/skills/hmos-resources-convert/template/entry/oh-package.json5 +10 -0
  53. package/skills/hmos-resources-convert/template/entry/src/main/ets/entryability/EntryAbility.ets +48 -0
  54. package/skills/hmos-resources-convert/template/entry/src/main/ets/entrybackupability/EntryBackupAbility.ets +16 -0
  55. package/skills/hmos-resources-convert/template/entry/src/main/ets/pages/Index.ets +23 -0
  56. package/skills/hmos-resources-convert/template/entry/src/main/module.json5 +55 -0
  57. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/element/color.json +8 -0
  58. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/element/float.json +8 -0
  59. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/element/string.json +16 -0
  60. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/media/background.png +0 -0
  61. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/media/foreground.png +0 -0
  62. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/media/layered_image.json +7 -0
  63. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/media/startIcon.png +0 -0
  64. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/profile/backup_config.json +3 -0
  65. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/profile/main_pages.json +5 -0
  66. package/skills/hmos-resources-convert/template/entry/src/main/resources/dark/element/color.json +8 -0
  67. package/skills/hmos-resources-convert/template/entry/src/mock/mock-config.json5 +2 -0
  68. package/skills/hmos-resources-convert/template/entry/src/ohosTest/ets/test/Ability.test.ets +35 -0
  69. package/skills/hmos-resources-convert/template/entry/src/ohosTest/ets/test/List.test.ets +5 -0
  70. package/skills/hmos-resources-convert/template/entry/src/ohosTest/module.json5 +16 -0
  71. package/skills/hmos-resources-convert/template/entry/src/test/List.test.ets +5 -0
  72. package/skills/hmos-resources-convert/template/entry/src/test/LocalUnit.test.ets +33 -0
  73. package/skills/hmos-resources-convert/template/hvigor/hvigor-config.json5 +23 -0
  74. package/skills/hmos-resources-convert/template/hvigorfile.ts +6 -0
  75. package/skills/hmos-resources-convert/template/oh-package-lock.json5 +28 -0
  76. package/skills/hmos-resources-convert/template/oh-package.json5 +10 -0
  77. package/skills/hmos-spec-generate/SKILL.md +413 -0
  78. package/skills/hmos-spec-generate/references/android-platform-tokens.md +105 -0
  79. package/skills/hmos-spec-generate/references/spec-sample-1.md +78 -0
  80. package/skills/hmos-spec-generate/references/spec-sample-2.md +58 -0
  81. package/skills/hmos-spec-generate/references/spec-sample-3.md +116 -0
  82. package/skills/hmos-spec-generate/references/step4-report-template.md +33 -0
@@ -0,0 +1,1750 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Android ๆจกๆ‹Ÿๅ™จ้กต้ข้ๅކๅทฅๅ…ท๏ผˆๅŠ ้€Ÿ็‰ˆ, TypeScript ็‰ˆ๏ผ‰
4
+ * ๅŠŸ่ƒฝ๏ผšBFS ้ๅކๆฏไธช้กต้ข๏ผŒไฟๅญ˜ๆˆชๅ›พใ€View XMLใ€Activity/Window ไฟกๆฏ๏ผŒ็”Ÿๆˆ index.json + report.htmlใ€‚
5
+ *
6
+ * ้›ถ็ฌฌไธ‰ๆ–นไพ่ต–๏ผŒไป…ไฝฟ็”จ Node ๅ†…็ฝฎๆจกๅ—๏ผŒๅฏ็‹ฌ็ซ‹ๅˆ†ๅ‘ใ€‚
7
+ * ็ญ‰ไปทไบŽๅŽŸ android_parse_fast.py๏ผˆfunctional equivalence๏ผ‰ใ€‚
8
+ *
9
+ * ็”จๆณ•:
10
+ * node android_parse_fast.ts --package <pkg> --output <dir> [--device serial] [--adb-path adb]
11
+ * [--max-pages 500] [--max-scrolls 10] [--manual]
12
+ */
13
+
14
+ import { spawnSync, type SpawnSyncReturns } from 'node:child_process';
15
+ import * as fs from 'node:fs';
16
+ import * as path from 'node:path';
17
+ import * as readline from 'node:readline';
18
+ import { createHash } from 'node:crypto';
19
+ import { pathToFileURL } from 'node:url';
20
+
21
+ // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
22
+ // ้ป˜่ฎค้…็ฝฎๅ€ผ
23
+ // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
24
+ const ADB_PATH_DEFAULT = 'adb';
25
+ const DEVICE_SERIAL_DEFAULT = 'emulator-5554';
26
+ const IDLE_POLL_INTERVAL_DEFAULT = 0.2;
27
+ const IDLE_TIMEOUT_DEFAULT = 5;
28
+ const MAX_PAGES_DEFAULT = 500;
29
+ const MAX_SCROLLS_DEFAULT = 10;
30
+ const SCROLL_DURATION_MS_DEFAULT = 300;
31
+ const SKIP_RESOURCE_ID_PATTERNS_DEFAULT = [
32
+ 'statusBarBackground',
33
+ 'navigationBarBackground',
34
+ 'action_bar_container',
35
+ 'status_bar',
36
+ 'navigation_bar',
37
+ ];
38
+ const HORIZONTAL_SCROLL_CLASSES = new Set(['android.widget.HorizontalScrollView']);
39
+
40
+ function sleep(seconds: number): void {
41
+ const end = Date.now() + seconds * 1000;
42
+ const ia = new Int32Array(new SharedArrayBuffer(4));
43
+ while (Date.now() < end) Atomics.wait(ia, 0, 0, Math.min(50, end - Date.now()));
44
+ }
45
+
46
+ // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
47
+ // ๆœ€ๅฐ XML ่งฃๆžๅ™จ๏ผˆ็ญ‰ไปท xml.etree ็š„ root.iter("node") + get ๅฑžๆ€ง๏ผ‰
48
+ // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
49
+ interface XmlNode {
50
+ tag: string;
51
+ attrs: Record<string, string>;
52
+ children: XmlNode[];
53
+ }
54
+
55
+ const XML_ENTITIES: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
56
+
57
+ function decodeEntities(s: string): string {
58
+ return s.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (m, ent: string) => {
59
+ if (ent[0] === '#') {
60
+ const code = ent[1] === 'x' || ent[1] === 'X' ? parseInt(ent.slice(2), 16) : parseInt(ent.slice(1), 10);
61
+ return Number.isNaN(code) ? m : String.fromCodePoint(code);
62
+ }
63
+ return ent in XML_ENTITIES ? XML_ENTITIES[ent] : m;
64
+ });
65
+ }
66
+
67
+ class XmlParseError extends Error {}
68
+
69
+ function parseXml(xml: string): XmlNode {
70
+ let i = 0;
71
+ const n = xml.length;
72
+ const stack: XmlNode[] = [];
73
+ let root: XmlNode | null = null;
74
+
75
+ const readAttrs = (raw: string): Record<string, string> => {
76
+ const attrs: Record<string, string> = {};
77
+ const re = /([^\s=/>]+)\s*=\s*"([^"]*)"|([^\s=/>]+)\s*=\s*'([^']*)'/g;
78
+ let m: RegExpExecArray | null;
79
+ while ((m = re.exec(raw)) !== null) {
80
+ if (m[1] !== undefined) attrs[m[1]] = decodeEntities(m[2]);
81
+ else if (m[3] !== undefined) attrs[m[3]] = decodeEntities(m[4]);
82
+ }
83
+ return attrs;
84
+ };
85
+
86
+ while (i < n) {
87
+ const lt = xml.indexOf('<', i);
88
+ if (lt < 0) break;
89
+ i = lt;
90
+ if (xml.startsWith('<?', i)) {
91
+ const end = xml.indexOf('?>', i);
92
+ if (end < 0) throw new XmlParseError('unterminated declaration');
93
+ i = end + 2;
94
+ continue;
95
+ }
96
+ if (xml.startsWith('<!--', i)) {
97
+ const end = xml.indexOf('-->', i);
98
+ if (end < 0) throw new XmlParseError('unterminated comment');
99
+ i = end + 3;
100
+ continue;
101
+ }
102
+ if (xml.startsWith('<!', i)) {
103
+ const end = xml.indexOf('>', i);
104
+ if (end < 0) throw new XmlParseError('unterminated markup declaration');
105
+ i = end + 1;
106
+ continue;
107
+ }
108
+ let j = i + 1;
109
+ let quote: string | null = null;
110
+ for (; j < n; j++) {
111
+ const c = xml[j];
112
+ if (quote) {
113
+ if (c === quote) quote = null;
114
+ } else if (c === '"' || c === "'") quote = c;
115
+ else if (c === '>') break;
116
+ }
117
+ if (j >= n) throw new XmlParseError('unterminated tag');
118
+ const inner = xml.slice(i + 1, j);
119
+ i = j + 1;
120
+ if (inner[0] === '/') {
121
+ if (!stack.pop()) throw new XmlParseError('unexpected closing tag');
122
+ continue;
123
+ }
124
+ const selfClosing = inner.endsWith('/');
125
+ const body = selfClosing ? inner.slice(0, -1) : inner;
126
+ const spaceIdx = body.search(/[\s/]/);
127
+ const tag = spaceIdx < 0 ? body : body.slice(0, spaceIdx);
128
+ const attrRaw = spaceIdx < 0 ? '' : body.slice(spaceIdx);
129
+ const node: XmlNode = { tag, attrs: readAttrs(attrRaw), children: [] };
130
+ if (stack.length > 0) stack[stack.length - 1].children.push(node);
131
+ else if (root === null) root = node;
132
+ if (!selfClosing) stack.push(node);
133
+ }
134
+ if (root === null) throw new XmlParseError('no root element');
135
+ return root;
136
+ }
137
+
138
+ function* iterNodes(root: XmlNode, tag: string): Generator<XmlNode> {
139
+ if (root.tag === tag) yield root;
140
+ for (const child of root.children) yield* iterNodes(child, tag);
141
+ }
142
+
143
+ function attr(node: XmlNode, key: string, dflt = ''): string {
144
+ return key in node.attrs ? node.attrs[key] : dflt;
145
+ }
146
+
147
+ function extractInts(s: string): number[] {
148
+ const m = s.match(/\d+/g);
149
+ return m ? m.map((x) => parseInt(x, 10)) : [];
150
+ }
151
+
152
+ // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
153
+ // ๅ…ƒ็ด /็ญพๅ ็บฏๅ‡ฝๆ•ฐ๏ผˆ็ญ‰ไปท PageCrawler ็š„ @staticmethod๏ผ‰
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 {
173
+ const nums = extractInts(bounds);
174
+ if (nums.length === 4) {
175
+ const [x1, y1, x2, y2] = nums;
176
+ return [Math.trunc((x1 + x2) / 2), Math.trunc((y1 + y2) / 2)];
177
+ }
178
+ return null;
179
+ }
180
+
181
+ function getClickableElements(xmlContent: string): ClickableElement[] {
182
+ const elements: ClickableElement[] = [];
183
+ if (!xmlContent) return elements;
184
+ let root: XmlNode;
185
+ try {
186
+ root = parseXml(xmlContent);
187
+ } catch (e) {
188
+ console.log(` [WARN] XML ่งฃๆžๅคฑ่ดฅ: ${(e as Error).message}`);
189
+ return elements;
190
+ }
191
+
192
+ const listviewChildSet = new Set<XmlNode>();
193
+ for (const node of iterNodes(root, 'node')) {
194
+ const cls = attr(node, 'class');
195
+ if (cls.includes('ListView') || cls.includes('GridView')) {
196
+ for (const child of node.children) listviewChildSet.add(child);
197
+ }
198
+ }
199
+
200
+ const extractText = (node: XmlNode): string => {
201
+ const t = attr(node, 'text');
202
+ if (t) return t;
203
+ for (const child of iterNodes(node, 'node')) {
204
+ const ct = attr(child, 'text');
205
+ if (ct) return ct;
206
+ }
207
+ return '';
208
+ };
209
+
210
+ for (const node of iterNodes(root, 'node')) {
211
+ if (attr(node, 'enabled') !== 'true') continue;
212
+ const isClickable = attr(node, 'clickable') === 'true';
213
+ const isLongClickable = attr(node, 'long-clickable') === 'true';
214
+ const isListviewItem = listviewChildSet.has(node);
215
+ const isCheckable = attr(node, 'checkable') === 'true';
216
+ if (!(isClickable || isLongClickable || isListviewItem || isCheckable)) continue;
217
+ const bounds = attr(node, 'bounds');
218
+ let text = attr(node, 'text');
219
+ if (!text) text = extractText(node);
220
+ const baseInfo = {
221
+ class: attr(node, 'class'),
222
+ text,
223
+ 'resource-id': attr(node, 'resource-id'),
224
+ 'content-desc': attr(node, 'content-desc'),
225
+ bounds,
226
+ center: parseBoundsCenter(bounds),
227
+ };
228
+ if (isClickable || isListviewItem || isCheckable) elements.push({ ...baseInfo, 'long-clickable': false });
229
+ if (isLongClickable) elements.push({ ...baseInfo, 'long-clickable': true });
230
+ }
231
+ return elements;
232
+ }
233
+
234
+ function extractNodeKeys(xmlContent: string): Set<string> {
235
+ const keys = new Set<string>();
236
+ if (!xmlContent) return keys;
237
+ let root: XmlNode;
238
+ try {
239
+ root = parseXml(xmlContent);
240
+ } catch {
241
+ return keys;
242
+ }
243
+ for (const node of iterNodes(root, 'node')) {
244
+ keys.add(
245
+ [attr(node, 'class'), attr(node, 'resource-id'), attr(node, 'text'), attr(node, 'content-desc'), attr(node, 'bounds')].join(''),
246
+ );
247
+ }
248
+ return keys;
249
+ }
250
+
251
+ function setsEqual(a: Set<string>, b: Set<string>): boolean {
252
+ if (a.size !== b.size) return false;
253
+ for (const v of a) if (!b.has(v)) return false;
254
+ return true;
255
+ }
256
+
257
+ function findScrollableContainers(xmlContent: string): ScrollContainer[] {
258
+ const containers: ScrollContainer[] = [];
259
+ if (!xmlContent) return containers;
260
+ let root: XmlNode;
261
+ try {
262
+ root = parseXml(xmlContent);
263
+ } catch {
264
+ return containers;
265
+ }
266
+ for (const node of iterNodes(root, 'node')) {
267
+ if (attr(node, 'scrollable') !== 'true') continue;
268
+ const cls = attr(node, 'class');
269
+ if (HORIZONTAL_SCROLL_CLASSES.has(cls)) continue;
270
+ const nums = extractInts(attr(node, 'bounds'));
271
+ if (nums.length !== 4) continue;
272
+ const [x1, y1, x2, y2] = nums;
273
+ if (y2 - y1 < 200) continue;
274
+ containers.push({ bounds: [x1, y1, x2, y2], class: cls, 'resource-id': attr(node, 'resource-id') });
275
+ }
276
+ containers.sort(
277
+ (a, b) => (b.bounds[2] - b.bounds[0]) * (b.bounds[3] - b.bounds[1]) - (a.bounds[2] - a.bounds[0]) * (a.bounds[3] - a.bounds[1]),
278
+ );
279
+ return containers;
280
+ }
281
+
282
+ function normalizeComponent(component: string): string {
283
+ if (!component.includes('/')) return component;
284
+ const idx = component.indexOf('/');
285
+ const pkg = component.slice(0, idx);
286
+ let cls = component.slice(idx + 1);
287
+ if (cls.startsWith('.')) cls = pkg + cls;
288
+ return `${pkg}/${cls}`;
289
+ }
290
+
291
+ function stableActivityKey(activityInfo: Record<string, string>): string {
292
+ const focus = activityInfo['current_focus'] ?? '';
293
+ const resumed = activityInfo['resumed_activity'] ?? '';
294
+ const m = focus.match(/[\w.]+\/[\w.]+/);
295
+ if (m) return normalizeComponent(m[0]);
296
+ const actM = resumed.match(/[\w.]+\/[\w.]+/);
297
+ const actPart = actM ? normalizeComponent(actM[0]) : 'unknown';
298
+ const winTypeM = focus.match(/(PopupWindow|Dialog|DecorView|Toast)/);
299
+ const winType = winTypeM ? winTypeM[1] : 'overlay';
300
+ return `${actPart}|${winType}`;
301
+ }
302
+
303
+ function pageSignature(activityInfo: Record<string, string>, xmlContent: string): string {
304
+ const actKey = stableActivityKey(activityInfo);
305
+ if (!xmlContent) return `${actKey}|empty`;
306
+ const clickable = getClickableElements(xmlContent);
307
+ if (clickable.length === 0) return `${actKey}|no_clickable`;
308
+ const elemFeatures = clickable.map((e) =>
309
+ [e.class ?? '', e['resource-id'] ?? '', e['content-desc'] ?? '', e.bounds ?? ''].join('|'),
310
+ );
311
+ const featuresStr = elemFeatures.join(';');
312
+ const featureHash = createHash('md5').update(featuresStr, 'utf-8').digest('hex').slice(0, 16);
313
+ return `${actKey}|${featureHash}`;
314
+ }
315
+
316
+ type RootIdentity = [string, Set<string>];
317
+
318
+ function rootIdentifiedSet(activityInfo: Record<string, string>, xmlContent: string): RootIdentity {
319
+ const resumed = activityInfo['resumed_activity'] ?? '';
320
+ const m = resumed.match(/[\w.]+\/[\w.]+/);
321
+ const actKey = m ? normalizeComponent(m[0]) : 'unknown';
322
+ if (!xmlContent) return [actKey, new Set()];
323
+ const clickable = getClickableElements(xmlContent);
324
+ const elems = new Set<string>();
325
+ for (const e of clickable) {
326
+ const desc = e['content-desc'] ?? '';
327
+ const rid = e['resource-id'] ?? '';
328
+ if (!desc && !rid) continue;
329
+ elems.add([e.class ?? '', rid, desc].join(''));
330
+ }
331
+ return [actKey, elems];
332
+ }
333
+
334
+ function isSameRootPage(rootInfo: RootIdentity, currentInfo: RootIdentity, threshold = 0.6): boolean {
335
+ const [rootAct, rootElems] = rootInfo;
336
+ const [curAct, curElems] = currentInfo;
337
+ if (rootAct !== curAct) return false;
338
+ if (rootElems.size === 0 && curElems.size === 0) return true;
339
+ if (rootElems.size === 0 || curElems.size === 0) return false;
340
+ let inter = 0;
341
+ for (const v of rootElems) if (curElems.has(v)) inter++;
342
+ const union = rootElems.size + curElems.size - inter;
343
+ return union > 0 && inter / union >= threshold;
344
+ }
345
+
346
+ /** current_focus ๆ˜ฏๅฆๅซ `package/activity` ๅˆ†้‡๏ผˆๅผน็ช—/overlay ็„ฆ็‚นๆฒกๆœ‰๏ผ‰ใ€‚ */
347
+ function focusHasActivityComponent(activityInfo: Record<string, string>): boolean {
348
+ return /[\w.]+\/[\w.]+/.test(activityInfo['current_focus'] ?? '');
349
+ }
350
+
351
+ /**
352
+ * ไปŽ activity ไฟกๆฏๆๅ–ๅฏๅŠจ็ป„ไปถ [package, activity]ใ€‚
353
+ * ไผ˜ๅ…ˆ current_focus๏ผˆ้€šๅธธๆ˜ฏๅ…จ้™ๅฎš Activity๏ผ‰๏ผŒๅ›ž้€€ resumed_activity๏ผˆๅฏ่ƒฝๆ˜ฏ็›ธๅฏนๅ `.ui.Main`๏ผŒ
354
+ * am start ๅŒๆ ทๆŽฅๅ—๏ผ‰ใ€‚้ƒฝๅ–ไธๅˆฐ่ฟ”ๅ›ž nullใ€‚
355
+ */
356
+ function extractLaunchComponent(activityInfo: Record<string, string>): [string, string] | null {
357
+ for (const key of ['current_focus', 'resumed_activity']) {
358
+ const s = activityInfo[key] ?? '';
359
+ const m = s.match(/([\w.]+)\/([\w.]+)/);
360
+ if (m) return [m[1], m[2]];
361
+ }
362
+ return null;
363
+ }
364
+
365
+ type ElemId = [string, string, string, string, boolean];
366
+
367
+ function findElementOnScreen(xmlContent: string, elemId: ElemId | [string, string, string, string]): ClickableElement | null {
368
+ const rid = elemId[0];
369
+ const text = elemId[1];
370
+ const desc = elemId[2];
371
+ const bounds = elemId[3];
372
+ const isLong = elemId.length >= 5 ? (elemId[4] as boolean) : null;
373
+ const clickableElements = getClickableElements(xmlContent);
374
+
375
+ if (bounds) {
376
+ for (const e of clickableElements) {
377
+ if (
378
+ e['resource-id'] === rid &&
379
+ e.text === text &&
380
+ e['content-desc'] === desc &&
381
+ e.bounds === bounds &&
382
+ (isLong === null || e['long-clickable'] === isLong)
383
+ ) {
384
+ return e;
385
+ }
386
+ }
387
+ }
388
+ for (const e of clickableElements) {
389
+ if (
390
+ e['resource-id'] === rid &&
391
+ e.text === text &&
392
+ e['content-desc'] === desc &&
393
+ (isLong === null || e['long-clickable'] === isLong)
394
+ ) {
395
+ return e;
396
+ }
397
+ }
398
+ return null;
399
+ }
400
+
401
+ // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
402
+ // ADBHelper๏ผšๅฐ่ฃ…ๆ‰€ๆœ‰ ADB ่ฎพๅค‡้€šไฟกไธŽไบคไบ’ๆ“ไฝœ
403
+ // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
404
+ class ADBHelper {
405
+ adbPath: string;
406
+ deviceSerial: string | null;
407
+ idlePollInterval: number;
408
+ idleTimeout: number;
409
+ scrollDurationMs: number;
410
+ /** ็›ฎๆ ‡ App ๅŒ…ๅ๏ผ›ๅคšๅฑ่ฎพๅค‡๏ผˆ่ฝฆๆœบ็ญ‰๏ผ‰ไธŠ็”จไบŽไปŽๅคšๆก focus/resumed ่กŒไธญๆŒ‘ๅ‡บๆญฃ็กฎ็š„้‚ฃๆกใ€‚ */
411
+ targetPackage: string | null;
412
+ /** 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
+ } = {}) {
423
+ this.adbPath = opts.adbPath ?? ADB_PATH_DEFAULT;
424
+ this.deviceSerial = opts.deviceSerial ?? DEVICE_SERIAL_DEFAULT;
425
+ this.targetPackage = opts.targetPackage ?? null;
426
+ this.idlePollInterval = opts.idlePollInterval ?? IDLE_POLL_INTERVAL_DEFAULT;
427
+ this.idleTimeout = opts.idleTimeout ?? IDLE_TIMEOUT_DEFAULT;
428
+ this.scrollDurationMs = opts.scrollDurationMs ?? SCROLL_DURATION_MS_DEFAULT;
429
+ }
430
+
431
+ private buildArgs(args: string[]): string[] {
432
+ const full: string[] = [];
433
+ if (this.deviceSerial) full.push('-s', this.deviceSerial);
434
+ full.push(...args);
435
+ return full;
436
+ }
437
+
438
+ private spawn(args: string[], timeout: number, binary: boolean): SpawnSyncReturns<Buffer> {
439
+ return spawnSync(this.adbPath, this.buildArgs(args), {
440
+ timeout: timeout * 1000,
441
+ maxBuffer: 128 * 1024 * 1024,
442
+ windowsHide: true,
443
+ }) as SpawnSyncReturns<Buffer>;
444
+ }
445
+
446
+ run(args: string[], timeout = 15): string {
447
+ const res = this.spawn(args, timeout, false);
448
+ if (res.error) {
449
+ const code = (res.error as NodeJS.ErrnoException).code;
450
+ if (code === 'ETIMEDOUT') {
451
+ console.log(` [WARN] adb ๅ‘ฝไปค่ถ…ๆ—ถ: ${args.join(' ')}`);
452
+ return '';
453
+ }
454
+ if (code === 'ENOENT') {
455
+ throw new Error('ๆ‰พไธๅˆฐ adb๏ผŒ่ฏท็กฎ่ฎคๅทฒๅฎ‰่ฃ…ๅนถ้…็ฝฎ PATH๏ผŒๆˆ–ไฟฎๆ”น ADB_PATH');
456
+ }
457
+ }
458
+ const out = res.stdout ?? Buffer.alloc(0);
459
+ return out.toString('utf-8').trim();
460
+ }
461
+
462
+ shell(args: string[], timeout = 15): string {
463
+ return this.run(['shell', ...args], timeout);
464
+ }
465
+
466
+ waitForIdle(): void {
467
+ const deadline = Date.now() + this.idleTimeout * 1000;
468
+ let prevFocus: string | null = null;
469
+ let interval = this.idlePollInterval;
470
+ while (Date.now() < deadline) {
471
+ sleep(interval);
472
+ interval = Math.min(interval * 1.5, 0.5);
473
+ const transitionOut = this.shell(['dumpsys', 'window', 'animator']);
474
+ if (transitionOut.includes('Transition ready') || transitionOut.includes('Running animations')) continue;
475
+ const focusOut = this.shell(['dumpsys', 'window']);
476
+ // ๅคšๅฑ่ฎพๅค‡ไธŠๆฏไธช display ๅ„ๆœ‰ไธ€ๆก mCurrentFocus๏ผŒๅ…จ้ƒจๆ‹ผ่ตทๆฅๅš็จณๅฎšๆ€งๆฏ”่พƒ๏ผŒ
477
+ // ้ฟๅ…ๅช็œ‹ๅ‰ฏๅฑ้‚ฃๆก๏ผˆๅธธไธบ null๏ผŒๆฐธ่ฟœ"็จณๅฎš"๏ผ‰่€Œ่ฏฏๅˆค UI ๅทฒ็ฉบ้—ฒใ€‚
478
+ const currentFocus = focusOut
479
+ .split(/\r?\n/)
480
+ .filter((line) => line.includes('mCurrentFocus'))
481
+ .map((line) => line.trim())
482
+ .join(' | ');
483
+ if (currentFocus && currentFocus === prevFocus) return;
484
+ prevFocus = currentFocus;
485
+ }
486
+ console.log(` [WARN] ็ญ‰ๅพ… UI ็ฉบ้—ฒ่ถ…ๆ—ถ (${this.idleTimeout}s)๏ผŒ็ปง็ปญๆ‰ง่กŒ`);
487
+ }
488
+
489
+ checkDevice(): boolean {
490
+ const out = this.run(['devices']);
491
+ const lines = out.split(/\r?\n/).filter((l) => l.trim() && !l.includes('List of devices'));
492
+ const online = lines.filter((l) => l.includes('device') && !l.includes('offline'));
493
+ if (online.length === 0) {
494
+ console.log(' ๆœชๆฃ€ๆต‹ๅˆฐๅœจ็บฟ่ฎพๅค‡๏ผŒ่ฏทๅ…ˆๅฏๅŠจๆจกๆ‹Ÿๅ™จๅนถ็กฎ่ฎค adb devices ๆœ‰่พ“ๅ‡บ');
495
+ return false;
496
+ }
497
+ const onlineDevices = online.map((line) => line.split(/\s+/)[0]);
498
+ console.log(` ๅœจ็บฟ่ฎพๅค‡: [${onlineDevices.map((d) => `'${d}'`).join(', ')}]`);
499
+ if (this.deviceSerial === null) {
500
+ this.deviceSerial = onlineDevices[0];
501
+ console.log(` ๆœชๆŒ‡ๅฎš่ฎพๅค‡๏ผŒ่‡ชๅŠจ้€‰ๆ‹ฉ: ${this.deviceSerial}`);
502
+ } else if (!onlineDevices.includes(this.deviceSerial)) {
503
+ console.log(` ๆŒ‡ๅฎš็š„่ฎพๅค‡ ${this.deviceSerial} ไธๅญ˜ๅœจ๏ผŒ่‡ชๅŠจ้€‰ๆ‹ฉ: ${onlineDevices[0]}`);
504
+ this.deviceSerial = onlineDevices[0];
505
+ } else {
506
+ console.log(` ไฝฟ็”จๆŒ‡ๅฎš็š„่ฎพๅค‡: ${this.deviceSerial}`);
507
+ }
508
+ return true;
509
+ }
510
+
511
+ /**
512
+ * ไปŽๅคšๆกๅ€™้€‰่กŒไธญๆŒ‘ไธ€ๆกๆœ€ๅฏไฟก็š„ใ€‚
513
+ * ๅคšๅฑ่ฎพๅค‡๏ผˆ่ฝฆๆœบ / ๆŠ˜ๅ ๅฑ / cluster+ไธปๅฑ๏ผ‰ไธŠ๏ผŒ`dumpsys window` ไธŽ
514
+ * `dumpsys activity activities` ไผšไธบ**ๆฏไธช display ๅ„่พ“ๅ‡บไธ€ๆก**
515
+ * mCurrentFocus / topResumedActivity๏ผŒๅ…ถไธญๅ‰ฏๅฑ้‚ฃๆกๅธธๅธธๆ˜ฏ `null` ๆˆ–็ณป็ปŸ
516
+ * launcherใ€‚ๅ› ๆญคไธ่ƒฝๅ–็ฌฌไธ€ๆกๅฐฑ break๏ผšไผ˜ๅ…ˆๅ–ๅŒ…ๅซ็›ฎๆ ‡ๅŒ…ๅ็š„้‚ฃๆก๏ผŒ
517
+ * ๅ…ถๆฌกๅ–ไปปๆ„ๅธฆ `pkg/activity` ็ป„ไปถๅ็š„้ž null ่กŒ๏ผŒๆœ€ๅŽๅ…œๅบ•็ฌฌไธ€ๆกใ€‚
518
+ */
519
+ private pickBestLine(lines: string[]): string | undefined {
520
+ 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
+ }
525
+ const withComponent = lines.find((l) => !/=null\b/.test(l) && /[\w.]+\/[\w.]+/.test(l));
526
+ return withComponent ?? lines[0];
527
+ }
528
+
529
+ getCurrentActivity(): Record<string, string> {
530
+ const info: Record<string, string> = {};
531
+ const actOut = this.shell(['dumpsys', 'activity', 'activities']);
532
+ const winOut = this.shell(['dumpsys', 'window']);
533
+
534
+ const resumedLines: string[] = [];
535
+ for (const line of actOut.split(/\r?\n/)) {
536
+ if (/ResumedActivity|topActivity/i.test(line)) resumedLines.push(line.trim());
537
+ }
538
+ const resumed = this.pickBestLine(resumedLines);
539
+ if (resumed !== undefined) info['resumed_activity'] = resumed;
540
+
541
+ const focusLines: string[] = [];
542
+ const focusedAppLines: string[] = [];
543
+ for (const line of winOut.split(/\r?\n/)) {
544
+ if (line.includes('mCurrentFocus')) focusLines.push(line.trim());
545
+ else if (line.includes('mFocusedApp')) focusedAppLines.push(line.trim());
546
+ }
547
+ const focus = this.pickBestLine(focusLines);
548
+ if (focus !== undefined) info['current_focus'] = focus;
549
+ const focusedApp = this.pickBestLine(focusedAppLines);
550
+ if (focusedApp !== undefined) info['focused_app'] = focusedApp;
551
+
552
+ return info;
553
+ }
554
+
555
+ tap(x: number, y: number): void {
556
+ this.shell(['input', 'tap', String(x), String(y)]);
557
+ this.waitForIdle();
558
+ }
559
+
560
+ longPress(x: number, y: number, durationMs = 1000): void {
561
+ this.shell(['input', 'swipe', String(x), String(y), String(x), String(y), String(durationMs)]);
562
+ this.waitForIdle();
563
+ }
564
+
565
+ tapOrLongPress(x: number, y: number, isLongClick = false): void {
566
+ if (isLongClick) this.longPress(x, y);
567
+ else this.tap(x, y);
568
+ }
569
+
570
+ pressBack(): void {
571
+ this.shell(['input', 'keyevent', 'KEYCODE_BACK']);
572
+ this.waitForIdle();
573
+ }
574
+
575
+ dumpXmlQuick(): string {
576
+ const remotePath = '/sdcard/ui_dump.xml';
577
+ this.shell(['rm -f /sdcard/ui_dump.xml && uiautomator dump /sdcard/ui_dump.xml'], 20);
578
+ for (let k = 0; k < 6; k++) {
579
+ const check = this.shell(['ls', remotePath]);
580
+ if (check.includes(remotePath)) break;
581
+ sleep(0.2);
582
+ }
583
+ return this.shell(['cat', remotePath], 15);
584
+ }
585
+
586
+ getViewXmlFast(pageDir: string): string {
587
+ const xmlContent = this.dumpXmlQuick();
588
+ if (xmlContent) fs.writeFileSync(path.join(pageDir, 'view.xml'), xmlContent, 'utf-8');
589
+ return xmlContent;
590
+ }
591
+
592
+ /**
593
+ * ่งฃๆž็›ฎๆ ‡ App ๆ‰€ๅœจๅฑๅน•็š„ SurfaceFlinger **็‰ฉ็†** display id๏ผˆscreencap -d ๅช่ฎค็‰ฉ็† id๏ผŒ
594
+ * ้€ป่พ‘ id ๅฆ‚ 0/2/3 ไผš็›ดๆŽฅ "Failed to take screenshot. Status: -2"๏ผ‰ใ€‚
595
+ *
596
+ * ๅคšๅฑ่ฎพๅค‡๏ผˆ่ฝฆๆœบ cluster + ไธปๅฑใ€ๆŠ˜ๅ ๅฑ็ญ‰๏ผ‰ไธŠไธไผ  -d ๆ—ถ๏ผŒscreencap ไผšๅ…ˆๅพ€ stdout
597
+ * ๆ‰“ไธ€ๆฎต "[Warning] Multiple displays were found..." ๆ–‡ๆœฌ๏ผŒๆŠŠ PNG ไบŒ่ฟ›ๅˆถๆ•ดไฝ“ๅ‰็งป
598
+ * ๅฏผ่‡ดๅ›พ็‰‡ๆ‰“ไธๅผ€๏ผ›ไธ”ๅฎƒ"้ป˜่ฎคๅ–็ฌฌไธ€ไธช display"๏ผŒไธไฟ่ฏๅฐฑๆ˜ฏ App ๆ‰€ๅœจ้‚ฃๅ—ๅฑใ€‚
599
+ *
600
+ * ๆต็จ‹๏ผšdumpsys activity activities ๆ‰พๅˆฐ็›ฎๆ ‡ๅŒ…ๆ‰€ๅœจ็š„ `Display #N`
601
+ * โ†’ dumpsys display ้‡Œ็”จ `displayId N,` ๆ‰€ๅœจ่กŒ็š„ uniqueId "local:<physId>" ๅ–็‰ฉ็† idใ€‚
602
+ * ๅ•ๅฑ่ฎพๅค‡่งฃๆžๅคฑ่ดฅๆ—ถ่ฟ”ๅ›ž null๏ผŒ้€€ๅ›žไธๅธฆ -d ็š„่€่กŒไธบ๏ผˆๆญคๆ—ถไนŸไธไผšๆœ‰่ญฆๅ‘Š๏ผ‰ใ€‚
603
+ */
604
+ private resolveTargetPhysicalDisplayId(): string | null {
605
+ if (this.cachedPhysicalDisplayId !== undefined) return this.cachedPhysicalDisplayId;
606
+ let result: string | null = null;
607
+ try {
608
+ if (this.targetPackage) {
609
+ const actOut = this.shell(['dumpsys', 'activity', 'activities']);
610
+ let curDisplay: string | null = null;
611
+ let logicalId: string | null = null;
612
+ for (const line of actOut.split(/\r?\n/)) {
613
+ const m = line.match(/^Display #(\d+)/);
614
+ if (m) curDisplay = m[1];
615
+ else if (curDisplay && line.includes(this.targetPackage)) {
616
+ logicalId = curDisplay;
617
+ break;
618
+ }
619
+ }
620
+ if (logicalId !== null) {
621
+ const dispOut = this.shell(['dumpsys', 'display']);
622
+ // ้€่กŒๆ‰ซๆ๏ผšDisplayInfo ๆ˜ฏๅ•่กŒ้•ฟไธฒ๏ผŒ`displayId <N>,` ไธŽ uniqueId ๅŒ่กŒใ€‚
623
+ for (const line of dispOut.split(/\r?\n/)) {
624
+ if (!line.includes(`displayId ${logicalId},`)) continue;
625
+ const m = line.match(/uniqueId "local:(\d+)"/);
626
+ if (m) {
627
+ result = m[1];
628
+ break;
629
+ }
630
+ }
631
+ }
632
+ }
633
+ } catch {
634
+ result = null;
635
+ }
636
+ if (result) console.log(` [INFO] ๆˆชๅ›พ็›ฎๆ ‡ display ็‰ฉ็† id: ${result}`);
637
+ this.cachedPhysicalDisplayId = result;
638
+ return result;
639
+ }
640
+
641
+ screencapBytes(): Buffer | null {
642
+ const physId = this.resolveTargetPhysicalDisplayId();
643
+ const capArgs = physId
644
+ ? ['exec-out', 'screencap', '-d', physId, '-p']
645
+ : ['exec-out', 'screencap', '-p'];
646
+ const res = spawnSync(this.adbPath, this.buildArgs(capArgs), {
647
+ timeout: 15 * 1000,
648
+ maxBuffer: 128 * 1024 * 1024,
649
+ windowsHide: true,
650
+ }) as SpawnSyncReturns<Buffer>;
651
+ if (res.error && (res.error as NodeJS.ErrnoException).code === 'ETIMEDOUT') {
652
+ console.log(' [WARN] ๆˆชๅ›พ่ถ…ๆ—ถ');
653
+ return null;
654
+ }
655
+ const out = res.stdout ?? Buffer.alloc(0);
656
+ if (out.length === 0) return null;
657
+ // ๅ…œๅบ•๏ผšๆ— ่ฎบๅฆ‚ไฝ•้ƒฝไปŽ PNG magic ๅค„ๆˆชๆ–ญ๏ผŒๅ‰ฅๆމ screencap ๅฏ่ƒฝๅ‰็ฝฎๅˆฐ stdout ็š„่ญฆๅ‘Šๆ–‡ๆœฌใ€‚
658
+ const magic = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
659
+ const at = out.indexOf(magic);
660
+ if (at < 0) {
661
+ console.log(` [WARN] ๆˆชๅ›พ่พ“ๅ‡บไธๅซ PNG ๆ•ฐๆฎ: ${out.subarray(0, 120).toString('utf-8').trim()}`);
662
+ return null;
663
+ }
664
+ return at === 0 ? out : out.subarray(at);
665
+ }
666
+
667
+ takeScreenshot(pageDir: string): void {
668
+ const localPath = path.join(pageDir, 'screenshot.png');
669
+ const data = this.screencapBytes();
670
+ if (data) {
671
+ fs.writeFileSync(localPath, data);
672
+ } else {
673
+ const remotePath = '/sdcard/screenshot.png';
674
+ const physId = this.resolveTargetPhysicalDisplayId();
675
+ // ่ฝ็›˜ๅˆฐ่ฎพๅค‡ๅ† pull ๆ—ถๅŒๆ ท่ฆๆŒ‡ๅฎš display๏ผŒๅฆๅˆ™ๅคšๅฑไธ‹ๅฏ่ƒฝๆŠ“ๅˆฐๅ‰ฏๅฑใ€‚
676
+ if (physId) this.shell(['screencap', '-d', physId, '-p', remotePath]);
677
+ else this.shell(['screencap', '-p', remotePath]);
678
+ sleep(0.3);
679
+ this.run(['pull', remotePath, localPath], 15);
680
+ }
681
+ }
682
+
683
+ scrollToPosition(containerBounds: [number, number, number, number], scrollIndex: number): void {
684
+ if (scrollIndex <= 0) return;
685
+ const [x1, y1, , y2] = containerBounds;
686
+ const x2 = containerBounds[2];
687
+ const cx = Math.trunc((x1 + x2) / 2);
688
+ const height = y2 - y1;
689
+ const swipeFromY = y1 + Math.trunc(height * 0.7);
690
+ const swipeToY = y1 + Math.trunc(height * 0.3);
691
+ for (let k = 0; k < scrollIndex; k++) {
692
+ this.shell(['input', 'swipe', String(cx), String(swipeFromY), String(cx), String(swipeToY), String(this.scrollDurationMs)]);
693
+ this.waitForIdle();
694
+ }
695
+ }
696
+
697
+ flushPendingPulls(): void {
698
+ // TS ็‰ˆๆˆชๅ›พไธบๅŒๆญฅๅ†™ๅ…ฅ๏ผŒๆ— ๅผ‚ๆญฅ pull ้˜Ÿๅˆ—๏ผŒไฟ็•™็ฉบๅฎž็Žฐไปฅๅฏน้ฝ่ฐƒ็”จ็‚นใ€‚
699
+ }
700
+ }
701
+
702
+ // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
703
+ // ้˜Ÿๅˆ—้กน / ่ฎฐๅฝ•็ฑปๅž‹
704
+ // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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 {
743
+ return s
744
+ .replace(/&/g, '&amp;')
745
+ .replace(/</g, '&lt;')
746
+ .replace(/>/g, '&gt;')
747
+ .replace(/"/g, '&quot;')
748
+ .replace(/'/g, '&#x27;');
749
+ }
750
+
751
+ // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
752
+ // PageCrawler๏ผšUI ้ๅކ้€ป่พ‘ใ€้กต้ข้‡‡้›†ใ€ๆ–ญ็‚น็ปญ่ท‘ใ€ๆŠฅๅ‘Š็”Ÿๆˆ
753
+ // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
754
+ class PageCrawler {
755
+ adb: ADBHelper;
756
+ package: string;
757
+ outputRoot: string;
758
+ maxPages: number;
759
+ maxScrolls: number;
760
+ skipPatterns: string[];
761
+
762
+ // ่ฟ่กŒๆ—ถ็Šถๆ€
763
+ pagesIndex: PageRecord[] = [];
764
+ visitedSignatures = new Set<string>();
765
+ pathSignatures: Record<string, string> = {};
766
+ blockedElements = new Set<string>();
767
+ queue: QueueItem[] = [];
768
+ pageCounter = 0;
769
+ stats = { skipped_elements: 0, probe_skipped: 0 };
770
+
771
+ constructor(opts: {
772
+ adb: ADBHelper;
773
+ package: string;
774
+ outputDir: string;
775
+ maxPages?: number;
776
+ maxScrolls?: number;
777
+ skipPatterns?: string[];
778
+ }) {
779
+ this.adb = opts.adb;
780
+ this.package = opts.package;
781
+ this.outputRoot = opts.outputDir;
782
+ this.maxPages = opts.maxPages ?? MAX_PAGES_DEFAULT;
783
+ this.maxScrolls = opts.maxScrolls ?? MAX_SCROLLS_DEFAULT;
784
+ this.skipPatterns = opts.skipPatterns ?? SKIP_RESOURCE_ID_PATTERNS_DEFAULT;
785
+ }
786
+
787
+ static elemIdOf(elem: ClickableElement): ElemId {
788
+ return [
789
+ elem['resource-id'] ?? '',
790
+ elem.text ?? '',
791
+ elem['content-desc'] ?? '',
792
+ elem.bounds ?? '',
793
+ elem['long-clickable'] ?? false,
794
+ ];
795
+ }
796
+
797
+ shouldSkipElement(elem: ClickableElement): boolean {
798
+ const rid = elem['resource-id'] ?? '';
799
+ const text = elem.text ?? '';
800
+ const desc = elem['content-desc'] ?? '';
801
+ const cls = elem.class ?? '';
802
+ for (const pattern of this.skipPatterns) if (rid.includes(pattern)) return true;
803
+ if (!text && !desc && !rid && cls.includes('ImageView')) return true;
804
+ return false;
805
+ }
806
+
807
+ isInTargetApp(activityInfo: Record<string, string>): boolean {
808
+ const focus = activityInfo['current_focus'] ?? '';
809
+ const resumed = activityInfo['resumed_activity'] ?? '';
810
+ console.log(focus, resumed);
811
+ return focus.includes(this.package) || resumed.includes(this.package);
812
+ }
813
+
814
+ scrollAndDump(
815
+ pageDir: string,
816
+ containerBounds: [number, number, number, number],
817
+ initialXml = '',
818
+ ): { xmlList: string[]; xmlPaths: string[]; screenshotPaths: string[] } {
819
+ const [x1, y1, , y2b] = containerBounds;
820
+ const x2 = containerBounds[2];
821
+ const y2 = y2b;
822
+ const cx = Math.trunc((x1 + x2) / 2);
823
+ const height = y2 - y1;
824
+ const swipeFromY = y1 + Math.trunc(height * 0.7);
825
+ const swipeToY = y1 + Math.trunc(height * 0.3);
826
+
827
+ const xmlList: string[] = [];
828
+ const xmlPaths: string[] = [];
829
+ const screenshotPaths: string[] = [];
830
+ let prevKeys: Set<string> | null = initialXml ? extractNodeKeys(initialXml) : null;
831
+
832
+ for (let i = 0; i < this.maxScrolls; i++) {
833
+ this.adb.shell(['input', 'swipe', String(cx), String(swipeFromY), String(cx), String(swipeToY), String(this.adb.scrollDurationMs)]);
834
+ this.adb.waitForIdle();
835
+ const newXml = this.adb.dumpXmlQuick();
836
+ const newKeys = extractNodeKeys(newXml);
837
+ if (prevKeys !== null && setsEqual(newKeys, prevKeys)) {
838
+ console.log(` ๐Ÿ“œ ๆปšๅŠจ ${i + 1} ๆฌกๅŽๅˆฐ่พพๅบ•้ƒจ๏ผŒ่ทณ่ฟ‡ๆˆชๅ›พ`);
839
+ break;
840
+ }
841
+ prevKeys = newKeys;
842
+ xmlList.push(newXml);
843
+ const xmlPath = path.join(pageDir, `view_scroll_${i + 1}.xml`);
844
+ fs.writeFileSync(xmlPath, newXml, 'utf-8');
845
+ xmlPaths.push(xmlPath);
846
+ const ssPath = path.join(pageDir, `screenshot_scroll_${i + 1}.png`);
847
+ const data = this.adb.screencapBytes();
848
+ if (data) {
849
+ fs.writeFileSync(ssPath, data);
850
+ screenshotPaths.push(ssPath);
851
+ console.log(` ๐Ÿ“ธ ๆปšๅŠจ ${i + 1}: XML + ๆˆชๅ›พๅทฒไฟๅญ˜`);
852
+ }
853
+ }
854
+ return { xmlList, xmlPaths, screenshotPaths };
855
+ }
856
+
857
+ capturePage(label: string): {
858
+ record: PageRecord;
859
+ xmlContent: string;
860
+ clickable: ClickableElement[];
861
+ scrollContainerBounds: [number, number, number, number] | null;
862
+ } {
863
+ const tmpDir = path.join(this.outputRoot, '_tmp_capture');
864
+ if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true });
865
+ fs.mkdirSync(tmpDir, { recursive: true });
866
+
867
+ console.log(`\n๐Ÿ“„ [้‡‡้›†ไธญ] ${label}`);
868
+ const actInfo = this.adb.getCurrentActivity();
869
+ console.log(` Focus : ${actInfo['current_focus'] ?? 'N/A'}`);
870
+ console.log(` Activity: ${actInfo['resumed_activity'] ?? 'N/A'}`);
871
+
872
+ const xmlContent = this.adb.getViewXmlFast(tmpDir);
873
+ console.log(` View XML ๅทฒไฟๅญ˜ (${Buffer.byteLength(xmlContent, 'utf-8')} bytes)`);
874
+ this.adb.takeScreenshot(tmpDir);
875
+ console.log(' ๆˆชๅ›พๅทฒไฟๅญ˜');
876
+
877
+ const allScreenshots = [path.join(tmpDir, 'screenshot.png')];
878
+ const allViewXmls = [path.join(tmpDir, 'view.xml')];
879
+ const allXmlContents = [xmlContent];
880
+ const scrollContainers = findScrollableContainers(xmlContent);
881
+ if (scrollContainers.length > 0) {
882
+ const container = scrollContainers[0];
883
+ console.log(` ๐Ÿ“œ ๆฃ€ๆต‹ๅˆฐๅฏๆปšๅŠจๅฎนๅ™จ: ${container.class} ${container['resource-id']}`);
884
+ const { xmlList, xmlPaths, screenshotPaths } = this.scrollAndDump(tmpDir, container.bounds, xmlContent);
885
+ if (xmlList.length > 0) {
886
+ allXmlContents.push(...xmlList);
887
+ allViewXmls.push(...xmlPaths);
888
+ allScreenshots.push(...screenshotPaths);
889
+ console.log(` ๐Ÿ“œ ๆปšๅŠจ้‡‡้›†ๅฎŒๆˆ๏ผš${xmlList.length} ๆฌกๆปšๅŠจ๏ผŒๅ…ฑ ${allScreenshots.length} ๅผ ๆˆชๅ›พ`);
890
+ }
891
+ }
892
+
893
+ const seenClickable = new Set<string>();
894
+ const clickable: ClickableElement[] = [];
895
+ allXmlContents.forEach((xc, idx) => {
896
+ for (const elem of getClickableElements(xc)) {
897
+ const key = [
898
+ elem['resource-id'] ?? '',
899
+ elem.text ?? '',
900
+ elem['content-desc'] ?? '',
901
+ elem.class ?? '',
902
+ elem.bounds ?? '',
903
+ String(elem['long-clickable'] ?? false),
904
+ ].join(' ');
905
+ if (!seenClickable.has(key)) {
906
+ seenClickable.add(key);
907
+ elem.scroll_index = idx;
908
+ clickable.push(elem);
909
+ }
910
+ }
911
+ });
912
+ console.log(` ๅฏ็‚นๅ‡ปๅ…ƒ็ด : ${clickable.length} ไธช`);
913
+
914
+ const scrollContainerBounds = scrollContainers.length > 0 ? scrollContainers[0].bounds : null;
915
+ const record: PageRecord = {
916
+ page_id: null,
917
+ label,
918
+ timestamp: new Date().toISOString(),
919
+ activity_info: actInfo,
920
+ clickable_count: clickable.length,
921
+ clickable_elements: clickable,
922
+ scroll_container_bounds: scrollContainerBounds ? [...scrollContainerBounds] : null,
923
+ _tmp_dir: tmpDir,
924
+ _screenshots: allScreenshots,
925
+ _view_xmls: allViewXmls,
926
+ };
927
+ return { record, xmlContent, clickable, scrollContainerBounds };
928
+ }
929
+
930
+ restartToRoot(
931
+ launchPackage: string | null,
932
+ launchActivity: string | null,
933
+ rootActivityKeyword: string | null = null,
934
+ rootIdentity: RootIdentity | null = null,
935
+ ): boolean {
936
+ if (!launchPackage || !launchActivity) {
937
+ console.log(' [WARN] ๅŒ…ๅๆˆ– Activity ๅไธบ็ฉบ๏ผŒๆ— ๆณ•้‡ๅฏ');
938
+ return false;
939
+ }
940
+ const component = `${launchPackage}/${launchActivity}`;
941
+
942
+ const isAtRoot = (): boolean => {
943
+ const activityInfo = this.adb.getCurrentActivity();
944
+ const focus = activityInfo['current_focus'] ?? '';
945
+ const resumed = activityInfo['resumed_activity'] ?? '';
946
+ if (rootActivityKeyword) {
947
+ if (!focus.includes(rootActivityKeyword) && !resumed.includes(rootActivityKeyword)) return false;
948
+ }
949
+ if (rootIdentity) {
950
+ const xml = this.adb.dumpXmlQuick();
951
+ const curIdentity = rootIdentifiedSet(activityInfo, xml);
952
+ if (!isSameRootPage(rootIdentity, curIdentity)) {
953
+ const [, curElems] = curIdentity;
954
+ const [, rootElems] = rootIdentity;
955
+ let inter = 0;
956
+ for (const v of rootElems) if (curElems.has(v)) inter++;
957
+ const union = rootElems.size + curElems.size - inter;
958
+ const sim = union ? inter / union : 0;
959
+ console.log(` [DEBUG] ๆ น้กต้ข็›ธไผผๅบฆ: ${sim.toFixed(2)} (ไบค้›†${inter}/ๅนถ้›†${union})`);
960
+ return false;
961
+ }
962
+ }
963
+ return true;
964
+ };
965
+
966
+ this.adb.shell(['am', 'force-stop', launchPackage]);
967
+ sleep(1.0);
968
+ this.adb.shell(['am', 'start', '-n', component, '--activity-clear-task', '--activity-clear-top']);
969
+ this.adb.waitForIdle();
970
+
971
+ for (let retry = 0; retry < 4; retry++) {
972
+ if (isAtRoot()) return true;
973
+ console.log(` [INFO] ็ญ‰ๅพ…้กต้ขๅŠ ่ฝฝๅฎŒๆˆ... (้‡่ฏ• ${retry + 1}/4)`);
974
+ sleep(2);
975
+ }
976
+
977
+ console.log(' [WARN] force-stop ๅŽๆœชๅˆฐ่พพๆ น้กต้ข๏ผˆApp ๆขๅคไบ†ไธŠๆฌก็š„ Tab ็Šถๆ€๏ผ‰');
978
+ console.log(' [INFO] ๅฐ่ฏ• back + ็‚นๅ‡ป่ทฏๅพ„ๆขๅค...');
979
+ this.adb.pressBack();
980
+ if (isAtRoot()) return true;
981
+
982
+ console.log(' [WARN] ๆ‰€ๆœ‰ๆขๅคๆ–นๅผๅ‡ๅคฑ่ดฅ๏ผŒ่ทณ่ฟ‡ๆญคๆฌกๅฏผ่ˆช');
983
+ return false;
984
+ }
985
+
986
+ 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 {
994
+ if (!launchPackage || !launchActivity) {
995
+ console.log(' [WARN] ๅŒ…ๅๆˆ– Activity ๅไธบ็ฉบ๏ผŒๆ— ๆณ•ๅฏผ่ˆช');
996
+ return false;
997
+ }
998
+ if (clickPath.length === 0) return true;
999
+ if (!this.restartToRoot(launchPackage, launchActivity, rootActivityKeyword, rootIdentity)) {
1000
+ console.log(' [ERR] ๆ— ๆณ•ๅ›žๅˆฐๆ น้กต้ข๏ผŒๅฏผ่ˆชๅคฑ่ดฅ');
1001
+ return false;
1002
+ }
1003
+
1004
+ for (let i = 0; i < clickPath.length; i++) {
1005
+ const step = clickPath[i];
1006
+ let xml = this.adb.dumpXmlQuick();
1007
+ const stepScroll = step.scroll_info;
1008
+ if (stepScroll && stepScroll.scroll_index > 0) {
1009
+ this.adb.scrollToPosition(stepScroll.container_bounds as [number, number, number, number], stepScroll.scroll_index);
1010
+ xml = this.adb.dumpXmlQuick();
1011
+ }
1012
+ const elemId: [string, string, string, string] = [step['resource-id'] ?? '', step.text ?? '', step['content-desc'] ?? '', ''];
1013
+ const found = findElementOnScreen(xml, elemId);
1014
+ const isLong = step['long-clickable'] ?? false;
1015
+ if (found && found.center) {
1016
+ this.adb.tapOrLongPress(found.center[0], found.center[1], isLong);
1017
+ } else if (step.center) {
1018
+ console.log(` [WARN] ๆœชๆ‰พๅˆฐๅ…ƒ็ด  ${step.element ?? '?'}๏ผŒไฝฟ็”จ่ฎฐๅฝ•ๅๆ ‡`);
1019
+ this.adb.tapOrLongPress(step.center[0], step.center[1], isLong);
1020
+ } else {
1021
+ console.log(` [ERR] ๆ— ๆณ•ๅฏผ่ˆชๅˆฐๆญฅ้ชค: ${step.element ?? '?'}`);
1022
+ return false;
1023
+ }
1024
+
1025
+ if (stepSignatures && i < stepSignatures.length && stepSignatures[i]) {
1026
+ const verifyXml = this.adb.dumpXmlQuick();
1027
+ const verifyInfo = this.adb.getCurrentActivity();
1028
+ const actualSig = pageSignature(verifyInfo, verifyXml);
1029
+ if (actualSig !== stepSignatures[i]) {
1030
+ const expectedAct = stepSignatures[i].split('|')[0];
1031
+ const actualAct = actualSig.split('|')[0];
1032
+ if (expectedAct !== actualAct) {
1033
+ console.log(` [WARN] ๅฏผ่ˆชๆญฅ้ชค ${i + 1} ๅŽ Activity ไธๅŒน้… (ๆœŸๆœ›=${expectedAct}, ๅฎž้™…=${actualAct})`);
1034
+ return false;
1035
+ }
1036
+ }
1037
+ }
1038
+ }
1039
+ return true;
1040
+ }
1041
+
1042
+ // โ”€โ”€ ๆ–ญ็‚น็ปญ่ท‘ โ”€โ”€
1043
+ private saveQueue(): void {
1044
+ const items = this.queue.map(([clickPath, elemId, stepSigs, scrollInfo]) => [clickPath, [...elemId], stepSigs, scrollInfo]);
1045
+ try {
1046
+ fs.writeFileSync(path.join(this.outputRoot, 'queue_checkpoint.json'), JSON.stringify(items), 'utf-8');
1047
+ } catch (e) {
1048
+ console.log(` [WARN] ไฟๅญ˜้˜Ÿๅˆ—ๆฃ€ๆŸฅ็‚นๅคฑ่ดฅ: ${(e as Error).message}`);
1049
+ }
1050
+ }
1051
+
1052
+ private loadQueue(): QueueItem[] | null {
1053
+ const qfile = path.join(this.outputRoot, 'queue_checkpoint.json');
1054
+ if (!fs.existsSync(qfile)) return null;
1055
+ try {
1056
+ const items = JSON.parse(fs.readFileSync(qfile, 'utf-8')) as [ClickStep[], unknown[], string[], ScrollInfo | null][];
1057
+ return items.map(([clickPath, elemIdList, stepSigs, scrollInfo]) => [
1058
+ clickPath,
1059
+ elemIdList as ElemId,
1060
+ stepSigs,
1061
+ scrollInfo,
1062
+ ]);
1063
+ } catch (e) {
1064
+ console.log(` [WARN] ๅŠ ่ฝฝ้˜Ÿๅˆ—ๆฃ€ๆŸฅ็‚นๅคฑ่ดฅ: ${(e as Error).message}๏ผŒๅฐ†้‡ๅปบ้˜Ÿๅˆ—`);
1065
+ return null;
1066
+ }
1067
+ }
1068
+
1069
+ private rebuildQueueFromPages(pagesIndex: PageRecord[]): QueueItem[] {
1070
+ const queue: QueueItem[] = [];
1071
+ for (const page of pagesIndex) {
1072
+ const clickPath = (Array.isArray(page.click_path) ? page.click_path : []) as ClickStep[];
1073
+ const clickable = page.clickable_elements ?? [];
1074
+ const scrollContainerBounds = page.scroll_container_bounds;
1075
+ for (const elem of clickable) {
1076
+ if (this.shouldSkipElement(elem)) continue;
1077
+ const elemId = PageCrawler.elemIdOf(elem);
1078
+ const si = elem.scroll_index ?? 0;
1079
+ let scrollInfo: ScrollInfo | null = null;
1080
+ if (si > 0 && scrollContainerBounds) scrollInfo = { scroll_index: si, container_bounds: [...scrollContainerBounds] };
1081
+ queue.push([clickPath, elemId, [], scrollInfo]);
1082
+ }
1083
+ }
1084
+ return queue;
1085
+ }
1086
+
1087
+ private tryLoadCheckpoint():
1088
+ | { pagesIndex: PageRecord[]; visited: Set<string>; pageCounter: number; queue: QueueItem[]; blocked: Set<string> }
1089
+ | null {
1090
+ const indexFile = path.join(this.outputRoot, 'index.json');
1091
+ if (!fs.existsSync(indexFile)) return null;
1092
+ const pageDirs = fs.existsSync(this.outputRoot)
1093
+ ? fs.readdirSync(this.outputRoot).filter((d) => d.startsWith('page_'))
1094
+ : [];
1095
+ if (pageDirs.length === 0) return null;
1096
+
1097
+ let pagesIndex: PageRecord[];
1098
+ try {
1099
+ pagesIndex = JSON.parse(fs.readFileSync(indexFile, 'utf-8')) as PageRecord[];
1100
+ } catch (e) {
1101
+ console.log(` [WARN] ่ฏปๅ– index.json ๅคฑ่ดฅ: ${(e as Error).message}๏ผŒ้‡ๆ–ฐๅผ€ๅง‹`);
1102
+ return null;
1103
+ }
1104
+ if (!pagesIndex || pagesIndex.length === 0) return null;
1105
+
1106
+ console.log(`\nโฉ ๅ‘็Žฐๅทฒๆœ‰ๆฃ€ๆŸฅ็‚น๏ผš${pagesIndex.length} ไธช้กต้ข๏ผŒๅฐ่ฏ•ๆ–ญ็‚น็ปญ่ท‘...`);
1107
+ const visited = new Set<string>();
1108
+ let pageCounter = 0;
1109
+ for (const page of pagesIndex) {
1110
+ const pageId = page.page_id ?? '';
1111
+ if (!pageId) continue;
1112
+ const m = pageId.match(/page_(\d+)/);
1113
+ if (m) pageCounter = Math.max(pageCounter, parseInt(m[1], 10));
1114
+ const viewXmlPath = path.join(this.outputRoot, pageId, 'view.xml');
1115
+ if (fs.existsSync(viewXmlPath)) {
1116
+ const xmlContent = fs.readFileSync(viewXmlPath, 'utf-8');
1117
+ visited.add(pageSignature(page.activity_info ?? {}, xmlContent));
1118
+ }
1119
+ }
1120
+
1121
+ const savedQueue = this.loadQueue();
1122
+ let queue: QueueItem[];
1123
+ if (savedQueue !== null) {
1124
+ queue = savedQueue;
1125
+ console.log(` ้˜Ÿๅˆ—ๅทฒไปŽๆฃ€ๆŸฅ็‚นๆขๅค๏ผš${queue.length} ไธชๅพ…ๅค„็†ๅ…ƒ็ด ๏ผˆ็ฒพ็กฎ็ปญ่ท‘๏ผ‰`);
1126
+ } else {
1127
+ queue = this.rebuildQueueFromPages(pagesIndex);
1128
+ console.log(` ้˜Ÿๅˆ—ๅทฒ้‡ๅปบ๏ผˆfallback๏ผ‰๏ผš${queue.length} ไธชๅ…ƒ็ด ๏ผˆๅซๅทฒๆŽข็ดข้กน๏ผŒBFS ไผš่‡ชๅŠจ่ทณ่ฟ‡๏ผ‰`);
1129
+ }
1130
+ const blocked = new Set<string>();
1131
+ console.log(` ๅทฒๆขๅค ${visited.size} ไธชๅทฒ่ฎฟ้—ฎ็ญพๅ๏ผŒ้˜Ÿๅˆ— ${queue.length} ไธชๅพ…ๅค„็†ๅ…ƒ็ด `);
1132
+ return { pagesIndex, visited, pageCounter, queue, blocked };
1133
+ }
1134
+
1135
+ // โ”€โ”€ ้กต้ขๆไบค โ”€โ”€
1136
+ private finalizePage(record: PageRecord, pageId: string): void {
1137
+ const pageDir = path.join(this.outputRoot, pageId);
1138
+ const tmpDir = record._tmp_dir!;
1139
+ if (fs.existsSync(tmpDir)) fs.renameSync(tmpDir, pageDir);
1140
+ else fs.mkdirSync(pageDir, { recursive: true });
1141
+
1142
+ record.page_id = pageId;
1143
+ record.screenshot = `${pageId}/screenshot.png`;
1144
+ record.view_xml = `${pageId}/view.xml`;
1145
+
1146
+ const screenshotsList = [`${pageId}/screenshot.png`];
1147
+ for (const ssPath of record._screenshots ?? []) {
1148
+ const ssName = path.basename(ssPath);
1149
+ if (ssName !== 'screenshot.png') screenshotsList.push(`${pageId}/${ssName}`);
1150
+ }
1151
+ record.screenshots = screenshotsList;
1152
+
1153
+ const viewXmlsList = [`${pageId}/view.xml`];
1154
+ for (const xmlPath of record._view_xmls ?? []) {
1155
+ const xmlName = path.basename(xmlPath);
1156
+ if (xmlName !== 'view.xml') viewXmlsList.push(`${pageId}/${xmlName}`);
1157
+ }
1158
+ record.view_xmls = viewXmlsList;
1159
+
1160
+ delete record._tmp_dir;
1161
+ delete record._screenshots;
1162
+ delete record._view_xmls;
1163
+
1164
+ fs.writeFileSync(path.join(pageDir, 'meta.json'), JSON.stringify(record, null, 2), 'utf-8');
1165
+ console.log(` โœ… ๅทฒไฟๅญ˜ไธบ [${pageId}]`);
1166
+ }
1167
+
1168
+ private activityNameSuffix(record: PageRecord): string {
1169
+ const resumed = record.activity_info?.['resumed_activity'] ?? '';
1170
+ let mAct = resumed.match(/[\w.]+\/([\w.]+)/);
1171
+ if (!mAct) {
1172
+ const f = record.activity_info?.['current_focus'] ?? '';
1173
+ mAct = f.match(/[\w.]+\/([\w.]+)/);
1174
+ }
1175
+ let activityName = '';
1176
+ if (mAct) {
1177
+ const parts = mAct[1].split('.');
1178
+ activityName = parts[parts.length - 1];
1179
+ }
1180
+ return activityName ? `_${activityName}` : '';
1181
+ }
1182
+
1183
+ commitPage(record: PageRecord): string {
1184
+ this.pageCounter += 1;
1185
+ const suffix = this.activityNameSuffix(record);
1186
+ const pageId = `page_${String(this.pageCounter).padStart(4, '0')}${suffix}`;
1187
+ this.finalizePage(record, pageId);
1188
+ return pageId;
1189
+ }
1190
+
1191
+ commitManualPage(record: PageRecord, pageCounter: number): string {
1192
+ const suffix = this.activityNameSuffix(record);
1193
+ const pageId = `manual_${String(pageCounter).padStart(4, '0')}${suffix}`;
1194
+ this.finalizePage(record, pageId);
1195
+ return pageId;
1196
+ }
1197
+
1198
+ // โ”€โ”€ HTML ๆŠฅๅ‘Š็”Ÿๆˆ โ”€โ”€
1199
+ generateHtmlReport(pages: PageRecord[]): void {
1200
+ let cards = '';
1201
+ for (const p of pages) {
1202
+ const activity = escapeHtml(p.activity_info?.['current_focus'] ?? 'N/A');
1203
+ const resumed = escapeHtml(p.activity_info?.['resumed_activity'] ?? 'N/A');
1204
+ const clickPath = p.click_path ?? [];
1205
+ let pathDisplay: string;
1206
+ if (clickPath === 'manual') {
1207
+ pathDisplay = `๏ผˆๆ‰‹ๅŠจ้‡‡้›†๏ผš${escapeHtml(p.description ?? 'ๆ‰‹ๅŠจ้‡‡้›†')}๏ผ‰`;
1208
+ } else if (Array.isArray(clickPath) && clickPath.length > 0) {
1209
+ const longClickTag = '<b class="long-click">้•ฟๆŒ‰</b>';
1210
+ pathDisplay = clickPath
1211
+ .map(
1212
+ (s) =>
1213
+ `<code>${escapeHtml(s.from)}</code> ${s['long-clickable'] ? longClickTag : '็‚นๅ‡ป'} <b>${escapeHtml(s.element)}</b>`,
1214
+ )
1215
+ .join(' โ†’ ');
1216
+ } else {
1217
+ pathDisplay = '๏ผˆๅˆๅง‹้กต๏ผ‰';
1218
+ }
1219
+
1220
+ const screenshotPath = escapeHtml(p.screenshot ?? '');
1221
+ const screenshots = p.screenshots ?? [p.screenshot ?? ''];
1222
+ const viewXmls = p.view_xmls ?? [p.view_xml ?? ''];
1223
+ let screenshotsHtml: string;
1224
+ if (screenshots.length > 1) {
1225
+ let pairsHtml = '';
1226
+ screenshots.forEach((ss, idx) => {
1227
+ const xmlLink = idx < viewXmls.length ? viewXmls[idx] : viewXmls[viewXmls.length - 1];
1228
+ const labelTag = idx === 0 ? 'ๅˆๅง‹' : `ๆปšๅŠจ${idx}`;
1229
+ pairsHtml +=
1230
+ `<div class="scroll-pair">` +
1231
+ `<img src="${escapeHtml(ss)}" alt="screenshot" onerror="this.style.display='none'"/>` +
1232
+ `<a href="${escapeHtml(xmlLink)}" target="_blank">${labelTag} XML</a>` +
1233
+ `</div>`;
1234
+ });
1235
+ screenshotsHtml = `<div class="screenshots-gallery">${pairsHtml}</div>`;
1236
+ } else {
1237
+ screenshotsHtml = `<img src="${escapeHtml(screenshots[0])}" alt="screenshot" onerror="this.style.display='none'"/>`;
1238
+ }
1239
+
1240
+ const labelEscaped = escapeHtml(p.label);
1241
+ const pageIdEscaped = escapeHtml(p.page_id ?? '');
1242
+ const viewXmlEscaped = escapeHtml(p.view_xml ?? '');
1243
+ let xmlLinksHtml: string;
1244
+ if (viewXmls.length > 1) {
1245
+ xmlLinksHtml = viewXmls
1246
+ .map((vx, i) => `<a href="${escapeHtml(vx)}" target="_blank">๐Ÿ“„ XML${i === 0 ? '' : `_${i}`}</a>`)
1247
+ .join(' ');
1248
+ } else {
1249
+ xmlLinksHtml = `<a href="${viewXmlEscaped}" target="_blank">๐Ÿ“„ ๆŸฅ็œ‹ View XML</a>`;
1250
+ }
1251
+
1252
+ cards += `
1253
+ <div class="card">
1254
+ <div class="card-header">
1255
+ <span class="pid">${pageIdEscaped}</span>
1256
+ <span class="label">${labelEscaped}</span>
1257
+ <span class="time">${escapeHtml((p.timestamp ?? '').slice(0, 19))}</span>
1258
+ </div>
1259
+ <div class="card-body">
1260
+ ${screenshotsHtml}
1261
+ <div class="meta">
1262
+ <div><b>Focus:</b> <code>${activity}</code></div>
1263
+ <div><b>Activity:</b> <code>${resumed}</code></div>
1264
+ <div><b>็‚นๅ‡ป่ทฏๅพ„:</b> ${pathDisplay}</div>
1265
+ <div><b>ๅฏ็‚นๅ‡ปๅ…ƒ็ด :</b> ${p.clickable_count} ไธช</div>
1266
+ <div class="links">
1267
+ ${xmlLinksHtml}
1268
+ <a href="${pageIdEscaped}/meta.json" target="_blank">๐Ÿ—‚ ๅ…ƒๆ•ฐๆฎ JSON</a>
1269
+ </div>
1270
+ </div>
1271
+ </div>
1272
+ </div>`;
1273
+ }
1274
+
1275
+ const now = new Date();
1276
+ const pad = (x: number): string => String(x).padStart(2, '0');
1277
+ const genTime = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
1278
+
1279
+ const html = `<!DOCTYPE html>
1280
+ <html lang="zh">
1281
+ <head>
1282
+ <meta charset="UTF-8">
1283
+ <title>Android ้กต้ข้ๅކๆŠฅๅ‘Š๏ผˆๅŠ ้€Ÿ็‰ˆ๏ผ‰</title>
1284
+ <style>
1285
+ body { font-family: -apple-system, sans-serif; background: #f5f5f5; margin: 0; padding: 20px; }
1286
+ h1 { color: #333; }
1287
+ .summary { background: #fff; border-radius: 8px; padding: 16px; margin-bottom: 20px; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
1288
+ .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(420px, 1fr)); gap: 16px; }
1289
+ .card { background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
1290
+ .card-header { background: #1a73e8; color: #fff; padding: 10px 14px; display: flex; gap: 10px; align-items: center; }
1291
+ .pid { font-weight: bold; font-size: 13px; background: rgba(255,255,255,.2); padding: 2px 8px; border-radius: 4px; }
1292
+ .label { flex: 1; font-size: 14px; }
1293
+ .time { font-size: 11px; opacity: .8; }
1294
+ .card-body { display: flex; gap: 12px; padding: 12px; }
1295
+ .card-body img { width: 140px; height: auto; border-radius: 4px; border: 1px solid #eee; object-fit: cover; flex-shrink: 0; }
1296
+ .screenshots-gallery { display: flex; gap: 6px; overflow-x: auto; flex-shrink: 0; max-width: 320px; padding-bottom: 4px; }
1297
+ .screenshots-gallery img { width: 120px; height: auto; border-radius: 4px; border: 1px solid #eee; object-fit: cover; flex-shrink: 0; }
1298
+ .scroll-pair { display: flex; flex-direction: column; align-items: center; gap: 2px; flex-shrink: 0; }
1299
+ .scroll-pair a { font-size: 10px; color: #1a73e8; text-decoration: none; }
1300
+ .scroll-pair a:hover { text-decoration: underline; }
1301
+ .meta { font-size: 12px; color: #444; display: flex; flex-direction: column; gap: 6px; overflow: hidden; }
1302
+ .meta code { font-size: 11px; background: #f0f0f0; padding: 1px 4px; border-radius: 3px; word-break: break-all; }
1303
+ .links { display: flex; gap: 10px; margin-top: 4px; }
1304
+ .links a { color: #1a73e8; text-decoration: none; font-size: 12px; }
1305
+ .links a:hover { text-decoration: underline; }
1306
+ .long-click { color: #e65100; background: #fff3e0; padding: 1px 5px; border-radius: 3px; font-size: 11px; }
1307
+ </style>
1308
+ </head>
1309
+ <body>
1310
+ <h1>๐Ÿ“ฑ Android ้กต้ข้ๅކๆŠฅๅ‘Š๏ผˆๅŠ ้€Ÿ็‰ˆ๏ผ‰</h1>
1311
+ <div class="summary">
1312
+ <b>็”Ÿๆˆๆ—ถ้—ด๏ผš</b>${genTime} &nbsp;|&nbsp;
1313
+ <b>ๅ…ฑ้‡‡้›†้กต้ข๏ผš</b>${pages.length} ไธช
1314
+ </div>
1315
+ <div class="grid">
1316
+ ${cards}
1317
+ </div>
1318
+ </body>
1319
+ </html>`;
1320
+
1321
+ fs.writeFileSync(path.join(this.outputRoot, 'report.html'), html, 'utf-8');
1322
+ }
1323
+
1324
+ private flushReport(): void {
1325
+ try {
1326
+ fs.writeFileSync(path.join(this.outputRoot, 'index.json'), JSON.stringify(this.pagesIndex, null, 2), 'utf-8');
1327
+ this.generateHtmlReport(this.pagesIndex);
1328
+ this.saveQueue();
1329
+ } catch (e) {
1330
+ console.log(` [WARN] ๅ†™ๅ…ฅๆŠฅๅ‘Šๅคฑ่ดฅ: ${(e as Error).message}`);
1331
+ }
1332
+ }
1333
+
1334
+ crawl(): void {
1335
+ if (!this.adb.checkDevice()) return;
1336
+
1337
+ let activityInfo = this.adb.getCurrentActivity();
1338
+ // ้œ€่ฆๅนฒๅ‡€้‡ๅฏ็š„ไธค็งๆƒ…ๅ†ต๏ผš
1339
+ // 1) ไธๅœจ็›ฎๆ ‡ App ๅ†…๏ผˆๅŽŸ้€ป่พ‘๏ผ‰ใ€‚
1340
+ // 2) ๅœจ็›ฎๆ ‡ App ๅ†…๏ผŒไฝ†ๅ‰ๅฐๆ˜ฏๅผน็ช—/overlay โ€”โ€” current_focus ๆ—  activity ๅˆ†้‡๏ผŒๆๅ–ไธๅˆฐๅฏๅŠจ็ป„ไปถใ€‚
1341
+ // ๅธธ่งไบŽไธŠไธ€่ฝฎ้ๅކ/็ปญ่ท‘็ป“ๆŸๆ—ถๅœๅœจๅผน็ช—ไธŠ๏ผˆresumed_activity ไปๅซๅŒ…ๅ๏ผŒไผš่ขซๅˆคๅฎš"ๅœจ App ๅ†…"๏ผ‰ใ€‚
1342
+ // ๆญคๆ—ถ่‹ฅไธ้‡ๅฏ๏ผŒๅŽ็ปญๆ‰€ๆœ‰ force-stop ๅฏผ่ˆช้ƒฝไผšๅ› ็ผบๅฐ‘ๅฏๅŠจไฟกๆฏ่€Œไธญๆญขใ€‚
1343
+ const focusHasComponent = focusHasActivityComponent(activityInfo);
1344
+ const notInApp = !this.isInTargetApp(activityInfo);
1345
+ if (notInApp || !focusHasComponent) {
1346
+ if (notInApp) {
1347
+ console.log(`ๅฝ“ๅ‰ไธๅœจ็›ฎๆ ‡ App (${this.package}) ๅ†…๏ผŒๆธ…็ฉบๆ•ฐๆฎๅนถๅฏๅŠจ...`);
1348
+ } else {
1349
+ console.log(
1350
+ `ๅฝ“ๅ‰ๅ‰ๅฐไธบๅผน็ช—/overlay๏ผˆ${activityInfo['current_focus'] ?? 'N/A'}๏ผ‰๏ผŒforce-stop ๅŽ้‡ๆ–ฐๅฏๅŠจไปฅๅ›žๅˆฐๅนฒๅ‡€ๆ น้กต้ข...`,
1351
+ );
1352
+ }
1353
+ this.adb.shell(['am', 'force-stop', this.package]);
1354
+ sleep(0.5);
1355
+ this.adb.shell(['monkey', '-p', this.package, '-c', 'android.intent.category.LAUNCHER', '1']);
1356
+ this.adb.waitForIdle();
1357
+ activityInfo = this.adb.getCurrentActivity();
1358
+ if (!this.isInTargetApp(activityInfo)) {
1359
+ console.log(`[ERR] ๅฏๅŠจๅŽไปๆœช่ฟ›ๅ…ฅ็›ฎๆ ‡ App (${this.package})๏ผŒ่ฏทๆฃ€ๆŸฅๅŒ…ๅๆ˜ฏๅฆๆญฃ็กฎ`);
1360
+ return;
1361
+ }
1362
+ }
1363
+
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}`);
1370
+ } else {
1371
+ console.log(
1372
+ `[WARN] ๆ— ๆณ•ๆๅ–ๅฏๅŠจ Activity: focus=${activityInfo['current_focus'] ?? ''} resumed=${activityInfo['resumed_activity'] ?? ''}`,
1373
+ );
1374
+ }
1375
+
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
+ }
1382
+
1383
+ fs.mkdirSync(this.outputRoot, { recursive: true });
1384
+ console.log(`\n๐Ÿ“‚ ่พ“ๅ‡บ็›ฎๅฝ•: ${path.resolve(this.outputRoot)}\n`);
1385
+ console.log('='.repeat(60));
1386
+ console.log('ๅผ€ๅง‹้ๅކ๏ผˆๅŠ ้€Ÿ็‰ˆ๏ผšBFS + ่ฝป้‡ๆŽขๆต‹๏ผ‰...');
1387
+ console.log('='.repeat(60));
1388
+
1389
+ let rootIdentity: RootIdentity | null = null;
1390
+ const checkpoint = this.tryLoadCheckpoint();
1391
+ if (checkpoint) {
1392
+ this.pagesIndex = checkpoint.pagesIndex;
1393
+ this.visitedSignatures = checkpoint.visited;
1394
+ this.pageCounter = checkpoint.pageCounter;
1395
+ this.queue = checkpoint.queue;
1396
+ this.blockedElements = checkpoint.blocked;
1397
+ console.log(`โฉ ๆ–ญ็‚น็ปญ่ท‘๏ผšไปŽ็ฌฌ ${this.pageCounter + 1} ไธช้กต้ข็ปง็ปญ\n`);
1398
+ for (const p of this.pagesIndex) {
1399
+ 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');
1401
+ if (fs.existsSync(rootXmlPath)) {
1402
+ const rootXml = fs.readFileSync(rootXmlPath, 'utf-8');
1403
+ rootIdentity = rootIdentifiedSet(p.activity_info ?? {}, rootXml);
1404
+ }
1405
+ break;
1406
+ }
1407
+ }
1408
+ } else {
1409
+ const { record, xmlContent, clickable, scrollContainerBounds } = this.capturePage('ๅˆๅง‹้กต้ข');
1410
+ const pageId = this.commitPage(record);
1411
+ record.click_path = [];
1412
+ fs.writeFileSync(path.join(this.outputRoot, pageId, 'meta.json'), JSON.stringify(record, null, 2), 'utf-8');
1413
+ const sig = pageSignature(record.activity_info, xmlContent);
1414
+ rootIdentity = rootIdentifiedSet(record.activity_info, xmlContent);
1415
+ this.visitedSignatures.add(sig);
1416
+ this.pathSignatures[JSON.stringify([])] = sig;
1417
+ this.pagesIndex.push(record);
1418
+ this.flushReport();
1419
+
1420
+ for (const elem of clickable) {
1421
+ if (this.shouldSkipElement(elem)) {
1422
+ this.stats.skipped_elements += 1;
1423
+ continue;
1424
+ }
1425
+ const elemId = PageCrawler.elemIdOf(elem);
1426
+ const si = elem.scroll_index ?? 0;
1427
+ let scrollInfo: ScrollInfo | null = null;
1428
+ if (si > 0 && scrollContainerBounds) scrollInfo = { scroll_index: si, container_bounds: [...scrollContainerBounds] };
1429
+ this.queue.push([[], elemId, [], scrollInfo]);
1430
+ }
1431
+ }
1432
+
1433
+ const failedPaths = new Set<string>();
1434
+ while (this.queue.length > 0 && this.pageCounter < this.maxPages) {
1435
+ const [clickPath, elemId, stepSignatures, scrollInfo] = this.queue.shift()!;
1436
+ const elemKey = JSON.stringify(elemId);
1437
+ if (this.blockedElements.has(elemKey)) continue;
1438
+
1439
+ const pathKey = clickPath.length > 0 ? JSON.stringify(clickPath) : '';
1440
+ let skip = false;
1441
+ for (const fp of failedPaths) {
1442
+ if (pathKey.startsWith(fp) && fp.length > 2) {
1443
+ skip = true;
1444
+ break;
1445
+ }
1446
+ }
1447
+ if (skip) continue;
1448
+
1449
+ const pathDesc = clickPath.length > 0 ? clickPath.map((s) => s.element ?? '?').join(' โ†’ ') : 'ๆ น้กต้ข';
1450
+ console.log(`\n๐Ÿงญ ๅฏผ่ˆชๅˆฐ: ${pathDesc}`);
1451
+
1452
+ if (!launchPackage || !launchActivity) {
1453
+ console.log(' [ERR] ๆ— ๆณ•้‡ๅฏ App๏ผŒ็ผบๅฐ‘ๅฏๅŠจไฟกๆฏ');
1454
+ continue;
1455
+ }
1456
+
1457
+ if (clickPath.length > 0) {
1458
+ if (!this.navigateViaRestart(clickPath, launchPackage, launchActivity, rootActivityKeyword, stepSignatures, rootIdentity)) {
1459
+ console.log(' [ERR] ๅฏผ่ˆชๅคฑ่ดฅ๏ผŒ่ทณ่ฟ‡');
1460
+ failedPaths.add(pathKey);
1461
+ continue;
1462
+ }
1463
+ } else {
1464
+ if (!this.restartToRoot(launchPackage, launchActivity, rootActivityKeyword, rootIdentity)) {
1465
+ console.log(' [ERR] ๆ— ๆณ•ๅ›žๅˆฐๆ น้กต้ข๏ผŒ่ทณ่ฟ‡');
1466
+ continue;
1467
+ }
1468
+ }
1469
+
1470
+ let freshXml = this.adb.dumpXmlQuick();
1471
+ if (scrollInfo && scrollInfo.scroll_index > 0) {
1472
+ console.log(` ๐Ÿ“œ ๆปšๅŠจ ${scrollInfo.scroll_index} ๆฌกๅˆฐ่พพๅ…ƒ็ด ไฝ็ฝฎ`);
1473
+ this.adb.scrollToPosition(scrollInfo.container_bounds as [number, number, number, number], scrollInfo.scroll_index);
1474
+ freshXml = this.adb.dumpXmlQuick();
1475
+ }
1476
+
1477
+ const foundElem = findElementOnScreen(freshXml, elemId);
1478
+ if (!foundElem || !foundElem.center) {
1479
+ console.log(` [SKIP] ๆ— ๆณ•ๆ‰พๅˆฐๅ…ƒ็ด : ${JSON.stringify(elemId.slice(0, 3))}`);
1480
+ continue;
1481
+ }
1482
+
1483
+ const center = foundElem.center;
1484
+ const labelText = foundElem.text || foundElem['content-desc'] || foundElem['resource-id'] || 'unknown';
1485
+ console.log(`\n๐Ÿ‘† ็‚นๅ‡ป: [${labelText}] @ ${JSON.stringify(center)}`);
1486
+ const isLong = foundElem['long-clickable'] ?? false;
1487
+ if (isLong) console.log(' ๐Ÿ” ไฝฟ็”จ้•ฟๆŒ‰่งฆๅ‘');
1488
+ this.adb.tapOrLongPress(center[0], center[1], isLong);
1489
+
1490
+ const probeXml = this.adb.dumpXmlQuick();
1491
+ const probeInfo = this.adb.getCurrentActivity();
1492
+ let newSig = pageSignature(probeInfo, probeXml);
1493
+
1494
+ if (!this.isInTargetApp(probeInfo)) {
1495
+ console.log(' โš ๏ธ ่ทณ่ฝฌๅˆฐๅ…ถไป– App๏ผŒๆ ‡่ฎฐๅนถ่ทณ่ฟ‡');
1496
+ this.blockedElements.add(elemKey);
1497
+ continue;
1498
+ }
1499
+ if (this.visitedSignatures.has(newSig)) {
1500
+ console.log(' โ™ป๏ธ ้กต้ขๅทฒ่ฎฟ้—ฎ่ฟ‡๏ผŒ่ทณ่ฟ‡๏ผˆ็œๆˆชๅ›พ๏ผ‰');
1501
+ this.stats.probe_skipped += 1;
1502
+ continue;
1503
+ }
1504
+
1505
+ const { record: newRecord, xmlContent: newXml, clickable: newClickable, scrollContainerBounds: newScrollBounds } =
1506
+ this.capturePage(`็‚นๅ‡ป'${labelText}'ๅŽ`);
1507
+ newSig = pageSignature(newRecord.activity_info, newXml);
1508
+ this.visitedSignatures.add(newSig);
1509
+ const pageId = this.commitPage(newRecord);
1510
+
1511
+ const newClickPath: ClickStep[] = [
1512
+ ...clickPath,
1513
+ {
1514
+ from: pathDesc,
1515
+ element: labelText,
1516
+ center: [...center],
1517
+ 'resource-id': foundElem['resource-id'] ?? '',
1518
+ text: foundElem.text ?? '',
1519
+ 'content-desc': foundElem['content-desc'] ?? '',
1520
+ 'long-clickable': isLong,
1521
+ scroll_info: scrollInfo,
1522
+ },
1523
+ ];
1524
+ newRecord.came_from = pathDesc;
1525
+ newRecord.trigger_element = labelText;
1526
+ newRecord.click_path = newClickPath;
1527
+ fs.writeFileSync(path.join(this.outputRoot, pageId, 'meta.json'), JSON.stringify(newRecord, null, 2), 'utf-8');
1528
+ this.pagesIndex.push(newRecord);
1529
+ this.flushReport();
1530
+
1531
+ this.pathSignatures[JSON.stringify(newClickPath)] = newSig;
1532
+ const newStepSigs = [...stepSignatures, newSig];
1533
+
1534
+ for (const ne of newClickable) {
1535
+ if (this.shouldSkipElement(ne)) {
1536
+ this.stats.skipped_elements += 1;
1537
+ continue;
1538
+ }
1539
+ const newEid = PageCrawler.elemIdOf(ne);
1540
+ const si = ne.scroll_index ?? 0;
1541
+ let neScrollInfo: ScrollInfo | null = null;
1542
+ if (si > 0 && newScrollBounds) neScrollInfo = { scroll_index: si, container_bounds: [...newScrollBounds] };
1543
+ this.queue.push([newClickPath, newEid, newStepSigs, neScrollInfo]);
1544
+ }
1545
+ }
1546
+
1547
+ this.adb.flushPendingPulls();
1548
+ this.flushReport();
1549
+ try {
1550
+ fs.rmSync(path.join(this.outputRoot, 'queue_checkpoint.json'), { force: true });
1551
+ } catch {
1552
+ /* ignore */
1553
+ }
1554
+
1555
+ console.log('\n' + '='.repeat(60));
1556
+ console.log(`โœ… ้ๅކๅฎŒๆˆ๏ผๅ…ฑ้‡‡้›† ${this.pagesIndex.length} ไธช้กต้ข`);
1557
+ console.log(`๐Ÿ“‚ ่พ“ๅ‡บ็›ฎๅฝ•: ${path.resolve(this.outputRoot)}`);
1558
+ console.log(`๐Ÿ“‹ ็ดขๅผ•ๆ–‡ไปถ: ${path.join(this.outputRoot, 'index.json')}`);
1559
+ console.log(`๐ŸŒ HTMLๆŠฅๅ‘Š: ${path.join(this.outputRoot, 'report.html')}`);
1560
+ console.log('\n๐Ÿ“Š ๅŠ ้€Ÿ็ปŸ่ฎก:');
1561
+ console.log(` ่ฟ‡ๆปคไฝŽไปทๅ€ผๅ…ƒ็ด : ${this.stats.skipped_elements} ไธช`);
1562
+ console.log(` ่ฝป้‡ๆŽขๆต‹่ทณ่ฟ‡๏ผˆ็œๆˆชๅ›พ๏ผ‰: ${this.stats.probe_skipped} ๆฌก`);
1563
+ console.log('='.repeat(60));
1564
+ }
1565
+
1566
+ async manualCrawl(): Promise<void> {
1567
+ if (!this.adb.checkDevice()) return;
1568
+ fs.mkdirSync(this.outputRoot, { recursive: true });
1569
+
1570
+ let pagesIndex: PageRecord[] = [];
1571
+ 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
+ }
1579
+ }
1580
+
1581
+ let manualCounter = 0;
1582
+ for (const p of pagesIndex) {
1583
+ const mm = (p.page_id ?? '').match(/manual_(\d+)/);
1584
+ if (mm) manualCounter = Math.max(manualCounter, parseInt(mm[1], 10));
1585
+ }
1586
+
1587
+ console.log(`\n๐Ÿ“‚ ่พ“ๅ‡บ็›ฎๅฝ•: ${path.resolve(this.outputRoot)}`);
1588
+ console.log(`๐Ÿ“ ๆ‰‹ๅŠจ้‡‡้›†ๆจกๅผๅทฒๅฏๅŠจ๏ผˆmanual ็ผ–ๅทไปŽ ${manualCounter + 1} ๅผ€ๅง‹๏ผ‰`);
1589
+ console.log('='.repeat(60));
1590
+ console.log('ๆ“ไฝœ่ฏดๆ˜Ž๏ผš');
1591
+ console.log(' 1. ๅœจ่ฎพๅค‡ไธŠๆ‰‹ๅŠจๅฏผ่ˆชๅˆฐ็›ฎๆ ‡้กต้ข');
1592
+ console.log(' 2. ่พ“ๅ…ฅ้กต้ขๆ่ฟฐ๏ผˆๅฆ‚"่ฎพ็ฝฎ้กต > ๅ…ณไบŽ"๏ผ‰๏ผŒ็„ถๅŽๆŒ‰ๅ›ž่ฝฆๅผ€ๅง‹้‡‡้›†');
1593
+ console.log(' 3. ่พ“ๅ…ฅ q ้€€ๅ‡บ');
1594
+ console.log('='.repeat(60));
1595
+
1596
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1597
+ const question = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
1598
+
1599
+ try {
1600
+ for (;;) {
1601
+ console.log();
1602
+ let desc = (await question('๐Ÿ“ ่พ“ๅ…ฅ้กต้ขๆ่ฟฐ๏ผˆq ้€€ๅ‡บ๏ผ‰: ')).trim();
1603
+ if (desc.toLowerCase() === 'q') {
1604
+ console.log('๐Ÿ‘‹ ้€€ๅ‡บๆ‰‹ๅŠจ้‡‡้›†ๆจกๅผ');
1605
+ break;
1606
+ }
1607
+ if (!desc) desc = 'ๆ‰‹ๅŠจ้‡‡้›†';
1608
+ manualCounter += 1;
1609
+ const { record } = this.capturePage(desc);
1610
+ record.click_path = 'manual';
1611
+ record.description = desc;
1612
+ this.commitManualPage(record, manualCounter);
1613
+ pagesIndex.push(record);
1614
+ fs.writeFileSync(indexFile, JSON.stringify(pagesIndex, null, 2), 'utf-8');
1615
+ this.pagesIndex = pagesIndex;
1616
+ this.generateHtmlReport(pagesIndex);
1617
+ console.log(` ๐Ÿ“‹ index.json ๅ’Œ report.html ๅทฒๆ›ดๆ–ฐ๏ผˆๅ…ฑ ${pagesIndex.length} ไธช้กต้ข๏ผ‰`);
1618
+ }
1619
+ } finally {
1620
+ rl.close();
1621
+ }
1622
+ console.log(`\nโœ… ๆ‰‹ๅŠจ้‡‡้›†ๅฎŒๆˆ๏ผๅ…ฑ ${pagesIndex.length} ไธช้กต้ข๏ผˆๅซ่‡ชๅŠจ้ๅކ๏ผ‰`);
1623
+ console.log(`๐Ÿ“‚ ่พ“ๅ‡บ็›ฎๅฝ•: ${path.resolve(this.outputRoot)}`);
1624
+ }
1625
+ }
1626
+
1627
+ // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1628
+ // CLI
1629
+ // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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 {
1641
+ console.log(
1642
+ [
1643
+ 'Android ๆจกๆ‹Ÿๅ™จ้กต้ข้ๅކๅทฅๅ…ท๏ผˆๅŠ ้€Ÿ็‰ˆ๏ผ‰',
1644
+ '',
1645
+ 'Usage: node android_parse_fast.ts --package <pkg> --output <dir> [options]',
1646
+ '',
1647
+ ' --package, -p ็›ฎๆ ‡ App ๅŒ…ๅ๏ผˆๅฟ…้€‰๏ผ‰',
1648
+ ' --output, -o ่พ“ๅ‡บ็›ฎๅฝ•๏ผˆๅฟ…้€‰๏ผ‰',
1649
+ ' --manual ๆ‰‹ๅŠจ้‡‡้›†ๆจกๅผ๏ผšๆ‰‹ๅŠจๅฏผ่ˆชๅˆฐ้กต้ขๅŽๆŒ‰ๅ›ž่ฝฆ่งฆๅ‘้‡‡้›†',
1650
+ ` --device ADB ่ฎพๅค‡ serial๏ผˆ้ป˜่ฎค: ${DEVICE_SERIAL_DEFAULT}๏ผ‰`,
1651
+ ` --adb-path adb ๅฏๆ‰ง่กŒๆ–‡ไปถ่ทฏๅพ„๏ผˆ้ป˜่ฎค: ${ADB_PATH_DEFAULT}๏ผ‰`,
1652
+ ` --max-pages ๆœ€ๅคš้ๅކ้กต้ขๆ•ฐ๏ผˆ้ป˜่ฎค: ${MAX_PAGES_DEFAULT}๏ผ‰`,
1653
+ ` --max-scrolls ๆฏไธชๅฏๆปšๅŠจๅฎนๅ™จๆœ€ๅคšๆปšๅŠจๆฌกๆ•ฐ๏ผˆ้ป˜่ฎค: ${MAX_SCROLLS_DEFAULT}๏ผ‰`,
1654
+ ].join('\n'),
1655
+ );
1656
+ }
1657
+
1658
+ function parseArgs(argv: string[]): CliArgs {
1659
+ const args: CliArgs = {
1660
+ package: '',
1661
+ output: '',
1662
+ manual: false,
1663
+ device: DEVICE_SERIAL_DEFAULT,
1664
+ adbPath: ADB_PATH_DEFAULT,
1665
+ maxPages: MAX_PAGES_DEFAULT,
1666
+ maxScrolls: MAX_SCROLLS_DEFAULT,
1667
+ };
1668
+ for (let i = 0; i < argv.length; i++) {
1669
+ const a = argv[i];
1670
+ const next = (): string => {
1671
+ if (i + 1 >= argv.length) {
1672
+ console.error(`Missing value for ${a}`);
1673
+ process.exit(2);
1674
+ }
1675
+ return argv[++i];
1676
+ };
1677
+ if (a === '--package' || a === '-p') args.package = next();
1678
+ else if (a === '--output' || a === '-o') args.output = next();
1679
+ else if (a === '--manual') args.manual = true;
1680
+ else if (a === '--device') args.device = next();
1681
+ else if (a === '--adb-path') args.adbPath = next();
1682
+ else if (a === '--max-pages') args.maxPages = parseInt(next(), 10);
1683
+ else if (a === '--max-scrolls') args.maxScrolls = parseInt(next(), 10);
1684
+ else if (a === '-h' || a === '--help') {
1685
+ printHelp();
1686
+ process.exit(0);
1687
+ } else {
1688
+ console.error(`Unknown argument: ${a}`);
1689
+ process.exit(2);
1690
+ }
1691
+ }
1692
+ if (!args.package) {
1693
+ console.error('error: --package/-p is required');
1694
+ process.exit(2);
1695
+ }
1696
+ if (!args.output) {
1697
+ console.error('error: --output/-o is required');
1698
+ process.exit(2);
1699
+ }
1700
+ return args;
1701
+ }
1702
+
1703
+ async function main(): Promise<number> {
1704
+ const args = parseArgs(process.argv.slice(2));
1705
+ const adb = new ADBHelper({ adbPath: args.adbPath, deviceSerial: args.device, targetPackage: args.package });
1706
+ const crawler = new PageCrawler({
1707
+ adb,
1708
+ package: args.package,
1709
+ outputDir: args.output,
1710
+ maxPages: args.maxPages,
1711
+ maxScrolls: args.maxScrolls,
1712
+ });
1713
+ if (args.manual) await crawler.manualCrawl();
1714
+ else crawler.crawl();
1715
+ return 0;
1716
+ }
1717
+
1718
+ const isEntry = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href;
1719
+ if (isEntry) {
1720
+ main().then(
1721
+ (code) => process.exit(code),
1722
+ (err) => {
1723
+ console.error(err instanceof Error ? err.message : err);
1724
+ process.exit(1);
1725
+ },
1726
+ );
1727
+ }
1728
+
1729
+ export {
1730
+ parseXml,
1731
+ iterNodes,
1732
+ parseBoundsCenter,
1733
+ getClickableElements,
1734
+ extractNodeKeys,
1735
+ findScrollableContainers,
1736
+ normalizeComponent,
1737
+ stableActivityKey,
1738
+ pageSignature,
1739
+ rootIdentifiedSet,
1740
+ isSameRootPage,
1741
+ findElementOnScreen,
1742
+ focusHasActivityComponent,
1743
+ extractLaunchComponent,
1744
+ PageCrawler,
1745
+ ADBHelper,
1746
+ };
1747
+
1748
+
1749
+
1750
+