@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,800 @@
1
+ /**
2
+ * jike-pub — 即刻 (web.okjike.com) dynamic-post publisher (task 0291, Chunk A).
3
+ *
4
+ * Browser controls are pinned to the recovered extension v0.1.55 source (read-only evidence,
5
+ * `.spur/memory/evidence/publish-ready-0288-0297-20261014-01/source/platforms/jike.js`, windows
6
+ * 59–99 / 204–250 / 300–385); each selector cites its line, and anything the source does not
7
+ * ground is marked provisional. Live-only: 即刻 has no evidenced persistent draft flow, so an
8
+ * absent or non-public `publishStatus` refuses before any browser opens (ADR-022).
9
+ */
10
+ import { dirname, extname } from 'node:path';
11
+ import { parseArgs } from 'node:util';
12
+ import { type Content, ContentSchema, type Result, ResultSchema } from '@gobing-ai/kk-core';
13
+ import {
14
+ type BrowserDriver,
15
+ FakeBrowserTransport,
16
+ type PublishTransport,
17
+ type PublishTransportPayload,
18
+ trySelectors,
19
+ } from '@gobing-ai/publish-harness';
20
+ import {
21
+ type ChannelBrowserConfig,
22
+ checkChannelSession,
23
+ expectOutcome,
24
+ kkBrowserLauncher,
25
+ resolveChannelProfileDir,
26
+ resolveSelector,
27
+ uploadFile,
28
+ } from '@gobing-ai/publish-harness/browser';
29
+ import {
30
+ type BrowserLauncher,
31
+ BrowserProfileMissingError,
32
+ type IsAuthenticatedPredicate,
33
+ LoginTimeoutError,
34
+ withProfilePage,
35
+ } from '@gobing-ai/ts-browser-automation';
36
+ import { createNodeFileSystem } from '@gobing-ai/ts-runtime';
37
+ import { echoError } from '@gobing-ai/ts-utils';
38
+ import type { Page } from 'playwright';
39
+
40
+ /** Site-owned target slug; also the profile channel key. */
41
+ const TARGET = 'jike';
42
+ /** Home entry. Grounded: jike.js:1 header — 「即刻(web.okjike.com)动态填充适配器」. */
43
+ export const JIKE_HOME_URL = 'https://web.okjike.com';
44
+ /**
45
+ * Login/passport redirect marker. **Provisional**: the extension runs inside an already-open
46
+ * logged-in tab and only guesses 「可能未登录」 when the composer is missing (jike.js:309); no
47
+ * redirect URL is source-grounded. The composer probe below carries the real gate.
48
+ */
49
+ export const JIKE_LOGIN_URL_RE = /(passport\.okjike\.com|\/login)/;
50
+
51
+ /** Declared operations (ADR-029 dispatch): the dynamic-post flow only. */
52
+ export const JIKE_OPERATIONS = ['post'] as const;
53
+ export type JikeOperation = (typeof JIKE_OPERATIONS)[number];
54
+
55
+ /**
56
+ * The composer form is the media scope (jike.js:335 `editor.closest("form")`); `:has` pins it to
57
+ * the form that actually holds the editable composer, not a feed card.
58
+ */
59
+ const COMPOSER_FORM_SCOPE = 'form:has(div[contenteditable="true"])';
60
+
61
+ /**
62
+ * Post-composer selectors. **Grounded on jike.js** (extension v0.1.55):
63
+ * - composer `form div[contenteditable="true"]` (jike.js:307);
64
+ * - topic input `input[placeholder='未选择圈子']` (jike.js:207);
65
+ * - send: the first `button` whose text includes 发送 (jike.js:368-370), waited-while-disabled
66
+ * up to 10×3s (jike.js:371-373).
67
+ *
68
+ * **Provisional**: `fileInput` — the source fills media via paste events (jike.js:264-274), so no
69
+ * `input[type="file"]` exists in the evidence; the refine (2026-10-14) directs native setInputFiles
70
+ * on the composer-scoped input, and an absent input is a loud refusal, not a text-only submit.
71
+ * Also provisional: the source only ever fills an *already-open* composer inside the live tab, so
72
+ * "the composer is reachable straight from the home page" is an assumption the live checks must
73
+ * confirm; a missing composer is a loud typed refusal, never a silent no-op.
74
+ */
75
+ export const JIKE_POST_SELECTORS = {
76
+ composer: ['form div[contenteditable="true"]'],
77
+ topicInput: ["input[placeholder='未选择圈子']"],
78
+ fileInput: [`${COMPOSER_FORM_SCOPE} input[type="file"]`],
79
+ // The source queries 发送 page-wide (jike.js:372-374); scoped to the composer form per the
80
+ // 0291 design so a like/share 发送 control elsewhere on the page can never win (0291 review P3).
81
+ send: [`${COMPOSER_FORM_SCOPE} button:has-text("发送")`],
82
+ } as const;
83
+
84
+ /** Failure toast words — grounded jike.js:65. */
85
+ const FAILURE_TOAST_RE = /发送失败|发布失败|发送出错|重试/;
86
+ /** Success toast words — grounded jike.js:66. */
87
+ const SUCCESS_TOAST_RE = /发送成功|发布成功|已发布/;
88
+ /** Toast/notice containers — grounded jike.js:61-63. */
89
+ const TOAST_CONTAINERS = '[role="alert"], [role="status"], [class*="toast" i], [class*="message" i]';
90
+ /** Post-link href shapes — grounded jike.js:46-48 (baseline before submit, jike.js:373). */
91
+ const POST_LINK_SELECTOR = 'a[href*="/originalPost/"], a[href*="/post/"]';
92
+ /** Source-observed adapter cap for one post's media (jike.js:246, 329-331); excess is refused, not sliced. */
93
+ const MAX_IMAGES = 9;
94
+ /**
95
+ * Image extensions this channel accepts. **Provisional**: the source itself accepts any `image/*`
96
+ * MIME (jike.js:323-325); the explicit set follows the weibo-pub peer until a live run pins more.
97
+ */
98
+ const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif']);
99
+
100
+ const STEP_TIMEOUT_MS = 30_000;
101
+ const LOGIN_TIMEOUT_MS = 180_000;
102
+ /** Operator recovery command every session failure names — never a cookie, DOM dump or stack. */
103
+ export const JIKE_LOGIN_INSTRUCTION = 'bun plugins/publishings/jike-pub/src/profile-check.ts login';
104
+ /** Body excerpt length compared on readback and on the fresh-post-link signal. */
105
+ const EXCERPT_CHARS = 30;
106
+
107
+ function fail(error: string): Result {
108
+ return ResultSchema.parse({ ok: false, target: TARGET, error: boundedDetail(error) });
109
+ }
110
+
111
+ /** A browser-stage failure: `ok: false` with `stage` (and optional evidence) in the Result metadata. */
112
+ function stageFail(error: string, stage: string, metadata: Record<string, unknown>, evidence?: string): Result {
113
+ return ResultSchema.parse({
114
+ ok: false,
115
+ target: TARGET,
116
+ error: boundedDetail(error),
117
+ metadata: { ...metadata, stage, ...(evidence === undefined ? {} : { evidence: boundedDetail(evidence) }) },
118
+ });
119
+ }
120
+
121
+ /** One short line, bounded: no DOM dump, cookie, token or stack ever reaches the Result. */
122
+ function boundedDetail(detail: string): string {
123
+ return detail.replace(/\s+/g, ' ').trim().slice(0, 300);
124
+ }
125
+
126
+ /** A bounded, single-line rendering of an operator-supplied value for a refusal message. */
127
+ function bounded(value: unknown): string {
128
+ if (Array.isArray(value)) return `${value.length} entr${value.length === 1 ? 'y' : 'ies'}`;
129
+ return String(value).replace(/\s+/g, ' ').slice(0, 100);
130
+ }
131
+
132
+ function errorMessage(error: unknown): string {
133
+ return error instanceof Error ? error.message : String(error);
134
+ }
135
+
136
+ /** Whitespace-insensitive comparison: the contenteditable editor normalizes runs of spaces/newlines. */
137
+ function normalizeText(value: string): string {
138
+ return value.replace(/\s+/g, '');
139
+ }
140
+
141
+ function excerpt(text: string): string {
142
+ return normalizeText(text).slice(0, EXCERPT_CHARS);
143
+ }
144
+
145
+ /**
146
+ * Resolves the requested operation. An undeclared value is refused by name, before any transport —
147
+ * and therefore before any browser — is resolved.
148
+ * @param options - Per-call publish options (`operation`).
149
+ * @param content - Content whose `metadata.operation` is the fallback source.
150
+ * @returns The resolved operation, or the refusal message.
151
+ */
152
+ export function resolveOperation(
153
+ options?: Record<string, unknown>,
154
+ content?: Content,
155
+ ): { operation: JikeOperation; error?: undefined } | { operation?: undefined; error: string } {
156
+ const raw = options?.operation ?? content?.metadata?.operation ?? 'post';
157
+ if (typeof raw !== 'string' || !(JIKE_OPERATIONS as readonly string[]).includes(raw)) {
158
+ return {
159
+ error: `unsupported operation '${bounded(raw)}' for jike: expected one of ${JIKE_OPERATIONS.join(', ')}`,
160
+ };
161
+ }
162
+ return { operation: raw as JikeOperation };
163
+ }
164
+
165
+ /**
166
+ * Resolves the driver from `options.driver ?? 'script'`. This channel ships one driver and no
167
+ * `agent` path, so anything else — including a wrong type — is refused by name before a transport
168
+ * is built.
169
+ * @param options - Per-call publish options (`driver`).
170
+ * @returns The resolved driver, or the refusal message.
171
+ */
172
+ function resolveDriver(
173
+ options?: Record<string, unknown>,
174
+ ): { driver: BrowserDriver; error?: undefined } | { driver?: undefined; error: string } {
175
+ const raw = options?.driver ?? 'script';
176
+ if (raw !== 'script') {
177
+ return {
178
+ error: `unsupported driver '${bounded(raw)}' for jike: expected one of script (the jike-pub browser flow has no agent path)`,
179
+ };
180
+ }
181
+ return { driver: 'script' };
182
+ }
183
+
184
+ /**
185
+ * Jike-owned readiness predicate: no login redirect and the site-owned composer reachable. Shared
186
+ * by the publish preflight and the operator profile CLI, so both apply one gate. The login-redirect
187
+ * regex is provisional (see `JIKE_LOGIN_URL_RE`); the composer carries the real gate.
188
+ */
189
+ export const isJikeAuthenticated: IsAuthenticatedPredicate = async (page) => {
190
+ if (JIKE_LOGIN_URL_RE.test(page.url())) return false;
191
+ return (await trySelectors(page, JIKE_POST_SELECTORS.composer, { visible: false })).found;
192
+ };
193
+
194
+ /**
195
+ * Site-owned config for the shared session check and operator CLI (ADR-032). The publish preflight
196
+ * never forwards `checkHeadedOnly`: it is the operator CLI's setting, and a refusal must never
197
+ * escalate to a headed window on its own.
198
+ */
199
+ export const JIKE_CONFIG: ChannelBrowserConfig = {
200
+ channel: TARGET,
201
+ homeUrl: JIKE_HOME_URL,
202
+ isAuthenticated: isJikeAuthenticated,
203
+ loginTimeoutMs: LOGIN_TIMEOUT_MS,
204
+ checkHeadedOnly: false,
205
+ };
206
+
207
+ /**
208
+ * Resolves the optional image attachments (`options.images ?? metadata.images`) without uploading:
209
+ * Chunk A validates the payload — an array of existing local image files, at most 9 (the source's
210
+ * observed adapter cap, jike.js:246, 329-331; excess is refused, not sliced) — and Chunk B performs
211
+ * the upload at the named seam. The extension set is a provisional peer-parity narrowing (see
212
+ * `IMAGE_EXTS`), not a platform limit.
213
+ */
214
+ async function resolveImages(
215
+ options?: Record<string, unknown>,
216
+ content?: Content,
217
+ ): Promise<{ paths: string[] } | { error: string }> {
218
+ const raw = options?.images ?? content?.metadata?.images;
219
+ if (raw === undefined) return { paths: [] };
220
+ if (!Array.isArray(raw)) {
221
+ return { error: `metadata.images must be an array of local image paths, got '${bounded(raw)}'` };
222
+ }
223
+ if (raw.length > MAX_IMAGES) {
224
+ return {
225
+ error: `metadata.images requests ${raw.length} images, over the source-observed jike cap of ${MAX_IMAGES} (jike.js:329-331); refusing rather than truncating`,
226
+ };
227
+ }
228
+ const paths: string[] = [];
229
+ for (const entry of raw) {
230
+ if (typeof entry !== 'string' || entry.trim().length === 0) {
231
+ return { error: `metadata.images entries must be non-empty local image paths, got '${bounded(entry)}'` };
232
+ }
233
+ if (!IMAGE_EXTS.has(extname(entry).toLowerCase())) {
234
+ return { error: `metadata.images entry must be one of ${[...IMAGE_EXTS].join(', ')}, got '${entry}'` };
235
+ }
236
+ if (!(await createNodeFileSystem().exists(entry))) {
237
+ return { error: `metadata.images file does not exist: '${entry}'` };
238
+ }
239
+ paths.push(entry);
240
+ }
241
+ return { paths };
242
+ }
243
+
244
+ /**
245
+ * Resolves the optional exact circle/topic name (`options.topic ?? metadata.topic`): optional, but
246
+ * present must be a non-empty string (refine 2026-10-14 — the chip match is exact text, so a
247
+ * whitespace-only value is refused, not guessed). Trimmed like the source (jike.js:213).
248
+ */
249
+ function resolveTopic(options?: Record<string, unknown>, content?: Content): { topic?: string; error?: string } {
250
+ const raw = options?.topic ?? content?.metadata?.topic;
251
+ if (raw === undefined) return {};
252
+ if (typeof raw !== 'string' || raw.trim().length === 0) {
253
+ return { error: `metadata.topic must be a non-empty exact circle name, got '${bounded(raw)}'` };
254
+ }
255
+ return { topic: raw.trim() };
256
+ }
257
+
258
+ /**
259
+ * Jike browser transport on the shared rails (ADR-027): a headless session preflight, one headed
260
+ * flow, and success only from an observed signal. `post` is live-only (ADR-022): it refuses
261
+ * without `publishStatus: public` because 即刻 has no evidenced persistent draft flow.
262
+ */
263
+ export class JikeBrowserTransport implements PublishTransport {
264
+ public readonly kind = 'browser';
265
+ public readonly driver: BrowserDriver;
266
+ private readonly operation: JikeOperation;
267
+ private readonly launcher: BrowserLauncher;
268
+ private readonly stepTimeoutMs: number;
269
+ constructor(
270
+ operation: JikeOperation = 'post',
271
+ driver: BrowserDriver = 'script',
272
+ deps: { launcher?: BrowserLauncher; stepTimeoutMs?: number } = {},
273
+ ) {
274
+ this.operation = operation;
275
+ this.driver = driver;
276
+ this.launcher = deps.launcher ?? kkBrowserLauncher;
277
+ this.stepTimeoutMs = deps.stepTimeoutMs ?? STEP_TIMEOUT_MS;
278
+ }
279
+
280
+ /**
281
+ * Publishes one Content as a live dynamic post.
282
+ * @param payload - Target content plus per-call options.
283
+ * @returns The publish `Result`, `ok: true` only from an observed site signal.
284
+ */
285
+ public async publish(payload: PublishTransportPayload): Promise<Result> {
286
+ // Refusal order (all pre-browser): driver → publishStatus → body text → images → topic →
287
+ // session preflight. (The operation is resolved and refused in `resolveOperation` before
288
+ // any transport exists.)
289
+ if (this.driver !== 'script') {
290
+ return fail(
291
+ `unsupported driver '${bounded(this.driver)}' for jike: expected one of script (the jike-pub browser flow has no agent path)`,
292
+ );
293
+ }
294
+ const requested = payload.content.metadata?.publishStatus;
295
+ if (requested === undefined || requested === 'draft') {
296
+ return fail(
297
+ `jike has no evidenced draft flow: publishStatus ${requested === undefined ? 'is absent' : "'draft'"} refuses before the browser — set publishStatus: public to publish live`,
298
+ );
299
+ }
300
+ if (requested !== 'public') {
301
+ return fail(`unknown publishStatus '${bounded(requested)}' for jike: only 'public' publishes`);
302
+ }
303
+ const text = payload.content.body.trim();
304
+ if (text.length === 0) {
305
+ return fail(
306
+ 'jike dynamic requires non-empty Content.body text (the composer has no independent title field, jike.js:312-315)',
307
+ );
308
+ }
309
+ const images = await resolveImages(payload.options, payload.content);
310
+ if ('error' in images) return fail(images.error);
311
+ const topic = resolveTopic(payload.options, payload.content);
312
+ if (topic.error !== undefined) return fail(topic.error);
313
+
314
+ const override = typeof payload.options?.profileDir === 'string' ? payload.options.profileDir : undefined;
315
+ const profileDir = resolveChannelProfileDir(TARGET, override);
316
+
317
+ // ADR-025 preflight: an unauthenticated or absent profile must never produce a headed visit,
318
+ // so the read-only headless check runs first and a refusal stays a fast refusal.
319
+ const session = await checkChannelSession(JIKE_CONFIG, {
320
+ profileDir,
321
+ launcher: this.launcher,
322
+ headedFallback: false,
323
+ timeoutMs: this.stepTimeoutMs,
324
+ });
325
+ if (session.status !== 'verified') {
326
+ // The recovery command leads the message: `fail()` bounds the line, and the operator's
327
+ // next action must never be the part that gets truncated.
328
+ return fail(
329
+ `${TARGET} session preflight refused — run: ${JIKE_LOGIN_INSTRUCTION} — detail: ${session.detail}`,
330
+ );
331
+ }
332
+
333
+ try {
334
+ return await withProfilePage(
335
+ {
336
+ profileDir,
337
+ url: JIKE_HOME_URL,
338
+ isAuthenticated: isJikeAuthenticated,
339
+ // Headed by default; the headless preflight above is the unattended pass and
340
+ // `options.headless === true` the opt-in.
341
+ headless: payload.options?.headless === true,
342
+ launcher: this.launcher,
343
+ // The session is already preflight-verified, so the post-goto confirmation is a
344
+ // bounded step like every other one, not the 180s interactive login wait.
345
+ timeoutMs: this.stepTimeoutMs,
346
+ },
347
+ async ({ page }) => this.runPostFlow(page, text, topic.topic, images.paths, profileDir),
348
+ );
349
+ } catch (error) {
350
+ // Same ordering rule as the preflight refusal: the recovery command is never the tail
351
+ // that a bounded message drops.
352
+ const detail = errorMessage(error);
353
+ return fail(
354
+ needsLogin(error)
355
+ ? `${TARGET} session failed — run: ${JIKE_LOGIN_INSTRUCTION} — detail: ${detail}`
356
+ : detail,
357
+ );
358
+ }
359
+ }
360
+
361
+ /**
362
+ * The dynamic-post flow: fill → readback → topic chip → image upload → baselines → submit →
363
+ * confirmation (topics before images per the Chunk B contract; the source orders media first,
364
+ * jike.js:320-363). Success only from a fresh strong signal (jike.js:59-79): a success toast,
365
+ * or a new `/originalPost/|/post/` link whose card read back the body. The source's weak
366
+ * signals (editor cleared, composer closed — jike.js:81-85) alone never report success.
367
+ */
368
+ private async runPostFlow(
369
+ page: Page,
370
+ text: string,
371
+ topic: string | undefined,
372
+ images: readonly string[],
373
+ profileDir: string,
374
+ ): Promise<Result> {
375
+ const metadata = { operation: this.operation, driver: this.driver, publishStatus: 'public', profileDir };
376
+
377
+ // Composer. A missing composer is the typed stage failure — the source reads it as a
378
+ // logged-out session (「可能未登录」, jike.js:309).
379
+ let composerSelector: string;
380
+ try {
381
+ composerSelector = await resolveSelector(page, JIKE_POST_SELECTORS.composer, {
382
+ timeoutMs: this.stepTimeoutMs,
383
+ label: 'jike composer',
384
+ });
385
+ } catch {
386
+ return stageFail(
387
+ `jike composer not found on ${JIKE_HOME_URL} (session may be logged out) — run: ${JIKE_LOGIN_INSTRUCTION}`,
388
+ 'composer',
389
+ metadata,
390
+ );
391
+ }
392
+ await page.locator(composerSelector).first().fill(text);
393
+
394
+ // Readback before anything else: a field the editor dropped must never be submitted
395
+ // (source waits out the paste readback, jike.js:316-319).
396
+ const readback = await this.requireReadback(page, composerSelector, text);
397
+ if (!readback.ok) return stageFail(readback.error, 'text-readback', metadata);
398
+
399
+ // Topic chip: exact committed match only — a lookalike or first-item chip never substitutes
400
+ // (refine 2026-10-14), so any miss refuses before send.
401
+ if (topic !== undefined) {
402
+ const chip = await this.commitTopic(page, topic);
403
+ if (!chip.ok) return stageFail(chip.error, 'topic-commit', metadata);
404
+ }
405
+
406
+ // Image uploads: caller order, confirmed landed; a failed or unconfirmed upload refuses
407
+ // before submit — never a silent text-only post while images were requested.
408
+ if (images.length > 0) {
409
+ const uploaded = await this.uploadImages(page, images);
410
+ if (!uploaded.ok) return stageFail(uploaded.error, 'image-upload', metadata);
411
+ }
412
+
413
+ // Baselines before submit: only fresh toast and fresh post-link signals can confirm
414
+ // (beforeLinks grounded jike.js:373; toast baselining per the shared strong-baseline rule).
415
+ const toastsBefore = new Set(await this.readToasts(page));
416
+ const beforeLinks = await this.readPostLinks(page);
417
+
418
+ // Send. Grounded: first `button` whose text includes 发送 (jike.js:368-370), waited-while-
419
+ // disabled (jike.js:371-373; the 10×3s wait becomes the step timeout here).
420
+ const sendSelector = await resolveSelector(page, JIKE_POST_SELECTORS.send, {
421
+ timeoutMs: this.stepTimeoutMs,
422
+ label: 'jike 发送 button',
423
+ });
424
+ const send = page.locator(sendSelector).first();
425
+ const enabled = await expectOutcome(async () => ((await send.isEnabled()) ? 'enabled' : null), {
426
+ timeoutMs: this.stepTimeoutMs,
427
+ label: 'jike 发送 button enabled',
428
+ });
429
+ if (!enabled.ok)
430
+ return stageFail(`jike 发送 button never became clickable — ${enabled.error}`, 'send-enabled', metadata);
431
+ await send.click();
432
+
433
+ // Confirmation (source timeout 30s, jike.js:375-377): a failure toast fails fast; otherwise
434
+ // the fresh post link is preferred so the Result can carry the created post's url/id, with
435
+ // the fresh success toast as the remaining strong receipt.
436
+ const confirmed = await expectOutcome(() => this.probeConfirmation(page, text, beforeLinks, toastsBefore), {
437
+ timeoutMs: this.stepTimeoutMs,
438
+ label: 'jike publish confirmation',
439
+ });
440
+ if (!confirmed.ok) {
441
+ return stageFail(`jike publish not confirmed — ${confirmed.error}`, 'confirmation', metadata);
442
+ }
443
+ if (confirmed.evidence.startsWith('failure-toast: ')) {
444
+ return stageFail(
445
+ `jike send failed — site reported: ${confirmed.evidence.slice('failure-toast: '.length)}`,
446
+ 'failure-toast',
447
+ metadata,
448
+ );
449
+ }
450
+ const url = confirmed.evidence.startsWith('post-url: ')
451
+ ? confirmed.evidence.slice('post-url: '.length)
452
+ : undefined;
453
+ return ResultSchema.parse({
454
+ ok: true,
455
+ target: TARGET,
456
+ ...(url === undefined ? {} : { id: postIdFrom(url), url }),
457
+ message: `jike dynamic published live in profile ${profileDir}${url === undefined ? ' (success-toast receipt; no post URL read back)' : ''}`,
458
+ metadata,
459
+ });
460
+ }
461
+
462
+ /** Polls until the editor shows the submitted text; a field the editor dropped is a refusal. */
463
+ private async requireReadback(
464
+ page: Page,
465
+ selector: string,
466
+ expected: string,
467
+ ): Promise<{ ok: true; evidence: string } | { ok: false; error: string }> {
468
+ const wanted = excerpt(expected);
469
+ return expectOutcome(
470
+ async () => {
471
+ const current = normalizeText(String(await page.evaluate(editorTextScript(selector))));
472
+ if (wanted.length === 0 || !current.includes(wanted)) return null;
473
+ return current.slice(0, 60);
474
+ },
475
+ { timeoutMs: this.stepTimeoutMs, label: 'jike composer text readback' },
476
+ );
477
+ }
478
+
479
+ /**
480
+ * Commits the requested topic chip or refuses: opens the circle input (jike.js:207-208), fills
481
+ * it (native fill replaces the source's manual change/input dispatch, jike.js:209-211), then
482
+ * polls for an exact-text chip inside `div[name="topic"] div[tabindex="0"]` (jike.js:219-225).
483
+ * The source's first-item and structural-traversal fallbacks (jike.js:226-247) are deliberately
484
+ * not ported (refine 2026-10-14): an unconfirmed circle refuses the submit.
485
+ */
486
+ private async commitTopic(
487
+ page: Page,
488
+ topic: string,
489
+ ): Promise<{ ok: true; evidence: string } | { ok: false; error: string }> {
490
+ try {
491
+ const inputSelector = await resolveSelector(page, JIKE_POST_SELECTORS.topicInput, {
492
+ timeoutMs: this.stepTimeoutMs,
493
+ label: 'jike topic input',
494
+ });
495
+ const input = page.locator(inputSelector).first();
496
+ await input.click(); // opens the circle dropdown (jike.js:208)
497
+ await input.fill(topic);
498
+ const committed = await expectOutcome(
499
+ async () => {
500
+ const hit = String(await page.evaluate(commitTopicScript(topic))).trim();
501
+ return hit.length > 0 ? hit : null;
502
+ },
503
+ { timeoutMs: this.stepTimeoutMs, label: 'jike topic chip exact match' },
504
+ );
505
+ if (!committed.ok) {
506
+ return {
507
+ ok: false,
508
+ error: `jike did not offer an exact circle chip 「${bounded(topic)}」 (div[name="topic"], jike.js:219-225); refusing to submit without the requested circle`,
509
+ };
510
+ }
511
+ return { ok: true, evidence: committed.evidence };
512
+ } catch (error) {
513
+ return { ok: false, error: `jike topic commit failed — ${errorMessage(error)}` };
514
+ }
515
+ }
516
+
517
+ /**
518
+ * Uploads the caller-ordered images through the composer-scoped file input and confirms they
519
+ * landed. The input is provisional (see `JIKE_POST_SELECTORS`), so an absent input is a typed
520
+ * refusal, never a text-only submit. Landing uses the source's own predicate: the
521
+ * composer-scope media-node count must rise by the uploaded count (jike.js:46-52, 287-291),
522
+ * after the 正在上传 progress status settles (jike.js:277-286).
523
+ */
524
+ private async uploadImages(
525
+ page: Page,
526
+ images: readonly string[],
527
+ ): Promise<{ ok: true; evidence: string } | { ok: false; error: string }> {
528
+ try {
529
+ const before = Number(await page.evaluate(mediaEvidenceScript()));
530
+ // uploadFile (harness) covers one file; page.setInputFiles(paths) carries caller order
531
+ // for several (refine 2026-10-14: native helpers, no vendor clipboard bridge).
532
+ const [first] = images;
533
+ const inputSelector = JIKE_POST_SELECTORS.fileInput[0];
534
+ if (images.length === 1 && first !== undefined) {
535
+ await uploadFile(page, inputSelector, first, { timeoutMs: this.stepTimeoutMs });
536
+ } else {
537
+ await page.setInputFiles(inputSelector, [...images]);
538
+ }
539
+ const settled = await expectOutcome(
540
+ async () =>
541
+ String(await page.evaluate(uploadingStatusScript())).length > 0 ? null : 'uploads settled',
542
+ { timeoutMs: this.stepTimeoutMs, label: 'jike image upload settle (正在上传)' },
543
+ );
544
+ if (!settled.ok) return { ok: false, error: `jike image upload did not settle — ${settled.error}` };
545
+ // Source pins this preview wait to 5s @ 250ms (jike.js:288-291); mirrored, not the step timeout.
546
+ const confirmed = await expectOutcome(
547
+ async () => {
548
+ const count = Number(await page.evaluate(mediaEvidenceScript()));
549
+ return count >= before + images.length ? `media nodes ${before} → ${count}` : null;
550
+ },
551
+ { timeoutMs: 5_000, pollMs: 250, label: 'jike image preview evidence' },
552
+ );
553
+ if (!confirmed.ok) {
554
+ return {
555
+ ok: false,
556
+ error: `jike composer preview never reached ${before + images.length} media nodes (baseline ${before}) for ${images.length} uploaded image(s)`,
557
+ };
558
+ }
559
+ return { ok: true, evidence: confirmed.evidence };
560
+ } catch (error) {
561
+ return { ok: false, error: `jike image upload failed — ${errorMessage(error)}` };
562
+ }
563
+ }
564
+
565
+ /** Visible toast texts (grounded containers, jike.js:61-63), used as the pre-send baseline. */
566
+ private async readToasts(page: Page): Promise<readonly string[]> {
567
+ return parseStringArray(await page.evaluate(toastTextsScript()));
568
+ }
569
+
570
+ /** A toast carrying `pattern` that was not already on screen before the send. */
571
+ private async findFreshToast(page: Page, pattern: RegExp, before: ReadonlySet<string>): Promise<string | null> {
572
+ return (await this.readToasts(page)).find((text) => !before.has(text) && pattern.test(text)) ?? null;
573
+ }
574
+
575
+ /** Post-link hrefs currently on the page — the submit-time baseline (grounded jike.js:373). */
576
+ private async readPostLinks(page: Page): Promise<readonly string[]> {
577
+ return parseStringArray(await page.evaluate(postLinksScript()));
578
+ }
579
+
580
+ /**
581
+ * One confirmation probe per poll: failure toast (grounded words, jike.js:65) fails fast;
582
+ * otherwise a fresh `/originalPost/|/post/` link whose card contains the body excerpt
583
+ * (grounded jike.js:68-79) or a fresh success toast (jike.js:66-67).
584
+ */
585
+ private async probeConfirmation(
586
+ page: Page,
587
+ text: string,
588
+ beforeLinks: readonly string[],
589
+ toastsBefore: ReadonlySet<string>,
590
+ ): Promise<string | null> {
591
+ const failure = await this.findFreshToast(page, FAILURE_TOAST_RE, toastsBefore);
592
+ if (failure !== null) return `failure-toast: ${failure}`;
593
+ const link = String(await page.evaluate(freshPostLinkScript(beforeLinks, excerpt(text)))).trim();
594
+ if (link.length > 0) return `post-url: ${link}`;
595
+ const success = await this.findFreshToast(page, SUCCESS_TOAST_RE, toastsBefore);
596
+ return success === null ? null : `success-toast: ${success}`;
597
+ }
598
+ }
599
+
600
+ /** Session failures whose one-liner must name the recovery command. */
601
+ function needsLogin(error: unknown): boolean {
602
+ return error instanceof LoginTimeoutError || error instanceof BrowserProfileMissingError;
603
+ }
604
+
605
+ /** Parses a JSON string array from a page probe, tolerating anything else as "no snapshot". */
606
+ function parseStringArray(raw: unknown): readonly string[] {
607
+ try {
608
+ const parsed: unknown = JSON.parse(String(raw));
609
+ return Array.isArray(parsed) ? parsed.map((entry) => String(entry)) : [];
610
+ } catch {
611
+ return [];
612
+ }
613
+ }
614
+
615
+ /** Post id from an observed jike post URL (href shapes grounded jike.js:46-48; extraction is derived). */
616
+ function postIdFrom(url: string): string | undefined {
617
+ return /\/(?:originalPost|post)\/([A-Za-z0-9_-]+)/.exec(url)?.[1];
618
+ }
619
+
620
+ /** Reads the composer's text back (`textContent` — the grounded control is a contenteditable div). */
621
+ function editorTextScript(selector: string): string {
622
+ return `(() => { // kk-jike-editor-text
623
+ const el = document.querySelector(${JSON.stringify(selector)});
624
+ return el === null ? '' : String(el.textContent ?? '').trim();
625
+ })()`;
626
+ }
627
+
628
+ /**
629
+ * Exact-match commit of the requested circle chip — the strict path of the source's selectTopic
630
+ * (jike.js:219-225): click the one chip whose trimmed text equals the request. `''` means "not
631
+ * offered" (container missing or no exact chip); the first-item and structural-traversal fallbacks
632
+ * (jike.js:226-247) are intentionally absent (refine 2026-10-14).
633
+ */
634
+ function commitTopicScript(topic: string): string {
635
+ return `(() => { // kk-jike-topic-commit
636
+ const wanted = ${JSON.stringify(topic)};
637
+ const container = document.querySelector('div[name="topic"]');
638
+ if (container === null) return '';
639
+ for (const item of container.querySelectorAll('div[tabindex="0"]')) {
640
+ const text = (item.textContent || '').trim();
641
+ if (text === wanted) { item.click(); return text; }
642
+ }
643
+ return '';
644
+ })()`;
645
+ }
646
+
647
+ /**
648
+ * Media-node count inside the composer form scope — the source's landed-preview predicate
649
+ * (`mediaEvidenceCount`, jike.js:46-52) with its scope choice (`editor.closest("form") ||
650
+ * document`, jike.js:335); the preview-confirmed comparison is jike.js:287-291.
651
+ */
652
+ function mediaEvidenceScript(): string {
653
+ return `(() => { // kk-jike-media-evidence
654
+ const scope = document.querySelector('${COMPOSER_FORM_SCOPE}') || document;
655
+ return scope.querySelectorAll('img[src], video[src], video source[src], [style*="background-image"]').length;
656
+ })()`;
657
+ }
658
+
659
+ /** The site's upload-progress status text; `''` when settled — waited out before submit (jike.js:277-286). */
660
+ function uploadingStatusScript(): string {
661
+ return `(() => { // kk-jike-uploading-status
662
+ const uploading = Array.from(document.querySelectorAll('div[role="status"]')).find(
663
+ (el) => (el.textContent || '').includes('正在上传'),
664
+ );
665
+ return uploading === null ? '' : (uploading.textContent || '').trim();
666
+ })()`;
667
+ }
668
+
669
+ /** Visible toast texts, used as the pre-send baseline and as the fresh-signal read (jike.js:61-63). */
670
+ function toastTextsScript(): string {
671
+ return `(() => { // kk-jike-toasts
672
+ const nodes = document.querySelectorAll('${TOAST_CONTAINERS}');
673
+ const texts = [];
674
+ for (const node of nodes) {
675
+ const text = (node.textContent || '').trim();
676
+ if (text.length > 0) texts.push(text);
677
+ }
678
+ return JSON.stringify(texts);
679
+ })()`;
680
+ }
681
+
682
+ /** All post-link hrefs on the page, for the submit-time baseline (grounded jike.js:46-48, 373). */
683
+ function postLinksScript(): string {
684
+ return `(() => { // kk-jike-post-links
685
+ return JSON.stringify(Array.from(document.querySelectorAll('${POST_LINK_SELECTOR}')).map((a) => a.href).filter(Boolean));
686
+ })()`;
687
+ }
688
+
689
+ /**
690
+ * The first post link that was not on the page before the send and whose containing card read back
691
+ * the body excerpt (grounded probe jike.js:68-79). `''` means "no signal yet".
692
+ */
693
+ function freshPostLinkScript(before: readonly string[], wanted: string): string {
694
+ return `(() => { // kk-jike-fresh-post-link
695
+ const seen = new Set(${JSON.stringify(before)});
696
+ const wanted = ${JSON.stringify(wanted)};
697
+ if (wanted === '') return '';
698
+ for (const anchor of document.querySelectorAll('${POST_LINK_SELECTOR}')) {
699
+ if (seen.has(anchor.href)) continue;
700
+ const card = anchor.closest('article, section, li, div');
701
+ const cardText = (card === null ? '' : card.textContent || '').replace(/\\s+/g, '');
702
+ if (cardText.includes(wanted)) return anchor.href;
703
+ }
704
+ return '';
705
+ })()`;
706
+ }
707
+
708
+ /**
709
+ * Resolves the channel transport: the publish-harness fake under
710
+ * `KNOWLEDGE_KIT_PUBLISH_TRANSPORT=fake` (offline, no profile, no browser), otherwise the real
711
+ * Jike browser transport.
712
+ * @param operation - Resolved operation (`post`).
713
+ * @param driver - Resolved driver; only `script` is declared.
714
+ * @returns The publish transport for this channel.
715
+ */
716
+ export function getPublishTransport(
717
+ operation: JikeOperation = 'post',
718
+ driver: BrowserDriver = 'script',
719
+ ): PublishTransport {
720
+ if (process.env.KNOWLEDGE_KIT_PUBLISH_TRANSPORT === 'fake') return new FakeBrowserTransport('script');
721
+ return new JikeBrowserTransport(operation, driver);
722
+ }
723
+
724
+ /** Writes the Result to `--out`, creating its directory when the caller named a nested path. */
725
+ async function writePublishResult(outputPath: string, result: Result): Promise<void> {
726
+ const outDir = dirname(outputPath);
727
+ if (outDir && outDir !== '.') await createNodeFileSystem().ensureDir(outDir);
728
+ await createNodeFileSystem().writeFile(outputPath, JSON.stringify(result, null, 2));
729
+ }
730
+
731
+ /**
732
+ * The plugin's `--in/--out` contract: validate the Content, resolve **and refuse** the operation and
733
+ * the driver before any transport exists, then publish through the injected or configured transport.
734
+ * @param inputPath - Path to the Content JSON.
735
+ * @param outputPath - Path the Result JSON is written to, on success and on refusal.
736
+ * @param transport - Optional transport injection for tests and offline runs.
737
+ */
738
+ export async function processPublishIO(
739
+ inputPath: string,
740
+ outputPath: string,
741
+ transport?: PublishTransport,
742
+ ): Promise<void> {
743
+ await createNodeFileSystem().deleteFile(outputPath);
744
+ let content: Content;
745
+ let options: Record<string, unknown> | undefined;
746
+ try {
747
+ const raw = JSON.parse(await createNodeFileSystem().readFile(inputPath)) as Record<string, unknown>;
748
+ content = ContentSchema.parse(raw);
749
+ if (raw.options !== undefined && typeof raw.options === 'object' && raw.options !== null) {
750
+ options = raw.options as Record<string, unknown>;
751
+ }
752
+ } catch (error: unknown) {
753
+ const message = boundedDetail(`Invalid Content input: ${errorMessage(error)}`);
754
+ await writePublishResult(outputPath, fail(message));
755
+ throw new Error(message);
756
+ }
757
+ const mode = resolveOperation(options, content);
758
+ if (mode.error !== undefined) {
759
+ await writePublishResult(outputPath, fail(mode.error));
760
+ throw new Error(mode.error);
761
+ }
762
+ const driverMode = resolveDriver(options);
763
+ if (driverMode.error !== undefined) {
764
+ await writePublishResult(outputPath, fail(driverMode.error));
765
+ throw new Error(driverMode.error);
766
+ }
767
+ const resolved = transport ?? getPublishTransport(mode.operation, driverMode.driver);
768
+ const result = ResultSchema.parse(await resolved.publish({ content, options }));
769
+ await writePublishResult(outputPath, result);
770
+ if (!result.ok) throw new Error(result.error ?? 'Publish failed');
771
+ }
772
+
773
+ /**
774
+ * CLI entry point: `--in <content.json> --out <result.json>`.
775
+ * @returns Process exit code (0 published, 1 refused or invalid arguments).
776
+ */
777
+ export async function main(): Promise<number> {
778
+ let values: { in?: string; out?: string };
779
+ try {
780
+ ({ values } = parseArgs({ options: { in: { type: 'string' }, out: { type: 'string' } } }));
781
+ } catch (error: unknown) {
782
+ echoError(`Invalid arguments: ${errorMessage(error)}`);
783
+ return 1;
784
+ }
785
+ if (!values.in || !values.out) {
786
+ echoError('Missing required arguments: --in and --out');
787
+ return 1;
788
+ }
789
+ try {
790
+ await processPublishIO(values.in, values.out);
791
+ return 0;
792
+ } catch (error: unknown) {
793
+ echoError(`jike-pub failed: ${boundedDetail(errorMessage(error))}`);
794
+ return 1;
795
+ }
796
+ }
797
+
798
+ if (import.meta.main) {
799
+ process.exit(await main());
800
+ }