@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,19 @@
1
+ {
2
+ "name": "@gobing-ai/sspai-pub",
3
+ "type": "module",
4
+ "private": true,
5
+ "scripts": {
6
+ "typecheck": "tsc --noEmit"
7
+ },
8
+ "dependencies": {
9
+ "@gobing-ai/kk-core": "workspace:*",
10
+ "@gobing-ai/ts-browser-automation": "catalog:",
11
+ "@gobing-ai/publish-harness": "workspace:*",
12
+ "@gobing-ai/ts-runtime": "catalog:",
13
+ "@gobing-ai/ts-utils": "catalog:",
14
+ "playwright": "catalog:"
15
+ },
16
+ "devDependencies": {
17
+ "@types/bun": "1.4.3"
18
+ }
19
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "sspai-pub",
3
+ "kind": "publish",
4
+ "entry": "./dist/index.js",
5
+ "check": "./dist/profile-check.js",
6
+ "version": "1.0.0",
7
+ "description": "sspai (sspai.com) Matrix column article drafts via a script browser transport (ts-browser-automation); draft-only — absent/draft publishStatus saves a draft, public refuses pre-browser",
8
+ "browserSession": {
9
+ "profileName": "sspai",
10
+ "homeUrl": "https://sspai.com/"
11
+ }
12
+ }
@@ -0,0 +1,624 @@
1
+ /**
2
+ * sspai-pub transport + flow (task 0294).
3
+ *
4
+ * The live editor flow in `runArticleFlow`, stage-typed on every `Result.metadata.stage`:
5
+ * editor-readiness → title-readback → cover-attach (when requested) → body-paste-readback →
6
+ * draft-save → draft-receipt. This slice saves drafts ONLY: publishStatus absent or 'draft' (the
7
+ * frozen default) activates the exact-visible 保存草稿|保存 candidate; 'public' refuses before the
8
+ * browser (final publication/submission on sspai is not evidenced — sspai.js itself stops at the
9
+ * preview click, sspai.js:15-16).
10
+ *
11
+ * Mechanism (sspai.js:5, 8-13, 18): Markdown is rendered to HTML and pasted into the
12
+ * contenteditable editor as `text/html` (the source's `simulatePaste`, sspai.js:62-65 — it does
13
+ * no Markdown preprocessing, sspai.js:18); the pasted HTML is sanitized here first (script/style/
14
+ * iframe/embed/object, `on*` attributes and `javascript:` URLs stripped; code/list/heading markup
15
+ * preserved) with no new dependency. Success is confirmed only by a FRESH persisted receipt (a
16
+ * draft-id URL that was not on screen before the click) plus re-read persisted title/body (and
17
+ * cover-assignment) state — never a stale assignment, a toast, or the preview control
18
+ * (sspai.js:68, probed read-only, never clicked). The draft-save control and receipt shapes are
19
+ * provisional (the archived official guide documents draft saving historically only); a missing
20
+ * current save control is a typed failure, never a manufactured draft.
21
+ */
22
+
23
+ import { basename } from 'node:path';
24
+ import { type Result, ResultSchema } from '@gobing-ai/kk-core';
25
+ import {
26
+ type BrowserDriver,
27
+ type PublishTransport,
28
+ type PublishTransportPayload,
29
+ trySelectors,
30
+ } from '@gobing-ai/publish-harness';
31
+ import {
32
+ checkChannelSession,
33
+ expectOutcome,
34
+ kkBrowserLauncher,
35
+ markdownToHtml,
36
+ pasteHtml,
37
+ resolveChannelProfileDir,
38
+ resolveSelector,
39
+ uploadFile,
40
+ } from '@gobing-ai/publish-harness/browser';
41
+ import {
42
+ type BrowserLauncher,
43
+ BrowserProfileMissingError,
44
+ LoginTimeoutError,
45
+ withProfilePage,
46
+ } from '@gobing-ai/ts-browser-automation';
47
+ import type { Page } from 'playwright';
48
+ import {
49
+ isSspaiAuthenticated,
50
+ SSPAI_ARTICLE_SELECTORS,
51
+ SSPAI_CONFIG,
52
+ SSPAI_EDIT_URL,
53
+ SSPAI_RECEIPT_URL_RES,
54
+ SSPAI_TARGET,
55
+ } from './selectors';
56
+
57
+ const STEP_TIMEOUT_MS = 30_000;
58
+ /** Operator recovery command every session failure names — never a cookie, DOM dump or stack. */
59
+ export const SSPAI_LOGIN_INSTRUCTION = 'bun plugins/publishings/sspai-pub/src/profile-check.ts login';
60
+
61
+ /** `ok: false` refusal Result with a bounded, single-line error. */
62
+ export function fail(target: string, error: string): Result {
63
+ return ResultSchema.parse({ ok: false, target, error: boundedDetail(error) });
64
+ }
65
+
66
+ /** A browser-stage failure: `ok: false` with `stage` (and optional evidence) in the Result metadata. */
67
+ export function stageFail(
68
+ target: string,
69
+ error: string,
70
+ stage: string,
71
+ metadata: Record<string, unknown>,
72
+ evidence?: string,
73
+ ): Result {
74
+ return ResultSchema.parse({
75
+ ok: false,
76
+ target,
77
+ error: boundedDetail(error),
78
+ metadata: { ...metadata, stage, ...(evidence === undefined ? {} : { evidence: boundedDetail(evidence) }) },
79
+ });
80
+ }
81
+
82
+ /** One short line, bounded: no DOM dump, cookie, token, stack or leaked image path ever reaches the Result. */
83
+ export function boundedDetail(detail: string): string {
84
+ return detail.replace(/\s+/g, ' ').trim().slice(0, 300);
85
+ }
86
+
87
+ /** A bounded, single-line rendering of an operator-supplied value for a refusal message. */
88
+ export function bounded(value: unknown): string {
89
+ if (Array.isArray(value)) return `${value.length} entr${value.length === 1 ? 'y' : 'ies'}`;
90
+ return String(value).replace(/\s+/g, ' ').slice(0, 100);
91
+ }
92
+
93
+ export function errorMessage(error: unknown): string {
94
+ return error instanceof Error ? error.message : String(error);
95
+ }
96
+
97
+ /** Session failures whose one-liner must name the recovery command. */
98
+ function needsLogin(error: unknown): boolean {
99
+ return error instanceof LoginTimeoutError || error instanceof BrowserProfileMissingError;
100
+ }
101
+
102
+ /** Whitespace-insensitive comparison: site normalizations must not fail a strict readback. */
103
+ function normalizeText(value: string): string {
104
+ return value.replace(/\s+/g, '');
105
+ }
106
+
107
+ /** Input/textarea `value` property readback (title textarea). */
108
+ function inputValueScript(selector: string): string {
109
+ return `(() => { // kk-sspai-input-value
110
+ const el = document.querySelector(${JSON.stringify(selector)});
111
+ return el === null ? '' : String(el.value ?? '');
112
+ })()`;
113
+ }
114
+
115
+ /**
116
+ * Plugin-local HTML sanitizer (task 0294 Design freeze — no new dependency): strips
117
+ * script/style/iframe/embed/object containers, `on*` event attributes and `javascript:` URLs
118
+ * while preserving code/list/heading markup, before any paste reaches the editor.
119
+ */
120
+ export function sanitizeArticleHtml(html: string): string {
121
+ return html
122
+ .replace(/<\s*(script|style|iframe|embed|object)\b[\s\S]*?<\s*\/\s*\1\s*>/gi, '')
123
+ .replace(/<\s*(script|style|iframe|embed|object)\b[^>]*>/gi, '')
124
+ .replace(/\son[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '')
125
+ .replace(/(\s(?:href|src)\s*=\s*)(["'])\s*(?:javascript|data):[^"']*\2/gi, '$1$2$2')
126
+ .replace(/(\s(?:href|src)\s*=\s*)(?:javascript|data):[^\s>]*/gi, '$1""');
127
+ }
128
+
129
+ /**
130
+ * Body paste readback: the editor still holds non-empty markup whose text equals the sanitized
131
+ * HTML's text (whitespace-insensitive) — a dropped or transformed paste refuses before any save.
132
+ */
133
+ function editorReadbackScript(selector: string, html: string): string {
134
+ return `(() => { // kk-sspai-editor-readback
135
+ const editor = document.querySelector(${JSON.stringify(selector)});
136
+ if (editor === null) return JSON.stringify({ ok: false });
137
+ const probe = document.createElement('div');
138
+ probe.innerHTML = ${JSON.stringify(html)};
139
+ const squish = (v) => String(v ?? '').replace(/\\s+/g, '');
140
+ return JSON.stringify({ ok: editor.innerHTML.length > 0 && squish(editor.innerText) === squish(probe.textContent) });
141
+ })()`;
142
+ }
143
+
144
+ /**
145
+ * Persisted-state readback for save confirmation: the title textarea and editor still hold the
146
+ * requested content and, when a cover was requested, the editor-scoped file input still carries
147
+ * exactly that file assignment (frozen success rule — a fresh id alone never confirms).
148
+ */
149
+ function persistedStateScript(
150
+ titleSelector: string,
151
+ title: string,
152
+ editorSelector: string,
153
+ html: string,
154
+ cover: { selector: string; fileName: string } | undefined,
155
+ ): string {
156
+ return `(() => { // kk-sspai-persisted
157
+ const title = document.querySelector(${JSON.stringify(titleSelector)});
158
+ const editor = document.querySelector(${JSON.stringify(editorSelector)});
159
+ const probe = document.createElement('div');
160
+ probe.innerHTML = ${JSON.stringify(html)};
161
+ const squish = (v) => String(v ?? '').replace(/\\s+/g, '');
162
+ const titleOk = title !== null && squish(title.value) === squish(${JSON.stringify(title)});
163
+ const editorOk = editor !== null && editor.innerHTML.length > 0 && squish(editor.innerText) === squish(probe.textContent);
164
+ const coverOk = ${
165
+ cover === undefined
166
+ ? 'true'
167
+ : `(() => {
168
+ const input = document.querySelector(${JSON.stringify(cover.selector)});
169
+ return input !== null && input.files !== null && input.files.length === 1 && input.files[0].name === ${JSON.stringify(cover.fileName)};
170
+ })()`
171
+ };
172
+ return JSON.stringify({ titleOk, editorOk, coverOk });
173
+ })()`;
174
+ }
175
+
176
+ /** Receipt-shaped URLs on screen (location + anchors) — the save-time baseline and fresh read. */
177
+ async function readReceiptUrls(page: Page): Promise<readonly string[]> {
178
+ const raw = await page.evaluate(`(() => { // kk-sspai-receipt-urls
179
+ const patterns = [${SSPAI_RECEIPT_URL_RES.map((re) => re.toString()).join(', ')}];
180
+ const matches = (url) => patterns.some((re) => re.test(url));
181
+ const urls = [];
182
+ if (matches(location.href)) urls.push(location.href);
183
+ for (const a of document.querySelectorAll('a[href]')) {
184
+ if (matches(a.href) && !urls.includes(a.href)) urls.push(a.href);
185
+ }
186
+ return JSON.stringify(urls);
187
+ })()`);
188
+ try {
189
+ const parsed: unknown = JSON.parse(String(raw));
190
+ return Array.isArray(parsed) ? parsed.map((entry) => String(entry)) : [];
191
+ } catch {
192
+ return [];
193
+ }
194
+ }
195
+
196
+ /** Draft id from an observed receipt URL (shapes: `SSPAI_RECEIPT_URL_RES`, provisional). */
197
+ function articleIdFrom(url: string): string | undefined {
198
+ return /(?:\/post\/|\/me\/drafts\/)(\d+)/.exec(url)?.[1];
199
+ }
200
+
201
+ /**
202
+ * Pre-browser requested-cover gate: an optional local PNG/JPEG/WebP that must exist as a regular,
203
+ * readable, nonempty file. Requested-but-absent or wrong-typed refuses here — never the source's
204
+ * silent skip (sspai.js:17 makes the source's missing-input behavior explicit and this flow
205
+ * refuses instead).
206
+ */
207
+ async function resolveCoverPath(
208
+ raw: unknown,
209
+ ): Promise<{ path?: string; error?: undefined } | { path?: undefined; error: string }> {
210
+ if (raw === undefined) return {};
211
+ if (typeof raw !== 'string' || raw.trim().length === 0) {
212
+ return { error: `metadata.coverPath must be a non-empty local file path when present, got '${bounded(raw)}'` };
213
+ }
214
+ const path = raw.trim();
215
+ if (!/\.(png|jpe?g|webp)$/i.test(path)) {
216
+ return {
217
+ error: `metadata.coverPath must be a local PNG/JPEG/WebP file, got unsupported extension in '${bounded(path)}'`,
218
+ };
219
+ }
220
+ // no-direct-fs-io: probe the file through the Bun API instead of node:fs.
221
+ const file = Bun.file(path);
222
+ const readable = await file.exists().catch(() => false);
223
+ if (!readable || file.size === 0) {
224
+ return {
225
+ error: `metadata.coverPath does not resolve to a readable nonempty local file (refusing, never a silent skip — sspai.js:17)`,
226
+ };
227
+ }
228
+ return { path };
229
+ }
230
+
231
+ /**
232
+ * Sspai browser transport on the shared rails (ADR-027): a headless session preflight, one headed
233
+ * flow, and success only from a fresh persisted receipt plus persisted field state. Draft-only:
234
+ * publishStatus absent or 'draft' saves a draft; 'public' and any unknown value refuse before the
235
+ * browser (ladder in `src/index.ts` → `publish()`).
236
+ */
237
+ export class SspaiBrowserTransport implements PublishTransport {
238
+ public readonly kind = 'browser';
239
+ public readonly driver: BrowserDriver;
240
+ private readonly operation: 'article';
241
+ private readonly launcher: BrowserLauncher;
242
+ private readonly stepTimeoutMs: number;
243
+ constructor(
244
+ operation: 'article' = 'article',
245
+ driver: BrowserDriver = 'script',
246
+ deps: { launcher?: BrowserLauncher; stepTimeoutMs?: number } = {},
247
+ ) {
248
+ this.operation = operation;
249
+ this.driver = driver;
250
+ this.launcher = deps.launcher ?? kkBrowserLauncher;
251
+ this.stepTimeoutMs = deps.stepTimeoutMs ?? STEP_TIMEOUT_MS;
252
+ }
253
+
254
+ /**
255
+ * Publishes one Content as an sspai Matrix article draft.
256
+ * @param payload - Target content plus per-call options.
257
+ * @returns The publish `Result`, `ok: true` only from an observed persisted draft receipt.
258
+ */
259
+ public async publish(payload: PublishTransportPayload): Promise<Result> {
260
+ // The operation was resolved (and unknown names refused) before this transport existed;
261
+ // the remaining ladder is all pre-browser: driver → publishStatus (draft-only) → title →
262
+ // body → cover → unsupported fields → session preflight.
263
+ if (this.driver !== 'script') {
264
+ return fail(
265
+ SSPAI_TARGET,
266
+ `unsupported driver '${bounded(this.driver)}' for ${SSPAI_TARGET}: expected one of script (the sspai-pub browser flow has no agent path)`,
267
+ );
268
+ }
269
+ const requested = payload.content.metadata?.publishStatus;
270
+ if (requested === 'public') {
271
+ return fail(
272
+ SSPAI_TARGET,
273
+ `sspai-pub saves drafts only: metadata.publishStatus 'public' is refused before any browser opens — final publication/submission on sspai is not evidenced (the source itself stops at preview, sspai.js:15-16); omit publishStatus or pass 'draft'`,
274
+ );
275
+ }
276
+ if (requested !== undefined && requested !== 'draft') {
277
+ return fail(
278
+ SSPAI_TARGET,
279
+ `unknown publishStatus '${bounded(requested)}' for ${SSPAI_TARGET}: expected 'draft' or absent (absent defaults to draft); 'public' is refused — this slice saves drafts only`,
280
+ );
281
+ }
282
+ const title = payload.content.title?.trim() ?? '';
283
+ if (title.length === 0) {
284
+ return fail(
285
+ SSPAI_TARGET,
286
+ `sspai article requires a non-empty Content.title (the editor has an independent title textarea, sspai.js:26)`,
287
+ );
288
+ }
289
+ const body = payload.content.body.trim();
290
+ if (body.length === 0) {
291
+ return fail(
292
+ SSPAI_TARGET,
293
+ 'sspai article requires non-empty Content.body Markdown (the body is pasted as sanitized text/html, sspai.js:62)',
294
+ );
295
+ }
296
+ const unsupported = unsupportedFields(payload);
297
+ if (unsupported.length > 0) {
298
+ return fail(
299
+ SSPAI_TARGET,
300
+ `unsupported field(s) ${unsupported.join(', ')} for sspai: this draft-only flow implements title/body/cover only, and sspai.js implements none of images/tags/categories`,
301
+ );
302
+ }
303
+ const cover = await resolveCoverPath(payload.content.metadata?.coverPath ?? payload.options?.coverPath);
304
+ if (cover.error !== undefined) return fail(SSPAI_TARGET, cover.error);
305
+
306
+ const override = typeof payload.options?.profileDir === 'string' ? payload.options.profileDir : undefined;
307
+ const profileDir = resolveChannelProfileDir(SSPAI_TARGET, override);
308
+
309
+ // ADR-025 preflight: an unauthenticated or absent profile must never produce a headed visit,
310
+ // so the read-only headless check runs first and a refusal stays a fast refusal.
311
+ const session = await checkChannelSession(SSPAI_CONFIG, {
312
+ profileDir,
313
+ launcher: this.launcher,
314
+ headedFallback: false,
315
+ timeoutMs: this.stepTimeoutMs,
316
+ });
317
+ if (session.status !== 'verified') {
318
+ // The recovery command leads the message: `fail()` bounds the line, and the operator's
319
+ // next action must never be the part that gets truncated.
320
+ return fail(
321
+ SSPAI_TARGET,
322
+ `${SSPAI_TARGET} session preflight refused — run: ${SSPAI_LOGIN_INSTRUCTION} — detail: ${session.detail}`,
323
+ );
324
+ }
325
+
326
+ try {
327
+ return await withProfilePage(
328
+ {
329
+ profileDir,
330
+ url: SSPAI_EDIT_URL,
331
+ isAuthenticated: isSspaiAuthenticated,
332
+ // Headed by default; the headless preflight above is the unattended pass and
333
+ // `options.headless === true` the opt-in.
334
+ headless: payload.options?.headless === true,
335
+ launcher: this.launcher,
336
+ // The session is already preflight-verified, so the post-goto confirmation is a
337
+ // bounded step like every other one, not the 180s interactive login wait.
338
+ timeoutMs: this.stepTimeoutMs,
339
+ },
340
+ async ({ page }) => this.runArticleFlow(page, title, body, cover.path, profileDir),
341
+ );
342
+ } catch (error) {
343
+ // Same ordering rule as the preflight refusal: the recovery command is never the tail
344
+ // that a bounded message drops.
345
+ const detail = errorMessage(error);
346
+ return fail(
347
+ SSPAI_TARGET,
348
+ needsLogin(error)
349
+ ? `${SSPAI_TARGET} session failed — run: ${SSPAI_LOGIN_INSTRUCTION} — detail: ${detail}`
350
+ : detail,
351
+ );
352
+ }
353
+ }
354
+
355
+ /**
356
+ * The live article flow on the already-navigated editor page (`SSPAI_EDIT_URL`): read-only
357
+ * capability probe → title fill with strict value readback (native value + input events,
358
+ * sspai.js:53-55) → optional cover assignment with readback (sspai.js:32-41, but verified —
359
+ * never the source's silent skip) → sanitized text/html paste with strict readback
360
+ * (sspai.js:62-65) → the provisional exact-visible draft save → fresh-receipt confirmation.
361
+ * The preview control (sspai.js:68) is probed read-only and never clicked.
362
+ */
363
+ private async runArticleFlow(
364
+ page: Page,
365
+ title: string,
366
+ body: string,
367
+ coverPath: string | undefined,
368
+ profileDir: string,
369
+ ): Promise<Result> {
370
+ const metadata = {
371
+ operation: this.operation,
372
+ driver: this.driver,
373
+ publishStatus: 'draft' as const,
374
+ profileDir,
375
+ };
376
+
377
+ // Read-only capability probe: the grounded pair must attach (else the page is wrong or
378
+ // logged out — sspai.js:60 reads the missing editor as 未登录).
379
+ const grounded: Record<string, boolean> = {};
380
+ const resolved: Partial<Record<keyof typeof SSPAI_ARTICLE_SELECTORS, string>> = {};
381
+ for (const key of ['titleInput', 'bodyEditor'] as const) {
382
+ try {
383
+ resolved[key] = await resolveSelector(page, SSPAI_ARTICLE_SELECTORS[key], {
384
+ timeoutMs: this.stepTimeoutMs,
385
+ label: `sspai ${key}`,
386
+ });
387
+ } catch (error) {
388
+ return stageFail(
389
+ SSPAI_TARGET,
390
+ `sspai grounded editor control ${key} never attached on ${SSPAI_EDIT_URL}: ${errorMessage(error)} — run: ${SSPAI_LOGIN_INSTRUCTION}`,
391
+ 'editor-readiness',
392
+ metadata,
393
+ );
394
+ }
395
+ grounded[key] = (
396
+ await trySelectors(page, [resolved[key] as string], { visible: true, timeout: this.stepTimeoutMs })
397
+ ).found;
398
+ }
399
+ if (!grounded.titleInput || !grounded.bodyEditor) {
400
+ return stageFail(
401
+ SSPAI_TARGET,
402
+ `sspai grounded editor controls found but not visible on ${SSPAI_EDIT_URL} — run: ${SSPAI_LOGIN_INSTRUCTION}`,
403
+ 'editor-readiness',
404
+ metadata,
405
+ `grounded visible: ${JSON.stringify(grounded)}`,
406
+ );
407
+ }
408
+ const titleSelector = resolved.titleInput as string;
409
+ const editorSelector = resolved.bodyEditor as string;
410
+ const html = sanitizeArticleHtml(markdownToHtml(body));
411
+
412
+ // Title: native value fill with real input events, then strict readback (sspai.js:53-55).
413
+ try {
414
+ await page.locator(titleSelector).first().fill(title);
415
+ const readback = await expectOutcome(
416
+ async () =>
417
+ normalizeText(String(await page.evaluate(inputValueScript(titleSelector)))) === normalizeText(title)
418
+ ? 'title read back'
419
+ : null,
420
+ { timeoutMs: this.stepTimeoutMs, label: 'sspai title readback' },
421
+ );
422
+ if (!readback.ok)
423
+ return stageFail(
424
+ SSPAI_TARGET,
425
+ `sspai title readback failed — the editor did not retain the requested title — ${readback.error}`,
426
+ 'title-readback',
427
+ metadata,
428
+ );
429
+ } catch (error) {
430
+ return stageFail(
431
+ SSPAI_TARGET,
432
+ `sspai title fill failed — ${errorMessage(error)}`,
433
+ 'title-readback',
434
+ metadata,
435
+ );
436
+ }
437
+
438
+ // Cover: only when requested. setInputFiles on the editor-scoped input (sspai.js:32-41)
439
+ // plus a verified assignment readback — requested cover absent/unverified is a typed
440
+ // failure, never the source-style silent skip (sspai.js:17, 43-45).
441
+ let coverReadback: { selector: string; fileName: string } | undefined;
442
+ if (coverPath !== undefined) {
443
+ try {
444
+ const coverSelector = await resolveSelector(page, SSPAI_ARTICLE_SELECTORS.coverInput, {
445
+ timeoutMs: this.stepTimeoutMs,
446
+ label: 'sspai cover file input',
447
+ });
448
+ await uploadFile(page, coverSelector, coverPath, { timeoutMs: this.stepTimeoutMs });
449
+ coverReadback = { selector: coverSelector, fileName: basename(coverPath) };
450
+ const readback = await expectOutcome(
451
+ async () => {
452
+ const raw = String(
453
+ await page.evaluate(
454
+ `(() => { // kk-sspai-cover-assigned
455
+ const input = document.querySelector(${JSON.stringify(coverSelector)});
456
+ if (input === null || input.files === null) return JSON.stringify({ assigned: false });
457
+ return JSON.stringify({ assigned: input.files.length === 1 && input.files[0].name === ${JSON.stringify(basename(coverPath))} });
458
+ })()`,
459
+ ),
460
+ );
461
+ return JSON.parse(raw).assigned === true ? 'cover assignment read back' : null;
462
+ },
463
+ { timeoutMs: this.stepTimeoutMs, label: 'sspai cover assignment readback' },
464
+ );
465
+ if (!readback.ok)
466
+ return stageFail(
467
+ SSPAI_TARGET,
468
+ `sspai cover assignment could not be verified on the editor-scoped file input — refusing rather than claiming an unverified cover`,
469
+ 'cover-attach',
470
+ metadata,
471
+ );
472
+ } catch (error) {
473
+ return stageFail(
474
+ SSPAI_TARGET,
475
+ `sspai cover attach failed — ${errorMessage(error)}`,
476
+ 'cover-attach',
477
+ metadata,
478
+ );
479
+ }
480
+ }
481
+
482
+ // Body: Markdown → sanitized HTML pasted as text/html into the contenteditable editor
483
+ // (the source's simulatePaste mechanism, sspai.js:62-65), then strict readback.
484
+ try {
485
+ await pasteHtml(page, editorSelector, html, { timeoutMs: this.stepTimeoutMs });
486
+ const readback = await expectOutcome(
487
+ async () => {
488
+ const raw = String(await page.evaluate(editorReadbackScript(editorSelector, html)));
489
+ return JSON.parse(raw).ok === true ? 'editor html read back' : null;
490
+ },
491
+ { timeoutMs: this.stepTimeoutMs, label: 'sspai body paste readback' },
492
+ );
493
+ if (!readback.ok)
494
+ return stageFail(
495
+ SSPAI_TARGET,
496
+ `sspai body readback failed — the editor did not retain the pasted article — ${readback.error}`,
497
+ 'body-paste-readback',
498
+ metadata,
499
+ );
500
+ } catch (error) {
501
+ return stageFail(
502
+ SSPAI_TARGET,
503
+ `sspai body paste failed — ${errorMessage(error)}`,
504
+ 'body-paste-readback',
505
+ metadata,
506
+ );
507
+ }
508
+
509
+ // Draft save: only the exact-visible provisional 保存草稿|保存 candidate (no broad
510
+ // fallback). The grounded preview control (sspai.js:68) is probed read-only for capability
511
+ // evidence and NEVER clicked; missing current save control = typed failure, never a
512
+ // manufactured draft (the archived guide documents draft saving historically only).
513
+ let saveSelector: string;
514
+ try {
515
+ saveSelector = await resolveSelector(page, SSPAI_ARTICLE_SELECTORS.draftSave, {
516
+ timeoutMs: this.stepTimeoutMs,
517
+ label: 'sspai draft-save control',
518
+ });
519
+ } catch (error) {
520
+ return stageFail(
521
+ SSPAI_TARGET,
522
+ `sspai draft-save control never attached: ${errorMessage(error)} — draft saving is documented historically only, so a missing current control is a typed failure, not a manufactured draft`,
523
+ 'draft-save',
524
+ metadata,
525
+ );
526
+ }
527
+ const visible = (await trySelectors(page, [saveSelector], { visible: true, timeout: this.stepTimeoutMs }))
528
+ .found;
529
+ if (!visible)
530
+ return stageFail(
531
+ SSPAI_TARGET,
532
+ 'sspai draft-save control attached but not visible; refusing to save through a hidden control',
533
+ 'draft-save',
534
+ metadata,
535
+ );
536
+ const previewPresent = (
537
+ await trySelectors(page, SSPAI_ARTICLE_SELECTORS.previewButton, { visible: false, timeout: 2_000 })
538
+ ).found;
539
+ const saveButton = page.locator(saveSelector).first();
540
+ const enabled = await expectOutcome(async () => ((await saveButton.isEnabled()) ? 'enabled' : null), {
541
+ timeoutMs: this.stepTimeoutMs,
542
+ label: 'sspai draft-save control enabled',
543
+ });
544
+ if (!enabled.ok)
545
+ return stageFail(
546
+ SSPAI_TARGET,
547
+ `sspai draft-save control never became clickable — ${enabled.error}`,
548
+ 'draft-save',
549
+ metadata,
550
+ );
551
+
552
+ // Draft receipt: baseline before the click (fresh-signal rule), then only a FRESH
553
+ // draft-id URL plus persisted title/body/cover state confirms. Stale, toast-only or
554
+ // preview-only never passes.
555
+ const beforeReceipts = new Set(await readReceiptUrls(page));
556
+ await saveButton.click();
557
+ const confirmed = await expectOutcome(
558
+ async () => {
559
+ const url = (await readReceiptUrls(page)).find((candidate) => !beforeReceipts.has(candidate));
560
+ if (url === undefined) return null;
561
+ const raw = String(
562
+ await page.evaluate(
563
+ persistedStateScript(titleSelector, title, editorSelector, html, coverReadback),
564
+ ),
565
+ );
566
+ const state = JSON.parse(raw) as { titleOk: boolean; editorOk: boolean; coverOk: boolean };
567
+ if (!state.titleOk || !state.editorOk || !state.coverOk) {
568
+ return `stale-state: ${url}`; // a receipt whose fields no longer match is not success
569
+ }
570
+ return url;
571
+ },
572
+ { timeoutMs: this.stepTimeoutMs, label: 'sspai draft-receipt confirmation' },
573
+ );
574
+ if (!confirmed.ok) {
575
+ return stageFail(
576
+ SSPAI_TARGET,
577
+ `sspai draft-receipt not confirmed — no fresh draft-id receipt with persisted title/body/cover state appeared; preview or fill alone is never a saved draft`,
578
+ 'draft-receipt',
579
+ metadata,
580
+ `still on ${page.url()}; ${beforeReceipts.size} pre-existing receipt-shaped url(s); preview button ${previewPresent ? 'present (not clicked)' : 'absent'}`,
581
+ );
582
+ }
583
+ if (confirmed.evidence.startsWith('stale-state: ')) {
584
+ return stageFail(
585
+ SSPAI_TARGET,
586
+ 'sspai draft-receipt appeared but the persisted title/body/cover state no longer matches the requested content; refusing to claim success',
587
+ 'draft-receipt',
588
+ metadata,
589
+ confirmed.evidence,
590
+ );
591
+ }
592
+ const url = confirmed.evidence;
593
+ return ResultSchema.parse({
594
+ ok: true,
595
+ target: SSPAI_TARGET,
596
+ id: articleIdFrom(url),
597
+ url,
598
+ message: `sspai article draft saved in profile ${profileDir}`,
599
+ metadata: { ...metadata, confirmedBy: 'draft-receipt', previewButtonObserved: previewPresent },
600
+ });
601
+ }
602
+ }
603
+
604
+ /**
605
+ * Unsupported requested fields for this flow: images/tags/categories are not implemented
606
+ * (sspai.js implements none of them), so any of them — in options or metadata — is a typed
607
+ * unsupported-field refusal, never a silent claim of applied. `coverPath` is implemented and
608
+ * validated in `resolveCoverPath`.
609
+ */
610
+ function unsupportedFields(payload: PublishTransportPayload): readonly string[] {
611
+ const refused: string[] = [];
612
+ for (const [container, keys] of [
613
+ ['options', ['images', 'tags', 'categories'] as const],
614
+ ['metadata', ['images', 'tags', 'categories'] as const],
615
+ ] as const) {
616
+ const source: unknown = container === 'options' ? payload.options : payload.content.metadata;
617
+ for (const key of keys) {
618
+ if (source !== undefined && source !== null && typeof source === 'object' && key in source) {
619
+ refused.push(`${container}.${key}`);
620
+ }
621
+ }
622
+ }
623
+ return refused;
624
+ }