@hupan56/wlkj 2.7.12 → 3.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 (253) hide show
  1. package/bin/cli.js +1435 -1107
  2. package/package.json +29 -29
  3. package/templates/qoder/agents/insight-planning.md +1 -1
  4. package/templates/qoder/agents/insight-research.md +28 -15
  5. package/templates/qoder/commands/optional/wl-insight.md +161 -161
  6. package/templates/qoder/commands/optional/wl-report.md +20 -39
  7. package/templates/qoder/commands/optional/wl-status.md +4 -4
  8. package/templates/qoder/commands/wl-code.md +2 -2
  9. package/templates/qoder/commands/wl-design.md +6 -6
  10. package/templates/qoder/commands/wl-init.md +3 -3
  11. package/templates/qoder/commands/wl-prd.md +26 -13
  12. package/templates/qoder/commands/wl-req.md +43 -0
  13. package/templates/qoder/commands/wl-search.md +78 -47
  14. package/templates/qoder/commands/wl-task.md +343 -40
  15. package/templates/qoder/commands/wl-test.md +43 -17
  16. package/templates/qoder/config.yaml +52 -14
  17. package/templates/qoder/hooks/post-tool-use.py +181 -0
  18. package/templates/qoder/hooks/session-start.py +94 -23
  19. package/templates/qoder/hooks/stop-eval.py +207 -0
  20. package/templates/qoder/hooks/user-prompt-submit.py +139 -0
  21. package/templates/qoder/rules/wl-pipeline.md +85 -49
  22. package/templates/qoder/scripts/README.md +102 -0
  23. package/templates/qoder/scripts/capability/__init__.py +26 -0
  24. package/templates/qoder/scripts/capability/__main__.py +72 -0
  25. package/templates/qoder/scripts/capability/adapters/__init__.py +29 -0
  26. package/templates/qoder/scripts/capability/adapters/cli.py +316 -0
  27. package/templates/qoder/scripts/capability/adapters/mcp.py +334 -0
  28. package/templates/qoder/scripts/capability/adapters/qw.py +271 -0
  29. package/templates/qoder/scripts/capability/caps/__init__.py +27 -0
  30. package/templates/qoder/scripts/capability/caps/context.py +36 -0
  31. package/templates/qoder/scripts/capability/caps/cron.py +50 -0
  32. package/templates/qoder/scripts/capability/caps/identity.py +43 -0
  33. package/templates/qoder/scripts/capability/caps/memory.py +71 -0
  34. package/templates/qoder/scripts/capability/caps/notify.py +41 -0
  35. package/templates/qoder/scripts/capability/caps/present.py +48 -0
  36. package/templates/qoder/scripts/capability/caps/repo.py +31 -0
  37. package/templates/qoder/scripts/capability/caps/sandbox.py +43 -0
  38. package/templates/qoder/scripts/capability/chain.py +92 -0
  39. package/templates/qoder/scripts/capability/memory_chain.py +41 -0
  40. package/templates/qoder/scripts/capability/registry.py +246 -0
  41. package/templates/qoder/scripts/capability/registry_mcp.py +250 -0
  42. package/templates/qoder/scripts/capability/smoke_test.py +211 -0
  43. package/templates/qoder/scripts/capability/smoke_test_report.json +94 -0
  44. package/templates/qoder/scripts/deployment/setup/__init__.py +11 -0
  45. package/templates/qoder/scripts/deployment/setup/carriers.py +669 -0
  46. package/templates/qoder/scripts/deployment/setup/carriers_verify.py +148 -0
  47. package/templates/qoder/scripts/{init_doctor.py → deployment/setup/init_doctor.py} +100 -39
  48. package/templates/qoder/scripts/{install_qoderwork.py → deployment/setup/install_qoderwork.py} +48 -30
  49. package/templates/qoder/scripts/{platform_doctor.py → deployment/setup/platform_doctor.py} +271 -259
  50. package/templates/qoder/scripts/{repo_root.py → deployment/setup/repo_root.py} +9 -2
  51. package/templates/qoder/scripts/{setup.py → deployment/setup/setup.py} +143 -14
  52. package/templates/qoder/scripts/{setup_lanhu.py → deployment/setup/setup_lanhu.py} +979 -963
  53. package/templates/qoder/scripts/domain/design/__init__.py +0 -0
  54. package/templates/qoder/scripts/{fill_prototype.py → domain/design/fill_prototype.py} +17 -5
  55. package/templates/qoder/scripts/{gen_design_doc.py → domain/design/gen_design_doc.py} +406 -394
  56. package/templates/qoder/scripts/domain/kg/__init__.py +11 -0
  57. package/templates/qoder/scripts/domain/kg/build/__init__.py +0 -0
  58. package/templates/qoder/scripts/domain/kg/build/build_entity_registry.py +201 -0
  59. package/templates/qoder/scripts/domain/kg/build/build_relations.py +132 -0
  60. package/templates/qoder/scripts/{build_style_index.py → domain/kg/build/build_style_index.py} +50 -15
  61. package/templates/qoder/scripts/domain/kg/build/build_workflows.py +149 -0
  62. package/templates/qoder/scripts/{kg_build.py → domain/kg/build/kg_build.py} +685 -612
  63. package/templates/qoder/scripts/{kg_build_db.py → domain/kg/build/kg_build_db.py} +338 -327
  64. package/templates/qoder/scripts/{kg_incremental.py → domain/kg/build/kg_incremental.py} +425 -393
  65. package/templates/qoder/scripts/{learn_aggregate.py → domain/kg/build/learn_aggregate.py} +212 -201
  66. package/templates/qoder/scripts/domain/kg/extract/__init__.py +0 -0
  67. package/templates/qoder/scripts/{common → domain/kg/extract}/extract.py +430 -419
  68. package/templates/qoder/scripts/domain/kg/extract/test_extract.py +115 -0
  69. package/templates/qoder/scripts/{common → domain/kg/extract}/ts_extract.py +614 -536
  70. package/templates/qoder/scripts/domain/kg/graph/__init__.py +0 -0
  71. package/templates/qoder/scripts/{kg_link_db.py → domain/kg/graph/kg_link_db.py} +235 -224
  72. package/templates/qoder/scripts/domain/kg/graph/kg_semantic.py +683 -0
  73. package/templates/qoder/scripts/{kg.py → domain/kg/kg.py} +871 -708
  74. package/templates/qoder/scripts/domain/kg/kg_capabilities.py +182 -0
  75. package/templates/qoder/scripts/domain/kg/search/__init__.py +0 -0
  76. package/templates/qoder/scripts/{context_pack.py → domain/kg/search/context_pack.py} +45 -17
  77. package/templates/qoder/scripts/{enrich_prompt.py → domain/kg/search/enrich_prompt.py} +238 -226
  78. package/templates/qoder/scripts/domain/kg/search/prefetch.py +384 -0
  79. package/templates/qoder/scripts/{common → domain/kg/search}/search_engine.py +207 -205
  80. package/templates/qoder/scripts/{search_index.py → domain/kg/search/search_index.py} +210 -88
  81. package/templates/qoder/scripts/domain/kg/server/__init__.py +0 -0
  82. package/templates/qoder/scripts/domain/kg/server/kgd.py +549 -0
  83. package/templates/qoder/scripts/domain/kg/server/perf_bench.py +197 -0
  84. package/templates/qoder/scripts/domain/kg/storage/__init__.py +0 -0
  85. package/templates/qoder/scripts/{kg_duckdb.py → domain/kg/storage/kg_duckdb.py} +70 -45
  86. package/templates/qoder/scripts/domain/learning/__init__.py +0 -0
  87. package/templates/qoder/scripts/{learn.py → domain/learning/learn.py} +157 -146
  88. package/templates/qoder/scripts/domain/report/__init__.py +11 -0
  89. package/templates/qoder/scripts/{add_session.py → domain/report/add_session.py} +256 -244
  90. package/templates/qoder/scripts/{export.py → domain/report/export.py} +72 -4
  91. package/templates/qoder/scripts/{report.py → domain/report/report.py} +292 -281
  92. package/templates/qoder/scripts/domain/report/report_snapshot.py +261 -0
  93. package/templates/qoder/scripts/domain/report/role.py +39 -0
  94. package/templates/qoder/scripts/{status.py → domain/report/status.py} +737 -628
  95. package/templates/qoder/scripts/domain/report/status_snapshot.py +191 -0
  96. package/templates/qoder/scripts/domain/requirement/__init__.py +0 -0
  97. package/templates/qoder/scripts/{archive_prd.py → domain/requirement/archive_prd.py} +389 -377
  98. package/templates/qoder/scripts/domain/requirement/req.py +228 -0
  99. package/templates/qoder/scripts/domain/task/__init__.py +11 -0
  100. package/templates/qoder/scripts/{git_sync.py → domain/task/git_sync.py} +90 -50
  101. package/templates/qoder/scripts/{syncgate.py → domain/task/syncgate.py} +18 -7
  102. package/templates/qoder/scripts/domain/task/task.py +229 -0
  103. package/templates/qoder/scripts/domain/task/task_lifecycle.py +606 -0
  104. package/templates/qoder/scripts/domain/task/task_query.py +162 -0
  105. package/templates/qoder/scripts/domain/task/task_relations.py +425 -0
  106. package/templates/qoder/scripts/{team_sync.py → domain/task/team_sync.py} +101 -23
  107. package/templates/qoder/scripts/domain/task/zentao_sync.py +370 -0
  108. package/templates/qoder/scripts/foundation/__init__.py +0 -0
  109. package/templates/qoder/scripts/foundation/bootstrap.py +145 -0
  110. package/templates/qoder/scripts/foundation/core/__init__.py +0 -0
  111. package/templates/qoder/scripts/foundation/core/cmd_registry.py +112 -0
  112. package/templates/qoder/scripts/foundation/core/config.py +187 -0
  113. package/templates/qoder/scripts/{common → foundation/core}/paths.py +706 -400
  114. package/templates/qoder/scripts/foundation/data/__init__.py +0 -0
  115. package/templates/qoder/scripts/foundation/data/contract.py +317 -0
  116. package/templates/qoder/scripts/{common → foundation/data}/events.py +2 -2
  117. package/templates/qoder/scripts/foundation/data/result.py +223 -0
  118. package/templates/qoder/scripts/{common → foundation/data}/task_utils.py +427 -392
  119. package/templates/qoder/scripts/foundation/identity/__init__.py +0 -0
  120. package/templates/qoder/scripts/foundation/identity/check_publish.py +103 -0
  121. package/templates/qoder/scripts/{common → foundation/identity}/developer.py +239 -238
  122. package/templates/qoder/scripts/foundation/identity/guard.py +159 -0
  123. package/templates/qoder/scripts/{common → foundation/identity}/identity.py +132 -9
  124. package/templates/qoder/scripts/foundation/identity/roles.py +60 -0
  125. package/templates/qoder/scripts/foundation/integrations/__init__.py +0 -0
  126. package/templates/qoder/scripts/{common → foundation/integrations}/active_task.py +30 -15
  127. package/templates/qoder/scripts/{common → foundation/integrations}/feishu.py +11 -10
  128. package/templates/qoder/scripts/{common → foundation/integrations}/terms.py +3 -3
  129. package/templates/qoder/scripts/foundation/integrations/zentao_client.py +220 -0
  130. package/templates/qoder/scripts/foundation/io/__init__.py +0 -0
  131. package/templates/qoder/scripts/{common → foundation/io}/filelock.py +1 -1
  132. package/templates/qoder/scripts/{common → foundation/io}/reqid.py +1 -1
  133. package/templates/qoder/scripts/foundation/protocol/__init__.py +0 -0
  134. package/templates/qoder/scripts/foundation/protocol/duckdb_conn.py +39 -0
  135. package/templates/qoder/scripts/foundation/protocol/mcp_base.py +268 -0
  136. package/templates/qoder/scripts/foundation/utils/__init__.py +0 -0
  137. package/templates/qoder/scripts/{common → foundation/utils}/eval_api.py +12 -1
  138. package/templates/qoder/scripts/{common → foundation/utils}/pip_install.py +23 -6
  139. package/templates/qoder/scripts/{common → foundation/utils}/platform_guard.py +1 -1
  140. package/templates/qoder/scripts/orchestration/__init__.py +0 -0
  141. package/templates/qoder/scripts/orchestration/wlkj.py +185 -0
  142. package/templates/qoder/scripts/protocol/__init__.py +0 -0
  143. package/templates/qoder/scripts/protocol/browser/README.md +207 -0
  144. package/templates/qoder/scripts/protocol/browser/SKILL.md +265 -0
  145. package/templates/qoder/scripts/protocol/browser/config.env +9 -0
  146. package/templates/qoder/scripts/protocol/browser/references/cdp-api.md +110 -0
  147. package/templates/qoder/scripts/protocol/browser/references/migration-2.5.3.md +72 -0
  148. package/templates/qoder/scripts/protocol/browser/references/site-patterns/.gitkeep +0 -0
  149. package/templates/qoder/scripts/protocol/browser/scripts/browser-discovery.mjs +138 -0
  150. package/templates/qoder/scripts/protocol/browser/scripts/cdp-proxy.mjs +672 -0
  151. package/templates/qoder/scripts/protocol/browser/scripts/check-deps.mjs +206 -0
  152. package/templates/qoder/scripts/protocol/browser/scripts/find-url.mjs +253 -0
  153. package/templates/qoder/scripts/protocol/browser/scripts/match-site.mjs +46 -0
  154. package/templates/qoder/scripts/protocol/browser/templates/config.env.template +9 -0
  155. package/templates/qoder/scripts/protocol/mcp/__init__.py +11 -0
  156. package/templates/qoder/scripts/protocol/mcp/kg_mcp_server.py +478 -0
  157. package/templates/qoder/scripts/{lanhu_stdio_wrapper.py → protocol/mcp/lanhu_stdio_wrapper.py} +131 -119
  158. package/templates/qoder/scripts/protocol/mcp/mcp_doctor.py +548 -0
  159. package/templates/qoder/scripts/protocol/mcp/mcp_launcher.py +600 -0
  160. package/templates/qoder/scripts/protocol/mcp/mysql_mcp_server.py +461 -0
  161. package/templates/qoder/scripts/protocol/mcp/zentao_mcp_server.py +1920 -0
  162. package/templates/qoder/scripts/protocol/transports/__init__.py +56 -0
  163. package/templates/qoder/scripts/protocol/transports/base.py +87 -0
  164. package/templates/qoder/scripts/protocol/transports/cli.py +132 -0
  165. package/templates/qoder/scripts/protocol/transports/http.py +141 -0
  166. package/templates/qoder/scripts/protocol/transports/stdio.py +293 -0
  167. package/templates/qoder/scripts/run_weekly_update.bat +27 -18
  168. package/templates/qoder/scripts/run_weekly_update.sh +22 -13
  169. package/templates/qoder/scripts/validation/__init__.py +0 -0
  170. package/templates/qoder/scripts/validation/eval/__init__.py +0 -0
  171. package/templates/qoder/scripts/validation/eval/perf-report-2026-06-24.md +139 -0
  172. package/templates/qoder/scripts/validation/eval/qwork_harness.py +617 -0
  173. package/templates/qoder/scripts/validation/eval/report-commands.md +220 -0
  174. package/templates/qoder/scripts/validation/eval/transcript_timing.py +386 -0
  175. package/templates/qoder/scripts/validation/metrics/__init__.py +0 -0
  176. package/templates/qoder/scripts/{eval_prd.py → validation/metrics/eval_prd.py} +84 -15
  177. package/templates/qoder/scripts/validation/metrics/usability_score.py +750 -0
  178. package/templates/qoder/scripts/validation/test/__init__.py +11 -0
  179. package/templates/qoder/scripts/validation/test/assertion_gen.py +551 -0
  180. package/templates/qoder/scripts/{autotest.py → validation/test/autotest.py} +1234 -1751
  181. package/templates/qoder/scripts/validation/test/autotest_auth.py +109 -0
  182. package/templates/qoder/scripts/{autotest_batch.py → validation/test/autotest_batch.py} +255 -224
  183. package/templates/qoder/scripts/validation/test/autotest_data.py +680 -0
  184. package/templates/qoder/scripts/validation/test/autotest_opencli.py +646 -0
  185. package/templates/qoder/scripts/{autotest_run.py → validation/test/autotest_run.py} +323 -297
  186. package/templates/qoder/scripts/validation/test/autotest_webaccess.py +571 -0
  187. package/templates/qoder/scripts/{benchmark.py → validation/test/benchmark.py} +17 -5
  188. package/templates/qoder/scripts/{kg_auto_login.py → validation/test/kg_auto_login.py} +208 -196
  189. package/templates/qoder/scripts/{kg_test_runner.py → validation/test/kg_test_runner.py} +13 -2
  190. package/templates/qoder/scripts/{page_probe.py → validation/test/page_probe.py} +470 -459
  191. package/templates/qoder/scripts/validation/test/smoke_all_commands.py +170 -0
  192. package/templates/qoder/settings.json +23 -2
  193. package/templates/qoder/skills/design-import/SKILL.md +235 -226
  194. package/templates/qoder/skills/design-review/SKILL.md +91 -82
  195. package/templates/qoder/skills/prd-generator/SKILL.md +38 -24
  196. package/templates/qoder/skills/prd-review/SKILL.md +8 -6
  197. package/templates/qoder/skills/prototype-generator/SKILL.md +296 -247
  198. package/templates/qoder/skills/spec-coder/SKILL.md +24 -15
  199. package/templates/qoder/skills/spec-generator/SKILL.md +24 -12
  200. package/templates/qoder/skills/test-generator/SKILL.md +9 -7
  201. package/templates/qoder/skills/wl-code/SKILL.md +25 -13
  202. package/templates/qoder/skills/wl-commit/SKILL.md +10 -9
  203. package/templates/qoder/skills/wl-design/SKILL.md +7 -5
  204. package/templates/qoder/skills/wl-init/SKILL.md +8 -8
  205. package/templates/qoder/skills/wl-insight/SKILL.md +25 -12
  206. package/templates/qoder/skills/wl-prd-full/SKILL.md +59 -8
  207. package/templates/qoder/skills/wl-prd-quick/SKILL.md +7 -7
  208. package/templates/qoder/skills/wl-prd-review/SKILL.md +18 -6
  209. package/templates/qoder/skills/wl-report/SKILL.md +27 -25
  210. package/templates/qoder/skills/wl-search/SKILL.md +151 -80
  211. package/templates/qoder/skills/wl-spec/SKILL.md +9 -7
  212. package/templates/qoder/skills/wl-status/SKILL.md +50 -18
  213. package/templates/qoder/skills/wl-task/SKILL.md +94 -12
  214. package/templates/qoder/skills/wl-test/SKILL.md +154 -45
  215. package/templates/qoder/templates/prd-full-template.md +7 -0
  216. package/templates/root/AGENTS.md +275 -237
  217. package/templates/root/requirements.txt +3 -0
  218. package/templates/root//344/275/277/347/224/250/350/257/264/346/230/216.md +3 -3
  219. package/templates/root//346/226/260/346/211/213/346/214/207/345/215/227.md +2 -2
  220. package/templates/qoder/hooks/inject-workflow-state.py +0 -169
  221. package/templates/qoder/scripts/__pycache__/check_mcp_launch.cpython-39.pyc +0 -0
  222. package/templates/qoder/scripts/__pycache__/install_qoderwork.cpython-39.pyc +0 -0
  223. package/templates/qoder/scripts/__pycache__/mcp_launcher.cpython-39.pyc +0 -0
  224. package/templates/qoder/scripts/__pycache__/platform_doctor.cpython-39.pyc +0 -0
  225. package/templates/qoder/scripts/check_carriers.py +0 -238
  226. package/templates/qoder/scripts/check_mcp.py +0 -298
  227. package/templates/qoder/scripts/check_mcp_launch.py +0 -183
  228. package/templates/qoder/scripts/check_qoderwork_consistency.py +0 -166
  229. package/templates/qoder/scripts/collect_prds.py +0 -31
  230. package/templates/qoder/scripts/common/mentions.py +0 -134
  231. package/templates/qoder/scripts/common/utf8.py +0 -38
  232. package/templates/qoder/scripts/extract_api_params.py +0 -246
  233. package/templates/qoder/scripts/extract_routes.py +0 -54
  234. package/templates/qoder/scripts/extract_routes_tree.py +0 -78
  235. package/templates/qoder/scripts/handoff.py +0 -22
  236. package/templates/qoder/scripts/init_developer.py +0 -76
  237. package/templates/qoder/scripts/kg_mcp_server.py +0 -801
  238. package/templates/qoder/scripts/kg_semantic.py +0 -150
  239. package/templates/qoder/scripts/mcp_launcher.py +0 -414
  240. package/templates/qoder/scripts/mysql_mcp_server.py +0 -396
  241. package/templates/qoder/scripts/parse_prds.py +0 -33
  242. package/templates/qoder/scripts/role.py +0 -51
  243. package/templates/qoder/scripts/sync_carriers.py +0 -259
  244. package/templates/qoder/scripts/task.py +0 -1261
  245. package/templates/qoder/scripts/workspace_init.py +0 -102
  246. package/templates/qoder/scripts/zentao_mcp_server.py +0 -424
  247. package/templates/qoder/skills/prompt-enrich/SKILL.md +0 -90
  248. /package/templates/qoder/scripts/{common → deployment}/__init__.py +0 -0
  249. /package/templates/qoder/{skills/prototype-generator/SKILL.md.zcode-79180-2af4721f-f9a6-412c-88db-c0af680d211b.tmp → scripts/domain/__init__.py} +0 -0
  250. /package/templates/qoder/scripts/{common → domain/kg/graph}/graph_traverse.py +0 -0
  251. /package/templates/qoder/scripts/{common → domain/kg/server}/repowiki.py +0 -0
  252. /package/templates/qoder/scripts/{common → foundation/io}/atomicio.py +0 -0
  253. /package/templates/qoder/scripts/{secure-ls.js → validation/test/secure-ls.js} +0 -0
@@ -0,0 +1,109 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ autotest_auth.py - 自动化测试登录态 (单一真源, 消除 3 处重复)
4
+
5
+ 现状: get_token / read_developer / AUTH_FILE 在三个文件各自重复:
6
+ - autotest.py (用 common.config 读 dev)
7
+ - autotest_run.py:42-56 (读 token + 校验 + 失效重登)
8
+ - autotest_batch.py:22-45 (read_developer + login_api + get_token)
9
+ 三份逻辑基本一致 (读 auth-state.json 的 access_token, 失效调 kg_auto_login),
10
+ 但细节差异 (校验 URL/timeout/headers) 容易分叉。
11
+
12
+ 本模块作为单一真源, 三个文件共用。
13
+
14
+ 核心 API:
15
+ get_developer() -> str 读 .qoder/.developer
16
+ auth_file(dev=None) -> str 成员的 auth-state.json 路径
17
+ get_token(dev=None, base_url=None) -> str|None 读 token, 失效重登
18
+
19
+ Usage:
20
+ from test.autotest_auth import get_token, get_developer
21
+ tok = get_token() # 自动读当前开发者, token 失效自动调 kg_auto_login
22
+ """
23
+
24
+ import json
25
+ import os
26
+ import subprocess
27
+ import sys
28
+
29
+ __all__ = ["get_developer", "auth_file", "get_token", "MEMBER_DIR"]
30
+
31
+ # 相对导入
32
+ try:
33
+ from .paths import get_repo_root
34
+ except ImportError:
35
+ _COMMON_DIR = os.path.dirname(os.path.abspath(__file__))
36
+ if _COMMON_DIR not in sys.path:
37
+ sys.path.insert(0, _COMMON_DIR)
38
+ from paths import get_repo_root # type: ignore
39
+
40
+
41
+ def get_developer() -> str:
42
+ """读当前开发者名 (.qoder/.developer)。未初始化抛 RuntimeError。"""
43
+ dev_file = os.path.join(str(get_repo_root()), '.qoder', '.developer')
44
+ if os.path.isfile(dev_file):
45
+ for ln in open(dev_file, encoding='utf-8'):
46
+ if ln.strip().startswith('name='):
47
+ return ln.strip().split('=', 1)[1].strip()
48
+ raise RuntimeError('未检测到开发者身份(.qoder/.developer)。请先运行 /wl-init。')
49
+
50
+
51
+ def _member_dir(dev: str = None) -> str:
52
+ dev = dev or get_developer()
53
+ return os.path.join(str(get_repo_root()), 'workspace', 'members', dev)
54
+
55
+
56
+ MEMBER_DIR = _member_dir # 模块级便捷别名 (首次 import 时解析)
57
+
58
+
59
+ def auth_file(dev: str = None) -> str:
60
+ """成员的 auth-state.json 路径 (v3.0: 收进 .private/, 向后兼容)。"""
61
+ try:
62
+ from .paths import get_private_file
63
+ return str(get_private_file('auth-state.json', dev))
64
+ except Exception:
65
+ return os.path.join(_member_dir(dev), 'auth-state.json')
66
+
67
+
68
+ def get_token(dev: str = None, base_url: str = None) -> str:
69
+ """读 access_token, 失效则调 kg_auto_login.py 重登。
70
+
71
+ Args:
72
+ dev: 开发者名 (默认当前)
73
+ base_url: 校验 token 用的 base_url (默认用 QAS 测试环境)
74
+ Returns:
75
+ token 字符串, 或 None (重登后仍无)
76
+ """
77
+ af = auth_file(dev)
78
+ base = base_url or 'https://icsqas.inforecloud.com'
79
+
80
+ def _read_tok():
81
+ if os.path.exists(af):
82
+ try:
83
+ return json.load(open(af, encoding='utf-8')).get('access_token')
84
+ except Exception:
85
+ return None
86
+ return None
87
+
88
+ tok = _read_tok()
89
+ if tok:
90
+ # 校验 token 是否有效
91
+ try:
92
+ import requests
93
+ r = requests.get(
94
+ base + '/test-api/system/user/getInfo',
95
+ headers={'Authorization': 'Bearer ' + tok, 'clientid': 'e5cd7e4891bf95d1d19206ce24a7b32e'},
96
+ timeout=8,
97
+ )
98
+ if r.json().get('code') == 200:
99
+ return tok
100
+ except Exception:
101
+ pass # 校验失败 → 重登
102
+
103
+ # 重新登录
104
+ scripts_dir = os.path.join(str(get_repo_root()), '.qoder', 'scripts')
105
+ subprocess.run(
106
+ [sys.executable, os.path.join(scripts_dir, 'kg_auto_login.py')],
107
+ capture_output=True, timeout=120,
108
+ )
109
+ return _read_tok()
@@ -1,224 +1,255 @@
1
- # -*- coding: utf-8 -*-
2
- """autotest_batch.py 批量页面测试执行器 (一个浏览器会话串测N页).
3
- 用法: python autotest_batch.py <batch_file> [--out result.json]
4
- batch_file: JSON [{page_path, name, component}, ...] (page_path 用准确路由!)
5
-
6
- 与上轮 _batch_test.py 的关键区别:
7
- URL 来自 menu-routes-tree.json (准确父子树), 不是 component 猜
8
- 404 判定加强: 识别 "未找到页面/哎呀/404/Not Found/页面不存在" 多种文案
9
- ③ 真验页面加载: 区分 OK(有数据) / LOADED(无表格但非404) / 404 / SESSION_EXPIRED / EMPTY(空白)
10
- """
11
- import json, sys, time, os, re, argparse, subprocess
12
- from playwright.sync_api import sync_playwright
13
-
14
- BASE = 'https://icsqas.inforecloud.com'
15
- ACCESS_KEY = 'ICS-1.3.8-prod-core-access'
16
- SECRET = 'please-replace-me-with-your-own-key'
17
- SCRIPTS = os.path.dirname(os.path.abspath(__file__))
18
- PROJECT = os.path.normpath(os.path.join(SCRIPTS, '..', '..'))
19
- SECURELS_JS = os.path.join(SCRIPTS, 'secure-ls.js')
20
-
21
-
22
- def read_developer():
23
- f = os.path.join(PROJECT, '.qoder', '.developer')
24
- for ln in open(f, encoding='utf-8'):
25
- if ln.strip().startswith('name='):
26
- return ln.strip().split('=', 1)[1].strip()
27
- raise RuntimeError('无 .developer')
28
-
29
-
30
- DEV = read_developer()
31
- AUTH_FILE = os.path.join(PROJECT, 'workspace', 'members', DEV, 'auth-state.json')
32
-
33
-
34
- def login_api():
35
- """调 kg_auto_login.py 重新登录."""
36
- subprocess.run([sys.executable, os.path.join(SCRIPTS, 'kg_auto_login.py')],
37
- capture_output=True, timeout=180)
38
- if os.path.exists(AUTH_FILE):
39
- return json.load(open(AUTH_FILE, encoding='utf-8')).get('access_token')
40
- return None
41
-
42
-
43
- def get_token():
44
- if os.path.exists(AUTH_FILE):
45
- tok = json.load(open(AUTH_FILE, encoding='utf-8')).get('access_token')
46
- import requests
47
- try:
48
- r = requests.get(BASE + '/test-api/system/user/getInfo',
49
- headers={'Authorization': 'Bearer ' + tok, 'clientid': 'e5cd7e4891bf95d1d19206ce24a7b32e'}, timeout=8)
50
- if r.json().get('code') == 200:
51
- return tok
52
- except Exception:
53
- pass
54
- return login_api()
55
-
56
-
57
- def inject_token(page, token):
58
- page.goto(BASE + '/auth/login', wait_until='domcontentloaded'); time.sleep(1.2)
59
- page.add_script_tag(path=SECURELS_JS); time.sleep(0.3)
60
- page.evaluate('''(a)=>{const[t,k,s]=a;const S=window.SecureLS.default||window.SecureLS;const ls=new S({encodingType:'aes',isCompression:true,encryptionSecret:s});ls.set(k,{accessToken:t,refreshToken:t,accessCodes:[],originMenus:[],webMenus:[],accessMenus:[],accessRoutes:[],isAccessChecked:false,loginExpired:false});}''', [token, ACCESS_KEY, SECRET])
61
-
62
-
63
- def wait_menu_ready(page, secs=15):
64
- page.goto(BASE + '/dashboard/workspace', wait_until='domcontentloaded')
65
- for _ in range(secs):
66
- if '加载菜单中' not in page.evaluate('()=>document.body.innerText'):
67
- return True
68
- time.sleep(1)
69
- return False
70
-
71
-
72
- # 404 文案清单 (实测 ICS 的 404 页文案)
73
- NOT_FOUND_MARKERS = ['未找到页面', '哎呀', '404', 'Not Found', '页面不存在', '无法找到', 'page not found', '找不到页面']
74
-
75
-
76
- def is_not_found(body):
77
- b = body[:500]
78
- return any(m in b for m in NOT_FOUND_MARKERS)
79
-
80
-
81
- def collect_anchors(page):
82
- try:
83
- els = page.evaluate('''()=>{const out=[];document.querySelectorAll('button,.ant-tabs-tab,input[placeholder],input[name],[role=tab]').forEach(el=>{const name=(el.innerText||el.getAttribute('placeholder')||el.getAttribute('name')||'').trim();const role=el.getAttribute('role')||(el.tagName==='INPUT'?'textbox':el.tagName.toLowerCase());if(name&&name.length<25&&!/^[0-9\s]+$/.test(name))out.push({role,name});});return out.slice(0,30);}''')
84
- seen = set(); uniq = []
85
- for el in els:
86
- k = (el['role'], el['name'])
87
- if k not in seen:
88
- seen.add(k); uniq.append(el)
89
- return uniq
90
- except Exception:
91
- return []
92
-
93
-
94
- def test_page(page, item, shot_dir):
95
- """测单页. status: OK/LOADED/404/SESSION_EXPIRED/EMPTY/ERROR
96
- 每页截 1 张图存 shot_dir, 结果带 screenshot 路径 + body 全文(留证据)."""
97
- path = item['page_path']
98
- res = {'page': path, 'name': item.get('name', ''), 'status': 'UNKNOWN', 'rows': 0, 'anchors': [], 'screenshot': '', 'body': ''}
99
- try:
100
- page.goto(BASE + path, wait_until='domcontentloaded', timeout=12000)
101
- time.sleep(2)
102
- cur = page.url
103
- if '/auth/login' in cur:
104
- res['status'] = 'SESSION_EXPIRED'; return res
105
- body = page.evaluate('()=>document.body.innerText.slice(0,1500)')
106
- res['body'] = body
107
- if is_not_found(body):
108
- res['status'] = '404'; res['body_head'] = body[:80].replace('\n', ' ')
109
- res['screenshot'] = _shot(page, shot_dir, path, '404'); return res
110
- # 等表格加载
111
- rows = 0
112
- for _ in range(8):
113
- rows = page.locator('.vxe-body--row').count()
114
- if rows > 0:
115
- break
116
- time.sleep(0.5)
117
- res['rows'] = rows
118
- if rows > 0:
119
- res['status'] = 'OK'
120
- elif len([l for l in body.split('\n') if l.strip()]) > 3:
121
- res['status'] = 'LOADED'
122
- else:
123
- res['status'] = 'EMPTY'
124
- res['anchors'] = collect_anchors(page)
125
- res['screenshot'] = _shot(page, shot_dir, path, res['status'])
126
- except Exception as e:
127
- res['status'] = 'ERROR'; res['error'] = str(e)[:80]
128
- try: res['screenshot'] = _shot(page, shot_dir, path, 'ERROR')
129
- except Exception: pass
130
- return res
131
-
132
-
133
- def _shot(page, shot_dir, path, tag):
134
- """截图存 PNG, 返回文件名. 命名: tag_域名简写.png (去特殊字符)."""
135
- import re as _re
136
- safe = _re.sub(r'[^a-zA-Z0-9\u4e00-\u9fa5]', '_', path).strip('_')[-40:]
137
- fn = '%s_%s.png' % (tag, safe)
138
- fp = os.path.join(shot_dir, fn)
139
- try:
140
- page.screenshot(path=fp, full_page=False)
141
- return fn
142
- except Exception:
143
- return ''
144
-
145
-
146
- def sediment(page_path, anchors):
147
- if not anchors:
148
- return
149
- try:
150
- pj = json.dumps({'url': page_path, 'elements': [
151
- {'role': a['role'], 'name': a['name'], 'desc': a['name']} for a in anchors
152
- ]}, ensure_ascii=False)
153
- subprocess.run([sys.executable, os.path.join(SCRIPTS, 'autotest.py'), 'learn',
154
- '--url', page_path, '--page-json', pj], capture_output=True, timeout=20)
155
- except Exception:
156
- pass
157
-
158
-
159
- def main():
160
- ap = argparse.ArgumentParser()
161
- ap.add_argument('batch_file')
162
- ap.add_argument('--out', default='')
163
- args = ap.parse_args()
164
- batch = json.load(open(args.batch_file, encoding='utf-8'))
165
- print('批量测试 %d (URL来源: 准确路由树)' % len(batch))
166
-
167
- TOKEN = get_token()
168
- if not TOKEN:
169
- print('LOGIN_FAIL'); sys.exit(1)
170
- print('token: ok (dev=%s)' % DEV)
171
-
172
- results = []
173
- shot_dir = os.path.join(os.path.dirname(args.batch_file), 'screenshots')
174
- os.makedirs(shot_dir, exist_ok=True)
175
- with sync_playwright() as pw:
176
- b = pw.chromium.launch(headless=True)
177
- page = b.new_context(locale='zh-CN', viewport={'width': 1440, 'height': 900}).new_page()
178
- page.set_default_timeout(12000)
179
- try:
180
- inject_token(page, TOKEN)
181
- ready = wait_menu_ready(page)
182
- print('SPA初始化:', '菜单就绪' if ready else '菜单未就绪(继续)')
183
- except Exception as e:
184
- print('INIT_FAIL:', str(e)[:80]); b.close(); sys.exit(1)
185
-
186
- for i, item in enumerate(batch):
187
- res = test_page(page, item, shot_dir)
188
- results.append(res)
189
- anc_n = len(res.get('anchors', []))
190
- print(' [%d/%d] %-14s %-50s rows=%-3d anc=%d %s' % (
191
- i + 1, len(batch), res['status'], res['page'][:48], res.get('rows', 0), anc_n,
192
- res.get('body_head', res.get('error', ''))[:30]))
193
- sediment(res['page'], res.get('anchors', []))
194
- if res['status'] == 'SESSION_EXPIRED':
195
- print(' session过期, 重新登录...')
196
- TOKEN = login_api()
197
- if TOKEN:
198
- inject_token(page, TOKEN); wait_menu_ready(page, 8)
199
- b.close()
200
-
201
- from collections import Counter
202
- c = Counter(r['status'] for r in results)
203
- print()
204
- print('=== 汇总 ===')
205
- for s, n in c.most_common():
206
- print(' %-14s %d' % (s, n))
207
- total_anchors = sum(len(r.get('anchors', [])) for r in results)
208
- print(' 沉淀锚点: %d' % total_anchors)
209
- # 列出所有 404/ERROR
210
- problems = [r for r in results if r['status'] in ('404', 'ERROR', 'EMPTY', 'SESSION_EXPIRED')]
211
- if problems:
212
- print()
213
- print('=== 异常页面 (%d) ===' % len(problems))
214
- for r in problems:
215
- print(' [%s] %s %s' % (r['status'], r['page'][:50], r.get('body_head', r.get('error', ''))[:30]))
216
-
217
- out_file = args.out or args.batch_file.replace('.json', '_result.json')
218
- json.dump(results, open(out_file, 'w', encoding='utf-8'), ensure_ascii=False, indent=2)
219
- print()
220
- print('结果: %s' % out_file)
221
-
222
-
223
- if __name__ == '__main__':
224
- main()
1
+ # -*- coding: utf-8 -*-
2
+ # v3.0 路径自举: 引导到 common/bootstrap, 统一 sys.path 逻辑
3
+ import os as _o, sys as _s
4
+ _f = _o.path.abspath(__file__)
5
+ for _ in range(10):
6
+ _f = _o.path.dirname(_f)
7
+ _cp = _o.path.join(_f, 'foundation')
8
+ if _o.path.isfile(_o.path.join(_cp, 'bootstrap.py')):
9
+ break
10
+ if _cp not in _s.path: _s.path.insert(0, _cp)
11
+ from bootstrap import setup; setup()
12
+
13
+ """autotest_batch.py — 批量页面测试执行器 (一个浏览器会话串测N页).
14
+ 用法: python autotest_batch.py <batch_file> [--out result.json]
15
+ batch_file: JSON [{page_path, name, component}, ...] (page_path 用准确路由!)
16
+
17
+ 与上轮 _batch_test.py 的关键区别:
18
+ URL 来自 menu-routes-tree.json (准确父子树), 不是 component 猜
19
+ 404 判定加强: 识别 "未找到页面/哎呀/404/Not Found/页面不存在" 多种文案
20
+ ③ 真验页面加载: 区分 OK(有数据) / LOADED(无表格但非404) / 404 / SESSION_EXPIRED / EMPTY(空白)
21
+ """
22
+ import json, sys, time, os, re, argparse, subprocess
23
+ from playwright.sync_api import sync_playwright
24
+
25
+ BASE = 'https://icsqas.inforecloud.com'
26
+ ACCESS_KEY = 'ICS-1.3.8-prod-core-access'
27
+ SECRET = 'please-replace-me-with-your-own-key'
28
+ SCRIPTS = os.path.dirname(os.path.abspath(__file__))
29
+ # test 目录在 .qoder/scripts/test, 回项目根要上 3 级. 向上找含 .qoder 的目录兜底.
30
+ _d = SCRIPTS
31
+ while _d != os.path.dirname(_d):
32
+ if os.path.isdir(os.path.join(_d, '.qoder')):
33
+ PROJECT = _d
34
+ break
35
+ _d = os.path.dirname(_d)
36
+ else:
37
+ PROJECT = os.path.normpath(os.path.join(SCRIPTS, '..', '..', '..'))
38
+ SECURELS_JS = os.path.join(SCRIPTS, 'secure-ls.js')
39
+
40
+
41
+ # v3.0: 登录态从 common.autotest_auth 复用 (单一真源, 消除 3 处重复)
42
+ sys.path.insert(0, SCRIPTS)
43
+ try:
44
+ from test.autotest_auth import get_developer as read_developer, auth_file, get_token as _get_token_shared
45
+
46
+ DEV = read_developer()
47
+ AUTH_FILE = auth_file(DEV)
48
+
49
+ def login_api():
50
+ """调 kg_auto_login.py 重新登录 (转发到 autotest_auth.get_token)."""
51
+ return _get_token_shared(dev=DEV, base_url=BASE)
52
+
53
+ def get_token():
54
+ return _get_token_shared(dev=DEV, base_url=BASE)
55
+ except Exception:
56
+ # 回退: 内联逻辑
57
+ def read_developer():
58
+ f = os.path.join(PROJECT, '.qoder', '.developer')
59
+ for ln in open(f, encoding='utf-8'):
60
+ if ln.strip().startswith('name='):
61
+ return ln.strip().split('=', 1)[1].strip()
62
+ raise RuntimeError('无 .developer')
63
+
64
+ DEV = read_developer()
65
+ AUTH_FILE = os.path.join(PROJECT, 'workspace', 'members', DEV, 'auth-state.json')
66
+
67
+ def login_api():
68
+ subprocess.run([sys.executable, os.path.join(SCRIPTS, 'kg_auto_login.py')],
69
+ capture_output=True, timeout=180)
70
+ if os.path.exists(AUTH_FILE):
71
+ return json.load(open(AUTH_FILE, encoding='utf-8')).get('access_token')
72
+ return None
73
+
74
+ def get_token():
75
+ if os.path.exists(AUTH_FILE):
76
+ tok = json.load(open(AUTH_FILE, encoding='utf-8')).get('access_token')
77
+ import requests
78
+ try:
79
+ r = requests.get(BASE + '/test-api/system/user/getInfo',
80
+ headers={'Authorization': 'Bearer ' + tok, 'clientid': 'e5cd7e4891bf95d1d19206ce24a7b32e'}, timeout=8)
81
+ if r.json().get('code') == 200:
82
+ return tok
83
+ except Exception:
84
+ pass
85
+ return login_api()
86
+
87
+
88
+ def inject_token(page, token):
89
+ page.goto(BASE + '/auth/login', wait_until='domcontentloaded'); time.sleep(1.2)
90
+ page.add_script_tag(path=SECURELS_JS); time.sleep(0.3)
91
+ page.evaluate('''(a)=>{const[t,k,s]=a;const S=window.SecureLS.default||window.SecureLS;const ls=new S({encodingType:'aes',isCompression:true,encryptionSecret:s});ls.set(k,{accessToken:t,refreshToken:t,accessCodes:[],originMenus:[],webMenus:[],accessMenus:[],accessRoutes:[],isAccessChecked:false,loginExpired:false});}''', [token, ACCESS_KEY, SECRET])
92
+
93
+
94
+ def wait_menu_ready(page, secs=15):
95
+ page.goto(BASE + '/dashboard/workspace', wait_until='domcontentloaded')
96
+ for _ in range(secs):
97
+ if '加载菜单中' not in page.evaluate('()=>document.body.innerText'):
98
+ return True
99
+ time.sleep(1)
100
+ return False
101
+
102
+
103
+ # 404 文案清单 (实测 ICS 的 404 页文案)
104
+ NOT_FOUND_MARKERS = ['未找到页面', '哎呀', '404', 'Not Found', '页面不存在', '无法找到', 'page not found', '找不到页面']
105
+
106
+
107
+ def is_not_found(body):
108
+ b = body[:500]
109
+ return any(m in b for m in NOT_FOUND_MARKERS)
110
+
111
+
112
+ def collect_anchors(page):
113
+ try:
114
+ els = page.evaluate('''()=>{const out=[];document.querySelectorAll('button,.ant-tabs-tab,input[placeholder],input[name],[role=tab]').forEach(el=>{const name=(el.innerText||el.getAttribute('placeholder')||el.getAttribute('name')||'').trim();const role=el.getAttribute('role')||(el.tagName==='INPUT'?'textbox':el.tagName.toLowerCase());if(name&&name.length<25&&!/^[0-9\s]+$/.test(name))out.push({role,name});});return out.slice(0,30);}''')
115
+ seen = set(); uniq = []
116
+ for el in els:
117
+ k = (el['role'], el['name'])
118
+ if k not in seen:
119
+ seen.add(k); uniq.append(el)
120
+ return uniq
121
+ except Exception:
122
+ return []
123
+
124
+
125
+ def test_page(page, item, shot_dir):
126
+ """测单页. status: OK/LOADED/404/SESSION_EXPIRED/EMPTY/ERROR
127
+ 每页截 1 张图存 shot_dir, 结果带 screenshot 路径 + body 全文(留证据)."""
128
+ path = item['page_path']
129
+ res = {'page': path, 'name': item.get('name', ''), 'status': 'UNKNOWN', 'rows': 0, 'anchors': [], 'screenshot': '', 'body': ''}
130
+ try:
131
+ page.goto(BASE + path, wait_until='domcontentloaded', timeout=12000)
132
+ time.sleep(2)
133
+ cur = page.url
134
+ if '/auth/login' in cur:
135
+ res['status'] = 'SESSION_EXPIRED'; return res
136
+ body = page.evaluate('()=>document.body.innerText.slice(0,1500)')
137
+ res['body'] = body
138
+ if is_not_found(body):
139
+ res['status'] = '404'; res['body_head'] = body[:80].replace('\n', ' ')
140
+ res['screenshot'] = _shot(page, shot_dir, path, '404'); return res
141
+ # 等表格加载
142
+ rows = 0
143
+ for _ in range(8):
144
+ rows = page.locator('.vxe-body--row').count()
145
+ if rows > 0:
146
+ break
147
+ time.sleep(0.5)
148
+ res['rows'] = rows
149
+ if rows > 0:
150
+ res['status'] = 'OK'
151
+ elif len([l for l in body.split('\n') if l.strip()]) > 3:
152
+ res['status'] = 'LOADED'
153
+ else:
154
+ res['status'] = 'EMPTY'
155
+ res['anchors'] = collect_anchors(page)
156
+ res['screenshot'] = _shot(page, shot_dir, path, res['status'])
157
+ except Exception as e:
158
+ res['status'] = 'ERROR'; res['error'] = str(e)[:80]
159
+ try: res['screenshot'] = _shot(page, shot_dir, path, 'ERROR')
160
+ except Exception: pass
161
+ return res
162
+
163
+
164
+ def _shot(page, shot_dir, path, tag):
165
+ """截图存 PNG, 返回文件名. 命名: tag_域名简写.png (去特殊字符)."""
166
+ import re as _re
167
+ safe = _re.sub(r'[^a-zA-Z0-9\u4e00-\u9fa5]', '_', path).strip('_')[-40:]
168
+ fn = '%s_%s.png' % (tag, safe)
169
+ fp = os.path.join(shot_dir, fn)
170
+ try:
171
+ page.screenshot(path=fp, full_page=False)
172
+ return fn
173
+ except Exception:
174
+ return ''
175
+
176
+
177
+ def sediment(page_path, anchors):
178
+ if not anchors:
179
+ return
180
+ try:
181
+ pj = json.dumps({'url': page_path, 'elements': [
182
+ {'role': a['role'], 'name': a['name'], 'desc': a['name']} for a in anchors
183
+ ]}, ensure_ascii=False)
184
+ subprocess.run([sys.executable, os.path.join(SCRIPTS, 'autotest.py'), 'learn',
185
+ '--url', page_path, '--page-json', pj], capture_output=True, timeout=20)
186
+ except Exception:
187
+ pass
188
+
189
+
190
+ def main():
191
+ ap = argparse.ArgumentParser()
192
+ ap.add_argument('batch_file')
193
+ ap.add_argument('--out', default='')
194
+ args = ap.parse_args()
195
+ batch = json.load(open(args.batch_file, encoding='utf-8'))
196
+ print('批量测试 %d (URL来源: 准确路由树)' % len(batch))
197
+
198
+ TOKEN = get_token()
199
+ if not TOKEN:
200
+ print('LOGIN_FAIL'); sys.exit(1)
201
+ print('token: ok (dev=%s)' % DEV)
202
+
203
+ results = []
204
+ shot_dir = os.path.join(os.path.dirname(args.batch_file), 'screenshots')
205
+ os.makedirs(shot_dir, exist_ok=True)
206
+ with sync_playwright() as pw:
207
+ b = pw.chromium.launch(headless=True)
208
+ page = b.new_context(locale='zh-CN', viewport={'width': 1440, 'height': 900}).new_page()
209
+ page.set_default_timeout(12000)
210
+ try:
211
+ inject_token(page, TOKEN)
212
+ ready = wait_menu_ready(page)
213
+ print('SPA初始化:', '菜单就绪' if ready else '菜单未就绪(继续)')
214
+ except Exception as e:
215
+ print('INIT_FAIL:', str(e)[:80]); b.close(); sys.exit(1)
216
+
217
+ for i, item in enumerate(batch):
218
+ res = test_page(page, item, shot_dir)
219
+ results.append(res)
220
+ anc_n = len(res.get('anchors', []))
221
+ print(' [%d/%d] %-14s %-50s rows=%-3d anc=%d %s' % (
222
+ i + 1, len(batch), res['status'], res['page'][:48], res.get('rows', 0), anc_n,
223
+ res.get('body_head', res.get('error', ''))[:30]))
224
+ sediment(res['page'], res.get('anchors', []))
225
+ if res['status'] == 'SESSION_EXPIRED':
226
+ print(' session过期, 重新登录...')
227
+ TOKEN = login_api()
228
+ if TOKEN:
229
+ inject_token(page, TOKEN); wait_menu_ready(page, 8)
230
+ b.close()
231
+
232
+ from collections import Counter
233
+ c = Counter(r['status'] for r in results)
234
+ print()
235
+ print('=== 汇总 ===')
236
+ for s, n in c.most_common():
237
+ print(' %-14s %d' % (s, n))
238
+ total_anchors = sum(len(r.get('anchors', [])) for r in results)
239
+ print(' 沉淀锚点: %d' % total_anchors)
240
+ # 列出所有 404/ERROR
241
+ problems = [r for r in results if r['status'] in ('404', 'ERROR', 'EMPTY', 'SESSION_EXPIRED')]
242
+ if problems:
243
+ print()
244
+ print('=== 异常页面 (%d) ===' % len(problems))
245
+ for r in problems:
246
+ print(' [%s] %s %s' % (r['status'], r['page'][:50], r.get('body_head', r.get('error', ''))[:30]))
247
+
248
+ out_file = args.out or args.batch_file.replace('.json', '_result.json')
249
+ json.dump(results, open(out_file, 'w', encoding='utf-8'), ensure_ascii=False, indent=2)
250
+ print()
251
+ print('结果: %s' % out_file)
252
+
253
+
254
+ if __name__ == '__main__':
255
+ main()