@gobing-ai/knowledge-kit 0.0.36 → 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.
Files changed (159) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/board/collect/index.css +654 -16
  3. package/dist/board/collect/index.js +191 -64
  4. package/dist/board/daily-voice/index.css +679 -12
  5. package/dist/board/daily-voice/index.js +185 -66
  6. package/dist/board/itc/index.css +679 -13
  7. package/dist/board/itc/index.js +188 -66
  8. package/dist/index.js +2878 -152
  9. package/package.json +3 -2
  10. package/plugins/generations/content-gen/dist/index.js +158 -12
  11. package/plugins/generations/core-facts-gen/dist/index.js +158 -12
  12. package/plugins/generations/daily-article-gen/dist/index.js +158 -12
  13. package/plugins/generations/dailynews-gen/dist/index.js +180 -12
  14. package/plugins/generations/dailynews-gen/src/script-plan.ts +32 -0
  15. package/plugins/generations/episode-plan-gen/dist/index.js +158 -12
  16. package/plugins/generations/gemini-tts/README.md +4 -3
  17. package/plugins/generations/gemini-tts/dist/index.js +158 -12
  18. package/plugins/generations/image-gen/dist/index.js +158 -12
  19. package/plugins/generations/news-report-gen/dist/index.js +158 -12
  20. package/plugins/generations/omni-voice-gen/README.md +15 -4
  21. package/plugins/generations/omni-voice-gen/profiles.json +1 -2
  22. package/plugins/generations/voice-gen/dist/index.js +158 -12
  23. package/plugins/generations/voxcpm-tts/README.md +21 -1
  24. package/plugins/generations/voxcpm-tts/dist/index.js +173 -13
  25. package/plugins/generations/voxcpm-tts/scripts/audit-speaker-consistency.py +109 -0
  26. package/plugins/generations/voxcpm-tts/src/voxcpm-client.ts +28 -1
  27. package/plugins/ingestions/aihot-ingest/dist/index.js +158 -12
  28. package/plugins/ingestions/horizon-ingest/dist/index.js +158 -12
  29. package/plugins/ingestions/last30days-ingest/dist/index.js +158 -12
  30. package/plugins/ingestions/web-search/dist/index.js +158 -12
  31. package/plugins/kk/commands/workflow-run.md +56 -18
  32. package/plugins/kk/decisions/kk-itc.yaml +46 -0
  33. package/plugins/kk/decisions/kk-seo-keyword.yaml +57 -0
  34. package/plugins/kk/decisions/kk-seo-page.yaml +230 -0
  35. package/plugins/kk/decisions/kk-seo-pair.yaml +21 -0
  36. package/plugins/kk/decisions/kk-seo-site.yaml +90 -0
  37. package/plugins/kk/plugin.json +1 -1
  38. package/plugins/kk/publish-presets/chinese-article.json +14 -0
  39. package/plugins/kk/publish-presets/technical-article.json +14 -0
  40. package/plugins/kk/references/smart-seo-attribution.md +85 -0
  41. package/plugins/kk/scripts/daily-stages.ts +42 -2
  42. package/plugins/kk/scripts/daily-voice-style.zh.md +1 -1
  43. package/plugins/kk/scripts/itc-stages.ts +25 -118
  44. package/plugins/kk/scripts/kk-workflow-stages.ts +38 -1
  45. package/plugins/kk/scripts/seo-decisions.ts +477 -0
  46. package/plugins/kk/scripts/seo-enrich.ts +728 -0
  47. package/plugins/kk/scripts/seo-facts.ts +610 -0
  48. package/plugins/kk/scripts/seo-fetch.ts +766 -0
  49. package/plugins/kk/scripts/seo-report.ts +460 -0
  50. package/plugins/kk/scripts/seo-rules.ts +1882 -0
  51. package/plugins/kk/scripts/seo-stages.ts +1044 -0
  52. package/plugins/kk/skills/article-adapt/SKILL.md +2 -2
  53. package/plugins/kk/skills/smart-seo/SKILL.md +183 -0
  54. package/plugins/kk/skills/smart-seo/references/method.md +126 -0
  55. package/plugins/kk/skills/smart-seo/references/narrative.md +125 -0
  56. package/plugins/kk/skills/smart-seo/references/operations.md +202 -0
  57. package/plugins/kk/skills/smart-seo/references/seo-geo-sources.md +90 -0
  58. package/plugins/kk/workflows/feature-verification.yaml +28 -24
  59. package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +86 -18
  60. package/plugins/kk/workflows/kk-itc-author.yaml +406 -0
  61. package/plugins/kk/workflows/kk-itc.yaml +208 -682
  62. package/plugins/kk/workflows/kk-smart-seo.yaml +251 -0
  63. package/plugins/publishings/bilibili-pub/dist/index.js +21782 -0
  64. package/plugins/publishings/bilibili-pub/dist/profile-check.js +20410 -0
  65. package/plugins/publishings/bilibili-pub/package.json +20 -0
  66. package/plugins/publishings/bilibili-pub/plugin.json +12 -0
  67. package/plugins/publishings/bilibili-pub/src/flow.ts +1296 -0
  68. package/plugins/publishings/bilibili-pub/src/index.ts +139 -0
  69. package/plugins/publishings/bilibili-pub/src/profile-check.ts +19 -0
  70. package/plugins/publishings/bilibili-pub/src/selectors.ts +222 -0
  71. package/plugins/publishings/bilibili-pub/tsconfig.json +4 -0
  72. package/plugins/publishings/cnblogs-pub/dist/index.js +21383 -0
  73. package/plugins/publishings/cnblogs-pub/dist/profile-check.js +20412 -0
  74. package/plugins/publishings/cnblogs-pub/package.json +19 -0
  75. package/plugins/publishings/cnblogs-pub/plugin.json +12 -0
  76. package/plugins/publishings/cnblogs-pub/src/flow.ts +708 -0
  77. package/plugins/publishings/cnblogs-pub/src/index.ts +172 -0
  78. package/plugins/publishings/cnblogs-pub/src/profile-check.ts +24 -0
  79. package/plugins/publishings/cnblogs-pub/src/selectors.ts +104 -0
  80. package/plugins/publishings/cnblogs-pub/tsconfig.json +4 -0
  81. package/plugins/publishings/csdn-pub/dist/index.js +21552 -0
  82. package/plugins/publishings/csdn-pub/dist/profile-check.js +20963 -0
  83. package/plugins/publishings/csdn-pub/package.json +19 -0
  84. package/plugins/publishings/csdn-pub/plugin.json +12 -0
  85. package/plugins/publishings/csdn-pub/src/index.ts +1178 -0
  86. package/plugins/publishings/csdn-pub/src/profile-check.ts +23 -0
  87. package/plugins/publishings/csdn-pub/tsconfig.json +4 -0
  88. package/plugins/publishings/emdash-pub/dist/index.js +158 -12
  89. package/plugins/publishings/emdash-pub/plugin.json +22 -1
  90. package/plugins/publishings/infoq-pub/dist/index.js +160 -12
  91. package/plugins/publishings/infoq-pub/dist/profile-check.js +160 -12
  92. package/plugins/publishings/infoq-pub/plugin.json +21 -0
  93. package/plugins/publishings/jike-pub/dist/index.js +21379 -0
  94. package/plugins/publishings/jike-pub/dist/profile-check.js +20951 -0
  95. package/plugins/publishings/jike-pub/package.json +19 -0
  96. package/plugins/publishings/jike-pub/plugin.json +12 -0
  97. package/plugins/publishings/jike-pub/src/index.ts +800 -0
  98. package/plugins/publishings/jike-pub/src/profile-check.ts +21 -0
  99. package/plugins/publishings/jike-pub/tsconfig.json +4 -0
  100. package/plugins/publishings/juejin-pub/dist/index.js +160 -12
  101. package/plugins/publishings/juejin-pub/dist/profile-check.js +160 -12
  102. package/plugins/publishings/juejin-pub/plugin.json +21 -0
  103. package/plugins/publishings/medium-pub/dist/index.js +158 -12
  104. package/plugins/publishings/medium-pub/plugin.json +22 -1
  105. package/plugins/publishings/podcast-pub/dist/index.js +158 -12
  106. package/plugins/publishings/qiita-pub/dist/index.js +158 -12
  107. package/plugins/publishings/qiita-pub/plugin.json +24 -1
  108. package/plugins/publishings/shipinhao-pub/dist/index.js +21715 -0
  109. package/plugins/publishings/shipinhao-pub/dist/profile-check.js +20431 -0
  110. package/plugins/publishings/shipinhao-pub/package.json +19 -0
  111. package/plugins/publishings/shipinhao-pub/plugin.json +12 -0
  112. package/plugins/publishings/shipinhao-pub/src/flow.ts +1323 -0
  113. package/plugins/publishings/shipinhao-pub/src/index.ts +173 -0
  114. package/plugins/publishings/shipinhao-pub/src/profile-check.ts +25 -0
  115. package/plugins/publishings/shipinhao-pub/src/selectors.ts +241 -0
  116. package/plugins/publishings/shipinhao-pub/tsconfig.json +4 -0
  117. package/plugins/publishings/sspai-pub/dist/index.js +21371 -0
  118. package/plugins/publishings/sspai-pub/dist/profile-check.js +20414 -0
  119. package/plugins/publishings/sspai-pub/package.json +19 -0
  120. package/plugins/publishings/sspai-pub/plugin.json +12 -0
  121. package/plugins/publishings/sspai-pub/src/flow.ts +624 -0
  122. package/plugins/publishings/sspai-pub/src/index.ts +167 -0
  123. package/plugins/publishings/sspai-pub/src/profile-check.ts +25 -0
  124. package/plugins/publishings/sspai-pub/src/selectors.ts +122 -0
  125. package/plugins/publishings/sspai-pub/tsconfig.json +4 -0
  126. package/plugins/publishings/substack-pub/dist/index.js +160 -12
  127. package/plugins/publishings/substack-pub/dist/profile-check.js +160 -12
  128. package/plugins/publishings/substack-pub/plugin.json +21 -0
  129. package/plugins/publishings/surfdash-pub/dist/index.js +158 -12
  130. package/plugins/publishings/surfdash-pub/plugin.json +22 -1
  131. package/plugins/publishings/threads-pub/dist/index.js +158 -12
  132. package/plugins/publishings/wechatmp-pub/dist/index.js +1669 -502
  133. package/plugins/publishings/wechatmp-pub/dist/profile-check.js +204 -21
  134. package/plugins/publishings/wechatmp-pub/plugin.json +32 -0
  135. package/plugins/publishings/wechatmp-pub/src/cgi-draft.ts +540 -0
  136. package/plugins/publishings/wechatmp-pub/src/cgi-media.ts +380 -0
  137. package/plugins/publishings/wechatmp-pub/src/cgi.ts +485 -0
  138. package/plugins/publishings/wechatmp-pub/src/flow.ts +104 -0
  139. package/plugins/publishings/weibo-pub/dist/index.js +160 -12
  140. package/plugins/publishings/weibo-pub/dist/profile-check.js +160 -12
  141. package/plugins/publishings/weibo-pub/plugin.json +35 -0
  142. package/plugins/publishings/x-pub/dist/index.js +160 -12
  143. package/plugins/publishings/x-pub/dist/profile-check.js +160 -12
  144. package/plugins/publishings/x-pub/plugin.json +40 -0
  145. package/plugins/publishings/xhs-pub/dist/index.js +160 -12
  146. package/plugins/publishings/xhs-pub/dist/profile-check.js +160 -12
  147. package/plugins/publishings/xhs-pub/plugin.json +37 -0
  148. package/plugins/publishings/xiaoyuzhou-pub/dist/index.js +21491 -0
  149. package/plugins/publishings/xiaoyuzhou-pub/dist/profile-check.js +20417 -0
  150. package/plugins/publishings/xiaoyuzhou-pub/package.json +19 -0
  151. package/plugins/publishings/xiaoyuzhou-pub/plugin.json +12 -0
  152. package/plugins/publishings/xiaoyuzhou-pub/src/flow.ts +826 -0
  153. package/plugins/publishings/xiaoyuzhou-pub/src/index.ts +174 -0
  154. package/plugins/publishings/xiaoyuzhou-pub/src/profile-check.ts +26 -0
  155. package/plugins/publishings/xiaoyuzhou-pub/src/selectors.ts +125 -0
  156. package/plugins/publishings/xiaoyuzhou-pub/tsconfig.json +4 -0
  157. package/plugins/publishings/zenn-pub/dist/index.js +158 -12
  158. package/plugins/publishings/zenn-pub/plugin.json +22 -1
  159. 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
+ }