@whyshu/argent-engine 0.0.0-stage → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/MANIFEST.json +341 -0
- package/engine/VERSION +1 -0
- package/engine/agents/catalog.json +454 -0
- package/engine/configs/_aliases.proposed.json +651 -0
- package/engine/configs/_migration-plan.proposed.json +899 -0
- package/engine/configs/account.json +272 -0
- package/engine/configs/advanced.json +205 -0
- package/engine/configs/host.json +149 -0
- package/engine/configs/hr.json +27 -0
- package/engine/configs/notify.json +264 -0
- package/engine/core/cli/argent.mjs +277 -0
- package/engine/core/contracts/argent.contracts.json +1065 -0
- package/engine/core/contracts/verbs.schema.json +2539 -0
- package/engine/core/lib/argent-datadir.mjs +223 -0
- package/engine/core/lib/audit.mjs +187 -0
- package/engine/core/lib/claim.mjs +258 -0
- package/engine/core/lib/config-catalog.mjs +1172 -0
- package/engine/core/lib/doctor-customer.mjs +577 -0
- package/engine/core/lib/flow-list.mjs +138 -0
- package/engine/core/lib/flow-registry.mjs +48 -0
- package/engine/core/lib/init.mjs +247 -0
- package/engine/core/lib/ledger.mjs +124 -0
- package/engine/core/lib/license-activate.mjs +813 -0
- package/engine/core/lib/license-device.mjs +314 -0
- package/engine/core/lib/license-file.mjs +349 -0
- package/engine/core/lib/license-guard.mjs +123 -0
- package/engine/core/lib/license-ops.mjs +1645 -0
- package/engine/core/lib/license-verdict.mjs +133 -0
- package/engine/core/lib/license.mjs +577 -0
- package/engine/core/lib/notify/dedup.mjs +101 -0
- package/engine/core/lib/notify/feishu.mjs +38 -0
- package/engine/core/lib/notify/index.mjs +95 -0
- package/engine/core/lib/notify/verb.mjs +327 -0
- package/engine/core/lib/notify/webhook.mjs +135 -0
- package/engine/core/lib/notify/wecom.mjs +36 -0
- package/engine/core/lib/python.mjs +145 -0
- package/engine/core/lib/sop.mjs +116 -0
- package/engine/core/lib/versions.mjs +205 -0
- package/engine/core/mcp/client.mjs +67 -0
- package/engine/core/mcp/http.mjs +138 -0
- package/engine/core/mcp/server.mjs +702 -0
- package/engine/scripts/_engine_rules_probe.py +15 -0
- package/engine/scripts/analyze-full-day.py +568 -0
- package/engine/scripts/archive/feishu-bridge.js +246 -0
- package/engine/scripts/argent-agents.mjs +527 -0
- package/engine/scripts/argent-capabilities.mjs +776 -0
- package/engine/scripts/argent-catalog.mjs +186 -0
- package/engine/scripts/argent-datadir.py +62 -0
- package/engine/scripts/argent-doctor.mjs +624 -0
- package/engine/scripts/argent-env-write.mjs +302 -0
- package/engine/scripts/argent-schedule.mjs +620 -0
- package/engine/scripts/argent-setup-check.mjs +369 -0
- package/engine/scripts/argent-upgrade.mjs +1046 -0
- package/engine/scripts/base-bootstrap.sh +576 -0
- package/engine/scripts/billing-mode-daily.sh +13 -0
- package/engine/scripts/build-engine-package.mjs +126 -0
- package/engine/scripts/capability-packs.lock.json +484 -0
- package/engine/scripts/capability_pack.py +1242 -0
- package/engine/scripts/check-1.0-criteria.mjs +355 -0
- package/engine/scripts/check-agent-catalog.mjs +141 -0
- package/engine/scripts/check-billing-mode.sh +378 -0
- package/engine/scripts/check-child-sessions.mjs +83 -0
- package/engine/scripts/check-config-catalog.mjs +677 -0
- package/engine/scripts/check-docs-drift.mjs +522 -0
- package/engine/scripts/check-dsh-web-expose.sh +98 -0
- package/engine/scripts/check-judgment-drift.mjs +403 -0
- package/engine/scripts/check-license.mjs +199 -0
- package/engine/scripts/check-model-auth.py +443 -0
- package/engine/scripts/check-pack-closure.mjs +661 -0
- package/engine/scripts/check-portable-symbols.mjs +116 -0
- package/engine/scripts/check-preset-landing.mjs +124 -0
- package/engine/scripts/check-production-home.mjs +602 -0
- package/engine/scripts/check-role-mapping.mjs +461 -0
- package/engine/scripts/check-sample-redaction.mjs +320 -0
- package/engine/scripts/check-skill-classification.mjs +188 -0
- package/engine/scripts/check-sop-consistency.mjs +431 -0
- package/engine/scripts/check-test-env-safety.mjs +627 -0
- package/engine/scripts/check-version-consistency.mjs +452 -0
- package/engine/scripts/ci-run.sh +558 -0
- package/engine/scripts/collect-evidence.mjs +42 -0
- package/engine/scripts/config-explain.mjs +278 -0
- package/engine/scripts/configure-provider.rb +29 -0
- package/engine/scripts/customer-config-migrate.mjs +179 -0
- package/engine/scripts/customer-preflight.py +892 -0
- package/engine/scripts/customer-readiness.sh +135 -0
- package/engine/scripts/daily-report-inbox.py +242 -0
- package/engine/scripts/daily-report-reconcile.mjs +312 -0
- package/engine/scripts/daily-report.mjs +1310 -0
- package/engine/scripts/daily-report.sh +47 -0
- package/engine/scripts/data/lip-product-dict.json +306 -0
- package/engine/scripts/deliver-customer.mjs +932 -0
- package/engine/scripts/delivery-faces.txt +31 -0
- package/engine/scripts/desktop-trial.sh +146 -0
- package/engine/scripts/dev-window.sh +261 -0
- package/engine/scripts/drill-clean-machine.sh +315 -0
- package/engine/scripts/dsh-api-client.mjs +215 -0
- package/engine/scripts/dsh-backup-scope.sh +83 -0
- package/engine/scripts/dsh-offline-pack.mjs +77 -0
- package/engine/scripts/dsh-overlay-pin.sh +87 -0
- package/engine/scripts/dsh-plane.mjs +153 -0
- package/engine/scripts/dsh-portforward.py +223 -0
- package/engine/scripts/dsh-preflight.mjs +179 -0
- package/engine/scripts/dsh-release-watch-daily.sh +19 -0
- package/engine/scripts/dsh-release-watch.mjs +458 -0
- package/engine/scripts/dsh-remote-check.sh +99 -0
- package/engine/scripts/dsh-restart.sh +179 -0
- package/engine/scripts/dsh-restore.sh +111 -0
- package/engine/scripts/dsh-scope-rewire.mjs +803 -0
- package/engine/scripts/dsh-snapshot.mjs +116 -0
- package/engine/scripts/dsh-upgrade-readiness.mjs +165 -0
- package/engine/scripts/dsh-upgrade.mjs +612 -0
- package/engine/scripts/dsh-url.sh +86 -0
- package/engine/scripts/dsh-web-launch.mjs +155 -0
- package/engine/scripts/ensure-store-windows.py +509 -0
- package/engine/scripts/entitlements.py +458 -0
- package/engine/scripts/env_probe.py +411 -0
- package/engine/scripts/feishu-card.mjs +252 -0
- package/engine/scripts/final-check.mjs +43 -0
- package/engine/scripts/fix-skill-frontmatter.py +209 -0
- package/engine/scripts/flow-auto-retry.py +454 -0
- package/engine/scripts/flow-requirements.json +735 -0
- package/engine/scripts/flow-scaffold.py +275 -0
- package/engine/scripts/flow-timers-plist.sh +87 -0
- package/engine/scripts/flow-timers.sh +184 -0
- package/engine/scripts/flow-trigger-wake.mjs +284 -0
- package/engine/scripts/flow-trigger.sh +571 -0
- package/engine/scripts/flow_billing.py +354 -0
- package/engine/scripts/flow_capability_stats.py +314 -0
- package/engine/scripts/flow_case.py +194 -0
- package/engine/scripts/flow_claim.py +497 -0
- package/engine/scripts/flow_config.py +1025 -0
- package/engine/scripts/flow_failure.py +588 -0
- package/engine/scripts/flow_learn.py +105 -0
- package/engine/scripts/flow_ledger.py +235 -0
- package/engine/scripts/flow_policy.py +238 -0
- package/engine/scripts/flow_preflight.py +339 -0
- package/engine/scripts/flow_registry.py +341 -0
- package/engine/scripts/flow_retry.py +304 -0
- package/engine/scripts/flow_return_to_ops.py +191 -0
- package/engine/scripts/flow_source.py +315 -0
- package/engine/scripts/flow_sources/__init__.py +53 -0
- package/engine/scripts/flow_sources/cases_json.py +106 -0
- package/engine/scripts/flow_sources/common.py +428 -0
- package/engine/scripts/flow_sources/feishu_base.py +273 -0
- package/engine/scripts/flow_stale.py +185 -0
- package/engine/scripts/flow_status_reconcile.py +344 -0
- package/engine/scripts/flow_triage.py +849 -0
- package/engine/scripts/flow_write_verify.py +496 -0
- package/engine/scripts/fulfillment.js +586 -0
- package/engine/scripts/gen-agent-bundles.mjs +276 -0
- package/engine/scripts/gen-preset-rows.mjs +259 -0
- package/engine/scripts/init-ml.sh +2278 -0
- package/engine/scripts/install-internal-role.sh +190 -0
- package/engine/scripts/install-license.mjs +641 -0
- package/engine/scripts/install-one-shot.mjs +665 -0
- package/engine/scripts/integration_contract.py +220 -0
- package/engine/scripts/integrations/wenyao/adapter.py +98 -0
- package/engine/scripts/integrations/xzh/adapter.py +75 -0
- package/engine/scripts/invoice-mapping.example.json +40 -0
- package/engine/scripts/invoice-table.sh +195 -0
- package/engine/scripts/invoice_carrier_notice.py +279 -0
- package/engine/scripts/invoice_fail_closeout.py +297 -0
- package/engine/scripts/invoice_lip_runner.py +1508 -0
- package/engine/scripts/invoice_lip_runner.py.bak-20260922-1710-freready +1090 -0
- package/engine/scripts/invoice_ready_audit.py +191 -0
- package/engine/scripts/invoice_runner.py +675 -0
- package/engine/scripts/invoice_stageb.py +516 -0
- package/engine/scripts/launchd/com.whyshu.argent-bridge.plist.example +42 -0
- package/engine/scripts/launchd/com.whyshu.fulfillment-poll.plist.example +29 -0
- package/engine/scripts/lease.py +377 -0
- package/engine/scripts/lib/agent-install.mjs +649 -0
- package/engine/scripts/lib/argent-datadir.mjs +16 -0
- package/engine/scripts/lib/argent-datadir.sh +18 -0
- package/engine/scripts/lib/argent_datadir.py +285 -0
- package/engine/scripts/lib/argent_filelock.py +507 -0
- package/engine/scripts/lib/bootcheck-wiring.mjs +510 -0
- package/engine/scripts/lib/cdp_ports.py +197 -0
- package/engine/scripts/lib/customer-env.mjs +102 -0
- package/engine/scripts/lib/daily-report-core.mjs +517 -0
- package/engine/scripts/lib/dep-declaration.mjs +81 -0
- package/engine/scripts/lib/deps-migrate.mjs +310 -0
- package/engine/scripts/lib/dsh-host-resolve.mjs +109 -0
- package/engine/scripts/lib/dsh-scope.mjs +500 -0
- package/engine/scripts/lib/dsh-snapshot-path.mjs +35 -0
- package/engine/scripts/lib/dsh-versions.mjs +96 -0
- package/engine/scripts/lib/dsh-web-probe.mjs +214 -0
- package/engine/scripts/lib/is-direct-run.mjs +61 -0
- package/engine/scripts/lib/patch-compose.mjs +241 -0
- package/engine/scripts/lib/patch-target.mjs +35 -0
- package/engine/scripts/lib/plugin-lifecycle.mjs +128 -0
- package/engine/scripts/lib/plugin-packages.mjs +332 -0
- package/engine/scripts/lib/preset-compat.mjs +127 -0
- package/engine/scripts/lib/preset-install.mjs +575 -0
- package/engine/scripts/lib/preset-mechanism.mjs +56 -0
- package/engine/scripts/lib/preset-sync.mjs +59 -0
- package/engine/scripts/lib/profile-retire.mjs +97 -0
- package/engine/scripts/lib/python-runtime.mjs +190 -0
- package/engine/scripts/lib/registry.mjs +72 -0
- package/engine/scripts/lib/repo-root.sh +56 -0
- package/engine/scripts/lib/schedule-mechanism.mjs +659 -0
- package/engine/scripts/lib/schedule-probe.mjs +835 -0
- package/engine/scripts/lib/scope-pointer.mjs +164 -0
- package/engine/scripts/lib/settings-panels.mjs +816 -0
- package/engine/scripts/lib/stdio_utf8.py +86 -0
- package/engine/scripts/lib/wake-auth.mjs +392 -0
- package/engine/scripts/lib/webdriver_ipc.py +264 -0
- package/engine/scripts/lib/zstd-frames.mjs +104 -0
- package/engine/scripts/license-e2e-drill.mjs +426 -0
- package/engine/scripts/license-gate.mjs +245 -0
- package/engine/scripts/lip_backlog.py +171 -0
- package/engine/scripts/lip_table.py +185 -0
- package/engine/scripts/local-usage.mjs +214 -0
- package/engine/scripts/migrate-providers.mjs +127 -0
- package/engine/scripts/migrate-skills.sh +65 -0
- package/engine/scripts/ml-oauth-setup.mjs +550 -0
- package/engine/scripts/ml-store-ids.json +26 -0
- package/engine/scripts/ml_artifact_attribution.py +333 -0
- package/engine/scripts/ml_capacity_probe.py +1158 -0
- package/engine/scripts/ml_hub_status.py +1556 -0
- package/engine/scripts/ml_schema_check.py +115 -0
- package/engine/scripts/ml_store_ids.py +139 -0
- package/engine/scripts/npm-publish-argent.mjs +499 -0
- package/engine/scripts/npm-publish-argent.sh +20 -0
- package/engine/scripts/orchestrator.py +13811 -0
- package/engine/scripts/pack-plugins.mjs +140 -0
- package/engine/scripts/patches/dsh-llm-deepseek--transport-diagnostics.patch +297 -0
- package/engine/scripts/patches/dsh-session--append-message-shape.patch +15 -0
- package/engine/scripts/patches/dsh-session--tool-result-block-017.patch +28 -0
- package/engine/scripts/patches/dsh-session-format-v0-to-v1--permission-preset-origin.patch +11 -0
- package/engine/scripts/patches/dsh-session-format-v3-to-v4--turn-outside-replace.patch +20 -0
- package/engine/scripts/patches/dsh-session-persistence-jsonl--worker-bundled-migrators.patch +39 -0
- package/engine/scripts/patches/dsh-tools--render-return-shape.patch +50 -0
- package/engine/scripts/patches/verify-bundled-migrators.mjs +79 -0
- package/engine/scripts/patches/verify-hardening.mjs +167 -0
- package/engine/scripts/patches/verify-session-migration.mjs +149 -0
- package/engine/scripts/patches/verify-transport-diagnostics.mjs +227 -0
- package/engine/scripts/plan-role-rename.mjs +125 -0
- package/engine/scripts/plugin-bootcheck.mjs +451 -0
- package/engine/scripts/plugin-composition.json +15 -0
- package/engine/scripts/poc-mcp-host.mjs +455 -0
- package/engine/scripts/poll-fulfillment.sh +295 -0
- package/engine/scripts/poll-invoice.sh +76 -0
- package/engine/scripts/profile-doctor.mjs +686 -0
- package/engine/scripts/provider-select.sh +71 -0
- package/engine/scripts/provider-switch.sh +146 -0
- package/engine/scripts/provider-sync-target.check.mjs +64 -0
- package/engine/scripts/python-runtime.sh +78 -0
- package/engine/scripts/query_runner.py +310 -0
- package/engine/scripts/release-argent.sh +574 -0
- package/engine/scripts/repair-unresolved-tools.mjs +178 -0
- package/engine/scripts/retire-inventory.mjs +40 -0
- package/engine/scripts/run-tests.sh +257 -0
- package/engine/scripts/s1-audit.mjs +85 -0
- package/engine/scripts/self-sign-license.mjs +179 -0
- package/engine/scripts/session-health-scan.mjs +63 -0
- package/engine/scripts/session-repair-tool-result-content.mjs +181 -0
- package/engine/scripts/setup.sh +22 -0
- package/engine/scripts/skill-install.mjs +629 -0
- package/engine/scripts/sku_translate.py +148 -0
- package/engine/scripts/sync-plugins.sh +90 -0
- package/engine/scripts/sync-presets.sh +27 -0
- package/engine/scripts/sync-scripts.sh +445 -0
- package/engine/scripts/telemetry.py +873 -0
- package/engine/scripts/ui-copy-count.mjs +220 -0
- package/engine/scripts/usage-anchor.sh +137 -0
- package/engine/scripts/usage_report.py +531 -0
- package/engine/scripts/validate-preset-schema.mjs +208 -0
- package/engine/scripts/vertical-init.mjs +323 -0
- package/engine/scripts/vertical-lint.mjs +482 -0
- package/engine/scripts/wenyao-browser.sh +74 -0
- package/engine/scripts/wenyao-fixture-export.py +116 -0
- package/engine/scripts/wenyao-invoice-writeback.mjs +540 -0
- package/engine/scripts/wenyao-login.sh +35 -0
- package/engine/scripts/wenyao-template-map.json +292 -0
- package/engine/scripts/wenyao_attach.py +131 -0
- package/engine/scripts/wenyao_download_template.py +144 -0
- package/engine/scripts/wenyao_fill_template.py +568 -0
- package/engine/scripts/wenyao_login.py +145 -0
- package/engine/scripts/wenyao_portal.py +633 -0
- package/engine/scripts/wenyao_recon.py +230 -0
- package/engine/scripts/wenyao_run.py +503 -0
- package/engine/scripts/wenyao_shadow_diff.py +160 -0
- package/engine/scripts/wenyao_skill_contract.py +156 -0
- package/engine/scripts/wenyao_template_fetch.py +175 -0
- package/engine/scripts/win/dsh-web-autostart.ps1 +372 -0
- package/engine/scripts/ziniao-auth-probe.sh +268 -0
- package/engine/scripts/ziniao-keepalive.sh +217 -0
- package/engine/scripts/ziniao-post-upgrade-check.sh +121 -0
- package/engine/scripts/ziniao-windows.sh +154 -0
- package/engine/skills/README.md +86 -0
- package/engine/skills/argent-dev/SKILL.md +41 -0
- package/engine/skills/internal.json +37 -0
- package/engine/skills/lktop-pm/SKILL.md +35 -0
- package/engine/skills/lktopapp-dev/SKILL.md +34 -0
- package/engine/skills/ml-config-guide/SKILL.md +50 -0
- package/engine/skills/ml-fulfillment-sop/SKILL.md +178 -0
- package/engine/skills/ml-fulfillment-sop/references/andes-components.md +69 -0
- package/engine/skills/ml-fulfillment-sop/references/cdp-port-mapping.md +35 -0
- package/engine/skills/ml-fulfillment-sop/references/feishu-base-fulfillment-schema.md +136 -0
- package/engine/skills/ml-fulfillment-sop/references/multi-window-concurrency.md +69 -0
- package/engine/skills/ml-fulfillment-sop/references/pitfalls.md +77 -0
- package/engine/skills/ml-fulfillment-sop/references/selectors.md +243 -0
- package/engine/skills/ml-fulfillment-sop/scripts/fulfillment.js +586 -0
- package/engine/skills/ml-fulfillment-sop/scripts/poll-and-execute.sh +271 -0
- package/engine/skills/ml-fulfillment-sop/templates/report.md +55 -0
- package/engine/skills/ml-invoice/SKILL.md +506 -0
- package/engine/skills/ml-invoice/references/pitfalls.md +268 -0
- package/engine/skills/ml-invoice/references/portal-flow.md +360 -0
- package/engine/skills/ml-invoice/references/template-fill-contract.md +133 -0
- package/engine/skills/ml-invoice-xzh/SKILL.md +133 -0
- package/engine/skills/ml-invoice-xzh/references/pitfalls.md +230 -0
- package/engine/skills/ml-store-daily-report/SKILL.md +224 -0
- package/engine/skills/ml-store-daily-report/scripts/README.md +25 -0
- package/engine/skills/ml-store-daily-report/scripts/extract.js +691 -0
- package/engine/skills/ml-store-daily-report/scripts/generate.js +299 -0
- package/engine/skills/pet-dev/SKILL.md +42 -0
- package/engine/skills/whyshu-pm/SKILL.md +35 -0
- package/engine/skills/whyshu-web-dev/SKILL.md +50 -0
- package/engine/version.mjs +1 -0
- package/engine/verticals/README.md +103 -0
- package/engine/verticals/_engine-words.baseline.txt +237 -0
- package/engine/verticals/_engine-words.json +19 -0
- package/engine/verticals/mercadolibre/README.md +43 -0
- package/engine/verticals/mercadolibre/config.json +709 -0
- package/engine/verticals/mercadolibre/flows.json +738 -0
- package/engine/verticals/mercadolibre/manifest.json +538 -0
- package/engine/verticals/mercadolibre/migrate-machine-state.mjs +138 -0
- package/engine/verticals/mercadolibre/preflight.py +143 -0
- package/engine/verticals/mercadolibre/prompts/daily-report.failure.md +69 -0
- package/engine/verticals/mercadolibre/prompts/daily-report.md +47 -0
- package/engine/verticals/mercadolibre/prompts/daily-report.scheduled.md +79 -0
- package/engine/verticals/mercadolibre/prompts/full.failure.md +125 -0
- package/engine/verticals/mercadolibre/prompts/full.md +91 -0
- package/engine/verticals/mercadolibre/prompts/wenyao-invoice.failure.md +88 -0
- package/engine/verticals/mercadolibre/prompts/wenyao-invoice.md +64 -0
- package/engine/verticals/mercadolibre/prompts/xzh-invoice.failure.md +91 -0
- package/engine/verticals/mercadolibre/prompts/xzh-invoice.md +46 -0
- package/package.json +22 -4
- package/requirements.txt +5 -0
- package/README.md +0 -3
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""wenyao_portal.py — 上海文鳐门户(my.ffl-tech.com)操作模块(2026-09-11)
|
|
3
|
+
|
|
4
|
+
把 ⑥→⑨ 的门户动作封成可复用函数,供 `wenyao_run.py` 编排调用。
|
|
5
|
+
所有选择器/接口都来自 2026-09-11 首票 WY535082 的**实测**(见 skills/ml-invoice/references/portal-flow.md)。
|
|
6
|
+
|
|
7
|
+
设计约束
|
|
8
|
+
--------
|
|
9
|
+
· **只连已登录的长驻 Chrome**(CDP 9333,profile 持久化);本模块**不做登录**——
|
|
10
|
+
登录要过阿里云滑块,属人工介入点。
|
|
11
|
+
· 导出/导入等**写操作**都返回结构化结果,由调用方决定是否执行(编排层负责 shadow/真跑)。
|
|
12
|
+
· 下载用**浏览器级 CDP** 设下载目录 + `expect_download()` 捕获,并 `copy(download.path(), dest)`
|
|
13
|
+
(实测 `save_as()` 会存成 0 字节)。
|
|
14
|
+
|
|
15
|
+
并发隔离(2026-10-12 事故固化 —— 三条票 `79212271 / 79298694 / 79210359` 同抢**同一个 page**)
|
|
16
|
+
--------------------------------------------------------------------------------------
|
|
17
|
+
· **每条跑批一个自己的标签页**:`connect()` 默认在门户所在 context 里 `context.new_page()`
|
|
18
|
+
新建一个**只属于本次跑批**的页,并返回可解包的 `PortalSession`;调用方**必须**
|
|
19
|
+
`finally: sess.close()`(只关自己那个页,⛔ 不关别人/用户自己开着的页)。
|
|
20
|
+
死因回顾:旧实现三条并发执行者都复用**同一个既有 page** ⇒ 导航/点击互相打断 ⇒ 看起来"永远跑不完"。
|
|
21
|
+
· **每门户一把 flock**(数据目录下的门户锁文件,与 `scripts/lib/argent_filelock.py` 同源):
|
|
22
|
+
覆盖**会改门户状态或产出文件**的阶段(P7 查重 → P14 收口);**有界等待**(默认 300s,`WENYAO_PORTAL_LOCK_TIMEOUT`),
|
|
23
|
+
抢锁期间每 15s 打印一行,超时 ⇒ `PortalLockTimeout`(⛔ **绝不无限等待** —— 那正是本次死循环的形态)。
|
|
24
|
+
粒度 = **本门户/本 CDP profile**,⛔ 不是全局锁(不牵连 FULL 与其它流程)。
|
|
25
|
+
· **下载目录**:在**持锁**前提下设置,并尽量把 `Browser.setDownloadBehavior` 收到**本次跑批自己的 context**
|
|
26
|
+
(`browserContextId`);只能浏览器级时由锁保证串行,且下载后按 `suggestedFilename` + 包内容
|
|
27
|
+
校验归属,不符即报错(⛔ 不静默用错文件)。
|
|
28
|
+
|
|
29
|
+
CLI(供手工排障;脚本路径 = 本文件):
|
|
30
|
+
--state # 登录态/列表概况
|
|
31
|
+
--query 76636923 # 按货件号查(只读)
|
|
32
|
+
--warehouse-menu # 开导出进仓单菜单看选项(只读,不点选)
|
|
33
|
+
--lock-probe # 门户锁体检(先有界拿锁,再开一个自己的隔离页)
|
|
34
|
+
"""
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import argparse
|
|
38
|
+
import json
|
|
39
|
+
import os
|
|
40
|
+
import re
|
|
41
|
+
import shutil
|
|
42
|
+
import sys
|
|
43
|
+
import time
|
|
44
|
+
import zipfile
|
|
45
|
+
from contextlib import contextmanager
|
|
46
|
+
from pathlib import Path
|
|
47
|
+
from typing import Any, Callable, Iterator, Optional
|
|
48
|
+
from urllib.parse import urlparse
|
|
49
|
+
|
|
50
|
+
DEFAULT_CDP = "http://127.0.0.1:9333"
|
|
51
|
+
CDP = os.environ.get("WENYAO_CDP", DEFAULT_CDP)
|
|
52
|
+
LIST_URL = "https://my.ffl-tech.com/my/trailer-center#/trailer-center"
|
|
53
|
+
# 数据目录:**唯一判据**在 `scripts/lib/argent_datadir.py`(三级链,与 JS 内核同语义)
|
|
54
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent / 'lib'))
|
|
55
|
+
from argent_datadir import resolve_data_dir # noqa: E402 —— 勿在此复制判据
|
|
56
|
+
from argent_filelock import FileLock, LockError, read_lock_holder # noqa: E402 —— 锁的唯一真源
|
|
57
|
+
DEFAULT_DOWNLOAD_DIR = Path(resolve_data_dir()['dir']) / "wenyao-downloads"
|
|
58
|
+
RECEIPT_MENU_LABEL = "集货仓 - 东莞集货仓" # SOP:导出进仓单选「东莞集货仓」(= HNBD)
|
|
59
|
+
|
|
60
|
+
# ── 门户锁 ──────────────────────────────────────────────────────────────────
|
|
61
|
+
LOCK_NAME = "wenyao-portal.lock"
|
|
62
|
+
LOCK_TIMEOUT_ENV = "WENYAO_PORTAL_LOCK_TIMEOUT"
|
|
63
|
+
DEFAULT_LOCK_TIMEOUT = 300.0 # 秒;有界等待,⛔ 绝不无限等
|
|
64
|
+
LOCK_HINT_SEC = 15.0 # 抢锁期间每隔多久打印一行"⏳ 等待…"
|
|
65
|
+
LOCK_HINT_TEXT = "⏳ 等待其他发票跑批释放门户(已等 {n}s)"
|
|
66
|
+
#: 货件号形态 = **恰好 8 位**数字(`79212271`)。用前后不看数字来排除 14 位时间戳(`唛头20260911180600.zip`)。
|
|
67
|
+
TICKET_RE = re.compile(r"(?<!\d)(\d{8})(?!\d)")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class PortalLockTimeout(RuntimeError):
|
|
71
|
+
"""门户锁**有界等待**超时(⛔ 绝不无限等待;这正是 2026-10-12 死循环的形态)。"""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def data_dir() -> Path:
|
|
75
|
+
"""数据目录(**延迟求值**:`--home`/隔离 home 场景下不能在 import 期定死)。"""
|
|
76
|
+
return Path(resolve_data_dir()['dir'])
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def portal_lock_path() -> Path:
|
|
80
|
+
"""每门户一把锁的路径。粒度 = **本门户 profile**(默认 9333 那个 Chrome);
|
|
81
|
+
换了 `WENYAO_CDP`(另一个 Chrome profile)就换一把锁 —— ⛔ 不是全局锁。"""
|
|
82
|
+
if CDP != DEFAULT_CDP:
|
|
83
|
+
port = ""
|
|
84
|
+
try:
|
|
85
|
+
port = str(urlparse(CDP).port or "")
|
|
86
|
+
except Exception: # noqa: BLE001
|
|
87
|
+
port = ""
|
|
88
|
+
return data_dir() / (LOCK_NAME.replace(".lock", f"-{port or 'custom'}.lock"))
|
|
89
|
+
return data_dir() / LOCK_NAME
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def portal_lock_timeout() -> float:
|
|
93
|
+
"""有界等待上限(秒):`WENYAO_PORTAL_LOCK_TIMEOUT` > 默认 300s。
|
|
94
|
+
|
|
95
|
+
⛔ 非法值**响亮失败**(fail-closed):静默回落到某个值 = 又一次"降级路径产出错值"。
|
|
96
|
+
"""
|
|
97
|
+
raw = (os.environ.get(LOCK_TIMEOUT_ENV) or "").strip()
|
|
98
|
+
if not raw:
|
|
99
|
+
return DEFAULT_LOCK_TIMEOUT
|
|
100
|
+
try:
|
|
101
|
+
return max(0.0, float(raw))
|
|
102
|
+
except ValueError as exc:
|
|
103
|
+
raise RuntimeError(
|
|
104
|
+
f"{LOCK_TIMEOUT_ENV}={raw!r} 不是数字(秒)—— 拒绝静默回落;请改正或删掉该环境变量"
|
|
105
|
+
) from exc
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@contextmanager
|
|
109
|
+
def portal_lock(site: str = "", *, timeout: Optional[float] = None, hint_sec: float = LOCK_HINT_SEC,
|
|
110
|
+
reporter: Optional[Callable[[str], None]] = None, path: Optional[Path] = None,
|
|
111
|
+
) -> Iterator[float]:
|
|
112
|
+
"""**每门户一把 flock**,覆盖会改门户状态/产出文件的阶段(P7 查重 → P14 收口)。
|
|
113
|
+
|
|
114
|
+
· 有界等待:`timeout`(缺省 `WENYAO_PORTAL_LOCK_TIMEOUT` / 300s);
|
|
115
|
+
· 抢锁期间每 `hint_sec` 秒打印一行 `⏳ 等待其他发票跑批释放门户(已等 Ns)`;
|
|
116
|
+
· 超时 ⇒ 抛 `PortalLockTimeout`(文案含「门户被占用/等待超时,避免并发踩踏;请稍后重跑或提高超时」);
|
|
117
|
+
· 锁不可用(无后端 / 建不出锁文件)⇒ 抛 `LockError`(fail-closed,⛔ 绝不无锁继续);
|
|
118
|
+
· yield **实际等待秒数**(0 = 立刻拿到);退出时**释放并关掉自己的 fd**(锁文件可留,fd 必须释放)。
|
|
119
|
+
"""
|
|
120
|
+
lim = portal_lock_timeout() if timeout is None else max(0.0, float(timeout))
|
|
121
|
+
lock_path = Path(path) if path else portal_lock_path()
|
|
122
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
say = reporter or (lambda m: print(m, flush=True))
|
|
124
|
+
t0 = time.monotonic()
|
|
125
|
+
lk = FileLock(str(lock_path), site=site or "portal.lock")
|
|
126
|
+
nxt_hint = t0 + max(0.01, float(hint_sec))
|
|
127
|
+
try:
|
|
128
|
+
while not lk.acquire(timeout=0.0): # 非阻塞试一次;拿到就零等待(单跑路径不变)
|
|
129
|
+
waited = time.monotonic() - t0
|
|
130
|
+
if waited >= lim:
|
|
131
|
+
holder = read_lock_holder(lock_path)
|
|
132
|
+
raise PortalLockTimeout(
|
|
133
|
+
f"门户被占用/等待超时(已等 {waited:.0f}s,上限 {lim:.0f}s):避免并发踩踏;"
|
|
134
|
+
f"请稍后重跑或提高超时({LOCK_TIMEOUT_ENV}=<秒>)"
|
|
135
|
+
+ (f";持锁者:{holder[:200]}" if holder else "")
|
|
136
|
+
)
|
|
137
|
+
now = time.monotonic()
|
|
138
|
+
if now >= nxt_hint:
|
|
139
|
+
say(LOCK_HINT_TEXT.format(n=round(now - t0)))
|
|
140
|
+
nxt_hint = now + max(0.01, float(hint_sec))
|
|
141
|
+
time.sleep(max(0.01, min(0.5, lim - waited, max(0.0, nxt_hint - now) or 0.5)))
|
|
142
|
+
yield time.monotonic() - t0
|
|
143
|
+
finally:
|
|
144
|
+
lk.release() # 路径模式:连自己的 fd 一起关
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ───────────────────────────── 连接 ─────────────────────────────
|
|
148
|
+
|
|
149
|
+
def _page_target_info(page) -> dict:
|
|
150
|
+
"""该 page 的 CDP target 信息(`targetId` / `browserContextId`)。取不到 ⇒ `{}`(⛔ 不因此失败)。"""
|
|
151
|
+
try:
|
|
152
|
+
info = page.context.new_cdp_session(page).send("Target.getTargetInfo") or {}
|
|
153
|
+
return dict(info.get("targetInfo") or {})
|
|
154
|
+
except Exception: # noqa: BLE001
|
|
155
|
+
return {}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class PortalSession:
|
|
159
|
+
"""一次跑批**自己的**门户标签页(隔离)。
|
|
160
|
+
|
|
161
|
+
可解包成 `(browser, page, close_fn)`;调用方**必须** `finally: sess.close()`。
|
|
162
|
+
`browser` 是共享的 CDP 连接(⛔ 不要 close —— 那是用户的长驻 Chrome);`close()` **只关自己这个 page**,
|
|
163
|
+
失败只告警不上抛(别人的页/用户的页一个都不动)。
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
def __init__(self, browser: Any, page: Any, target_id: str = "", *, owns_page: bool = True) -> None:
|
|
167
|
+
self.browser = browser
|
|
168
|
+
self.page = page
|
|
169
|
+
self.target_id = target_id or ""
|
|
170
|
+
self.owns_page = bool(owns_page)
|
|
171
|
+
self.closed = False
|
|
172
|
+
self.last_error: Optional[BaseException] = None
|
|
173
|
+
|
|
174
|
+
def __iter__(self):
|
|
175
|
+
return iter((self.browser, self.page, self.close))
|
|
176
|
+
|
|
177
|
+
def __repr__(self) -> str: # pragma: no cover - 诊断用
|
|
178
|
+
return "<PortalSession target=%s owns_page=%s closed=%s>" % (
|
|
179
|
+
self.target_id[:8] or "-", self.owns_page, self.closed)
|
|
180
|
+
|
|
181
|
+
def close(self) -> None:
|
|
182
|
+
"""只关**自己**那个 page;幂等;失败**只告警**(⛔ 不上抛,别把真正的错盖掉)。"""
|
|
183
|
+
if self.closed or not self.owns_page or self.page is None:
|
|
184
|
+
self.closed = True
|
|
185
|
+
return
|
|
186
|
+
try:
|
|
187
|
+
self.page.close()
|
|
188
|
+
except Exception as exc: # noqa: BLE001
|
|
189
|
+
self.last_error = exc
|
|
190
|
+
print(f"[门户] ⚠️ 关闭自己的标签页失败({type(exc).__name__}: {exc})—— 不入库、不影响结论",
|
|
191
|
+
file=sys.stderr)
|
|
192
|
+
self.closed = True
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def connect(pw, *, isolated: bool = True) -> PortalSession:
|
|
196
|
+
"""连门户 Chrome,返回 `PortalSession`(可解包成 `(browser, page, close_fn)`)。
|
|
197
|
+
|
|
198
|
+
**默认即隔离**(`isolated=True`):在门户所在 context 里 `context.new_page()` 新建一个
|
|
199
|
+
**只属于本次跑批**的标签页并导航到跨境电商订单列表页 —— 并发跑批各用各的页,互相不打断。
|
|
200
|
+
`isolated=False` 复用既有门户 page(旧行为,`close()` 是 no-op)—— ⛔ 只给只读排障用。
|
|
201
|
+
|
|
202
|
+
⛔ 保留既有语义:**找不到门户 Chrome ⇒ 明确报错并提示先跑门户启动脚本**(文案见下);
|
|
203
|
+
⛔ **不动用户自己开着的门户页**(那是人的页面)。
|
|
204
|
+
"""
|
|
205
|
+
browser = pw.chromium.connect_over_cdp(CDP, timeout=8000)
|
|
206
|
+
pages = [pg for c in browser.contexts for pg in c.pages if "ffl-tech" in (pg.url or "")]
|
|
207
|
+
if not pages:
|
|
208
|
+
raise RuntimeError("没找到门户标签页;先跑 bash scripts/wenyao-browser.sh start")
|
|
209
|
+
host = next((pg for pg in pages if (pg.url or "").endswith("#/trailer-center")), pages[0])
|
|
210
|
+
if not isolated:
|
|
211
|
+
page = host
|
|
212
|
+
if not (page.url or "").endswith("#/trailer-center"):
|
|
213
|
+
page.goto(LIST_URL)
|
|
214
|
+
page.wait_for_timeout(3000)
|
|
215
|
+
return PortalSession(browser, page, _page_target_info(page).get("targetId", ""), owns_page=False)
|
|
216
|
+
try:
|
|
217
|
+
page = host.context.new_page()
|
|
218
|
+
except Exception as exc: # noqa: BLE001
|
|
219
|
+
raise RuntimeError(
|
|
220
|
+
f"打不开门户新标签页({type(exc).__name__}: {exc});"
|
|
221
|
+
"先跑 bash scripts/wenyao-browser.sh start 再重试"
|
|
222
|
+
) from exc
|
|
223
|
+
try:
|
|
224
|
+
page.goto(LIST_URL)
|
|
225
|
+
page.wait_for_timeout(3000)
|
|
226
|
+
except Exception as exc: # noqa: BLE001
|
|
227
|
+
PortalSession(browser, page, "").close() # 导航失败也别把自己的页留下
|
|
228
|
+
raise RuntimeError(
|
|
229
|
+
f"门户新标签页导航失败({type(exc).__name__}: {exc});"
|
|
230
|
+
"多半是门户未登录 —— 请人工在门户过一次滑块后重试"
|
|
231
|
+
) from exc
|
|
232
|
+
return PortalSession(browser, page, _page_target_info(page).get("targetId", ""), owns_page=True)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def set_download_dir(browser, page, download_dir: Path, *, browser_context_id: str = "") -> dict:
|
|
236
|
+
"""设下载目录,**尽量绑定到本次跑批自己的 context**。
|
|
237
|
+
|
|
238
|
+
⚠️ 必须设在**浏览器级** CDP session(设在 page 级实测不生效,2026-09-11)。但浏览器级是**全局**的 ⇒
|
|
239
|
+
并发跑批会互相覆盖下载目录(2026-10-12 次生竞争)⇒ 带上 `browserContextId` 把范围收窄到我们自己的
|
|
240
|
+
context;拿不到 contextId 时退回全局设置,由**门户锁**保证串行(调用方持有锁)。
|
|
241
|
+
返回 `{"scope": "context"|"browser"|"page", "browserContextId": …}`。
|
|
242
|
+
"""
|
|
243
|
+
download_dir.mkdir(parents=True, exist_ok=True)
|
|
244
|
+
bid = browser_context_id or str(_page_target_info(page).get("browserContextId") or "")
|
|
245
|
+
base = {"behavior": "allow", "downloadPath": str(download_dir), "eventsEnabled": True}
|
|
246
|
+
tried: list = []
|
|
247
|
+
if bid:
|
|
248
|
+
tried.append(("context", dict(base, browserContextId=bid)))
|
|
249
|
+
tried.append(("browser", dict(base)))
|
|
250
|
+
last: Optional[BaseException] = None
|
|
251
|
+
for scope, params in tried:
|
|
252
|
+
try:
|
|
253
|
+
browser.new_browser_cdp_session().send("Browser.setDownloadBehavior", params)
|
|
254
|
+
return {"scope": scope, "browserContextId": params.get("browserContextId", "")}
|
|
255
|
+
except Exception as exc: # noqa: BLE001
|
|
256
|
+
last = exc
|
|
257
|
+
page.context.new_cdp_session(page).send("Browser.setDownloadBehavior", dict(base)) # 兜底:page 级
|
|
258
|
+
if last is not None:
|
|
259
|
+
print(f"[门户] ⚠️ context/浏览器级设下载目录都失败({last})⇒ 退回 page 级(实测常常不生效)",
|
|
260
|
+
file=sys.stderr)
|
|
261
|
+
return {"scope": "page", "browserContextId": ""}
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def install_hooks(page) -> None:
|
|
265
|
+
"""钩住 window.open / XHR,用于抓导出产生的预签名下载地址。"""
|
|
266
|
+
page.evaluate("""() => {
|
|
267
|
+
if (window.__wy) return;
|
|
268
|
+
window.__wy = {opens: [], reqs: []};
|
|
269
|
+
const ow = window.open;
|
|
270
|
+
window.open = function (u) { try { window.__wy.opens.push(String(u)); } catch (e) {} return ow.apply(this, arguments); };
|
|
271
|
+
const oo = XMLHttpRequest.prototype.open, os = XMLHttpRequest.prototype.send;
|
|
272
|
+
XMLHttpRequest.prototype.open = function (m, u) { this.__m = m; this.__u = u; return oo.apply(this, arguments); };
|
|
273
|
+
XMLHttpRequest.prototype.send = function (b) {
|
|
274
|
+
try { window.__wy.reqs.push({m: this.__m, u: String(this.__u), b: b ? String(b).slice(0, 300) : ''}); } catch (e) {}
|
|
275
|
+
return os.apply(this, arguments);
|
|
276
|
+
};
|
|
277
|
+
}""")
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def hooks(page) -> dict:
|
|
281
|
+
return page.evaluate("() => (window.__wy || {opens: [], reqs: []})")
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# ───────────────────────────── 只读 ─────────────────────────────
|
|
285
|
+
|
|
286
|
+
def login_state(page) -> dict:
|
|
287
|
+
return page.evaluate("""() => ({
|
|
288
|
+
url: location.href,
|
|
289
|
+
needLogin: !!document.querySelector('input[type=password]'),
|
|
290
|
+
onList: /跨境电商订单/.test(document.body.innerText || ''),
|
|
291
|
+
summary: (document.querySelector('.ff-base-table2-summary')?.innerText || '').trim()
|
|
292
|
+
})""")
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def close_task_panel(page) -> bool:
|
|
296
|
+
"""关掉右上角「导出任务」面板。返回是否已确认收起。
|
|
297
|
+
|
|
298
|
+
⚠️ 2026-09-18 踩坑(皮 10i 的真因):该面板 **Esc 关不掉**,而它的容器
|
|
299
|
+
(`.task-his`,实测 498×6888px)会盖在「查询」按钮上 ⇒ `btn.click()` 超时后
|
|
300
|
+
走的 `click(force=True)` **点在了遮罩上、查询根本没发出去**,DOM 里残留的是
|
|
301
|
+
**上一次查询的结果**。据此做查重 ⇒ 假阳性白停一票、假阴性则重复导入对方生产系统。
|
|
302
|
+
"""
|
|
303
|
+
try:
|
|
304
|
+
if not page.evaluate("() => !!document.querySelector('.task-his')?.offsetWidth"):
|
|
305
|
+
return True
|
|
306
|
+
page.evaluate("() => document.querySelector('.taskcenterHandle')?.click()")
|
|
307
|
+
page.wait_for_timeout(1200)
|
|
308
|
+
return not page.evaluate("() => !!document.querySelector('.task-his')?.offsetWidth")
|
|
309
|
+
except Exception:
|
|
310
|
+
return False
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def close_overlays(page) -> None:
|
|
314
|
+
"""关掉可能挡住点击的浮层(导出任务面板/下拉/弹窗)。
|
|
315
|
+
实测:导出任务面板留着不关,其遮罩会拦截「查询」按钮的点击(TimeoutError)。"""
|
|
316
|
+
for _ in range(3):
|
|
317
|
+
try:
|
|
318
|
+
page.keyboard.press("Escape")
|
|
319
|
+
page.wait_for_timeout(500)
|
|
320
|
+
except Exception:
|
|
321
|
+
break
|
|
322
|
+
if page.locator(".next-dialog.ff-modal").count():
|
|
323
|
+
el = page.query_selector(".next-dialog-close-icon")
|
|
324
|
+
if el and el.is_visible():
|
|
325
|
+
el.click()
|
|
326
|
+
page.wait_for_timeout(800)
|
|
327
|
+
close_task_panel(page)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def reset_query(page) -> None:
|
|
331
|
+
"""点「重置」,别把筛选条件留给下一个人。"""
|
|
332
|
+
try:
|
|
333
|
+
page.locator("button", has_text="重置").first.click()
|
|
334
|
+
page.wait_for_timeout(2500)
|
|
335
|
+
except Exception:
|
|
336
|
+
pass
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def query_shipment(page, shipment_no: str) -> dict:
|
|
340
|
+
"""按货件号查询(只读)。返回 {count, rows:[文本], summary, api_total, dom_count}。
|
|
341
|
+
|
|
342
|
+
⚠️ 2026-09-18 fail-closed 加固:**只认接口响应,不认 DOM 残留**。
|
|
343
|
+
踩坑实录:导出任务面板遮罩挡住「查询」按钮 ⇒ 点击超时后走的 `click(force=True)`
|
|
344
|
+
点在遮罩上、**请求根本没发出**,DOM 里留着上一次查询的旧行;此时读 DOM 会得到
|
|
345
|
+
假阳性(白停一票)或假阴性(重复导入对方生产系统)。故改为:
|
|
346
|
+
① 用 `expect_response` 抓 `/api/v1/lcl/order/page`,以 `data.page.totalCount` 为准;
|
|
347
|
+
② 抓不到响应/N 解析不出 ⇒ **直接报错**,绝不回落到可能过期的 DOM 数字。
|
|
348
|
+
"""
|
|
349
|
+
close_overlays(page)
|
|
350
|
+
reset_query(page)
|
|
351
|
+
inp = page.locator("input[placeholder='支持多号查询']").nth(2) # 1=业务编号 2=导入批次号 3=货件号
|
|
352
|
+
inp.fill(shipment_no)
|
|
353
|
+
page.wait_for_timeout(300)
|
|
354
|
+
btn = page.get_by_role("button", name="查询", exact=True)
|
|
355
|
+
api_total = None
|
|
356
|
+
|
|
357
|
+
def _is_our_query(r) -> bool:
|
|
358
|
+
"""只认**本次货件号**的查询响应(避免抓到重置/上一次查询的迟到响应)。
|
|
359
|
+
post_data 取不到时放宽为仅按 URL 匹配,避免误判成"没发出去"。"""
|
|
360
|
+
try:
|
|
361
|
+
if "/api/v1/lcl/order/page" not in r.url:
|
|
362
|
+
return False
|
|
363
|
+
pd = r.request.post_data
|
|
364
|
+
return pd is None or shipment_no in pd
|
|
365
|
+
except Exception:
|
|
366
|
+
return False
|
|
367
|
+
|
|
368
|
+
try:
|
|
369
|
+
with page.expect_response(_is_our_query, timeout=20000) as ri:
|
|
370
|
+
try:
|
|
371
|
+
btn.click(timeout=8000)
|
|
372
|
+
except Exception:
|
|
373
|
+
close_overlays(page)
|
|
374
|
+
btn.click(force=True, timeout=8000) # 兜底:无视遮罩
|
|
375
|
+
body = ri.value.json() or {}
|
|
376
|
+
api_total = ((body.get("data") or {}).get("page") or {}).get("totalCount")
|
|
377
|
+
except Exception as e:
|
|
378
|
+
raise RuntimeError(
|
|
379
|
+
f"「查询」未真正发出或未收到接口响应(多半是遮罩挡住了按钮):"
|
|
380
|
+
f"{type(e).__name__}: {str(e)[:200]}"
|
|
381
|
+
) from e
|
|
382
|
+
if api_total is None:
|
|
383
|
+
raise RuntimeError("查询接口响应里没有 totalCount,拒绝用 DOM 残留做判断(fail-closed)")
|
|
384
|
+
page.wait_for_timeout(3500)
|
|
385
|
+
summary = page.evaluate("() => (document.querySelector('.ff-base-table2-summary')?.innerText || '').trim()")
|
|
386
|
+
rows = page.evaluate("""() => Array.from(document.querySelectorAll('.BaseTable__row.ddp-row'))
|
|
387
|
+
.map(r => (r.innerText || '').replace(/\\s+/g, ' ').trim()).filter(t => /货件号/.test(t))""")
|
|
388
|
+
n = 0
|
|
389
|
+
for tok in summary.replace("已选择", " ").split():
|
|
390
|
+
if tok.isdigit():
|
|
391
|
+
n = int(tok)
|
|
392
|
+
return {"count": int(api_total), "rows": rows, "summary": summary,
|
|
393
|
+
"api_total": int(api_total), "dom_count": n}
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def select_shipment(page, shipment_no: str) -> dict:
|
|
397
|
+
"""勾选该货件号所在行。返回 {selected, summary}。"""
|
|
398
|
+
row = page.locator(".BaseTable__row.ddp-row").filter(has_text=shipment_no).first
|
|
399
|
+
cb = row.locator("input[type=checkbox], .next-checkbox").first
|
|
400
|
+
cb.click()
|
|
401
|
+
page.wait_for_timeout(1500)
|
|
402
|
+
summary = page.evaluate("() => (document.querySelector('.ff-base-table2-summary')?.innerText || '').trim()")
|
|
403
|
+
return {"selected": "已选择 1" in summary, "summary": summary}
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def warehouse_menu_items(page) -> list:
|
|
407
|
+
"""只读:点开「导出进仓单」看菜单项(不点选)。"""
|
|
408
|
+
if not page.locator("li:has-text('集货仓')").count():
|
|
409
|
+
page.get_by_role("button", name="导出进仓单").click()
|
|
410
|
+
page.wait_for_timeout(1800)
|
|
411
|
+
return page.evaluate("""() => Array.from(document.querySelectorAll('.next-menu-item'))
|
|
412
|
+
.map(e => (e.innerText || '').trim()).filter(t => /仓/.test(t))""")
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
# ───────────────────────────── 写操作 ─────────────────────────────
|
|
416
|
+
|
|
417
|
+
def import_order(page, xlsx: str) -> dict:
|
|
418
|
+
"""⑤ 导入订单(**真写对方生产系统**)。"""
|
|
419
|
+
page.get_by_role("button", name="导入订单", exact=True).click()
|
|
420
|
+
page.wait_for_timeout(2500)
|
|
421
|
+
page.locator(".next-dialog.ff-modal input[type=file]").first.set_input_files(str(xlsx))
|
|
422
|
+
page.wait_for_timeout(3500)
|
|
423
|
+
page.get_by_role("button", name="导入", exact=True).last.click()
|
|
424
|
+
toasts: list = []
|
|
425
|
+
for _ in range(15):
|
|
426
|
+
page.wait_for_timeout(2000)
|
|
427
|
+
toasts = page.evaluate("""() => Array.from(document.querySelectorAll('.next-message,.next-toast,.next-notification'))
|
|
428
|
+
.map(e => (e.innerText || '').replace(/\\s+/g, ' ').trim()).filter(Boolean)""")
|
|
429
|
+
if toasts:
|
|
430
|
+
break
|
|
431
|
+
text = " | ".join(dict.fromkeys(toasts))
|
|
432
|
+
return {"toasts": toasts, "text": text, "ok": ("成功" in text and "失败 0" in text)}
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def export_receipt(page, label: str = RECEIPT_MENU_LABEL) -> dict:
|
|
436
|
+
"""⑦ 导出进仓单:菜单选仓 → 预签名 xlsx 走 window.open。"""
|
|
437
|
+
page.get_by_role("button", name="导出进仓单").click()
|
|
438
|
+
page.wait_for_timeout(1800)
|
|
439
|
+
page.locator(".next-menu-item", has_text=label).first.click()
|
|
440
|
+
page.wait_for_timeout(5000)
|
|
441
|
+
opens = hooks(page).get("opens", [])
|
|
442
|
+
return {"url": opens[-1] if opens else None}
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def export_marks(page) -> dict:
|
|
446
|
+
"""⑧ 导出唛头(异步任务;文件去「导出任务」面板取)。"""
|
|
447
|
+
page.get_by_role("button", name="导出唛头", exact=True).click()
|
|
448
|
+
page.wait_for_timeout(6000)
|
|
449
|
+
toasts = page.evaluate("""() => Array.from(document.querySelectorAll('.next-message,.next-toast,.next-notification'))
|
|
450
|
+
.map(e => (e.innerText || '').replace(/\\s+/g, ' ').trim()).filter(Boolean)""")
|
|
451
|
+
text = " | ".join(dict.fromkeys(toasts))
|
|
452
|
+
return {"toasts": toasts, "text": text, "ok": "成功" in text}
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def open_task_panel(page) -> None:
|
|
456
|
+
if not page.locator("button:has-text('下载')").count():
|
|
457
|
+
page.click("text=导出任务")
|
|
458
|
+
page.wait_for_timeout(2500)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def top_task(page) -> dict:
|
|
462
|
+
"""「导出任务」面板最上面一条(= 本次)。返回 {text, downloads}。"""
|
|
463
|
+
open_task_panel(page)
|
|
464
|
+
return page.evaluate("""() => {
|
|
465
|
+
const dl = Array.from(document.querySelectorAll('button,a,[class*=btn]'))
|
|
466
|
+
.filter(e => (e.innerText || '').trim() === '下载' && e.getBoundingClientRect().width);
|
|
467
|
+
return {downloads: dl.length};
|
|
468
|
+
}""")
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def fetch_bytes(page, url: str) -> bytes:
|
|
472
|
+
"""用**页面自身会话**取字节(同源带 cookie),不依赖浏览器下载落盘。"""
|
|
473
|
+
import base64
|
|
474
|
+
r = page.evaluate("""async (u) => {
|
|
475
|
+
const res = await fetch(u, {credentials: 'include'});
|
|
476
|
+
const buf = await res.arrayBuffer();
|
|
477
|
+
const bytes = new Uint8Array(buf);
|
|
478
|
+
let s = '';
|
|
479
|
+
const CH = 8192;
|
|
480
|
+
for (let i = 0; i < bytes.length; i += CH) s += String.fromCharCode.apply(null, bytes.subarray(i, i + CH));
|
|
481
|
+
return {status: res.status, b64: btoa(s), len: bytes.length};
|
|
482
|
+
}""", url)
|
|
483
|
+
if r.get("status") != 200:
|
|
484
|
+
raise RuntimeError(f"取字节失败 HTTP {r.get('status')}")
|
|
485
|
+
return base64.b64decode(r["b64"])
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _is_date_like(tok: str) -> bool:
|
|
489
|
+
"""`20260911` 这种 8 位**日期**不是货件号(货件号都是 `79…`/`76…` 开头)。
|
|
490
|
+
没有这条过滤,文件名里带日期的正常下载会被误判成"别的票"而白停一票。"""
|
|
491
|
+
if len(tok) != 8 or not tok.startswith("20"):
|
|
492
|
+
return False
|
|
493
|
+
return 1 <= int(tok[4:6]) <= 12 and 1 <= int(tok[6:8]) <= 31
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def assert_download_owned(name: str, expect_shipment: str) -> dict:
|
|
497
|
+
"""**下载归属**(2026-10-12 次生竞争固化):文件名里带 8 位票号且不是本票 ⇒ **报错**。
|
|
498
|
+
|
|
499
|
+
实测 DDP 包名是 `唛头<YYYYMMDDHHMMSS>.zip`(**不含票号**)⇒ 那种情况这里放行,交给
|
|
500
|
+
`assert_zip_owned()` 用**包内文件名**(`<运单号>-<货件号>_mark.pdf`)判归属。
|
|
501
|
+
8 位日期(`20260911`)不算票号(见 `_is_date_like`),避免误判白停。
|
|
502
|
+
返回 `{"checked": bool, "tokens": [...]}`;⛔ 不匹配时**抛错而不是静默用错文件**。
|
|
503
|
+
"""
|
|
504
|
+
toks = [t for t in TICKET_RE.findall(name or "") if not _is_date_like(t)]
|
|
505
|
+
if expect_shipment and toks and expect_shipment not in toks:
|
|
506
|
+
raise RuntimeError(
|
|
507
|
+
f"❌ 下载文件名「{name}」属于别的票(期望 {expect_shipment},实见 {toks})—— "
|
|
508
|
+
"并发跑批拿错了文件;本次**不使用**它(请检查门户锁/下载目录绑定)"
|
|
509
|
+
)
|
|
510
|
+
return {"checked": bool(expect_shipment and toks), "tokens": toks}
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def assert_zip_owned(zip_path: str, expect_shipment: str) -> list:
|
|
514
|
+
"""**包内容归属**:DDP 包内是 `<运单号>-<货件号>_mark.pdf` ⇒ 必须含本票货件号。
|
|
515
|
+
|
|
516
|
+
返回成员名列表。⛔ 不符即报错(外层文件名是时间戳时,这是唯一能拦住"用错文件"的判据)。
|
|
517
|
+
"""
|
|
518
|
+
with zipfile.ZipFile(zip_path) as z:
|
|
519
|
+
names = list(z.namelist())
|
|
520
|
+
if expect_shipment and not any(expect_shipment in n for n in names):
|
|
521
|
+
raise RuntimeError(
|
|
522
|
+
f"❌ 下载的压缩包与本票不符(期望含 {expect_shipment},实见 {names[:5]})—— "
|
|
523
|
+
"并发跑批拿错了文件;本次**不使用**它"
|
|
524
|
+
)
|
|
525
|
+
return names
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def download_latest_ddp(page, download_dir: Path, *, expect_shipment: str = "") -> dict:
|
|
529
|
+
"""⑨ 取「导出任务」面板最上面一条并下载(⚠️ 面板混着历史任务,靠"最上面"=最新)。
|
|
530
|
+
|
|
531
|
+
两条路,优先走**页面取字节**:
|
|
532
|
+
① `expect_download()` 只为拿到**稳定 URL**,然后用页面会话 fetch 字节——
|
|
533
|
+
实测 `download.path()` 的临时文件会被 Playwright 清掉(`FileNotFoundError` 竞态),
|
|
534
|
+
而 `save_as()` 又会存成 0 字节,所以都不可靠;
|
|
535
|
+
② 兜底:临时文件还在就 `shutil.copy`。
|
|
536
|
+
|
|
537
|
+
`expect_shipment` 给了就做**归属校验**(文件名 8 位票号 + 包内容成员名),不符即报错。
|
|
538
|
+
"""
|
|
539
|
+
open_task_panel(page)
|
|
540
|
+
with page.expect_download(timeout=40000) as info:
|
|
541
|
+
page.locator("button:has-text('下载'), a:has-text('下载')").first.click()
|
|
542
|
+
d = info.value
|
|
543
|
+
name = d.suggested_filename or ""
|
|
544
|
+
owned = assert_download_owned(name, expect_shipment) # ⛔ 先判归属,再落盘(不落错文件)
|
|
545
|
+
download_dir.mkdir(parents=True, exist_ok=True)
|
|
546
|
+
dest = download_dir / (name or "ddp.zip")
|
|
547
|
+
url = d.url
|
|
548
|
+
try:
|
|
549
|
+
data = fetch_bytes(page, url)
|
|
550
|
+
dest.write_bytes(data)
|
|
551
|
+
how = "page-fetch"
|
|
552
|
+
except Exception:
|
|
553
|
+
shutil.copy(d.path(), dest) # 兜底
|
|
554
|
+
how = "temp-copy"
|
|
555
|
+
inner: list = []
|
|
556
|
+
if zipfile.is_zipfile(dest):
|
|
557
|
+
inner = assert_zip_owned(str(dest), expect_shipment)
|
|
558
|
+
close_overlays(page) # ⚠️ 用完就关,别让遮罩挡住下一步
|
|
559
|
+
return {"path": str(dest), "name": dest.name, "size": dest.stat().st_size, "url": url,
|
|
560
|
+
"how": how, "owned": owned, "inner": inner}
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def unzip_marks(zip_path: str, out_dir: Path) -> list:
|
|
564
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
565
|
+
with zipfile.ZipFile(zip_path) as z:
|
|
566
|
+
z.extractall(out_dir)
|
|
567
|
+
return [str(p) for p in sorted(out_dir.rglob("*")) if p.is_file()]
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def rename_mark(pdf_path: str, shipment_no: str, boxes, name_cn: str) -> str:
|
|
571
|
+
"""⑪ 按 `运单-<货件号>-<箱数>箱-<中文品名>.pdf` 改名。"""
|
|
572
|
+
src = Path(pdf_path)
|
|
573
|
+
dest = src.with_name(f"运单-{shipment_no}-{boxes}箱-{name_cn}.pdf")
|
|
574
|
+
src.rename(dest)
|
|
575
|
+
return str(dest)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
# ───────────────────────────── CLI ─────────────────────────────
|
|
579
|
+
|
|
580
|
+
def lock_probe() -> int:
|
|
581
|
+
"""排障入口:**按生产同一顺序**(先有界拿门户锁 → 再开一个自己的隔离页)。
|
|
582
|
+
|
|
583
|
+
退出码:0 = 拿到锁且开得出自己的页;3 = **门户被占用/等待超时**(文案见 `PortalLockTimeout`,
|
|
584
|
+
此时**根本不开浏览器**、连 playwright 都不 import ⇒ "超时 = 不进入门户阶段");2 = 锁本身不可用(fail-closed)。
|
|
585
|
+
"""
|
|
586
|
+
try:
|
|
587
|
+
with portal_lock(site="portal.lock-probe", reporter=lambda m: print(m, flush=True)) as waited:
|
|
588
|
+
from playwright.sync_api import sync_playwright # ⚠️ 进门户阶段才 import(超时路径零依赖)
|
|
589
|
+
with sync_playwright() as p:
|
|
590
|
+
sess = connect(p)
|
|
591
|
+
try:
|
|
592
|
+
print(json.dumps({"ok": True, "waited": round(waited, 2), "locked": True,
|
|
593
|
+
"targetId": sess.target_id}, ensure_ascii=False))
|
|
594
|
+
finally:
|
|
595
|
+
sess.close()
|
|
596
|
+
except LockError as exc:
|
|
597
|
+
print(f"❌ 门户锁不可用(fail-closed,绝不无锁继续):{exc}", file=sys.stderr)
|
|
598
|
+
return 2
|
|
599
|
+
except PortalLockTimeout as exc:
|
|
600
|
+
print(f"❌ {exc}", file=sys.stderr)
|
|
601
|
+
return 3
|
|
602
|
+
return 0
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def main() -> int:
|
|
606
|
+
ap = argparse.ArgumentParser()
|
|
607
|
+
ap.add_argument("--state", action="store_true")
|
|
608
|
+
ap.add_argument("--query")
|
|
609
|
+
ap.add_argument("--warehouse-menu", action="store_true")
|
|
610
|
+
ap.add_argument("--lock-probe", action="store_true",
|
|
611
|
+
help="门户锁体检:先有界拿锁(拿不到 ⇒ 退出码 3 + 标准文案),再开一个自己的隔离页")
|
|
612
|
+
a = ap.parse_args()
|
|
613
|
+
if a.lock_probe:
|
|
614
|
+
return lock_probe()
|
|
615
|
+
from playwright.sync_api import sync_playwright
|
|
616
|
+
with sync_playwright() as p:
|
|
617
|
+
sess = connect(p)
|
|
618
|
+
try:
|
|
619
|
+
page = sess.page
|
|
620
|
+
if a.state:
|
|
621
|
+
print(json.dumps(login_state(page), ensure_ascii=False, indent=1))
|
|
622
|
+
if a.query:
|
|
623
|
+
print(json.dumps(query_shipment(page, a.query), ensure_ascii=False, indent=1))
|
|
624
|
+
if a.warehouse_menu:
|
|
625
|
+
print(json.dumps(warehouse_menu_items(page), ensure_ascii=False, indent=1))
|
|
626
|
+
page.keyboard.press("Escape")
|
|
627
|
+
finally:
|
|
628
|
+
sess.close() # ⛔ 只关自己这个页(开→办事→关闭)
|
|
629
|
+
return 0
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
if __name__ == "__main__":
|
|
633
|
+
raise SystemExit(main())
|