@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/dist/board/collect/index.css +654 -16
- package/dist/board/collect/index.js +191 -64
- package/dist/board/daily-voice/index.css +679 -12
- package/dist/board/daily-voice/index.js +185 -66
- package/dist/board/itc/index.css +679 -13
- package/dist/board/itc/index.js +188 -66
- package/dist/index.js +2864 -139
- package/package.json +3 -2
- package/plugins/generations/content-gen/dist/index.js +158 -12
- package/plugins/generations/core-facts-gen/dist/index.js +158 -12
- package/plugins/generations/daily-article-gen/dist/index.js +158 -12
- package/plugins/generations/dailynews-gen/dist/index.js +158 -12
- package/plugins/generations/episode-plan-gen/dist/index.js +158 -12
- package/plugins/generations/gemini-tts/README.md +4 -3
- package/plugins/generations/gemini-tts/dist/index.js +158 -12
- package/plugins/generations/image-gen/dist/index.js +158 -12
- package/plugins/generations/news-report-gen/dist/index.js +158 -12
- package/plugins/generations/omni-voice-gen/README.md +15 -4
- package/plugins/generations/omni-voice-gen/profiles.json +1 -2
- package/plugins/generations/voice-gen/dist/index.js +158 -12
- package/plugins/generations/voxcpm-tts/README.md +21 -1
- package/plugins/generations/voxcpm-tts/dist/index.js +173 -13
- package/plugins/generations/voxcpm-tts/scripts/audit-speaker-consistency.py +109 -0
- package/plugins/generations/voxcpm-tts/src/voxcpm-client.ts +28 -1
- package/plugins/ingestions/aihot-ingest/dist/index.js +158 -12
- package/plugins/ingestions/horizon-ingest/dist/index.js +158 -12
- package/plugins/ingestions/last30days-ingest/dist/index.js +158 -12
- package/plugins/ingestions/web-search/dist/index.js +158 -12
- package/plugins/kk/commands/workflow-run.md +56 -18
- package/plugins/kk/decisions/kk-itc.yaml +46 -0
- package/plugins/kk/decisions/kk-seo-keyword.yaml +57 -0
- package/plugins/kk/decisions/kk-seo-page.yaml +230 -0
- package/plugins/kk/decisions/kk-seo-pair.yaml +21 -0
- package/plugins/kk/decisions/kk-seo-site.yaml +90 -0
- package/plugins/kk/plugin.json +1 -1
- package/plugins/kk/publish-presets/chinese-article.json +14 -0
- package/plugins/kk/publish-presets/technical-article.json +14 -0
- package/plugins/kk/references/smart-seo-attribution.md +85 -0
- package/plugins/kk/scripts/daily-stages.ts +42 -2
- package/plugins/kk/scripts/itc-stages.ts +25 -118
- package/plugins/kk/scripts/kk-workflow-stages.ts +38 -1
- package/plugins/kk/scripts/seo-decisions.ts +491 -0
- package/plugins/kk/scripts/seo-enrich.ts +728 -0
- package/plugins/kk/scripts/seo-facts.ts +610 -0
- package/plugins/kk/scripts/seo-fetch.ts +766 -0
- package/plugins/kk/scripts/seo-report.ts +460 -0
- package/plugins/kk/scripts/seo-rules.ts +1882 -0
- package/plugins/kk/scripts/seo-stages.ts +1044 -0
- package/plugins/kk/skills/article-adapt/SKILL.md +2 -2
- package/plugins/kk/skills/smart-seo/SKILL.md +183 -0
- package/plugins/kk/skills/smart-seo/references/method.md +126 -0
- package/plugins/kk/skills/smart-seo/references/narrative.md +125 -0
- package/plugins/kk/skills/smart-seo/references/operations.md +209 -0
- package/plugins/kk/skills/smart-seo/references/seo-geo-sources.md +90 -0
- package/plugins/kk/workflows/feature-verification.yaml +28 -24
- package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +85 -17
- package/plugins/kk/workflows/kk-itc-author.yaml +406 -0
- package/plugins/kk/workflows/kk-itc.yaml +208 -682
- package/plugins/kk/workflows/kk-smart-seo.yaml +251 -0
- package/plugins/publishings/bilibili-pub/dist/index.js +21782 -0
- package/plugins/publishings/bilibili-pub/dist/profile-check.js +20410 -0
- package/plugins/publishings/bilibili-pub/package.json +20 -0
- package/plugins/publishings/bilibili-pub/plugin.json +12 -0
- package/plugins/publishings/bilibili-pub/src/flow.ts +1296 -0
- package/plugins/publishings/bilibili-pub/src/index.ts +139 -0
- package/plugins/publishings/bilibili-pub/src/profile-check.ts +19 -0
- package/plugins/publishings/bilibili-pub/src/selectors.ts +222 -0
- package/plugins/publishings/bilibili-pub/tsconfig.json +4 -0
- package/plugins/publishings/cnblogs-pub/dist/index.js +21383 -0
- package/plugins/publishings/cnblogs-pub/dist/profile-check.js +20412 -0
- package/plugins/publishings/cnblogs-pub/package.json +19 -0
- package/plugins/publishings/cnblogs-pub/plugin.json +12 -0
- package/plugins/publishings/cnblogs-pub/src/flow.ts +708 -0
- package/plugins/publishings/cnblogs-pub/src/index.ts +172 -0
- package/plugins/publishings/cnblogs-pub/src/profile-check.ts +24 -0
- package/plugins/publishings/cnblogs-pub/src/selectors.ts +104 -0
- package/plugins/publishings/cnblogs-pub/tsconfig.json +4 -0
- package/plugins/publishings/csdn-pub/dist/index.js +21552 -0
- package/plugins/publishings/csdn-pub/dist/profile-check.js +20963 -0
- package/plugins/publishings/csdn-pub/package.json +19 -0
- package/plugins/publishings/csdn-pub/plugin.json +12 -0
- package/plugins/publishings/csdn-pub/src/index.ts +1178 -0
- package/plugins/publishings/csdn-pub/src/profile-check.ts +23 -0
- package/plugins/publishings/csdn-pub/tsconfig.json +4 -0
- package/plugins/publishings/emdash-pub/dist/index.js +158 -12
- package/plugins/publishings/emdash-pub/plugin.json +22 -1
- package/plugins/publishings/infoq-pub/dist/index.js +160 -12
- package/plugins/publishings/infoq-pub/dist/profile-check.js +160 -12
- package/plugins/publishings/infoq-pub/plugin.json +21 -0
- package/plugins/publishings/jike-pub/dist/index.js +21379 -0
- package/plugins/publishings/jike-pub/dist/profile-check.js +20951 -0
- package/plugins/publishings/jike-pub/package.json +19 -0
- package/plugins/publishings/jike-pub/plugin.json +12 -0
- package/plugins/publishings/jike-pub/src/index.ts +800 -0
- package/plugins/publishings/jike-pub/src/profile-check.ts +21 -0
- package/plugins/publishings/jike-pub/tsconfig.json +4 -0
- package/plugins/publishings/juejin-pub/dist/index.js +160 -12
- package/plugins/publishings/juejin-pub/dist/profile-check.js +160 -12
- package/plugins/publishings/juejin-pub/plugin.json +21 -0
- package/plugins/publishings/medium-pub/dist/index.js +158 -12
- package/plugins/publishings/medium-pub/plugin.json +22 -1
- package/plugins/publishings/podcast-pub/dist/index.js +158 -12
- package/plugins/publishings/qiita-pub/dist/index.js +158 -12
- package/plugins/publishings/qiita-pub/plugin.json +24 -1
- package/plugins/publishings/shipinhao-pub/dist/index.js +21715 -0
- package/plugins/publishings/shipinhao-pub/dist/profile-check.js +20431 -0
- package/plugins/publishings/shipinhao-pub/package.json +19 -0
- package/plugins/publishings/shipinhao-pub/plugin.json +12 -0
- package/plugins/publishings/shipinhao-pub/src/flow.ts +1323 -0
- package/plugins/publishings/shipinhao-pub/src/index.ts +173 -0
- package/plugins/publishings/shipinhao-pub/src/profile-check.ts +25 -0
- package/plugins/publishings/shipinhao-pub/src/selectors.ts +241 -0
- package/plugins/publishings/shipinhao-pub/tsconfig.json +4 -0
- package/plugins/publishings/sspai-pub/dist/index.js +21371 -0
- package/plugins/publishings/sspai-pub/dist/profile-check.js +20414 -0
- package/plugins/publishings/sspai-pub/package.json +19 -0
- package/plugins/publishings/sspai-pub/plugin.json +12 -0
- package/plugins/publishings/sspai-pub/src/flow.ts +624 -0
- package/plugins/publishings/sspai-pub/src/index.ts +167 -0
- package/plugins/publishings/sspai-pub/src/profile-check.ts +25 -0
- package/plugins/publishings/sspai-pub/src/selectors.ts +122 -0
- package/plugins/publishings/sspai-pub/tsconfig.json +4 -0
- package/plugins/publishings/substack-pub/dist/index.js +160 -12
- package/plugins/publishings/substack-pub/dist/profile-check.js +160 -12
- package/plugins/publishings/substack-pub/plugin.json +21 -0
- package/plugins/publishings/surfdash-pub/dist/index.js +158 -12
- package/plugins/publishings/surfdash-pub/plugin.json +22 -1
- package/plugins/publishings/threads-pub/dist/index.js +158 -12
- package/plugins/publishings/wechatmp-pub/dist/index.js +1669 -502
- package/plugins/publishings/wechatmp-pub/dist/profile-check.js +204 -21
- package/plugins/publishings/wechatmp-pub/plugin.json +32 -0
- package/plugins/publishings/wechatmp-pub/src/cgi-draft.ts +540 -0
- package/plugins/publishings/wechatmp-pub/src/cgi-media.ts +380 -0
- package/plugins/publishings/wechatmp-pub/src/cgi.ts +485 -0
- package/plugins/publishings/wechatmp-pub/src/flow.ts +104 -0
- package/plugins/publishings/weibo-pub/dist/index.js +160 -12
- package/plugins/publishings/weibo-pub/dist/profile-check.js +160 -12
- package/plugins/publishings/weibo-pub/plugin.json +35 -0
- package/plugins/publishings/x-pub/dist/index.js +160 -12
- package/plugins/publishings/x-pub/dist/profile-check.js +160 -12
- package/plugins/publishings/x-pub/plugin.json +40 -0
- package/plugins/publishings/xhs-pub/dist/index.js +160 -12
- package/plugins/publishings/xhs-pub/dist/profile-check.js +160 -12
- package/plugins/publishings/xhs-pub/plugin.json +37 -0
- package/plugins/publishings/xiaoyuzhou-pub/dist/index.js +21491 -0
- package/plugins/publishings/xiaoyuzhou-pub/dist/profile-check.js +20417 -0
- package/plugins/publishings/xiaoyuzhou-pub/package.json +19 -0
- package/plugins/publishings/xiaoyuzhou-pub/plugin.json +12 -0
- package/plugins/publishings/xiaoyuzhou-pub/src/flow.ts +826 -0
- package/plugins/publishings/xiaoyuzhou-pub/src/index.ts +174 -0
- package/plugins/publishings/xiaoyuzhou-pub/src/profile-check.ts +26 -0
- package/plugins/publishings/xiaoyuzhou-pub/src/selectors.ts +125 -0
- package/plugins/publishings/xiaoyuzhou-pub/tsconfig.json +4 -0
- package/plugins/publishings/zenn-pub/dist/index.js +158 -12
- package/plugins/publishings/zenn-pub/plugin.json +22 -1
- package/plugins/generations/omni-voice-gen/voices/robin-news.wav +0 -0
|
@@ -0,0 +1,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
|
+
}
|