@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.
Files changed (136) hide show
  1. package/README.md +141 -124
  2. package/agents/build-fixer.md +1 -0
  3. package/agents/code-review-fix.md +1 -0
  4. package/agents/code-reviewer.md +1 -0
  5. package/agents/logic-coding.md +1 -0
  6. package/agents/logic-context-builder.md +1 -0
  7. package/agents/review-fixer.md +1 -0
  8. package/agents/self-test-fixer.md +1 -0
  9. package/agents/self-tester.md +260 -233
  10. package/agents/spec-generator.md +1 -0
  11. package/agents/test-tools/autotest/README.md +223 -0
  12. package/agents/test-tools/autotest/config.yaml.example +58 -0
  13. package/agents/test-tools/autotest/pyproject.toml +16 -0
  14. package/agents/test-tools/autotest/report_tool.py +759 -0
  15. package/agents/test-tools/autotest/self_test_runner.py +773 -0
  16. package/agents/test-tools/autotest/testcases_schema.md +143 -0
  17. package/agents/test-tools/autotest/testcases_tool.py +215 -0
  18. package/agents/test-tools/autotest/uv.lock +3156 -0
  19. package/agents/test-tools/harmony_autotest-0.1.0-py3-none-any.whl +0 -0
  20. package/agents/test-tools/hypium-6.1.0.210-py3-none-any.whl +0 -0
  21. package/agents/test-tools/hypium_mcp-0.6.5-py3-none-any.whl +0 -0
  22. package/agents/test-tools/xdevice-6.1.0.210-py3-none-any.whl +0 -0
  23. package/agents/test-tools/xdevice_devicetest-6.1.0.210-py3-none-any.whl +0 -0
  24. package/agents/test-tools/xdevice_ohos-6.1.0.210-py3-none-any.whl +0 -0
  25. package/dist/cli/config-store.js +27 -2
  26. package/dist/cli/config.js +17 -6
  27. package/dist/cli/index.js +3 -2
  28. package/dist/cli/init.js +135 -22
  29. package/dist/cli/mcp.js +2 -2
  30. package/dist/context/index.js +165 -69
  31. package/package.json +59 -60
  32. package/skills/code-dev-review-fix/SKILL.md +279 -0
  33. package/skills/code-dev-review-fix-workspace/evals/evals.json +56 -0
  34. package/skills/code-dev-review-fix-workspace/iteration-1/routing-results.md +23 -0
  35. package/skills/convert_pipeline/SKILL.md +423 -439
  36. package/skills/hmos-resources-convert/SKILL.md +623 -0
  37. package/skills/hmos-resources-convert/evals/evals.json +171 -0
  38. package/skills/hmos-resources-convert/references/conversion-rules.md +663 -0
  39. package/skills/hmos-resources-convert/references/dependency-analysis-rules.md +388 -0
  40. package/skills/hmos-resources-convert/references/resource-mapping-rules.md +457 -0
  41. package/skills/hmos-resources-convert/references/xml-drawable-to-svg-rules.md +513 -0
  42. package/skills/hmos-resources-convert/template/AppScope/app.json5 +10 -0
  43. package/skills/hmos-resources-convert/template/AppScope/resources/base/element/string.json +8 -0
  44. package/skills/hmos-resources-convert/template/AppScope/resources/base/media/background.png +0 -0
  45. package/skills/hmos-resources-convert/template/AppScope/resources/base/media/foreground.png +0 -0
  46. package/skills/hmos-resources-convert/template/AppScope/resources/base/media/layered_image.json +7 -0
  47. package/skills/hmos-resources-convert/template/build-profile.json5 +42 -0
  48. package/skills/hmos-resources-convert/template/code-linter.json5 +32 -0
  49. package/skills/hmos-resources-convert/template/entry/build-profile.json5 +33 -0
  50. package/skills/hmos-resources-convert/template/entry/hvigorfile.ts +6 -0
  51. package/skills/hmos-resources-convert/template/entry/obfuscation-rules.txt +23 -0
  52. package/skills/hmos-resources-convert/template/entry/oh-package.json5 +10 -0
  53. package/skills/hmos-resources-convert/template/entry/src/main/ets/entryability/EntryAbility.ets +48 -0
  54. package/skills/hmos-resources-convert/template/entry/src/main/ets/entrybackupability/EntryBackupAbility.ets +16 -0
  55. package/skills/hmos-resources-convert/template/entry/src/main/ets/pages/Index.ets +23 -0
  56. package/skills/hmos-resources-convert/template/entry/src/main/module.json5 +55 -0
  57. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/element/color.json +8 -0
  58. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/element/float.json +8 -0
  59. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/element/string.json +16 -0
  60. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/media/background.png +0 -0
  61. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/media/foreground.png +0 -0
  62. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/media/layered_image.json +7 -0
  63. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/media/startIcon.png +0 -0
  64. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/profile/backup_config.json +3 -0
  65. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/profile/main_pages.json +5 -0
  66. package/skills/hmos-resources-convert/template/entry/src/main/resources/dark/element/color.json +8 -0
  67. package/skills/hmos-resources-convert/template/entry/src/mock/mock-config.json5 +2 -0
  68. package/skills/hmos-resources-convert/template/entry/src/ohosTest/ets/test/Ability.test.ets +35 -0
  69. package/skills/hmos-resources-convert/template/entry/src/ohosTest/ets/test/List.test.ets +5 -0
  70. package/skills/hmos-resources-convert/template/entry/src/ohosTest/module.json5 +16 -0
  71. package/skills/hmos-resources-convert/template/entry/src/test/List.test.ets +5 -0
  72. package/skills/hmos-resources-convert/template/entry/src/test/LocalUnit.test.ets +33 -0
  73. package/skills/hmos-resources-convert/template/hvigor/hvigor-config.json5 +23 -0
  74. package/skills/hmos-resources-convert/template/hvigorfile.ts +6 -0
  75. package/skills/hmos-resources-convert/template/oh-package-lock.json5 +28 -0
  76. package/skills/hmos-resources-convert/template/oh-package.json5 +10 -0
  77. package/skills/hmos-resources-convert/tools/apktool.bat +85 -0
  78. package/skills/hmos-resources-convert/tools/apktool_3.0.1.jar +0 -0
  79. package/skills/hmos-ui-align/SKILL.md +182 -0
  80. package/skills/hmos-ui-align/config-example.json +11 -0
  81. package/skills/hmos-ui-align/config.json +11 -0
  82. package/skills/hmos-ui-align/diff_analysis.md +53 -0
  83. package/skills/hmos-ui-align/page_align.md +62 -0
  84. package/skills/hmos-ui-align/readme.md +231 -0
  85. package/skills/hmos-ui-align/references/Comparison_Template.md +2 -0
  86. 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
  87. 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
  88. 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
  89. 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
  90. 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
  91. 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
  92. 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
  93. 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
  94. 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
  95. 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
  96. 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
  97. 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
  98. 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
  99. 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
  100. package/skills/hmos-ui-align/references/UI_Analysis_Template.md +4 -0
  101. package/skills/hmos-ui-align/references/android-to-harmonyOS-ui-atomic-component-mapping-reference.md +2535 -0
  102. package/skills/hmos-ui-align/references/android-to-harmonyOS-ui-interaction-mapping-reference.md +555 -0
  103. package/skills/hmos-ui-align/references/android-to-harmonyOS-ui-layout-mapping-reference.md +117 -0
  104. package/skills/hmos-ui-align/scripts/app_feature_verify.py +443 -0
  105. package/skills/hmos-ui-align/scripts/navigation-capure.md +37 -0
  106. package/skills/hmos-ui-align/scripts/page_capture.py +592 -0
  107. package/skills/hmos-ui-align-batch/SKILL.md +99 -0
  108. package/skills/hmos-ui-align-batch/references/conversion-procedure.md +180 -0
  109. package/skills/hmos-ui-align-batch/references/mappings/android-to-harmonyOS-ui-atomic-component-mapping-reference.md +2535 -0
  110. package/skills/hmos-ui-align-batch/references/mappings/android-to-harmonyOS-ui-interaction-mapping-reference.md +555 -0
  111. package/skills/hmos-ui-align-batch/references/mappings/android-to-harmonyOS-ui-layout-mapping-reference.md +117 -0
  112. 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
  113. 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
  114. 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
  115. 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
  116. 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
  117. 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
  118. 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
  119. 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
  120. 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
  121. 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
  122. package/skills/hmos-ui-align-batch/references/mvvm/MVVM/346/250/241/345/274/217/357/274/210V1/357/274/211.md +911 -0
  123. 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
  124. 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
  125. package/skills/hmos-ui-align-batch/scripts/android_parse_fast.py +1606 -0
  126. package/skills/self-test/SKILL.md +369 -0
  127. package/skills/self-test/readme.md +309 -0
  128. package/skills/spec-generator-skill/SKILL.md +332 -0
  129. package/skills/spec-generator-skill/references/android-platform-tokens.md +105 -0
  130. package/skills/spec-generator-skill/references/spec-sample-1.md +78 -0
  131. package/skills/spec-generator-skill/references/spec-sample-2.md +58 -0
  132. package/skills/spec-generator-skill/references/spec-sample-3.md +116 -0
  133. package/skills/spec-generator-skill/references/step4-report-template.md +33 -0
  134. package/agents/self-test-setup.md +0 -165
  135. package/dist/context/resources/sdkConfig.json +0 -24
  136. 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")} &nbsp;|&nbsp;
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()