@buaa_smat/hometrans 0.1.0 → 0.1.2
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 +141 -124
- package/agents/build-fixer.md +1 -0
- package/agents/code-review-fix.md +1 -0
- package/agents/code-reviewer.md +1 -0
- package/agents/logic-coding.md +1 -0
- package/agents/logic-context-builder.md +1 -0
- package/agents/review-fixer.md +1 -0
- package/agents/self-test-fixer.md +1 -0
- package/agents/self-tester.md +260 -233
- package/agents/spec-generator.md +1 -0
- package/agents/test-tools/autotest/README.md +223 -0
- package/agents/test-tools/autotest/config.yaml.example +58 -0
- package/agents/test-tools/autotest/pyproject.toml +16 -0
- package/agents/test-tools/autotest/report_tool.py +759 -0
- package/agents/test-tools/autotest/self_test_runner.py +773 -0
- package/agents/test-tools/autotest/testcases_schema.md +143 -0
- package/agents/test-tools/autotest/testcases_tool.py +215 -0
- package/agents/test-tools/autotest/uv.lock +3156 -0
- package/agents/test-tools/harmony_autotest-0.1.0-py3-none-any.whl +0 -0
- package/agents/test-tools/hypium-6.1.0.210-py3-none-any.whl +0 -0
- package/agents/test-tools/hypium_mcp-0.6.5-py3-none-any.whl +0 -0
- package/agents/test-tools/xdevice-6.1.0.210-py3-none-any.whl +0 -0
- package/agents/test-tools/xdevice_devicetest-6.1.0.210-py3-none-any.whl +0 -0
- package/agents/test-tools/xdevice_ohos-6.1.0.210-py3-none-any.whl +0 -0
- package/dist/cli/config-store.js +27 -2
- package/dist/cli/config.js +17 -6
- package/dist/cli/index.js +3 -2
- package/dist/cli/init.js +135 -22
- package/dist/cli/mcp.js +2 -2
- package/dist/context/index.js +165 -69
- package/package.json +59 -60
- package/skills/code-dev-review-fix/SKILL.md +279 -0
- package/skills/code-dev-review-fix-workspace/evals/evals.json +56 -0
- package/skills/code-dev-review-fix-workspace/iteration-1/routing-results.md +23 -0
- package/skills/convert_pipeline/SKILL.md +423 -439
- package/skills/hmos-resources-convert/SKILL.md +623 -0
- package/skills/hmos-resources-convert/evals/evals.json +171 -0
- package/skills/hmos-resources-convert/references/conversion-rules.md +663 -0
- package/skills/hmos-resources-convert/references/dependency-analysis-rules.md +388 -0
- package/skills/hmos-resources-convert/references/resource-mapping-rules.md +457 -0
- package/skills/hmos-resources-convert/references/xml-drawable-to-svg-rules.md +513 -0
- package/skills/hmos-resources-convert/template/AppScope/app.json5 +10 -0
- package/skills/hmos-resources-convert/template/AppScope/resources/base/element/string.json +8 -0
- 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 +7 -0
- package/skills/hmos-resources-convert/template/build-profile.json5 +42 -0
- package/skills/hmos-resources-convert/template/code-linter.json5 +32 -0
- package/skills/hmos-resources-convert/template/entry/build-profile.json5 +33 -0
- package/skills/hmos-resources-convert/template/entry/hvigorfile.ts +6 -0
- package/skills/hmos-resources-convert/template/entry/obfuscation-rules.txt +23 -0
- package/skills/hmos-resources-convert/template/entry/oh-package.json5 +10 -0
- package/skills/hmos-resources-convert/template/entry/src/main/ets/entryability/EntryAbility.ets +48 -0
- package/skills/hmos-resources-convert/template/entry/src/main/ets/entrybackupability/EntryBackupAbility.ets +16 -0
- package/skills/hmos-resources-convert/template/entry/src/main/ets/pages/Index.ets +23 -0
- package/skills/hmos-resources-convert/template/entry/src/main/module.json5 +55 -0
- package/skills/hmos-resources-convert/template/entry/src/main/resources/base/element/color.json +8 -0
- package/skills/hmos-resources-convert/template/entry/src/main/resources/base/element/float.json +8 -0
- package/skills/hmos-resources-convert/template/entry/src/main/resources/base/element/string.json +16 -0
- 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 +7 -0
- 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 +3 -0
- package/skills/hmos-resources-convert/template/entry/src/main/resources/base/profile/main_pages.json +5 -0
- package/skills/hmos-resources-convert/template/entry/src/main/resources/dark/element/color.json +8 -0
- package/skills/hmos-resources-convert/template/entry/src/mock/mock-config.json5 +2 -0
- package/skills/hmos-resources-convert/template/entry/src/ohosTest/ets/test/Ability.test.ets +35 -0
- package/skills/hmos-resources-convert/template/entry/src/ohosTest/ets/test/List.test.ets +5 -0
- package/skills/hmos-resources-convert/template/entry/src/ohosTest/module.json5 +16 -0
- package/skills/hmos-resources-convert/template/entry/src/test/List.test.ets +5 -0
- package/skills/hmos-resources-convert/template/entry/src/test/LocalUnit.test.ets +33 -0
- package/skills/hmos-resources-convert/template/hvigor/hvigor-config.json5 +23 -0
- package/skills/hmos-resources-convert/template/hvigorfile.ts +6 -0
- package/skills/hmos-resources-convert/template/oh-package-lock.json5 +28 -0
- package/skills/hmos-resources-convert/template/oh-package.json5 +10 -0
- package/skills/hmos-resources-convert/tools/apktool.bat +85 -0
- package/skills/hmos-resources-convert/tools/apktool_3.0.1.jar +0 -0
- package/skills/hmos-ui-align/SKILL.md +182 -0
- package/skills/hmos-ui-align/config-example.json +11 -0
- package/skills/hmos-ui-align/config.json +11 -0
- package/skills/hmos-ui-align/diff_analysis.md +53 -0
- package/skills/hmos-ui-align/page_align.md +62 -0
- package/skills/hmos-ui-align/readme.md +231 -0
- package/skills/hmos-ui-align/references/Comparison_Template.md +2 -0
- package/skills/hmos-ui-align/references/MVVM/345/274/200/345/217/221/346/226/207/346/241/243/@Link/350/243/205/351/245/260/345/231/250/357/274/232/347/210/266/345/255/220/345/217/214/345/220/221/345/220/214/346/255/245.md +648 -0
- package/skills/hmos-ui-align/references/MVVM/345/274/200/345/217/221/346/226/207/346/241/243/@Observed/350/243/205/351/245/260/345/231/250/345/222/214@ObjectLink/350/243/205/351/245/260/345/231/250/357/274/232/345/265/214/345/245/227/347/261/273/345/257/271/350/261/241/345/261/236/346/200/247/345/217/230/345/214/226.md +2089 -0
- package/skills/hmos-ui-align/references/MVVM/345/274/200/345/217/221/346/226/207/346/241/243/@Prop/350/243/205/351/245/260/345/231/250/357/274/232/347/210/266/345/255/220/345/215/225/345/220/221/345/220/214/346/255/245.md +1033 -0
- package/skills/hmos-ui-align/references/MVVM/345/274/200/345/217/221/346/226/207/346/241/243/@Provide/350/243/205/351/245/260/345/231/250/345/222/214@Consume/350/243/205/351/245/260/345/231/250/357/274/232/344/270/216/345/220/216/344/273/243/347/273/204/344/273/266/345/217/214/345/220/221/345/220/214/346/255/245.md +1183 -0
- package/skills/hmos-ui-align/references/MVVM/345/274/200/345/217/221/346/226/207/346/241/243/@State/350/243/205/351/245/260/345/231/250/357/274/232/347/273/204/344/273/266/345/206/205/347/212/266/346/200/201.md +576 -0
- package/skills/hmos-ui-align/references/MVVM/345/274/200/345/217/221/346/226/207/346/241/243/@Track/350/243/205/351/245/260/345/231/250/357/274/232class/345/257/271/350/261/241/345/261/236/346/200/247/347/272/247/346/233/264/346/226/260.md +297 -0
- package/skills/hmos-ui-align/references/MVVM/345/274/200/345/217/221/346/226/207/346/241/243/@Watch/350/243/205/351/245/260/345/231/250/357/274/232/347/212/266/346/200/201/345/217/230/351/207/217/346/233/264/346/224/271/351/200/232/347/237/245.md +395 -0
- package/skills/hmos-ui-align/references/MVVM/345/274/200/345/217/221/346/226/207/346/241/243/AppStorage/357/274/232/345/272/224/347/224/250/345/205/250/345/261/200/347/232/204UI/347/212/266/346/200/201/345/255/230/345/202/250.md +903 -0
- package/skills/hmos-ui-align/references/MVVM/345/274/200/345/217/221/346/226/207/346/241/243/Environment/357/274/232/350/256/276/345/244/207/347/216/257/345/242/203/346/237/245/350/257/242.md +106 -0
- package/skills/hmos-ui-align/references/MVVM/345/274/200/345/217/221/346/226/207/346/241/243/LocalStorage/357/274/232/351/241/265/351/235/242/347/272/247UI/347/212/266/346/200/201/345/255/230/345/202/250.md +1178 -0
- package/skills/hmos-ui-align/references/MVVM/345/274/200/345/217/221/346/226/207/346/241/243/MVVM/346/250/241/345/274/217V1.md +911 -0
- package/skills/hmos-ui-align/references/MVVM/345/274/200/345/217/221/346/226/207/346/241/243/MVVM/346/250/241/345/274/217/357/274/210V1/357/274/211.md +911 -0
- package/skills/hmos-ui-align/references/MVVM/345/274/200/345/217/221/346/226/207/346/241/243/PersistentStorage/357/274/232/346/214/201/344/271/205/345/214/226/345/255/230/345/202/250UI/347/212/266/346/200/201.md +355 -0
- package/skills/hmos-ui-align/references/MVVM/345/274/200/345/217/221/346/226/207/346/241/243//347/256/241/347/220/206/345/272/224/347/224/250/346/213/245/346/234/211/347/232/204/347/212/266/346/200/201/346/246/202/350/277/260.md +11 -0
- package/skills/hmos-ui-align/references/UI_Analysis_Template.md +4 -0
- package/skills/hmos-ui-align/references/android-to-harmonyOS-ui-atomic-component-mapping-reference.md +2535 -0
- package/skills/hmos-ui-align/references/android-to-harmonyOS-ui-interaction-mapping-reference.md +555 -0
- package/skills/hmos-ui-align/references/android-to-harmonyOS-ui-layout-mapping-reference.md +117 -0
- package/skills/hmos-ui-align/scripts/app_feature_verify.py +443 -0
- package/skills/hmos-ui-align/scripts/navigation-capure.md +37 -0
- package/skills/hmos-ui-align/scripts/page_capture.py +592 -0
- package/skills/hmos-ui-align-batch/SKILL.md +99 -0
- package/skills/hmos-ui-align-batch/references/conversion-procedure.md +180 -0
- package/skills/hmos-ui-align-batch/references/mappings/android-to-harmonyOS-ui-atomic-component-mapping-reference.md +2535 -0
- package/skills/hmos-ui-align-batch/references/mappings/android-to-harmonyOS-ui-interaction-mapping-reference.md +555 -0
- package/skills/hmos-ui-align-batch/references/mappings/android-to-harmonyOS-ui-layout-mapping-reference.md +117 -0
- package/skills/hmos-ui-align-batch/references/mvvm/@Link/350/243/205/351/245/260/345/231/250/357/274/232/347/210/266/345/255/220/345/217/214/345/220/221/345/220/214/346/255/245.md +648 -0
- package/skills/hmos-ui-align-batch/references/mvvm/@Observed/350/243/205/351/245/260/345/231/250/345/222/214@ObjectLink/350/243/205/351/245/260/345/231/250/357/274/232/345/265/214/345/245/227/347/261/273/345/257/271/350/261/241/345/261/236/346/200/247/345/217/230/345/214/226.md +2089 -0
- package/skills/hmos-ui-align-batch/references/mvvm/@Prop/350/243/205/351/245/260/345/231/250/357/274/232/347/210/266/345/255/220/345/215/225/345/220/221/345/220/214/346/255/245.md +1033 -0
- package/skills/hmos-ui-align-batch/references/mvvm/@Provide/350/243/205/351/245/260/345/231/250/345/222/214@Consume/350/243/205/351/245/260/345/231/250/357/274/232/344/270/216/345/220/216/344/273/243/347/273/204/344/273/266/345/217/214/345/220/221/345/220/214/346/255/245.md +1183 -0
- package/skills/hmos-ui-align-batch/references/mvvm/@State/350/243/205/351/245/260/345/231/250/357/274/232/347/273/204/344/273/266/345/206/205/347/212/266/346/200/201.md +576 -0
- package/skills/hmos-ui-align-batch/references/mvvm/@Track/350/243/205/351/245/260/345/231/250/357/274/232class/345/257/271/350/261/241/345/261/236/346/200/247/347/272/247/346/233/264/346/226/260.md +297 -0
- package/skills/hmos-ui-align-batch/references/mvvm/@Watch/350/243/205/351/245/260/345/231/250/357/274/232/347/212/266/346/200/201/345/217/230/351/207/217/346/233/264/346/224/271/351/200/232/347/237/245.md +395 -0
- package/skills/hmos-ui-align-batch/references/mvvm/AppStorage/357/274/232/345/272/224/347/224/250/345/205/250/345/261/200/347/232/204UI/347/212/266/346/200/201/345/255/230/345/202/250.md +903 -0
- package/skills/hmos-ui-align-batch/references/mvvm/Environment/357/274/232/350/256/276/345/244/207/347/216/257/345/242/203/346/237/245/350/257/242.md +106 -0
- package/skills/hmos-ui-align-batch/references/mvvm/LocalStorage/357/274/232/351/241/265/351/235/242/347/272/247UI/347/212/266/346/200/201/345/255/230/345/202/250.md +1178 -0
- package/skills/hmos-ui-align-batch/references/mvvm/MVVM/346/250/241/345/274/217/357/274/210V1/357/274/211.md +911 -0
- package/skills/hmos-ui-align-batch/references/mvvm/PersistentStorage/357/274/232/346/214/201/344/271/205/345/214/226/345/255/230/345/202/250UI/347/212/266/346/200/201.md +355 -0
- package/skills/hmos-ui-align-batch/references/mvvm//347/256/241/347/220/206/345/272/224/347/224/250/346/213/245/346/234/211/347/232/204/347/212/266/346/200/201/346/246/202/350/277/260.md +11 -0
- package/skills/hmos-ui-align-batch/scripts/android_parse_fast.py +1606 -0
- package/skills/self-test/SKILL.md +369 -0
- package/skills/self-test/readme.md +309 -0
- package/skills/spec-generator-skill/SKILL.md +332 -0
- package/skills/spec-generator-skill/references/android-platform-tokens.md +105 -0
- package/skills/spec-generator-skill/references/spec-sample-1.md +78 -0
- package/skills/spec-generator-skill/references/spec-sample-2.md +58 -0
- package/skills/spec-generator-skill/references/spec-sample-3.md +116 -0
- package/skills/spec-generator-skill/references/step4-report-template.md +33 -0
- package/agents/self-test-setup.md +0 -165
- package/dist/context/resources/sdkConfig.json +0 -24
- package/src/context/resources/sdkConfig.json +0 -24
|
@@ -0,0 +1,1606 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Android 模拟器页面遍历工具(加速版)
|
|
4
|
+
功能:遍历每个页面,保存截图、View XML、Activity/Window 信息
|
|
5
|
+
|
|
6
|
+
优化点(与 android_parse.py 相同的 BFS + 重启导航策略):
|
|
7
|
+
1. 轻量探测:点击后先快速 dump XML 判断是否新页面,重复页面跳过截图
|
|
8
|
+
2. 并行化 ADB 调用(dumpsys、截图)
|
|
9
|
+
3. 指数退避的 wait_for_idle(更短等待)
|
|
10
|
+
4. 过滤低价值元素(减少无效点击)
|
|
11
|
+
5. 合并 ADB 命令(减少往返)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import subprocess
|
|
15
|
+
import time
|
|
16
|
+
import json
|
|
17
|
+
import re
|
|
18
|
+
import html as html_module
|
|
19
|
+
import shutil
|
|
20
|
+
from collections import deque
|
|
21
|
+
from datetime import datetime
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Optional
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ─────────────────────────────────────────────
|
|
27
|
+
# 默认配置值
|
|
28
|
+
# ─────────────────────────────────────────────
|
|
29
|
+
ADB_PATH_DEFAULT = "adb"
|
|
30
|
+
DEVICE_SERIAL_DEFAULT = "emulator-5554"
|
|
31
|
+
MUMU_EMULATOR_DEFAULT = False
|
|
32
|
+
MUMU_ADB_ADDR_DEFAULT = "127.0.0.1:7555"
|
|
33
|
+
IDLE_POLL_INTERVAL_DEFAULT = 0.2
|
|
34
|
+
IDLE_TIMEOUT_DEFAULT = 5
|
|
35
|
+
MAX_PAGES_DEFAULT = 500
|
|
36
|
+
MAX_SCROLLS_DEFAULT = 10
|
|
37
|
+
SCROLL_DURATION_MS_DEFAULT = 300
|
|
38
|
+
BACK_MAX_DEFAULT = 10
|
|
39
|
+
SKIP_RESOURCE_ID_PATTERNS_DEFAULT = [
|
|
40
|
+
"statusBarBackground",
|
|
41
|
+
"navigationBarBackground",
|
|
42
|
+
"action_bar_container",
|
|
43
|
+
"status_bar",
|
|
44
|
+
"navigation_bar",
|
|
45
|
+
]
|
|
46
|
+
ROOT_RECOVERY_CLICKS_DEFAULT = []
|
|
47
|
+
HORIZONTAL_SCROLL_CLASSES = {"android.widget.HorizontalScrollView"}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ─────────────────────────────────────────────
|
|
51
|
+
# ADBHelper:封装所有 ADB 设备通信与交互操作
|
|
52
|
+
# ─────────────────────────────────────────────
|
|
53
|
+
class ADBHelper:
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
adb_path=ADB_PATH_DEFAULT,
|
|
58
|
+
device_serial=DEVICE_SERIAL_DEFAULT,
|
|
59
|
+
mumu_emulator=MUMU_EMULATOR_DEFAULT,
|
|
60
|
+
mumu_adb_addr=MUMU_ADB_ADDR_DEFAULT,
|
|
61
|
+
idle_poll_interval=IDLE_POLL_INTERVAL_DEFAULT,
|
|
62
|
+
idle_timeout=IDLE_TIMEOUT_DEFAULT,
|
|
63
|
+
scroll_duration_ms=SCROLL_DURATION_MS_DEFAULT,
|
|
64
|
+
):
|
|
65
|
+
self.adb_path = adb_path
|
|
66
|
+
self.device_serial = device_serial
|
|
67
|
+
self.mumu_emulator = mumu_emulator
|
|
68
|
+
self.mumu_adb_addr = mumu_adb_addr
|
|
69
|
+
self.idle_poll_interval = idle_poll_interval
|
|
70
|
+
self.idle_timeout = idle_timeout
|
|
71
|
+
self.scroll_duration_ms = scroll_duration_ms
|
|
72
|
+
self._pending_pulls: list[subprocess.Popen] = []
|
|
73
|
+
|
|
74
|
+
def _build_cmd(self, *args) -> list[str]:
|
|
75
|
+
cmd = [self.adb_path]
|
|
76
|
+
if self.device_serial:
|
|
77
|
+
cmd += ["-s", self.device_serial]
|
|
78
|
+
cmd += list(args)
|
|
79
|
+
return cmd
|
|
80
|
+
|
|
81
|
+
def run(self, *args, timeout=15) -> str:
|
|
82
|
+
cmd = self._build_cmd(*args)
|
|
83
|
+
try:
|
|
84
|
+
result = subprocess.run(
|
|
85
|
+
cmd,
|
|
86
|
+
capture_output=True,
|
|
87
|
+
timeout=timeout,
|
|
88
|
+
encoding="utf-8",
|
|
89
|
+
errors="replace",
|
|
90
|
+
)
|
|
91
|
+
return (result.stdout or "").strip()
|
|
92
|
+
except subprocess.TimeoutExpired:
|
|
93
|
+
print(f" [WARN] adb 命令超时: {' '.join(args)}")
|
|
94
|
+
return ""
|
|
95
|
+
except FileNotFoundError:
|
|
96
|
+
raise RuntimeError(
|
|
97
|
+
f"找不到 adb,请确认已安装并配置 PATH,或修改 ADB_PATH"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def shell(self, *args, timeout=15) -> str:
|
|
101
|
+
return self.run("shell", *args, timeout=timeout)
|
|
102
|
+
|
|
103
|
+
def popen(self, *args, **kwargs) -> subprocess.Popen:
|
|
104
|
+
cmd = self._build_cmd(*args)
|
|
105
|
+
return subprocess.Popen(
|
|
106
|
+
cmd,
|
|
107
|
+
stdout=subprocess.PIPE,
|
|
108
|
+
stderr=subprocess.PIPE,
|
|
109
|
+
encoding="utf-8",
|
|
110
|
+
errors="replace",
|
|
111
|
+
**kwargs,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def wait_for_idle(self):
|
|
115
|
+
deadline = time.time() + self.idle_timeout
|
|
116
|
+
prev_focus = None
|
|
117
|
+
interval = self.idle_poll_interval
|
|
118
|
+
|
|
119
|
+
while time.time() < deadline:
|
|
120
|
+
time.sleep(interval)
|
|
121
|
+
interval = min(interval * 1.5, 0.5)
|
|
122
|
+
|
|
123
|
+
transition_out = self.shell("dumpsys", "window", "animator")
|
|
124
|
+
if (
|
|
125
|
+
"Transition ready" in transition_out
|
|
126
|
+
or "Running animations" in transition_out
|
|
127
|
+
):
|
|
128
|
+
continue
|
|
129
|
+
|
|
130
|
+
focus_out = self.shell("dumpsys", "window")
|
|
131
|
+
current_focus = ""
|
|
132
|
+
for line in focus_out.splitlines():
|
|
133
|
+
if "mCurrentFocus" in line:
|
|
134
|
+
current_focus = line.strip()
|
|
135
|
+
break
|
|
136
|
+
|
|
137
|
+
if current_focus and current_focus == prev_focus:
|
|
138
|
+
return
|
|
139
|
+
prev_focus = current_focus
|
|
140
|
+
|
|
141
|
+
print(f" [WARN] 等待 UI 空闲超时 ({self.idle_timeout}s),继续执行")
|
|
142
|
+
|
|
143
|
+
def check_device(self) -> bool:
|
|
144
|
+
if self.mumu_emulator:
|
|
145
|
+
print(f" 正在连接 MuMu 模拟器 ({self.mumu_adb_addr})...")
|
|
146
|
+
connect_out = self.run("connect", self.mumu_adb_addr)
|
|
147
|
+
print(f" adb connect: {connect_out}")
|
|
148
|
+
time.sleep(1)
|
|
149
|
+
if self.device_serial == "emulator-5554":
|
|
150
|
+
self.device_serial = self.mumu_adb_addr
|
|
151
|
+
|
|
152
|
+
out = self.run("devices")
|
|
153
|
+
lines = [l for l in out.splitlines() if l.strip() and "List of devices" not in l]
|
|
154
|
+
online = [l for l in lines if "device" in l and "offline" not in l]
|
|
155
|
+
if not online:
|
|
156
|
+
print(" 未检测到在线设备,请先启动模拟器并确认 adb devices 有输出")
|
|
157
|
+
return False
|
|
158
|
+
|
|
159
|
+
online_devices = [line.split()[0] for line in online]
|
|
160
|
+
print(f" 在线设备: {online_devices}")
|
|
161
|
+
|
|
162
|
+
if self.device_serial is None:
|
|
163
|
+
self.device_serial = online_devices[0]
|
|
164
|
+
print(f" 未指定设备,自动选择: {self.device_serial}")
|
|
165
|
+
elif self.device_serial not in online_devices:
|
|
166
|
+
print(f" 指定的设备 {self.device_serial} 不存在,自动选择: {online_devices[0]}")
|
|
167
|
+
self.device_serial = online_devices[0]
|
|
168
|
+
else:
|
|
169
|
+
print(f" 使用指定的设备: {self.device_serial}")
|
|
170
|
+
|
|
171
|
+
return True
|
|
172
|
+
|
|
173
|
+
def get_current_activity(self) -> dict:
|
|
174
|
+
info = {}
|
|
175
|
+
p_activity = self.popen("shell", "dumpsys", "activity", "activities")
|
|
176
|
+
p_window = self.popen("shell", "dumpsys", "window")
|
|
177
|
+
|
|
178
|
+
act_out, _ = p_activity.communicate(timeout=15)
|
|
179
|
+
win_out, _ = p_window.communicate(timeout=15)
|
|
180
|
+
|
|
181
|
+
act_out = (act_out or "").strip()
|
|
182
|
+
win_out = (win_out or "").strip()
|
|
183
|
+
|
|
184
|
+
for line in act_out.splitlines():
|
|
185
|
+
if re.search(r"ResumedActivity|topActivity", line, re.IGNORECASE):
|
|
186
|
+
info["resumed_activity"] = line.strip()
|
|
187
|
+
break
|
|
188
|
+
|
|
189
|
+
for line in win_out.splitlines():
|
|
190
|
+
if "mCurrentFocus" in line:
|
|
191
|
+
info["current_focus"] = line.strip()
|
|
192
|
+
break
|
|
193
|
+
if "mFocusedApp" in line:
|
|
194
|
+
info.setdefault("focused_app", line.strip())
|
|
195
|
+
|
|
196
|
+
return info
|
|
197
|
+
|
|
198
|
+
def tap(self, x: int, y: int):
|
|
199
|
+
self.shell("input", "tap", str(x), str(y))
|
|
200
|
+
self.wait_for_idle()
|
|
201
|
+
|
|
202
|
+
def long_press(self, x: int, y: int, duration_ms: int = 1000):
|
|
203
|
+
self.shell("input", "swipe", str(x), str(y), str(x), str(y), str(duration_ms))
|
|
204
|
+
self.wait_for_idle()
|
|
205
|
+
|
|
206
|
+
def tap_or_long_press(self, x: int, y: int, is_long_click: bool = False):
|
|
207
|
+
if is_long_click:
|
|
208
|
+
self.long_press(x, y)
|
|
209
|
+
else:
|
|
210
|
+
self.tap(x, y)
|
|
211
|
+
|
|
212
|
+
def press_back(self):
|
|
213
|
+
self.shell("input", "keyevent", "KEYCODE_BACK")
|
|
214
|
+
self.wait_for_idle()
|
|
215
|
+
|
|
216
|
+
def dump_xml_quick(self) -> str:
|
|
217
|
+
remote_path = "/sdcard/ui_dump.xml"
|
|
218
|
+
self.shell(
|
|
219
|
+
"rm -f /sdcard/ui_dump.xml && uiautomator dump /sdcard/ui_dump.xml",
|
|
220
|
+
timeout=20,
|
|
221
|
+
)
|
|
222
|
+
for _ in range(6):
|
|
223
|
+
check = self.shell("ls", remote_path)
|
|
224
|
+
if remote_path in check:
|
|
225
|
+
break
|
|
226
|
+
time.sleep(0.2)
|
|
227
|
+
return self.shell("cat", remote_path, timeout=15)
|
|
228
|
+
|
|
229
|
+
def get_view_xml_fast(self, page_dir: Path) -> str:
|
|
230
|
+
remote_path = "/sdcard/ui_dump.xml"
|
|
231
|
+
local_path = page_dir / "view.xml"
|
|
232
|
+
|
|
233
|
+
self.shell(
|
|
234
|
+
"rm -f /sdcard/ui_dump.xml && uiautomator dump /sdcard/ui_dump.xml",
|
|
235
|
+
timeout=20,
|
|
236
|
+
)
|
|
237
|
+
for _ in range(6):
|
|
238
|
+
check = self.shell("ls", remote_path)
|
|
239
|
+
if remote_path in check:
|
|
240
|
+
break
|
|
241
|
+
time.sleep(0.2)
|
|
242
|
+
|
|
243
|
+
xml_content = self.shell("cat", remote_path, timeout=15)
|
|
244
|
+
if xml_content:
|
|
245
|
+
local_path.write_text(xml_content, encoding="utf-8")
|
|
246
|
+
return xml_content
|
|
247
|
+
|
|
248
|
+
def screencap_bytes(self) -> bytes | None:
|
|
249
|
+
cmd = self._build_cmd("exec-out", "screencap", "-p")
|
|
250
|
+
try:
|
|
251
|
+
result = subprocess.run(cmd, capture_output=True, timeout=15)
|
|
252
|
+
if result.stdout:
|
|
253
|
+
return result.stdout
|
|
254
|
+
except subprocess.TimeoutExpired:
|
|
255
|
+
print(f" [WARN] 截图超时")
|
|
256
|
+
return None
|
|
257
|
+
|
|
258
|
+
def take_screenshot(self, page_dir: Path):
|
|
259
|
+
local_path = page_dir / "screenshot.png"
|
|
260
|
+
data = self.screencap_bytes()
|
|
261
|
+
if data:
|
|
262
|
+
local_path.write_bytes(data)
|
|
263
|
+
else:
|
|
264
|
+
remote_path = "/sdcard/screenshot.png"
|
|
265
|
+
self.shell("screencap", "-p", remote_path)
|
|
266
|
+
time.sleep(0.3)
|
|
267
|
+
self.run("pull", remote_path, str(local_path), timeout=15)
|
|
268
|
+
|
|
269
|
+
def scroll_to_position(self, container_bounds: tuple[int, int, int, int], scroll_index: int):
|
|
270
|
+
if scroll_index <= 0:
|
|
271
|
+
return
|
|
272
|
+
x1, y1, x2, y2 = container_bounds
|
|
273
|
+
cx = (x1 + x2) // 2
|
|
274
|
+
height = y2 - y1
|
|
275
|
+
swipe_from_y = y1 + int(height * 0.7)
|
|
276
|
+
swipe_to_y = y1 + int(height * 0.3)
|
|
277
|
+
|
|
278
|
+
for i in range(scroll_index):
|
|
279
|
+
self.shell(
|
|
280
|
+
"input", "swipe",
|
|
281
|
+
str(cx), str(swipe_from_y),
|
|
282
|
+
str(cx), str(swipe_to_y),
|
|
283
|
+
str(self.scroll_duration_ms),
|
|
284
|
+
)
|
|
285
|
+
self.wait_for_idle()
|
|
286
|
+
|
|
287
|
+
def grant_runtime_permissions(self, package: str):
|
|
288
|
+
out = self.shell("dumpsys", "package", package, timeout=15)
|
|
289
|
+
permissions = []
|
|
290
|
+
in_requested = False
|
|
291
|
+
for line in out.splitlines():
|
|
292
|
+
stripped = line.strip()
|
|
293
|
+
if "runtime permissions:" in stripped.lower():
|
|
294
|
+
in_requested = True
|
|
295
|
+
continue
|
|
296
|
+
if in_requested:
|
|
297
|
+
if not stripped or (
|
|
298
|
+
not stripped.startswith("android.permission")
|
|
299
|
+
and not stripped.startswith("com.")
|
|
300
|
+
):
|
|
301
|
+
in_requested = False
|
|
302
|
+
continue
|
|
303
|
+
perm = stripped.split(":")[0].strip()
|
|
304
|
+
if perm:
|
|
305
|
+
permissions.append(perm)
|
|
306
|
+
|
|
307
|
+
for perm in permissions:
|
|
308
|
+
self.shell("pm", "grant", package, perm)
|
|
309
|
+
|
|
310
|
+
self.shell("appops", "set", package, "MANAGE_EXTERNAL_STORAGE", "allow")
|
|
311
|
+
|
|
312
|
+
def flush_pending_pulls(self):
|
|
313
|
+
for p in self._pending_pulls:
|
|
314
|
+
try:
|
|
315
|
+
p.wait(timeout=5)
|
|
316
|
+
except subprocess.TimeoutExpired:
|
|
317
|
+
p.kill()
|
|
318
|
+
self._pending_pulls.clear()
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
# ─────────────────────────────────────────────
|
|
322
|
+
# PageCrawler:UI 遍历逻辑、页面采集、断点续跑、报告生成
|
|
323
|
+
# ─────────────────────────────────────────────
|
|
324
|
+
class PageCrawler:
|
|
325
|
+
|
|
326
|
+
def __init__(
|
|
327
|
+
self,
|
|
328
|
+
adb: ADBHelper,
|
|
329
|
+
package: str,
|
|
330
|
+
output_dir: str,
|
|
331
|
+
max_pages=MAX_PAGES_DEFAULT,
|
|
332
|
+
max_scrolls=MAX_SCROLLS_DEFAULT,
|
|
333
|
+
skip_patterns=None,
|
|
334
|
+
root_recovery_clicks=None,
|
|
335
|
+
):
|
|
336
|
+
self.adb = adb
|
|
337
|
+
self.package = package
|
|
338
|
+
self.output_root = Path(output_dir)
|
|
339
|
+
self.max_pages = max_pages
|
|
340
|
+
self.max_scrolls = max_scrolls
|
|
341
|
+
self.skip_patterns = skip_patterns if skip_patterns is not None else SKIP_RESOURCE_ID_PATTERNS_DEFAULT
|
|
342
|
+
self.root_recovery_clicks = root_recovery_clicks if root_recovery_clicks is not None else ROOT_RECOVERY_CLICKS_DEFAULT
|
|
343
|
+
|
|
344
|
+
# ── 纯静态辅助方法 ──
|
|
345
|
+
|
|
346
|
+
@staticmethod
|
|
347
|
+
def parse_bounds_center(bounds: str) -> tuple[int, int] | None:
|
|
348
|
+
m = re.findall(r"\d+", bounds)
|
|
349
|
+
if len(m) == 4:
|
|
350
|
+
x1, y1, x2, y2 = map(int, m)
|
|
351
|
+
return ((x1 + x2) // 2, (y1 + y2) // 2)
|
|
352
|
+
return None
|
|
353
|
+
|
|
354
|
+
@staticmethod
|
|
355
|
+
def get_clickable_elements(xml_content: str) -> list[dict]:
|
|
356
|
+
import xml.etree.ElementTree as ET
|
|
357
|
+
|
|
358
|
+
elements = []
|
|
359
|
+
if not xml_content:
|
|
360
|
+
return elements
|
|
361
|
+
try:
|
|
362
|
+
root = ET.fromstring(xml_content)
|
|
363
|
+
|
|
364
|
+
listview_child_set = set()
|
|
365
|
+
for node in root.iter("node"):
|
|
366
|
+
cls = node.get("class", "")
|
|
367
|
+
if "ListView" in cls or "GridView" in cls:
|
|
368
|
+
for child in node:
|
|
369
|
+
listview_child_set.add(child)
|
|
370
|
+
|
|
371
|
+
def extract_text(node):
|
|
372
|
+
text = node.get("text", "")
|
|
373
|
+
if text:
|
|
374
|
+
return text
|
|
375
|
+
for child in node.iter("node"):
|
|
376
|
+
t = child.get("text", "")
|
|
377
|
+
if t:
|
|
378
|
+
return t
|
|
379
|
+
return ""
|
|
380
|
+
|
|
381
|
+
for node in root.iter("node"):
|
|
382
|
+
if node.get("enabled") != "true":
|
|
383
|
+
continue
|
|
384
|
+
|
|
385
|
+
is_clickable = node.get("clickable") == "true"
|
|
386
|
+
is_long_clickable = node.get("long-clickable") == "true"
|
|
387
|
+
is_listview_item = node in listview_child_set
|
|
388
|
+
is_checkable = node.get("checkable") == "true"
|
|
389
|
+
|
|
390
|
+
if not (is_clickable or is_long_clickable or is_listview_item or is_checkable):
|
|
391
|
+
continue
|
|
392
|
+
|
|
393
|
+
bounds = node.get("bounds", "")
|
|
394
|
+
text = node.get("text", "")
|
|
395
|
+
if not text:
|
|
396
|
+
text = extract_text(node)
|
|
397
|
+
|
|
398
|
+
base_info = {
|
|
399
|
+
"class": node.get("class", ""),
|
|
400
|
+
"text": text,
|
|
401
|
+
"resource-id": node.get("resource-id", ""),
|
|
402
|
+
"content-desc": node.get("content-desc", ""),
|
|
403
|
+
"bounds": bounds,
|
|
404
|
+
"center": PageCrawler.parse_bounds_center(bounds),
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if is_clickable or is_listview_item or is_checkable:
|
|
408
|
+
elements.append({**base_info, "long-clickable": False})
|
|
409
|
+
if is_long_clickable:
|
|
410
|
+
elements.append({**base_info, "long-clickable": True})
|
|
411
|
+
except ET.ParseError as e:
|
|
412
|
+
print(f" [WARN] XML 解析失败: {e}")
|
|
413
|
+
return elements
|
|
414
|
+
|
|
415
|
+
@staticmethod
|
|
416
|
+
def _extract_node_keys(xml_content: str) -> set[tuple]:
|
|
417
|
+
import xml.etree.ElementTree as ET
|
|
418
|
+
|
|
419
|
+
keys = set()
|
|
420
|
+
if not xml_content:
|
|
421
|
+
return keys
|
|
422
|
+
try:
|
|
423
|
+
root = ET.fromstring(xml_content)
|
|
424
|
+
except ET.ParseError:
|
|
425
|
+
return keys
|
|
426
|
+
for node in root.iter("node"):
|
|
427
|
+
keys.add((
|
|
428
|
+
node.get("class", ""),
|
|
429
|
+
node.get("resource-id", ""),
|
|
430
|
+
node.get("text", ""),
|
|
431
|
+
node.get("content-desc", ""),
|
|
432
|
+
node.get("bounds", ""),
|
|
433
|
+
))
|
|
434
|
+
return keys
|
|
435
|
+
|
|
436
|
+
@staticmethod
|
|
437
|
+
def find_scrollable_containers(xml_content: str) -> list[dict]:
|
|
438
|
+
import xml.etree.ElementTree as ET
|
|
439
|
+
|
|
440
|
+
containers = []
|
|
441
|
+
if not xml_content:
|
|
442
|
+
return containers
|
|
443
|
+
try:
|
|
444
|
+
root = ET.fromstring(xml_content)
|
|
445
|
+
except ET.ParseError:
|
|
446
|
+
return containers
|
|
447
|
+
|
|
448
|
+
for node in root.iter("node"):
|
|
449
|
+
if node.get("scrollable") != "true":
|
|
450
|
+
continue
|
|
451
|
+
cls = node.get("class", "")
|
|
452
|
+
if cls in HORIZONTAL_SCROLL_CLASSES:
|
|
453
|
+
continue
|
|
454
|
+
bounds_str = node.get("bounds", "")
|
|
455
|
+
m = re.findall(r"\d+", bounds_str)
|
|
456
|
+
if len(m) != 4:
|
|
457
|
+
continue
|
|
458
|
+
x1, y1, x2, y2 = map(int, m)
|
|
459
|
+
if y2 - y1 < 200:
|
|
460
|
+
continue
|
|
461
|
+
containers.append({
|
|
462
|
+
"bounds": (x1, y1, x2, y2),
|
|
463
|
+
"class": cls,
|
|
464
|
+
"resource-id": node.get("resource-id", ""),
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
containers.sort(
|
|
468
|
+
key=lambda c: (c["bounds"][2] - c["bounds"][0]) * (c["bounds"][3] - c["bounds"][1]),
|
|
469
|
+
reverse=True,
|
|
470
|
+
)
|
|
471
|
+
return containers
|
|
472
|
+
|
|
473
|
+
@staticmethod
|
|
474
|
+
def _normalize_component(component: str) -> str:
|
|
475
|
+
if "/" not in component:
|
|
476
|
+
return component
|
|
477
|
+
pkg, cls = component.split("/", 1)
|
|
478
|
+
if cls.startswith("."):
|
|
479
|
+
cls = pkg + cls
|
|
480
|
+
return f"{pkg}/{cls}"
|
|
481
|
+
|
|
482
|
+
@staticmethod
|
|
483
|
+
def _stable_activity_key(activity_info: dict) -> str:
|
|
484
|
+
focus = activity_info.get("current_focus", "")
|
|
485
|
+
resumed = activity_info.get("resumed_activity", "")
|
|
486
|
+
|
|
487
|
+
m = re.search(r"[\w.]+/[\w.]+", focus)
|
|
488
|
+
if m:
|
|
489
|
+
return PageCrawler._normalize_component(m.group(0))
|
|
490
|
+
|
|
491
|
+
act_m = re.search(r"[\w.]+/[\w.]+", resumed)
|
|
492
|
+
act_part = PageCrawler._normalize_component(act_m.group(0)) if act_m else "unknown"
|
|
493
|
+
|
|
494
|
+
win_type_m = re.search(r"(PopupWindow|Dialog|DecorView|Toast)", focus)
|
|
495
|
+
win_type = win_type_m.group(1) if win_type_m else "overlay"
|
|
496
|
+
|
|
497
|
+
return f"{act_part}|{win_type}"
|
|
498
|
+
|
|
499
|
+
@staticmethod
|
|
500
|
+
def page_signature(activity_info: dict, xml_content: str) -> str:
|
|
501
|
+
from hashlib import md5
|
|
502
|
+
|
|
503
|
+
act_key = PageCrawler._stable_activity_key(activity_info)
|
|
504
|
+
|
|
505
|
+
if not xml_content:
|
|
506
|
+
return f"{act_key}|empty"
|
|
507
|
+
|
|
508
|
+
clickable = PageCrawler.get_clickable_elements(xml_content)
|
|
509
|
+
if not clickable:
|
|
510
|
+
return f"{act_key}|no_clickable"
|
|
511
|
+
|
|
512
|
+
elem_features = []
|
|
513
|
+
for e in clickable:
|
|
514
|
+
parts = [
|
|
515
|
+
e.get("class", ""),
|
|
516
|
+
e.get("resource-id", ""),
|
|
517
|
+
e.get("content-desc", ""),
|
|
518
|
+
e.get("bounds", ""),
|
|
519
|
+
]
|
|
520
|
+
elem_features.append("|".join(parts))
|
|
521
|
+
|
|
522
|
+
features_str = ";".join(elem_features)
|
|
523
|
+
feature_hash = md5(features_str.encode("utf-8")).hexdigest()[:16]
|
|
524
|
+
|
|
525
|
+
return f"{act_key}|{feature_hash}"
|
|
526
|
+
|
|
527
|
+
@staticmethod
|
|
528
|
+
def _root_identified_set(activity_info: dict, xml_content: str) -> tuple:
|
|
529
|
+
resumed = activity_info.get("resumed_activity", "")
|
|
530
|
+
m = re.search(r"[\w.]+/[\w.]+", resumed)
|
|
531
|
+
act_key = PageCrawler._normalize_component(m.group(0)) if m else "unknown"
|
|
532
|
+
|
|
533
|
+
if not xml_content:
|
|
534
|
+
return act_key, set()
|
|
535
|
+
|
|
536
|
+
clickable = PageCrawler.get_clickable_elements(xml_content)
|
|
537
|
+
elems = set()
|
|
538
|
+
for e in clickable:
|
|
539
|
+
desc = e.get("content-desc", "")
|
|
540
|
+
rid = e.get("resource-id", "")
|
|
541
|
+
if not desc and not rid:
|
|
542
|
+
continue
|
|
543
|
+
elems.add((e.get("class", ""), rid, desc))
|
|
544
|
+
|
|
545
|
+
return act_key, elems
|
|
546
|
+
|
|
547
|
+
@staticmethod
|
|
548
|
+
def _is_same_root_page(root_info: tuple, current_info: tuple, threshold: float = 0.6) -> bool:
|
|
549
|
+
root_act, root_elems = root_info
|
|
550
|
+
cur_act, cur_elems = current_info
|
|
551
|
+
|
|
552
|
+
if root_act != cur_act:
|
|
553
|
+
return False
|
|
554
|
+
|
|
555
|
+
if not root_elems and not cur_elems:
|
|
556
|
+
return True
|
|
557
|
+
if not root_elems or not cur_elems:
|
|
558
|
+
return False
|
|
559
|
+
|
|
560
|
+
intersection = root_elems & cur_elems
|
|
561
|
+
union = root_elems | cur_elems
|
|
562
|
+
similarity = len(intersection) / len(union)
|
|
563
|
+
return similarity >= threshold
|
|
564
|
+
|
|
565
|
+
@staticmethod
|
|
566
|
+
def find_element_on_screen(xml_content: str, elem_id: tuple) -> dict | None:
|
|
567
|
+
if len(elem_id) >= 5:
|
|
568
|
+
rid, text, desc, bounds, is_long = elem_id[0], elem_id[1], elem_id[2], elem_id[3], elem_id[4]
|
|
569
|
+
else:
|
|
570
|
+
rid, text, desc, bounds = elem_id[0], elem_id[1], elem_id[2], elem_id[3]
|
|
571
|
+
is_long = None
|
|
572
|
+
clickable_elements = PageCrawler.get_clickable_elements(xml_content)
|
|
573
|
+
|
|
574
|
+
if bounds:
|
|
575
|
+
for e in clickable_elements:
|
|
576
|
+
if (
|
|
577
|
+
e.get("resource-id", "") == rid
|
|
578
|
+
and e.get("text", "") == text
|
|
579
|
+
and e.get("content-desc", "") == desc
|
|
580
|
+
and e.get("bounds", "") == bounds
|
|
581
|
+
and (is_long is None or e.get("long-clickable", False) == is_long)
|
|
582
|
+
):
|
|
583
|
+
return e
|
|
584
|
+
|
|
585
|
+
for e in clickable_elements:
|
|
586
|
+
if (
|
|
587
|
+
e.get("resource-id", "") == rid
|
|
588
|
+
and e.get("text", "") == text
|
|
589
|
+
and e.get("content-desc", "") == desc
|
|
590
|
+
and (is_long is None or e.get("long-clickable", False) == is_long)
|
|
591
|
+
):
|
|
592
|
+
return e
|
|
593
|
+
return None
|
|
594
|
+
|
|
595
|
+
# ── 实例方法(依赖 self 配置或 self.adb)──
|
|
596
|
+
|
|
597
|
+
def should_skip_element(self, elem: dict) -> bool:
|
|
598
|
+
rid = elem.get("resource-id", "")
|
|
599
|
+
text = elem.get("text", "")
|
|
600
|
+
desc = elem.get("content-desc", "")
|
|
601
|
+
cls = elem.get("class", "")
|
|
602
|
+
|
|
603
|
+
for pattern in self.skip_patterns:
|
|
604
|
+
if pattern in rid:
|
|
605
|
+
return True
|
|
606
|
+
|
|
607
|
+
if not text and not desc and not rid:
|
|
608
|
+
if "ImageView" in cls:
|
|
609
|
+
return True
|
|
610
|
+
|
|
611
|
+
return False
|
|
612
|
+
|
|
613
|
+
def is_in_target_app(self, activity_info: dict) -> bool:
|
|
614
|
+
focus = activity_info.get("current_focus", "")
|
|
615
|
+
resumed = activity_info.get("resumed_activity", "")
|
|
616
|
+
print(focus, resumed)
|
|
617
|
+
return self.package in focus or self.package in resumed
|
|
618
|
+
|
|
619
|
+
def scroll_and_dump(
|
|
620
|
+
self,
|
|
621
|
+
page_dir: Path,
|
|
622
|
+
container_bounds: tuple[int, int, int, int],
|
|
623
|
+
initial_xml: str = "",
|
|
624
|
+
) -> tuple[list[str], list[Path], list[Path]]:
|
|
625
|
+
x1, y1, x2, y2 = container_bounds
|
|
626
|
+
cx = (x1 + x2) // 2
|
|
627
|
+
height = y2 - y1
|
|
628
|
+
swipe_from_y = y1 + int(height * 0.7)
|
|
629
|
+
swipe_to_y = y1 + int(height * 0.3)
|
|
630
|
+
|
|
631
|
+
xml_list = []
|
|
632
|
+
xml_paths = []
|
|
633
|
+
screenshot_paths = []
|
|
634
|
+
prev_keys = self._extract_node_keys(initial_xml) if initial_xml else None
|
|
635
|
+
|
|
636
|
+
for i in range(self.max_scrolls):
|
|
637
|
+
self.adb.shell(
|
|
638
|
+
"input", "swipe",
|
|
639
|
+
str(cx), str(swipe_from_y),
|
|
640
|
+
str(cx), str(swipe_to_y),
|
|
641
|
+
str(self.adb.scroll_duration_ms),
|
|
642
|
+
)
|
|
643
|
+
self.adb.wait_for_idle()
|
|
644
|
+
|
|
645
|
+
new_xml = self.adb.dump_xml_quick()
|
|
646
|
+
new_keys = self._extract_node_keys(new_xml)
|
|
647
|
+
|
|
648
|
+
if prev_keys is not None and new_keys == prev_keys:
|
|
649
|
+
print(f" 📜 滚动 {i+1} 次后到达底部,跳过截图")
|
|
650
|
+
break
|
|
651
|
+
prev_keys = new_keys
|
|
652
|
+
|
|
653
|
+
xml_list.append(new_xml)
|
|
654
|
+
|
|
655
|
+
xml_path = page_dir / f"view_scroll_{i + 1}.xml"
|
|
656
|
+
xml_path.write_text(new_xml, encoding="utf-8")
|
|
657
|
+
xml_paths.append(xml_path)
|
|
658
|
+
|
|
659
|
+
ss_path = page_dir / f"screenshot_scroll_{i + 1}.png"
|
|
660
|
+
data = self.adb.screencap_bytes()
|
|
661
|
+
if data:
|
|
662
|
+
ss_path.write_bytes(data)
|
|
663
|
+
screenshot_paths.append(ss_path)
|
|
664
|
+
print(f" 📸 滚动 {i+1}: XML + 截图已保存")
|
|
665
|
+
|
|
666
|
+
return xml_list, xml_paths, screenshot_paths
|
|
667
|
+
|
|
668
|
+
def capture_page(self, label: str):
|
|
669
|
+
tmp_dir = self.output_root / "_tmp_capture"
|
|
670
|
+
if tmp_dir.exists():
|
|
671
|
+
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
672
|
+
tmp_dir.mkdir(exist_ok=True)
|
|
673
|
+
|
|
674
|
+
print(f"\n📄 [采集中] {label}")
|
|
675
|
+
|
|
676
|
+
act_info = self.adb.get_current_activity()
|
|
677
|
+
print(f" Focus : {act_info.get('current_focus', 'N/A')}")
|
|
678
|
+
print(f" Activity: {act_info.get('resumed_activity', 'N/A')}")
|
|
679
|
+
|
|
680
|
+
xml_content = self.adb.get_view_xml_fast(tmp_dir)
|
|
681
|
+
print(f" View XML 已保存 ({len(xml_content)} bytes)")
|
|
682
|
+
|
|
683
|
+
self.adb.take_screenshot(tmp_dir)
|
|
684
|
+
print(f" 截图已保存")
|
|
685
|
+
|
|
686
|
+
all_screenshots = [tmp_dir / "screenshot.png"]
|
|
687
|
+
all_view_xmls = [tmp_dir / "view.xml"]
|
|
688
|
+
all_xml_contents = [xml_content]
|
|
689
|
+
scroll_containers = self.find_scrollable_containers(xml_content)
|
|
690
|
+
if scroll_containers:
|
|
691
|
+
container = scroll_containers[0]
|
|
692
|
+
print(f" 📜 检测到可滚动容器: {container['class']} {container.get('resource-id', '')}")
|
|
693
|
+
scroll_xmls, scroll_xml_paths, scroll_ss = self.scroll_and_dump(
|
|
694
|
+
tmp_dir, container["bounds"], initial_xml=xml_content,
|
|
695
|
+
)
|
|
696
|
+
if scroll_xmls:
|
|
697
|
+
all_xml_contents.extend(scroll_xmls)
|
|
698
|
+
all_view_xmls.extend(scroll_xml_paths)
|
|
699
|
+
all_screenshots.extend(scroll_ss)
|
|
700
|
+
print(f" 📜 滚动采集完成:{len(scroll_xmls)} 次滚动,共 {len(all_screenshots)} 张截图")
|
|
701
|
+
|
|
702
|
+
seen_clickable = set()
|
|
703
|
+
clickable = []
|
|
704
|
+
for idx, xc in enumerate(all_xml_contents):
|
|
705
|
+
for elem in self.get_clickable_elements(xc):
|
|
706
|
+
key = (
|
|
707
|
+
elem.get("resource-id", ""),
|
|
708
|
+
elem.get("text", ""),
|
|
709
|
+
elem.get("content-desc", ""),
|
|
710
|
+
elem.get("class", ""),
|
|
711
|
+
elem.get("bounds", ""),
|
|
712
|
+
elem.get("long-clickable", False),
|
|
713
|
+
)
|
|
714
|
+
if key not in seen_clickable:
|
|
715
|
+
seen_clickable.add(key)
|
|
716
|
+
elem["scroll_index"] = idx
|
|
717
|
+
clickable.append(elem)
|
|
718
|
+
print(f" 可点击元素: {len(clickable)} 个")
|
|
719
|
+
|
|
720
|
+
scroll_container_bounds = None
|
|
721
|
+
if scroll_containers:
|
|
722
|
+
scroll_container_bounds = scroll_containers[0]["bounds"]
|
|
723
|
+
|
|
724
|
+
record = {
|
|
725
|
+
"page_id": None,
|
|
726
|
+
"label": label,
|
|
727
|
+
"timestamp": datetime.now().isoformat(),
|
|
728
|
+
"activity_info": act_info,
|
|
729
|
+
"clickable_count": len(clickable),
|
|
730
|
+
"clickable_elements": clickable,
|
|
731
|
+
"scroll_container_bounds": list(scroll_container_bounds) if scroll_container_bounds else None,
|
|
732
|
+
"_tmp_dir": str(tmp_dir),
|
|
733
|
+
"_screenshots": [str(p) for p in all_screenshots],
|
|
734
|
+
"_view_xmls": [str(p) for p in all_view_xmls],
|
|
735
|
+
}
|
|
736
|
+
return record, xml_content, clickable, scroll_container_bounds
|
|
737
|
+
|
|
738
|
+
def restart_to_root(
|
|
739
|
+
self,
|
|
740
|
+
launch_package: Optional[str],
|
|
741
|
+
launch_activity: Optional[str],
|
|
742
|
+
root_activity_keyword: Optional[str] = None,
|
|
743
|
+
root_identity: Optional[tuple] = None,
|
|
744
|
+
):
|
|
745
|
+
if not launch_package or not launch_activity:
|
|
746
|
+
print(" [WARN] 包名或 Activity 名为空,无法重启")
|
|
747
|
+
return False
|
|
748
|
+
|
|
749
|
+
component = f"{launch_package}/{launch_activity}"
|
|
750
|
+
|
|
751
|
+
def _is_at_root():
|
|
752
|
+
activity_info = self.adb.get_current_activity()
|
|
753
|
+
focus = activity_info.get("current_focus", "")
|
|
754
|
+
resumed = activity_info.get("resumed_activity", "")
|
|
755
|
+
if root_activity_keyword:
|
|
756
|
+
if root_activity_keyword not in focus and root_activity_keyword not in resumed:
|
|
757
|
+
return False
|
|
758
|
+
if root_identity:
|
|
759
|
+
xml = self.adb.dump_xml_quick()
|
|
760
|
+
cur_identity = self._root_identified_set(activity_info, xml)
|
|
761
|
+
if not self._is_same_root_page(root_identity, cur_identity):
|
|
762
|
+
cur_act, cur_elems = cur_identity
|
|
763
|
+
root_act, root_elems = root_identity
|
|
764
|
+
intersection = root_elems & cur_elems
|
|
765
|
+
union = root_elems | cur_elems
|
|
766
|
+
sim = len(intersection) / len(union) if union else 0
|
|
767
|
+
print(f" [DEBUG] 根页面相似度: {sim:.2f} (交集{len(intersection)}/并集{len(union)})")
|
|
768
|
+
return False
|
|
769
|
+
return True
|
|
770
|
+
|
|
771
|
+
def _try_recovery_clicks():
|
|
772
|
+
if not self.root_recovery_clicks:
|
|
773
|
+
return False
|
|
774
|
+
print(f" [INFO] 尝试通过点击路径恢复到根页面...")
|
|
775
|
+
for step in self.root_recovery_clicks:
|
|
776
|
+
xml = self.adb.dump_xml_quick()
|
|
777
|
+
clickable = self.get_clickable_elements(xml)
|
|
778
|
+
found = None
|
|
779
|
+
for e in clickable:
|
|
780
|
+
if step.get("content-desc") and e.get("content-desc") == step["content-desc"]:
|
|
781
|
+
found = e
|
|
782
|
+
break
|
|
783
|
+
if step.get("text") and e.get("text") == step["text"]:
|
|
784
|
+
found = e
|
|
785
|
+
break
|
|
786
|
+
if step.get("resource-id") and e.get("resource-id") == step["resource-id"]:
|
|
787
|
+
found = e
|
|
788
|
+
break
|
|
789
|
+
if found and found.get("center"):
|
|
790
|
+
print(f" [INFO] 点击: {step.get('content-desc') or step.get('text') or step.get('resource-id')}")
|
|
791
|
+
self.adb.tap(found["center"][0], found["center"][1])
|
|
792
|
+
elif step.get("center"):
|
|
793
|
+
print(f" [INFO] 使用固定坐标点击: {step['center']}")
|
|
794
|
+
self.adb.tap(step["center"][0], step["center"][1])
|
|
795
|
+
else:
|
|
796
|
+
print(f" [WARN] 恢复路径中未找到元素: {step}")
|
|
797
|
+
return False
|
|
798
|
+
return _is_at_root()
|
|
799
|
+
|
|
800
|
+
self.adb.shell("am", "force-stop", launch_package)
|
|
801
|
+
time.sleep(1.0)
|
|
802
|
+
self.adb.shell(
|
|
803
|
+
"am",
|
|
804
|
+
"start",
|
|
805
|
+
"-n",
|
|
806
|
+
component,
|
|
807
|
+
"--activity-clear-task",
|
|
808
|
+
"--activity-clear-top",
|
|
809
|
+
)
|
|
810
|
+
self.adb.wait_for_idle()
|
|
811
|
+
|
|
812
|
+
for _retry in range(4):
|
|
813
|
+
if _is_at_root():
|
|
814
|
+
return True
|
|
815
|
+
print(f" [INFO] 等待页面加载完成... (重试 {_retry + 1}/4)")
|
|
816
|
+
time.sleep(2)
|
|
817
|
+
|
|
818
|
+
print(f" [WARN] force-stop 后未到达根页面(App 恢复了上次的 Tab 状态)")
|
|
819
|
+
if _try_recovery_clicks():
|
|
820
|
+
return True
|
|
821
|
+
|
|
822
|
+
print(f" [INFO] 尝试 back + 点击路径恢复...")
|
|
823
|
+
self.adb.press_back()
|
|
824
|
+
if _is_at_root():
|
|
825
|
+
return True
|
|
826
|
+
if _try_recovery_clicks():
|
|
827
|
+
return True
|
|
828
|
+
|
|
829
|
+
print(f" [WARN] 所有恢复方式均失败,跳过此次导航")
|
|
830
|
+
return False
|
|
831
|
+
|
|
832
|
+
def navigate_via_restart(
|
|
833
|
+
self,
|
|
834
|
+
click_path: list,
|
|
835
|
+
launch_package: Optional[str],
|
|
836
|
+
launch_activity: Optional[str],
|
|
837
|
+
root_activity_keyword: Optional[str] = None,
|
|
838
|
+
step_signatures: Optional[list] = None,
|
|
839
|
+
root_identity: Optional[tuple] = None,
|
|
840
|
+
) -> bool:
|
|
841
|
+
if not launch_package or not launch_activity:
|
|
842
|
+
print(" [WARN] 包名或 Activity 名为空,无法导航")
|
|
843
|
+
return False
|
|
844
|
+
|
|
845
|
+
if not click_path:
|
|
846
|
+
return True
|
|
847
|
+
|
|
848
|
+
if not self.restart_to_root(launch_package, launch_activity, root_activity_keyword, root_identity):
|
|
849
|
+
print(" [ERR] 无法回到根页面,导航失败")
|
|
850
|
+
return False
|
|
851
|
+
|
|
852
|
+
for i, step in enumerate(click_path):
|
|
853
|
+
xml = self.adb.dump_xml_quick()
|
|
854
|
+
|
|
855
|
+
step_scroll = step.get("scroll_info")
|
|
856
|
+
if step_scroll and step_scroll.get("scroll_index", 0) > 0:
|
|
857
|
+
self.adb.scroll_to_position(
|
|
858
|
+
tuple(step_scroll["container_bounds"]),
|
|
859
|
+
step_scroll["scroll_index"],
|
|
860
|
+
)
|
|
861
|
+
xml = self.adb.dump_xml_quick()
|
|
862
|
+
|
|
863
|
+
elem_id = (
|
|
864
|
+
step.get("resource-id", ""),
|
|
865
|
+
step.get("text", ""),
|
|
866
|
+
step.get("content-desc", ""),
|
|
867
|
+
"",
|
|
868
|
+
)
|
|
869
|
+
found = self.find_element_on_screen(xml, elem_id)
|
|
870
|
+
is_long = step.get("long-clickable", False)
|
|
871
|
+
if found and found.get("center"):
|
|
872
|
+
self.adb.tap_or_long_press(found["center"][0], found["center"][1], is_long)
|
|
873
|
+
elif step.get("center"):
|
|
874
|
+
print(
|
|
875
|
+
f" [WARN] 未找到元素 {step.get('element', '?')},使用记录坐标"
|
|
876
|
+
)
|
|
877
|
+
self.adb.tap_or_long_press(step["center"][0], step["center"][1], is_long)
|
|
878
|
+
else:
|
|
879
|
+
print(f" [ERR] 无法导航到步骤: {step.get('element', '?')}")
|
|
880
|
+
return False
|
|
881
|
+
|
|
882
|
+
if step_signatures and i < len(step_signatures) and step_signatures[i]:
|
|
883
|
+
verify_xml = self.adb.dump_xml_quick()
|
|
884
|
+
verify_info = self.adb.get_current_activity()
|
|
885
|
+
actual_sig = self.page_signature(verify_info, verify_xml)
|
|
886
|
+
if actual_sig != step_signatures[i]:
|
|
887
|
+
expected_act = step_signatures[i].split("|")[0]
|
|
888
|
+
actual_act = actual_sig.split("|")[0]
|
|
889
|
+
if expected_act != actual_act:
|
|
890
|
+
print(
|
|
891
|
+
f" [WARN] 导航步骤 {i+1} 后 Activity 不匹配 "
|
|
892
|
+
f"(期望={expected_act}, "
|
|
893
|
+
f"实际={actual_act})"
|
|
894
|
+
)
|
|
895
|
+
return False
|
|
896
|
+
return True
|
|
897
|
+
|
|
898
|
+
# ── 断点续跑 ──
|
|
899
|
+
|
|
900
|
+
def _save_queue(self, queue: deque):
|
|
901
|
+
items = []
|
|
902
|
+
for click_path, elem_id, step_sigs, scroll_info in queue:
|
|
903
|
+
items.append([click_path, list(elem_id), step_sigs, scroll_info])
|
|
904
|
+
try:
|
|
905
|
+
(self.output_root / "queue_checkpoint.json").write_text(
|
|
906
|
+
json.dumps(items, ensure_ascii=False),
|
|
907
|
+
encoding="utf-8",
|
|
908
|
+
)
|
|
909
|
+
except Exception as e:
|
|
910
|
+
print(f" [WARN] 保存队列检查点失败: {e}")
|
|
911
|
+
|
|
912
|
+
def _load_queue(self) -> "deque | None":
|
|
913
|
+
qfile = self.output_root / "queue_checkpoint.json"
|
|
914
|
+
if not qfile.exists():
|
|
915
|
+
return None
|
|
916
|
+
try:
|
|
917
|
+
items = json.loads(qfile.read_text(encoding="utf-8"))
|
|
918
|
+
q: deque = deque()
|
|
919
|
+
for click_path, elem_id_list, step_sigs, scroll_info in items:
|
|
920
|
+
q.append((click_path, tuple(elem_id_list), step_sigs, scroll_info))
|
|
921
|
+
return q
|
|
922
|
+
except Exception as e:
|
|
923
|
+
print(f" [WARN] 加载队列检查点失败: {e},将重建队列")
|
|
924
|
+
return None
|
|
925
|
+
|
|
926
|
+
def _rebuild_queue_from_pages(self, pages_index: list) -> deque:
|
|
927
|
+
queue: deque = deque()
|
|
928
|
+
for page in pages_index:
|
|
929
|
+
click_path = page.get("click_path", [])
|
|
930
|
+
clickable = page.get("clickable_elements", [])
|
|
931
|
+
scroll_container_bounds = page.get("scroll_container_bounds")
|
|
932
|
+
for elem in clickable:
|
|
933
|
+
if self.should_skip_element(elem):
|
|
934
|
+
continue
|
|
935
|
+
elem_id = (
|
|
936
|
+
elem.get("resource-id", ""),
|
|
937
|
+
elem.get("text", ""),
|
|
938
|
+
elem.get("content-desc", ""),
|
|
939
|
+
elem.get("bounds", ""),
|
|
940
|
+
elem.get("long-clickable", False),
|
|
941
|
+
)
|
|
942
|
+
si = elem.get("scroll_index", 0)
|
|
943
|
+
scroll_info = None
|
|
944
|
+
if si > 0 and scroll_container_bounds:
|
|
945
|
+
scroll_info = {
|
|
946
|
+
"scroll_index": si,
|
|
947
|
+
"container_bounds": list(scroll_container_bounds),
|
|
948
|
+
}
|
|
949
|
+
queue.append((click_path, elem_id, [], scroll_info))
|
|
950
|
+
return queue
|
|
951
|
+
|
|
952
|
+
def _try_load_checkpoint(self):
|
|
953
|
+
index_file = self.output_root / "index.json"
|
|
954
|
+
if not index_file.exists():
|
|
955
|
+
return None
|
|
956
|
+
|
|
957
|
+
page_dirs = list(self.output_root.glob("page_*"))
|
|
958
|
+
if not page_dirs:
|
|
959
|
+
return None
|
|
960
|
+
|
|
961
|
+
try:
|
|
962
|
+
pages_index = json.loads(index_file.read_text(encoding="utf-8"))
|
|
963
|
+
except Exception as e:
|
|
964
|
+
print(f" [WARN] 读取 index.json 失败: {e},重新开始")
|
|
965
|
+
return None
|
|
966
|
+
|
|
967
|
+
if not pages_index:
|
|
968
|
+
return None
|
|
969
|
+
|
|
970
|
+
print(f"\n⏩ 发现已有检查点:{len(pages_index)} 个页面,尝试断点续跑...")
|
|
971
|
+
|
|
972
|
+
visited_signatures = set()
|
|
973
|
+
page_counter = 0
|
|
974
|
+
|
|
975
|
+
for page in pages_index:
|
|
976
|
+
page_id = page.get("page_id", "")
|
|
977
|
+
if not page_id:
|
|
978
|
+
continue
|
|
979
|
+
|
|
980
|
+
m = re.match(r"page_(\d+)", page_id)
|
|
981
|
+
if m:
|
|
982
|
+
page_counter = max(page_counter, int(m.group(1)))
|
|
983
|
+
|
|
984
|
+
view_xml_path = self.output_root / page_id / "view.xml"
|
|
985
|
+
if view_xml_path.exists():
|
|
986
|
+
xml_content = view_xml_path.read_text(encoding="utf-8", errors="replace")
|
|
987
|
+
sig = self.page_signature(page.get("activity_info", {}), xml_content)
|
|
988
|
+
visited_signatures.add(sig)
|
|
989
|
+
|
|
990
|
+
saved_queue = self._load_queue()
|
|
991
|
+
if saved_queue is not None:
|
|
992
|
+
queue = saved_queue
|
|
993
|
+
print(f" 队列已从检查点恢复:{len(queue)} 个待处理元素(精确续跑)")
|
|
994
|
+
else:
|
|
995
|
+
queue = self._rebuild_queue_from_pages(pages_index)
|
|
996
|
+
print(f" 队列已重建(fallback):{len(queue)} 个元素(含已探索项,BFS 会自动跳过)")
|
|
997
|
+
|
|
998
|
+
blocked_elements: set = set()
|
|
999
|
+
print(f" 已恢复 {len(visited_signatures)} 个已访问签名,队列 {len(queue)} 个待处理元素")
|
|
1000
|
+
return pages_index, visited_signatures, page_counter, queue, blocked_elements
|
|
1001
|
+
|
|
1002
|
+
# ── 页面提交 ──
|
|
1003
|
+
|
|
1004
|
+
def _commit_page(self, record: dict) -> str:
|
|
1005
|
+
self.page_counter += 1
|
|
1006
|
+
|
|
1007
|
+
activity_name = ""
|
|
1008
|
+
resumed = record.get("activity_info", {}).get("resumed_activity", "")
|
|
1009
|
+
m_act = re.search(r"[\w.]+/([\w.]+)", resumed)
|
|
1010
|
+
if not m_act:
|
|
1011
|
+
f = record.get("activity_info", {}).get("current_focus", "")
|
|
1012
|
+
m_act = re.search(r"[\w.]+/([\w.]+)", f)
|
|
1013
|
+
if m_act:
|
|
1014
|
+
activity_name = m_act.group(1).split(".")[-1]
|
|
1015
|
+
suffix = f"_{activity_name}" if activity_name else ""
|
|
1016
|
+
page_id = f"page_{self.page_counter:04d}{suffix}"
|
|
1017
|
+
page_dir = self.output_root / page_id
|
|
1018
|
+
|
|
1019
|
+
tmp_dir = Path(record["_tmp_dir"])
|
|
1020
|
+
if tmp_dir.exists():
|
|
1021
|
+
tmp_dir.rename(page_dir)
|
|
1022
|
+
else:
|
|
1023
|
+
page_dir.mkdir(exist_ok=True)
|
|
1024
|
+
|
|
1025
|
+
record["page_id"] = page_id
|
|
1026
|
+
record["screenshot"] = f"{page_id}/screenshot.png"
|
|
1027
|
+
record["view_xml"] = f"{page_id}/view.xml"
|
|
1028
|
+
|
|
1029
|
+
screenshots_list = [f"{page_id}/screenshot.png"]
|
|
1030
|
+
tmp_screenshots = record.pop("_screenshots", [])
|
|
1031
|
+
for ss_path_str in tmp_screenshots:
|
|
1032
|
+
ss_name = Path(ss_path_str).name
|
|
1033
|
+
if ss_name != "screenshot.png":
|
|
1034
|
+
screenshots_list.append(f"{page_id}/{ss_name}")
|
|
1035
|
+
record["screenshots"] = screenshots_list
|
|
1036
|
+
|
|
1037
|
+
view_xmls_list = [f"{page_id}/view.xml"]
|
|
1038
|
+
tmp_view_xmls = record.pop("_view_xmls", [])
|
|
1039
|
+
for xml_path_str in tmp_view_xmls:
|
|
1040
|
+
xml_name = Path(xml_path_str).name
|
|
1041
|
+
if xml_name != "view.xml":
|
|
1042
|
+
view_xmls_list.append(f"{page_id}/{xml_name}")
|
|
1043
|
+
record["view_xmls"] = view_xmls_list
|
|
1044
|
+
|
|
1045
|
+
del record["_tmp_dir"]
|
|
1046
|
+
|
|
1047
|
+
(page_dir / "meta.json").write_text(
|
|
1048
|
+
json.dumps(record, ensure_ascii=False, indent=2), encoding="utf-8"
|
|
1049
|
+
)
|
|
1050
|
+
print(f" ✅ 已保存为 [{page_id}]")
|
|
1051
|
+
return page_id
|
|
1052
|
+
|
|
1053
|
+
def _commit_manual_page(self, record: dict, page_counter: int) -> str:
|
|
1054
|
+
activity_name = ""
|
|
1055
|
+
resumed = record.get("activity_info", {}).get("resumed_activity", "")
|
|
1056
|
+
m_act = re.search(r"[\w.]+/([\w.]+)", resumed)
|
|
1057
|
+
if not m_act:
|
|
1058
|
+
f = record.get("activity_info", {}).get("current_focus", "")
|
|
1059
|
+
m_act = re.search(r"[\w.]+/([\w.]+)", f)
|
|
1060
|
+
if m_act:
|
|
1061
|
+
activity_name = m_act.group(1).split(".")[-1]
|
|
1062
|
+
suffix = f"_{activity_name}" if activity_name else ""
|
|
1063
|
+
page_id = f"manual_{page_counter:04d}{suffix}"
|
|
1064
|
+
page_dir = self.output_root / page_id
|
|
1065
|
+
|
|
1066
|
+
tmp_dir = Path(record["_tmp_dir"])
|
|
1067
|
+
if tmp_dir.exists():
|
|
1068
|
+
tmp_dir.rename(page_dir)
|
|
1069
|
+
else:
|
|
1070
|
+
page_dir.mkdir(exist_ok=True)
|
|
1071
|
+
|
|
1072
|
+
record["page_id"] = page_id
|
|
1073
|
+
record["screenshot"] = f"{page_id}/screenshot.png"
|
|
1074
|
+
record["view_xml"] = f"{page_id}/view.xml"
|
|
1075
|
+
|
|
1076
|
+
screenshots_list = [f"{page_id}/screenshot.png"]
|
|
1077
|
+
tmp_screenshots = record.pop("_screenshots", [])
|
|
1078
|
+
for ss_path_str in tmp_screenshots:
|
|
1079
|
+
ss_name = Path(ss_path_str).name
|
|
1080
|
+
if ss_name != "screenshot.png":
|
|
1081
|
+
screenshots_list.append(f"{page_id}/{ss_name}")
|
|
1082
|
+
record["screenshots"] = screenshots_list
|
|
1083
|
+
|
|
1084
|
+
view_xmls_list = [f"{page_id}/view.xml"]
|
|
1085
|
+
tmp_view_xmls = record.pop("_view_xmls", [])
|
|
1086
|
+
for xml_path_str in tmp_view_xmls:
|
|
1087
|
+
xml_name = Path(xml_path_str).name
|
|
1088
|
+
if xml_name != "view.xml":
|
|
1089
|
+
view_xmls_list.append(f"{page_id}/{xml_name}")
|
|
1090
|
+
record["view_xmls"] = view_xmls_list
|
|
1091
|
+
|
|
1092
|
+
del record["_tmp_dir"]
|
|
1093
|
+
|
|
1094
|
+
(page_dir / "meta.json").write_text(
|
|
1095
|
+
json.dumps(record, ensure_ascii=False, indent=2), encoding="utf-8"
|
|
1096
|
+
)
|
|
1097
|
+
print(f" ✅ 已保存为 [{page_id}]")
|
|
1098
|
+
return page_id
|
|
1099
|
+
|
|
1100
|
+
# ── HTML 报告生成 ──
|
|
1101
|
+
|
|
1102
|
+
def generate_html_report(self, pages: list[dict]):
|
|
1103
|
+
cards = ""
|
|
1104
|
+
for p in pages:
|
|
1105
|
+
activity = html_module.escape(
|
|
1106
|
+
p["activity_info"].get("current_focus", "N/A")
|
|
1107
|
+
)
|
|
1108
|
+
resumed = html_module.escape(
|
|
1109
|
+
p["activity_info"].get("resumed_activity", "N/A")
|
|
1110
|
+
)
|
|
1111
|
+
click_path = p.get("click_path", [])
|
|
1112
|
+
if click_path == "manual":
|
|
1113
|
+
desc = html_module.escape(p.get("description", "手动采集"))
|
|
1114
|
+
path_display = f"(手动采集:{desc})"
|
|
1115
|
+
elif click_path:
|
|
1116
|
+
long_click_tag = '<b class="long-click">长按</b>'
|
|
1117
|
+
path_display = " → ".join(
|
|
1118
|
+
[
|
|
1119
|
+
"<code>{}</code> {} <b>{}</b>".format(
|
|
1120
|
+
html_module.escape(s['from']),
|
|
1121
|
+
long_click_tag if s.get('long-clickable') else '点击',
|
|
1122
|
+
html_module.escape(s['element']),
|
|
1123
|
+
)
|
|
1124
|
+
for s in click_path
|
|
1125
|
+
]
|
|
1126
|
+
)
|
|
1127
|
+
else:
|
|
1128
|
+
path_display = "(初始页)"
|
|
1129
|
+
screenshot_path = html_module.escape(p["screenshot"])
|
|
1130
|
+
screenshots = p.get("screenshots", [screenshot_path])
|
|
1131
|
+
view_xmls = p.get("view_xmls", [p["view_xml"]])
|
|
1132
|
+
if len(screenshots) > 1:
|
|
1133
|
+
pairs_html = ""
|
|
1134
|
+
for idx, ss in enumerate(screenshots):
|
|
1135
|
+
xml_link = view_xmls[idx] if idx < len(view_xmls) else view_xmls[-1]
|
|
1136
|
+
label_tag = "初始" if idx == 0 else f"滚动{idx}"
|
|
1137
|
+
pairs_html += (
|
|
1138
|
+
f'<div class="scroll-pair">'
|
|
1139
|
+
f'<img src="{html_module.escape(ss)}" alt="screenshot" onerror="this.style.display=\'none\'"/>'
|
|
1140
|
+
f'<a href="{html_module.escape(xml_link)}" target="_blank">{label_tag} XML</a>'
|
|
1141
|
+
f'</div>'
|
|
1142
|
+
)
|
|
1143
|
+
screenshots_html = f'<div class="screenshots-gallery">{pairs_html}</div>'
|
|
1144
|
+
else:
|
|
1145
|
+
screenshots_html = f'<img src="{html_module.escape(screenshots[0])}" alt="screenshot" onerror="this.style.display=\'none\'"/>'
|
|
1146
|
+
label_escaped = html_module.escape(p["label"])
|
|
1147
|
+
page_id_escaped = html_module.escape(p["page_id"])
|
|
1148
|
+
view_xml_escaped = html_module.escape(p["view_xml"])
|
|
1149
|
+
if len(view_xmls) > 1:
|
|
1150
|
+
xml_links_html = " ".join(
|
|
1151
|
+
f'<a href="{html_module.escape(vx)}" target="_blank">📄 XML{"" if i == 0 else f"_{i}"}</a>'
|
|
1152
|
+
for i, vx in enumerate(view_xmls)
|
|
1153
|
+
)
|
|
1154
|
+
else:
|
|
1155
|
+
xml_links_html = f'<a href="{view_xml_escaped}" target="_blank">📄 查看 View XML</a>'
|
|
1156
|
+
cards += f"""
|
|
1157
|
+
<div class="card">
|
|
1158
|
+
<div class="card-header">
|
|
1159
|
+
<span class="pid">{page_id_escaped}</span>
|
|
1160
|
+
<span class="label">{label_escaped}</span>
|
|
1161
|
+
<span class="time">{html_module.escape(p["timestamp"][:19])}</span>
|
|
1162
|
+
</div>
|
|
1163
|
+
<div class="card-body">
|
|
1164
|
+
{screenshots_html}
|
|
1165
|
+
<div class="meta">
|
|
1166
|
+
<div><b>Focus:</b> <code>{activity}</code></div>
|
|
1167
|
+
<div><b>Activity:</b> <code>{resumed}</code></div>
|
|
1168
|
+
<div><b>点击路径:</b> {path_display}</div>
|
|
1169
|
+
<div><b>可点击元素:</b> {p["clickable_count"]} 个</div>
|
|
1170
|
+
<div class="links">
|
|
1171
|
+
{xml_links_html}
|
|
1172
|
+
<a href="{page_id_escaped}/meta.json" target="_blank">🗂 元数据 JSON</a>
|
|
1173
|
+
</div>
|
|
1174
|
+
</div>
|
|
1175
|
+
</div>
|
|
1176
|
+
</div>"""
|
|
1177
|
+
|
|
1178
|
+
html = f"""<!DOCTYPE html>
|
|
1179
|
+
<html lang="zh">
|
|
1180
|
+
<head>
|
|
1181
|
+
<meta charset="UTF-8">
|
|
1182
|
+
<title>Android 页面遍历报告(加速版)</title>
|
|
1183
|
+
<style>
|
|
1184
|
+
body {{ font-family: -apple-system, sans-serif; background: #f5f5f5; margin: 0; padding: 20px; }}
|
|
1185
|
+
h1 {{ color: #333; }}
|
|
1186
|
+
.summary {{ background: #fff; border-radius: 8px; padding: 16px; margin-bottom: 20px; box-shadow: 0 1px 4px rgba(0,0,0,.1); }}
|
|
1187
|
+
.grid {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(420px, 1fr)); gap: 16px; }}
|
|
1188
|
+
.card {{ background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,.1); }}
|
|
1189
|
+
.card-header {{ background: #1a73e8; color: #fff; padding: 10px 14px; display: flex; gap: 10px; align-items: center; }}
|
|
1190
|
+
.pid {{ font-weight: bold; font-size: 13px; background: rgba(255,255,255,.2); padding: 2px 8px; border-radius: 4px; }}
|
|
1191
|
+
.label {{ flex: 1; font-size: 14px; }}
|
|
1192
|
+
.time {{ font-size: 11px; opacity: .8; }}
|
|
1193
|
+
.card-body {{ display: flex; gap: 12px; padding: 12px; }}
|
|
1194
|
+
.card-body img {{ width: 140px; height: auto; border-radius: 4px; border: 1px solid #eee; object-fit: cover; flex-shrink: 0; }}
|
|
1195
|
+
.screenshots-gallery {{ display: flex; gap: 6px; overflow-x: auto; flex-shrink: 0; max-width: 320px; padding-bottom: 4px; }}
|
|
1196
|
+
.screenshots-gallery img {{ width: 120px; height: auto; border-radius: 4px; border: 1px solid #eee; object-fit: cover; flex-shrink: 0; }}
|
|
1197
|
+
.scroll-pair {{ display: flex; flex-direction: column; align-items: center; gap: 2px; flex-shrink: 0; }}
|
|
1198
|
+
.scroll-pair a {{ font-size: 10px; color: #1a73e8; text-decoration: none; }}
|
|
1199
|
+
.scroll-pair a:hover {{ text-decoration: underline; }}
|
|
1200
|
+
.meta {{ font-size: 12px; color: #444; display: flex; flex-direction: column; gap: 6px; overflow: hidden; }}
|
|
1201
|
+
.meta code {{ font-size: 11px; background: #f0f0f0; padding: 1px 4px; border-radius: 3px; word-break: break-all; }}
|
|
1202
|
+
.links {{ display: flex; gap: 10px; margin-top: 4px; }}
|
|
1203
|
+
.links a {{ color: #1a73e8; text-decoration: none; font-size: 12px; }}
|
|
1204
|
+
.links a:hover {{ text-decoration: underline; }}
|
|
1205
|
+
.long-click {{ color: #e65100; background: #fff3e0; padding: 1px 5px; border-radius: 3px; font-size: 11px; }}
|
|
1206
|
+
</style>
|
|
1207
|
+
</head>
|
|
1208
|
+
<body>
|
|
1209
|
+
<h1>📱 Android 页面遍历报告(加速版)</h1>
|
|
1210
|
+
<div class="summary">
|
|
1211
|
+
<b>生成时间:</b>{datetime.now().strftime("%Y-%m-%d %H:%M:%S")} |
|
|
1212
|
+
<b>共采集页面:</b>{len(pages)} 个
|
|
1213
|
+
</div>
|
|
1214
|
+
<div class="grid">
|
|
1215
|
+
{cards}
|
|
1216
|
+
</div>
|
|
1217
|
+
</body>
|
|
1218
|
+
</html>"""
|
|
1219
|
+
|
|
1220
|
+
(self.output_root / "report.html").write_text(html, encoding="utf-8")
|
|
1221
|
+
|
|
1222
|
+
# ── 主遍历逻辑(BFS + 重启导航 + 轻量探测)──
|
|
1223
|
+
|
|
1224
|
+
def _flush_report(self):
|
|
1225
|
+
try:
|
|
1226
|
+
(self.output_root / "index.json").write_text(
|
|
1227
|
+
json.dumps(self.pages_index, ensure_ascii=False, indent=2),
|
|
1228
|
+
encoding="utf-8",
|
|
1229
|
+
)
|
|
1230
|
+
self.generate_html_report(self.pages_index)
|
|
1231
|
+
self._save_queue(self.queue)
|
|
1232
|
+
except Exception as e:
|
|
1233
|
+
print(f" [WARN] 写入报告失败: {e}")
|
|
1234
|
+
|
|
1235
|
+
def crawl(self):
|
|
1236
|
+
if not self.adb.check_device():
|
|
1237
|
+
return
|
|
1238
|
+
|
|
1239
|
+
activity_info = self.adb.get_current_activity()
|
|
1240
|
+
if not self.is_in_target_app(activity_info):
|
|
1241
|
+
print(f"当前不在目标 App ({self.package}) 内,清空数据并启动...")
|
|
1242
|
+
self.adb.shell("am", "force-stop", self.package)
|
|
1243
|
+
time.sleep(0.5)
|
|
1244
|
+
self.adb.shell(
|
|
1245
|
+
"monkey",
|
|
1246
|
+
"-p",
|
|
1247
|
+
self.package,
|
|
1248
|
+
"-c",
|
|
1249
|
+
"android.intent.category.LAUNCHER",
|
|
1250
|
+
"1",
|
|
1251
|
+
)
|
|
1252
|
+
self.adb.wait_for_idle()
|
|
1253
|
+
activity_info = self.adb.get_current_activity()
|
|
1254
|
+
if not self.is_in_target_app(activity_info):
|
|
1255
|
+
print(
|
|
1256
|
+
f"[ERR] 启动后仍未进入目标 App ({self.package}),请检查包名是否正确"
|
|
1257
|
+
)
|
|
1258
|
+
return
|
|
1259
|
+
|
|
1260
|
+
launch_package = None
|
|
1261
|
+
launch_activity = None
|
|
1262
|
+
focus = activity_info.get("current_focus", "")
|
|
1263
|
+
m = re.search(r"([\w.]+)/([\w.]+)", focus)
|
|
1264
|
+
if m:
|
|
1265
|
+
launch_package = m.group(1)
|
|
1266
|
+
launch_activity = m.group(2)
|
|
1267
|
+
print(f"启动 Activity: {launch_package}/{launch_activity}")
|
|
1268
|
+
else:
|
|
1269
|
+
print(f"[WARN] 无法从 current_focus 提取启动 Activity: {focus}")
|
|
1270
|
+
|
|
1271
|
+
root_activity_keyword = None
|
|
1272
|
+
if launch_activity:
|
|
1273
|
+
root_activity_keyword = launch_activity.split(".")[-1]
|
|
1274
|
+
print(f"根 Activity 关键字: {root_activity_keyword}")
|
|
1275
|
+
|
|
1276
|
+
self.output_root.mkdir(exist_ok=True)
|
|
1277
|
+
|
|
1278
|
+
self.pages_index = []
|
|
1279
|
+
self.visited_signatures = set()
|
|
1280
|
+
self.path_signatures = {}
|
|
1281
|
+
self.blocked_elements = set()
|
|
1282
|
+
self.queue = deque()
|
|
1283
|
+
self.page_counter = 0
|
|
1284
|
+
self.stats = {
|
|
1285
|
+
"skipped_elements": 0,
|
|
1286
|
+
"probe_skipped": 0,
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
print(f"\n📂 输出目录: {self.output_root.resolve()}\n")
|
|
1290
|
+
print("=" * 60)
|
|
1291
|
+
print("开始遍历(加速版:BFS + 轻量探测)...")
|
|
1292
|
+
print("=" * 60)
|
|
1293
|
+
|
|
1294
|
+
root_identity = None
|
|
1295
|
+
checkpoint = self._try_load_checkpoint()
|
|
1296
|
+
if checkpoint:
|
|
1297
|
+
self.pages_index, self.visited_signatures, self.page_counter, self.queue, self.blocked_elements = checkpoint
|
|
1298
|
+
print(f"⏩ 断点续跑:从第 {self.page_counter + 1} 个页面继续\n")
|
|
1299
|
+
for p in self.pages_index:
|
|
1300
|
+
if not p.get("click_path"):
|
|
1301
|
+
root_xml_path = self.output_root / p["page_id"] / "view.xml"
|
|
1302
|
+
if root_xml_path.exists():
|
|
1303
|
+
root_xml = root_xml_path.read_text(encoding="utf-8", errors="replace")
|
|
1304
|
+
root_identity = self._root_identified_set(p.get("activity_info", {}), root_xml)
|
|
1305
|
+
break
|
|
1306
|
+
else:
|
|
1307
|
+
record, xml_content, clickable, init_scroll_bounds = self.capture_page("初始页面")
|
|
1308
|
+
page_id = self._commit_page(record)
|
|
1309
|
+
record["click_path"] = []
|
|
1310
|
+
(self.output_root / page_id / "meta.json").write_text(
|
|
1311
|
+
json.dumps(record, ensure_ascii=False, indent=2), encoding="utf-8"
|
|
1312
|
+
)
|
|
1313
|
+
sig = self.page_signature(record["activity_info"], xml_content)
|
|
1314
|
+
root_identity = self._root_identified_set(record["activity_info"], xml_content)
|
|
1315
|
+
self.visited_signatures.add(sig)
|
|
1316
|
+
self.path_signatures[json.dumps([])] = sig
|
|
1317
|
+
self.pages_index.append(record)
|
|
1318
|
+
self._flush_report()
|
|
1319
|
+
|
|
1320
|
+
for elem in clickable:
|
|
1321
|
+
if self.should_skip_element(elem):
|
|
1322
|
+
self.stats["skipped_elements"] += 1
|
|
1323
|
+
continue
|
|
1324
|
+
elem_id = (
|
|
1325
|
+
elem.get("resource-id", ""),
|
|
1326
|
+
elem.get("text", ""),
|
|
1327
|
+
elem.get("content-desc", ""),
|
|
1328
|
+
elem.get("bounds", ""),
|
|
1329
|
+
elem.get("long-clickable", False),
|
|
1330
|
+
)
|
|
1331
|
+
si = elem.get("scroll_index", 0)
|
|
1332
|
+
scroll_info = None
|
|
1333
|
+
if si > 0 and init_scroll_bounds:
|
|
1334
|
+
scroll_info = {"scroll_index": si, "container_bounds": list(init_scroll_bounds)}
|
|
1335
|
+
self.queue.append(([], elem_id, [], scroll_info))
|
|
1336
|
+
|
|
1337
|
+
failed_paths = set()
|
|
1338
|
+
while self.queue and self.page_counter < self.max_pages:
|
|
1339
|
+
click_path, elem_id, step_signatures, scroll_info = self.queue.popleft()
|
|
1340
|
+
|
|
1341
|
+
if elem_id in self.blocked_elements:
|
|
1342
|
+
continue
|
|
1343
|
+
|
|
1344
|
+
path_key = json.dumps(click_path, ensure_ascii=False) if click_path else ""
|
|
1345
|
+
if any(path_key.startswith(fp) and len(fp) > 2 for fp in failed_paths):
|
|
1346
|
+
continue
|
|
1347
|
+
|
|
1348
|
+
path_desc = (
|
|
1349
|
+
" → ".join([s.get("element", "?") for s in click_path])
|
|
1350
|
+
if click_path
|
|
1351
|
+
else "根页面"
|
|
1352
|
+
)
|
|
1353
|
+
print(f"\n🧭 导航到: {path_desc}")
|
|
1354
|
+
|
|
1355
|
+
if not launch_package or not launch_activity:
|
|
1356
|
+
print(f" [ERR] 无法重启 App,缺少启动信息")
|
|
1357
|
+
continue
|
|
1358
|
+
|
|
1359
|
+
if click_path:
|
|
1360
|
+
if not self.navigate_via_restart(
|
|
1361
|
+
click_path,
|
|
1362
|
+
launch_package,
|
|
1363
|
+
launch_activity,
|
|
1364
|
+
root_activity_keyword,
|
|
1365
|
+
step_signatures,
|
|
1366
|
+
root_identity,
|
|
1367
|
+
):
|
|
1368
|
+
print(f" [ERR] 导航失败,跳过")
|
|
1369
|
+
failed_paths.add(path_key)
|
|
1370
|
+
continue
|
|
1371
|
+
else:
|
|
1372
|
+
if not self.restart_to_root(
|
|
1373
|
+
launch_package, launch_activity, root_activity_keyword, root_identity
|
|
1374
|
+
):
|
|
1375
|
+
print(f" [ERR] 无法回到根页面,跳过")
|
|
1376
|
+
continue
|
|
1377
|
+
|
|
1378
|
+
fresh_xml = self.adb.dump_xml_quick()
|
|
1379
|
+
|
|
1380
|
+
if scroll_info and scroll_info["scroll_index"] > 0:
|
|
1381
|
+
print(f" 📜 滚动 {scroll_info['scroll_index']} 次到达元素位置")
|
|
1382
|
+
self.adb.scroll_to_position(tuple(scroll_info["container_bounds"]), scroll_info["scroll_index"])
|
|
1383
|
+
fresh_xml = self.adb.dump_xml_quick()
|
|
1384
|
+
|
|
1385
|
+
found_elem = self.find_element_on_screen(fresh_xml, elem_id)
|
|
1386
|
+
|
|
1387
|
+
if not found_elem or not found_elem.get("center"):
|
|
1388
|
+
print(f" [SKIP] 无法找到元素: {elem_id[:3]}")
|
|
1389
|
+
continue
|
|
1390
|
+
|
|
1391
|
+
center = found_elem["center"]
|
|
1392
|
+
label_text = (
|
|
1393
|
+
found_elem.get("text")
|
|
1394
|
+
or found_elem.get("content-desc")
|
|
1395
|
+
or found_elem.get("resource-id")
|
|
1396
|
+
or "unknown"
|
|
1397
|
+
)
|
|
1398
|
+
|
|
1399
|
+
print(f"\n👆 点击: [{label_text}] @ {center}")
|
|
1400
|
+
is_long = found_elem.get("long-clickable", False)
|
|
1401
|
+
if is_long:
|
|
1402
|
+
print(f" 🔁 使用长按触发")
|
|
1403
|
+
self.adb.tap_or_long_press(center[0], center[1], is_long)
|
|
1404
|
+
|
|
1405
|
+
probe_xml = self.adb.dump_xml_quick()
|
|
1406
|
+
probe_info = self.adb.get_current_activity()
|
|
1407
|
+
new_sig = self.page_signature(probe_info, probe_xml)
|
|
1408
|
+
|
|
1409
|
+
if not self.is_in_target_app(probe_info):
|
|
1410
|
+
print(f" ⚠️ 跳转到其他 App,标记并跳过")
|
|
1411
|
+
self.blocked_elements.add(elem_id)
|
|
1412
|
+
continue
|
|
1413
|
+
|
|
1414
|
+
if new_sig in self.visited_signatures:
|
|
1415
|
+
print(f" ♻️ 页面已访问过,跳过(省截图)")
|
|
1416
|
+
self.stats["probe_skipped"] += 1
|
|
1417
|
+
continue
|
|
1418
|
+
|
|
1419
|
+
new_record, new_xml, new_clickable, new_scroll_bounds = self.capture_page(f"点击'{label_text}'后")
|
|
1420
|
+
new_sig = self.page_signature(new_record["activity_info"], new_xml)
|
|
1421
|
+
self.visited_signatures.add(new_sig)
|
|
1422
|
+
page_id = self._commit_page(new_record)
|
|
1423
|
+
|
|
1424
|
+
new_click_path = click_path + [
|
|
1425
|
+
{
|
|
1426
|
+
"from": path_desc,
|
|
1427
|
+
"element": label_text,
|
|
1428
|
+
"center": list(center),
|
|
1429
|
+
"resource-id": found_elem.get("resource-id", ""),
|
|
1430
|
+
"text": found_elem.get("text", ""),
|
|
1431
|
+
"content-desc": found_elem.get("content-desc", ""),
|
|
1432
|
+
"long-clickable": is_long,
|
|
1433
|
+
"scroll_info": scroll_info,
|
|
1434
|
+
}
|
|
1435
|
+
]
|
|
1436
|
+
new_record["came_from"] = path_desc
|
|
1437
|
+
new_record["trigger_element"] = label_text
|
|
1438
|
+
new_record["click_path"] = new_click_path
|
|
1439
|
+
(self.output_root / page_id / "meta.json").write_text(
|
|
1440
|
+
json.dumps(new_record, ensure_ascii=False, indent=2),
|
|
1441
|
+
encoding="utf-8",
|
|
1442
|
+
)
|
|
1443
|
+
self.pages_index.append(new_record)
|
|
1444
|
+
self._flush_report()
|
|
1445
|
+
|
|
1446
|
+
self.path_signatures[json.dumps(new_click_path)] = new_sig
|
|
1447
|
+
new_step_sigs = step_signatures + [new_sig]
|
|
1448
|
+
|
|
1449
|
+
for ne in new_clickable:
|
|
1450
|
+
if self.should_skip_element(ne):
|
|
1451
|
+
self.stats["skipped_elements"] += 1
|
|
1452
|
+
continue
|
|
1453
|
+
new_eid = (
|
|
1454
|
+
ne.get("resource-id", ""),
|
|
1455
|
+
ne.get("text", ""),
|
|
1456
|
+
ne.get("content-desc", ""),
|
|
1457
|
+
ne.get("bounds", ""),
|
|
1458
|
+
ne.get("long-clickable", False),
|
|
1459
|
+
)
|
|
1460
|
+
si = ne.get("scroll_index", 0)
|
|
1461
|
+
ne_scroll_info = None
|
|
1462
|
+
if si > 0 and new_scroll_bounds:
|
|
1463
|
+
ne_scroll_info = {"scroll_index": si, "container_bounds": list(new_scroll_bounds)}
|
|
1464
|
+
self.queue.append((new_click_path, new_eid, new_step_sigs, ne_scroll_info))
|
|
1465
|
+
|
|
1466
|
+
self.adb.flush_pending_pulls()
|
|
1467
|
+
self._flush_report()
|
|
1468
|
+
|
|
1469
|
+
try:
|
|
1470
|
+
(self.output_root / "queue_checkpoint.json").unlink(missing_ok=True)
|
|
1471
|
+
except Exception:
|
|
1472
|
+
pass
|
|
1473
|
+
|
|
1474
|
+
print("\n" + "=" * 60)
|
|
1475
|
+
print(f"✅ 遍历完成!共采集 {len(self.pages_index)} 个页面")
|
|
1476
|
+
print(f"📂 输出目录: {self.output_root.resolve()}")
|
|
1477
|
+
print(f"📋 索引文件: {self.output_root / 'index.json'}")
|
|
1478
|
+
print(f"🌐 HTML报告: {self.output_root / 'report.html'}")
|
|
1479
|
+
print(f"\n📊 加速统计:")
|
|
1480
|
+
print(f" 过滤低价值元素: {self.stats['skipped_elements']} 个")
|
|
1481
|
+
print(f" 轻量探测跳过(省截图): {self.stats['probe_skipped']} 次")
|
|
1482
|
+
print("=" * 60)
|
|
1483
|
+
|
|
1484
|
+
# ── 手动采集模式 ──
|
|
1485
|
+
|
|
1486
|
+
def manual_crawl(self):
|
|
1487
|
+
if not self.adb.check_device():
|
|
1488
|
+
return
|
|
1489
|
+
|
|
1490
|
+
self.output_root.mkdir(exist_ok=True)
|
|
1491
|
+
|
|
1492
|
+
pages_index = []
|
|
1493
|
+
index_file = self.output_root / "index.json"
|
|
1494
|
+
if index_file.exists():
|
|
1495
|
+
try:
|
|
1496
|
+
pages_index = json.loads(index_file.read_text(encoding="utf-8"))
|
|
1497
|
+
print(f"📂 已加载 {len(pages_index)} 个已有页面")
|
|
1498
|
+
except Exception as e:
|
|
1499
|
+
print(f"[WARN] 读取 index.json 失败: {e},将创建新索引")
|
|
1500
|
+
|
|
1501
|
+
manual_counter = 0
|
|
1502
|
+
for p in pages_index:
|
|
1503
|
+
pid = p.get("page_id", "")
|
|
1504
|
+
m = re.match(r"manual_(\d+)", pid)
|
|
1505
|
+
if m:
|
|
1506
|
+
manual_counter = max(manual_counter, int(m.group(1)))
|
|
1507
|
+
|
|
1508
|
+
print(f"\n📂 输出目录: {self.output_root.resolve()}")
|
|
1509
|
+
print(f"📝 手动采集模式已启动(manual 编号从 {manual_counter + 1} 开始)")
|
|
1510
|
+
print("=" * 60)
|
|
1511
|
+
print("操作说明:")
|
|
1512
|
+
print(" 1. 在设备上手动导航到目标页面")
|
|
1513
|
+
print(' 2. 输入页面描述(如"设置页 > 关于"),然后按回车开始采集')
|
|
1514
|
+
print(" 3. 输入 q 退出")
|
|
1515
|
+
print("=" * 60)
|
|
1516
|
+
|
|
1517
|
+
while True:
|
|
1518
|
+
print()
|
|
1519
|
+
desc = input("📝 输入页面描述(q 退出): ").strip()
|
|
1520
|
+
if desc.lower() == "q":
|
|
1521
|
+
print("👋 退出手动采集模式")
|
|
1522
|
+
break
|
|
1523
|
+
if not desc:
|
|
1524
|
+
desc = "手动采集"
|
|
1525
|
+
|
|
1526
|
+
manual_counter += 1
|
|
1527
|
+
record, xml_content, clickable, scroll_bounds = self.capture_page(desc)
|
|
1528
|
+
|
|
1529
|
+
record["click_path"] = "manual"
|
|
1530
|
+
record["description"] = desc
|
|
1531
|
+
self._commit_manual_page(record, manual_counter)
|
|
1532
|
+
|
|
1533
|
+
pages_index.append(record)
|
|
1534
|
+
|
|
1535
|
+
(self.output_root / "index.json").write_text(
|
|
1536
|
+
json.dumps(pages_index, ensure_ascii=False, indent=2),
|
|
1537
|
+
encoding="utf-8",
|
|
1538
|
+
)
|
|
1539
|
+
self.generate_html_report(pages_index)
|
|
1540
|
+
print(f" 📋 index.json 和 report.html 已更新(共 {len(pages_index)} 个页面)")
|
|
1541
|
+
|
|
1542
|
+
print(f"\n✅ 手动采集完成!共 {len(pages_index)} 个页面(含自动遍历)")
|
|
1543
|
+
print(f"📂 输出目录: {self.output_root.resolve()}")
|
|
1544
|
+
|
|
1545
|
+
|
|
1546
|
+
# ─────────────────────────────────────────────
|
|
1547
|
+
# 入口
|
|
1548
|
+
# ─────────────────────────────────────────────
|
|
1549
|
+
if __name__ == "__main__":
|
|
1550
|
+
import argparse
|
|
1551
|
+
|
|
1552
|
+
parser = argparse.ArgumentParser(description="Android 模拟器页面遍历工具")
|
|
1553
|
+
parser.add_argument(
|
|
1554
|
+
"--package", "-p",
|
|
1555
|
+
required=True,
|
|
1556
|
+
help="目标 App 包名(必选)",
|
|
1557
|
+
)
|
|
1558
|
+
parser.add_argument(
|
|
1559
|
+
"--output", "-o",
|
|
1560
|
+
required=True,
|
|
1561
|
+
help="输出目录(必选)",
|
|
1562
|
+
)
|
|
1563
|
+
parser.add_argument(
|
|
1564
|
+
"--manual", action="store_true",
|
|
1565
|
+
help="手动采集模式:手动导航到页面后按回车触发采集",
|
|
1566
|
+
)
|
|
1567
|
+
parser.add_argument(
|
|
1568
|
+
"--device",
|
|
1569
|
+
default=DEVICE_SERIAL_DEFAULT,
|
|
1570
|
+
help=f"ADB 设备 serial(默认: {DEVICE_SERIAL_DEFAULT})",
|
|
1571
|
+
)
|
|
1572
|
+
parser.add_argument(
|
|
1573
|
+
"--adb-path",
|
|
1574
|
+
default=ADB_PATH_DEFAULT,
|
|
1575
|
+
help=f"adb 可执行文件路径(默认: {ADB_PATH_DEFAULT})",
|
|
1576
|
+
)
|
|
1577
|
+
parser.add_argument(
|
|
1578
|
+
"--max-pages",
|
|
1579
|
+
type=int,
|
|
1580
|
+
default=MAX_PAGES_DEFAULT,
|
|
1581
|
+
help=f"最多遍历页面数(默认: {MAX_PAGES_DEFAULT})",
|
|
1582
|
+
)
|
|
1583
|
+
parser.add_argument(
|
|
1584
|
+
"--max-scrolls",
|
|
1585
|
+
type=int,
|
|
1586
|
+
default=MAX_SCROLLS_DEFAULT,
|
|
1587
|
+
help=f"每个可滚动容器最多滚动次数(默认: {MAX_SCROLLS_DEFAULT})",
|
|
1588
|
+
)
|
|
1589
|
+
args = parser.parse_args()
|
|
1590
|
+
|
|
1591
|
+
adb = ADBHelper(
|
|
1592
|
+
adb_path=args.adb_path,
|
|
1593
|
+
device_serial=args.device,
|
|
1594
|
+
)
|
|
1595
|
+
crawler = PageCrawler(
|
|
1596
|
+
adb=adb,
|
|
1597
|
+
package=args.package,
|
|
1598
|
+
output_dir=args.output,
|
|
1599
|
+
max_pages=args.max_pages,
|
|
1600
|
+
max_scrolls=args.max_scrolls,
|
|
1601
|
+
)
|
|
1602
|
+
|
|
1603
|
+
if args.manual:
|
|
1604
|
+
crawler.manual_crawl()
|
|
1605
|
+
else:
|
|
1606
|
+
crawler.crawl()
|