@whyshu/argent-engine 0.0.0-stage → 0.6.0

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 (305) hide show
  1. package/MANIFEST.json +307 -0
  2. package/engine/VERSION +1 -0
  3. package/engine/agents/catalog.json +454 -0
  4. package/engine/configs/_aliases.proposed.json +651 -0
  5. package/engine/configs/_migration-plan.proposed.json +899 -0
  6. package/engine/configs/account.json +272 -0
  7. package/engine/configs/advanced.json +205 -0
  8. package/engine/configs/host.json +149 -0
  9. package/engine/configs/hr.json +27 -0
  10. package/engine/configs/notify.json +264 -0
  11. package/engine/core/cli/argent.mjs +277 -0
  12. package/engine/core/contracts/argent.contracts.json +1065 -0
  13. package/engine/core/contracts/verbs.schema.json +2539 -0
  14. package/engine/core/lib/argent-datadir.mjs +223 -0
  15. package/engine/core/lib/audit.mjs +187 -0
  16. package/engine/core/lib/claim.mjs +258 -0
  17. package/engine/core/lib/config-catalog.mjs +1172 -0
  18. package/engine/core/lib/doctor-customer.mjs +577 -0
  19. package/engine/core/lib/flow-list.mjs +138 -0
  20. package/engine/core/lib/flow-registry.mjs +48 -0
  21. package/engine/core/lib/init.mjs +247 -0
  22. package/engine/core/lib/ledger.mjs +124 -0
  23. package/engine/core/lib/license-activate.mjs +813 -0
  24. package/engine/core/lib/license-device.mjs +314 -0
  25. package/engine/core/lib/license-file.mjs +349 -0
  26. package/engine/core/lib/license-guard.mjs +123 -0
  27. package/engine/core/lib/license-ops.mjs +1645 -0
  28. package/engine/core/lib/license-verdict.mjs +133 -0
  29. package/engine/core/lib/license.mjs +577 -0
  30. package/engine/core/lib/notify/dedup.mjs +101 -0
  31. package/engine/core/lib/notify/feishu.mjs +38 -0
  32. package/engine/core/lib/notify/index.mjs +95 -0
  33. package/engine/core/lib/notify/verb.mjs +327 -0
  34. package/engine/core/lib/notify/webhook.mjs +135 -0
  35. package/engine/core/lib/notify/wecom.mjs +36 -0
  36. package/engine/core/lib/python.mjs +145 -0
  37. package/engine/core/lib/sop.mjs +116 -0
  38. package/engine/core/lib/versions.mjs +205 -0
  39. package/engine/core/mcp/client.mjs +67 -0
  40. package/engine/core/mcp/http.mjs +138 -0
  41. package/engine/core/mcp/server.mjs +702 -0
  42. package/engine/scripts/_engine_rules_probe.py +15 -0
  43. package/engine/scripts/analyze-full-day.py +568 -0
  44. package/engine/scripts/archive/feishu-bridge.js +246 -0
  45. package/engine/scripts/argent-agents.mjs +527 -0
  46. package/engine/scripts/argent-capabilities.mjs +776 -0
  47. package/engine/scripts/argent-catalog.mjs +186 -0
  48. package/engine/scripts/argent-datadir.py +62 -0
  49. package/engine/scripts/argent-doctor.mjs +624 -0
  50. package/engine/scripts/argent-env-write.mjs +302 -0
  51. package/engine/scripts/argent-schedule.mjs +620 -0
  52. package/engine/scripts/argent-setup-check.mjs +369 -0
  53. package/engine/scripts/argent-upgrade.mjs +1046 -0
  54. package/engine/scripts/base-bootstrap.sh +575 -0
  55. package/engine/scripts/billing-mode-daily.sh +12 -0
  56. package/engine/scripts/build-engine-package.mjs +123 -0
  57. package/engine/scripts/capability-packs.lock.json +484 -0
  58. package/engine/scripts/capability_pack.py +1242 -0
  59. package/engine/scripts/check-1.0-criteria.mjs +355 -0
  60. package/engine/scripts/check-agent-catalog.mjs +141 -0
  61. package/engine/scripts/check-billing-mode.sh +377 -0
  62. package/engine/scripts/check-child-sessions.mjs +83 -0
  63. package/engine/scripts/check-config-catalog.mjs +677 -0
  64. package/engine/scripts/check-docs-drift.mjs +522 -0
  65. package/engine/scripts/check-dsh-web-expose.sh +98 -0
  66. package/engine/scripts/check-judgment-drift.mjs +403 -0
  67. package/engine/scripts/check-license.mjs +199 -0
  68. package/engine/scripts/check-model-auth.py +443 -0
  69. package/engine/scripts/check-pack-closure.mjs +661 -0
  70. package/engine/scripts/check-portable-symbols.mjs +116 -0
  71. package/engine/scripts/check-preset-landing.mjs +124 -0
  72. package/engine/scripts/check-production-home.mjs +602 -0
  73. package/engine/scripts/check-role-mapping.mjs +461 -0
  74. package/engine/scripts/check-sample-redaction.mjs +320 -0
  75. package/engine/scripts/check-skill-classification.mjs +188 -0
  76. package/engine/scripts/check-sop-consistency.mjs +431 -0
  77. package/engine/scripts/check-test-env-safety.mjs +627 -0
  78. package/engine/scripts/check-version-consistency.mjs +452 -0
  79. package/engine/scripts/ci-run.sh +558 -0
  80. package/engine/scripts/config-explain.mjs +278 -0
  81. package/engine/scripts/configure-provider.rb +29 -0
  82. package/engine/scripts/customer-config-migrate.mjs +179 -0
  83. package/engine/scripts/customer-preflight.py +892 -0
  84. package/engine/scripts/customer-readiness.sh +134 -0
  85. package/engine/scripts/daily-report-inbox.py +242 -0
  86. package/engine/scripts/daily-report-reconcile.mjs +312 -0
  87. package/engine/scripts/daily-report.mjs +1310 -0
  88. package/engine/scripts/daily-report.sh +46 -0
  89. package/engine/scripts/data/lip-product-dict.json +306 -0
  90. package/engine/scripts/deliver-customer.mjs +932 -0
  91. package/engine/scripts/delivery-faces.txt +31 -0
  92. package/engine/scripts/desktop-trial.sh +146 -0
  93. package/engine/scripts/dev-window.sh +260 -0
  94. package/engine/scripts/drill-clean-machine.sh +315 -0
  95. package/engine/scripts/dsh-api-client.mjs +215 -0
  96. package/engine/scripts/dsh-backup-scope.sh +83 -0
  97. package/engine/scripts/dsh-offline-pack.mjs +77 -0
  98. package/engine/scripts/dsh-overlay-pin.sh +87 -0
  99. package/engine/scripts/dsh-plane.mjs +153 -0
  100. package/engine/scripts/dsh-portforward.py +223 -0
  101. package/engine/scripts/dsh-preflight.mjs +179 -0
  102. package/engine/scripts/dsh-release-watch-daily.sh +19 -0
  103. package/engine/scripts/dsh-release-watch.mjs +458 -0
  104. package/engine/scripts/dsh-remote-check.sh +99 -0
  105. package/engine/scripts/dsh-restart.sh +179 -0
  106. package/engine/scripts/dsh-restore.sh +111 -0
  107. package/engine/scripts/dsh-scope-rewire.mjs +803 -0
  108. package/engine/scripts/dsh-snapshot.mjs +116 -0
  109. package/engine/scripts/dsh-upgrade-readiness.mjs +165 -0
  110. package/engine/scripts/dsh-upgrade.mjs +612 -0
  111. package/engine/scripts/dsh-url.sh +86 -0
  112. package/engine/scripts/dsh-web-launch.mjs +155 -0
  113. package/engine/scripts/ensure-store-windows.py +509 -0
  114. package/engine/scripts/entitlements.py +458 -0
  115. package/engine/scripts/env_probe.py +411 -0
  116. package/engine/scripts/feishu-card.mjs +252 -0
  117. package/engine/scripts/fix-skill-frontmatter.py +209 -0
  118. package/engine/scripts/flow-auto-retry.py +454 -0
  119. package/engine/scripts/flow-requirements.json +720 -0
  120. package/engine/scripts/flow-scaffold.py +275 -0
  121. package/engine/scripts/flow-timers-plist.sh +86 -0
  122. package/engine/scripts/flow-timers.sh +182 -0
  123. package/engine/scripts/flow-trigger-wake.mjs +284 -0
  124. package/engine/scripts/flow-trigger.sh +571 -0
  125. package/engine/scripts/flow_billing.py +354 -0
  126. package/engine/scripts/flow_capability_stats.py +314 -0
  127. package/engine/scripts/flow_case.py +194 -0
  128. package/engine/scripts/flow_claim.py +497 -0
  129. package/engine/scripts/flow_config.py +1024 -0
  130. package/engine/scripts/flow_failure.py +588 -0
  131. package/engine/scripts/flow_learn.py +105 -0
  132. package/engine/scripts/flow_ledger.py +235 -0
  133. package/engine/scripts/flow_policy.py +238 -0
  134. package/engine/scripts/flow_preflight.py +339 -0
  135. package/engine/scripts/flow_registry.py +341 -0
  136. package/engine/scripts/flow_retry.py +304 -0
  137. package/engine/scripts/flow_return_to_ops.py +191 -0
  138. package/engine/scripts/flow_source.py +315 -0
  139. package/engine/scripts/flow_sources/__init__.py +53 -0
  140. package/engine/scripts/flow_sources/cases_json.py +106 -0
  141. package/engine/scripts/flow_sources/common.py +428 -0
  142. package/engine/scripts/flow_sources/feishu_base.py +273 -0
  143. package/engine/scripts/flow_stale.py +185 -0
  144. package/engine/scripts/flow_status_reconcile.py +344 -0
  145. package/engine/scripts/flow_triage.py +849 -0
  146. package/engine/scripts/flow_write_verify.py +496 -0
  147. package/engine/scripts/fulfillment.js +586 -0
  148. package/engine/scripts/gen-agent-bundles.mjs +276 -0
  149. package/engine/scripts/gen-preset-rows.mjs +259 -0
  150. package/engine/scripts/init-ml.sh +2275 -0
  151. package/engine/scripts/install-internal-role.sh +190 -0
  152. package/engine/scripts/install-license.mjs +641 -0
  153. package/engine/scripts/install-one-shot.mjs +665 -0
  154. package/engine/scripts/integration_contract.py +220 -0
  155. package/engine/scripts/integrations/wenyao/adapter.py +98 -0
  156. package/engine/scripts/integrations/xzh/adapter.py +75 -0
  157. package/engine/scripts/invoice-mapping.example.json +40 -0
  158. package/engine/scripts/invoice-table.sh +194 -0
  159. package/engine/scripts/invoice_carrier_notice.py +279 -0
  160. package/engine/scripts/invoice_fail_closeout.py +297 -0
  161. package/engine/scripts/invoice_lip_runner.py +1508 -0
  162. package/engine/scripts/invoice_lip_runner.py.bak-20260922-1710-freready +1090 -0
  163. package/engine/scripts/invoice_ready_audit.py +191 -0
  164. package/engine/scripts/invoice_runner.py +675 -0
  165. package/engine/scripts/invoice_stageb.py +516 -0
  166. package/engine/scripts/launchd/com.whyshu.argent-bridge.plist.example +42 -0
  167. package/engine/scripts/launchd/com.whyshu.fulfillment-poll.plist.example +29 -0
  168. package/engine/scripts/lease.py +377 -0
  169. package/engine/scripts/lib/agent-install.mjs +649 -0
  170. package/engine/scripts/lib/argent-datadir.mjs +16 -0
  171. package/engine/scripts/lib/argent_datadir.py +262 -0
  172. package/engine/scripts/lib/argent_filelock.py +507 -0
  173. package/engine/scripts/lib/bootcheck-wiring.mjs +510 -0
  174. package/engine/scripts/lib/cdp_ports.py +197 -0
  175. package/engine/scripts/lib/customer-env.mjs +102 -0
  176. package/engine/scripts/lib/daily-report-core.mjs +517 -0
  177. package/engine/scripts/lib/dep-declaration.mjs +81 -0
  178. package/engine/scripts/lib/deps-migrate.mjs +310 -0
  179. package/engine/scripts/lib/dsh-host-resolve.mjs +109 -0
  180. package/engine/scripts/lib/dsh-scope.mjs +500 -0
  181. package/engine/scripts/lib/dsh-snapshot-path.mjs +35 -0
  182. package/engine/scripts/lib/dsh-versions.mjs +96 -0
  183. package/engine/scripts/lib/dsh-web-probe.mjs +214 -0
  184. package/engine/scripts/lib/is-direct-run.mjs +61 -0
  185. package/engine/scripts/lib/patch-compose.mjs +241 -0
  186. package/engine/scripts/lib/patch-target.mjs +35 -0
  187. package/engine/scripts/lib/plugin-lifecycle.mjs +128 -0
  188. package/engine/scripts/lib/plugin-packages.mjs +332 -0
  189. package/engine/scripts/lib/preset-compat.mjs +127 -0
  190. package/engine/scripts/lib/preset-install.mjs +575 -0
  191. package/engine/scripts/lib/preset-mechanism.mjs +56 -0
  192. package/engine/scripts/lib/preset-sync.mjs +59 -0
  193. package/engine/scripts/lib/profile-retire.mjs +97 -0
  194. package/engine/scripts/lib/python-runtime.mjs +190 -0
  195. package/engine/scripts/lib/registry.mjs +72 -0
  196. package/engine/scripts/lib/repo-root.sh +56 -0
  197. package/engine/scripts/lib/schedule-mechanism.mjs +659 -0
  198. package/engine/scripts/lib/schedule-probe.mjs +835 -0
  199. package/engine/scripts/lib/scope-pointer.mjs +164 -0
  200. package/engine/scripts/lib/settings-panels.mjs +816 -0
  201. package/engine/scripts/lib/stdio_utf8.py +86 -0
  202. package/engine/scripts/lib/wake-auth.mjs +392 -0
  203. package/engine/scripts/lib/webdriver_ipc.py +264 -0
  204. package/engine/scripts/lib/zstd-frames.mjs +104 -0
  205. package/engine/scripts/license-e2e-drill.mjs +426 -0
  206. package/engine/scripts/license-gate.mjs +245 -0
  207. package/engine/scripts/lip_backlog.py +171 -0
  208. package/engine/scripts/lip_table.py +185 -0
  209. package/engine/scripts/local-usage.mjs +214 -0
  210. package/engine/scripts/migrate-providers.mjs +127 -0
  211. package/engine/scripts/migrate-skills.sh +65 -0
  212. package/engine/scripts/ml-oauth-setup.mjs +550 -0
  213. package/engine/scripts/ml-store-ids.json +26 -0
  214. package/engine/scripts/ml_artifact_attribution.py +333 -0
  215. package/engine/scripts/ml_capacity_probe.py +1158 -0
  216. package/engine/scripts/ml_hub_status.py +1556 -0
  217. package/engine/scripts/ml_schema_check.py +115 -0
  218. package/engine/scripts/ml_store_ids.py +139 -0
  219. package/engine/scripts/npm-publish-argent.mjs +499 -0
  220. package/engine/scripts/npm-publish-argent.sh +20 -0
  221. package/engine/scripts/orchestrator.py +13811 -0
  222. package/engine/scripts/pack-plugins.mjs +140 -0
  223. package/engine/scripts/patches/dsh-llm-deepseek--transport-diagnostics.patch +297 -0
  224. package/engine/scripts/patches/dsh-session--append-message-shape.patch +15 -0
  225. package/engine/scripts/patches/dsh-session--tool-result-block-017.patch +28 -0
  226. package/engine/scripts/patches/dsh-session-format-v0-to-v1--permission-preset-origin.patch +11 -0
  227. package/engine/scripts/patches/dsh-session-format-v3-to-v4--turn-outside-replace.patch +20 -0
  228. package/engine/scripts/patches/dsh-session-persistence-jsonl--worker-bundled-migrators.patch +39 -0
  229. package/engine/scripts/patches/dsh-tools--render-return-shape.patch +50 -0
  230. package/engine/scripts/patches/verify-bundled-migrators.mjs +79 -0
  231. package/engine/scripts/patches/verify-hardening.mjs +167 -0
  232. package/engine/scripts/patches/verify-session-migration.mjs +149 -0
  233. package/engine/scripts/patches/verify-transport-diagnostics.mjs +227 -0
  234. package/engine/scripts/plan-role-rename.mjs +125 -0
  235. package/engine/scripts/plugin-bootcheck.mjs +451 -0
  236. package/engine/scripts/plugin-composition.json +15 -0
  237. package/engine/scripts/poc-mcp-host.mjs +455 -0
  238. package/engine/scripts/poll-fulfillment.sh +293 -0
  239. package/engine/scripts/poll-invoice.sh +74 -0
  240. package/engine/scripts/profile-doctor.mjs +686 -0
  241. package/engine/scripts/provider-select.sh +70 -0
  242. package/engine/scripts/provider-switch.sh +146 -0
  243. package/engine/scripts/provider-sync-target.check.mjs +64 -0
  244. package/engine/scripts/python-runtime.sh +78 -0
  245. package/engine/scripts/query_runner.py +310 -0
  246. package/engine/scripts/release-argent.sh +574 -0
  247. package/engine/scripts/repair-unresolved-tools.mjs +178 -0
  248. package/engine/scripts/run-tests.sh +256 -0
  249. package/engine/scripts/self-sign-license.mjs +179 -0
  250. package/engine/scripts/session-health-scan.mjs +63 -0
  251. package/engine/scripts/session-repair-tool-result-content.mjs +181 -0
  252. package/engine/scripts/setup.sh +22 -0
  253. package/engine/scripts/skill-install.mjs +629 -0
  254. package/engine/scripts/sku_translate.py +148 -0
  255. package/engine/scripts/sync-plugins.sh +90 -0
  256. package/engine/scripts/sync-presets.sh +27 -0
  257. package/engine/scripts/sync-scripts.sh +445 -0
  258. package/engine/scripts/telemetry.py +873 -0
  259. package/engine/scripts/ui-copy-count.mjs +220 -0
  260. package/engine/scripts/usage-anchor.sh +136 -0
  261. package/engine/scripts/usage_report.py +531 -0
  262. package/engine/scripts/validate-preset-schema.mjs +208 -0
  263. package/engine/scripts/vertical-init.mjs +323 -0
  264. package/engine/scripts/vertical-lint.mjs +482 -0
  265. package/engine/scripts/wenyao-browser.sh +73 -0
  266. package/engine/scripts/wenyao-fixture-export.py +116 -0
  267. package/engine/scripts/wenyao-invoice-writeback.mjs +540 -0
  268. package/engine/scripts/wenyao-login.sh +34 -0
  269. package/engine/scripts/wenyao-template-map.json +292 -0
  270. package/engine/scripts/wenyao_attach.py +131 -0
  271. package/engine/scripts/wenyao_download_template.py +144 -0
  272. package/engine/scripts/wenyao_fill_template.py +568 -0
  273. package/engine/scripts/wenyao_login.py +145 -0
  274. package/engine/scripts/wenyao_portal.py +633 -0
  275. package/engine/scripts/wenyao_recon.py +230 -0
  276. package/engine/scripts/wenyao_run.py +503 -0
  277. package/engine/scripts/wenyao_shadow_diff.py +160 -0
  278. package/engine/scripts/wenyao_skill_contract.py +156 -0
  279. package/engine/scripts/wenyao_template_fetch.py +175 -0
  280. package/engine/scripts/win/dsh-web-autostart.ps1 +372 -0
  281. package/engine/scripts/ziniao-auth-probe.sh +268 -0
  282. package/engine/scripts/ziniao-keepalive.sh +216 -0
  283. package/engine/scripts/ziniao-post-upgrade-check.sh +120 -0
  284. package/engine/scripts/ziniao-windows.sh +153 -0
  285. package/engine/version.mjs +1 -0
  286. package/engine/verticals/README.md +103 -0
  287. package/engine/verticals/_engine-words.baseline.txt +237 -0
  288. package/engine/verticals/_engine-words.json +19 -0
  289. package/engine/verticals/mercadolibre/README.md +43 -0
  290. package/engine/verticals/mercadolibre/config.json +709 -0
  291. package/engine/verticals/mercadolibre/flows.json +706 -0
  292. package/engine/verticals/mercadolibre/manifest.json +538 -0
  293. package/engine/verticals/mercadolibre/migrate-machine-state.mjs +138 -0
  294. package/engine/verticals/mercadolibre/preflight.py +143 -0
  295. package/engine/verticals/mercadolibre/prompts/daily-report.failure.md +69 -0
  296. package/engine/verticals/mercadolibre/prompts/daily-report.md +47 -0
  297. package/engine/verticals/mercadolibre/prompts/daily-report.scheduled.md +79 -0
  298. package/engine/verticals/mercadolibre/prompts/full.failure.md +125 -0
  299. package/engine/verticals/mercadolibre/prompts/full.md +91 -0
  300. package/engine/verticals/mercadolibre/prompts/wenyao-invoice.failure.md +88 -0
  301. package/engine/verticals/mercadolibre/prompts/wenyao-invoice.md +64 -0
  302. package/engine/verticals/mercadolibre/prompts/xzh-invoice.failure.md +91 -0
  303. package/engine/verticals/mercadolibre/prompts/xzh-invoice.md +46 -0
  304. package/package.json +21 -4
  305. package/README.md +0 -3
@@ -0,0 +1,1556 @@
1
+ #!/usr/bin/env python3
2
+ """hub-v2 货件真实状态:**只读探针 + 纯解析器**(需求 1 的判据层)。
3
+
4
+ ## 为什么需要它(用户口径,2026-10-09)
5
+
6
+ > 「创建货件号之后,完全可以根据 `<平台域名>/shipping/inbounds/<货件号>/hub-v2` 页面中各状态来判断
7
+ > 该货件号状态,例如**产品标签和箱唛是否下载完成、预约是否被取消**,这是最真实的状态查询方式。」
8
+
9
+ 现场实证(同一天):`79096447` 在 hub-v2 上是「预约已取消 + 其余 4 项已完成」,而 lip 表里是
10
+ `FULL状态=失败` / `当前步骤=失败:…` —— 表与真相**相反**,只有人工改表才能对齐。
11
+ ⇒ 本模块把「hub-v2 页面的逐项状态」变成结构化判据,供编排器在
12
+ ① 收口识别 ② 步骤1 前置校验 ③ 失败现场诊断 三处使用。
13
+
14
+ ## 分层(⛔ 别把浏览器塞进解析器)
15
+
16
+ - **纯解析器**(本文件 99%):`parse_hub_dump()` 吃「DOM dump 字典/JSON 字符串」吐结构化结论。
17
+ **无浏览器依赖、无网络、无宿主依赖**(只有 stdlib)⇒ 可用纯夹具做回归。
18
+ - **只读探针**(`JS_HUB_DUMP` + 编排器 `--mode hub-status` / `Orchestrator.hub_status()`):
19
+ 导航到 hub-v2(**只读,⛔ 不点任何写按钮**)→ `page.evaluate(JS_HUB_DUMP)` → 交给本解析器。
20
+
21
+ ## 派生规则(**确定性**,逐条对应实现)
22
+
23
+ | 派生结论 | 判据(按序,先命中先赢) |
24
+ |---|---|
25
+ | 逐项 `done` | 见 `_item_done()`:`aria-checked` / `aria-label` / class 词元 / 勾选图标 / 文本标记;正负信号都有才敢下结论,否则 `null`(**保守**) |
26
+ | `appointment_cancelled` | 见 `_APPOINTMENT_RULES`(下表):**残缺 ⇒ `null`** ⇒ **反证优先 ⇒ `false`** ⇒ 明确取消文案 ⇒ `true` ⇒ **真机校准组合**(环节已结束 ∧ 取消入口已消失)⇒ `true` ⇒ 其余 `null` |
27
+ | `labels_downloaded` | 「产品标签」项的 `done`;`done=null` 时若页面文本含 `etiquetas descargadas` 之类正标记 ⇒ `true`;否则 `null`(**不猜 false**) |
28
+ | `box_labels_downloaded` | 同上,「箱唛」项 |
29
+ | `all_items_done` | 已知项**全部** `done is True` ∧ 无未知项 ⇒ `true`;任一项 `null/false` ⇒ `false` |
30
+ | `operationally_complete` | **`appointment_cancelled is True` ∧ `labels_downloaded is True` ∧ `box_labels_downloaded is True`**(严格要求 `True`,`null` 一律不算)⇒ 识别为**运营手动完成** |
31
+
32
+ ### 步骤7「进不了箱数页」的只读归因(2026-10-12 批,`step7_block_verdict()`)
33
+
34
+ 同一模块里再加一条**独立的**判据链(**不动**上面的 hub 完成度判据):步骤7 连续失败于
35
+ 「未见 `Agrupa tu stock`」时,**先只读读页面**再下结论 —— 判据顺序即优先级:
36
+
37
+ | 序 | `kind` | 判据(先命中先赢) |
38
+ |---|---|---|
39
+ | ① | `size-block` | units 页量到 `size-dimensions-tag`,且 `size_tag_allowed(tag) is False`(∉ 中小件允许集,如 `EXTRAGRANDE`)⇒ 商品**超大件**、本货件空间**只收中小件** ⇒ **运营侧**(改包装尺寸或改渠道);⛔ 脚本无解 |
40
+ | ② | `precondition` | hub 有 `done is False` 的项,**或**正文含「Debes completar las tareas anteriores」⇒ 列出**未完成项原文** + 需运营在门户完成 |
41
+ | ③ | `unattributed` | 都不是 ⇒ **逐字保留旧文案** + 补一句证据(`Agrupa tu stock` 缺失 / 当时 URL / 自愈轮数)供 ml-dev 排查门户改版 |
42
+
43
+ ⚠️ 三态**共用** `err_type="business"` ⇒ **探测结果绝不改变失败码/阻断性**;探测失败(无 DOM、
44
+ 导航失败、解析失败)一律落在③(⛔ 不上抛、⛔ 不判成"业务阻塞")。
45
+
46
+ ⚠️ **判别力**:①的判据是**商品自己的尺寸标签**,⛔ 不是"页面上有空间限制文案" ——
47
+ 「Pequeños y medianos — Este espacio incluye…」描述的是**空间**,它在**任何一个**中小件货件的
48
+ units 页上都会出现 ⇒ 单凭它会把正常票判成尺寸阻塞。空间文案只作**佐证留痕**。
49
+
50
+ ## 分支:units 页只读探针(`JS_UNITS_DUMP` + `parse_units_dump()`)
51
+
52
+ `JS_UNITS_DUMP` **零判定词、零写操作**(只有 `querySelector` + 读属性):把
53
+ `[data-testid="size-dimensions-tag"]` / `[data-testid="volume-pill"]` 的**原文**、
54
+ 正文摘要、以及库存输入框(`aria-label="Stock a enviar"`)的 **disabled 渲染态**如实取回。
55
+ `parse_units_dump()` 只登记事实 + 两个归一化事实(`size_tag_allowed` / `space_restricted`),
56
+ **不下结论** —— 下结论的唯一入口是 `step7_block_verdict()`。
57
+
58
+ ### `appointment_cancelled` 的四步判据(**2026-10-09 真机校准**,见 `_APPOINTMENT_RULES`)
59
+
60
+ 真机样本 `79096447`(运营已手动完成:列表页 `Reserva cancelada`、预约已取消、标签+箱唛已下载)**证伪了
61
+ 旧判据**:那一页**通篇没有** `Reserva` / `cancelad` / `Cancelar` 任何字样(整份 dump 命中 0 次),
62
+ 旧规则 ①③ 永不命中;而 `Cancelar reserva` 按钮也**已消失**,于是旧规则落到 ④ ⇒ `null` ⇒
63
+ `operationally_complete=false` ⇒ **把已完成的货件判成没完成**。
64
+
65
+ | 步 | 条件 | 结论 |
66
+ |---|---|---|
67
+ | 0 | 探针无效 / **预约区域缺失**(无预约项)/ 预约卡**没渲染**(该项 `html` 为空)/ 预约项自身就是渲染残缺卡 | `null`(**残缺保守**:缺证据 ≠ 完成) |
68
+ | 1 | hub 上**仍有** `Cancelar reserva`/`Cancelar cita`(按钮或文本),或有改期入口(`Reprogramar` / `Reagendar` / `Cambiar` + `reserva`/`cita`) | `false`(**预约还活着**,优先级最高 ⇒ 反证压过一切正证据) |
69
+ | 2 | 预约项 `done is False`(环节**明确没结束**) | `false`(未结束 ⇒ 谈不上"取消完成") |
70
+ | 3 | 明确取消文案:`Reserva cancelada`/`cita cancelada`/`cancelaste la reserva`/`sin reserva`(预约项 label、预约项 `html`、正文任一命中) | `true` |
71
+ | 4 | **真机校准组合**:预约项 `done is True`(环节**已结束**)**∧** `cancel_button_present is False`(取消入口**已消失**)**∧** 预约卡根节点可辨 | `true` |
72
+ | 5 | 其余 | `null` |
73
+
74
+ **为什么必须是"两条同时满足"(而不是只看其一)**:
75
+
76
+ - **只看"没有取消按钮"会误判**:**从未预约**的货件同样没有 `Cancelar reserva` 按钮(没有预约可取消)
77
+ ⇒ 必须有"环节**已结束**"这一条把"尚未开始"排除掉。
78
+ - **只看"环节已结束"会误判**:**预约成功**的货件该环节同样是"已结束"的(`done=True`),
79
+ 这种货件页面上**仍留着 `Cancelar reserva`** ⇒ 第 1 步的按钮反证会先赢,判 `false`。
80
+ - ⇒ 两条合取才唯一指向"结束的方式是**取消**"。真机证据另有一条**旁证**:五项里**只有预约卡**
81
+ 的内层 `andes-card` **不带** `andes-card--completed`(其余四项都带)⇒ 预约环节处在
82
+ **另一种终态**(不是常规完成态),与"已取消"一致、与"从未开始"不矛盾 —— 但**它不是判据**
83
+ (真机同样形态无法排除"从未开始"),只作为证据留痕。
84
+
85
+ **接口(可选组合,⛔ 不是必需)**:`parse_hub_dump(..., list_status="")` 收列表页/表里的粗粒度状态原文。
86
+ 优先级:**它永远不会把"判不了"变成"完成"**(⛔ 不做唯一依据);只在第 4 步做
87
+ ①**否决**(列表页说预约仍有效 ⇒ 降级回 `null`)②**佐证留痕**(`signals.list_status_confirms`)。
88
+ 本样本**不依赖它**(仅凭 hub 即可判 `true`)。
89
+
90
+ ⚠️ `operationally_complete=True` ⇒ 货件**被视为已完成**(无论表里写什么),但
91
+ **⛔ 绝不是让引擎去改写 `FULL状态`** —— 改写权在运营/用户;引擎只写「识别为运营手动完成(依据 hub-v2 实测)」的备注。
92
+
93
+ ## 容错(平台改版是常态)
94
+
95
+ - 页面是**西班牙语**、且平台会改版 ⇒ 未知结构**绝不崩**:
96
+ 逐项 dump **页面上真实存在的每一项**(`⛔ 不只挑认识的`),识别不了的记
97
+ `{"label": <原文>, "done": null, "known": false, "note": "unknown-structure"}`;
98
+ - `parse_hub_dump()` 对畸形入参(非 JSON / 缺字段 / 类型错)一律返回**可用骨架 + `errors` 列表**,从不抛异常;
99
+ - 派生结论遇 `null` 一律**保守**(不当成已完成)。
100
+
101
+ ## CLI(离线,不需要浏览器)
102
+
103
+ python3 scripts/ml_hub_status.py --dump-file <dump.json> # 解析一份已抓到的 dump
104
+ python3 scripts/ml_hub_status.py --fixture all-done # 跑内置夹具(回归/演示)
105
+ python3 scripts/ml_hub_status.py --dump-file - --json # 从 stdin 读
106
+
107
+ 真机抓取(只读)走编排器:`--mode hub-status --shipment-id <号> [--store-name <店>]`。
108
+ """
109
+
110
+ from __future__ import annotations
111
+
112
+ import argparse
113
+ import json
114
+ import pathlib
115
+ import re
116
+ import sys
117
+ from typing import Any, Optional
118
+
119
+ # ────────────────────────────────────────────────
120
+ # 常量:URL 与词表(**唯一真源**,编排器从这里取,⛔ 不要各写一份)
121
+ # ────────────────────────────────────────────────
122
+
123
+ # ⚠️ **本模块刻意不内置平台域名**:`scripts/**` 是引擎面,不得出现业务词
124
+ # (门禁 `scripts/vertical-lint.mjs` 的 engine-words 档,词表见 `verticals/_engine-words.json`)。
125
+ # 平台基址由**调用方**注入(编排器传它自己的 `ML_BASE_URL`)⇒ 本模块零业务词。
126
+ HUB_PATH_TEMPLATE = "/shipping/inbounds/{shipment_id}/hub-v2"
127
+ # 商品/尺寸页(步骤7 归因探测的第二个观察点):**现场证据**(2026-10-12 货件 79301358)——
128
+ # hub 上「Tienes N producto para revisar」右侧【Editar】的 href 就是它。
129
+ UNITS_PATH_TEMPLATE = "/shipping/inbounds/{shipment_id}/units?from=hub"
130
+
131
+ # 夹具/离线自检用的**中性**基址(不是任何真实平台域名;只用于拼串与回归)
132
+ FIXTURE_BASE_URL = "https://ml-platform.invalid"
133
+
134
+
135
+ def hub_url(shipment_id: str, base_url: str) -> str:
136
+ """hub-v2 页 URL(与 `orchestrator._visit_plan_page_by_click` 的拼法逐字一致)。
137
+
138
+ `base_url` **必填**(⛔ 无默认值):引擎面不内置业务域名,见上面的说明。
139
+ """
140
+ return f"{str(base_url).rstrip('/')}{HUB_PATH_TEMPLATE.format(shipment_id=str(shipment_id).strip())}"
141
+
142
+
143
+ def units_url(shipment_id: str, base_url: str) -> str:
144
+ """商品/尺寸页 URL(步骤7 归因探测用;⛔ 只导航,不点任何按钮)。
145
+
146
+ 与 `hub_url()` 同一口径:`base_url` **必填**(引擎面不内置业务域名)。
147
+ """
148
+ return f"{str(base_url).rstrip('/')}{UNITS_PATH_TEMPLATE.format(shipment_id=str(shipment_id).strip())}"
149
+
150
+
151
+ # 已知项 key:识别**只为归类**(哪一项是标签/箱唛/预约),⛔ 不作为过滤条件。
152
+ KEY_PREPARE = "prepare"
153
+ KEY_APPOINTMENT = "appointment"
154
+ KEY_PACKAGING = "packaging"
155
+ KEY_LABELS = "labels"
156
+ KEY_BOX_LABELS = "box_labels"
157
+
158
+ # 归类词表(西班牙语;大小写不敏感)。顺序敏感:箱唛先于标签(「etiquetas de tus bultos」两边都沾)。
159
+ _ITEM_CLASSIFIERS: tuple[tuple[str, tuple[str, ...]], ...] = (
160
+ (KEY_BOX_LABELS, ("bulto", "pallet", "etiquetas de caja", "etiquetas de las cajas",
161
+ "etiqueta de caja")),
162
+ (KEY_APPOINTMENT, ("reserva", "cita", "cuándo vas a enviar", "cuando vas a enviar",
163
+ "elige cómo", "elige como")),
164
+ (KEY_LABELS, ("etiqueta", "label")),
165
+ (KEY_PACKAGING, ("revisa la preparación", "revisa la preparacion", "empaque",
166
+ "reglamentaci", "preparación y reglamentación")),
167
+ (KEY_PREPARE, ("preparación del envío", "preparacion del envio", "prepara tu envío",
168
+ "prepara tu envio", "agregar título", "agregar titulo", "descargar listado")),
169
+ )
170
+
171
+ # 页面级「预约已取消」词(强信号;**真机上常常缺席**,见模块 docstring)
172
+ _APPT_CANCELLED_RE = re.compile(
173
+ r"reserva\s+cancelada|cita\s+cancelada|reserva\s+fu[eé]\s+cancelada|"
174
+ r"cancelaste\s+(?:la\s+)?(?:reserva|cita)|sin\s+(?:reserva|cita)",
175
+ re.IGNORECASE)
176
+
177
+ # hub 上仍有此按钮/文本 ⇒ 预约仍有效(步骤8 的判定口径:取消生效后按钮消失)
178
+ _APPT_ALIVE_RE = re.compile(r"cancelar\s+(?:reserva|cita)", re.IGNORECASE)
179
+ # 改期入口同样说明"预约还在"(比按钮弱一档,但仍是**反证**)
180
+ _APPT_ALIVE_WORD_RE = re.compile(
181
+ r"(?:reprogramar|reagendar|cambiar|modificar|reprogramaci[oó]n)\s+(?:la\s+|tu\s+)?"
182
+ r"(?:reserva|cita)",
183
+ re.IGNORECASE)
184
+
185
+ # 列表页/表里的粗粒度状态说"预约仍有效" ⇒ 只能用来**否决**组合判据(⛔ 不做唯一依据)
186
+ _LIST_ALIVE_RE = re.compile(
187
+ r"reserva\s+(?:activa|confirmada|vigente|programada)|cita\s+(?:activa|confirmada|vigente)",
188
+ re.IGNORECASE)
189
+
190
+ # 渲染残缺卡(某子组件未渲染时平台会渲染它)——**只读证据**:
191
+ # 它**不等于**"预约区域缺失",判据必须能区分"别处坏了"与"预约区坏了"。
192
+ _ERROR_CARD_RE = re.compile(r"error[-_]?card", re.IGNORECASE)
193
+ _ERROR_TEXT_RE = re.compile(
194
+ r"algo\s+sali[oó]\s+mal|no\s+pudimos\s+mostrar\s+la\s+informaci[oó]n", re.IGNORECASE)
195
+
196
+ # 预约卡根节点可辨(真机形态:`<div class="tour__appointment hub--card " data-testid="link-appointment">`)
197
+ _APPT_CARD_ROOT_RE = re.compile(r"data-testid|hub--card", re.IGNORECASE)
198
+
199
+ # ⛔ 判据**顺序即优先级**(改这条 = 改判据);`appointment_cancelled` 的唯一实现见
200
+ # `_appointment_cancelled()`。保留表结构只为让"四步判据"可被程序化引用/文档对齐。
201
+ _APPOINTMENT_RULES: tuple[tuple[str, str], ...] = (
202
+ ("incomplete-page", "null(残缺保守:探针无效/预约区域缺失/预约卡未渲染)"),
203
+ ("alive-evidence", "false(仍有 Cancelar reserva 按钮或改期入口)"),
204
+ ("appointment-not-concluded", "false(预约项 done is False)"),
205
+ ("cancelled-text", "true(明确取消文案)"),
206
+ ("step-closed-and-no-cancel-affordance", "true(真机校准组合)"),
207
+ ("no-evidence", "null(判不了)"),
208
+ )
209
+
210
+ # 逐项正/负信号词(页面文本)
211
+ _DONE_TEXT_RE = re.compile(
212
+ r"completad[oa]|finalizad[oa]|\blisto\b|\blista\b|descargad[oa]s?\b|"
213
+ r"ya\s+descargaste|\bhecho\b|\bcompleto\b",
214
+ re.IGNORECASE)
215
+ _TODO_TEXT_RE = re.compile(
216
+ r"pendiente|comenzar|continuar|por\s+hacer|sin\s+comenzar|falta|incompleto|"
217
+ r"descargar\s+etiquetas|a[uú]n\s+no",
218
+ re.IGNORECASE)
219
+
220
+ # 独立于逐项结构的「已下载」正标记(页面级兜底)
221
+ _LABELS_DONE_TEXT_RE = re.compile(
222
+ r"etiquetas\s+descargadas|ya\s+descargaste\s+las\s+etiquetas|descarga\s+completada",
223
+ re.IGNORECASE)
224
+ _BOX_DONE_TEXT_RE = re.compile(
225
+ r"bultos?\s+descargad|etiquetas\s+de\s+(?:tus\s+)?bultos\s+descargad|"
226
+ r"pallets?\s+descargad",
227
+ re.IGNORECASE)
228
+
229
+ # class 词元级「完成」/「未完成」(用词元匹配,避免 substring 误命中)
230
+ _DONE_CLASS_RE = re.compile(
231
+ r"(?:^|[-_])(?:check|checked|complete|completed|done|success|succeeded|finished|ready)(?:$|[-_])",
232
+ re.IGNORECASE)
233
+ _NOT_DONE_CLASS_RE = re.compile(
234
+ r"incomplete|unchecked|not[-_]?done|pending|disabled|blocked|todo", re.IGNORECASE)
235
+
236
+ # aria-label / title 上的完成语
237
+ _ARIA_DONE_RE = re.compile(r"completad|finalizad|checked|\blisto\b|descargad", re.IGNORECASE)
238
+ _ARIA_TODO_RE = re.compile(r"pendiente|sin\s+completar|no\s+completad|incomplet", re.IGNORECASE)
239
+
240
+ MAX_ITEMS = 60 # 逐项上限(页面异常时不至于吐出几千条)
241
+ MAX_RAW_SNIPPET = 600 # 单项 raw 片段截断
242
+ MAX_BODY_TEXT = 2000 # 正文摘要截断
243
+
244
+
245
+ # ────────────────────────────────────────────────
246
+ # 只读 DOM dump(编排器 evaluate 这段 JS;⛔ 只读,不点任何按钮)
247
+ # ────────────────────────────────────────────────
248
+
249
+ JS_HUB_DUMP = r"""(function(){
250
+ var norm = function(s){ return (s || '').replace(/\s+/g, ' ').trim(); };
251
+ var txt = function(el){ return el ? norm(el.innerText || el.textContent) : ''; };
252
+ var cls = function(el){
253
+ var c = el && el.className;
254
+ return (typeof c === 'string') ? c : (c && c.baseVal) ? String(c.baseVal) : '';
255
+ };
256
+ // 证据扩展(v2,2026-10-09 真机校准):把 class **按元素分词**存下来。
257
+ // 旧形态把 40 个后代 class 拼成 400 字符的串 ⇒ ①拼接截断会把命中的词元切掉
258
+ // ②事后无法回答"解析器记的 done_class=true 究竟从哪个词元来"。
259
+ // 真机 79096447 就撞上②:预约项记到 done_class=true,但 600 字符 html 里的 class
260
+ // 一个都不匹配 ⇒ 下一轮无法校准。⛔ 本字段**只扩证据、不改判据**。
261
+ var doneRe = /(?:^|[-_])(?:check|checked|complete|completed|done|success|succeeded|finished|ready)(?:$|[-_])/i;
262
+ var todoRe = /incomplete|unchecked|not[-_]?done|pending|disabled|blocked|todo/i;
263
+ var tokens = function(s){
264
+ var parts = String(s || '').split(/\s+/);
265
+ var acc = [];
266
+ for (var i = 0; i < parts.length; i++) { if (parts[i] && acc.indexOf(parts[i]) < 0) acc.push(parts[i]); }
267
+ return acc;
268
+ };
269
+ var out = {
270
+ url: location.href || '', title: document.title || '',
271
+ ready_state: document.readyState || '', h1: '',
272
+ body_text: '', items: [], cancel_button_present: false,
273
+ status_words: {}, errors: [], error_cards: [], probe_version: 2
274
+ };
275
+ try {
276
+ // 1) 页头:优先 h1,退化到 h2
277
+ var h = document.querySelector('h1') || document.querySelector('h2');
278
+ out.h1 = txt(h).substring(0, 200);
279
+
280
+ // 2) 正文摘要(只读)
281
+ var main = document.querySelector('main') || document.querySelector('#root-app')
282
+ || document.querySelector('[role=main]') || document.body;
283
+ out.body_text = txt(main).substring(0, 2000);
284
+
285
+ // 3) 「取消预约」按钮是否仍在页面上(预约仍有活 ⇒ 未取消)。只读探测,⛔ 不点。
286
+ var btns = document.querySelectorAll('button, a, [role=button]');
287
+ for (var b = 0; b < btns.length; b++) {
288
+ var bt = norm(btns[b].innerText || btns[b].textContent);
289
+ if (/cancelar\s+(reserva|cita)/i.test(bt)) { out.cancel_button_present = true; break; }
290
+ }
291
+
292
+ // 4) 逐项:宽口径收集"任务卡候选",逐项如实 dump(不认识也留着)
293
+ var seen = {};
294
+ var cands = [];
295
+ var sels = ['[data-testid]', 'li', 'section', 'article', '[role=listitem]',
296
+ '[class*="task"]', '[class*="Task"]', '[class*="step"]',
297
+ '[class*="Step"]', '[class*="card"]', '[class*="Card"]',
298
+ '[class*="action"]', '[class*="Action"]', '[class*="requirement"]',
299
+ '[class*="Requirement"]', '[class*="checklist"]', '[class*="Checklist"]'];
300
+ for (var s = 0; s < sels.length; s++) {
301
+ var nodes;
302
+ try { nodes = document.querySelectorAll(sels[s]); } catch (e) { continue; }
303
+ for (var n = 0; n < nodes.length; n++) {
304
+ var el = nodes[n];
305
+ if (!el || el === document.body) continue;
306
+ var t = txt(el);
307
+ // 任务卡特征:文案短(5..180 字)且**不是**整页容器
308
+ if (t.length < 5 || t.length > 180) continue;
309
+ if (el.querySelectorAll('li, section, article').length > 1) continue;
310
+ cands.push({ el: el, text: t });
311
+ }
312
+ }
313
+ // 去重 + 取「最具体」的那一个(同文案保留最短文本者,即最内层)
314
+ for (var c = 0; c < cands.length; c++) {
315
+ var key = cands[c].text;
316
+ if (!seen[key] || seen[key].text.length > key.length) { seen[key] = cands[c]; }
317
+ }
318
+ var keys = Object.keys(seen);
319
+ for (var k = 0; k < keys.length && out.items.length < 60; k++) {
320
+ var item = seen[keys[k]];
321
+ var el2 = item.el;
322
+ var info = { label: item.text.substring(0, 180), html: '' };
323
+
324
+ // aria-checked(Andes 复选框/步骤条)
325
+ var ac = el2.getAttribute && el2.getAttribute('aria-checked');
326
+ if (ac === 'true' || ac === 'false') { info.aria_checked = (ac === 'true'); }
327
+
328
+ // aria-label / title(本体 + 一层后代)
329
+ var aria = (el2.getAttribute && (el2.getAttribute('aria-label') || el2.getAttribute('title'))) || '';
330
+ info.aria_label = norm(aria).substring(0, 160);
331
+
332
+ // class(本体 + 后代,限 40 个,防爆)
333
+ var classes = [cls(el2)];
334
+ var desc = el2.querySelectorAll('*');
335
+ for (var d = 0; d < desc.length && d < 40; d++) { classes.push(cls(desc[d])); }
336
+ info.classes = classes.filter(Boolean).join(' ').substring(0, 400);
337
+
338
+ // 勾选图标(Andes check 图标 / svg use#check)
339
+ var icon = el2.querySelector(
340
+ 'svg[class*="check"], svg[class*="Check"], [class*="check-circle"], ' +
341
+ '[class*="CheckCircle"], use[href*="check"], use[xlink\\:href*="check"], ' +
342
+ 'img[src*="check"], i[class*="check"]');
343
+ info.has_check_icon = !!icon;
344
+
345
+ // 未完成态按钮(Descargar / Comenzar / Continuar)仍在 ⇒ 该项多半没做完
346
+ var todoBtn = false;
347
+ var bs = el2.querySelectorAll('button, a[role=button], [role=button]');
348
+ for (var q = 0; q < bs.length; q++) {
349
+ var btxt = norm(bs[q].innerText || bs[q].textContent);
350
+ var dis = !!bs[q].disabled || bs[q].getAttribute('aria-disabled') === 'true';
351
+ if (!dis && /^(descargar|comenzar|continuar|elegir|agregar)/i.test(btxt)) { todoBtn = true; break; }
352
+ }
353
+ info.has_todo_button = todoBtn;
354
+
355
+ // 片段(供回归/排障)
356
+ try { info.html = (el2.outerHTML || '').substring(0, 600); } catch (e) { info.html = ''; }
357
+
358
+ // 证据扩展(v2):逐元素分词后的 class 词元 + 命中的词元 + 卡状态。
359
+ // ⛔ 只写进 dump,判据仍走 `classes`(旧字段),行为零变化。
360
+ try {
361
+ var els = [el2];
362
+ for (var e1 = 0; e1 < desc.length; e1++) { els.push(desc[e1]); }
363
+ var tk = [];
364
+ for (var e2 = 0; e2 < els.length; e2++) {
365
+ var one = tokens(cls(els[e2]));
366
+ for (var t1 = 0; t1 < one.length; t1++) {
367
+ if (tk.indexOf(one[t1]) < 0) tk.push(one[t1]);
368
+ }
369
+ }
370
+ info.class_tokens = tk.slice(0, 160);
371
+ info.done_class_hits = tk.filter(function(x){ return doneRe.test(x); }).slice(0, 12);
372
+ info.todo_class_hits = tk.filter(function(x){ return todoRe.test(x); }).slice(0, 12);
373
+ // 两个**权威布尔**(与命中词元同源):离线复核/夹具可以只带它们 ——
374
+ // 不要求"必须知道是哪个词元命中的"(真机 79096447 就只知道布尔、不知道词元)。
375
+ info.done_class = (info.done_class_hits.length > 0);
376
+ info.todo_class = (info.todo_class_hits.length > 0);
377
+ info.card_completed_class = (tk.indexOf('andes-card--completed') >= 0);
378
+ info.tag = String(el2.tagName || '').toLowerCase();
379
+ info.data_testid = norm(el2.getAttribute ? (el2.getAttribute('data-testid') || '') : '');
380
+ } catch (e) { /* 证据扩展失败不影响主 dump */ }
381
+ out.items.push(info);
382
+ }
383
+
384
+ // 5) 状态词计数(页面级证据,供排障对账)
385
+ var vocab = ['Reserva cancelada', 'Cancelar reserva', 'Cancelar cita', 'Reserva',
386
+ 'Completado', 'Completada', 'Finalizado', 'Pendiente', 'Descargar',
387
+ 'etiquetas', 'bultos', 'pallets'];
388
+ for (var v = 0; v < vocab.length; v++) {
389
+ var w = vocab[v];
390
+ var m = out.body_text.split(w).length - 1;
391
+ if (m > 0) { out.status_words[w] = m; }
392
+ }
393
+
394
+ // 6) 渲染残缺卡(只读证据):某子组件未渲染 ⇒ 平台渲染一个 error-card。
395
+ // ⛔ 它**不等于**"预约区域缺失":真机 79096447 上就同时存在
396
+ // 「预约卡正常渲染」与「seller-monitor 子组件报错」⇒ 判据必须能区分二者。
397
+ var ecs = document.querySelectorAll(
398
+ '[class*="error-card"], [class*="ErrorCard"], [class*="errorCard"]');
399
+ for (var e3 = 0; e3 < ecs.length && e3 < 5; e3++) {
400
+ out.error_cards.push({ label: txt(ecs[e3]).substring(0, 160),
401
+ classes: cls(ecs[e3]).substring(0, 160) });
402
+ }
403
+ } catch (e) {
404
+ out.errors.push('dump_error: ' + (e && e.message ? e.message : String(e)));
405
+ }
406
+ return JSON.stringify(out);
407
+ })();"""
408
+
409
+
410
+ # ── units 页(商品/尺寸)只读 dump(2026-10-12 步骤7 归因批)────────────────────────────
411
+ # ⛔ **零判定词**:这段 JS 只**测量**(把页面上的原文/渲染态如实取回),判据一律在
412
+ # 下面的纯函数(`parse_units_dump` / `size_tag_allowed` / `step7_block_verdict`)里。
413
+ # ⛔ **只读**:没有任何 click / fill / 赋值,只有 querySelector + 读属性。
414
+ # 观察点(现场证据 79301358 逐字):
415
+ # · `[data-testid="size-dimensions-tag"]` 文案(真机 = `EXTRAGRANDE`)
416
+ # · `[data-testid="volume-pill"]` 文案(真机 = `11,712 cm 3`)
417
+ # · 正文摘要(内含「Pequeños y medianos — Este espacio incluye…」/「Revisar dimensiones permitidas」)
418
+ # · 库存输入框(`aria-label="Stock a enviar"`,真机值 200)的**渲染态**:
419
+ # 平台把它渲染成 disabled(外层 `andes-form-control--disabled` + `data-andes-state="disabled"`)
420
+ JS_UNITS_DUMP = r"""(function(){
421
+ var norm = function(s){ return (s || '').replace(/\s+/g, ' ').trim(); };
422
+ var txt = function(el){ return el ? norm(el.innerText || el.textContent) : ''; };
423
+ var textOf = function(sel){
424
+ var el = null;
425
+ try { el = document.querySelector(sel); } catch (e) { el = null; }
426
+ return el ? txt(el).substring(0, 200) : '';
427
+ };
428
+ var out = {
429
+ url: location.href || '', title: document.title || '',
430
+ ready_state: document.readyState || '',
431
+ size_dimensions_tag: '', volume_pill: '', body_text: '',
432
+ stock_input_present: false, stock_input_disabled: false,
433
+ stock_input_aria: '', stock_input_value: '',
434
+ probe_version: 1, errors: []
435
+ };
436
+ try {
437
+ out.size_dimensions_tag = textOf('[data-testid="size-dimensions-tag"]');
438
+ out.volume_pill = textOf('[data-testid="volume-pill"]');
439
+
440
+ var main = document.querySelector('main') || document.querySelector('#root-app')
441
+ || document.querySelector('[role=main]') || document.body;
442
+ out.body_text = (main ? norm(main.innerText || main.textContent) : '').substring(0, 2000);
443
+
444
+ // 库存输入框:只**测量**它是否被平台渲染成 disabled(⛔ 不改值、不启用、不聚焦)
445
+ var inp = document.querySelector('[aria-label="Stock a enviar"]')
446
+ || document.querySelector('[data-testid^="input-textfield"]')
447
+ || document.querySelector('input[type="number"]');
448
+ if (inp) {
449
+ out.stock_input_present = true;
450
+ out.stock_input_aria = norm(inp.getAttribute('aria-label') || '');
451
+ out.stock_input_value = norm(inp.value === undefined ? '' : inp.value).substring(0, 40);
452
+ var state = inp.getAttribute('data-andes-state') || '';
453
+ var ctl = (inp.closest ? inp.closest('.andes-form-control') : null);
454
+ var ccls = (ctl && typeof ctl.className === 'string') ? ctl.className : '';
455
+ out.stock_input_disabled = (inp.disabled === true) || (state === 'disabled')
456
+ || (ccls.indexOf('andes-form-control--disabled') >= 0);
457
+ }
458
+ } catch (e) {
459
+ out.errors.push('dump_error: ' + (e && e.message ? e.message : String(e)));
460
+ }
461
+ return JSON.stringify(out);
462
+ })();"""
463
+
464
+
465
+ # ────────────────────────────────────────────────
466
+ # 纯解析器
467
+ # ────────────────────────────────────────────────
468
+
469
+ def _as_text(v: Any) -> str:
470
+ if v is None:
471
+ return ""
472
+ if isinstance(v, list):
473
+ return " ".join(_as_text(x) for x in v)
474
+ if isinstance(v, bool):
475
+ return "true" if v else "false"
476
+ return str(v)
477
+
478
+
479
+ def classify_item(label: str) -> Optional[str]:
480
+ """把一条原文标签归到已知 key;认不出返回 `None`(**不猜**)。"""
481
+ low = _as_text(label).lower()
482
+ if not low:
483
+ return None
484
+ for key, needles in _ITEM_CLASSIFIERS:
485
+ for needle in needles:
486
+ if needle in low:
487
+ return key
488
+ return None
489
+
490
+
491
+ def _item_done(info: dict) -> tuple[Optional[bool], dict, str]:
492
+ """单条的完成判定 → `(done, signals, note)`。
493
+
494
+ **三态**:`True` / `False` / `None`(判不了 ⇒ None,**保守**,⛔ 不默认成完成)。
495
+ 正信号(完成侧)与负信号(未完成侧)都记进 `signals` 便于回归与排障。
496
+ """
497
+ label = _as_text(info.get("label"))
498
+ classes = _as_text(info.get("classes"))
499
+ aria = _as_text(info.get("aria_label"))
500
+ html = _as_text(info.get("html"))
501
+
502
+ signals: dict[str, Any] = {}
503
+
504
+ ac = info.get("aria_checked", None)
505
+ if isinstance(ac, bool):
506
+ signals["aria_checked"] = ac
507
+
508
+ if aria:
509
+ if _ARIA_DONE_RE.search(aria):
510
+ signals["aria_done_word"] = True
511
+ if _ARIA_TODO_RE.search(aria):
512
+ signals["aria_todo_word"] = True
513
+
514
+ if classes or isinstance(info.get("done_class_hits"), list) or isinstance(info.get("done_class"), bool):
515
+ # 证据扩展(v2,真机校准):探针给了**逐元素分词**的命中词元 / 权威布尔时**以它为准**
516
+ # (`[]` / `false` 也是权威答案:说明确实没有完成词元 ⇒ 不再回落到拼接串,避免
517
+ # 400 字符截断把某个词元切出 `$` 边界而**假命中**)。
518
+ hits = info.get("done_class_hits")
519
+ flag = info.get("done_class")
520
+ if isinstance(hits, list):
521
+ if hits:
522
+ signals["done_class"] = True
523
+ signals["done_class_hits"] = [_as_text(x)[:48] for x in hits[:12] if _as_text(x)]
524
+ elif isinstance(flag, bool):
525
+ if flag:
526
+ signals["done_class"] = True
527
+ elif _DONE_CLASS_RE.search(classes):
528
+ signals["done_class"] = True
529
+ if (classes or isinstance(info.get("todo_class_hits"), list)
530
+ or isinstance(info.get("todo_class"), bool)):
531
+ todo_hits = info.get("todo_class_hits")
532
+ todo_flag = info.get("todo_class")
533
+ if isinstance(todo_hits, list):
534
+ if todo_hits:
535
+ signals["todo_class"] = True
536
+ signals["todo_class_hits"] = [_as_text(x)[:48] for x in todo_hits[:12] if _as_text(x)]
537
+ elif isinstance(todo_flag, bool):
538
+ if todo_flag:
539
+ signals["todo_class"] = True
540
+ elif _NOT_DONE_CLASS_RE.search(classes):
541
+ signals["todo_class"] = True
542
+
543
+ # 卡状态(**只留痕,⛔ 绝不当负信号**):真机 79096447 上"预约已取消"的预约卡恰好
544
+ # **不带** `andes-card--completed`(其余四项都带)⇒ 若把它当"未完成"负信号,会把
545
+ # 真机样本当场判成未完成(与事实相反)。它是"另一种终态"的证据,不是 done/not-done。
546
+ if isinstance(info.get("card_completed_class"), bool):
547
+ signals["card_completed_class"] = info["card_completed_class"]
548
+ if info.get("data_testid"):
549
+ signals["data_testid"] = _as_text(info.get("data_testid"))[:48]
550
+
551
+ if info.get("has_check_icon"):
552
+ signals["check_icon"] = True
553
+ if info.get("has_todo_button"):
554
+ signals["todo_button"] = True
555
+
556
+ if _DONE_TEXT_RE.search(label):
557
+ signals["done_word"] = True
558
+ if _TODO_TEXT_RE.search(label):
559
+ signals["todo_word"] = True
560
+
561
+ # 负面 evidence 优先(页面同时出现两套词时,宁可不判)
562
+ hard_false = [k for k in ("aria_checked", "todo_class", "todo_button", "todo_word",
563
+ "aria_todo_word") if signals.get(k) is True and k != "aria_checked"]
564
+ if signals.get("aria_checked") is False:
565
+ hard_false.append("aria_checked=false")
566
+ hard_true = [k for k in ("aria_checked", "done_class", "check_icon", "done_word",
567
+ "aria_done_word") if signals.get(k) is True]
568
+
569
+ if hard_false and not hard_true:
570
+ return False, signals, ""
571
+ if hard_true and not hard_false:
572
+ return True, signals, ""
573
+ if hard_true and hard_false:
574
+ # 自相矛盾(例如 card 容器 class 带 done、内部按钮仍可点):保守判 False
575
+ return False, signals, "conflicting-signals(保守判未完成)"
576
+ note = "unknown-structure(未识别出完成/未完成标记)" if not html else "no-signal"
577
+ return None, signals, note
578
+
579
+
580
+ def _appointment_cancelled(appt_item: dict, *, label: str, html: str, body: str,
581
+ cancel_button_present: Optional[bool], probe_ok: bool,
582
+ error_card_present: bool, error_item_indices: Optional[set] = None,
583
+ list_status: str = "") -> tuple[Optional[bool], str, dict]:
584
+ """预约环节是否**已取消** ⇒ `(值, 判据名, 附加 signals)`。
585
+
586
+ ⛔ **顺序即优先级**(改这条 = 改判据);四步判据的完整理由与真机证据见模块 docstring。
587
+ 真机校准(2026-10-09,样本 `79096447`:运营已手动完成、预约已取消):
588
+ - 旧实现把"页面文本含 `Reserva cancelada`"排在第一位,而**真机那一页通篇没有**
589
+ `Reserva`/`cancelad`/`Cancelar` 任何字样 ⇒ 旧判据恒不命中,落回 `null`;
590
+ - 真机**有**的可靠证据是两条:①预约项 `done is True`(环节已结束)
591
+ ②页面上**再也找不到** `Cancelar reserva`(取消入口已消失)
592
+ ⇒ 二者合取才唯一指向"结束的方式是取消"(见下面的排除逻辑)。
593
+ """
594
+ extra: dict[str, Any] = {}
595
+
596
+ # 步 0:残缺闸门 —— 缺证据 ≠ 完成(⛔ 绝不放行)
597
+ if not probe_ok:
598
+ return None, "probe-unusable", extra
599
+ if not appt_item:
600
+ return None, "appointment-region-missing", extra
601
+ if not _as_text(html).strip():
602
+ return None, "appointment-card-not-rendered", extra
603
+ if not _APPT_CARD_ROOT_RE.search(html):
604
+ return None, "appointment-card-unrecognized", extra
605
+ if error_card_present and int(appt_item.get("index", -1)) in (error_item_indices or set()):
606
+ # 预约项**自身**就是渲染残缺卡 ⇒ 预约区域被破坏
607
+ return None, "appointment-region-broken", extra
608
+
609
+ # 步 1:反证优先(预约还活着)—— ⛔ 按钮/改期入口压过一切正证据
610
+ if cancel_button_present is True:
611
+ return False, "cancel-button-present", extra
612
+ if _APPT_ALIVE_RE.search(f"{label} {html} {body}"):
613
+ return False, "appointment-alive-text", extra
614
+ if _APPT_ALIVE_WORD_RE.search(f"{label} {html} {body}"):
615
+ return False, "appointment-reschedule-affordance", extra
616
+
617
+ # 步 2:环节**明确没结束** ⇒ 谈不上"取消完成"
618
+ if appt_item.get("done") is False:
619
+ return False, "appointment-item-not-done", extra
620
+
621
+ # 步 3:明确取消文案(真机常常没有;有就最硬)
622
+ if _APPT_CANCELLED_RE.search(f"{label} {html} {body}"):
623
+ return True, "cancelled-text", extra
624
+
625
+ # 步 4:真机校准组合 —— 环节已结束 ∧ 取消入口已消失
626
+ if appt_item.get("done") is True and cancel_button_present is False:
627
+ if _LIST_ALIVE_RE.search(_as_text(list_status)):
628
+ # 组合证据被列表页否决 ⇒ 退回"判不了"(⛔ 不硬判 false:hub 侧证据没被证伪)
629
+ extra["list_status_vetoes"] = _as_text(list_status)[:80]
630
+ return None, "step-closed-but-list-status-alive", extra
631
+ if _APPT_CANCELLED_RE.search(_as_text(list_status)):
632
+ extra["list_status_confirms"] = _as_text(list_status)[:80]
633
+ return True, "step-closed-and-no-cancel-affordance", extra
634
+
635
+ # 步 5:判不了(例如"从未预约":环节未结束 ∧ 也没有取消按钮 ⇒ 两条都不满足)
636
+ return None, "no-evidence", extra
637
+
638
+
639
+ def _raw_item_of(info: dict, label: str) -> dict:
640
+ """逐项**原始证据**投影(进 `state["raw"]["items"]`,供离线复核与下一轮校准)。
641
+
642
+ 2026-10-09 真机校准的教训:旧投影只留 `label` + `html`(600 字符截断)⇒ 解析器
643
+ 记下 `done_class=true` 之后,**没有人能再回答"它从哪个词元来"**(命中词元落在
644
+ 截断之外)⇒ 判据无法校准。这里把 v2 证据字段一并带上(⛔ 只留证据,不改判据)。
645
+ """
646
+ out = {"label": label, "html": _as_text(info.get("html"))[:MAX_RAW_SNIPPET]}
647
+ for key in ("classes", "aria_label"):
648
+ v = _as_text(info.get(key))
649
+ if v:
650
+ out[key] = v[:MAX_RAW_SNIPPET]
651
+ for key in ("class_tokens", "done_class_hits", "todo_class_hits"):
652
+ v = info.get(key)
653
+ if isinstance(v, list):
654
+ out[key] = [_as_text(x)[:64] for x in v[:160] if _as_text(x)]
655
+ for key in ("card_completed_class", "has_check_icon", "has_todo_button", "aria_checked",
656
+ "done_class", "todo_class"):
657
+ if isinstance(info.get(key), bool):
658
+ out[key] = info[key]
659
+ for key in ("tag", "data_testid"):
660
+ v = _as_text(info.get(key))
661
+ if v:
662
+ out[key] = v[:64]
663
+ return out
664
+
665
+
666
+ def _empty_state(shipment_id: str = "", url: str = "") -> dict:
667
+
668
+ return {
669
+ "shipment_id": str(shipment_id or ""),
670
+ "url": url or "",
671
+ "url_inbound": "",
672
+ "title": "",
673
+ "h1": "",
674
+ "ready_state": "",
675
+ "body_text": "",
676
+ "status_words": {},
677
+ "cancel_button_present": None,
678
+ "list_status": "",
679
+ "appointment_cancelled_by": "",
680
+ "items": [],
681
+ "by_key": {},
682
+ "unknown_items": [],
683
+ "appointment_cancelled": None,
684
+ "labels_downloaded": None,
685
+ "box_labels_downloaded": None,
686
+ "all_items_done": False,
687
+ "operationally_complete": False,
688
+ "complete_reason": "",
689
+ "probe_ok": False,
690
+ "errors": [],
691
+ "raw": {},
692
+ }
693
+
694
+
695
+ def parse_hub_dump(dump: Any, shipment_id: str = "", list_status: str = "") -> dict:
696
+ """DOM dump(dict 或 JSON 字符串)→ 结构化 hub 状态。**从不抛异常**(容错是硬要求)。
697
+
698
+ `list_status`(可选):列表页/表里的粗粒度状态原文。**⛔ 永远不是唯一依据** ——
699
+ 它只能在第 4 步**否决**组合判据,或作为佐证留痕(接口与优先级见模块 docstring)。
700
+ """
701
+ state = _empty_state(shipment_id=shipment_id)
702
+ errors: list = state["errors"]
703
+
704
+ data: dict
705
+ if isinstance(dump, str):
706
+ try:
707
+ data = json.loads(dump or "{}")
708
+ except Exception as exc: # noqa: BLE001
709
+ errors.append(f"dump-not-json: {type(exc).__name__}: {exc}")
710
+ data = {}
711
+ elif isinstance(dump, dict):
712
+ data = dump
713
+ else:
714
+ errors.append(f"dump-type-unexpected: {type(dump).__name__}")
715
+ data = {}
716
+ if not isinstance(data, dict):
717
+ errors.append("dump-not-object")
718
+ data = {}
719
+ errors.extend([_as_text(e) for e in (data.get("errors") or []) if e])
720
+
721
+ url = _as_text(data.get("url"))
722
+ state["url"] = url
723
+ state["title"] = _as_text(data.get("title"))[:300]
724
+ state["h1"] = _as_text(data.get("h1"))[:300]
725
+ state["ready_state"] = _as_text(data.get("ready_state"))[:32]
726
+ body = _as_text(data.get("body_text"))[:MAX_BODY_TEXT]
727
+ state["body_text"] = body
728
+
729
+ # 真实 URL 被跳转时以 URL 为准(「真相在 URL」——与 ⑰ 取号缺陷同一口径)
730
+ m = re.search(r"/inbounds/(\d{6,})/", url)
731
+ state["url_inbound"] = m.group(1) if m else ""
732
+ if not state["shipment_id"] and state["url_inbound"]:
733
+ state["shipment_id"] = state["url_inbound"]
734
+
735
+ words = data.get("status_words")
736
+ if isinstance(words, dict):
737
+ state["status_words"] = {str(k): v for k, v in words.items() if v}
738
+
739
+ cbp = data.get("cancel_button_present")
740
+ state["cancel_button_present"] = cbp if isinstance(cbp, bool) else None
741
+
742
+ # ── 逐项:如实 dump,认不出也留 ──────────────────────────────
743
+ items: list = []
744
+ raw_items: list = []
745
+ error_item_indices: set = set()
746
+ raw = data.get("items")
747
+ if not isinstance(raw, list):
748
+ if raw is not None:
749
+ errors.append("items-not-list")
750
+ raw = []
751
+ for idx, info in enumerate(raw[:MAX_ITEMS]):
752
+ if not isinstance(info, dict):
753
+ items.append({"key": None, "label": _as_text(info)[:180], "done": None,
754
+ "known": False, "signals": {},
755
+ "note": "unknown-structure(条目不是对象)"})
756
+ continue
757
+ label = _as_text(info.get("label"))[:180]
758
+ key = classify_item(label)
759
+ done, signals, note = _item_done(info)
760
+ entry = {
761
+ "key": key,
762
+ "label": label,
763
+ "done": done,
764
+ "known": key is not None,
765
+ "signals": signals,
766
+ "note": note,
767
+ "index": idx,
768
+ }
769
+ items.append(entry)
770
+ if not key:
771
+ state["unknown_items"].append({"label": label, "done": None,
772
+ "note": note or "unknown-structure"})
773
+ raw_items.append(_raw_item_of(info, label))
774
+ state["items"] = items
775
+
776
+ # 同类多项时取「最强的那个」:done True 优先,其次 False,再次 None
777
+ def _rank(e: dict) -> int:
778
+ return {True: 0, False: 1, None: 2}.get(e.get("done"), 3)
779
+
780
+ by_key: dict = {}
781
+ for e in items:
782
+ k = e.get("key")
783
+ if not k:
784
+ continue
785
+ cur = by_key.get(k)
786
+ if cur is None or _rank(e) < _rank(cur):
787
+ by_key[k] = e
788
+ state["by_key"] = by_key
789
+
790
+ # 探针有效性:**必须在派生结论之前**算出来(残缺闸门要用它)
791
+ probe_ok = bool(url or items or body)
792
+ state["probe_ok"] = probe_ok
793
+ # 渲染残缺证据(v2 字段 + 逐项兜底识别):⛔ 它**不等于**"预约区域缺失"
794
+ error_card_present = bool(
795
+ isinstance(data.get("error_cards"), list) and data.get("error_cards"))
796
+ for idx, info in enumerate(raw[:MAX_ITEMS]):
797
+ if not isinstance(info, dict):
798
+ continue
799
+ if (_ERROR_CARD_RE.search(_as_text(info.get("classes")))
800
+ or _ERROR_CARD_RE.search(_as_text(info.get("html")))
801
+ or _ERROR_TEXT_RE.search(_as_text(info.get("label")))):
802
+ error_card_present = True
803
+ error_item_indices.add(idx)
804
+
805
+ # ── 派生结论(规则见模块 docstring 的表) ────────────────────
806
+ appt_item = by_key.get(KEY_APPOINTMENT) or {}
807
+ appt_text = f"{_as_text(appt_item.get('label'))} {body}"
808
+ # 逐项 html 只在 `raw` 投影里(parsed entry 不带原始片段)⇒ 按 index 取回
809
+ _ai = appt_item.get("index")
810
+ appt_html = (_as_text(raw_items[_ai].get("html"))
811
+ if isinstance(_ai, int) and 0 <= _ai < len(raw_items) else "")
812
+
813
+ cancelled, cancelled_by, cancelled_extra = _appointment_cancelled(
814
+ appt_item,
815
+ label=_as_text(appt_item.get("label")),
816
+ html=appt_html,
817
+ body=body,
818
+ cancel_button_present=state["cancel_button_present"],
819
+ probe_ok=probe_ok,
820
+ error_card_present=error_card_present,
821
+ error_item_indices=error_item_indices,
822
+ list_status=list_status,
823
+ )
824
+ state["appointment_cancelled"] = cancelled
825
+ state["list_status"] = _as_text(list_status)[:120]
826
+ state.setdefault("signals", {})["appointment_cancelled_by"] = cancelled_by
827
+ state["signals"].update(cancelled_extra)
828
+
829
+ labels_item = by_key.get(KEY_LABELS) or {}
830
+ if labels_item.get("done") is True:
831
+ state["labels_downloaded"] = True
832
+ elif _LABELS_DONE_TEXT_RE.search(body):
833
+ state["labels_downloaded"] = True
834
+ elif labels_item.get("done") is False:
835
+ state["labels_downloaded"] = False
836
+ else:
837
+ state["labels_downloaded"] = None
838
+
839
+ box_item = by_key.get(KEY_BOX_LABELS) or {}
840
+ if box_item.get("done") is True:
841
+ state["box_labels_downloaded"] = True
842
+ elif _BOX_DONE_TEXT_RE.search(body):
843
+ state["box_labels_downloaded"] = True
844
+ elif box_item.get("done") is False:
845
+ state["box_labels_downloaded"] = False
846
+ else:
847
+ state["box_labels_downloaded"] = None
848
+
849
+ known_items = [e for e in items if e.get("known")]
850
+ state["all_items_done"] = bool(
851
+ known_items
852
+ and not state["unknown_items"]
853
+ and all(e.get("done") is True for e in known_items)
854
+ )
855
+
856
+ state["operationally_complete"] = (
857
+ state["appointment_cancelled"] is True
858
+ and state["labels_downloaded"] is True
859
+ and state["box_labels_downloaded"] is True
860
+ )
861
+ if state["operationally_complete"]:
862
+ state["complete_reason"] = ("预约已取消 ∧ 产品标签已下载 ∧ 箱唛已下载(hub-v2 实测)")
863
+ else:
864
+ missing = []
865
+ if state["appointment_cancelled"] is not True:
866
+ missing.append(f"预约未确认取消({_tri(state['appointment_cancelled'])})")
867
+ if state["labels_downloaded"] is not True:
868
+ missing.append(f"产品标签未确认下载({_tri(state['labels_downloaded'])})")
869
+ if state["box_labels_downloaded"] is not True:
870
+ missing.append(f"箱唛未确认下载({_tri(state['box_labels_downloaded'])})")
871
+ state["complete_reason"] = "未识别为完成:缺 " + "、".join(missing)
872
+
873
+ state["probe_ok"] = probe_ok
874
+ state["raw"] = {
875
+ "body_text": body,
876
+ "h1": state["h1"],
877
+ "matched_url": url,
878
+ "probe_version": data.get("probe_version") if isinstance(data.get("probe_version"), int) else 1,
879
+ "error_cards": [{"label": _as_text(c.get("label"))[:160],
880
+ "classes": _as_text(c.get("classes"))[:160]}
881
+ for c in (data.get("error_cards") or []) if isinstance(c, dict)][:5],
882
+ "items": raw_items,
883
+ }
884
+ if not probe_ok:
885
+ errors.append("empty-dump(URL/正文/逐项全空 ⇒ 页面可能未加载或探针失效)")
886
+ return state
887
+
888
+
889
+ # ────────────────────────────────────────────────
890
+ # 步骤7「进不了箱数页」的**只读归因**(2026-10-12 生产事故批)
891
+ # ────────────────────────────────────────────────
892
+ #
893
+ # ## 为什么要它(现场,货件 79301358 / SKU HW-MX-162 / 1 店)
894
+ #
895
+ # 步骤7 连续 **5 次**失败,文案一律是「页面未到箱数填写页:2 轮自愈+重导航后仍未见
896
+ # 「Agrupa tu stock」」⇒ 这句话**只说"没看到什么"**,不说"页面到底是什么样" ⇒ 角色据此刻度
897
+ # 升级为「脚本缺陷/门户改版(入口缺失)⇒ 需 ml-dev 修选择器」—— **误导**(ml-dev 无从下手)。
898
+ #
899
+ # **真因**(运营判定 + 门户实读):商品被判为**超大件**,而该货件空间**只收中小件**:
900
+ # · units 页 `size-dimensions-tag` = `EXTRAGRANDE`;`volume-pill` = `11,712 cm 3`
901
+ # · 空间原文「Pequeños y medianos — Este espacio incluye a los productos Pequeños y medianos.
902
+ # Revisa el tamaño de cada uno junto a su nombre.」
903
+ # · 库存输入框(`aria-label="Stock a enviar"`,值 200)被平台渲染为 **disabled**
904
+ # · hub 第 5 项「Prepara bultos y/o pallets…」`done=False`,紧随其后
905
+ # 「Debes completar las tareas anteriores para preparar e identificar.」
906
+ # 运营实测:把该 disabled 输入框强制启用+重输+点 Guardar **无任何效果**(hub 项不变)
907
+ # ⇒ **脚本侧无解**,必须运营改商品(尺寸或渠道)。
908
+ #
909
+ # ## 判据(**顺序即优先级**;⛔ 改这条 = 改判据;唯一实现见 `step7_block_verdict()`)
910
+ #
911
+ # | 序 | 结论 kind | 判据 |
912
+ # |---|---|---|
913
+ # | ① | `size-block` | measures 到尺寸标签,且 `size_tag_allowed(tag) is False`(标签 ∉ 中小件允许集) |
914
+ # | ② | `precondition` | hub 有 `done is False` 的项,**或**正文含「Debes completar las tareas anteriores」 |
915
+ # | ③ | `unattributed` | 都不是 ⇒ 保留旧文案 + 补证据(`Agrupa tu stock` 缺失 / 当时 URL / 自愈轮数) |
916
+ #
917
+ # ⚠️ **判别力:为什么①的判据是"商品自己的尺寸标签",而不是"页面上有空间限制文案"**
918
+ # (⛔ 别改回去):空间文案「Pequeños y medianos — Este espacio incluye…」描述的是**空间**
919
+ # ("这个空间收中小件"),它在**任何一个**中小件货件的 units 页上都会出现 ⇒ 单凭它判①会把
920
+ # **正常票**全判成尺寸阻塞(回归 §② 的负样本正是这个形态)。故空间文案 / 「Revisar dimensiones
921
+ # permitidas」只作**佐证留痕**(`signals.space_restricted`),判据仍是商品自己的标签。
922
+ #
923
+ # ⚠️ **探测失败 ⇒ 一律③**(⛔ 绝不上抛、⛔ 绝不改成"业务阻塞"):归因缺失只能让证据少一句,
924
+ # 不能让失败码/阻断性发生变化(旧文案与失败类型逐字保留)。
925
+ _SIZE_TAG_ALLOWED: tuple[str, ...] = (
926
+ # 中小件允许集(= 空间文案「Pequeños y medianos」的取值域);⛔ 刻意不含 grande/extragrande
927
+ "PEQUENO", "PEQUENA", "PEQUENOS", "PEQUENAS",
928
+ "MEDIANO", "MEDIANA", "MEDIANOS", "MEDIANAS",
929
+ "CHICO", "CHICA", "CHICOS", "CHICAS",
930
+ )
931
+
932
+ # 标签里的连接词/标签词("Tamaño: pequeño y mediano" 这类写法)——**只用于归一化取词元**,不参与判定
933
+ _SIZE_TAG_STOP: frozenset = frozenset({
934
+ "Y", "O", "E", "U", "DE", "DEL", "LA", "EL", "LOS", "LAS", "TAMANO", "TAMANOS",
935
+ })
936
+
937
+ _ACCENTS = str.maketrans({
938
+ "Á": "A", "É": "E", "Í": "I", "Ó": "O", "Ú": "U", "Ü": "U", "Ñ": "N",
939
+ "á": "A", "é": "E", "í": "I", "ó": "O", "ú": "U", "ü": "U", "ñ": "N",
940
+ })
941
+
942
+ # 「这个空间只收中小件」的空间原文(**佐证**,不是判据;见上面的判别力说明)
943
+ # 现场原文:「Pequeños y medianos — Este espacio incluye a los productos Pequeños y medianos.
944
+ # Revisa el tamaño de cada uno junto a su nombre.」+链接「Revisar dimensiones permitidas」
945
+ _SIZE_SPACE_RESTRICTED_RE = re.compile(
946
+ r"peque[nñ]os\s+y\s+medianos[^.]{0,160}\.?|"
947
+ r"espacio\s+incluye\s+a\s+los\s+productos[^.]{0,160}\.?|"
948
+ r"revisar?\s+(?:las\s+)?dimensiones\s+permitidas|"
949
+ r"dimensiones\s+permitidas|"
950
+ r"c[oó]mo\s+clasifican\s+los\s+productos\s+por\s+tama[nñ]o",
951
+ re.IGNORECASE)
952
+
953
+ # hub 上的「待复核商品数」原文(①的证据要素之一)
954
+ _HUB_UNITS_PENDING_RE = re.compile(
955
+ r"tienes\s+\d+\s+productos?\s+para\s+revisar", re.IGNORECASE)
956
+
957
+ # hub 上的「前面的任务还没做完」原文(②的判据之一,也是①的证据要素之一)
958
+ _HUB_PRECONDITION_TEXT_RE = re.compile(
959
+ r"(?:debes\s+)?completar\s+las\s+tareas\s+anteriores[^.]*\.?", re.IGNORECASE)
960
+
961
+ # 步骤7 旧文案(**③必须逐字保留**⇒ 构造点收敛到这里一处,⛔ 别在编排器里再写一份)
962
+ _STEP7_LEGACY_HEAD = "页面未到箱数填写页:2 轮自愈+重导航后仍未见「Agrupa tu stock」"
963
+
964
+
965
+ def normalize_size_tag(tag: Any) -> str:
966
+ """尺寸标签 → 判据口径(去重音、大写、压缩空白)。⛔ 只做归一化,不做判定。"""
967
+ return re.sub(r"\s+", " ", _as_text(tag).translate(_ACCENTS)).strip().upper()
968
+
969
+
970
+ def size_tag_allowed(tag: Any) -> Optional[bool]:
971
+ """尺寸标签是否落在**中小件**允许集内 ⇒ `True`/`False`;拿不到标签 ⇒ `None`(判不了)。
972
+
973
+ 按**词元**匹配(标签可能是「Pequeño y mediano」这类组合写法);只要出现允许集之外的
974
+ 尺寸词元(如 `EXTRAGRANDE`)就判 `False`。
975
+ ⛔ 绝不据"没见过的词"猜成允许 —— 本判据的代价不对称:误判成"正常"会把运营挡在门外,
976
+ 误判成"尺寸问题"运营一眼就能核(文案里带 tag 与体积原文)。
977
+ """
978
+ tokens = [t for t in re.split(r"[^A-Z0-9]+", normalize_size_tag(tag)) if t]
979
+ tokens = [t for t in tokens if t not in _SIZE_TAG_STOP]
980
+ if not tokens:
981
+ return None
982
+ return all(t in _SIZE_TAG_ALLOWED for t in tokens)
983
+
984
+
985
+ def _empty_units_state(shipment_id: str = "", url: str = "") -> dict:
986
+ return {
987
+ "shipment_id": str(shipment_id or ""),
988
+ "url": url or "",
989
+ "url_inbound": "",
990
+ "title": "",
991
+ "ready_state": "",
992
+ "body_text": "",
993
+ "size_dimensions_tag": "",
994
+ "volume_pill": "",
995
+ "size_tag_allowed": None,
996
+ "space_restricted": False,
997
+ "space_restricted_text": "",
998
+ "stock_input_present": False,
999
+ "stock_input_disabled": False,
1000
+ "stock_input_aria": "",
1001
+ "stock_input_value": "",
1002
+ "probe_ok": False,
1003
+ "errors": [],
1004
+ }
1005
+
1006
+
1007
+ def parse_units_dump(dump: Any, shipment_id: str = "") -> dict:
1008
+ """units 页 DOM dump(`JS_UNITS_DUMP` 的产物)→ 结构化事实。**从不抛异常**。
1009
+
1010
+ 只做**事实登记**(原文/渲染态)与两个**归一化事实**:
1011
+ `size_tag_allowed`(走 `size_tag_allowed()`)与 `space_restricted`(走空间原文正则)。
1012
+ ⛔ 这里**不下结论** —— "该不该判尺寸阻塞"是 `step7_block_verdict()` 的事。
1013
+ """
1014
+ state = _empty_units_state(shipment_id=shipment_id)
1015
+ errors: list = state["errors"]
1016
+
1017
+ data: dict
1018
+ if isinstance(dump, str):
1019
+ try:
1020
+ data = json.loads(dump or "{}")
1021
+ except Exception as exc: # noqa: BLE001
1022
+ errors.append(f"dump-not-json: {type(exc).__name__}: {exc}")
1023
+ data = {}
1024
+ elif isinstance(dump, dict):
1025
+ data = dump
1026
+ else:
1027
+ errors.append(f"dump-type-unexpected: {type(dump).__name__}")
1028
+ data = {}
1029
+ if not isinstance(data, dict):
1030
+ errors.append("dump-not-object")
1031
+ data = {}
1032
+ errors.extend([_as_text(e) for e in (data.get("errors") or []) if e])
1033
+
1034
+ url = _as_text(data.get("url"))
1035
+ state["url"] = url
1036
+ state["title"] = _as_text(data.get("title"))[:300]
1037
+ state["ready_state"] = _as_text(data.get("ready_state"))[:32]
1038
+ body = _as_text(data.get("body_text"))[:MAX_BODY_TEXT]
1039
+ state["body_text"] = body
1040
+
1041
+ m = re.search(r"/inbounds/(\d{6,})/", url)
1042
+ state["url_inbound"] = m.group(1) if m else ""
1043
+ if not state["shipment_id"] and state["url_inbound"]:
1044
+ state["shipment_id"] = state["url_inbound"]
1045
+
1046
+ state["size_dimensions_tag"] = _as_text(data.get("size_dimensions_tag"))[:120]
1047
+ state["volume_pill"] = _as_text(data.get("volume_pill"))[:120]
1048
+ state["size_tag_allowed"] = size_tag_allowed(state["size_dimensions_tag"])
1049
+
1050
+ hit = _SIZE_SPACE_RESTRICTED_RE.search(body)
1051
+ state["space_restricted"] = bool(hit)
1052
+ state["space_restricted_text"] = (hit.group(0)[:200] if hit else "")
1053
+
1054
+ for key in ("stock_input_present", "stock_input_disabled"):
1055
+ state[key] = bool(data.get(key))
1056
+ state["stock_input_aria"] = _as_text(data.get("stock_input_aria"))[:120]
1057
+ state["stock_input_value"] = _as_text(data.get("stock_input_value"))[:40]
1058
+
1059
+ state["probe_ok"] = bool(url or body or state["size_dimensions_tag"] or state["volume_pill"])
1060
+ if not state["probe_ok"]:
1061
+ errors.append("empty-dump(URL/正文/尺寸标签/体积全空 ⇒ 页面可能未加载或探针失效)")
1062
+ return state
1063
+
1064
+
1065
+ def _hub_pending_items(hub: Any) -> list:
1066
+ """hub 状态里 `done is False` 的项(**原文**),供②列未完成项。"""
1067
+ if not isinstance(hub, dict):
1068
+ return []
1069
+ out = []
1070
+ for e in (hub.get("items") or []):
1071
+ if isinstance(e, dict) and e.get("done") is False:
1072
+ label = _as_text(e.get("label"))[:160]
1073
+ if label:
1074
+ out.append(label)
1075
+ return out
1076
+
1077
+
1078
+ def _coerce_hub(v: Any, shipment_id: str = "") -> Any:
1079
+ """`step7_block_verdict` 的 hub 入参:**已解析状态**直接用,其余(原始 dump/None)就地解析。
1080
+
1081
+ 判据是"有没有 `probe_ok` 键"——`parse_hub_dump()` 的产物恒有它(原文 dump 不会有)。
1082
+ ⛔ 解析失败一律返回 `None`(= 证据缺失 ⇒ 走③),不上抛。
1083
+ """
1084
+ if v is None:
1085
+ return None
1086
+ if isinstance(v, dict) and "probe_ok" in v:
1087
+ return v
1088
+ try:
1089
+ return parse_hub_dump(v, shipment_id=shipment_id)
1090
+ except Exception: # noqa: BLE001
1091
+ return None
1092
+
1093
+
1094
+ def step7_block_verdict(hub: Any = None, units: Any = None, *,
1095
+ shipment_id: str = "", url: str = "",
1096
+ heals: int = 0) -> dict:
1097
+ """步骤7「进不了箱数页」的**只读归因结论**(唯一判据入口;纯函数,可离线回归)。
1098
+
1099
+ `hub` / `units`:`parse_hub_dump()` / `parse_units_dump()` 的产物,**或**原始 dump
1100
+ (str/dict ⇒ 就地解析);`None` 表示该路探测不可用。`url` = 失败当时所在页 URL;
1101
+ `heals` = 本轮错误弹窗自愈次数(③证据用)。
1102
+
1103
+ 返回(**三个结论共用同一形状**,`err_type` 恒 `business` ⇒ ⛔ 探测结果不改变失败码):
1104
+
1105
+ {"kind": "size-block"|"precondition"|"unattributed",
1106
+ "err_type": "business", "blocking": True,
1107
+ "conclusion_zh": "<一句话结论>", "reason": "<进 failed.reason 的完整文案>",
1108
+ "trace": "<轮次日志一行,<1000 字>", "evidence": {...}, "signals": {...},
1109
+ "probe_ok": bool}
1110
+
1111
+ ⛔ 本函数**只读、零副作用**:不点按钮、不写任何状态、不导航。
1112
+ """
1113
+ hub_state = _coerce_hub(hub, shipment_id)
1114
+ units_state = units if (isinstance(units, dict) and "probe_ok" in units) else None
1115
+ if units_state is None and units is not None:
1116
+ try:
1117
+ units_state = parse_units_dump(units, shipment_id=shipment_id)
1118
+ except Exception: # noqa: BLE001
1119
+ units_state = None
1120
+ if not isinstance(units_state, dict):
1121
+ units_state = None
1122
+
1123
+ # ── 事实登记(一律先取回来,三个结论都要留痕)─────────────────────────────
1124
+ hub_body = _as_text((hub_state or {}).get("body_text"))
1125
+ units_body = _as_text((units_state or {}).get("body_text"))
1126
+ tag = _as_text((units_state or {}).get("size_dimensions_tag")).strip()
1127
+ volume = _as_text((units_state or {}).get("volume_pill")).strip()
1128
+ space_text = _as_text((units_state or {}).get("space_restricted_text")).strip()
1129
+ if not space_text:
1130
+ _hit = _SIZE_SPACE_RESTRICTED_RE.search(units_body)
1131
+ space_text = _hit.group(0) if _hit else ""
1132
+ allowed = (units_state or {}).get("size_tag_allowed")
1133
+ stock_disabled = bool((units_state or {}).get("stock_input_disabled"))
1134
+ pending = _hub_pending_items(hub_state)
1135
+
1136
+ combined_body = f"{hub_body} {units_body}"
1137
+ m_pending = _HUB_UNITS_PENDING_RE.search(combined_body)
1138
+ units_pending_text = m_pending.group(0).strip() if m_pending else ""
1139
+ m_pre = _HUB_PRECONDITION_TEXT_RE.search(combined_body)
1140
+ precondition_text = m_pre.group(0).strip() if m_pre else ""
1141
+
1142
+ probe_ok = bool((hub_state or {}).get("probe_ok")) or bool((units_state or {}).get("probe_ok"))
1143
+ units_page = _as_text((units_state or {}).get("url"))
1144
+ hub_page = _as_text((hub_state or {}).get("url"))
1145
+ # `url` = **失败当时所在页**(③ 的旧文案逐字用它);`evidence_page` = **证据来自哪一页**
1146
+ # (尺寸/体积在 units 页 ⇒ 证据页优先 units)。两者分开,否则"当时 URL"会被写成隔壁页面。
1147
+ fail_url = url or units_page or hub_page
1148
+ evidence_page = units_page or hub_page or fail_url
1149
+ heals = int(heals or 0)
1150
+
1151
+ evidence = {
1152
+ "url": fail_url,
1153
+ "evidence_page": evidence_page,
1154
+ "heals": heals,
1155
+ "units_url": units_page,
1156
+ "hub_url": hub_page,
1157
+ "size_dimensions_tag": tag,
1158
+ "volume_pill": volume,
1159
+ "size_tag_allowed": allowed,
1160
+ "space_restricted": bool(space_text),
1161
+ "space_restricted_text": space_text,
1162
+ "stock_input_disabled": stock_disabled,
1163
+ "stock_input_value": _as_text((units_state or {}).get("stock_input_value")),
1164
+ "hub_pending_items": pending,
1165
+ "hub_units_pending_text": units_pending_text,
1166
+ "hub_precondition_text": precondition_text,
1167
+ "hub_probe_ok": bool((hub_state or {}).get("probe_ok")),
1168
+ "units_probe_ok": bool((units_state or {}).get("probe_ok")),
1169
+ }
1170
+ signals = {
1171
+ "size_tag_allowed": allowed,
1172
+ "hub_pending_count": len(pending),
1173
+ "space_restricted": bool(space_text),
1174
+ "stock_input_disabled": stock_disabled,
1175
+ }
1176
+
1177
+ # 证据串(三个结论共用;**带 tag/体积/原文**留痕,⛔ 不塞整页文本)
1178
+ ev = {
1179
+ "tag 原文": tag or "(未取到)",
1180
+ "体积原文": volume or "(未取到)",
1181
+ "空间原文": space_text or "(未取到)",
1182
+ "hub「待复核商品」原文": units_pending_text or "(未取到)",
1183
+ "未完成项原文": ";".join(pending) if pending else "(无 done=False 的项)",
1184
+ "hub「前置未完成」原文": precondition_text or "(未取到)",
1185
+ "箱数输入框": ("disabled(平台渲染态)" if stock_disabled
1186
+ else "(未取到 disabled 渲染态)"),
1187
+ "证据页": evidence_page or "(URL 未取到)",
1188
+ }
1189
+
1190
+ def _evidence_line(*skip: str) -> str:
1191
+ """证据串(`skip` 里的键已进结论主句 ⇒ 不再重复一遍)。"""
1192
+ return "|".join(f"{k}={v}" for k, v in ev.items() if k not in skip)
1193
+
1194
+ evidence_line = _evidence_line()
1195
+
1196
+ # ── ① 产品尺寸分级阻塞(本批主目标)─────────────────────────────────────
1197
+ if tag and allowed is False:
1198
+ conclusion = (f"该商品被平台判为 **{tag}**"
1199
+ + (f"({volume})" if volume else "")
1200
+ + ",本货件空间只收**中小件**")
1201
+ reason = (
1202
+ f"⛔ 步骤7 无法进入箱数页:该商品被平台判为 **{tag}**"
1203
+ + (f"({volume})" if volume else "")
1204
+ + ",本货件空间只收**中小件** ⇒ 需运营**修正商品包装尺寸**或改走其他渠道;"
1205
+ "脚本侧无解(⛔ 不是门户改版/选择器问题)"
1206
+ + f"|{evidence_line}"
1207
+ )
1208
+ # 轮次日志与 `reason` **同源同要素**(tag/体积/空间原文/两条 hub 原文/证据页
1209
+ # 一个不少;⛔ 不塞整页文本,见 `_evidence_line()`),风格对齐既有 `_capacity_trace`
1210
+ trace = f"⛔ 归因=尺寸分级阻塞|{_evidence_line()}"
1211
+ return {
1212
+ "kind": "size-block", "err_type": "business", "blocking": True,
1213
+ "conclusion_zh": conclusion + " ⇒ 需运营修正商品包装尺寸(或改走其他渠道);脚本侧无解",
1214
+ "reason": reason, "trace": trace[:1000],
1215
+ "evidence": evidence, "signals": signals, "probe_ok": probe_ok,
1216
+ }
1217
+
1218
+ # ── ② 前置未完成(非尺寸类)────────────────────────────────────────────
1219
+ if pending or precondition_text:
1220
+ items_txt = ";".join(pending) if pending else "(hub 未逐项给出,见下方页面原文)"
1221
+ # 未完成项/前置原文已进主句 ⇒ 证据串里不再重复一遍
1222
+ _ev2 = _evidence_line("未完成项原文", "hub「前置未完成」原文")
1223
+ reason = (
1224
+ "⛔ 步骤7 无法进入箱数页:hub 上有**未完成的前置项**(⛔ 不是门户改版/选择器问题)"
1225
+ f"|未完成项原文={items_txt}"
1226
+ + (f"|hub「前置未完成」原文={precondition_text}" if precondition_text else "")
1227
+ + " ⇒ 需运营在门户完成这些项后再跑;脚本侧无解"
1228
+ + f"|{_ev2}"
1229
+ )
1230
+ # 同①:轮次日志带全**证据要素**(tag/体积/空间原文/hub 原文/证据页;缺的写"(未取到)")
1231
+ trace = f"⛔ 归因=前置未完成|{_evidence_line()}"
1232
+ return {
1233
+ "kind": "precondition", "err_type": "business", "blocking": True,
1234
+ "conclusion_zh": "需运营在门户完成未完成的前置项后再跑;脚本侧无解",
1235
+ "reason": reason, "trace": trace[:1000],
1236
+ "evidence": evidence, "signals": signals, "probe_ok": probe_ok,
1237
+ }
1238
+
1239
+ # ── ③ 都不是 ⇒ **逐字保留旧文案** + 补一句证据(供 ml-dev 排查门户改版)──
1240
+ legacy = (_STEP7_LEGACY_HEAD + f"|{fail_url}"
1241
+ + (f"|现场为 ML 错误弹窗(Algo salió mal)且已自愈 {heals} 次并退避"
1242
+ f" ⇒ **平台侧**,不是业务缺数据" if heals else ""))
1243
+ reason = (legacy
1244
+ + "|归因探测(只读)未命中:`Agrupa tu stock` 缺失"
1245
+ f" + 当时 URL={fail_url or '(URL 未取到)'} + 自愈轮数={heals}"
1246
+ " ⇒ 疑似门户改版,供 ml-dev 排查选择器"
1247
+ + f"|探测事实:{evidence_line}")
1248
+ trace = (f"归因=未命中(疑似门户改版)|`Agrupa tu stock` 缺失"
1249
+ f"|当时 URL={fail_url or '(URL 未取到)'}|自愈轮数={heals}"
1250
+ f"|探测事实={evidence_line}")
1251
+ return {
1252
+ "kind": "unattributed", "err_type": "business", "blocking": True,
1253
+ "conclusion_zh": "疑似门户改版(选择器/入口缺失);归因探测未命中尺寸分级或前置未完成",
1254
+ "reason": reason, "trace": trace[:1000],
1255
+ "evidence": evidence, "signals": signals, "probe_ok": probe_ok,
1256
+ }
1257
+
1258
+
1259
+ def _tri(v: Any) -> str:
1260
+ return "是" if v is True else ("否" if v is False else "判不了")
1261
+
1262
+
1263
+ def item_lines(state: dict) -> list[str]:
1264
+ """逐项一行(`中文项名「页面原文标签」=已完成/未完成/判不了`)。
1265
+
1266
+ 供日志/备注/failures.jsonl **复用同源同文**。中文项名只为**运营可读**(用户明确要求
1267
+ 「一段运营可读话术」);括号里恒为**页面原文**,⛔ 不改写、不翻译。
1268
+ """
1269
+ out = []
1270
+ for e in state.get("items") or []:
1271
+ label = _as_text(e.get("label"))[:60]
1272
+ d = e.get("done")
1273
+ mark = "已完成" if d is True else ("未完成" if d is False else "判不了")
1274
+ key = _KEY_ZH.get(e.get("key") or "") or "未知项"
1275
+ out.append(f"{key}「{label}」={mark}")
1276
+ return out
1277
+
1278
+
1279
+ # 已知项的**中文显示名**(仅用于人读的摘要;判据仍是 key,⛔ 与识别无关)
1280
+ _KEY_ZH: dict[str, str] = {
1281
+ KEY_PREPARE: "货件准备",
1282
+ KEY_APPOINTMENT: "预约",
1283
+ KEY_PACKAGING: "包装确认",
1284
+ KEY_LABELS: "产品标签",
1285
+ KEY_BOX_LABELS: "箱唛",
1286
+ }
1287
+
1288
+
1289
+ def summary_zh(state: dict) -> str:
1290
+ """一句话中文摘要(供 stderr 日志 / 表备注 / 台账 detail 复用)。"""
1291
+ if not state.get("probe_ok"):
1292
+ err = ";".join(state.get("errors") or [])[:120] or "无证据"
1293
+ return f"hub-v2 探针无有效结果({err})"
1294
+ parts = [
1295
+ f"预约{'已取消' if state.get('appointment_cancelled') is True else ('仍有效' if state.get('appointment_cancelled') is False else '判不了')}",
1296
+ f"产品标签{'已下载' if state.get('labels_downloaded') is True else ('未下载' if state.get('labels_downloaded') is False else '判不了')}",
1297
+ f"箱唛{'已下载' if state.get('box_labels_downloaded') is True else ('未下载' if state.get('box_labels_downloaded') is False else '判不了')}",
1298
+ ]
1299
+ tail = ";".join(item_lines(state)[:8])
1300
+ return ("hub-v2 实测:" + "、".join(parts)
1301
+ + (f"({state.get('shipment_id')})" if state.get("shipment_id") else "")
1302
+ + (f"|逐项:{tail}" if tail else ""))
1303
+
1304
+
1305
+ def completion_verdict(state: dict) -> dict:
1306
+ """完成度裁定(**确定性规则**,供收口/前置校验/失败现场三处共用)。
1307
+
1308
+ `operationally_complete=True` ⇒ 「识别为运营手动完成(依据 hub-v2 实测)」。
1309
+ ⛔ 本函数**不改任何数据**:它只给结论,写表与否由调用方按各自白名单决定。
1310
+ """
1311
+ ok = bool(state.get("operationally_complete"))
1312
+ return {
1313
+ "operationally_complete": ok,
1314
+ "identified_as": "manual-completion-by-hub" if ok else "",
1315
+ "note_zh": ("识别为运营手动完成(依据 hub-v2 实测):" + summary_zh(state)) if ok else "",
1316
+ "reason": state.get("complete_reason") or "",
1317
+ "items": item_lines(state),
1318
+ "shipment_id": state.get("shipment_id") or "",
1319
+ "url_inbound": state.get("url_inbound") or "",
1320
+ "probe_ok": bool(state.get("probe_ok")),
1321
+ }
1322
+
1323
+
1324
+ # ────────────────────────────────────────────────
1325
+ # 内置夹具(纯离线;供 CLI 演示与回归共用判据)
1326
+ # ────────────────────────────────────────────────
1327
+
1328
+ def _fixture_item(label: str, done: Optional[bool], *, card: str = "hub--card",
1329
+ testid: str = "link-fixture",
1330
+ completed: Optional[bool] = None) -> dict:
1331
+ """夹具条目:`label` + **真机形态的卡片 html**(`hub--card` + `data-testid`)。
1332
+
1333
+ ⚠️ 2026-10-09 真机校准:html 必须带卡片根标记 —— 判据的**第 0 步**(残缺闸门)要
1334
+ 区分"预约区域没渲染"与"渲染了但判不了";旧夹具的 `<div>{label}</div>` 在真机上
1335
+ 不存在,会把残缺闸门喂成假阳性。
1336
+ """
1337
+ comp = " andes-card--completed" if (completed is True or (completed is None and done is True)) else ""
1338
+ info: dict = {
1339
+ "label": label,
1340
+ "html": (f'<div class="tour__fixture {card} " data-testid="{testid}" role="button" '
1341
+ f'tabindex="0"><div class="andes-card{comp} andes-card--flat '
1342
+ f'andes-card--primary andes-card--padding-0"><div class="andes-card__content">'
1343
+ f'<span class="hub--card__content-summary-title">{label}</span>'
1344
+ f'</div></div></div>'),
1345
+ "data_testid": testid,
1346
+ "card_completed_class": bool(comp),
1347
+ # v2 证据字段:与 class 命中**同义**(真机探针会同时给出这两者)
1348
+ "done_class_hits": (["andes-card--completed"] if (completed is True
1349
+ or (completed is None and done is True))
1350
+ else []),
1351
+ "todo_class_hits": [],
1352
+ }
1353
+ if done is True:
1354
+ info["aria_checked"] = True
1355
+ elif done is False:
1356
+ info["has_todo_button"] = True
1357
+ return info
1358
+
1359
+
1360
+ FIXTURES: dict[str, dict] = {
1361
+ # ① 全勾选 + 预约取消(= 现场 79096447 的形态)
1362
+ "all-done": {
1363
+ "url": hub_url("79096447", FIXTURE_BASE_URL),
1364
+ "title": "Envío #79096447",
1365
+ "h1": "Envío #79096447",
1366
+ "ready_state": "complete",
1367
+ "body_text": ("Ir a Gestión de envíos Full Cómo preparar tu envío Envío #79096447 "
1368
+ "Preparación del envío Elige cómo y cuándo vas a enviar tus productos "
1369
+ "Reserva cancelada Revisa la preparación y reglamentación de tus productos "
1370
+ "Descarga las etiquetas para tus productos Descarga las etiquetas de tus bultos"),
1371
+ "cancel_button_present": False,
1372
+ "items": [
1373
+ _fixture_item("Preparación del envío Completado", True),
1374
+ _fixture_item("Elige cómo y cuándo vas a enviar tus productos Reserva cancelada", True),
1375
+ _fixture_item("Revisa la preparación y reglamentación de tus productos Completado", True),
1376
+ _fixture_item("Descarga las etiquetas para tus productos Completado", True),
1377
+ _fixture_item("Descarga las etiquetas de tus bultos Completado", True),
1378
+ ],
1379
+ },
1380
+ # ② 缺箱唛(预约已取消、标签已下载、箱唛仍待下载)
1381
+ "missing-box": {
1382
+ "url": hub_url("79096447", FIXTURE_BASE_URL),
1383
+ "h1": "Envío #79096447",
1384
+ "body_text": ("Preparación del envío Reserva cancelada Revisa la preparación "
1385
+ "Descarga las etiquetas para tus productos Descargar etiquetas"),
1386
+ "cancel_button_present": False,
1387
+ "items": [
1388
+ _fixture_item("Preparación del envío Completado", True),
1389
+ _fixture_item("Elige cómo y cuándo vas a enviar tus productos Reserva cancelada", True),
1390
+ _fixture_item("Revisa la preparación Completado", True),
1391
+ _fixture_item("Descarga las etiquetas para tus productos Completado", True),
1392
+ _fixture_item("Descarga las etiquetas de tus bultos Descargar etiquetas", False),
1393
+ ],
1394
+ },
1395
+ # ③ 改版结构:全是不认识的项,零完成标记(保守 ⇒ 判不了/未完成)
1396
+ "revamp-unknown": {
1397
+ "url": hub_url("79096447", FIXTURE_BASE_URL),
1398
+ "h1": "Envío #79096447",
1399
+ "body_text": "Nueva interfaz de preparación de envíos",
1400
+ "items": [
1401
+ {"label": "Paso alpha", "html": "<div class='x'>Paso alpha</div>"},
1402
+ {"label": "Paso beta", "html": "<div class='y'>Paso beta</div>"},
1403
+ ],
1404
+ },
1405
+ # ④ 空 dump(探针失效)
1406
+ "empty": {},
1407
+ # ── ⑤~⑧ 真机校准新增(2026-10-09):四条**负样本**,全部"宁可不判也不误判完成" ──
1408
+ # ⑤ 预约**仍活着**:取消按钮还在 ⇒ 必须 false(按钮反证压过一切正证据)。
1409
+ # 形态 = 真机样本原样 + 只把 `cancel_button_present` 翻成 True + 正文补回预约文案。
1410
+ "appointment-alive": {
1411
+ "url": hub_url("12345678", FIXTURE_BASE_URL),
1412
+ "h1": "Preparación del envío",
1413
+ "body_text": ("Preparación del envío Elige cómo y cuándo vas a enviar tus productos "
1414
+ "Cita: lunes 13 de octubre Cancelar reserva"),
1415
+ "cancel_button_present": True,
1416
+ "items": [
1417
+ _fixture_item("Vas a enviar 9 unidades Descargar listado Editar", True,
1418
+ testid="link-units"),
1419
+ _fixture_item("Elige cómo y cuándo vas a enviar tus productos", True,
1420
+ testid="link-appointment"),
1421
+ _fixture_item("Revisaste la preparación y reglamentación de tus productos Editar",
1422
+ True, testid="link-procedures"),
1423
+ _fixture_item("Descargaste las etiquetas para todos tus productos Descargar listado",
1424
+ True, testid="link-identifiers"),
1425
+ _fixture_item("Preparaste e identificaste 3 bultos Revisar", True,
1426
+ testid="link-volumes"),
1427
+ ],
1428
+ },
1429
+ # ⑥ 预约**从未预约**(环节没结束、也没有取消按钮)⇒ 判不了,⛔ 绝不当成"已取消"。
1430
+ # 这是组合判据的**关键护栏**:只看"没有取消按钮"会把它误判成完成。
1431
+ "appointment-not-concluded": {
1432
+ "url": hub_url("12345678", FIXTURE_BASE_URL),
1433
+ "h1": "Preparación del envío",
1434
+ "body_text": ("Preparación del envío Elige cómo y cuándo vas a enviar tus productos "
1435
+ "Revisaste la preparación y reglamentación de tus productos "
1436
+ "Descargaste las etiquetas para todos tus productos"),
1437
+ "cancel_button_present": False,
1438
+ "items": [
1439
+ _fixture_item("Vas a enviar 9 unidades Descargar listado Editar", True,
1440
+ testid="link-units"),
1441
+ _fixture_item("Elige cómo y cuándo vas a enviar tus productos", None,
1442
+ testid="link-appointment"),
1443
+ _fixture_item("Revisaste la preparación y reglamentación de tus productos Editar",
1444
+ True, testid="link-procedures"),
1445
+ _fixture_item("Descargaste las etiquetas para todos tus productos Descargar listado",
1446
+ True, testid="link-identifiers"),
1447
+ _fixture_item("Preparaste e identificaste 3 bultos Revisar", True,
1448
+ testid="link-volumes"),
1449
+ ],
1450
+ },
1451
+ # ⑦ 页面残缺:预约区域**整个没渲染**(被渲染残缺卡顶替)⇒ 判不了、⛔ 不算完成
1452
+ # (真机上那段 `Algo salió mal...No pudimos mostrar la información`)
1453
+ "page-broken": {
1454
+ "url": hub_url("12345678", FIXTURE_BASE_URL),
1455
+ "h1": "Preparación del envío",
1456
+ "body_text": ("Preparación del envío Descargaste las etiquetas para todos tus productos "
1457
+ "Algo salió mal"),
1458
+ "cancel_button_present": False,
1459
+ "error_cards": [{"label": ("Algo salió mal...No pudimos mostrar la información. "
1460
+ "Prueba recargando la página.Recargar página"),
1461
+ "classes": "seller-monitor__error-card"}],
1462
+ "items": [
1463
+ _fixture_item("Vas a enviar 9 unidades Descargar listado Editar", True,
1464
+ testid="link-units"),
1465
+ {"label": ("Algo salió mal...No pudimos mostrar la información. "
1466
+ "Prueba recargando la página.Recargar página"),
1467
+ "html": ('<div class="seller-monitor__error-card"><span>Algo salió mal...'
1468
+ 'No pudimos mostrar la información.</span></div>')},
1469
+ _fixture_item("Descargaste las etiquetas para todos tus productos Descargar listado",
1470
+ True, testid="link-identifiers"),
1471
+ _fixture_item("Preparaste e identificaste 3 bultos Revisar", True,
1472
+ testid="link-volumes"),
1473
+ ],
1474
+ },
1475
+ # ⑧ 预约项在,但**卡片本身没渲染**(html 空)⇒ 判不了、⛔ 不算完成
1476
+ "appointment-card-blank": {
1477
+ "url": hub_url("12345678", FIXTURE_BASE_URL),
1478
+ "h1": "Preparación del envío",
1479
+ "body_text": "Elige cómo y cuándo vas a enviar tus productos",
1480
+ "cancel_button_present": False,
1481
+ "items": [
1482
+ _fixture_item("Vas a enviar 9 unidades Descargar listado Editar", True,
1483
+ testid="link-units"),
1484
+ {"label": "Elige cómo y cuándo vas a enviar tus productos", "html": ""},
1485
+ _fixture_item("Descargaste las etiquetas para todos tus productos Descargar listado",
1486
+ True, testid="link-identifiers"),
1487
+ _fixture_item("Preparaste e identificaste 3 bultos Revisar", True,
1488
+ testid="link-volumes"),
1489
+ ],
1490
+ },
1491
+ }
1492
+
1493
+ # ── 文件夹具(体积大/由真机 dump 脱敏而来,⛔ 原始生产 dump 绝不进仓库)──────────────
1494
+ # `real-anon`:真机样本(货件 79096447,运营已手动完成)**脱敏**后的逐项 DOM 证据。
1495
+ # 脱敏口径:货件号/数量/箱数/标题一律换占位符 ⇒ **只保留结构与信号**。
1496
+ FIXTURE_DIR = pathlib.Path(__file__).resolve().parent / "tests" / "fixtures"
1497
+ FIXTURE_FILES: dict[str, str] = {"real-anon": "hub-v2-real-anon.json"}
1498
+
1499
+
1500
+ def load_fixture(name: str) -> dict:
1501
+ """取夹具(内置 dict 或文件夹具)。⛔ 文件缺失时**响亮失败**,不静默退化成空 dump。"""
1502
+ if name in FIXTURES:
1503
+ return dict(FIXTURES[name])
1504
+ fname = FIXTURE_FILES.get(name)
1505
+ if not fname:
1506
+ raise KeyError(f"未知夹具:{name}")
1507
+ path = FIXTURE_DIR / fname
1508
+ return json.loads(path.read_text(encoding="utf-8"))
1509
+
1510
+
1511
+ def fixture_names() -> list[str]:
1512
+ return sorted(list(FIXTURES) + list(FIXTURE_FILES))
1513
+
1514
+
1515
+ def _main(argv: Optional[list[str]] = None) -> int:
1516
+ p = argparse.ArgumentParser(
1517
+ prog="ml_hub_status.py",
1518
+ description="hub-v2 货件真实状态:纯解析器(离线;真机抓取用 orchestrator --mode hub-status)")
1519
+ src = p.add_mutually_exclusive_group(required=True)
1520
+ src.add_argument("--dump-file", help="DOM dump JSON 文件(`-` = 从 stdin 读)")
1521
+ src.add_argument("--fixture", choices=fixture_names(), help="跑内置/文件夹具")
1522
+ p.add_argument("--shipment-id", default="", help="货件号(dump 里没有时用于回填)")
1523
+ p.add_argument("--list-status", default="",
1524
+ help="列表页/表里的粗粒度状态原文(**可选**:只能否决组合判据或佐证留痕)")
1525
+ p.add_argument("--json", action="store_true", help="输出完整 JSON(默认:中文摘要 + 裁定)")
1526
+ args = p.parse_args(argv)
1527
+
1528
+ if args.fixture:
1529
+ try:
1530
+ dump: Any = load_fixture(args.fixture)
1531
+ except (KeyError, OSError) as exc:
1532
+ print(f"⛔ 取不到夹具 {args.fixture}:{exc}", file=sys.stderr)
1533
+ return 2
1534
+ elif args.dump_file == "-":
1535
+ dump = sys.stdin.read()
1536
+ else:
1537
+ try:
1538
+ dump = open(args.dump_file, encoding="utf-8").read()
1539
+ except OSError as exc:
1540
+ print(f"⛔ 读不到 dump 文件:{exc}", file=sys.stderr)
1541
+ return 2
1542
+
1543
+ state = parse_hub_dump(dump, shipment_id=args.shipment_id, list_status=args.list_status)
1544
+ verdict = completion_verdict(state)
1545
+ if args.json:
1546
+ print(json.dumps({"hub": state, "verdict": verdict}, ensure_ascii=False, indent=2))
1547
+ else:
1548
+ print(summary_zh(state))
1549
+ print(("✅ " if verdict["operationally_complete"] else "⛔ ") + verdict["reason"])
1550
+ for line in verdict["items"]:
1551
+ print(f" · {line}")
1552
+ return 0
1553
+
1554
+
1555
+ if __name__ == "__main__":
1556
+ sys.exit(_main())