@harmonyos-arkts/d2h 0.1.3 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +78 -44
  2. package/agents/android-to-hmos-00-orchestrator.md +34 -28
  3. package/agents/d2h.md +266 -95
  4. package/dist/bin/install-opencode.js +137 -43
  5. package/dist/src/install-opencode.js +44 -0
  6. package/dist/src/install-vscode.js +226 -0
  7. package/dist/src/toolchain.js +87 -0
  8. package/docs/environment-check.md +86 -0
  9. package/opencode/agents.json +1 -1
  10. package/opencode/vscode-d2h-package.json +7 -0
  11. package/opencode/vscode-d2h-plugin.mjs +29 -0
  12. package/package.json +6 -4
  13. package/schemas/task-envelope.schema.json +58 -4
  14. package/schemas/task-plan.schema.json +281 -63
  15. package/scripts/check-environment.mjs +321 -0
  16. package/skills/d2h-hypium-forge/SKILL.md +252 -0
  17. package/skills/d2h-hypium-forge/references/functional-spec-template.md +140 -0
  18. package/skills/d2h-hypium-forge/references/official-api.md +135 -0
  19. package/skills/d2h-hypium-forge/references/pitfalls.md +98 -0
  20. package/skills/d2h-hypium-forge/references/testability-template.md +71 -0
  21. package/skills/d2h-hypium-forge/scripts/_common_template.py +614 -0
  22. package/skills/d2h-hypium-forge/scripts/analyze_results.py +156 -0
  23. package/skills/d2h-hypium-forge/scripts/assert_audit.py +107 -0
  24. package/skills/d2h-hypium-forge/scripts/collect_runtime_pages.py +169 -0
  25. package/skills/d2h-hypium-forge/scripts/forge_report.py +325 -0
  26. package/skills/d2h-hypium-forge/scripts/inject_resource_ids.py +114 -0
  27. package/skills/d2h-hypium-forge/scripts/lint_asserts.py +80 -0
  28. package/skills/d2h-hypium-forge/scripts/lint_locate.py +60 -0
  29. package/skills/d2h-hypium-forge/scripts/merge_appgraph.py +249 -0
  30. package/skills/d2h-hypium-forge/scripts/precondition.py +140 -0
  31. package/skills/d2h-hypium-forge/scripts/preinject_ids.py +232 -0
  32. package/skills/d2h-hypium-forge/scripts/run_direct.py +392 -0
  33. package/skills/d2h-hypium-forge/scripts/run_parallel.py +121 -0
  34. package/skills/d2h-hypium-forge/scripts/scan_features.py +255 -0
  35. package/skills/d2h-hypium-forge/scripts/upgrade_asserts.py +66 -0
  36. package/skills/d2h-hypium-forge/scripts/validate_scenarios.py +84 -0
  37. package/skills/d2h-utg-ai-generate/SKILL.md +128 -0
  38. package/skills/d2h-utg-ai-generate/references/click-paths-schema.json +76 -0
  39. package/skills/d2h-utg-ai-generate/references/extraction-guide.md +34 -0
  40. package/skills/d2h-utg-ai-generate/references/facts-contract.md +96 -0
  41. package/skills/d2h-utg-ai-generate/references/page-schema.json +61 -0
  42. package/skills/d2h-utg-ai-generate/references/utg-schema.json +89 -0
  43. package/skills/d2h-utg-ai-generate/scripts/build_page_json.py +314 -0
  44. package/skills/d2h-utg-ai-generate/scripts/build_utg.py +123 -0
  45. package/skills/d2h-utg-ai-generate/scripts/capture_from_page_json.ts +280 -0
  46. package/skills/d2h-utg-ai-generate/scripts/utg_common.py +524 -0
  47. package/skills/d2h-utg-ai-generate/scripts/utg_html.py +489 -0
  48. package/skills/d2h-utg-ai-generate/scripts/validate_utg.py +69 -0
  49. package/skills/d2h-utg-ai-generate/scripts/vendor/D3-LICENSE +13 -0
  50. package/skills/d2h-utg-ai-generate/scripts/vendor/d3-7.9.0.min.js +2 -0
  51. package/skills/d2h-utg-ai-generate/tests/test_pipeline.py +369 -0
  52. package/skills/d2h-utg-static-generate/SKILL.md +73 -0
  53. package/skills/d2h-utg-static-generate/references/discoveries.md +38 -0
  54. package/skills/d2h-utg-static-generate/scripts/generate.py +499 -0
  55. package/skills/d2h-utg-static-generate/tests/test_static.py +335 -0
  56. package/skills/d2h-utg-static-generate/tests/verify-offline-html.py +60 -0
  57. package/skills/d2h-utg-static-generate/tests/verify-produced-fixture.py +131 -0
  58. package/skills/harmonyos-migration-playbook/references/ui-automation.md +4 -3
  59. package/skills/harmonyos-migration-playbook/references/windows-environment.md +1 -1
  60. package/skills/managing-d2h-toolchain/SKILL.md +23 -0
  61. package/skills/managing-d2h-toolchain/environment-requirements.json +16 -0
  62. package/skills/managing-d2h-toolchain/toolchain.json +16 -0
  63. package/skills/preparing-migration-workspace/SKILL.md +9 -7
  64. package/agents/d2h_v2.md +0 -351
  65. package/skills/preparing-migration-workspace/toolchain.json +0 -19
@@ -0,0 +1,156 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """失败结果机械分类器(EXPERIENCE-LIB EXEC-011/012 的落地)。
4
+
5
+ 输入 run_direct.py / run_parallel.py 产出的 results*.json,结合 cases.json 与
6
+ runtime_pages.json,把每条失败归入证据明确的簇,并对门禁疑似簇给出 BLOCKED-GATE
7
+ 改判建议(最终改判仍需 hilog 路由日志确认,分类器只做候选提名)。
8
+
9
+ 分类规则(按优先级):
10
+ DEVICE 设备连接类错误(Fail to get device / connector)——环境问题,非用例问题
11
+ DESIGN-ERROR 断言文本在 runtime_pages 任何页都找不到——用例引用了采集期不存在的文本
12
+ NAV-CONTEXT-LOSS 断言文本存在、但只出现在其他页(常见于我的域门禁:执行时入口点击
13
+ 无路由,页面停在母页导致目标文本缺失);若文本所在页含门禁标记词
14
+ (关联账户/登录/充值)标 gate_suspect=True
15
+ ASSERT-FAIL 文本存在于本页上下文,属状态断言失败(数据/时序问题),需人工看截图
16
+
17
+ 用法:
18
+ python3 analyze_results.py --results results.json --cases cases.json \
19
+ [--runtime-pages runtime_pages.json] [--out analysis.md] [--reclassify]
20
+ """
21
+ import argparse
22
+ import json
23
+ import os
24
+ import re
25
+ import sys
26
+ from collections import Counter
27
+
28
+ GATE_MARKERS = ('关联账户', '登录', '充值', '绑定手机', '实名')
29
+
30
+ TEXT_NOT_FOUND = re.compile(r'text not found[::]\s*(.+?)(?:\s*\||$)')
31
+
32
+
33
+ def load(path):
34
+ if not path or not os.path.exists(path):
35
+ return None
36
+ with open(path, encoding='utf-8') as f:
37
+ return json.load(f)
38
+
39
+
40
+ def build_text_index(runtime_pages):
41
+ """文本 → 出现过的页 key 列表;同时返回 页 key → texts 反查表。"""
42
+ idx, by_page = {}, {}
43
+ for p in runtime_pages or []:
44
+ by_page[p['key']] = p.get('texts', [])
45
+ for t in p.get('texts', []):
46
+ idx.setdefault(t, []).append(p['key'])
47
+ return idx, by_page
48
+
49
+
50
+ def classify_one(res, case, text_idx, by_page):
51
+ """返回 (cluster, detail_dict)。"""
52
+ msg = str(res.get('msg', ''))
53
+
54
+ if 'Fail to get device' in msg or 'connector' in msg:
55
+ return 'DEVICE', {'reason': '设备连接失败', 'device': res.get('device', '')}
56
+
57
+ m = TEXT_NOT_FOUND.search(msg)
58
+ if not m:
59
+ return 'ASSERT-FAIL', {'reason': msg[:120] or '无消息'}
60
+
61
+ text = m.group(1).strip()
62
+ pages = text_idx.get(text) if text_idx else None
63
+ if pages is None:
64
+ return 'UNKNOWN', {'reason': '无 runtime_pages 证据,无法定位文本', 'text': text}
65
+ if not pages:
66
+ return 'DESIGN-ERROR', {'reason': '断言文本不在任何采集页中', 'text': text}
67
+
68
+ # 门禁疑似:文本所在页含门禁标记词(关联账户/登录/充值等,可按工程扩展)
69
+ gate = any(any(g in t for g in GATE_MARKERS) for t in by_page.get(pages[0], []))
70
+ return 'NAV-CONTEXT-LOSS', {
71
+ 'reason': '文本存在于采集页但执行时未出现(导航/门禁上下文丢失)',
72
+ 'text': text,
73
+ 'pages': pages,
74
+ 'gate_suspect': gate,
75
+ }
76
+
77
+
78
+ def main():
79
+ ap = argparse.ArgumentParser()
80
+ ap.add_argument('--results', required=True)
81
+ ap.add_argument('--cases', default=None)
82
+ ap.add_argument('--runtime-pages', default=None)
83
+ ap.add_argument('--out', default=None)
84
+ ap.add_argument('--reclassify', action='store_true',
85
+ help='输出改判后的 results 文件(BLOCKED-GATE 候选仍标 FAIL,仅附注)')
86
+ args = ap.parse_args()
87
+
88
+ results = load(args.results) or []
89
+ cases = {c['id']: c for c in (load(args.cases) or [])}
90
+ runtime_pages_cache = load(args.runtime_pages) or []
91
+ text_idx, by_page = build_text_index(runtime_pages_cache)
92
+
93
+ rows = []
94
+ counter = Counter()
95
+ for res in results:
96
+ status = res.get('status')
97
+ if status == 'PASS':
98
+ continue
99
+ case = cases.get(res.get('case', '').replace('_case', ''), {})
100
+ cluster, detail = classify_one(res, case, text_idx, by_page)
101
+ counter[cluster] += 1
102
+ rows.append({'case': res.get('case'), 'status': status,
103
+ 'cluster': cluster, **detail,
104
+ 'title': case.get('title', ''), 'dur': res.get('dur')})
105
+
106
+ total = len(results)
107
+ passed = sum(1 for r in results if r.get('status') == 'PASS')
108
+ lines = [
109
+ f'# 失败分类报告 — {os.path.basename(args.results)}',
110
+ '',
111
+ f'总计 {total} 条:PASS {passed} / 失败 {len(rows)}',
112
+ '',
113
+ '## 簇分布',
114
+ '',
115
+ ]
116
+ for c, n in counter.most_common():
117
+ lines.append(f'- **{c}**: {n}')
118
+ lines += ['', '## 明细', '']
119
+ for r in rows:
120
+ gate = ' ⚠️门禁疑似(BLOCKED-GATE 候选,需 hilog 确认)' if r.get('gate_suspect') else ''
121
+ pages = f"(文本所在页: {', '.join(r['pages'])})" if r.get('pages') else ''
122
+ lines.append(f"- `{r['case']}` **{r['cluster']}**{gate} — {r.get('title','')}:{r.get('reason','')}"
123
+ + (f" `{r.get('text','')}`{pages}" if r.get('text') else ''))
124
+ lines += [
125
+ '',
126
+ '## 处置建议',
127
+ '',
128
+ '- DEVICE:环境问题,重跑即可,不计用例失败',
129
+ '- DESIGN-ERROR:回 cases.json 修正 target(引用 runtime_pages 中真实存在的文本)',
130
+ '- NAV-CONTEXT-LOSS:先 hdc hilog 查目标入口点击是否有路由事件——无事件即 EXEC-011 门禁,改判 BLOCKED-GATE;有事件则查 Tab 切换/back 链(EXEC-012)',
131
+ '- ASSERT-FAIL:看截图核状态,属数据/时序断言失败',
132
+ ]
133
+
134
+ report = '\n'.join(lines)
135
+ print(report)
136
+ if args.out:
137
+ with open(args.out, 'w', encoding='utf-8') as f:
138
+ f.write(report + '\n')
139
+ print(f'\n[written] {args.out}')
140
+ if args.reclassify:
141
+ out_path = re.sub(r'\.json$', '', args.results) + '.classified.json'
142
+ merged = []
143
+ rowmap = {r['case']: r for r in rows}
144
+ for res in results:
145
+ r = rowmap.get(res.get('case'))
146
+ if r:
147
+ res = dict(res, cluster=r['cluster'],
148
+ gate_suspect=bool(r.get('gate_suspect')))
149
+ merged.append(res)
150
+ with open(out_path, 'w', encoding='utf-8') as f:
151
+ json.dump(merged, f, ensure_ascii=False, indent=1)
152
+ print(f'[written] {out_path}')
153
+
154
+
155
+ if __name__ == '__main__':
156
+ main()
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """assert_audit.py — 断言强度审计(按场景测试目的选型,EXEC-045 修订)。
4
+ 功能交互类场景必须有效果断言(EFFECT_LEVEL);资源渲染类的 exist 合法——审计标记 vacuous
5
+ 的含义是"无任何值级断言",由评审结合场景类型判断是否无效(非一刀切反 exist)。
6
+
7
+ 扫描 cases.json,按「能否发现问题」给每条用例的断言分级评分:
8
+
9
+ 0 分(锚点级) exist —— 只证"页面活着",不能证"功能对"
10
+ 1 分(值级) equals/absent/text/text_contains/count/below/checked/
11
+ unchecked/enabled/disabled/toast
12
+ 特殊 无断言步骤(纯点击链)= 0 分
13
+
14
+ 判定:
15
+ strong 值级断言 >= 2 且覆盖至少 2 个不同维度(如 值+计数)
16
+ weak 值级断言 >= 1
17
+ vacuous 全部 exist 或无断言 —— 「断言强度不足」,评审红灯
18
+
19
+ 用法(.hypium_forge 目录):
20
+ python3 assert_audit.py [--cases cases.json] [--out assert_audit.md]
21
+ """
22
+ import argparse
23
+ import json
24
+ import os
25
+
26
+ VALUE_LEVEL = {'equals', 'absent', 'text', 'text_contains', 'count', 'below',
27
+ 'checked', 'unchecked', 'enabled', 'disabled', 'toast'}
28
+ # 效果断言(2026-09-29 合并强化):直接证明"功能发生了"的维度
29
+ EFFECT_LEVEL = {'text', 'text_contains', 'count', 'checked', 'unchecked',
30
+ 'absent', 'toast', 'below'}
31
+ DIMS = {
32
+ 'equals': '精确文本', 'absent': '反向', 'text': '值', 'text_contains': '值包含',
33
+ 'count': '计数', 'below': '几何', 'checked': '开关态', 'unchecked': '开关态',
34
+ 'enabled': '可用态', 'disabled': '可用态', 'toast': 'Toast',
35
+ }
36
+
37
+
38
+ def audit_case(case):
39
+ """返回 (score, dims, detail_rows)。"""
40
+ score, dims, rows = 0, set(), []
41
+ effect_flags = set()
42
+ for s in case.get('steps', []):
43
+ e = s.get('expect')
44
+ if not e:
45
+ rows.append((s.get('action', ''), '无断言(动作)', 0))
46
+ continue
47
+ if e in VALUE_LEVEL:
48
+ score += 1
49
+ dims.add(DIMS[e])
50
+ if e in EFFECT_LEVEL:
51
+ effect_flags.add(e)
52
+ rows.append((s.get('action', ''), e, 1))
53
+ else:
54
+ rows.append((s.get('action', ''), e, 0))
55
+ has_effect = any(e in EFFECT_LEVEL for e in effect_flags)
56
+ if score == 0:
57
+ verdict = 'vacuous'
58
+ elif not has_effect:
59
+ verdict = 'weak' # 有值级但无效果断言(纯存在性/可达组合)
60
+ elif score < 2 or len(dims) < 2:
61
+ verdict = 'weak'
62
+ else:
63
+ verdict = 'strong'
64
+ return score, dims, verdict, rows
65
+
66
+
67
+ def main():
68
+ ap = argparse.ArgumentParser()
69
+ ap.add_argument('--cases', default='cases.json')
70
+ ap.add_argument('--out', default=None)
71
+ args = ap.parse_args()
72
+ cases = json.load(open(args.cases, encoding='utf-8'))
73
+
74
+ stats = {'strong': [], 'weak': [], 'vacuous': []}
75
+ for c in cases:
76
+ score, dims, verdict, _ = audit_case(c)
77
+ stats[verdict].append((c.get('id'), score, sorted(dims)))
78
+
79
+ L = [f'# 断言强度审计 — {os.path.basename(os.getcwd())}', '',
80
+ '> 目标是发现问题:exist 只证"页面活着",值级断言才证"功能对"(EXEC-041)', '',
81
+ f'- strong(值级≥2 且跨≥2 维度): **{len(stats["strong"])}**',
82
+ f'- weak(值级=1): **{len(stats["weak"])}**',
83
+ f'- **vacuous(仅 exist/无断言——评审红灯): {len(stats["vacuous"])}**', '']
84
+
85
+ if stats['vacuous']:
86
+ L += ['## 🔴 断言强度不足(须补值级断言后才能过评审)', '']
87
+ for cid, sc, dm in stats['vacuous']:
88
+ L.append(f'- `{cid}`:0 个值级断言——全部 exist/纯点击')
89
+ L.append('')
90
+ if stats['weak']:
91
+ L += ['## 🟡 弱断言(单一值级)', '']
92
+ for cid, sc, dm in stats['weak']:
93
+ L.append(f'- `{cid}`:{sc} 分({", ".join(dm) or "无"})')
94
+ L.append('')
95
+ L += ['## ✅ 强断言', '']
96
+ for cid, sc, dm in stats['strong']:
97
+ L.append(f'- `{cid}`:{sc} 分({", ".join(dm)})')
98
+
99
+ out = '\n'.join(L)
100
+ print(out)
101
+ if args.out:
102
+ open(args.out, 'w', encoding='utf-8').write(out + '\n')
103
+ print(f'\n[written] {args.out}')
104
+
105
+
106
+ if __name__ == '__main__':
107
+ main()
@@ -0,0 +1,169 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ collect_runtime_pages.py — 运行时页面文本采集器(设计前置,消灭上下文假设错位)。
4
+
5
+ 流程: 启动 App → dump 首页 → 逐个点击底部 Tab → dump →
6
+ 对每个已 dump 页面提取「可点击文本」(clickable 或短文本按钮类)逐个浅层点击 → dump 新页面 → 返回。
7
+ 产出 runtime_pages.json: [{"key": "home|tab:我的|click:设置", "texts": [...], "hints": [...], "from": 父页key}]
8
+
9
+ 用法: collect_runtime_pages.py --device 127.0.0.1:5559 --bundle com.x [--max-pages 20] [--out runtime_pages.json]
10
+ """
11
+ import argparse
12
+ import json
13
+ import re
14
+ import subprocess
15
+ import time
16
+
17
+ from hypium import BY, MatchPattern
18
+
19
+ HDC = '/Applications/DevEco-Studio.app/Contents/sdk/default/openharmony/toolchains/hdc'
20
+
21
+
22
+ def dump_texts(sn, tag):
23
+ """hdc dumpLayout → (texts, clickable_texts, hints)。"""
24
+ dev = f'/data/local/tmp/rp_{tag}.json'
25
+ subprocess.run(['bash', '-c', f'{HDC} -t {sn} shell uitest dumpLayout -p {dev}'],
26
+ capture_output=True, timeout=30)
27
+ local = f'/tmp/rp_{tag}.json'
28
+ subprocess.run(['bash', '-c', f'{HDC} -t {sn} file recv {dev} {local}'],
29
+ capture_output=True, timeout=30)
30
+ try:
31
+ d = json.load(open(local, encoding='utf-8'))
32
+ except Exception:
33
+ return [], [], []
34
+ texts, clickables, hints = [], [], []
35
+
36
+ def walk(n):
37
+ a = n.get('attributes', {})
38
+ t = a.get('text')
39
+ if t:
40
+ ts = str(t).strip()
41
+ if ts and len(ts) < 20 and not re.match(r'^[\d:,.\s]+$', ts):
42
+ texts.append(ts)
43
+ if str(a.get('clickable')) == 'true':
44
+ clickables.append(ts)
45
+ if a.get('hint'):
46
+ hints.append(str(a['hint'])[:16])
47
+ for c in n.get('children', []) or []:
48
+ walk(c)
49
+
50
+ walk(d)
51
+ seen_t, seen_c = [], []
52
+ for t in texts:
53
+ if t not in seen_t:
54
+ seen_t.append(t)
55
+ for t in clickables:
56
+ if t not in seen_c:
57
+ seen_c.append(t)
58
+ return seen_t, seen_c, hints
59
+
60
+
61
+ def main():
62
+ ap = argparse.ArgumentParser()
63
+ ap.add_argument('--device', required=True)
64
+ ap.add_argument('--bundle', required=True)
65
+ ap.add_argument('--ability', default='EntryAbility')
66
+ ap.add_argument('--max-pages', type=int, default=18)
67
+ ap.add_argument('--out', default='runtime_pages.json')
68
+ args = ap.parse_args()
69
+
70
+ from hypium import UiDriver
71
+ d = UiDriver.connect(device_sn=args.device)
72
+ # 热启动(冷启动后 UI agent 短暂不可用,实测文本点击会静默失败)
73
+ d.start_app(args.bundle, args.ability)
74
+ for _ in range(20):
75
+ try:
76
+ if d.find_component(BY.text('首页')):
77
+ break
78
+ except Exception:
79
+ pass
80
+ time.sleep(1)
81
+ time.sleep(2)
82
+
83
+ pages = []
84
+ queue = [] # (key, clickable_texts) 待浅探
85
+
86
+ def cap(key):
87
+ texts, clicks, hints = dump_texts(args.device, abs(hash(key)) % 10000)
88
+ pages.append({'key': key, 'texts': texts[:40], 'clickables': clicks[:15], 'hints': hints[:5]})
89
+ print(f'[+] {key}: {len(texts)} 文本 / {len(clicks)} 可点', flush=True)
90
+ return clicks
91
+
92
+ clicks = cap('home')
93
+ queue.append(('home', clicks))
94
+
95
+ # 底部 Tab(取 home 可点文本中位于常见 Tab 词表的)
96
+ TABS = ['首页', '主页', '分类', '购物车', '我的', '预约', '课程', '发现']
97
+ if not clicks:
98
+ clicks = [t for t in pages[0]['texts'] if 2 <= len(t) <= 6]
99
+ pages[0]['clickables'] = clicks
100
+ tab_hits = [t for t in clicks if any(tw in t for tw in TABS)]
101
+ for t in tab_hits[:5]:
102
+ try:
103
+ click_text(d, t)
104
+ time.sleep(1.8)
105
+ key = f'tab:{t}'
106
+ c2 = cap(key)
107
+ queue.append((key, c2))
108
+ # 回 home 供下一个 tab 点击
109
+ try:
110
+ click_text(d, '首页')
111
+ except Exception:
112
+ d.press_back()
113
+ time.sleep(1.5)
114
+ except Exception as e:
115
+ print(f' [x] tab {t}: {str(e)[:60]}', flush=True)
116
+
117
+ # BFS 浅层:每页最多取 2 个入口点击(限页数)
118
+ visited_clicks = set()
119
+ while len(pages) < args.max_pages and queue:
120
+ pkey, clicks = queue.pop(0)
121
+ if not clicks:
122
+ pg = next((p for p in pages if p['key'] == pkey), None)
123
+ clicks = [t for t in (pg['texts'] if pg else []) if 2 <= len(t) <= 6]
124
+ n = 0
125
+ for t in clicks:
126
+ if len(pages) >= args.max_pages or n >= 2:
127
+ break
128
+ if t in visited_clicks or len(t) < 2 or t in ('返回', '确定', '取消'):
129
+ continue
130
+ visited_clicks.add(t)
131
+ try:
132
+ click_text(d, t)
133
+ time.sleep(1.8)
134
+ key = f'{pkey}>{t}'
135
+ c3 = cap(key)
136
+ queue.append((key, c3))
137
+ n += 1
138
+ except Exception:
139
+ continue
140
+ finally:
141
+ try:
142
+ d.press_back(); time.sleep(1.2)
143
+ except Exception:
144
+ pass
145
+
146
+ json.dump(pages, open(args.out, 'w', encoding='utf-8'), ensure_ascii=False, indent=1)
147
+ print(f'采集完成: {len(pages)} 页 → {args.out}')
148
+
149
+
150
+ def click_text(d, txt, wait=6):
151
+ import time as _t
152
+ deadline = _t.time() + wait
153
+ while _t.time() < deadline:
154
+ try:
155
+ comps = d.find_all_components(BY.text(txt, MatchPattern.CONTAINS))
156
+ if comps:
157
+ c = comps[-1]
158
+ b = c.getBounds()
159
+ d.touch(((b.left + b.right) // 2, (b.top + b.bottom) // 2))
160
+ _t.sleep(1.2)
161
+ return
162
+ except Exception:
163
+ pass
164
+ _t.sleep(0.4)
165
+ raise AssertionError('not clickable: ' + txt)
166
+
167
+
168
+ if __name__ == '__main__':
169
+ main()