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