@gobing-ai/knowledge-kit 0.0.37 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) 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 +2864 -139
  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 +158 -12
  14. package/plugins/generations/episode-plan-gen/dist/index.js +158 -12
  15. package/plugins/generations/gemini-tts/README.md +4 -3
  16. package/plugins/generations/gemini-tts/dist/index.js +158 -12
  17. package/plugins/generations/image-gen/dist/index.js +158 -12
  18. package/plugins/generations/news-report-gen/dist/index.js +158 -12
  19. package/plugins/generations/omni-voice-gen/README.md +15 -4
  20. package/plugins/generations/omni-voice-gen/profiles.json +1 -2
  21. package/plugins/generations/voice-gen/dist/index.js +158 -12
  22. package/plugins/generations/voxcpm-tts/README.md +21 -1
  23. package/plugins/generations/voxcpm-tts/dist/index.js +173 -13
  24. package/plugins/generations/voxcpm-tts/scripts/audit-speaker-consistency.py +109 -0
  25. package/plugins/generations/voxcpm-tts/src/voxcpm-client.ts +28 -1
  26. package/plugins/ingestions/aihot-ingest/dist/index.js +158 -12
  27. package/plugins/ingestions/horizon-ingest/dist/index.js +158 -12
  28. package/plugins/ingestions/last30days-ingest/dist/index.js +158 -12
  29. package/plugins/ingestions/web-search/dist/index.js +158 -12
  30. package/plugins/kk/commands/workflow-run.md +56 -18
  31. package/plugins/kk/decisions/kk-itc.yaml +46 -0
  32. package/plugins/kk/decisions/kk-seo-keyword.yaml +57 -0
  33. package/plugins/kk/decisions/kk-seo-page.yaml +230 -0
  34. package/plugins/kk/decisions/kk-seo-pair.yaml +21 -0
  35. package/plugins/kk/decisions/kk-seo-site.yaml +90 -0
  36. package/plugins/kk/plugin.json +1 -1
  37. package/plugins/kk/publish-presets/chinese-article.json +14 -0
  38. package/plugins/kk/publish-presets/technical-article.json +14 -0
  39. package/plugins/kk/references/smart-seo-attribution.md +85 -0
  40. package/plugins/kk/scripts/daily-stages.ts +42 -2
  41. package/plugins/kk/scripts/itc-stages.ts +25 -118
  42. package/plugins/kk/scripts/kk-workflow-stages.ts +38 -1
  43. package/plugins/kk/scripts/seo-decisions.ts +491 -0
  44. package/plugins/kk/scripts/seo-enrich.ts +728 -0
  45. package/plugins/kk/scripts/seo-facts.ts +610 -0
  46. package/plugins/kk/scripts/seo-fetch.ts +766 -0
  47. package/plugins/kk/scripts/seo-report.ts +460 -0
  48. package/plugins/kk/scripts/seo-rules.ts +1882 -0
  49. package/plugins/kk/scripts/seo-stages.ts +1044 -0
  50. package/plugins/kk/skills/article-adapt/SKILL.md +2 -2
  51. package/plugins/kk/skills/smart-seo/SKILL.md +183 -0
  52. package/plugins/kk/skills/smart-seo/references/method.md +126 -0
  53. package/plugins/kk/skills/smart-seo/references/narrative.md +125 -0
  54. package/plugins/kk/skills/smart-seo/references/operations.md +209 -0
  55. package/plugins/kk/skills/smart-seo/references/seo-geo-sources.md +90 -0
  56. package/plugins/kk/workflows/feature-verification.yaml +28 -24
  57. package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +85 -17
  58. package/plugins/kk/workflows/kk-itc-author.yaml +406 -0
  59. package/plugins/kk/workflows/kk-itc.yaml +208 -682
  60. package/plugins/kk/workflows/kk-smart-seo.yaml +251 -0
  61. package/plugins/publishings/bilibili-pub/dist/index.js +21782 -0
  62. package/plugins/publishings/bilibili-pub/dist/profile-check.js +20410 -0
  63. package/plugins/publishings/bilibili-pub/package.json +20 -0
  64. package/plugins/publishings/bilibili-pub/plugin.json +12 -0
  65. package/plugins/publishings/bilibili-pub/src/flow.ts +1296 -0
  66. package/plugins/publishings/bilibili-pub/src/index.ts +139 -0
  67. package/plugins/publishings/bilibili-pub/src/profile-check.ts +19 -0
  68. package/plugins/publishings/bilibili-pub/src/selectors.ts +222 -0
  69. package/plugins/publishings/bilibili-pub/tsconfig.json +4 -0
  70. package/plugins/publishings/cnblogs-pub/dist/index.js +21383 -0
  71. package/plugins/publishings/cnblogs-pub/dist/profile-check.js +20412 -0
  72. package/plugins/publishings/cnblogs-pub/package.json +19 -0
  73. package/plugins/publishings/cnblogs-pub/plugin.json +12 -0
  74. package/plugins/publishings/cnblogs-pub/src/flow.ts +708 -0
  75. package/plugins/publishings/cnblogs-pub/src/index.ts +172 -0
  76. package/plugins/publishings/cnblogs-pub/src/profile-check.ts +24 -0
  77. package/plugins/publishings/cnblogs-pub/src/selectors.ts +104 -0
  78. package/plugins/publishings/cnblogs-pub/tsconfig.json +4 -0
  79. package/plugins/publishings/csdn-pub/dist/index.js +21552 -0
  80. package/plugins/publishings/csdn-pub/dist/profile-check.js +20963 -0
  81. package/plugins/publishings/csdn-pub/package.json +19 -0
  82. package/plugins/publishings/csdn-pub/plugin.json +12 -0
  83. package/plugins/publishings/csdn-pub/src/index.ts +1178 -0
  84. package/plugins/publishings/csdn-pub/src/profile-check.ts +23 -0
  85. package/plugins/publishings/csdn-pub/tsconfig.json +4 -0
  86. package/plugins/publishings/emdash-pub/dist/index.js +158 -12
  87. package/plugins/publishings/emdash-pub/plugin.json +22 -1
  88. package/plugins/publishings/infoq-pub/dist/index.js +160 -12
  89. package/plugins/publishings/infoq-pub/dist/profile-check.js +160 -12
  90. package/plugins/publishings/infoq-pub/plugin.json +21 -0
  91. package/plugins/publishings/jike-pub/dist/index.js +21379 -0
  92. package/plugins/publishings/jike-pub/dist/profile-check.js +20951 -0
  93. package/plugins/publishings/jike-pub/package.json +19 -0
  94. package/plugins/publishings/jike-pub/plugin.json +12 -0
  95. package/plugins/publishings/jike-pub/src/index.ts +800 -0
  96. package/plugins/publishings/jike-pub/src/profile-check.ts +21 -0
  97. package/plugins/publishings/jike-pub/tsconfig.json +4 -0
  98. package/plugins/publishings/juejin-pub/dist/index.js +160 -12
  99. package/plugins/publishings/juejin-pub/dist/profile-check.js +160 -12
  100. package/plugins/publishings/juejin-pub/plugin.json +21 -0
  101. package/plugins/publishings/medium-pub/dist/index.js +158 -12
  102. package/plugins/publishings/medium-pub/plugin.json +22 -1
  103. package/plugins/publishings/podcast-pub/dist/index.js +158 -12
  104. package/plugins/publishings/qiita-pub/dist/index.js +158 -12
  105. package/plugins/publishings/qiita-pub/plugin.json +24 -1
  106. package/plugins/publishings/shipinhao-pub/dist/index.js +21715 -0
  107. package/plugins/publishings/shipinhao-pub/dist/profile-check.js +20431 -0
  108. package/plugins/publishings/shipinhao-pub/package.json +19 -0
  109. package/plugins/publishings/shipinhao-pub/plugin.json +12 -0
  110. package/plugins/publishings/shipinhao-pub/src/flow.ts +1323 -0
  111. package/plugins/publishings/shipinhao-pub/src/index.ts +173 -0
  112. package/plugins/publishings/shipinhao-pub/src/profile-check.ts +25 -0
  113. package/plugins/publishings/shipinhao-pub/src/selectors.ts +241 -0
  114. package/plugins/publishings/shipinhao-pub/tsconfig.json +4 -0
  115. package/plugins/publishings/sspai-pub/dist/index.js +21371 -0
  116. package/plugins/publishings/sspai-pub/dist/profile-check.js +20414 -0
  117. package/plugins/publishings/sspai-pub/package.json +19 -0
  118. package/plugins/publishings/sspai-pub/plugin.json +12 -0
  119. package/plugins/publishings/sspai-pub/src/flow.ts +624 -0
  120. package/plugins/publishings/sspai-pub/src/index.ts +167 -0
  121. package/plugins/publishings/sspai-pub/src/profile-check.ts +25 -0
  122. package/plugins/publishings/sspai-pub/src/selectors.ts +122 -0
  123. package/plugins/publishings/sspai-pub/tsconfig.json +4 -0
  124. package/plugins/publishings/substack-pub/dist/index.js +160 -12
  125. package/plugins/publishings/substack-pub/dist/profile-check.js +160 -12
  126. package/plugins/publishings/substack-pub/plugin.json +21 -0
  127. package/plugins/publishings/surfdash-pub/dist/index.js +158 -12
  128. package/plugins/publishings/surfdash-pub/plugin.json +22 -1
  129. package/plugins/publishings/threads-pub/dist/index.js +158 -12
  130. package/plugins/publishings/wechatmp-pub/dist/index.js +1669 -502
  131. package/plugins/publishings/wechatmp-pub/dist/profile-check.js +204 -21
  132. package/plugins/publishings/wechatmp-pub/plugin.json +32 -0
  133. package/plugins/publishings/wechatmp-pub/src/cgi-draft.ts +540 -0
  134. package/plugins/publishings/wechatmp-pub/src/cgi-media.ts +380 -0
  135. package/plugins/publishings/wechatmp-pub/src/cgi.ts +485 -0
  136. package/plugins/publishings/wechatmp-pub/src/flow.ts +104 -0
  137. package/plugins/publishings/weibo-pub/dist/index.js +160 -12
  138. package/plugins/publishings/weibo-pub/dist/profile-check.js +160 -12
  139. package/plugins/publishings/weibo-pub/plugin.json +35 -0
  140. package/plugins/publishings/x-pub/dist/index.js +160 -12
  141. package/plugins/publishings/x-pub/dist/profile-check.js +160 -12
  142. package/plugins/publishings/x-pub/plugin.json +40 -0
  143. package/plugins/publishings/xhs-pub/dist/index.js +160 -12
  144. package/plugins/publishings/xhs-pub/dist/profile-check.js +160 -12
  145. package/plugins/publishings/xhs-pub/plugin.json +37 -0
  146. package/plugins/publishings/xiaoyuzhou-pub/dist/index.js +21491 -0
  147. package/plugins/publishings/xiaoyuzhou-pub/dist/profile-check.js +20417 -0
  148. package/plugins/publishings/xiaoyuzhou-pub/package.json +19 -0
  149. package/plugins/publishings/xiaoyuzhou-pub/plugin.json +12 -0
  150. package/plugins/publishings/xiaoyuzhou-pub/src/flow.ts +826 -0
  151. package/plugins/publishings/xiaoyuzhou-pub/src/index.ts +174 -0
  152. package/plugins/publishings/xiaoyuzhou-pub/src/profile-check.ts +26 -0
  153. package/plugins/publishings/xiaoyuzhou-pub/src/selectors.ts +125 -0
  154. package/plugins/publishings/xiaoyuzhou-pub/tsconfig.json +4 -0
  155. package/plugins/publishings/zenn-pub/dist/index.js +158 -12
  156. package/plugins/publishings/zenn-pub/plugin.json +22 -1
  157. package/plugins/generations/omni-voice-gen/voices/robin-news.wav +0 -0
@@ -0,0 +1,826 @@
1
+ /**
2
+ * xiaoyuzhou-pub transport + flow (task 0295).
3
+ *
4
+ * The live podcast flow in `runPodcastFlow`, stage-typed on every `Result.metadata.stage`:
5
+ * browser-precondition → show-verify → audio-upload → title-readback → shownotes-paste-readback →
6
+ * cover-attach (when requested) → draft-save → draft-receipt. This slice saves drafts ONLY:
7
+ * publishStatus absent or 'draft' (the frozen default) activates the exact-visible
8
+ * 保存草稿|保存 candidate; 'public' and any unknown value refuse before the browser (the source
9
+ * itself never publishes — 不点发布按钮, xiaoyuzhou.js:6, 18-19).
10
+ *
11
+ * Mechanism (xiaoyuzhou.js:3-6): the local audio is attached via CDP `setInputFiles` on the
12
+ * grounded `input#upload` — never the source's in-page DataTransfer/blob fetch (xiaoyuzhou.js:38,
13
+ * 46-50), which would move file bytes through the page; processing readiness is then verified (a
14
+ * file assignment alone is never success, xiaoyuzhou.js:51-52). The title is filled natively with
15
+ * input/change events — the source's bare `.value` write is marked uncertain (逆向存疑) by the source
16
+ * itself (xiaoyuzhou.js:57-58). Shownotes Markdown is rendered to HTML, sanitized here first
17
+ * (script/style/iframe/embed/object, `on*` attributes, `javascript:`/`data:` URLs stripped —
18
+ * closing task 0294's P3 sanitizer gap; code/list/heading markup preserved) and pasted into the
19
+ * Draft.js editor as `text/html` (xiaoyuzhou.js:72-74). Success is confirmed only by a FRESH
20
+ * persisted receipt (an episode-id URL that was not on screen before the click) plus re-read
21
+ * persisted title/audio/shownotes(/cover) state — never a stale assignment or a toast.
22
+ */
23
+
24
+ import { basename } from 'node:path';
25
+ import { type Result, ResultSchema } from '@gobing-ai/kk-core';
26
+ import {
27
+ type BrowserDriver,
28
+ type PublishTransport,
29
+ type PublishTransportPayload,
30
+ trySelectors,
31
+ } from '@gobing-ai/publish-harness';
32
+ import {
33
+ checkChannelSession,
34
+ expectOutcome,
35
+ kkBrowserLauncher,
36
+ markdownToHtml,
37
+ pasteHtml,
38
+ resolveChannelProfileDir,
39
+ resolveSelector,
40
+ uploadFile,
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 { Page } from 'playwright';
49
+ import {
50
+ isXiaoyuzhouAuthenticated,
51
+ XYZF_AUDIO_SELECTORS,
52
+ XYZF_CONFIG,
53
+ XYZF_HOME_URL,
54
+ XYZF_RECEIPT_URL_RES,
55
+ XYZF_TARGET,
56
+ XYZF_UNSUPPORTED_URL_RE,
57
+ XYZF_UPLOAD_TIMEOUT_MS,
58
+ } from './selectors';
59
+
60
+ const STEP_TIMEOUT_MS = 30_000;
61
+ /**
62
+ * KK v1 local-audio size bound (task 0295 Design freeze): 500 MiB. A project-scoped product
63
+ * bound, not a platform limit — the studio's own ceiling is unpinned.
64
+ */
65
+ const MAX_AUDIO_BYTES = 500 * 1024 * 1024;
66
+ /** Operator recovery command every session failure names — never a cookie, DOM dump or stack. */
67
+ export const XYZF_LOGIN_INSTRUCTION = 'bun plugins/publishings/xiaoyuzhou-pub/src/profile-check.ts login';
68
+
69
+ /** `ok: false` refusal Result with a bounded, single-line error. */
70
+ export function fail(target: string, error: string): Result {
71
+ return ResultSchema.parse({ ok: false, target, error: boundedDetail(error) });
72
+ }
73
+
74
+ /** A browser-stage failure: `ok: false` with `stage` (and optional evidence) in the Result metadata. */
75
+ export function stageFail(
76
+ target: string,
77
+ error: string,
78
+ stage: string,
79
+ metadata: Record<string, unknown>,
80
+ evidence?: string,
81
+ ): Result {
82
+ return ResultSchema.parse({
83
+ ok: false,
84
+ target,
85
+ error: boundedDetail(error),
86
+ metadata: { ...metadata, stage, ...(evidence === undefined ? {} : { evidence: boundedDetail(evidence) }) },
87
+ });
88
+ }
89
+
90
+ /** One short line, bounded: no DOM dump, cookie, token, stack or leaked audio path ever reaches the Result. */
91
+ export function boundedDetail(detail: string): string {
92
+ return detail.replace(/\s+/g, ' ').trim().slice(0, 300);
93
+ }
94
+
95
+ /** A bounded, single-line rendering of an operator-supplied value for a refusal message. */
96
+ export function bounded(value: unknown): string {
97
+ if (Array.isArray(value)) return `${value.length} entr${value.length === 1 ? 'y' : 'ies'}`;
98
+ return String(value).replace(/\s+/g, ' ').slice(0, 100);
99
+ }
100
+
101
+ export function errorMessage(error: unknown): string {
102
+ return error instanceof Error ? error.message : String(error);
103
+ }
104
+
105
+ /** Session failures whose one-liner must name the recovery command. */
106
+ function needsLogin(error: unknown): boolean {
107
+ return error instanceof LoginTimeoutError || error instanceof BrowserProfileMissingError;
108
+ }
109
+
110
+ /** Whitespace-insensitive comparison: site normalizations must not fail a strict readback. */
111
+ function normalizeText(value: string): string {
112
+ return value.replace(/\s+/g, '');
113
+ }
114
+
115
+ /** Input `value` property readback (title input). */
116
+ function inputValueScript(selector: string): string {
117
+ return `(() => { // kk-xyzf-input-value
118
+ const el = document.querySelector(${JSON.stringify(selector)});
119
+ return el === null ? '' : String(el.value ?? '');
120
+ })()`;
121
+ }
122
+
123
+ /**
124
+ * Plugin-local HTML sanitizer (task 0295 Design freeze — no new dependency): strips
125
+ * script/style/iframe/embed/object containers, `on*` event attributes and `javascript:`/`data:`
126
+ * URLs while preserving code/list/heading markup, before any paste reaches the editor. The
127
+ * `data:` arm closes the sanitizer gap task 0294 review logged (P3) for sspai.
128
+ */
129
+ export function sanitizeXiaoyuzhouHtml(html: string): string {
130
+ return html
131
+ .replace(/<\s*(script|style|iframe|embed|object)\b[\s\S]*?<\s*\/\s*\1\s*>/gi, '')
132
+ .replace(/<\s*(script|style|iframe|embed|object)\b[^>]*>/gi, '')
133
+ .replace(/\son[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '')
134
+ .replace(/(\s(?:href|src)\s*=\s*)(["'])\s*(?:javascript|data):[^"']*\2/gi, '$1$2$2')
135
+ .replace(/(\s(?:href|src)\s*=\s*)(?:javascript|data):[^\s>]*/gi, '$1""');
136
+ }
137
+
138
+ /**
139
+ * Shownotes paste readback: the Draft.js editor still holds non-empty markup whose text equals the
140
+ * sanitized HTML's text (whitespace-insensitive) — a dropped or transformed paste refuses before
141
+ * any save.
142
+ */
143
+ function editorReadbackScript(selector: string, html: string): string {
144
+ return `(() => { // kk-xyzf-editor-readback
145
+ const editor = document.querySelector(${JSON.stringify(selector)});
146
+ if (editor === null) return JSON.stringify({ ok: false });
147
+ const probe = document.createElement('div');
148
+ probe.innerHTML = ${JSON.stringify(html)};
149
+ const squish = (v) => String(v ?? '').replace(/\\s+/g, '');
150
+ return JSON.stringify({ ok: editor.innerHTML.length > 0 && squish(editor.innerText) === squish(probe.textContent) });
151
+ })()`;
152
+ }
153
+
154
+ /**
155
+ * Audio processing-ready probe: a media record appears in the form — an `audio` preview player or
156
+ * the uploaded file name rendered in the page. The bare file assignment (xiaoyuzhou.js:51) is
157
+ * explicitly NOT success (task 0295 Design freeze); this is the provisional readiness evidence,
158
+ * and its absence at the ceiling is a typed upload failure, never an assumed upload.
159
+ */
160
+ function audioReadyScript(fileName: string): string {
161
+ return `(() => { // kk-xyzf-audio-ready
162
+ const audio = document.querySelector('audio');
163
+ if (audio !== null) return JSON.stringify({ ready: true, by: 'audio-player' });
164
+ const text = String(document.body?.innerText ?? '');
165
+ return JSON.stringify({ ready: text.includes(${JSON.stringify(fileName)}) });
166
+ })()`;
167
+ }
168
+
169
+ /**
170
+ * Persisted-state readback for save confirmation: the title input and Draft.js editor still hold
171
+ * the requested content, the audio record is still on screen, and, when a cover was requested, the
172
+ * scoped file input still carries exactly that file assignment (frozen success rule — a fresh id
173
+ * alone never confirms).
174
+ */
175
+ function persistedStateScript(
176
+ titleSelector: string,
177
+ title: string,
178
+ editorSelector: string,
179
+ html: string,
180
+ audio: { fileName: string },
181
+ cover: { selector: string; fileName: string } | undefined,
182
+ ): string {
183
+ return `(() => { // kk-xyzf-persisted
184
+ const title = document.querySelector(${JSON.stringify(titleSelector)});
185
+ const editor = document.querySelector(${JSON.stringify(editorSelector)});
186
+ const probe = document.createElement('div');
187
+ probe.innerHTML = ${JSON.stringify(html)};
188
+ const squish = (v) => String(v ?? '').replace(/\\s+/g, '');
189
+ const titleOk = title !== null && squish(title.value) === squish(${JSON.stringify(title)});
190
+ const editorOk = editor !== null && editor.innerHTML.length > 0 && squish(editor.innerText) === squish(probe.textContent);
191
+ const bodyText = String(document.body?.innerText ?? '');
192
+ const audioOk = document.querySelector('audio') !== null || bodyText.includes(${JSON.stringify(audio.fileName)});
193
+ const coverOk = ${
194
+ cover === undefined
195
+ ? 'true'
196
+ : `(() => {
197
+ const input = document.querySelector(${JSON.stringify(cover.selector)});
198
+ return input !== null && input.files !== null && input.files.length === 1 && input.files[0].name === ${JSON.stringify(cover.fileName)};
199
+ })()`
200
+ };
201
+ return JSON.stringify({ titleOk, editorOk, audioOk, coverOk });
202
+ })()`;
203
+ }
204
+
205
+ /** Receipt-shaped URLs on screen (location + anchors) — the save-time baseline and fresh read. */
206
+ async function readReceiptUrls(page: Page): Promise<readonly string[]> {
207
+ const raw = await page.evaluate(`(() => { // kk-xyzf-receipt-urls
208
+ const patterns = [${XYZF_RECEIPT_URL_RES.map((re) => re.toString()).join(', ')}];
209
+ const matches = (url) => patterns.some((re) => re.test(url));
210
+ const urls = [];
211
+ if (matches(location.href)) urls.push(location.href);
212
+ for (const a of document.querySelectorAll('a[href]')) {
213
+ if (matches(a.href) && !urls.includes(a.href)) urls.push(a.href);
214
+ }
215
+ return JSON.stringify(urls);
216
+ })()`);
217
+ try {
218
+ const parsed: unknown = JSON.parse(String(raw));
219
+ return Array.isArray(parsed) ? parsed.map((entry) => String(entry)) : [];
220
+ } catch {
221
+ return [];
222
+ }
223
+ }
224
+
225
+ /** Episode id from an observed receipt URL (shape: `/episode/<id>`, provisional). */
226
+ function episodeIdFrom(url: string): string | undefined {
227
+ return /\/episode\/([0-9a-zA-Z]+)/.exec(url)?.[1];
228
+ }
229
+
230
+ /**
231
+ * Pre-browser required-audio gate: `metadata.audioPath` (falling back to `metadata.mp3Path`) must
232
+ * name an existing, regular, readable, nonempty local MP3/M4A/WAV under the KK v1 500 MiB cap.
233
+ * Probed through the Bun file API (repo rule no-direct-fs-io) — never node:fs.
234
+ */
235
+ async function resolveAudioPath(
236
+ raw: unknown,
237
+ ): Promise<{ path?: string; error?: undefined } | { path?: undefined; error: string }> {
238
+ if (typeof raw !== 'string' || raw.trim().length === 0) {
239
+ return {
240
+ error: `metadata.audioPath (or metadata.mp3Path) must be a non-empty local audio file path, got '${bounded(raw)}'`,
241
+ };
242
+ }
243
+ const path = raw.trim();
244
+ if (!/\.(mp3|m4a|wav)$/i.test(path)) {
245
+ return {
246
+ error: `metadata.audioPath (or metadata.mp3Path) must be a local MP3/M4A/WAV file, got unsupported extension in '${bounded(path)}'`,
247
+ };
248
+ }
249
+ // no-direct-fs-io: probe the file through the Bun API instead of node:fs.
250
+ const file = Bun.file(path);
251
+ const readable = await file.exists().catch(() => false);
252
+ if (!readable || file.size === 0) {
253
+ return {
254
+ error: 'metadata.audioPath (or metadata.mp3Path) does not resolve to a readable nonempty local audio file',
255
+ };
256
+ }
257
+ if (file.size > MAX_AUDIO_BYTES) {
258
+ return {
259
+ error: `metadata.audioPath (or metadata.mp3Path) exceeds the KK v1 local audio cap of 500 MiB (${file.size} bytes)`,
260
+ };
261
+ }
262
+ return { path };
263
+ }
264
+
265
+ /**
266
+ * Pre-browser required-show gate: `metadata.podcastId` is REQUIRED — a non-empty string naming
267
+ * the target show. The flow verifies the studio's currently selected show against it
268
+ * (show-verify stage) and never assumes the first/default show.
269
+ */
270
+ function resolvePodcastId(raw: unknown): { id?: string; error?: undefined } | { id?: undefined; error: string } {
271
+ if (typeof raw !== 'string' || raw.trim().length === 0) {
272
+ return {
273
+ error: `metadata.podcastId is required for xiaoyuzhou podcast publish: a non-empty show id naming the target show — the flow verifies the studio's selected show against it and never uploads to a first/default show`,
274
+ };
275
+ }
276
+ return { id: raw.trim() };
277
+ }
278
+
279
+ /** Pre-browser provenance gate: `metadata.duration`, when present, is a positive finite number. */
280
+ function resolveDuration(raw: unknown): { error?: string } {
281
+ if (raw === undefined) return {};
282
+ if (typeof raw !== 'number' || !Number.isFinite(raw) || raw <= 0) {
283
+ return {
284
+ error: `metadata.duration must be a positive finite number (seconds, provenance only), got '${bounded(raw)}'`,
285
+ };
286
+ }
287
+ return {};
288
+ }
289
+
290
+ /**
291
+ * Pre-browser requested-cover gate: an optional local PNG/JPEG/WebP that must exist as a regular,
292
+ * readable, nonempty file. Requested-but-absent or wrong-typed refuses here — a requested cover is
293
+ * never silently skipped (the vendor does not handle covers at all, xiaoyuzhou.js:17).
294
+ */
295
+ async function resolveCoverPath(
296
+ raw: unknown,
297
+ ): Promise<{ path?: string; error?: undefined } | { path?: undefined; error: string }> {
298
+ if (raw === undefined) return {};
299
+ if (typeof raw !== 'string' || raw.trim().length === 0) {
300
+ return { error: `metadata.coverPath must be a non-empty local file path when present, got '${bounded(raw)}'` };
301
+ }
302
+ const path = raw.trim();
303
+ if (!/\.(png|jpe?g|webp)$/i.test(path)) {
304
+ return {
305
+ error: `metadata.coverPath must be a local PNG/JPEG/WebP file, got unsupported extension in '${bounded(path)}'`,
306
+ };
307
+ }
308
+ // no-direct-fs-io: probe the file through the Bun API instead of node:fs.
309
+ const file = Bun.file(path);
310
+ const readable = await file.exists().catch(() => false);
311
+ if (!readable || file.size === 0) {
312
+ return {
313
+ error: 'metadata.coverPath does not resolve to a readable nonempty local file (refusing, never a silent skip)',
314
+ };
315
+ }
316
+ return { path };
317
+ }
318
+
319
+ /**
320
+ * Xiaoyuzhou browser transport on the shared rails (ADR-027): a headless session preflight, one
321
+ * headed flow, and success only from a fresh persisted receipt plus persisted field state.
322
+ * Draft-only: publishStatus absent or 'draft' saves a draft; 'public' and any unknown value refuse
323
+ * before the browser (ladder in `src/index.ts` → `publish()`).
324
+ */
325
+ export class XiaoyuzhouBrowserTransport implements PublishTransport {
326
+ public readonly kind = 'browser';
327
+ public readonly driver: BrowserDriver;
328
+ private readonly operation: 'podcast';
329
+ private readonly launcher: BrowserLauncher;
330
+ private readonly stepTimeoutMs: number;
331
+ constructor(
332
+ operation: 'podcast' = 'podcast',
333
+ driver: BrowserDriver = 'script',
334
+ deps: { launcher?: BrowserLauncher; stepTimeoutMs?: number } = {},
335
+ ) {
336
+ this.operation = operation;
337
+ this.driver = driver;
338
+ this.launcher = deps.launcher ?? kkBrowserLauncher;
339
+ this.stepTimeoutMs = deps.stepTimeoutMs ?? STEP_TIMEOUT_MS;
340
+ }
341
+
342
+ /**
343
+ * Publishes one Content as a xiaoyuzhou podcast episode draft.
344
+ * @param payload - Target content plus per-call options.
345
+ * @returns The publish `Result`, `ok: true` only from an observed persisted draft receipt.
346
+ */
347
+ public async publish(payload: PublishTransportPayload): Promise<Result> {
348
+ // The operation was resolved (and unknown names refused) before this transport existed;
349
+ // the remaining ladder is all pre-browser: driver → publishStatus (draft-only) → format →
350
+ // title → body → audio file → podcastId → duration → cover → unsupported fields → session
351
+ // preflight.
352
+ if (this.driver !== 'script') {
353
+ return fail(
354
+ XYZF_TARGET,
355
+ `unsupported driver '${bounded(this.driver)}' for ${XYZF_TARGET}: expected one of script (the xiaoyuzhou-pub browser flow has no agent path)`,
356
+ );
357
+ }
358
+ const requested = payload.content.metadata?.publishStatus;
359
+ if (requested === 'public') {
360
+ return fail(
361
+ XYZF_TARGET,
362
+ `xiaoyuzhou-pub saves drafts only: metadata.publishStatus 'public' is refused before any browser opens — final publication on the studio is not evidenced (the source itself stops before publish, xiaoyuzhou.js:6, 18-19); omit publishStatus or pass 'draft'`,
363
+ );
364
+ }
365
+ if (requested !== undefined && requested !== 'draft') {
366
+ return fail(
367
+ XYZF_TARGET,
368
+ `unknown publishStatus '${bounded(requested)}' for ${XYZF_TARGET}: expected 'draft' or absent (absent defaults to draft); 'public' is refused — this slice saves drafts only`,
369
+ );
370
+ }
371
+ if (payload.content.format !== 'audio') {
372
+ return fail(
373
+ XYZF_TARGET,
374
+ `xiaoyuzhou podcast requires Content.format 'audio', got '${bounded(payload.content.format)}'`,
375
+ );
376
+ }
377
+ const title = payload.content.title?.trim() ?? '';
378
+ if (title.length === 0) {
379
+ return fail(
380
+ XYZF_TARGET,
381
+ 'xiaoyuzhou podcast requires a non-empty Content.title (episode title input, xiaoyuzhou.js:55)',
382
+ );
383
+ }
384
+ const body = payload.content.body.trim();
385
+ if (body.length === 0) {
386
+ return fail(
387
+ XYZF_TARGET,
388
+ 'xiaoyuzhou podcast requires non-empty Content.body Markdown (the shownotes are pasted as sanitized text/html, xiaoyuzhou.js:72)',
389
+ );
390
+ }
391
+ const audio = await resolveAudioPath(payload.content.metadata?.audioPath ?? payload.content.metadata?.mp3Path);
392
+ if (audio.error !== undefined) return fail(XYZF_TARGET, audio.error);
393
+ const show = resolvePodcastId(payload.content.metadata?.podcastId);
394
+ if (show.error !== undefined) return fail(XYZF_TARGET, show.error);
395
+ const duration = resolveDuration(payload.content.metadata?.duration);
396
+ if (duration.error !== undefined) return fail(XYZF_TARGET, duration.error);
397
+ const cover = await resolveCoverPath(payload.content.metadata?.coverPath ?? payload.options?.coverPath);
398
+ if (cover.error !== undefined) return fail(XYZF_TARGET, cover.error);
399
+ const unsupported = unsupportedFields(payload);
400
+ if (unsupported.length > 0) {
401
+ return fail(
402
+ XYZF_TARGET,
403
+ `unsupported field(s) ${unsupported.join(', ')} for xiaoyuzhou: this draft-only flow implements title/shownotes/audio/podcastId/cover only, and xiaoyuzhou.js implements none of images/tags/categories`,
404
+ );
405
+ }
406
+
407
+ const override = typeof payload.options?.profileDir === 'string' ? payload.options.profileDir : undefined;
408
+ const profileDir = resolveChannelProfileDir(XYZF_TARGET, override);
409
+
410
+ // ADR-025 preflight: an unauthenticated or absent profile must never produce a headed visit,
411
+ // so the read-only headless check runs first and a refusal stays a fast refusal.
412
+ const session = await checkChannelSession(XYZF_CONFIG, {
413
+ profileDir,
414
+ launcher: this.launcher,
415
+ headedFallback: false,
416
+ timeoutMs: this.stepTimeoutMs,
417
+ });
418
+ if (session.status !== 'verified') {
419
+ // The recovery command leads the message: `fail()` bounds the line, and the operator's
420
+ // next action must never be the part that gets truncated.
421
+ return fail(
422
+ XYZF_TARGET,
423
+ `${XYZF_TARGET} session preflight refused — run: ${XYZF_LOGIN_INSTRUCTION} — detail: ${session.detail}`,
424
+ );
425
+ }
426
+
427
+ try {
428
+ return await withProfilePage(
429
+ {
430
+ profileDir,
431
+ url: XYZF_HOME_URL,
432
+ isAuthenticated: isXiaoyuzhouAuthenticated,
433
+ // Headed by default; the headless preflight above is the unattended pass and
434
+ // `options.headless === true` the opt-in.
435
+ headless: payload.options?.headless === true,
436
+ launcher: this.launcher,
437
+ // The session is already preflight-verified, so the post-goto confirmation is a
438
+ // bounded step like every other one, not the 180s interactive login wait.
439
+ timeoutMs: this.stepTimeoutMs,
440
+ },
441
+ async ({ page }) =>
442
+ this.runPodcastFlow(
443
+ page,
444
+ title,
445
+ body,
446
+ audio.path as string,
447
+ show.id as string,
448
+ cover.path,
449
+ profileDir,
450
+ ),
451
+ );
452
+ } catch (error) {
453
+ // Same ordering rule as the preflight refusal: the recovery command is never the tail
454
+ // that a bounded message drops.
455
+ const detail = errorMessage(error);
456
+ return fail(
457
+ XYZF_TARGET,
458
+ needsLogin(error)
459
+ ? `${XYZF_TARGET} session failed — run: ${XYZF_LOGIN_INSTRUCTION} — detail: ${detail}`
460
+ : detail,
461
+ );
462
+ }
463
+ }
464
+
465
+ /**
466
+ * The live podcast flow on the already-navigated studio page (`XYZF_HOME_URL`): the
467
+ * unsupported-browser environment precondition → show-verify against `metadata.podcastId` →
468
+ * audio attach with processing-ready verification (never a bare file assignment,
469
+ * xiaoyuzhou.js:51-52) → title fill with readback (input/change events, never the source's
470
+ * bare `.value`, xiaoyuzhou.js:57-58) → sanitized text/html shownotes paste with readback
471
+ * (xiaoyuzhou.js:72-74) → optional verified cover attach → the provisional exact-visible
472
+ * draft save → fresh-receipt confirmation.
473
+ */
474
+ private async runPodcastFlow(
475
+ page: Page,
476
+ title: string,
477
+ body: string,
478
+ audioPath: string,
479
+ podcastId: string,
480
+ coverPath: string | undefined,
481
+ profileDir: string,
482
+ ): Promise<Result> {
483
+ const metadata = {
484
+ operation: this.operation,
485
+ driver: this.driver,
486
+ publishStatus: 'draft' as const,
487
+ podcastId,
488
+ profileDir,
489
+ };
490
+
491
+ // Environment precondition BEFORE any upload: the studio officially requires desktop
492
+ // Chrome 147+ and serves an unsupported-browser page otherwise. Chromium 155.0.8059.40 is
493
+ // observed-only; the operator recovery is KK_BROWSER_CHANNEL=chrome via the existing
494
+ // kkBrowserLauncher (no Playwright pin change).
495
+ if (XYZF_UNSUPPORTED_URL_RE.test(page.url())) {
496
+ return stageFail(
497
+ XYZF_TARGET,
498
+ `xiaoyuzhou studio served an unsupported-browser page (${page.url()}): the podcaster studio officially requires desktop Chrome 147+ — rerun with KK_BROWSER_CHANNEL=chrome (the kkBrowserLauncher reads it; the Playwright pin is unchanged; the observed bundled Chromium 155.0.8059.40 is observed-only)`,
499
+ 'browser-precondition',
500
+ metadata,
501
+ );
502
+ }
503
+
504
+ // Show-verify BEFORE upload: the studio's currently selected show must carry the requested
505
+ // podcastId (URL or page text). Never the first/default show — an unconfirmable identity
506
+ // is a typed refusal, and the operator opens the target show in the studio themselves.
507
+ const showOk = await expectOutcome(
508
+ async () => {
509
+ if (page.url().includes(podcastId)) return `show id in url: ${page.url()}`;
510
+ const text = String(await page.evaluate('String(document.body?.innerText ?? "")'));
511
+ return text.includes(podcastId) ? 'show id on page' : null;
512
+ },
513
+ { timeoutMs: this.stepTimeoutMs, label: 'xiaoyuzhou show-verify' },
514
+ );
515
+ if (!showOk.ok) {
516
+ return stageFail(
517
+ XYZF_TARGET,
518
+ `xiaoyuzhou show-verify failed: the requested podcastId '${bounded(podcastId)}' was not observable on ${page.url()} — open the target show in the studio first; the flow never uploads to a first/default show`,
519
+ 'show-verify',
520
+ metadata,
521
+ );
522
+ }
523
+
524
+ // Audio attach: CDP setInputFiles of the local file on the grounded input#upload — never
525
+ // the source's in-page DataTransfer/blob fetch (xiaoyuzhou.js:38, 46-50). Then processing
526
+ // readiness at the 600s media ceiling: a media record (audio player or the file name) must
527
+ // appear; the bare assignment is never success (xiaoyuzhou.js:51-52). The source's fixed
528
+ // 1s settle sleeps (xiaoyuzhou.js:29, 52) are subsumed by this polled verification.
529
+ const fileName = basename(audioPath);
530
+ let audioSelector: string;
531
+ try {
532
+ audioSelector = await resolveSelector(page, XYZF_AUDIO_SELECTORS.audioInput, {
533
+ timeoutMs: this.stepTimeoutMs,
534
+ label: 'xiaoyuzhou audio file input',
535
+ });
536
+ } catch (error) {
537
+ return stageFail(
538
+ XYZF_TARGET,
539
+ `xiaoyuzhou audio file input never attached on ${page.url()}: ${errorMessage(error)} (the source reads a missing input as maybe-unlogged, xiaoyuzhou.js:27-28) — run: ${XYZF_LOGIN_INSTRUCTION}`,
540
+ 'upload-timeout',
541
+ metadata,
542
+ );
543
+ }
544
+ try {
545
+ await uploadFile(page, audioSelector, audioPath, { timeoutMs: this.stepTimeoutMs });
546
+ } catch (error) {
547
+ return stageFail(
548
+ XYZF_TARGET,
549
+ `xiaoyuzhou audio attach failed: ${errorMessage(error)}`,
550
+ 'upload-timeout',
551
+ metadata,
552
+ );
553
+ }
554
+ const processed = await expectOutcome(
555
+ async () => {
556
+ const raw = String(await page.evaluate(audioReadyScript(fileName)));
557
+ return JSON.parse(raw).ready === true ? `audio media record observed (${JSON.parse(raw).by})` : null;
558
+ },
559
+ { timeoutMs: XYZF_UPLOAD_TIMEOUT_MS, label: 'xiaoyuzhou audio processing' },
560
+ );
561
+ if (!processed.ok) {
562
+ return stageFail(
563
+ XYZF_TARGET,
564
+ `xiaoyuzhou audio processing not confirmed within the ${XYZF_UPLOAD_TIMEOUT_MS}ms ceiling — the file was assigned but no media record (audio player or file name) appeared; refusing to claim an uploaded episode`,
565
+ 'upload-processing',
566
+ metadata,
567
+ );
568
+ }
569
+
570
+ // Title: native fill with real input/change events (Playwright fill dispatches both) —
571
+ // never the source's bare `.value` write that the source itself flags suspect
572
+ // (xiaoyuzhou.js:57-58) — then a normalized value readback. The selectors are hoisted
573
+ // because the receipt readback re-probes the persisted state through them; the flow can
574
+ // only reach the receipt after both stages resolved and read back.
575
+ let titleSelector: string | undefined;
576
+ try {
577
+ const resolvedTitle = await resolveSelector(page, XYZF_AUDIO_SELECTORS.titleInput, {
578
+ timeoutMs: this.stepTimeoutMs,
579
+ label: 'xiaoyuzhou title input',
580
+ });
581
+ titleSelector = resolvedTitle;
582
+ await page.locator(resolvedTitle).first().fill(title);
583
+ const readback = await expectOutcome(
584
+ async () =>
585
+ normalizeText(String(await page.evaluate(inputValueScript(resolvedTitle)))) === normalizeText(title)
586
+ ? 'title read back'
587
+ : null,
588
+ { timeoutMs: this.stepTimeoutMs, label: 'xiaoyuzhou title readback' },
589
+ );
590
+ if (!readback.ok)
591
+ return stageFail(
592
+ XYZF_TARGET,
593
+ `xiaoyuzhou title readback failed — the editor did not retain the requested title — ${readback.error}`,
594
+ 'title-readback',
595
+ metadata,
596
+ );
597
+ } catch (error) {
598
+ return stageFail(
599
+ XYZF_TARGET,
600
+ `xiaoyuzhou title fill failed — ${errorMessage(error)}`,
601
+ 'title-readback',
602
+ metadata,
603
+ );
604
+ }
605
+
606
+ // Shownotes: Markdown → sanitized HTML pasted as text/html into the Draft.js editor (the
607
+ // source's simulatePaste mechanism, xiaoyuzhou.js:72-74) plus the source's input/change
608
+ // dispatch, then a strict text readback.
609
+ const html = sanitizeXiaoyuzhouHtml(markdownToHtml(body));
610
+ let editorSelector: string | undefined;
611
+ try {
612
+ const resolvedEditor = await resolveSelector(page, XYZF_AUDIO_SELECTORS.shownotesEditor, {
613
+ timeoutMs: this.stepTimeoutMs,
614
+ label: 'xiaoyuzhou shownotes editor',
615
+ });
616
+ editorSelector = resolvedEditor;
617
+ await pasteHtml(page, resolvedEditor, html, { timeoutMs: this.stepTimeoutMs });
618
+ await page.evaluate(
619
+ `(() => { // kk-xyzf-editor-events
620
+ const editor = document.querySelector(${JSON.stringify(resolvedEditor)});
621
+ if (editor === null) return;
622
+ editor.dispatchEvent(new Event('input', { bubbles: true }));
623
+ editor.dispatchEvent(new Event('change', { bubbles: true }));
624
+ })()`,
625
+ );
626
+ const readback = await expectOutcome(
627
+ async () => {
628
+ const raw = String(await page.evaluate(editorReadbackScript(resolvedEditor, html)));
629
+ return JSON.parse(raw).ok === true ? 'editor html read back' : null;
630
+ },
631
+ { timeoutMs: this.stepTimeoutMs, label: 'xiaoyuzhou shownotes paste readback' },
632
+ );
633
+ if (!readback.ok)
634
+ return stageFail(
635
+ XYZF_TARGET,
636
+ `xiaoyuzhou shownotes readback failed — the editor did not retain the pasted shownotes — ${readback.error}`,
637
+ 'shownotes-paste-readback',
638
+ metadata,
639
+ );
640
+ } catch (error) {
641
+ return stageFail(
642
+ XYZF_TARGET,
643
+ `xiaoyuzhou shownotes paste failed — ${errorMessage(error)}`,
644
+ 'shownotes-paste-readback',
645
+ metadata,
646
+ );
647
+ }
648
+
649
+ // Cover: only when requested, on the provisional scoped control — a missing control is a
650
+ // typed failure, never the vendor-style silent absence (xiaoyuzhou.js:17), and the
651
+ // attached preview must be observed before it counts.
652
+ let coverReadback: { selector: string; fileName: string } | undefined;
653
+ if (coverPath !== undefined) {
654
+ try {
655
+ const coverSelector = await resolveSelector(page, XYZF_AUDIO_SELECTORS.coverInput, {
656
+ timeoutMs: this.stepTimeoutMs,
657
+ label: 'xiaoyuzhou cover file input',
658
+ });
659
+ await uploadFile(page, coverSelector, coverPath, { timeoutMs: this.stepTimeoutMs });
660
+ coverReadback = { selector: coverSelector, fileName: basename(coverPath) };
661
+ const readback = await expectOutcome(
662
+ async () => {
663
+ const raw = String(
664
+ await page.evaluate(
665
+ `(() => { // kk-xyzf-cover-assigned
666
+ const input = document.querySelector(${JSON.stringify(coverSelector)});
667
+ if (input === null || input.files === null) return JSON.stringify({ assigned: false });
668
+ const assigned = input.files.length === 1 && input.files[0].name === ${JSON.stringify(basename(coverPath))};
669
+ const preview = assigned && document.querySelector('form:has(div[data-contents="true"]) img') !== null;
670
+ return JSON.stringify({ assigned, preview });
671
+ })()`,
672
+ ),
673
+ );
674
+ const state = JSON.parse(raw) as { assigned: boolean; preview: boolean };
675
+ return state.assigned && state.preview ? 'cover assignment and preview read back' : null;
676
+ },
677
+ { timeoutMs: this.stepTimeoutMs, label: 'xiaoyuzhou cover readback' },
678
+ );
679
+ if (!readback.ok)
680
+ return stageFail(
681
+ XYZF_TARGET,
682
+ 'xiaoyuzhou cover attach could not be verified (assignment or preview missing on the scoped control) — refusing rather than claiming an unverified cover',
683
+ 'cover-attach',
684
+ metadata,
685
+ );
686
+ } catch (error) {
687
+ return stageFail(
688
+ XYZF_TARGET,
689
+ `xiaoyuzhou cover attach failed — ${errorMessage(error)}`,
690
+ 'cover-attach',
691
+ metadata,
692
+ );
693
+ }
694
+ }
695
+
696
+ // Draft save: only the exact-visible provisional 保存草稿|保存 candidate (no broad
697
+ // fallback). Missing current save control = typed failure, never a manufactured draft
698
+ // (the source never saves, xiaoyuzhou.js:6, 18-19).
699
+ let saveSelector: string;
700
+ try {
701
+ saveSelector = await resolveSelector(page, XYZF_AUDIO_SELECTORS.saveButton, {
702
+ timeoutMs: this.stepTimeoutMs,
703
+ label: 'xiaoyuzhou draft-save control',
704
+ });
705
+ } catch (error) {
706
+ return stageFail(
707
+ XYZF_TARGET,
708
+ `xiaoyuzhou draft-save control never attached: ${errorMessage(error)} — the source never saves, so a missing current control is a typed failure, not a manufactured draft`,
709
+ 'draft-save',
710
+ metadata,
711
+ );
712
+ }
713
+ const visible = (await trySelectors(page, [saveSelector], { visible: true, timeout: this.stepTimeoutMs }))
714
+ .found;
715
+ if (!visible)
716
+ return stageFail(
717
+ XYZF_TARGET,
718
+ 'xiaoyuzhou draft-save control attached but not visible; refusing to save through a hidden control',
719
+ 'draft-save',
720
+ metadata,
721
+ );
722
+ const saveButton = page.locator(saveSelector).first();
723
+ const enabled = await expectOutcome(async () => ((await saveButton.isEnabled()) ? 'enabled' : null), {
724
+ timeoutMs: this.stepTimeoutMs,
725
+ label: 'xiaoyuzhou draft-save control enabled',
726
+ });
727
+ if (!enabled.ok)
728
+ return stageFail(
729
+ XYZF_TARGET,
730
+ `xiaoyuzhou draft-save control never became clickable — ${enabled.error}`,
731
+ 'draft-save',
732
+ metadata,
733
+ );
734
+
735
+ // Draft receipt: baseline before the click (fresh-signal rule), then only a FRESH
736
+ // episode-id URL plus persisted title/audio/shownotes/cover state confirms. Stale,
737
+ // ambiguous (disagreeing fresh ids) or contradictory never passes.
738
+ const beforeReceipts = new Set(await readReceiptUrls(page));
739
+ await saveButton.click();
740
+ const confirmed = await expectOutcome(
741
+ async () => {
742
+ const fresh = (await readReceiptUrls(page)).filter((candidate) => !beforeReceipts.has(candidate));
743
+ if (fresh.length === 0) return null;
744
+ const ids = new Set(fresh.map(episodeIdFrom).filter((id): id is string => id !== undefined));
745
+ if (ids.size === 0) return `ambiguous-receipt: ${fresh[0] ?? ''}`; // no id-bearing episode URL
746
+ if (ids.size > 1) return `ambiguous-receipt: ${ids.size} disagreeing fresh episode ids`;
747
+ const url = fresh.find((candidate) => episodeIdFrom(candidate) !== undefined);
748
+ if (url === undefined) return `ambiguous-receipt: ${fresh[0] ?? ''}`;
749
+ const raw = String(
750
+ await page.evaluate(
751
+ persistedStateScript(
752
+ titleSelector as string,
753
+ title,
754
+ editorSelector as string,
755
+ html,
756
+ { fileName },
757
+ coverReadback,
758
+ ),
759
+ ),
760
+ );
761
+ const state = JSON.parse(raw) as {
762
+ titleOk: boolean;
763
+ editorOk: boolean;
764
+ audioOk: boolean;
765
+ coverOk: boolean;
766
+ };
767
+ if (!state.titleOk || !state.editorOk || !state.audioOk || !state.coverOk) {
768
+ return `stale-state: ${url ?? ''}`; // a receipt whose fields no longer match is not success
769
+ }
770
+ return url;
771
+ },
772
+ { timeoutMs: this.stepTimeoutMs, label: 'xiaoyuzhou draft-receipt confirmation' },
773
+ );
774
+ if (!confirmed.ok) {
775
+ return stageFail(
776
+ XYZF_TARGET,
777
+ 'xiaoyuzhou draft-receipt not confirmed — no fresh episode-id receipt with persisted title/audio/shownotes/cover state appeared; fill alone is never a saved draft',
778
+ 'draft-receipt',
779
+ metadata,
780
+ `still on ${page.url()}; ${beforeReceipts.size} pre-existing receipt-shaped url(s)`,
781
+ );
782
+ }
783
+ if (confirmed.evidence.startsWith('stale-state: ') || confirmed.evidence.startsWith('ambiguous-receipt: ')) {
784
+ return stageFail(
785
+ XYZF_TARGET,
786
+ confirmed.evidence.startsWith('stale-state: ')
787
+ ? 'xiaoyuzhou draft-receipt appeared but the persisted title/audio/shownotes/cover state no longer matches the requested content; refusing to claim success'
788
+ : 'xiaoyuzhou draft-receipt is ambiguous (no single id-bearing episode URL among the fresh receipts); refusing to claim success',
789
+ 'draft-receipt',
790
+ metadata,
791
+ confirmed.evidence,
792
+ );
793
+ }
794
+ const url = confirmed.evidence;
795
+ return ResultSchema.parse({
796
+ ok: true,
797
+ target: XYZF_TARGET,
798
+ id: episodeIdFrom(url),
799
+ url,
800
+ message: `xiaoyuzhou podcast draft saved in profile ${profileDir}`,
801
+ metadata: { ...metadata, confirmedBy: 'draft-receipt' },
802
+ });
803
+ }
804
+ }
805
+
806
+ /**
807
+ * Unsupported requested fields for this flow: images/tags/categories are not implemented
808
+ * (xiaoyuzhou.js implements none of them), so any of them — in options or metadata — is a typed
809
+ * unsupported-field refusal, never a silent claim of applied. `coverPath` is implemented and
810
+ * validated in `resolveCoverPath`.
811
+ */
812
+ function unsupportedFields(payload: PublishTransportPayload): readonly string[] {
813
+ const refused: string[] = [];
814
+ for (const [container, keys] of [
815
+ ['options', ['images', 'tags', 'categories'] as const],
816
+ ['metadata', ['images', 'tags', 'categories'] as const],
817
+ ] as const) {
818
+ const source: unknown = container === 'options' ? payload.options : payload.content.metadata;
819
+ for (const key of keys) {
820
+ if (source !== undefined && source !== null && typeof source === 'object' && key in source) {
821
+ refused.push(`${container}.${key}`);
822
+ }
823
+ }
824
+ }
825
+ return refused;
826
+ }