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