@gobing-ai/knowledge-kit 0.0.37 → 0.1.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.
- package/.claude-plugin/marketplace.json +1 -1
- package/dist/board/collect/index.css +654 -16
- package/dist/board/collect/index.js +191 -64
- package/dist/board/daily-voice/index.css +679 -12
- package/dist/board/daily-voice/index.js +185 -66
- package/dist/board/itc/index.css +679 -13
- package/dist/board/itc/index.js +188 -66
- package/dist/index.js +2864 -139
- package/package.json +3 -2
- package/plugins/generations/content-gen/dist/index.js +158 -12
- package/plugins/generations/core-facts-gen/dist/index.js +158 -12
- package/plugins/generations/daily-article-gen/dist/index.js +158 -12
- package/plugins/generations/dailynews-gen/dist/index.js +158 -12
- package/plugins/generations/episode-plan-gen/dist/index.js +158 -12
- package/plugins/generations/gemini-tts/README.md +4 -3
- package/plugins/generations/gemini-tts/dist/index.js +158 -12
- package/plugins/generations/image-gen/dist/index.js +158 -12
- package/plugins/generations/news-report-gen/dist/index.js +158 -12
- package/plugins/generations/omni-voice-gen/README.md +15 -4
- package/plugins/generations/omni-voice-gen/profiles.json +1 -2
- package/plugins/generations/voice-gen/dist/index.js +158 -12
- package/plugins/generations/voxcpm-tts/README.md +21 -1
- package/plugins/generations/voxcpm-tts/dist/index.js +173 -13
- package/plugins/generations/voxcpm-tts/scripts/audit-speaker-consistency.py +109 -0
- package/plugins/generations/voxcpm-tts/src/voxcpm-client.ts +28 -1
- package/plugins/ingestions/aihot-ingest/dist/index.js +158 -12
- package/plugins/ingestions/horizon-ingest/dist/index.js +158 -12
- package/plugins/ingestions/last30days-ingest/dist/index.js +158 -12
- package/plugins/ingestions/web-search/dist/index.js +158 -12
- package/plugins/kk/commands/workflow-run.md +56 -18
- package/plugins/kk/decisions/kk-itc.yaml +46 -0
- package/plugins/kk/decisions/kk-seo-keyword.yaml +57 -0
- package/plugins/kk/decisions/kk-seo-page.yaml +230 -0
- package/plugins/kk/decisions/kk-seo-pair.yaml +21 -0
- package/plugins/kk/decisions/kk-seo-site.yaml +90 -0
- package/plugins/kk/plugin.json +1 -1
- package/plugins/kk/publish-presets/chinese-article.json +14 -0
- package/plugins/kk/publish-presets/technical-article.json +14 -0
- package/plugins/kk/references/smart-seo-attribution.md +85 -0
- package/plugins/kk/scripts/daily-stages.ts +42 -2
- package/plugins/kk/scripts/itc-stages.ts +25 -118
- package/plugins/kk/scripts/kk-workflow-stages.ts +38 -1
- package/plugins/kk/scripts/seo-decisions.ts +477 -0
- package/plugins/kk/scripts/seo-enrich.ts +728 -0
- package/plugins/kk/scripts/seo-facts.ts +610 -0
- package/plugins/kk/scripts/seo-fetch.ts +766 -0
- package/plugins/kk/scripts/seo-report.ts +460 -0
- package/plugins/kk/scripts/seo-rules.ts +1882 -0
- package/plugins/kk/scripts/seo-stages.ts +1044 -0
- package/plugins/kk/skills/article-adapt/SKILL.md +2 -2
- package/plugins/kk/skills/smart-seo/SKILL.md +183 -0
- package/plugins/kk/skills/smart-seo/references/method.md +126 -0
- package/plugins/kk/skills/smart-seo/references/narrative.md +125 -0
- package/plugins/kk/skills/smart-seo/references/operations.md +202 -0
- package/plugins/kk/skills/smart-seo/references/seo-geo-sources.md +90 -0
- package/plugins/kk/workflows/feature-verification.yaml +28 -24
- package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +85 -17
- package/plugins/kk/workflows/kk-itc-author.yaml +406 -0
- package/plugins/kk/workflows/kk-itc.yaml +208 -682
- package/plugins/kk/workflows/kk-smart-seo.yaml +251 -0
- package/plugins/publishings/bilibili-pub/dist/index.js +21782 -0
- package/plugins/publishings/bilibili-pub/dist/profile-check.js +20410 -0
- package/plugins/publishings/bilibili-pub/package.json +20 -0
- package/plugins/publishings/bilibili-pub/plugin.json +12 -0
- package/plugins/publishings/bilibili-pub/src/flow.ts +1296 -0
- package/plugins/publishings/bilibili-pub/src/index.ts +139 -0
- package/plugins/publishings/bilibili-pub/src/profile-check.ts +19 -0
- package/plugins/publishings/bilibili-pub/src/selectors.ts +222 -0
- package/plugins/publishings/bilibili-pub/tsconfig.json +4 -0
- package/plugins/publishings/cnblogs-pub/dist/index.js +21383 -0
- package/plugins/publishings/cnblogs-pub/dist/profile-check.js +20412 -0
- package/plugins/publishings/cnblogs-pub/package.json +19 -0
- package/plugins/publishings/cnblogs-pub/plugin.json +12 -0
- package/plugins/publishings/cnblogs-pub/src/flow.ts +708 -0
- package/plugins/publishings/cnblogs-pub/src/index.ts +172 -0
- package/plugins/publishings/cnblogs-pub/src/profile-check.ts +24 -0
- package/plugins/publishings/cnblogs-pub/src/selectors.ts +104 -0
- package/plugins/publishings/cnblogs-pub/tsconfig.json +4 -0
- package/plugins/publishings/csdn-pub/dist/index.js +21552 -0
- package/plugins/publishings/csdn-pub/dist/profile-check.js +20963 -0
- package/plugins/publishings/csdn-pub/package.json +19 -0
- package/plugins/publishings/csdn-pub/plugin.json +12 -0
- package/plugins/publishings/csdn-pub/src/index.ts +1178 -0
- package/plugins/publishings/csdn-pub/src/profile-check.ts +23 -0
- package/plugins/publishings/csdn-pub/tsconfig.json +4 -0
- package/plugins/publishings/emdash-pub/dist/index.js +158 -12
- package/plugins/publishings/emdash-pub/plugin.json +22 -1
- package/plugins/publishings/infoq-pub/dist/index.js +160 -12
- package/plugins/publishings/infoq-pub/dist/profile-check.js +160 -12
- package/plugins/publishings/infoq-pub/plugin.json +21 -0
- package/plugins/publishings/jike-pub/dist/index.js +21379 -0
- package/plugins/publishings/jike-pub/dist/profile-check.js +20951 -0
- package/plugins/publishings/jike-pub/package.json +19 -0
- package/plugins/publishings/jike-pub/plugin.json +12 -0
- package/plugins/publishings/jike-pub/src/index.ts +800 -0
- package/plugins/publishings/jike-pub/src/profile-check.ts +21 -0
- package/plugins/publishings/jike-pub/tsconfig.json +4 -0
- package/plugins/publishings/juejin-pub/dist/index.js +160 -12
- package/plugins/publishings/juejin-pub/dist/profile-check.js +160 -12
- package/plugins/publishings/juejin-pub/plugin.json +21 -0
- package/plugins/publishings/medium-pub/dist/index.js +158 -12
- package/plugins/publishings/medium-pub/plugin.json +22 -1
- package/plugins/publishings/podcast-pub/dist/index.js +158 -12
- package/plugins/publishings/qiita-pub/dist/index.js +158 -12
- package/plugins/publishings/qiita-pub/plugin.json +24 -1
- package/plugins/publishings/shipinhao-pub/dist/index.js +21715 -0
- package/plugins/publishings/shipinhao-pub/dist/profile-check.js +20431 -0
- package/plugins/publishings/shipinhao-pub/package.json +19 -0
- package/plugins/publishings/shipinhao-pub/plugin.json +12 -0
- package/plugins/publishings/shipinhao-pub/src/flow.ts +1323 -0
- package/plugins/publishings/shipinhao-pub/src/index.ts +173 -0
- package/plugins/publishings/shipinhao-pub/src/profile-check.ts +25 -0
- package/plugins/publishings/shipinhao-pub/src/selectors.ts +241 -0
- package/plugins/publishings/shipinhao-pub/tsconfig.json +4 -0
- package/plugins/publishings/sspai-pub/dist/index.js +21371 -0
- package/plugins/publishings/sspai-pub/dist/profile-check.js +20414 -0
- package/plugins/publishings/sspai-pub/package.json +19 -0
- package/plugins/publishings/sspai-pub/plugin.json +12 -0
- package/plugins/publishings/sspai-pub/src/flow.ts +624 -0
- package/plugins/publishings/sspai-pub/src/index.ts +167 -0
- package/plugins/publishings/sspai-pub/src/profile-check.ts +25 -0
- package/plugins/publishings/sspai-pub/src/selectors.ts +122 -0
- package/plugins/publishings/sspai-pub/tsconfig.json +4 -0
- package/plugins/publishings/substack-pub/dist/index.js +160 -12
- package/plugins/publishings/substack-pub/dist/profile-check.js +160 -12
- package/plugins/publishings/substack-pub/plugin.json +21 -0
- package/plugins/publishings/surfdash-pub/dist/index.js +158 -12
- package/plugins/publishings/surfdash-pub/plugin.json +22 -1
- package/plugins/publishings/threads-pub/dist/index.js +158 -12
- package/plugins/publishings/wechatmp-pub/dist/index.js +1669 -502
- package/plugins/publishings/wechatmp-pub/dist/profile-check.js +204 -21
- package/plugins/publishings/wechatmp-pub/plugin.json +32 -0
- package/plugins/publishings/wechatmp-pub/src/cgi-draft.ts +540 -0
- package/plugins/publishings/wechatmp-pub/src/cgi-media.ts +380 -0
- package/plugins/publishings/wechatmp-pub/src/cgi.ts +485 -0
- package/plugins/publishings/wechatmp-pub/src/flow.ts +104 -0
- package/plugins/publishings/weibo-pub/dist/index.js +160 -12
- package/plugins/publishings/weibo-pub/dist/profile-check.js +160 -12
- package/plugins/publishings/weibo-pub/plugin.json +35 -0
- package/plugins/publishings/x-pub/dist/index.js +160 -12
- package/plugins/publishings/x-pub/dist/profile-check.js +160 -12
- package/plugins/publishings/x-pub/plugin.json +40 -0
- package/plugins/publishings/xhs-pub/dist/index.js +160 -12
- package/plugins/publishings/xhs-pub/dist/profile-check.js +160 -12
- package/plugins/publishings/xhs-pub/plugin.json +37 -0
- package/plugins/publishings/xiaoyuzhou-pub/dist/index.js +21491 -0
- package/plugins/publishings/xiaoyuzhou-pub/dist/profile-check.js +20417 -0
- package/plugins/publishings/xiaoyuzhou-pub/package.json +19 -0
- package/plugins/publishings/xiaoyuzhou-pub/plugin.json +12 -0
- package/plugins/publishings/xiaoyuzhou-pub/src/flow.ts +826 -0
- package/plugins/publishings/xiaoyuzhou-pub/src/index.ts +174 -0
- package/plugins/publishings/xiaoyuzhou-pub/src/profile-check.ts +26 -0
- package/plugins/publishings/xiaoyuzhou-pub/src/selectors.ts +125 -0
- package/plugins/publishings/xiaoyuzhou-pub/tsconfig.json +4 -0
- package/plugins/publishings/zenn-pub/dist/index.js +158 -12
- package/plugins/publishings/zenn-pub/plugin.json +22 -1
- package/plugins/generations/omni-voice-gen/voices/robin-news.wav +0 -0
|
@@ -0,0 +1,1323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* shipinhao-pub transport + flow (task 0296).
|
|
3
|
+
*
|
|
4
|
+
* The live short-video flow in `runShortVideoFlow`, stage-typed on every `Result.metadata.stage`:
|
|
5
|
+
* root-resolve (the wujie policy) → video-upload → media-processing → title-fill →
|
|
6
|
+
* description-paste-readback → original-declare (only when `metadata.original === true`) →
|
|
7
|
+
* cover-attach (when requested) → draft-save | publish-click → draft-receipt | publish-receipt.
|
|
8
|
+
*
|
|
9
|
+
* Root policy (shipinhao.js:3-5, 514-545, frozen by the task 0296 Design): if `wujie-app` is
|
|
10
|
+
* present, wait at most `SHP_ROOT_WAIT_MS` (vendor ROOT_WAIT_MS 30000, shipinhao.js:38) for its
|
|
11
|
+
* shadowRoot plus creator-form evidence inside it and NEVER fall back to document
|
|
12
|
+
* (shipinhao.js:538-539); if no wujie-app exists, require the direct evidence probes
|
|
13
|
+
* (shipinhao.js:516-518) with a unique video file input. Ambiguous or absent roots are typed
|
|
14
|
+
* `root-resolve` refusals — no wrong-root, ever. All wujie-mode lookups are scoped inside the
|
|
15
|
+
* shadow root (the vendor queries only `[document, wujieApp.shadowRoot]`, shipinhao.js:61-63);
|
|
16
|
+
* Playwright locators pierce the open root natively and evaluate scripts re-derive the same
|
|
17
|
+
* vendor root.
|
|
18
|
+
*
|
|
19
|
+
* Video attach uses `Locator.setInputFiles` on the local path — never the vendor's in-page
|
|
20
|
+
* DataTransfer/blob fetch (shipinhao.js:609-631) — and the bare file assignment is explicitly
|
|
21
|
+
* NOT success (shipinhao.js:623-625): the post-upload editing form (title input + description
|
|
22
|
+
* editor) must appear at the 600s media ceiling before the flow proceeds. Description Markdown is
|
|
23
|
+
* rendered to HTML, sanitized here first and pasted as text/html. The 声明原创 sequence (entry
|
|
24
|
+
* checkbox → declare-body-wrapper agreement checkbox → 声明原创 confirm) runs ONLY on an explicit
|
|
25
|
+
* operator assertion (`metadata.original === true`) and type-fails on missing/ambiguous evidence
|
|
26
|
+
* — the vendor runs it unconditionally and degrades failures to logs (shipinhao.js:10-14,
|
|
27
|
+
* 2270-2310); false/absent never touches originality. Success — draft or public — comes only
|
|
28
|
+
* from a FRESH id-bearing receipt URL plus re-read persisted title/description(/cover/original)
|
|
29
|
+
* state; stale, ambiguous or toast-only signals are typed failures.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { type Result, ResultSchema } from '@gobing-ai/kk-core';
|
|
33
|
+
import type { BrowserDriver, PublishTransport, PublishTransportPayload } from '@gobing-ai/publish-harness';
|
|
34
|
+
import {
|
|
35
|
+
checkChannelSession,
|
|
36
|
+
expectOutcome,
|
|
37
|
+
kkBrowserLauncher,
|
|
38
|
+
markdownToHtml,
|
|
39
|
+
resolveChannelProfileDir,
|
|
40
|
+
waitUntil,
|
|
41
|
+
} from '@gobing-ai/publish-harness/browser';
|
|
42
|
+
import {
|
|
43
|
+
type BrowserLauncher,
|
|
44
|
+
BrowserProfileMissingError,
|
|
45
|
+
LoginTimeoutError,
|
|
46
|
+
withProfilePage,
|
|
47
|
+
} from '@gobing-ai/ts-browser-automation';
|
|
48
|
+
import type { Locator, Page } from 'playwright';
|
|
49
|
+
import {
|
|
50
|
+
isShipinhaoAuthenticated,
|
|
51
|
+
readRootSnapshot,
|
|
52
|
+
SHP_CONFIG,
|
|
53
|
+
SHP_COVER_SAVE_WAIT_MS,
|
|
54
|
+
SHP_HOME_URL,
|
|
55
|
+
SHP_RECEIPT_URL_RES,
|
|
56
|
+
SHP_ROOT_WAIT_MS,
|
|
57
|
+
SHP_TARGET,
|
|
58
|
+
SHP_UPLOAD_TIMEOUT_MS,
|
|
59
|
+
SHP_VIDEO_SELECTORS,
|
|
60
|
+
type ShipinhaoRootSnapshot,
|
|
61
|
+
} from './selectors';
|
|
62
|
+
|
|
63
|
+
const STEP_TIMEOUT_MS = 30_000;
|
|
64
|
+
/**
|
|
65
|
+
* KK v1 local-video size bound (task 0296 Design freeze): 2 GiB. A project-scoped product bound,
|
|
66
|
+
* not a platform limit — Channels' own ceiling is unpinned.
|
|
67
|
+
*/
|
|
68
|
+
const MAX_VIDEO_BYTES = 2 * 1024 * 1024 * 1024;
|
|
69
|
+
/** Operator recovery command every session failure names — never a cookie, DOM dump or stack. */
|
|
70
|
+
export const SHP_LOGIN_INSTRUCTION = 'bun plugins/publishings/shipinhao-pub/src/profile-check.ts login';
|
|
71
|
+
|
|
72
|
+
/** `ok: false` refusal Result with a bounded, single-line error. */
|
|
73
|
+
export function fail(target: string, error: string): Result {
|
|
74
|
+
return ResultSchema.parse({ ok: false, target, error: boundedDetail(error) });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A browser-stage failure: `ok: false` with `stage` (and optional evidence) in the Result metadata. */
|
|
78
|
+
export function stageFail(
|
|
79
|
+
target: string,
|
|
80
|
+
error: string,
|
|
81
|
+
stage: string,
|
|
82
|
+
metadata: Record<string, unknown>,
|
|
83
|
+
evidence?: string,
|
|
84
|
+
): Result {
|
|
85
|
+
return ResultSchema.parse({
|
|
86
|
+
ok: false,
|
|
87
|
+
target,
|
|
88
|
+
error: boundedDetail(error),
|
|
89
|
+
metadata: { ...metadata, stage, ...(evidence === undefined ? {} : { evidence: boundedDetail(evidence) }) },
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** One short line, bounded: no DOM dump, cookie, token, stack or leaked media path ever reaches the Result. */
|
|
94
|
+
export function boundedDetail(detail: string): string {
|
|
95
|
+
return detail.replace(/\s+/g, ' ').trim().slice(0, 300);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A bounded, single-line rendering of an operator-supplied value for a refusal message. */
|
|
99
|
+
export function bounded(value: unknown): string {
|
|
100
|
+
if (Array.isArray(value)) return `${value.length} entr${value.length === 1 ? 'y' : 'ies'}`;
|
|
101
|
+
return String(value).replace(/\s+/g, ' ').slice(0, 100);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function errorMessage(error: unknown): string {
|
|
105
|
+
return error instanceof Error ? error.message : String(error);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Session failures whose one-liner must name the recovery command. */
|
|
109
|
+
function needsLogin(error: unknown): boolean {
|
|
110
|
+
return error instanceof LoginTimeoutError || error instanceof BrowserProfileMissingError;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Whitespace-insensitive comparison: site normalizations must not fail a strict readback. */
|
|
114
|
+
function normalizeText(value: string): string {
|
|
115
|
+
return value.replace(/\s+/g, '');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Plugin-local HTML sanitizer (task 0296 Design freeze — no new dependency): strips
|
|
120
|
+
* script/style/iframe/embed/object containers, `on*` event attributes and `javascript:`/`data:`
|
|
121
|
+
* URLs while preserving code/list/heading markup, before any paste reaches the editor. Mirrors the
|
|
122
|
+
* xiaoyuzhou-pub sanitizer (0295) including its `data:` arm.
|
|
123
|
+
*/
|
|
124
|
+
export function sanitizeShipinhaoHtml(html: string): string {
|
|
125
|
+
return html
|
|
126
|
+
.replace(/<\s*(script|style|iframe|embed|object)\b[\s\S]*?<\s*\/\s*\1\s*>/gi, '')
|
|
127
|
+
.replace(/<\s*(script|style|iframe|embed|object)\b[^>]*>/gi, '')
|
|
128
|
+
.replace(/\son[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '')
|
|
129
|
+
.replace(/(\s(?:href|src)\s*=\s*)(["'])\s*(?:javascript|data):[^"']*\2/gi, '$1$2$2')
|
|
130
|
+
.replace(/(\s(?:href|src)\s*=\s*)(?:javascript|data):[^\s>]*/gi, '$1""');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The vendor root expression inside evaluate scripts: wujie mode derives exactly
|
|
135
|
+
* `document.querySelector('wujie-app').shadowRoot` (shipinhao.js:61-63 — shadow ONLY, never
|
|
136
|
+
* document); direct mode is document. Every page script re-derives this root instead of piercing
|
|
137
|
+
* globally, so a wujie-mode lookup can never resolve a host-page (wrong-root) control.
|
|
138
|
+
*/
|
|
139
|
+
function rootExpression(mode: 'wujie' | 'direct'): string {
|
|
140
|
+
return mode === 'wujie' ? "document.querySelector('wujie-app')?.shadowRoot ?? null" : 'document';
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Shared script preamble: the vendor root plus the normalized-text/checked/label helpers. */
|
|
144
|
+
function rootScriptPrefix(mode: 'wujie' | 'direct'): string {
|
|
145
|
+
return `(() => {
|
|
146
|
+
const root = ${rootExpression(mode)};
|
|
147
|
+
const norm = (v) => String(v ?? '').replace(/\\s+/g, '');
|
|
148
|
+
const isChecked = (input) => Boolean(input && (input.checked
|
|
149
|
+
|| input.getAttribute('aria-checked') === 'true'
|
|
150
|
+
|| (input.closest && input.closest('.ant-checkbox-checked, [data-state="checked"]'))));
|
|
151
|
+
const fieldLabel = (input) => [input.id, input.name, input.className,
|
|
152
|
+
input.getAttribute && input.getAttribute('aria-label'), input.getAttribute && input.getAttribute('title'),
|
|
153
|
+
input.getAttribute && input.getAttribute('data-testid')].filter(Boolean).join(' ');`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Bounded wujie root policy (stage `root-resolve`): waits at most `SHP_ROOT_WAIT_MS` for the
|
|
158
|
+
* active creator root exactly like the vendor's `resolveRoot` (shipinhao.js:532-545) — wujie
|
|
159
|
+
* present ⇒ its shadowRoot ONLY (no document fallback, shipinhao.js:538-539); no wujie ⇒ direct
|
|
160
|
+
* evidence probes (shipinhao.js:516-518) with a UNIQUE video file input (the vendor's single
|
|
161
|
+
* untyped-input rule, shipinhao.js:580-583). Ambiguous/absent = typed refusal naming the vendor
|
|
162
|
+
* readiness reason (shipinhao.js:524-529).
|
|
163
|
+
*/
|
|
164
|
+
async function resolveCreatorRoot(
|
|
165
|
+
page: Page,
|
|
166
|
+
): Promise<{ mode?: 'wujie' | 'direct'; error?: undefined } | { mode?: undefined; error: string }> {
|
|
167
|
+
let last: ShipinhaoRootSnapshot | undefined;
|
|
168
|
+
const ready = await waitUntil(
|
|
169
|
+
async () => {
|
|
170
|
+
const snapshot = await readRootSnapshot(page);
|
|
171
|
+
last = snapshot;
|
|
172
|
+
if (snapshot.wujiePresent) {
|
|
173
|
+
return snapshot.shadowRootReady && snapshot.shadowFileInputs + snapshot.shadowTextEvidence > 0;
|
|
174
|
+
}
|
|
175
|
+
return snapshot.directFileInputs + snapshot.directTextEvidence > 0;
|
|
176
|
+
},
|
|
177
|
+
SHP_ROOT_WAIT_MS,
|
|
178
|
+
500,
|
|
179
|
+
);
|
|
180
|
+
const snapshot = last ?? (await readRootSnapshot(page));
|
|
181
|
+
if (!ready) {
|
|
182
|
+
if (snapshot.wujiePresent && !snapshot.shadowRootReady) {
|
|
183
|
+
return {
|
|
184
|
+
error: `shipinhao creator form not ready (shadow_root_not_ready): wujie-app is present but its shadowRoot did not become ready within ${SHP_ROOT_WAIT_MS}ms — the vendor waits for the shadow root and never falls back to document (shipinhao.js:524-525, 538-539); refusing without a wrong-root`,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
if (snapshot.wujiePresent) {
|
|
188
|
+
return {
|
|
189
|
+
error: `shipinhao creator form not ready: the wujie-app shadowRoot is attached but no creator-form evidence (video file input or 添加描述/标题 placeholder) appeared inside it within ${SHP_ROOT_WAIT_MS}ms (shipinhao.js:516-518)`,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
error: `shipinhao creator form not ready (publish_root_selector_unmatched): no wujie-app and no direct evidence (input[type=file], 添加描述/标题 placeholder) within ${SHP_ROOT_WAIT_MS}ms on ${page.url()} (shipinhao.js:526-529) — run: ${SHP_LOGIN_INSTRUCTION}`,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
if (!snapshot.wujiePresent && snapshot.directFileInputs > 1) {
|
|
197
|
+
return {
|
|
198
|
+
error: `shipinhao creator form ambiguous: ${snapshot.directFileInputs} direct input[type=file] candidates without a wujie root — the vendor resolves a unique untyped input or nothing (shipinhao.js:580-583); refusing rather than picking a wrong upload target`,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
return { mode: snapshot.wujiePresent ? 'wujie' : 'direct' };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Resolves the first attached candidate, scoped to the active creator root: wujie mode chains
|
|
206
|
+
* through `wujie-app`'s open shadow root, direct mode searches the document. Polls to the
|
|
207
|
+
* deadline; throws naming the label and every candidate when nothing attaches (typed at the call
|
|
208
|
+
* site — every call site wraps this and turns the throw into a `stageFail`).
|
|
209
|
+
*/
|
|
210
|
+
async function resolveRootLocator(
|
|
211
|
+
page: Page,
|
|
212
|
+
mode: 'wujie' | 'direct',
|
|
213
|
+
selectors: readonly string[],
|
|
214
|
+
label: string,
|
|
215
|
+
timeoutMs: number = STEP_TIMEOUT_MS,
|
|
216
|
+
): Promise<{ locator: Locator; selector: string }> {
|
|
217
|
+
const scope = mode === 'wujie' ? page.locator('wujie-app') : page;
|
|
218
|
+
const outcome = await expectOutcome(
|
|
219
|
+
async () => {
|
|
220
|
+
for (const selector of selectors) {
|
|
221
|
+
if ((await scope.locator(selector).first().count()) > 0) return selector;
|
|
222
|
+
}
|
|
223
|
+
return null;
|
|
224
|
+
},
|
|
225
|
+
{ timeoutMs, label: `${label} attach` },
|
|
226
|
+
);
|
|
227
|
+
if (!outcome.ok) throw new Error(`${label}: ${selectors.join(' | ')} not attached (${outcome.error})`);
|
|
228
|
+
const selector = outcome.evidence;
|
|
229
|
+
return { locator: scope.locator(selector).first(), selector };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Shadow-safe rich-text paste: a synthetic `paste` ClipboardEvent (text/html + text/plain) and
|
|
234
|
+
* input/change dispatch evaluated against the active root and the resolved editor selector, so
|
|
235
|
+
* the editor is reached even inside the wujie shadow root (the shared `pasteHtml` helper
|
|
236
|
+
* evaluates `document.querySelector`, which cannot pierce). Mirrors the harness paste mechanics
|
|
237
|
+
* and the vendor's simulatePaste + event dispatch; runs via `page.evaluate` like every other
|
|
238
|
+
* in-page script here — Playwright 1.55 evaluates a `Locator.evaluate` string as a bare
|
|
239
|
+
* expression, so a function string silently never runs (found by the 0296 real-DOM fixture).
|
|
240
|
+
*/
|
|
241
|
+
async function pasteRichText(page: Page, mode: 'wujie' | 'direct', selector: string, html: string): Promise<void> {
|
|
242
|
+
const outcome = String(
|
|
243
|
+
await page.evaluate(`${rootScriptPrefix(mode)}
|
|
244
|
+
const el = root === null ? null : root.querySelector(${JSON.stringify(selector)});
|
|
245
|
+
if (el === null) return 'missing';
|
|
246
|
+
const probe = document.createElement('div');
|
|
247
|
+
probe.innerHTML = ${JSON.stringify(html)};
|
|
248
|
+
const data = new DataTransfer();
|
|
249
|
+
data.setData('text/html', ${JSON.stringify(html)});
|
|
250
|
+
data.setData('text/plain', String(probe.textContent ?? ''));
|
|
251
|
+
el.focus();
|
|
252
|
+
el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }));
|
|
253
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
254
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
255
|
+
return 'pasted';
|
|
256
|
+
})()`),
|
|
257
|
+
);
|
|
258
|
+
if (outcome !== 'pasted') throw new Error(`pasteRichText: ${selector} not attached for paste`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Root-scoped title input `value` readback. */
|
|
262
|
+
function titleValueScript(mode: 'wujie' | 'direct', selector: string): string {
|
|
263
|
+
return `${rootScriptPrefix(mode)}
|
|
264
|
+
const el = root === null ? null : root.querySelector(${JSON.stringify(selector)});
|
|
265
|
+
return el === null ? '' : String(el.value ?? '');
|
|
266
|
+
})()`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Root-scoped editor text readback (innerText of the resolved description editor). */
|
|
270
|
+
function editorTextScript(mode: 'wujie' | 'direct', selector: string): string {
|
|
271
|
+
return `${rootScriptPrefix(mode)}
|
|
272
|
+
const el = root === null ? null : root.querySelector(${JSON.stringify(selector)});
|
|
273
|
+
return el === null ? '' : String(el.innerText ?? el.textContent ?? '');
|
|
274
|
+
})()`;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Receipt-shaped URLs on screen (location + anchors across the vendor roots) — baseline and fresh
|
|
279
|
+
* read for the receipt stage.
|
|
280
|
+
*/
|
|
281
|
+
async function readReceiptUrls(page: Page): Promise<readonly string[]> {
|
|
282
|
+
const raw = await page.evaluate(`(() => { // kk-shp-receipt-urls
|
|
283
|
+
const patterns = [${SHP_RECEIPT_URL_RES.map((re) => re.toString()).join(', ')}];
|
|
284
|
+
const matches = (url) => patterns.some((re) => re.test(url));
|
|
285
|
+
const urls = [];
|
|
286
|
+
const roots = [document, (document.querySelector('wujie-app') || {}).shadowRoot].filter(Boolean);
|
|
287
|
+
for (const root of roots) {
|
|
288
|
+
if (root === document && matches(location.href)) urls.push(location.href);
|
|
289
|
+
for (const a of root.querySelectorAll('a[href]')) {
|
|
290
|
+
if (matches(a.href) && !urls.includes(a.href)) urls.push(a.href);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return JSON.stringify(urls);
|
|
294
|
+
})()`);
|
|
295
|
+
try {
|
|
296
|
+
const parsed: unknown = JSON.parse(String(raw));
|
|
297
|
+
return Array.isArray(parsed) ? parsed.map((entry) => String(entry)) : [];
|
|
298
|
+
} catch {
|
|
299
|
+
return [];
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Works/record id from an observed receipt URL (shapes: `/record/detail/<id>`, `/post/video/<id>`; provisional). */
|
|
304
|
+
function receiptIdFrom(url: string): string | undefined {
|
|
305
|
+
return /\/(?:detail|video)\/([0-9a-zA-Z_-]+)/.exec(url)?.[1];
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Pre-browser required-video gate: `metadata.videoPath` must name an existing, regular, readable,
|
|
310
|
+
* nonempty local MP4/MOV under the KK v1 2 GiB local cap (labeled local bound — not a platform
|
|
311
|
+
* limit). Probed through the Bun file API (repo rule no-direct-fs-io) — never node:fs.
|
|
312
|
+
*/
|
|
313
|
+
async function resolveVideoPath(
|
|
314
|
+
raw: unknown,
|
|
315
|
+
): Promise<{ path?: string; error?: undefined } | { path?: undefined; error: string }> {
|
|
316
|
+
if (typeof raw !== 'string' || raw.trim().length === 0) {
|
|
317
|
+
return { error: `metadata.videoPath must be a non-empty local video file path, got '${bounded(raw)}'` };
|
|
318
|
+
}
|
|
319
|
+
const path = raw.trim();
|
|
320
|
+
if (!/\.(mp4|mov)$/i.test(path)) {
|
|
321
|
+
return {
|
|
322
|
+
error: `metadata.videoPath must be a local MP4/MOV file, got unsupported extension in '${bounded(path)}'`,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
// no-direct-fs-io: probe the file through the Bun API instead of node:fs.
|
|
326
|
+
const file = Bun.file(path);
|
|
327
|
+
const readable = await file.exists().catch(() => false);
|
|
328
|
+
if (!readable || file.size === 0) {
|
|
329
|
+
return { error: 'metadata.videoPath does not resolve to a readable nonempty local video file' };
|
|
330
|
+
}
|
|
331
|
+
if (file.size > MAX_VIDEO_BYTES) {
|
|
332
|
+
return {
|
|
333
|
+
error: `metadata.videoPath exceeds the KK v1 local video cap of 2 GiB (${file.size} bytes; a local product bound, not a platform limit)`,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
return { path };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Pre-browser requested-cover gate: an optional local PNG/JPEG/WebP that must exist as a regular,
|
|
341
|
+
* readable, nonempty file. Requested-but-absent or wrong-typed refuses here — a requested cover is
|
|
342
|
+
* never silently skipped (the vendor type-fails a missing cover slot too, shipinhao.js:2147-2149).
|
|
343
|
+
*/
|
|
344
|
+
async function resolveCoverPath(
|
|
345
|
+
raw: unknown,
|
|
346
|
+
): Promise<{ path?: string; error?: undefined } | { path?: undefined; error: string }> {
|
|
347
|
+
if (raw === undefined) return {};
|
|
348
|
+
if (typeof raw !== 'string' || raw.trim().length === 0) {
|
|
349
|
+
return { error: `metadata.coverPath must be a non-empty local file path when present, got '${bounded(raw)}'` };
|
|
350
|
+
}
|
|
351
|
+
const path = raw.trim();
|
|
352
|
+
if (!/\.(png|jpe?g|webp)$/i.test(path)) {
|
|
353
|
+
return {
|
|
354
|
+
error: `metadata.coverPath must be a local PNG/JPEG/WebP file, got unsupported extension in '${bounded(path)}'`,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
// no-direct-fs-io: probe the file through the Bun API instead of node:fs.
|
|
358
|
+
const file = Bun.file(path);
|
|
359
|
+
const readable = await file.exists().catch(() => false);
|
|
360
|
+
if (!readable || file.size === 0) {
|
|
361
|
+
return {
|
|
362
|
+
error: 'metadata.coverPath does not resolve to a readable nonempty local file (refusing, never a silent skip)',
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
return { path };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Pre-browser tags gate: optional array of nonempty strings; empties and non-strings refuse (the
|
|
370
|
+
* vendor trims and strips leading `#` when writing topics, shipinhao.js:2689-2692 — the same
|
|
371
|
+
* normalization is applied at paste time).
|
|
372
|
+
*/
|
|
373
|
+
function resolveTags(
|
|
374
|
+
raw: unknown,
|
|
375
|
+
): { tags?: readonly string[]; error?: undefined } | { tags?: undefined; error: string } {
|
|
376
|
+
if (raw === undefined) return { tags: [] };
|
|
377
|
+
if (!Array.isArray(raw)) {
|
|
378
|
+
return { error: `metadata.tags must be an array of nonempty topic strings, got '${bounded(raw)}'` };
|
|
379
|
+
}
|
|
380
|
+
const tags: string[] = [];
|
|
381
|
+
for (const entry of raw) {
|
|
382
|
+
if (typeof entry !== 'string' || entry.trim().length === 0) {
|
|
383
|
+
return { error: `metadata.tags must contain only nonempty topic strings, got '${bounded(entry)}'` };
|
|
384
|
+
}
|
|
385
|
+
tags.push(entry.trim());
|
|
386
|
+
}
|
|
387
|
+
return { tags };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Pre-browser originality gate: optional boolean — only `true` authorizes the declaration sequence. */
|
|
391
|
+
function resolveOriginal(
|
|
392
|
+
raw: unknown,
|
|
393
|
+
): { original?: boolean; error?: undefined } | { original?: undefined; error: string } {
|
|
394
|
+
if (raw === undefined) return { original: false };
|
|
395
|
+
if (typeof raw !== 'boolean') {
|
|
396
|
+
return { error: `metadata.original must be a boolean when present, got '${bounded(raw)}'` };
|
|
397
|
+
}
|
|
398
|
+
return { original: raw };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Unsupported requested fields for this flow: images, categories, publishTime/scheduling are not
|
|
403
|
+
* implemented (定时 is an unported vendor enhancement, shipinhao.js:7-9), so any of them — in
|
|
404
|
+
* options or metadata — is a typed unsupported-field refusal, never a silent claim of applied.
|
|
405
|
+
* `tags`/`original`/`coverPath` are implemented and validated in their resolvers.
|
|
406
|
+
*/
|
|
407
|
+
function unsupportedFields(payload: PublishTransportPayload): readonly string[] {
|
|
408
|
+
const refused: string[] = [];
|
|
409
|
+
for (const [container, keys] of [
|
|
410
|
+
['options', ['images', 'categories', 'publishTime', 'scheduling'] as const],
|
|
411
|
+
['metadata', ['images', 'categories', 'publishTime', 'scheduling'] as const],
|
|
412
|
+
] as const) {
|
|
413
|
+
const source: unknown = container === 'options' ? payload.options : payload.content.metadata;
|
|
414
|
+
for (const key of keys) {
|
|
415
|
+
if (source !== undefined && source !== null && typeof source === 'object' && key in source) {
|
|
416
|
+
refused.push(`${container}.${key}`);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
return refused;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** Post-upload media-processing probe: the editing form (title input + description editor) must appear in the active root — the assignment itself is never success (shipinhao.js:623-625, 2676-2683). */
|
|
424
|
+
function editingFormReadyScript(mode: 'wujie' | 'direct', titleSelector: string, editorSelector: string): string {
|
|
425
|
+
return `${rootScriptPrefix(mode)}
|
|
426
|
+
const title = root === null ? null : root.querySelector(${JSON.stringify(titleSelector)});
|
|
427
|
+
const editor = root === null ? null : root.querySelector(${JSON.stringify(editorSelector)});
|
|
428
|
+
return JSON.stringify({ ready: title !== null && editor !== null });
|
|
429
|
+
})()`;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* 声明原创 entry checkbox locator, vendor scoring ported verbatim (shipinhao.js:2233-2261):
|
|
434
|
+
* 原创/original attribute evidence, an associated 原创 label, or a short 原创 ancestor text;
|
|
435
|
+
* candidates inside `div.declare-body-wrapper` are excluded (shipinhao.js:2235-2236). The single
|
|
436
|
+
* top-scoring candidate is the entry; zero = missing, several top scorers = ambiguous (the
|
|
437
|
+
* operator-assertion contract type-fails where the vendor degrades to a log, shipinhao.js:2274).
|
|
438
|
+
* `action: 'ensure'` clicks an unchecked entry (the vendor clicks the raw transparent input
|
|
439
|
+
* directly, shipinhao.js:2225-2227, 2281).
|
|
440
|
+
*/
|
|
441
|
+
function originalEntryScript(mode: 'wujie' | 'direct', action: 'probe' | 'ensure'): string {
|
|
442
|
+
const ensure = action === 'ensure';
|
|
443
|
+
return `${rootScriptPrefix(mode)}
|
|
444
|
+
if (root === null) return JSON.stringify({ ok: false, reason: 'no-root' });
|
|
445
|
+
const boxes = Array.from(root.querySelectorAll('input[type="checkbox"]')).filter(
|
|
446
|
+
(input) => !(input.closest && input.closest('div.declare-body-wrapper')));
|
|
447
|
+
const labels = Array.from(root.querySelectorAll('label'));
|
|
448
|
+
const scored = [];
|
|
449
|
+
boxes.forEach((input, index) => {
|
|
450
|
+
let score = /原创|original/i.test(fieldLabel(input)) ? 120 : 0;
|
|
451
|
+
const label = labels.find((candidate) => candidate === (input.closest && input.closest('label'))
|
|
452
|
+
|| (input.id && candidate.htmlFor === input.id) || (candidate.contains && candidate.contains(input)));
|
|
453
|
+
if (label && /原创|声明原创/.test(norm(label.textContent))) score += 200;
|
|
454
|
+
let parent = input.parentElement;
|
|
455
|
+
for (let depth = 0; parent && depth < 4; depth += 1) {
|
|
456
|
+
if (/^(BODY|MAIN|FORM)$/.test(parent.tagName || '')) break;
|
|
457
|
+
const text = norm(parent.textContent);
|
|
458
|
+
if (text.length <= 80 && /原创|声明原创/.test(text)) { score += 100 - depth * 10; break; }
|
|
459
|
+
parent = parent.parentElement;
|
|
460
|
+
}
|
|
461
|
+
if (score > 0) scored.push({ index, score });
|
|
462
|
+
});
|
|
463
|
+
if (scored.length === 0) return JSON.stringify({ ok: false, reason: 'missing' });
|
|
464
|
+
const top = Math.max.apply(null, scored.map((candidate) => candidate.score));
|
|
465
|
+
const best = scored.filter((candidate) => candidate.score === top);
|
|
466
|
+
if (best.length > 1) return JSON.stringify({ ok: false, reason: 'ambiguous', count: best.length });
|
|
467
|
+
const input = boxes[best[0].index];
|
|
468
|
+
const checked = isChecked(input);
|
|
469
|
+
if (${ensure} && !checked) input.click();
|
|
470
|
+
return JSON.stringify({ ok: true, checked, clicked: ${ensure} && !checked });
|
|
471
|
+
})()`;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* The declare-body-wrapper agreement checkbox (shipinhao.js:2285-2287); `action: 'ensure'` clicks
|
|
476
|
+
* it when unchecked (shipinhao.js:2291-2294). Missing wrapper checkbox = typed failure where the
|
|
477
|
+
* vendor abandons the declaration (shipinhao.js:2288-2289).
|
|
478
|
+
*/
|
|
479
|
+
function originalAgreementScript(mode: 'wujie' | 'direct', action: 'probe' | 'ensure'): string {
|
|
480
|
+
const ensure = action === 'ensure';
|
|
481
|
+
return `${rootScriptPrefix(mode)}
|
|
482
|
+
if (root === null) return JSON.stringify({ ok: false, reason: 'no-root' });
|
|
483
|
+
const box = root.querySelector('div.declare-body-wrapper input[type="checkbox"].ant-checkbox-input');
|
|
484
|
+
if (!box) return JSON.stringify({ ok: false, reason: 'missing' });
|
|
485
|
+
const checked = isChecked(box);
|
|
486
|
+
if (${ensure} && !checked) box.click();
|
|
487
|
+
return JSON.stringify({ ok: true, checked, clicked: ${ensure} && !checked });
|
|
488
|
+
})()`;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* The 声明原创 confirm button, exact normalized text, exactly one expected
|
|
493
|
+
* (shipinhao.js:2296-2301). Zero = missing, several = ambiguous — both typed failures where the
|
|
494
|
+
* vendor abandons (shipinhao.js:2300-2301).
|
|
495
|
+
*/
|
|
496
|
+
function originalConfirmScript(mode: 'wujie' | 'direct'): string {
|
|
497
|
+
return `${rootScriptPrefix(mode)}
|
|
498
|
+
if (root === null) return JSON.stringify({ ok: false, reason: 'no-root' });
|
|
499
|
+
const buttons = Array.from(root.querySelectorAll('button, [role="button"]'))
|
|
500
|
+
.filter((button) => norm(button.textContent) === ${JSON.stringify(SHP_VIDEO_SELECTORS.originalConfirmLabel)});
|
|
501
|
+
if (buttons.length === 0) return JSON.stringify({ ok: false, reason: 'missing' });
|
|
502
|
+
if (buttons.length > 1) return JSON.stringify({ ok: false, reason: 'ambiguous', count: buttons.length });
|
|
503
|
+
buttons[0].click();
|
|
504
|
+
return JSON.stringify({ ok: true });
|
|
505
|
+
})()`;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Declaration commit probe: after the confirm click the vendor waits for the 弹层 to close
|
|
510
|
+
* (shipinhao.js:2302-2305) — the declare-body-wrapper dialog must be gone; the committed checked
|
|
511
|
+
* entry state is probed separately via `originalEntryScript('probe')`.
|
|
512
|
+
*/
|
|
513
|
+
function originalClosedScript(mode: 'wujie' | 'direct'): string {
|
|
514
|
+
return `${rootScriptPrefix(mode)}
|
|
515
|
+
if (root === null) return JSON.stringify({ closed: false });
|
|
516
|
+
const wrapper = root.querySelector('div.declare-body-wrapper');
|
|
517
|
+
return JSON.stringify({ closed: wrapper === null || wrapper.getClientRects().length === 0 });
|
|
518
|
+
})()`;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Vertical-cover slot evidence (vendor attribute filter `coverStateAttributes`,
|
|
523
|
+
* shipinhao.js:1461-1467, plus img src). Canvas pixels are deliberately NOT read: canvas presence
|
|
524
|
+
* alone is insufficient evidence (shipinhao.js:2111) — the committed state is the slot's
|
|
525
|
+
* data-* and src descriptor changing after the dialog closes.
|
|
526
|
+
*/
|
|
527
|
+
function coverSlotEvidenceScript(mode: 'wujie' | 'direct'): string {
|
|
528
|
+
return `${rootScriptPrefix(mode)}
|
|
529
|
+
if (root === null) return JSON.stringify({ found: false });
|
|
530
|
+
const slot = root.querySelector('div.cover-preview-wrap > div');
|
|
531
|
+
if (slot === null) return JSON.stringify({ found: false });
|
|
532
|
+
const attrs = (slot.getAttributeNames ? slot.getAttributeNames() : [])
|
|
533
|
+
.filter((name) => /^(?:data-(?:src|url))$/i.test(name) || /^data-(?:cover|upload)[-_]/i.test(name))
|
|
534
|
+
.sort().map((name) => name + '=' + (slot.getAttribute(name) || '')).join(';');
|
|
535
|
+
const img = slot.querySelector('img[src]');
|
|
536
|
+
return JSON.stringify({ found: true, descriptor: attrs + '|' + (img === null ? '' : String(img.getAttribute('src') || '')) });
|
|
537
|
+
})()`;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** Clicks the vertical-cover edit entry (shipinhao.js:2145-2147); missing entry = typed failure. */
|
|
541
|
+
function coverEntryClickScript(mode: 'wujie' | 'direct'): string {
|
|
542
|
+
return `${rootScriptPrefix(mode)}
|
|
543
|
+
if (root === null) return JSON.stringify({ ok: false, reason: 'no-root' });
|
|
544
|
+
const entry = root.querySelector('div.cover-preview-wrap > div');
|
|
545
|
+
if (entry === null) return JSON.stringify({ ok: false, reason: 'missing' });
|
|
546
|
+
entry.click();
|
|
547
|
+
return JSON.stringify({ ok: true });
|
|
548
|
+
})()`;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Cover dialog probe (vendor `findCoverControl`, shipinhao.js:2144-2149): the first matching
|
|
553
|
+
* container selector that holds a file input OR reads 封面|直接编辑|确认. Returns the matched
|
|
554
|
+
* selector so the flow can scope the dialog's own file input and confirm buttons to it.
|
|
555
|
+
*/
|
|
556
|
+
function coverDialogProbeScript(mode: 'wujie' | 'direct'): string {
|
|
557
|
+
return `${rootScriptPrefix(mode)}
|
|
558
|
+
if (root === null) return JSON.stringify({ found: false });
|
|
559
|
+
const selectors = ${JSON.stringify(SHP_VIDEO_SELECTORS.coverDialog)};
|
|
560
|
+
for (const selector of selectors) {
|
|
561
|
+
for (const control of root.querySelectorAll(selector)) {
|
|
562
|
+
const hasFileInput = control.querySelector('input[type="file"]') !== null;
|
|
563
|
+
if (hasFileInput || /封面|直接编辑|确认/.test(norm(control.textContent))) {
|
|
564
|
+
return JSON.stringify({ found: true, selector, hasFileInput });
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
return JSON.stringify({ found: false });
|
|
569
|
+
})()`;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Cover confirm click, vendor label ladder (shipinhao.js:1853-1871): the exact 确认 footer button
|
|
574
|
+
* inside `div.finder-dialog-footer` first, then 确认|完成|保存|应用|确定 within the control — the
|
|
575
|
+
* crop-confirm → outer-submit ladder the vendor walks with its previous-button guard
|
|
576
|
+
* (shipinhao.js:1883-1916) is driven by these labels only; a missing control/confirm is a typed
|
|
577
|
+
* failure, never a silent skip.
|
|
578
|
+
*/
|
|
579
|
+
function coverConfirmClickScript(mode: 'wujie' | 'direct', dialogSelector: string): string {
|
|
580
|
+
return `${rootScriptPrefix(mode)}
|
|
581
|
+
if (root === null) return JSON.stringify({ ok: false, reason: 'no-root' });
|
|
582
|
+
const controls = Array.from(root.querySelectorAll(${JSON.stringify(dialogSelector)}))
|
|
583
|
+
.filter((control) => control.querySelector('input[type="file"]') !== null
|
|
584
|
+
|| /封面|直接编辑|确认/.test(norm(control.textContent)));
|
|
585
|
+
if (controls.length === 0) return JSON.stringify({ ok: false, reason: 'missing-control' });
|
|
586
|
+
const control = controls[0];
|
|
587
|
+
const footer = Array.from(control.querySelectorAll(${JSON.stringify(SHP_VIDEO_SELECTORS.coverConfirmFooter)}))
|
|
588
|
+
.find((button) => norm(button.textContent) === '确认');
|
|
589
|
+
const labels = ${JSON.stringify(SHP_VIDEO_SELECTORS.coverConfirmLabels)};
|
|
590
|
+
const button = footer || Array.from(control.querySelectorAll('button, [role="button"]'))
|
|
591
|
+
.find((candidate) => labels.includes(norm(candidate.textContent)));
|
|
592
|
+
if (!button) return JSON.stringify({ ok: false, reason: 'missing-confirm' });
|
|
593
|
+
button.click();
|
|
594
|
+
return JSON.stringify({ ok: true, label: norm(button.textContent) });
|
|
595
|
+
})()`;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Persisted-state readback at receipt time: the title input and description editor still hold the
|
|
600
|
+
* requested content, and when a declaration was requested the 弹层 is still closed, and when a
|
|
601
|
+
* cover was requested the slot still exposes readable evidence. Frozen success rule — a fresh id
|
|
602
|
+
* alone never confirms (the vendor re-reads the final page after its own re-renders,
|
|
603
|
+
* shipinhao.js:2705-2722).
|
|
604
|
+
*/
|
|
605
|
+
function persistedStateScript(
|
|
606
|
+
mode: 'wujie' | 'direct',
|
|
607
|
+
titleSelector: string,
|
|
608
|
+
title: string,
|
|
609
|
+
editorSelector: string,
|
|
610
|
+
html: string,
|
|
611
|
+
): string {
|
|
612
|
+
return `${rootScriptPrefix(mode)}
|
|
613
|
+
if (root === null) return JSON.stringify({ titleOk: false, editorOk: false, originalOk: false, coverUnknown: true });
|
|
614
|
+
const title = root.querySelector(${JSON.stringify(titleSelector)});
|
|
615
|
+
const titleOk = title !== null && norm(title.value) === norm(${JSON.stringify(title)});
|
|
616
|
+
const editor = root.querySelector(${JSON.stringify(editorSelector)});
|
|
617
|
+
const probe = document.createElement('div');
|
|
618
|
+
probe.innerHTML = ${JSON.stringify(html)};
|
|
619
|
+
const editorText = String((editor && (editor.innerText ?? editor.textContent)) ?? '');
|
|
620
|
+
const editorOk = editor !== null && editorText.length > 0 && norm(editorText) === norm(probe.textContent);
|
|
621
|
+
const wrapper = root.querySelector('div.declare-body-wrapper');
|
|
622
|
+
const originalOk = wrapper === null || wrapper.getClientRects().length === 0;
|
|
623
|
+
const slot = root.querySelector('div.cover-preview-wrap > div');
|
|
624
|
+
const coverUnknown = slot === null;
|
|
625
|
+
return JSON.stringify({ titleOk, editorOk, originalOk, coverUnknown });
|
|
626
|
+
})()`;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Shipinhao browser transport on the shared rails (ADR-027): a headless session preflight, one
|
|
631
|
+
* headed flow, and success only from a fresh persisted receipt plus persisted field state.
|
|
632
|
+
* publishStatus absent or 'draft' (the frozen default) saves through the 保存草稿 candidate;
|
|
633
|
+
* 'public' publishes through the scoped 发表 button and requires a NEW works receipt; any unknown
|
|
634
|
+
* value refuses before the browser (ladder in `publish()`).
|
|
635
|
+
*/
|
|
636
|
+
export class ShipinhaoBrowserTransport implements PublishTransport {
|
|
637
|
+
public readonly kind = 'browser';
|
|
638
|
+
public readonly driver: BrowserDriver;
|
|
639
|
+
private readonly operation: 'shortVideo';
|
|
640
|
+
private readonly launcher: BrowserLauncher;
|
|
641
|
+
private readonly stepTimeoutMs: number;
|
|
642
|
+
constructor(
|
|
643
|
+
operation: 'shortVideo' = 'shortVideo',
|
|
644
|
+
driver: BrowserDriver = 'script',
|
|
645
|
+
deps: { launcher?: BrowserLauncher; stepTimeoutMs?: number } = {},
|
|
646
|
+
) {
|
|
647
|
+
this.operation = operation;
|
|
648
|
+
this.driver = driver;
|
|
649
|
+
this.launcher = deps.launcher ?? kkBrowserLauncher;
|
|
650
|
+
this.stepTimeoutMs = deps.stepTimeoutMs ?? STEP_TIMEOUT_MS;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Publishes one Content as a shipinhao short-video draft (default) or public work.
|
|
655
|
+
* @param payload - Target content plus per-call options.
|
|
656
|
+
* @returns The publish `Result`, `ok: true` only from an observed fresh persisted receipt.
|
|
657
|
+
*/
|
|
658
|
+
public async publish(payload: PublishTransportPayload): Promise<Result> {
|
|
659
|
+
// The operation was resolved (and unknown names refused) before this transport existed;
|
|
660
|
+
// the remaining ladder is all pre-browser: driver → publishStatus → format → title → body
|
|
661
|
+
// → video file → cover → tags → original → unsupported fields → session preflight.
|
|
662
|
+
if (this.driver !== 'script') {
|
|
663
|
+
return fail(
|
|
664
|
+
SHP_TARGET,
|
|
665
|
+
`unsupported driver '${bounded(this.driver)}' for ${SHP_TARGET}: expected one of script (the shipinhao-pub browser flow has no agent path)`,
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
const requested = payload.content.metadata?.publishStatus;
|
|
669
|
+
if (requested !== undefined && requested !== 'draft' && requested !== 'public') {
|
|
670
|
+
return fail(
|
|
671
|
+
SHP_TARGET,
|
|
672
|
+
`unknown publishStatus '${bounded(requested)}' for ${SHP_TARGET}: expected 'draft', 'public' or absent (absent defaults to draft)`,
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
if (payload.content.format !== 'video') {
|
|
676
|
+
return fail(
|
|
677
|
+
SHP_TARGET,
|
|
678
|
+
`shipinhao shortVideo requires Content.format 'video', got '${bounded(payload.content.format)}'`,
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
const title = payload.content.title?.trim() ?? '';
|
|
682
|
+
if (title.length === 0) {
|
|
683
|
+
return fail(
|
|
684
|
+
SHP_TARGET,
|
|
685
|
+
'shipinhao shortVideo requires a non-empty Content.title (the 短标题 input, shipinhao.js:638-654)',
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
const body = payload.content.body.trim();
|
|
689
|
+
if (body.length === 0) {
|
|
690
|
+
return fail(
|
|
691
|
+
SHP_TARGET,
|
|
692
|
+
'shipinhao shortVideo requires non-empty Content.body Markdown (the 添加描述 description is pasted as sanitized text/html, shipinhao.js:669-674)',
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
const video = await resolveVideoPath(payload.content.metadata?.videoPath);
|
|
696
|
+
if (video.error !== undefined) return fail(SHP_TARGET, video.error);
|
|
697
|
+
const cover = await resolveCoverPath(payload.content.metadata?.coverPath ?? payload.options?.coverPath);
|
|
698
|
+
if (cover.error !== undefined) return fail(SHP_TARGET, cover.error);
|
|
699
|
+
const tags = resolveTags(payload.content.metadata?.tags ?? payload.options?.tags);
|
|
700
|
+
if (tags.error !== undefined) return fail(SHP_TARGET, tags.error);
|
|
701
|
+
const original = resolveOriginal(payload.content.metadata?.original);
|
|
702
|
+
if (original.error !== undefined) return fail(SHP_TARGET, original.error);
|
|
703
|
+
const unsupported = unsupportedFields(payload);
|
|
704
|
+
if (unsupported.length > 0) {
|
|
705
|
+
return fail(
|
|
706
|
+
SHP_TARGET,
|
|
707
|
+
`unsupported field(s) ${unsupported.join(', ')} for shipinhao: this flow implements title/description/tags/video/cover/original declaration only, and scheduling/publishTime is an unported vendor enhancement (shipinhao.js:7-9)`,
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const override = typeof payload.options?.profileDir === 'string' ? payload.options.profileDir : undefined;
|
|
712
|
+
const profileDir = resolveChannelProfileDir(SHP_TARGET, override);
|
|
713
|
+
|
|
714
|
+
// ADR-025 preflight: an unauthenticated or absent profile must never produce a headed visit,
|
|
715
|
+
// so the read-only headless check runs first and a refusal stays a fast refusal.
|
|
716
|
+
const session = await checkChannelSession(SHP_CONFIG, {
|
|
717
|
+
profileDir,
|
|
718
|
+
launcher: this.launcher,
|
|
719
|
+
headedFallback: false,
|
|
720
|
+
timeoutMs: this.stepTimeoutMs,
|
|
721
|
+
});
|
|
722
|
+
if (session.status !== 'verified') {
|
|
723
|
+
// The recovery command leads the message: `fail()` bounds the line, and the operator's
|
|
724
|
+
// next action must never be the part that gets truncated.
|
|
725
|
+
return fail(
|
|
726
|
+
SHP_TARGET,
|
|
727
|
+
`${SHP_TARGET} session preflight refused — run: ${SHP_LOGIN_INSTRUCTION} — detail: ${session.detail}`,
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
try {
|
|
732
|
+
return await withProfilePage(
|
|
733
|
+
{
|
|
734
|
+
profileDir,
|
|
735
|
+
url: SHP_HOME_URL,
|
|
736
|
+
isAuthenticated: isShipinhaoAuthenticated,
|
|
737
|
+
// Headed by default; the headless preflight above is the unattended pass and
|
|
738
|
+
// `options.headless === true` the opt-in.
|
|
739
|
+
headless: payload.options?.headless === true,
|
|
740
|
+
launcher: this.launcher,
|
|
741
|
+
// The session is already preflight-verified, so the post-goto confirmation is a
|
|
742
|
+
// bounded step like every other one, not the 180s interactive login wait.
|
|
743
|
+
timeoutMs: this.stepTimeoutMs,
|
|
744
|
+
},
|
|
745
|
+
async ({ page }) =>
|
|
746
|
+
this.runShortVideoFlow(
|
|
747
|
+
page,
|
|
748
|
+
title,
|
|
749
|
+
body,
|
|
750
|
+
tags.tags as readonly string[],
|
|
751
|
+
original.original === true,
|
|
752
|
+
video.path as string,
|
|
753
|
+
cover.path,
|
|
754
|
+
requested === 'public',
|
|
755
|
+
profileDir,
|
|
756
|
+
),
|
|
757
|
+
);
|
|
758
|
+
} catch (error) {
|
|
759
|
+
// Same ordering rule as the preflight refusal: the recovery command is never the tail
|
|
760
|
+
// that a bounded message drops.
|
|
761
|
+
const detail = errorMessage(error);
|
|
762
|
+
return fail(
|
|
763
|
+
SHP_TARGET,
|
|
764
|
+
needsLogin(error)
|
|
765
|
+
? `${SHP_TARGET} session failed — run: ${SHP_LOGIN_INSTRUCTION} — detail: ${detail}`
|
|
766
|
+
: detail,
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* The live short-video flow on the already-navigated publishing page (`SHP_HOME_URL`): the
|
|
773
|
+
* bounded wujie root policy (never a wrong root) → video attach with editing-form verification
|
|
774
|
+
* → title fill with readback → sanitized description/topics paste with readback →
|
|
775
|
+
* requested-only 声明原创 → optional committed cover → the draft save or the explicit-public
|
|
776
|
+
* 发表 → fresh-receipt confirmation.
|
|
777
|
+
*/
|
|
778
|
+
private async runShortVideoFlow(
|
|
779
|
+
page: Page,
|
|
780
|
+
title: string,
|
|
781
|
+
body: string,
|
|
782
|
+
tags: readonly string[],
|
|
783
|
+
declareOriginal: boolean,
|
|
784
|
+
videoPath: string,
|
|
785
|
+
coverPath: string | undefined,
|
|
786
|
+
publishPublic: boolean,
|
|
787
|
+
profileDir: string,
|
|
788
|
+
): Promise<Result> {
|
|
789
|
+
const publishStatus = publishPublic ? ('public' as const) : ('draft' as const);
|
|
790
|
+
const metadata = {
|
|
791
|
+
operation: this.operation,
|
|
792
|
+
driver: this.driver,
|
|
793
|
+
publishStatus,
|
|
794
|
+
profileDir,
|
|
795
|
+
...(declareOriginal ? { original: true } : {}),
|
|
796
|
+
};
|
|
797
|
+
|
|
798
|
+
// Root-resolve BEFORE any upload: the bounded wujie policy — wujie shadowRoot only, or the
|
|
799
|
+
// unique direct creator form; ambiguity/absence refuses here (see `resolveCreatorRoot`).
|
|
800
|
+
const root = await resolveCreatorRoot(page);
|
|
801
|
+
if (root.error !== undefined) return stageFail(SHP_TARGET, root.error, 'root-resolve', metadata);
|
|
802
|
+
const mode = root.mode as 'wujie' | 'direct';
|
|
803
|
+
|
|
804
|
+
// Video attach: setInputFiles of the local file on the active root's file input — the
|
|
805
|
+
// vendor's in-page DataTransfer/blob fetch is never used (shipinhao.js:609-631). The bare
|
|
806
|
+
// untyped candidate is accepted only when exactly one input matches (vendor
|
|
807
|
+
// shipinhao.js:580-583), enforced before the assignment.
|
|
808
|
+
try {
|
|
809
|
+
const resolved = await resolveRootLocator(
|
|
810
|
+
page,
|
|
811
|
+
mode,
|
|
812
|
+
SHP_VIDEO_SELECTORS.videoInput,
|
|
813
|
+
'shipinhao video file input',
|
|
814
|
+
this.stepTimeoutMs,
|
|
815
|
+
);
|
|
816
|
+
if (resolved.selector === 'input[type="file"]' && (await resolved.locator.count()) !== 1) {
|
|
817
|
+
return stageFail(
|
|
818
|
+
SHP_TARGET,
|
|
819
|
+
`shipinhao video file input ambiguous: the untyped input[type=file] candidate matched more than one input in the ${mode === 'wujie' ? 'wujie shadowRoot' : 'document'} root — the vendor resolves a unique untyped input or nothing (shipinhao.js:580-583)`,
|
|
820
|
+
'root-resolve',
|
|
821
|
+
metadata,
|
|
822
|
+
);
|
|
823
|
+
}
|
|
824
|
+
await resolved.locator.setInputFiles(videoPath, { timeout: this.stepTimeoutMs });
|
|
825
|
+
} catch (error) {
|
|
826
|
+
return stageFail(
|
|
827
|
+
SHP_TARGET,
|
|
828
|
+
`shipinhao video file input/attach failed on ${page.url()}: ${errorMessage(error)}`,
|
|
829
|
+
'upload-timeout',
|
|
830
|
+
metadata,
|
|
831
|
+
);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// Media processing: the post-upload editing form (title input + description editor) must
|
|
835
|
+
// appear at the 600s media ceiling — the vendor marks media applied only when the editing
|
|
836
|
+
// form appears (shipinhao.js:2676-2683) and never treats the file assignment as success
|
|
837
|
+
// (shipinhao.js:623-625). FORM_WAIT_MS 90000 / PROCESSING_WAIT_MS 120000
|
|
838
|
+
// (shipinhao.js:39,43) are subsumed by this single verified ceiling.
|
|
839
|
+
let titleSelector: string;
|
|
840
|
+
let editorSelector: string;
|
|
841
|
+
try {
|
|
842
|
+
titleSelector = (
|
|
843
|
+
await resolveRootLocator(
|
|
844
|
+
page,
|
|
845
|
+
mode,
|
|
846
|
+
SHP_VIDEO_SELECTORS.titleInput,
|
|
847
|
+
'shipinhao title input',
|
|
848
|
+
this.stepTimeoutMs,
|
|
849
|
+
)
|
|
850
|
+
).selector;
|
|
851
|
+
editorSelector = (
|
|
852
|
+
await resolveRootLocator(
|
|
853
|
+
page,
|
|
854
|
+
mode,
|
|
855
|
+
SHP_VIDEO_SELECTORS.descriptionEditor,
|
|
856
|
+
'shipinhao description editor',
|
|
857
|
+
this.stepTimeoutMs,
|
|
858
|
+
)
|
|
859
|
+
).selector;
|
|
860
|
+
} catch (error) {
|
|
861
|
+
return stageFail(
|
|
862
|
+
SHP_TARGET,
|
|
863
|
+
`shipinhao media processing not confirmed: the file was assigned but the post-upload editing form never appeared — ${errorMessage(error)}; refusing to claim an uploaded video`,
|
|
864
|
+
'upload-processing',
|
|
865
|
+
metadata,
|
|
866
|
+
);
|
|
867
|
+
}
|
|
868
|
+
const formReady = await expectOutcome(
|
|
869
|
+
async () => {
|
|
870
|
+
const raw = String(await page.evaluate(editingFormReadyScript(mode, titleSelector, editorSelector)));
|
|
871
|
+
return JSON.parse(raw).ready === true ? 'editing form observed in the active root' : null;
|
|
872
|
+
},
|
|
873
|
+
{ timeoutMs: SHP_UPLOAD_TIMEOUT_MS, label: 'shipinhao media processing' },
|
|
874
|
+
);
|
|
875
|
+
if (!formReady.ok) {
|
|
876
|
+
return stageFail(
|
|
877
|
+
SHP_TARGET,
|
|
878
|
+
`shipinhao media processing not confirmed within the ${SHP_UPLOAD_TIMEOUT_MS}ms ceiling — the file was assigned but no editing form appeared; refusing to claim an uploaded video`,
|
|
879
|
+
'upload-processing',
|
|
880
|
+
metadata,
|
|
881
|
+
);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// Title: native fill (Playwright fill dispatches input/change) on the root-scoped input,
|
|
885
|
+
// then a normalized value readback (the vendor reads the written title back,
|
|
886
|
+
// shipinhao.js:1332-1341).
|
|
887
|
+
try {
|
|
888
|
+
const titleInput = (
|
|
889
|
+
await resolveRootLocator(
|
|
890
|
+
page,
|
|
891
|
+
mode,
|
|
892
|
+
SHP_VIDEO_SELECTORS.titleInput,
|
|
893
|
+
'shipinhao title input',
|
|
894
|
+
this.stepTimeoutMs,
|
|
895
|
+
)
|
|
896
|
+
).locator;
|
|
897
|
+
await titleInput.fill(title);
|
|
898
|
+
const readback = await expectOutcome(
|
|
899
|
+
async () =>
|
|
900
|
+
normalizeText(String(await page.evaluate(titleValueScript(mode, titleSelector)))) ===
|
|
901
|
+
normalizeText(title)
|
|
902
|
+
? 'title read back'
|
|
903
|
+
: null,
|
|
904
|
+
{ timeoutMs: this.stepTimeoutMs, label: 'shipinhao title readback' },
|
|
905
|
+
);
|
|
906
|
+
if (!readback.ok)
|
|
907
|
+
return stageFail(
|
|
908
|
+
SHP_TARGET,
|
|
909
|
+
`shipinhao title readback failed — the editor did not retain the requested title — ${readback.error}`,
|
|
910
|
+
'title-readback',
|
|
911
|
+
metadata,
|
|
912
|
+
);
|
|
913
|
+
} catch (error) {
|
|
914
|
+
return stageFail(
|
|
915
|
+
SHP_TARGET,
|
|
916
|
+
`shipinhao title fill failed — ${errorMessage(error)}`,
|
|
917
|
+
'title-readback',
|
|
918
|
+
metadata,
|
|
919
|
+
);
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// Description + topics: Markdown → sanitized HTML (topics appended as `#tag` lines, the
|
|
923
|
+
// vendor strips `#`/trims the same way before writing, shipinhao.js:2689-2692) pasted as
|
|
924
|
+
// text/html through the root-scoped locator, then a strict text readback. Topic-token
|
|
925
|
+
// conversion (the vendor's per-topic paste + Enter, shipinhao.js:1414) is not asserted —
|
|
926
|
+
// the readback is text-level, and the receipt stage re-verifies persistence.
|
|
927
|
+
const html = sanitizeShipinhaoHtml(
|
|
928
|
+
markdownToHtml(
|
|
929
|
+
tags.length === 0 ? body : `${body}\n\n${tags.map((tag) => `#${tag.replace(/^#/, '')}`).join(' ')}`,
|
|
930
|
+
),
|
|
931
|
+
);
|
|
932
|
+
try {
|
|
933
|
+
const editorSelector = (
|
|
934
|
+
await resolveRootLocator(
|
|
935
|
+
page,
|
|
936
|
+
mode,
|
|
937
|
+
SHP_VIDEO_SELECTORS.descriptionEditor,
|
|
938
|
+
'shipinhao description editor',
|
|
939
|
+
this.stepTimeoutMs,
|
|
940
|
+
)
|
|
941
|
+
).selector;
|
|
942
|
+
await pasteRichText(page, mode, editorSelector, html);
|
|
943
|
+
const readback = await expectOutcome(
|
|
944
|
+
async () => {
|
|
945
|
+
const editorText = String(await page.evaluate(editorTextScript(mode, editorSelector)));
|
|
946
|
+
return editorText.length > 0 &&
|
|
947
|
+
normalizeText(editorText).includes(normalizeText(body)) &&
|
|
948
|
+
tags.every((tag) => editorText.includes(`#${tag.replace(/^#/, '')}`))
|
|
949
|
+
? 'description and topics read back'
|
|
950
|
+
: null;
|
|
951
|
+
},
|
|
952
|
+
{ timeoutMs: this.stepTimeoutMs, label: 'shipinhao description paste readback' },
|
|
953
|
+
);
|
|
954
|
+
if (!readback.ok)
|
|
955
|
+
return stageFail(
|
|
956
|
+
SHP_TARGET,
|
|
957
|
+
`shipinhao description readback failed — the editor did not retain the pasted description/topics — ${readback.error}`,
|
|
958
|
+
'description-paste-readback',
|
|
959
|
+
metadata,
|
|
960
|
+
);
|
|
961
|
+
} catch (error) {
|
|
962
|
+
return stageFail(
|
|
963
|
+
SHP_TARGET,
|
|
964
|
+
`shipinhao description paste failed — ${errorMessage(error)}`,
|
|
965
|
+
'description-paste-readback',
|
|
966
|
+
metadata,
|
|
967
|
+
);
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// 声明原创: ONLY on the explicit operator assertion (metadata.original === true); the
|
|
971
|
+
// vendor's unconditional run and log-only degradation (shipinhao.js:10-14, 2270-2310) are
|
|
972
|
+
// deliberately not mirrored. Ordering matches the vendor: after description, before cover
|
|
973
|
+
// (shipinhao.js:2702-2703).
|
|
974
|
+
if (declareOriginal) {
|
|
975
|
+
const declaration = await this.declareOriginalFlow(page, mode, metadata);
|
|
976
|
+
if (declaration !== undefined) return declaration;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// Cover: only when requested, through the vendor-grounded dialog rails (see
|
|
980
|
+
// `attachCoverFlow`). A missing control is a typed failure, never a silent skip.
|
|
981
|
+
if (coverPath !== undefined) {
|
|
982
|
+
const coverOutcome = await this.attachCoverFlow(page, mode, coverPath, metadata);
|
|
983
|
+
if (coverOutcome !== undefined) return coverOutcome;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// Submission: draft (default) through the exact-visible 保存草稿 candidate (provisional —
|
|
987
|
+
// the vendor never saves a draft), or explicit public through the scoped exact-发表 button
|
|
988
|
+
// (shipinhao.js:2312-2316). Missing current control = typed failure, never a manufactured
|
|
989
|
+
// draft and never a publish fallback for a requested draft.
|
|
990
|
+
const stage = publishPublic ? 'publish-click' : 'draft-save';
|
|
991
|
+
const submitSelectors = publishPublic ? SHP_VIDEO_SELECTORS.publishButton : SHP_VIDEO_SELECTORS.saveButton;
|
|
992
|
+
let submitLocator: Locator;
|
|
993
|
+
try {
|
|
994
|
+
const resolved = await resolveRootLocator(
|
|
995
|
+
page,
|
|
996
|
+
mode,
|
|
997
|
+
submitSelectors,
|
|
998
|
+
'shipinhao submit control',
|
|
999
|
+
this.stepTimeoutMs,
|
|
1000
|
+
);
|
|
1001
|
+
submitLocator = resolved.locator;
|
|
1002
|
+
} catch (error) {
|
|
1003
|
+
return stageFail(
|
|
1004
|
+
SHP_TARGET,
|
|
1005
|
+
publishPublic
|
|
1006
|
+
? `shipinhao publish control never attached: ${errorMessage(error)} (the vendor resolves 发表 by exact text, shipinhao.js:2312-2316)`
|
|
1007
|
+
: `shipinhao draft-save control never attached: ${errorMessage(error)} — the vendor never saves a draft, so a missing current control is a typed failure, not a manufactured draft and never a publish click`,
|
|
1008
|
+
stage,
|
|
1009
|
+
metadata,
|
|
1010
|
+
);
|
|
1011
|
+
}
|
|
1012
|
+
if (!(await submitLocator.isVisible().catch(() => false))) {
|
|
1013
|
+
return stageFail(
|
|
1014
|
+
SHP_TARGET,
|
|
1015
|
+
'shipinhao submit control attached but not visible; refusing to submit through a hidden control',
|
|
1016
|
+
stage,
|
|
1017
|
+
metadata,
|
|
1018
|
+
);
|
|
1019
|
+
}
|
|
1020
|
+
const enabled = await expectOutcome(async () => ((await submitLocator.isEnabled()) ? 'enabled' : null), {
|
|
1021
|
+
timeoutMs: this.stepTimeoutMs,
|
|
1022
|
+
label: 'shipinhao submit control enabled',
|
|
1023
|
+
});
|
|
1024
|
+
if (!enabled.ok) {
|
|
1025
|
+
return stageFail(
|
|
1026
|
+
SHP_TARGET,
|
|
1027
|
+
`shipinhao submit control never became clickable — ${enabled.error}`,
|
|
1028
|
+
stage,
|
|
1029
|
+
metadata,
|
|
1030
|
+
);
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// Receipt: baseline before the click (fresh-signal rule), then only a FRESH id-bearing URL
|
|
1034
|
+
// plus persisted title/description(/original/cover) state confirms. The vendor never reads
|
|
1035
|
+
// a receipt — a clicked 发表 without a works record is unknown (shipinhao.js:6-9, 2326) —
|
|
1036
|
+
// so stale, ambiguous or receipt-less states are typed failures, never invented success.
|
|
1037
|
+
const receiptStage = publishPublic ? 'publish-receipt' : 'draft-receipt';
|
|
1038
|
+
const beforeReceipts = new Set(await readReceiptUrls(page));
|
|
1039
|
+
await submitLocator.click();
|
|
1040
|
+
const confirmed = await expectOutcome(
|
|
1041
|
+
async () => {
|
|
1042
|
+
const fresh = (await readReceiptUrls(page)).filter((candidate) => !beforeReceipts.has(candidate));
|
|
1043
|
+
if (fresh.length === 0) return null;
|
|
1044
|
+
const ids = new Set(fresh.map(receiptIdFrom).filter((id): id is string => id !== undefined));
|
|
1045
|
+
if (ids.size === 0) return `ambiguous-receipt: ${fresh[0] ?? ''}`; // no id-bearing works URL
|
|
1046
|
+
if (ids.size > 1) return `ambiguous-receipt: ${ids.size} disagreeing fresh works ids`;
|
|
1047
|
+
const url = fresh.find((candidate) => receiptIdFrom(candidate) !== undefined);
|
|
1048
|
+
if (url === undefined) return `ambiguous-receipt: ${fresh[0] ?? ''}`;
|
|
1049
|
+
const raw = String(
|
|
1050
|
+
await page.evaluate(persistedStateScript(mode, titleSelector, title, editorSelector, html)),
|
|
1051
|
+
);
|
|
1052
|
+
const state = JSON.parse(raw) as {
|
|
1053
|
+
titleOk: boolean;
|
|
1054
|
+
editorOk: boolean;
|
|
1055
|
+
originalOk: boolean;
|
|
1056
|
+
coverUnknown: boolean;
|
|
1057
|
+
};
|
|
1058
|
+
if (!state.titleOk || !state.editorOk) return `stale-state: ${url ?? ''}`; // fields no longer matching is not success
|
|
1059
|
+
if (declareOriginal && !state.originalOk)
|
|
1060
|
+
return `stale-state: original dialog reopened or state lost (${url ?? ''})`;
|
|
1061
|
+
if (coverPath !== undefined && state.coverUnknown)
|
|
1062
|
+
return `stale-state: cover slot evidence unreadable (${url ?? ''})`;
|
|
1063
|
+
return url;
|
|
1064
|
+
},
|
|
1065
|
+
{ timeoutMs: this.stepTimeoutMs, label: `shipinhao ${receiptStage} confirmation` },
|
|
1066
|
+
);
|
|
1067
|
+
if (!confirmed.ok) {
|
|
1068
|
+
return stageFail(
|
|
1069
|
+
SHP_TARGET,
|
|
1070
|
+
publishPublic
|
|
1071
|
+
? 'shipinhao publish-receipt not confirmed — no fresh id-bearing works/record receipt with persisted state appeared; a clicked 发表 without a works record is unknown (shipinhao.js:2326) and is never reported as published'
|
|
1072
|
+
: 'shipinhao draft-receipt not confirmed — no fresh id-bearing receipt with persisted title/description state appeared; fill alone is never a saved draft',
|
|
1073
|
+
receiptStage,
|
|
1074
|
+
metadata,
|
|
1075
|
+
`still on ${page.url()}; ${beforeReceipts.size} pre-existing receipt-shaped url(s)`,
|
|
1076
|
+
);
|
|
1077
|
+
}
|
|
1078
|
+
if (confirmed.evidence.startsWith('stale-state: ') || confirmed.evidence.startsWith('ambiguous-receipt: ')) {
|
|
1079
|
+
return stageFail(
|
|
1080
|
+
SHP_TARGET,
|
|
1081
|
+
confirmed.evidence.startsWith('stale-state: ')
|
|
1082
|
+
? 'shipinhao receipt appeared but the persisted title/description(/original/cover) state no longer matches the requested content; refusing to claim success'
|
|
1083
|
+
: 'shipinhao receipt is ambiguous (no single id-bearing works URL among the fresh receipts); refusing to claim success',
|
|
1084
|
+
receiptStage,
|
|
1085
|
+
metadata,
|
|
1086
|
+
confirmed.evidence,
|
|
1087
|
+
);
|
|
1088
|
+
}
|
|
1089
|
+
const url = confirmed.evidence;
|
|
1090
|
+
return ResultSchema.parse({
|
|
1091
|
+
ok: true,
|
|
1092
|
+
target: SHP_TARGET,
|
|
1093
|
+
id: receiptIdFrom(url),
|
|
1094
|
+
url,
|
|
1095
|
+
message: `shipinhao shortVideo ${publishStatus} confirmed in profile ${profileDir}`,
|
|
1096
|
+
metadata: { ...metadata, confirmedBy: receiptStage },
|
|
1097
|
+
});
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
/**
|
|
1101
|
+
* The requested-only 声明原创 sequence; returns a typed-failure `Result`, or `undefined` when
|
|
1102
|
+
* the declaration committed with verified state: entry checkbox clicked and read back checked
|
|
1103
|
+
* (shipinhao.js:2276-2283), agreement checkbox inside the 弹层 (shipinhao.js:2285-2294), exact
|
|
1104
|
+
* 声明原创 confirm (shipinhao.js:2296-2301), dialog closed afterward (shipinhao.js:2302-2305).
|
|
1105
|
+
* Every missing/ambiguous/unchecked outcome type-fails instead of the vendor's
|
|
1106
|
+
* log-and-continue (shipinhao.js:2274, 2289, 2301, 2307-2309).
|
|
1107
|
+
*/
|
|
1108
|
+
private async declareOriginalFlow(
|
|
1109
|
+
page: Page,
|
|
1110
|
+
mode: 'wujie' | 'direct',
|
|
1111
|
+
metadata: Record<string, unknown>,
|
|
1112
|
+
): Promise<Result | undefined> {
|
|
1113
|
+
const failDeclaration = (message: string, evidence?: string): Result =>
|
|
1114
|
+
stageFail(SHP_TARGET, message, 'original-declare', metadata, evidence);
|
|
1115
|
+
const entry = JSON.parse(String(await page.evaluate(originalEntryScript(mode, 'ensure')))) as {
|
|
1116
|
+
ok: boolean;
|
|
1117
|
+
reason?: string;
|
|
1118
|
+
count?: number;
|
|
1119
|
+
checked?: boolean;
|
|
1120
|
+
};
|
|
1121
|
+
if (!entry.ok) {
|
|
1122
|
+
return failDeclaration(
|
|
1123
|
+
entry.reason === 'ambiguous'
|
|
1124
|
+
? `shipinhao 声明原创 entry checkbox is ambiguous (${entry.count} equally-scored 原创 candidates, shipinhao.js:2233-2261); refusing the requested declaration rather than guessing`
|
|
1125
|
+
: 'shipinhao 声明原创 entry checkbox not found — the requested original declaration cannot be verified on this page; refusing (the vendor skips with a log only because it never treats the claim as operator-asserted, shipinhao.js:2274)',
|
|
1126
|
+
);
|
|
1127
|
+
}
|
|
1128
|
+
const entryChecked = await expectOutcome(
|
|
1129
|
+
async () => {
|
|
1130
|
+
const probe = JSON.parse(String(await page.evaluate(originalEntryScript(mode, 'probe')))) as {
|
|
1131
|
+
ok: boolean;
|
|
1132
|
+
checked?: boolean;
|
|
1133
|
+
};
|
|
1134
|
+
return probe.ok && probe.checked === true ? 'entry checkbox committed checked' : null;
|
|
1135
|
+
},
|
|
1136
|
+
{ timeoutMs: this.stepTimeoutMs, label: 'shipinhao 声明原创 entry checked state' },
|
|
1137
|
+
);
|
|
1138
|
+
if (!entryChecked.ok)
|
|
1139
|
+
return failDeclaration(
|
|
1140
|
+
'shipinhao 声明原创 entry checkbox did not commit to a checked state',
|
|
1141
|
+
entryChecked.error,
|
|
1142
|
+
);
|
|
1143
|
+
const agreement = JSON.parse(String(await page.evaluate(originalAgreementScript(mode, 'ensure')))) as {
|
|
1144
|
+
ok: boolean;
|
|
1145
|
+
reason?: string;
|
|
1146
|
+
};
|
|
1147
|
+
if (!agreement.ok) {
|
|
1148
|
+
return failDeclaration(
|
|
1149
|
+
'shipinhao 声明原创 agreement checkbox (div.declare-body-wrapper) never appeared — refusing the requested declaration (shipinhao.js:2285-2289)',
|
|
1150
|
+
);
|
|
1151
|
+
}
|
|
1152
|
+
const agreementChecked = await expectOutcome(
|
|
1153
|
+
async () => {
|
|
1154
|
+
const probe = JSON.parse(String(await page.evaluate(originalAgreementScript(mode, 'probe')))) as {
|
|
1155
|
+
ok: boolean;
|
|
1156
|
+
checked?: boolean;
|
|
1157
|
+
};
|
|
1158
|
+
return probe.ok && probe.checked === true ? 'agreement checkbox committed checked' : null;
|
|
1159
|
+
},
|
|
1160
|
+
{ timeoutMs: this.stepTimeoutMs, label: 'shipinhao 声明原创 agreement checked state' },
|
|
1161
|
+
);
|
|
1162
|
+
if (!agreementChecked.ok)
|
|
1163
|
+
return failDeclaration(
|
|
1164
|
+
'shipinhao 声明原创 agreement checkbox did not commit to a checked state',
|
|
1165
|
+
agreementChecked.error,
|
|
1166
|
+
);
|
|
1167
|
+
const confirm = JSON.parse(String(await page.evaluate(originalConfirmScript(mode)))) as {
|
|
1168
|
+
ok: boolean;
|
|
1169
|
+
reason?: string;
|
|
1170
|
+
count?: number;
|
|
1171
|
+
};
|
|
1172
|
+
if (!confirm.ok) {
|
|
1173
|
+
return failDeclaration(
|
|
1174
|
+
confirm.reason === 'ambiguous'
|
|
1175
|
+
? `shipinhao 声明原创 confirm ambiguous: ${confirm.count} exact 「声明原创」 buttons on the page (shipinhao.js:2296-2299); refusing rather than clicking the wrong one`
|
|
1176
|
+
: 'shipinhao 声明原创 confirm button not found — refusing the requested declaration (shipinhao.js:2300-2301)',
|
|
1177
|
+
);
|
|
1178
|
+
}
|
|
1179
|
+
const closed = await expectOutcome(
|
|
1180
|
+
async () => {
|
|
1181
|
+
const state = JSON.parse(String(await page.evaluate(originalClosedScript(mode)))) as {
|
|
1182
|
+
closed: boolean;
|
|
1183
|
+
};
|
|
1184
|
+
return state.closed ? 'declare-body-wrapper closed' : null;
|
|
1185
|
+
},
|
|
1186
|
+
{ timeoutMs: this.stepTimeoutMs, label: 'shipinhao 声明原创 dialog closure' },
|
|
1187
|
+
);
|
|
1188
|
+
if (!closed.ok)
|
|
1189
|
+
return failDeclaration(
|
|
1190
|
+
'shipinhao 声明原创 dialog did not close after the confirm click; the committed declaration is unverifiable',
|
|
1191
|
+
closed.error,
|
|
1192
|
+
);
|
|
1193
|
+
return undefined;
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
/**
|
|
1197
|
+
* The optional requested cover through the vendor-grounded dialog rails: slot evidence read
|
|
1198
|
+
* BEFORE (shipinhao.js:1461-1467) → edit entry click (shipinhao.js:2145-2147) → scoped cover
|
|
1199
|
+
* dialog with its own file input (shipinhao.js:2144-2149) → `setInputFiles` → labeled confirm
|
|
1200
|
+
* ladder with the vendor's two-stage re-click guard (shipinhao.js:1853-1871, 1883-1916) →
|
|
1201
|
+
* dialog closure with the slot descriptor CHANGED, verified at the COVER_SAVE_WAIT_MS 60000
|
|
1202
|
+
* ceiling (shipinhao.js:42, 1976). Canvas pixels are never read (canvas presence alone is
|
|
1203
|
+
* insufficient, shipinhao.js:2111). Any missing control or unconfirmed commit is a typed
|
|
1204
|
+
* failure — the vendor's unattributable/timeout `unknown` outcomes (shipinhao.js:2009, 2095,
|
|
1205
|
+
* 2111) become refusals because a requested cover is never silently skipped.
|
|
1206
|
+
*/
|
|
1207
|
+
private async attachCoverFlow(
|
|
1208
|
+
page: Page,
|
|
1209
|
+
mode: 'wujie' | 'direct',
|
|
1210
|
+
coverPath: string,
|
|
1211
|
+
metadata: Record<string, unknown>,
|
|
1212
|
+
): Promise<Result | undefined> {
|
|
1213
|
+
const failCover = (message: string, stage: string, evidence?: string): Result =>
|
|
1214
|
+
stageFail(SHP_TARGET, message, stage, metadata, evidence);
|
|
1215
|
+
const before = JSON.parse(String(await page.evaluate(coverSlotEvidenceScript(mode)))) as {
|
|
1216
|
+
found: boolean;
|
|
1217
|
+
descriptor?: string;
|
|
1218
|
+
};
|
|
1219
|
+
if (!before.found || before.descriptor === undefined) {
|
|
1220
|
+
return failCover(
|
|
1221
|
+
'shipinhao vertical cover slot (个人主页卡片 3:4, div.cover-preview-wrap) not found — refusing the requested cover (shipinhao.js:2128-2144)',
|
|
1222
|
+
'cover-open',
|
|
1223
|
+
);
|
|
1224
|
+
}
|
|
1225
|
+
const clicked = JSON.parse(String(await page.evaluate(coverEntryClickScript(mode)))) as {
|
|
1226
|
+
ok: boolean;
|
|
1227
|
+
reason?: string;
|
|
1228
|
+
};
|
|
1229
|
+
if (!clicked.ok)
|
|
1230
|
+
return failCover('shipinhao cover edit entry click failed — refusing the requested cover', 'cover-open');
|
|
1231
|
+
const dialog = await expectOutcome(
|
|
1232
|
+
async () => {
|
|
1233
|
+
const probe = JSON.parse(String(await page.evaluate(coverDialogProbeScript(mode)))) as {
|
|
1234
|
+
found: boolean;
|
|
1235
|
+
selector?: string;
|
|
1236
|
+
hasFileInput?: boolean;
|
|
1237
|
+
};
|
|
1238
|
+
if (!probe.found || probe.selector === undefined) return null;
|
|
1239
|
+
if (probe.hasFileInput !== true) return `dialog-without-file-input: ${probe.selector}`;
|
|
1240
|
+
return probe.selector;
|
|
1241
|
+
},
|
|
1242
|
+
{ timeoutMs: this.stepTimeoutMs, label: 'shipinhao cover dialog' },
|
|
1243
|
+
);
|
|
1244
|
+
if (!dialog.ok) {
|
|
1245
|
+
return failCover(
|
|
1246
|
+
'shipinhao cover dialog never appeared after the edit-entry click — refusing the requested cover (shipinhao.js:2147-2149)',
|
|
1247
|
+
'cover-open',
|
|
1248
|
+
dialog.error,
|
|
1249
|
+
);
|
|
1250
|
+
}
|
|
1251
|
+
if (dialog.evidence.startsWith('dialog-without-file-input: ')) {
|
|
1252
|
+
return failCover(
|
|
1253
|
+
'shipinhao cover dialog resolved but exposes no input[type=file] — refusing to attach a cover through an unverifiable control',
|
|
1254
|
+
'cover-open',
|
|
1255
|
+
dialog.evidence,
|
|
1256
|
+
);
|
|
1257
|
+
}
|
|
1258
|
+
const dialogSelector = dialog.evidence;
|
|
1259
|
+
try {
|
|
1260
|
+
const fileInput = await resolveRootLocator(
|
|
1261
|
+
page,
|
|
1262
|
+
mode,
|
|
1263
|
+
[`${dialogSelector} input[type="file"]`],
|
|
1264
|
+
'shipinhao cover dialog file input',
|
|
1265
|
+
this.stepTimeoutMs,
|
|
1266
|
+
);
|
|
1267
|
+
await fileInput.locator.setInputFiles(coverPath, { timeout: this.stepTimeoutMs });
|
|
1268
|
+
} catch (error) {
|
|
1269
|
+
return failCover(
|
|
1270
|
+
`shipinhao cover file attach failed — refusing the requested cover: ${errorMessage(error)}`,
|
|
1271
|
+
'cover-attach',
|
|
1272
|
+
);
|
|
1273
|
+
}
|
|
1274
|
+
// Confirm ladder: first click, then — while the dialog persists — the NEXT labeled button
|
|
1275
|
+
// (vendor previous-button guard, shipinhao.js:1893-1901) up to three clicks, then closure
|
|
1276
|
+
// with changed slot evidence at the COVER_SAVE_WAIT_MS ceiling.
|
|
1277
|
+
const committed = await expectOutcome(
|
|
1278
|
+
async () => {
|
|
1279
|
+
const probe = JSON.parse(String(await page.evaluate(coverDialogProbeScript(mode)))) as {
|
|
1280
|
+
found: boolean;
|
|
1281
|
+
};
|
|
1282
|
+
if (!probe.found) {
|
|
1283
|
+
const after = JSON.parse(String(await page.evaluate(coverSlotEvidenceScript(mode)))) as {
|
|
1284
|
+
found: boolean;
|
|
1285
|
+
descriptor?: string;
|
|
1286
|
+
};
|
|
1287
|
+
if (!after.found || after.descriptor === undefined) return 'slot-evidence-unreadable-after-closure';
|
|
1288
|
+
return after.descriptor !== before.descriptor
|
|
1289
|
+
? 'cover committed and verified after dialog closure'
|
|
1290
|
+
: null;
|
|
1291
|
+
}
|
|
1292
|
+
const click = JSON.parse(
|
|
1293
|
+
String(await page.evaluate(coverConfirmClickScript(mode, dialogSelector))),
|
|
1294
|
+
) as { ok: boolean; reason?: string; label?: string };
|
|
1295
|
+
if (!click.ok) {
|
|
1296
|
+
return click.reason === 'missing-confirm'
|
|
1297
|
+
? `missing-confirm: no labeled confirm inside the cover dialog (${dialogSelector})`
|
|
1298
|
+
: null; // crop dialog re-render; keep polling within the ceiling
|
|
1299
|
+
}
|
|
1300
|
+
return null; // clicked; poll for closure or the next labeled button
|
|
1301
|
+
},
|
|
1302
|
+
{ timeoutMs: SHP_COVER_SAVE_WAIT_MS, label: 'shipinhao cover commit' },
|
|
1303
|
+
);
|
|
1304
|
+
if (!committed.ok) {
|
|
1305
|
+
return failCover(
|
|
1306
|
+
'shipinhao cover commit not confirmed — the dialog never closed with changed slot evidence at the ceiling — refusing to claim an attached cover (canvas presence alone is insufficient, shipinhao.js:2111)',
|
|
1307
|
+
'cover-attach',
|
|
1308
|
+
committed.error,
|
|
1309
|
+
);
|
|
1310
|
+
}
|
|
1311
|
+
if (
|
|
1312
|
+
committed.evidence.startsWith('missing-confirm: ') ||
|
|
1313
|
+
committed.evidence.startsWith('slot-evidence-unreadable')
|
|
1314
|
+
) {
|
|
1315
|
+
return failCover(
|
|
1316
|
+
'shipinhao cover commit unverifiable — no labeled confirm control or unreadable post-closure slot evidence — refusing the requested cover (shipinhao.js:2009, 2111)',
|
|
1317
|
+
'cover-attach',
|
|
1318
|
+
committed.evidence,
|
|
1319
|
+
);
|
|
1320
|
+
}
|
|
1321
|
+
return undefined;
|
|
1322
|
+
}
|
|
1323
|
+
}
|