@gobing-ai/knowledge-kit 0.0.35 → 0.0.36

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 (122) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +1 -1
  3. package/dist/board/collect/index.css +42 -0
  4. package/dist/board/collect/index.js +787 -0
  5. package/dist/board/daily-voice/index.css +13 -0
  6. package/dist/board/daily-voice/index.js +902 -0
  7. package/dist/board/itc/index.css +14 -0
  8. package/dist/board/itc/index.js +1026 -0
  9. package/dist/index.js +11768 -9249
  10. package/package.json +10 -9
  11. package/plugins/generations/content-gen/dist/index.js +1015 -10814
  12. package/plugins/generations/content-gen/package.json +1 -1
  13. package/plugins/generations/core-facts-gen/dist/index.js +1002 -10801
  14. package/plugins/generations/core-facts-gen/package.json +1 -1
  15. package/plugins/generations/daily-article-gen/dist/index.js +1004 -10803
  16. package/plugins/generations/daily-article-gen/package.json +1 -1
  17. package/plugins/generations/dailynews-gen/dist/index.js +1092 -10891
  18. package/plugins/generations/dailynews-gen/package.json +1 -1
  19. package/plugins/generations/episode-plan-gen/dist/index.js +1124 -10923
  20. package/plugins/generations/episode-plan-gen/package.json +1 -1
  21. package/plugins/generations/gemini-tts/dist/index.js +1104 -10903
  22. package/plugins/generations/gemini-tts/package.json +1 -1
  23. package/plugins/generations/image-gen/dist/index.js +1239 -11035
  24. package/plugins/generations/image-gen/package.json +1 -1
  25. package/plugins/generations/news-report-gen/dist/index.js +1048 -10847
  26. package/plugins/generations/news-report-gen/package.json +1 -1
  27. package/plugins/generations/voice-gen/dist/index.js +1062 -10861
  28. package/plugins/generations/voice-gen/package.json +1 -1
  29. package/plugins/generations/voxcpm-tts/README.md +72 -0
  30. package/plugins/generations/voxcpm-tts/dist/index.js +14006 -0
  31. package/plugins/generations/voxcpm-tts/package.json +16 -0
  32. package/plugins/generations/voxcpm-tts/plugin.json +6 -0
  33. package/plugins/generations/voxcpm-tts/profiles.json +10 -0
  34. package/plugins/generations/voxcpm-tts/pyproject.toml +9 -0
  35. package/plugins/generations/voxcpm-tts/python/worker.py +353 -0
  36. package/plugins/generations/voxcpm-tts/src/index.ts +42 -0
  37. package/plugins/generations/voxcpm-tts/src/voxcpm-client.ts +765 -0
  38. package/plugins/generations/voxcpm-tts/tsconfig.json +8 -0
  39. package/plugins/generations/voxcpm-tts/uv.lock +2571 -0
  40. package/plugins/ingestions/aihot-ingest/dist/index.js +1081 -10628
  41. package/plugins/ingestions/aihot-ingest/package.json +1 -1
  42. package/plugins/ingestions/horizon-ingest/dist/index.js +1013 -10812
  43. package/plugins/ingestions/horizon-ingest/package.json +1 -1
  44. package/plugins/ingestions/karakeep-local/dist/index.js +689 -10774
  45. package/plugins/ingestions/karakeep-local/package.json +1 -1
  46. package/plugins/ingestions/last30days-ingest/dist/index.js +1011 -10810
  47. package/plugins/ingestions/last30days-ingest/package.json +1 -1
  48. package/plugins/ingestions/web-search/dist/index.js +1250 -11033
  49. package/plugins/ingestions/web-search/package.json +1 -1
  50. package/plugins/kk/README.md +29 -2
  51. package/plugins/kk/plugin.json +1 -1
  52. package/plugins/kk/schedules/cron.tmpl +9 -1
  53. package/plugins/kk/schedules/kk-serve.launchd.plist.tmpl +79 -0
  54. package/plugins/kk/schedules/launchd.plist.tmpl +11 -1
  55. package/plugins/kk/scripts/daily-stages.ts +428 -46
  56. package/plugins/kk/scripts/kk-workflow-stages.ts +76 -146
  57. package/plugins/kk/skills/publish/SKILL.md +8 -4
  58. package/plugins/kk/workflows/feature-verification.yaml +93 -0
  59. package/plugins/kk/workflows/judge-gated-publish-example.yaml +1 -0
  60. package/plugins/kk/workflows/kk-collect.yaml +43 -11
  61. package/plugins/kk/workflows/kk-daily-ai-voice.yaml +4 -2
  62. package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +86 -15
  63. package/plugins/kk/workflows/kk-daily-safety.yaml +15 -2
  64. package/plugins/kk/workflows/kk-itc.yaml +8 -1
  65. package/plugins/kk/workflows/kk-solo-podcast.yaml +2 -0
  66. package/plugins/publishings/emdash-pub/dist/index.js +987 -10814
  67. package/plugins/publishings/emdash-pub/package.json +1 -1
  68. package/plugins/publishings/infoq-pub/dist/index.js +1928 -11444
  69. package/plugins/publishings/infoq-pub/dist/profile-check.js +1677 -11199
  70. package/plugins/publishings/infoq-pub/package.json +1 -1
  71. package/plugins/publishings/infoq-pub/plugin.json +5 -1
  72. package/plugins/publishings/juejin-pub/dist/index.js +1940 -11456
  73. package/plugins/publishings/juejin-pub/dist/profile-check.js +1677 -11199
  74. package/plugins/publishings/juejin-pub/package.json +1 -1
  75. package/plugins/publishings/juejin-pub/plugin.json +5 -1
  76. package/plugins/publishings/medium-pub/dist/index.js +978 -10805
  77. package/plugins/publishings/medium-pub/package.json +1 -1
  78. package/plugins/publishings/podcast-pub/dist/index.js +1016 -10843
  79. package/plugins/publishings/podcast-pub/package.json +1 -1
  80. package/plugins/publishings/qiita-pub/dist/index.js +976 -10803
  81. package/plugins/publishings/qiita-pub/package.json +1 -1
  82. package/plugins/publishings/substack-pub/dist/index.js +1932 -11448
  83. package/plugins/publishings/substack-pub/dist/profile-check.js +1698 -11200
  84. package/plugins/publishings/substack-pub/package.json +1 -1
  85. package/plugins/publishings/substack-pub/plugin.json +4 -1
  86. package/plugins/publishings/substack-pub/src/profile-check.ts +36 -3
  87. package/plugins/publishings/surfdash-pub/dist/index.js +1151 -10834
  88. package/plugins/publishings/surfdash-pub/package.json +1 -1
  89. package/plugins/publishings/surfdash-pub/src/index.ts +137 -15
  90. package/plugins/publishings/surfdash-pub/src/patch.ts +144 -0
  91. package/plugins/publishings/threads-pub/dist/index.js +12394 -0
  92. package/plugins/publishings/threads-pub/package.json +17 -0
  93. package/plugins/publishings/threads-pub/plugin.json +7 -0
  94. package/plugins/publishings/threads-pub/src/index.ts +340 -0
  95. package/plugins/publishings/threads-pub/tsconfig.json +4 -0
  96. package/plugins/publishings/wechatmp-pub/dist/index.js +2508 -11695
  97. package/plugins/publishings/wechatmp-pub/dist/profile-check.js +1656 -11188
  98. package/plugins/publishings/wechatmp-pub/package.json +1 -1
  99. package/plugins/publishings/wechatmp-pub/plugin.json +5 -1
  100. package/plugins/publishings/wechatmp-pub/src/enrich.ts +244 -71
  101. package/plugins/publishings/wechatmp-pub/src/flow.ts +412 -96
  102. package/plugins/publishings/wechatmp-pub/src/index.ts +24 -3
  103. package/plugins/publishings/wechatmp-pub/src/page.ts +16 -0
  104. package/plugins/publishings/wechatmp-pub/src/profile-check.ts +40 -4
  105. package/plugins/publishings/weibo-pub/dist/index.js +21345 -0
  106. package/plugins/publishings/weibo-pub/dist/profile-check.js +20817 -0
  107. package/plugins/publishings/weibo-pub/package.json +19 -0
  108. package/plugins/publishings/weibo-pub/plugin.json +12 -0
  109. package/plugins/publishings/weibo-pub/src/index.ts +868 -0
  110. package/plugins/publishings/weibo-pub/src/profile-check.ts +20 -0
  111. package/plugins/publishings/weibo-pub/tsconfig.json +4 -0
  112. package/plugins/publishings/x-pub/dist/index.js +1948 -11440
  113. package/plugins/publishings/x-pub/dist/profile-check.js +1677 -11199
  114. package/plugins/publishings/x-pub/package.json +1 -1
  115. package/plugins/publishings/x-pub/plugin.json +5 -1
  116. package/plugins/publishings/xhs-pub/dist/index.js +1993 -11509
  117. package/plugins/publishings/xhs-pub/dist/profile-check.js +1697 -11212
  118. package/plugins/publishings/xhs-pub/package.json +1 -1
  119. package/plugins/publishings/xhs-pub/plugin.json +5 -1
  120. package/plugins/publishings/xhs-pub/src/profile-check.ts +72 -2
  121. package/plugins/publishings/zenn-pub/dist/index.js +988 -10815
  122. package/plugins/publishings/zenn-pub/package.json +1 -1
@@ -0,0 +1,868 @@
1
+ import { dirname, extname, isAbsolute } from 'node:path';
2
+ import { parseArgs } from 'node:util';
3
+ import { type Content, ContentSchema, type Result, ResultSchema } from '@gobing-ai/kk-core';
4
+ import {
5
+ type BrowserDriver,
6
+ FakeBrowserTransport,
7
+ type PublishTransport,
8
+ type PublishTransportPayload,
9
+ trySelectors,
10
+ } from '@gobing-ai/publish-harness';
11
+ import {
12
+ type ChannelBrowserConfig,
13
+ checkChannelSession,
14
+ expectOutcome,
15
+ kkBrowserLauncher,
16
+ markdownToHtml,
17
+ pasteHtml,
18
+ resolveChannelProfileDir,
19
+ resolveSelector,
20
+ uploadFile,
21
+ } from '@gobing-ai/publish-harness/browser';
22
+ import {
23
+ type BrowserLauncher,
24
+ BrowserProfileMissingError,
25
+ type IsAuthenticatedPredicate,
26
+ LoginTimeoutError,
27
+ withProfilePage,
28
+ } from '@gobing-ai/ts-browser-automation';
29
+ import { createNodeFileSystem } from '@gobing-ai/ts-runtime';
30
+ import { echoError } from '@gobing-ai/ts-utils';
31
+ import type { Page } from 'playwright';
32
+
33
+ /** Site-owned target slug; also the profile channel key (`WEIBO_PROFILE_DIR`). */
34
+ const TARGET = 'weibo';
35
+ /** Weibo home: the post composer page and the readiness page for a session check. */
36
+ export const WEIBO_HOME_URL = 'https://weibo.com';
37
+ /**
38
+ * 头条文章 editor entry. **Measured 2026-10-12** (task 0246): this route still redirects to
39
+ * `https://weibo.com/` for an account **without the 头条文章 entitlement** — the throwaway account
40
+ * used for the live walk had none (no 文章/写文章 entry in the creator centre either), so the article
41
+ * leg stays entitlement-gated. See `docs/design/weibo-ui-discovery.md`.
42
+ */
43
+ export const WEIBO_ARTICLE_URL = 'https://card.weibo.com/article/v3/editor';
44
+ /**
45
+ * Post composer entry. **Measured 2026-10-12** (task 0246, `docs/design/weibo-ui-discovery.md` §2b):
46
+ * the mobile web composer is the route this channel posts through. It is a plain server-rendered page —
47
+ * one real `<textarea>`, one `a.m-send-btn` — where the desktop React app's 发送 click produced no
48
+ * submission at all (the draft stayed in the composer, no toast, no card, twice), and it accepts an
49
+ * image through `input[type="file"]` natively, which an episode announcement needs.
50
+ */
51
+ export const WEIBO_MOBILE_COMPOSE_URL = 'https://m.weibo.cn/compose/';
52
+ /** Login and passport redirects: while the browser sits on one, the composer never renders. */
53
+ export const LOGIN_URL_RE = /(passport\.weibo\.com|\/login)/;
54
+
55
+ /** Declared operations (ADR-029 dispatch): a home-feed post and a 头条文章 article. */
56
+ export const WEIBO_OPERATIONS = ['post', 'article'] as const;
57
+ export type WeiboOperation = (typeof WEIBO_OPERATIONS)[number];
58
+
59
+ /**
60
+ * Post-composer selectors, ordered fallbacks. **Measured live 2026-10-12** (task 0246, throwaway
61
+ * account, `docs/design/weibo-ui-discovery.md`): the composer is a real `<textarea>` whose
62
+ * placeholder is "有什么新鲜事想分享给大家?" (class `_input_*`) and the send control is
63
+ * `button` "发送" (classes `woo-button-main woo-button-flat woo-button-primary …`).
64
+ *
65
+ * `send` deliberately keeps every fallback constrained to its own 发送 text: the measured
66
+ * `woo-button-primary` class is ALSO on the account's 管理 button, so a bare class fallback would
67
+ * click the wrong control.
68
+ */
69
+ export const WEIBO_POST_SELECTORS = {
70
+ composer: ['textarea[placeholder*="分享新鲜事"]', 'textarea[placeholder*="新鲜事"]', 'textarea'],
71
+ /**
72
+ * Send control: measured as `a.m-send-btn` (text 发送) on the mobile composer. The 发送-text
73
+ * constraint is kept on every fallback so a bare class can never match a different control.
74
+ */
75
+ send: ['a.m-send-btn', 'a:has-text("发送")', 'button:has-text("发送")'],
76
+ /** Post image: the mobile composer's `input[type="file"]`, accept `image/gif,jpeg,webp,jpg,png,bmp`. */
77
+ fileInput: ['input[type="file"][accept*="image"]', 'input[type="file"]'],
78
+ } as const;
79
+
80
+ /**
81
+ * 头条文章 editor selectors, ordered fallbacks. **Not measured live**: task 0246's throwaway account
82
+ * lacks the 头条文章 entitlement, so the editor never rendered (measured redirects, no draft
83
+ * control). They stay provisional ladders that fail closed — a missing control is a loud refusal,
84
+ * never a silent no-op — and `docs/design/weibo-ui-discovery.md` records exactly what was measured.
85
+ */
86
+ export const WEIBO_ARTICLE_SELECTORS = {
87
+ title: ['input[placeholder*="标题"]', 'textarea[placeholder*="标题"]', 'input[class*="title"]'],
88
+ body: [
89
+ 'div[contenteditable="true"][class*="editor"]',
90
+ '.ProseMirror',
91
+ 'div[contenteditable="true"]',
92
+ 'iframe[class*="editor"]',
93
+ ],
94
+ fileInput: ['input[type="file"][accept*="image"]', 'input[type="file"]'],
95
+ saveDraft: ['button:has-text("保存草稿")', 'a:has-text("保存草稿")'],
96
+ publish: ['button:has-text("发布")', 'button:has-text("发表")'],
97
+ } as const;
98
+
99
+ const STEP_TIMEOUT_MS = 30_000;
100
+ const LOGIN_TIMEOUT_MS = 180_000;
101
+ /** Operator recovery command every session failure names — never a cookie, DOM dump or stack. */
102
+ export const LOGIN_INSTRUCTION = 'bun plugins/publishings/weibo-pub/src/profile-check.ts login';
103
+ /**
104
+ * Image extensions this channel accepts — the mobile composer's measured `accept` list
105
+ * (`image/gif,image/jpeg,image/webp,image/jpg,image/png,image/bmp`), which is also what the
106
+ * open-platform `pic` parameter takes.
107
+ */
108
+ const POST_IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp']);
109
+ /**
110
+ * One-image cap. Weibo documents the open-platform `pic` upload as under 5 MB
111
+ * (<https://open.weibo.com/wiki/2/statuses/share>); the same Content must be portable between the
112
+ * browser and API routes, so the stricter documented bound is the channel's bound.
113
+ */
114
+ const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
115
+ /** The 头条文章 cover keeps the x-pub peer's narrower set (0244 R2); only `post` widened (0246 R9). */
116
+ const ARTICLE_COVER_EXTS = new Set(['.png', '.jpg', '.jpeg', '.webp']);
117
+
118
+ /** Body excerpt length compared on readback and on the fresh-post signal. */
119
+ const EXCERPT_CHARS = 30;
120
+
121
+ function fail(error: string): Result {
122
+ return ResultSchema.parse({ ok: false, target: TARGET, error: boundedDetail(error) });
123
+ }
124
+
125
+ /** One short line, bounded: no DOM dump, cookie, token or stack ever reaches the Result (E91-D6). */
126
+ function boundedDetail(detail: string): string {
127
+ return detail.replace(/\s+/g, ' ').trim().slice(0, 300);
128
+ }
129
+
130
+ /** A bounded, single-line rendering of an operator-supplied value for a refusal message. */
131
+ function bounded(value: unknown): string {
132
+ if (Array.isArray(value)) return `${value.length} entr${value.length === 1 ? 'y' : 'ies'}`;
133
+ return String(value).replace(/\s+/g, ' ').slice(0, 100);
134
+ }
135
+
136
+ function errorMessage(error: unknown): string {
137
+ return error instanceof Error ? error.message : String(error);
138
+ }
139
+
140
+ /** Whitespace-insensitive comparison: editors normalize runs of spaces and newlines. */
141
+ function normalizeText(value: string): string {
142
+ return value.replace(/\s+/g, '');
143
+ }
144
+
145
+ function excerpt(text: string): string {
146
+ return normalizeText(text).slice(0, EXCERPT_CHARS);
147
+ }
148
+
149
+ /**
150
+ * Resolves the requested operation. An undeclared value is refused by name, before any transport —
151
+ * and therefore before any browser — is resolved (R8).
152
+ * @param options - Per-call publish options (`operation`).
153
+ * @param content - Content whose `metadata.operation` is the fallback source.
154
+ * @returns The resolved operation, or the refusal message.
155
+ */
156
+ export function resolveOperation(
157
+ options?: Record<string, unknown>,
158
+ content?: Content,
159
+ ): { operation: WeiboOperation; error?: undefined } | { operation?: undefined; error: string } {
160
+ const raw = options?.operation ?? content?.metadata?.operation ?? 'post';
161
+ if (typeof raw !== 'string' || !(WEIBO_OPERATIONS as readonly string[]).includes(raw)) {
162
+ return {
163
+ error: `unsupported operation '${bounded(raw)}' for weibo: expected one of ${WEIBO_OPERATIONS.join(', ')}`,
164
+ };
165
+ }
166
+ return { operation: raw as WeiboOperation };
167
+ }
168
+
169
+ /**
170
+ * Resolves the driver from `options.driver ?? 'script'`. This channel ships one driver and no
171
+ * `agent` path, so anything else — including a wrong type — is refused by name before a transport
172
+ * is built (R8).
173
+ * @param options - Per-call publish options (`driver`).
174
+ * @returns The resolved driver, or the refusal message.
175
+ */
176
+ function resolveDriver(
177
+ options?: Record<string, unknown>,
178
+ ): { driver: BrowserDriver; error?: undefined } | { driver?: undefined; error: string } {
179
+ const raw = options?.driver ?? 'script';
180
+ if (raw !== 'script') {
181
+ return {
182
+ error: `unsupported driver '${bounded(raw)}' for weibo: expected one of script (the weibo-pub browser flow has no agent path)`,
183
+ };
184
+ }
185
+ return { driver: 'script' };
186
+ }
187
+
188
+ /**
189
+ * Weibo-owned readiness predicate: no login redirect and either the home composer or the 头条文章
190
+ * editor reachable. Shared by the publish preflight and the operator profile CLI, so both apply one
191
+ * gate (R6, R7). Both surfaces are accepted because the preflight always opens the home page while
192
+ * the article flow's post-goto confirmation opens the editor.
193
+ */
194
+ export const isWeiboAuthenticated: IsAuthenticatedPredicate = async (page) => {
195
+ if (LOGIN_URL_RE.test(page.url())) return false;
196
+ if ((await trySelectors(page, WEIBO_POST_SELECTORS.composer, { visible: false })).found) return true;
197
+ return (await trySelectors(page, WEIBO_ARTICLE_SELECTORS.title, { visible: false })).found;
198
+ };
199
+
200
+ /**
201
+ * Site-owned config for the shared session check and operator CLI (ADR-032). The publish preflight
202
+ * never forwards `checkHeadedOnly`: it is the operator CLI's setting, and a refusal must never
203
+ * escalate to a headed window on its own (R6).
204
+ */
205
+ export const WEIBO_CONFIG: ChannelBrowserConfig = {
206
+ channel: TARGET,
207
+ homeUrl: WEIBO_HOME_URL,
208
+ isAuthenticated: isWeiboAuthenticated,
209
+ loginTimeoutMs: LOGIN_TIMEOUT_MS,
210
+ checkHeadedOnly: false,
211
+ };
212
+
213
+ /**
214
+ * Resolves and validates the optional image: `options.coverPath` wins over a single `options.images`
215
+ * entry and `metadata.coverImage`. It must be an absolute path to an existing file of the
216
+ * operation's accepted set (article `.png|.jpg|.jpeg|.webp`, post adds `.gif|.bmp`) within 5 MB —
217
+ * a bad image is refused before any browser opens (R2, R9).
218
+ */
219
+ async function resolveCover(
220
+ options: Record<string, unknown> | undefined,
221
+ content: Content,
222
+ operation: string,
223
+ ): Promise<{ path: string } | { error: string } | undefined> {
224
+ const list = options?.images;
225
+ if (Array.isArray(list) && list.length > 1) {
226
+ return { error: `only one image is supported, got ${list.length} — weibo-pub posts one image` };
227
+ }
228
+ const single = Array.isArray(list) && list.length === 1 ? list[0] : undefined;
229
+ const raw = options?.coverPath ?? single ?? content.metadata?.coverImage;
230
+ if (raw === undefined) return undefined;
231
+ if (typeof raw !== 'string' || raw.trim().length === 0) {
232
+ return { error: `image path must be a non-empty local file path, got '${bounded(raw)}'` };
233
+ }
234
+ if (!isAbsolute(raw)) {
235
+ return { error: `image path must be an absolute path, got '${raw}'` };
236
+ }
237
+ const accepted = operation === 'article' ? ARTICLE_COVER_EXTS : POST_IMAGE_EXTS;
238
+ if (!accepted.has(extname(raw).toLowerCase())) {
239
+ return { error: `image must be one of ${[...accepted].join(', ')} (Weibo's accepted set), got '${raw}'` };
240
+ }
241
+ if (!(await createNodeFileSystem().exists(raw))) {
242
+ return { error: `image file does not exist: '${raw}'` };
243
+ }
244
+ const size = Bun.file(raw).size;
245
+ if (size > MAX_IMAGE_BYTES) {
246
+ return {
247
+ error: `image is ${Math.round(size / 1024 / 1024)} MB, over the ${MAX_IMAGE_BYTES / 1024 / 1024} MB weibo limit: ${raw}`,
248
+ };
249
+ }
250
+ return { path: raw };
251
+ }
252
+
253
+ /**
254
+ * Weibo browser transport on the shared rails (ADR-027): a headless session preflight, one headed
255
+ * flow, and success only from an observed signal (E91-D5/D6). `post` refuses without
256
+ * `publishStatus: public` because the home composer has no draft; `article` saves a draft by
257
+ * default and clicks a live control only for `public`.
258
+ */
259
+ export class WeiboBrowserTransport implements PublishTransport {
260
+ public readonly kind = 'browser';
261
+ public readonly driver: BrowserDriver;
262
+ private readonly operation: WeiboOperation;
263
+ private readonly launcher: BrowserLauncher;
264
+ private readonly stepTimeoutMs: number;
265
+ constructor(
266
+ operation: WeiboOperation = 'post',
267
+ driver: BrowserDriver = 'script',
268
+ deps: { launcher?: BrowserLauncher; stepTimeoutMs?: number } = {},
269
+ ) {
270
+ this.operation = operation;
271
+ this.driver = driver;
272
+ this.launcher = deps.launcher ?? kkBrowserLauncher;
273
+ this.stepTimeoutMs = deps.stepTimeoutMs ?? STEP_TIMEOUT_MS;
274
+ }
275
+
276
+ /**
277
+ * Publishes one Content: `article` (draft by default) or `post` (live only).
278
+ * @param payload - Target content plus per-call options.
279
+ * @returns The publish `Result`, `ok: true` only from an observed site signal.
280
+ */
281
+ public async publish(payload: PublishTransportPayload): Promise<Result> {
282
+ // Refusal order (all pre-browser, R8/R5/R3): operation → driver → publishStatus value →
283
+ // post-without-public → post text → cover → session preflight.
284
+ if (!(WEIBO_OPERATIONS as readonly string[]).includes(this.operation)) {
285
+ return fail(
286
+ `unsupported operation '${bounded(this.operation)}' for weibo: expected one of ${WEIBO_OPERATIONS.join(', ')}`,
287
+ );
288
+ }
289
+ if (this.driver !== 'script') {
290
+ return fail(
291
+ `unsupported driver '${bounded(this.driver)}' for weibo: expected one of script (the weibo-pub browser flow has no agent path)`,
292
+ );
293
+ }
294
+ const requested = payload.content.metadata?.publishStatus;
295
+ if (requested !== undefined && requested !== 'draft' && requested !== 'public') {
296
+ return fail(`unknown publishStatus '${bounded(requested)}' (expected absent, 'draft' or 'public')`);
297
+ }
298
+ const publishStatus = requested === 'public' ? 'public' : 'draft';
299
+ if (this.operation === 'post' && publishStatus !== 'public') {
300
+ return fail('weibo post has no draft; set publishStatus: public');
301
+ }
302
+ const text = payload.content.body.trim() || payload.content.title?.trim() || '';
303
+ if (this.operation === 'post' && text.length === 0) {
304
+ return fail('weibo post requires Content.body or Content.title');
305
+ }
306
+ const cover = await resolveCover(payload.options, payload.content, this.operation);
307
+ if (cover !== undefined && 'error' in cover) return fail(cover.error);
308
+ // An episode announcement carries its cover: `post` accepts exactly one image (R9, 0246).
309
+
310
+ const override = typeof payload.options?.profileDir === 'string' ? payload.options.profileDir : undefined;
311
+ const profileDir = resolveChannelProfileDir(TARGET, override);
312
+
313
+ // ADR-025 preflight: an unauthenticated or absent profile must never produce a headed visit,
314
+ // so the read-only headless check runs first and a refusal stays a fast refusal (R6).
315
+ const session = await checkChannelSession(WEIBO_CONFIG, {
316
+ profileDir,
317
+ launcher: this.launcher,
318
+ headedFallback: false,
319
+ timeoutMs: this.stepTimeoutMs,
320
+ });
321
+ if (session.status !== 'verified') {
322
+ // The recovery command leads the message: `fail()` bounds the line, and the operator's
323
+ // next action must never be the part that gets truncated.
324
+ return fail(`${TARGET} session preflight refused — run: ${LOGIN_INSTRUCTION} — detail: ${session.detail}`);
325
+ }
326
+
327
+ try {
328
+ return await withProfilePage(
329
+ {
330
+ profileDir,
331
+ url: this.operation === 'article' ? WEIBO_ARTICLE_URL : WEIBO_MOBILE_COMPOSE_URL,
332
+ isAuthenticated: isWeiboAuthenticated,
333
+ // Headed by default; the headless preflight above is the unattended pass and
334
+ // `options.headless === true` the opt-in.
335
+ headless: payload.options?.headless === true,
336
+ launcher: this.launcher,
337
+ // The session is already preflight-verified, so the post-goto confirmation is a
338
+ // bounded step like every other one, not the 180s interactive login wait.
339
+ timeoutMs: this.stepTimeoutMs,
340
+ },
341
+ async ({ page }) =>
342
+ this.operation === 'article'
343
+ ? this.runArticleFlow(page, payload.content, publishStatus, cover?.path, profileDir)
344
+ : this.runPostFlow(page, text, profileDir, cover?.path),
345
+ );
346
+ } catch (error) {
347
+ // Same ordering rule as the preflight refusal: the recovery command is never the tail
348
+ // that a bounded message drops.
349
+ const detail = errorMessage(error);
350
+ return fail(
351
+ needsLogin(error) ? `${TARGET} session failed — run: ${LOGIN_INSTRUCTION} — detail: ${detail}` : detail,
352
+ );
353
+ }
354
+ }
355
+
356
+ /**
357
+ * The 头条文章 flow: title → body paste → optional cover → readback → `保存草稿` (default) or
358
+ * `发布`. Success only from a fresh signal: a new saved toast or a changed draft URL for a
359
+ * draft, a published article URL for a live run (R2, R4).
360
+ */
361
+ private async runArticleFlow(
362
+ page: Page,
363
+ content: Content,
364
+ publishStatus: 'draft' | 'public',
365
+ coverPath: string | undefined,
366
+ profileDir: string,
367
+ ): Promise<Result> {
368
+ const metadata = { operation: 'article', driver: this.driver, publishStatus, profileDir };
369
+ const title = content.title ?? 'Untitled';
370
+
371
+ const titleSelector = await resolveSelector(page, WEIBO_ARTICLE_SELECTORS.title, {
372
+ timeoutMs: this.stepTimeoutMs,
373
+ label: 'article title input',
374
+ });
375
+ await page.locator(titleSelector).first().fill(title);
376
+
377
+ const bodySelector = await resolveSelector(page, WEIBO_ARTICLE_SELECTORS.body, {
378
+ timeoutMs: this.stepTimeoutMs,
379
+ label: 'article body editor',
380
+ });
381
+ // 头条文章 is rich text: the body goes in as rendered HTML through the shared paste (E91-D4).
382
+ await pasteHtml(page, bodySelector, markdownToHtml(content.body), { timeoutMs: this.stepTimeoutMs });
383
+
384
+ if (coverPath !== undefined) {
385
+ const fileInputSelector = await resolveSelector(page, WEIBO_ARTICLE_SELECTORS.fileInput, {
386
+ timeoutMs: this.stepTimeoutMs,
387
+ label: 'article cover file input',
388
+ });
389
+ const baseline = await this.countImages(page, bodySelector);
390
+ await uploadFile(page, fileInputSelector, coverPath, { timeoutMs: this.stepTimeoutMs });
391
+ // A requested cover needs a fresh observed attachment, not a successful upload call.
392
+ const attached = await expectOutcome(() => this.probeCoverAttached(page, bodySelector, baseline), {
393
+ timeoutMs: this.stepTimeoutMs,
394
+ label: 'weibo article cover attachment',
395
+ });
396
+ if (!attached.ok) return fail(attached.error);
397
+ }
398
+
399
+ // Read the composer back before the save/publish control: a field the editor dropped must
400
+ // never become a saved draft or a live article (R4).
401
+ const titleReadback = await this.requireReadback(page, titleSelector, title, 'article title readback');
402
+ if (!titleReadback.ok) return fail(titleReadback.error);
403
+ const bodyReadback = await this.requireReadback(page, bodySelector, content.body, 'article body readback');
404
+ if (!bodyReadback.ok) return fail(bodyReadback.error);
405
+
406
+ const editorUrl = page.url();
407
+ const toastsBefore = await this.readToasts(page);
408
+
409
+ if (publishStatus === 'draft') {
410
+ const saveSelector = await resolveSelector(page, WEIBO_ARTICLE_SELECTORS.saveDraft, {
411
+ timeoutMs: this.stepTimeoutMs,
412
+ label: 'article 保存草稿 control',
413
+ });
414
+ await page.locator(saveSelector).first().click();
415
+ const draft = await expectOutcome(() => this.probeDraft(page, editorUrl, toastsBefore), {
416
+ timeoutMs: this.stepTimeoutMs,
417
+ label: `weibo article draft '${saveSelector}'`,
418
+ });
419
+ if (!draft.ok) return fail(draft.error);
420
+ return ResultSchema.parse({
421
+ ok: true,
422
+ target: TARGET,
423
+ url: draft.evidence,
424
+ message: `${TARGET} article draft saved (unpublished) in profile ${profileDir}`,
425
+ metadata,
426
+ });
427
+ }
428
+
429
+ const publishSelector = await resolveSelector(page, WEIBO_ARTICLE_SELECTORS.publish, {
430
+ timeoutMs: this.stepTimeoutMs,
431
+ label: 'article publish control',
432
+ });
433
+ await page.locator(publishSelector).first().click();
434
+ const live = await expectOutcome(() => this.probeArticleLive(page), {
435
+ timeoutMs: this.stepTimeoutMs,
436
+ label: `weibo article publish '${publishSelector}'`,
437
+ });
438
+ if (!live.ok) return fail(live.error);
439
+ return ResultSchema.parse({
440
+ ok: true,
441
+ target: TARGET,
442
+ id: statusIdFrom(live.evidence),
443
+ url: live.evidence,
444
+ message: `${TARGET} article published live in profile ${profileDir}`,
445
+ metadata,
446
+ });
447
+ }
448
+
449
+ /**
450
+ * The mobile-composer flow: fill → readback → optional one image → submit through the page's own
451
+ * mobile API; success is the returned status id. Only reached for `publishStatus: public` (R3, R4).
452
+ */
453
+ private async runPostFlow(page: Page, text: string, profileDir: string, coverPath?: string): Promise<Result> {
454
+ let picId = '';
455
+ const metadata = { operation: 'post', driver: this.driver, publishStatus: 'public', profileDir };
456
+
457
+ const composerSelector = await resolveSelector(page, WEIBO_POST_SELECTORS.composer, {
458
+ timeoutMs: this.stepTimeoutMs,
459
+ label: 'post composer',
460
+ });
461
+ await page.locator(composerSelector).first().fill(text);
462
+ const readback = await this.requireReadback(page, composerSelector, text, 'post composer readback');
463
+ if (!readback.ok) return fail(readback.error);
464
+
465
+ // The announcement's cover: one image, uploaded through the composer's own input, with a
466
+ // fresh observed attachment before the send (R9, 0246) — the same rail `article` uses.
467
+ if (coverPath !== undefined) {
468
+ const fileInputSelector = await resolveSelector(page, WEIBO_POST_SELECTORS.fileInput, {
469
+ timeoutMs: this.stepTimeoutMs,
470
+ label: 'post image input',
471
+ });
472
+ const baseline = await this.countImages(page, composerSelector);
473
+ await uploadFile(page, fileInputSelector, coverPath, { timeoutMs: this.stepTimeoutMs });
474
+ const attached = await expectOutcome(() => this.probeCoverAttached(page, composerSelector, baseline), {
475
+ timeoutMs: this.stepTimeoutMs,
476
+ label: 'weibo post image attachment',
477
+ });
478
+ if (!attached.ok) return fail(attached.error);
479
+ // The API submit needs the uploaded image's id. Until that source is measured, an image
480
+ // whose id cannot be read is refused rather than posted as a text-only status while
481
+ // claiming an image (R9 is fail-closed like every other rail).
482
+ picId = String(await page.evaluate(picIdScript())).trim();
483
+ if (picId.length === 0) {
484
+ return fail(
485
+ 'weibo image uploaded but its id could not be read from the composer — refusing to post a text-only status while claiming an image',
486
+ );
487
+ }
488
+ }
489
+
490
+ // Submit the way the web app itself does. Measured 2026-10-12 (0246): both the desktop and the
491
+ // mobile 发送 anchors are JS-bound with no href, and a synthetic click produced no request at
492
+ // all (the draft stayed in the composer, no toast, no card, three attempts). The mobile page's
493
+ // own API answers this session — `GET /api/config` → `{ok:1,data:{login:true,st,uid}}` — so the
494
+ // browser process posts through it, with the session, the `st` token and the ids taken from the
495
+ // page. Same request the page issues, no app registration, no synthetic click to lose.
496
+ const toastsBefore = await this.readToasts(page);
497
+ const submitted = await this.submitViaMobileApi(page, text, picId);
498
+ if (!submitted.ok)
499
+ return fail(
500
+ `${submitted.error} [${await this.postDiagnostics(page, composerSelector, text, toastsBefore)}]`,
501
+ );
502
+ return ResultSchema.parse({
503
+ ok: true,
504
+ target: TARGET,
505
+ id: submitted.id,
506
+ url: `https://m.weibo.cn/detail/${submitted.id}`,
507
+ message: `${TARGET} post published live in profile ${profileDir}`,
508
+ metadata,
509
+ });
510
+ }
511
+
512
+ /**
513
+ * Posts through the page's own mobile API (`POST /api/statuses/update`) with the page's `st`
514
+ * token, and returns the created status id. A response without an id is a refusal carrying the
515
+ * site's own bounded error text — never a success.
516
+ */
517
+ private async submitViaMobileApi(
518
+ page: Page,
519
+ text: string,
520
+ picId?: string,
521
+ ): Promise<{ ok: true; id: string } | { ok: false; error: string }> {
522
+ const raw = await page
523
+ .evaluate(apiSubmitScript(text, picId ?? ''))
524
+ .catch((error: unknown) => `eval-failed: ${String(error)}`);
525
+ const parsed = parseApiSubmit(raw);
526
+ if (parsed.id === undefined) {
527
+ return { ok: false, error: `weibo mobile api refused the post: ${parsed.detail}` };
528
+ }
529
+ return { ok: true, id: parsed.id };
530
+ }
531
+
532
+ /** Polls until the field shows the submitted text; a field the editor dropped is a refusal. */
533
+ private async requireReadback(
534
+ page: Page,
535
+ selector: string,
536
+ expected: string,
537
+ label: string,
538
+ ): Promise<{ ok: true; evidence: string } | { ok: false; error: string }> {
539
+ const wanted = excerpt(expected);
540
+ return expectOutcome(
541
+ async () => {
542
+ const current = normalizeText(String(await page.evaluate(readbackScript(selector))));
543
+ if (wanted.length === 0 || !current.includes(wanted)) return null;
544
+ return current.slice(0, 60);
545
+ },
546
+ { timeoutMs: this.stepTimeoutMs, label: `weibo ${label}` },
547
+ );
548
+ }
549
+
550
+ /** Baseline count of rendered images near the editor, for the fresh cover-attachment signal. */
551
+ private async countImages(page: Page, selector: string): Promise<number> {
552
+ const value = Number(await page.evaluate(coverCountScript(selector)));
553
+ return Number.isFinite(value) ? value : 0;
554
+ }
555
+
556
+ private async probeCoverAttached(page: Page, selector: string, baseline: number): Promise<string | null> {
557
+ const count = await this.countImages(page, selector);
558
+ return count > baseline ? `cover-attached (${count} images)` : null;
559
+ }
560
+
561
+ /** Visible toast texts before a save, so an already-visible toast cannot satisfy the draft rail. */
562
+ private async readToasts(page: Page): Promise<readonly string[]> {
563
+ return parseStringArray(await page.evaluate(toastTextsScript()));
564
+ }
565
+
566
+ /**
567
+ * Draft evidence: a toast that was not visible before the save, or a URL that left the editor
568
+ * entry and carries a draft/article id. The editor URL alone is never success (R4).
569
+ */
570
+ private async probeDraft(page: Page, editorUrl: string, toastsBefore: readonly string[]): Promise<string | null> {
571
+ return (await this.probeFreshToast(page, toastsBefore)) ?? probeDraftUrl(page.url(), editorUrl);
572
+ }
573
+
574
+ /**
575
+ * One bounded line of what the page shows after a failed send (R5): the composer's length change,
576
+ * the first visible dialog/modal/verification text, and any fresh toast. Never page HTML.
577
+ */
578
+ private async postDiagnostics(
579
+ page: Page,
580
+ composerSelector: string,
581
+ text: string,
582
+ toastsBefore: readonly string[],
583
+ ): Promise<string> {
584
+ const now = String(await page.evaluate(readbackScript(composerSelector))).length;
585
+ const parts = [`composer ${[...text].length}->${now}`];
586
+ const dialog = String(await page.evaluate(dialogTextScript())).trim();
587
+ if (dialog.length > 0) parts.push(`dialog: ${dialog}`);
588
+ const toast = (await this.readToasts(page)).find((entry) => !new Set(toastsBefore).has(entry));
589
+ if (toast !== undefined) parts.push(`site said: ${toast}`);
590
+ return parts.join('; ');
591
+ }
592
+
593
+ /** A toast carrying a save/success word that was not already on screen before the click. */
594
+ private async probeFreshToast(page: Page, toastsBefore: readonly string[]): Promise<string | null> {
595
+ const before = new Set(toastsBefore);
596
+ const now = await this.readToasts(page);
597
+ return now.find((text) => !before.has(text) && /保存|草稿|成功/.test(text)) ?? null;
598
+ }
599
+
600
+ /** Live article evidence: the browser left the editor for a published article URL. */
601
+ private async probeArticleLive(page: Page): Promise<string | null> {
602
+ return liveArticleUrl(page.url());
603
+ }
604
+ }
605
+
606
+ /** Session failures whose one-liner must name the recovery command. */
607
+ function needsLogin(error: unknown): boolean {
608
+ return error instanceof LoginTimeoutError || error instanceof BrowserProfileMissingError;
609
+ }
610
+
611
+ /** Parses a JSON string array from a page probe, tolerating anything else as "no snapshot". */
612
+ function parseStringArray(raw: unknown): readonly string[] {
613
+ try {
614
+ const parsed: unknown = JSON.parse(String(raw));
615
+ return Array.isArray(parsed) ? parsed.map((entry) => String(entry)) : [];
616
+ } catch {
617
+ return [];
618
+ }
619
+ }
620
+
621
+ /** Extracts a Weibo status/article/draft id from an observed URL, when it carries one. */
622
+ function statusIdFrom(url: string): string | undefined {
623
+ const explicit = /(?:\/status\/|\/detail\/|\/show\?id=|draft_id=)([A-Za-z0-9]+)/.exec(url);
624
+ if (explicit?.[1] !== undefined) return explicit[1];
625
+ // The desktop status form is `weibo.com/<uid>/<status-key>`; the trailing segment is the id.
626
+ return /^[^?#]*\/([A-Za-z0-9]{6,})(?:[?#]|$)/.exec(url)?.[1];
627
+ }
628
+
629
+ /**
630
+ * A URL that proves a 头条文章 article went live: Weibo's `ttarticle` show page, or an article
631
+ * detail URL. The editor entry URL never matches (R4).
632
+ */
633
+ function liveArticleUrl(url: string): string | null {
634
+ return /(?:\/ttarticle\/p\/show\?id=|\/article\/(?:show|detail)\/)[A-Za-z0-9]+/.test(url) ? url : null;
635
+ }
636
+
637
+ /**
638
+ * The page-context submit: same form-encoded request the mobile web app issues, with the page's own
639
+ * `st` token and session cookies. Returns a JSON string so the caller parses a bounded payload.
640
+ */
641
+ function apiSubmitScript(text: string, picId: string): string {
642
+ return `(async () => { // kk-weibo-submit
643
+ const cfg = (window.config || {});
644
+ const form = new URLSearchParams({ content: ${JSON.stringify(text)}, st: String(cfg.st || '') });
645
+ ${picId === '' ? '' : `form.set('pic_id', ${JSON.stringify(picId)});`}
646
+ let response;
647
+ try {
648
+ response = await fetch('https://m.weibo.cn/api/statuses/update', {
649
+ method: 'POST',
650
+ credentials: 'include',
651
+ // The app's own XHR carries these two; m.weibo.cn's write endpoint distinguishes a
652
+ // page-originated request from a bare one (measured 2026-10-12).
653
+ headers: {
654
+ 'Content-Type': 'application/x-www-form-urlencoded',
655
+ 'X-Requested-With': 'XMLHttpRequest',
656
+ Referer: 'https://m.weibo.cn/compose/',
657
+ },
658
+ body: form.toString(),
659
+ });
660
+ } catch (error) {
661
+ return JSON.stringify({ status: 0, detail: 'no HTTP response was received: ' + String(error).slice(0, 80) });
662
+ }
663
+ let body = null;
664
+ try { body = await response.json(); } catch { body = null; }
665
+ const data = (body && body.data) || {};
666
+ const id = String(data.id ?? data.idstr ?? '');
667
+ const detail = id !== '' ? '' : JSON.stringify(body ?? {}).replace(/s+/g, ' ').slice(0, 160);
668
+ return JSON.stringify({ status: response.status, id: id, detail: detail });
669
+ })()`;
670
+ }
671
+
672
+ /** Parses the page-context submit result into an id or a bounded failure detail. */
673
+ /**
674
+ * Parses the page-context submit result into an id or a bounded failure detail. When the site's own
675
+ * refusal payload is present it is rendered as `HTTP <status> errno <code>: <msg> (<action>: <url>)`
676
+ * — measured 2026-10-12: a flagged account answers `errno 200001 账号异常` with an activation link,
677
+ * which is the operator's next action and must not be hidden inside a raw JSON blob.
678
+ */
679
+ function parseApiSubmit(raw: unknown): { id?: string; detail: string } {
680
+ try {
681
+ const parsed = JSON.parse(String(raw)) as { status?: number; id?: string; detail?: string };
682
+ if (typeof parsed.id === 'string' && parsed.id.length > 0) return { id: parsed.id, detail: '' };
683
+ const status = parsed.status === undefined ? 'no status' : `HTTP ${parsed.status}`;
684
+ if (parsed.detail === undefined || parsed.detail.length === 0) return { detail: status };
685
+ const site = parseSiteRefusal(parsed.detail);
686
+ return { detail: site === undefined ? `${status}: ${parsed.detail}` : `${status} ${site}` };
687
+ } catch {
688
+ return { detail: String(raw).slice(0, 160) };
689
+ }
690
+ }
691
+
692
+ /** Renders Weibo's own refusal payload (`errno`/`msg`/activation link) as one bounded phrase. */
693
+ function parseSiteRefusal(detail: string): string | undefined {
694
+ try {
695
+ const body = JSON.parse(detail) as {
696
+ errno?: unknown;
697
+ msg?: unknown;
698
+ title?: unknown;
699
+ btn?: { text?: unknown; url?: unknown };
700
+ };
701
+ const msg = typeof body.msg === 'string' ? body.msg.trim() : '';
702
+ const title = typeof body.title === 'string' ? body.title.trim() : '';
703
+ const errno = body.errno === undefined ? '' : `errno ${String(body.errno)}`;
704
+ const action = typeof body.btn?.url === 'string' ? ` (${String(body.btn.text ?? 'fix')}: ${body.btn.url})` : '';
705
+ const text = msg.length > 0 ? msg : title;
706
+ if (text.length === 0) return undefined;
707
+ return `${errno.length > 0 ? `${errno}: ` : ''}${text}${action}`;
708
+ } catch {
709
+ return undefined;
710
+ }
711
+ }
712
+
713
+ /** The uploaded image's id, if the composer exposes one; '' means "not readable" (fail closed). */
714
+ function picIdScript(): string {
715
+ return `(() => { // kk-weibo-picid
716
+ const cfg = (window.config || {});
717
+ const candidates = [cfg.pic_id, cfg.picId, (window.$render_data || {}).pic_id];
718
+ for (const value of candidates) {
719
+ if (typeof value === 'string' && value.length > 0) return value;
720
+ }
721
+ const img = document.querySelector('img[src*="sinaimg"]');
722
+ return img === null ? '' : '';
723
+ })()`;
724
+ }
725
+
726
+ /** The first visible dialog/modal/verification text on the page, bounded; '' when none. */
727
+ function dialogTextScript(): string {
728
+ return `(() => { // kk-weibo-dialog
729
+ const nodes = document.querySelectorAll('[role="dialog"], [class*="dialog" i], [class*="modal" i], [class*="verify" i], [class*="captcha" i]');
730
+ for (const node of nodes) {
731
+ const text = (node.textContent || '').replace(/s+/g, ' ').trim();
732
+ if (text.length > 0) return text.slice(0, 120);
733
+ }
734
+ return '';
735
+ })()`;
736
+ }
737
+
738
+ /** A draft URL: left the editor entry and carries a draft/article id. */
739
+ function probeDraftUrl(url: string, editorUrl: string): string | null {
740
+ if (url === editorUrl) return null;
741
+ return /(?:draft|article|editor)[^\d]*(\d{4,})/.test(url) ? url : null;
742
+ }
743
+
744
+ /** Reads `value` first (inputs) then `textContent` (contenteditable), trimmed. */
745
+ function readbackScript(selector: string): string {
746
+ return `(() => { // kk-weibo-readback
747
+ const el = document.querySelector(${JSON.stringify(selector)});
748
+ if (!el) return '';
749
+ const value = el.value !== undefined && el.value !== null ? el.value : el.textContent;
750
+ return (value === undefined || value === null ? '' : String(value)).trim();
751
+ })()`;
752
+ }
753
+
754
+ /** Counts rendered images in the editor's nearest container, for the cover baseline. */
755
+ function coverCountScript(selector: string): string {
756
+ return `(() => { // kk-weibo-cover
757
+ const anchor = document.querySelector(${JSON.stringify(selector)});
758
+ const scope = anchor === null ? document.body : anchor.closest('form, section, article, main') || document.body;
759
+ return String(scope.querySelectorAll('img[src]').length);
760
+ })()`;
761
+ }
762
+
763
+ /** Visible toast texts, used as the pre-save baseline and as the fresh-signal read. */
764
+ function toastTextsScript(): string {
765
+ return `(() => { // kk-weibo-toasts
766
+ const nodes = document.querySelectorAll('[class*="toast" i], [class*="Toast"], [class*="message" i], [role="alert"]');
767
+ const texts = [];
768
+ for (const node of nodes) {
769
+ const text = (node.textContent || '').trim();
770
+ if (text.length > 0) texts.push(text);
771
+ }
772
+ return JSON.stringify(texts);
773
+ })()`;
774
+ }
775
+
776
+ /**
777
+ * Resolves the channel transport: the publish-harness fake under
778
+ * `KNOWLEDGE_KIT_PUBLISH_TRANSPORT=fake` (offline, no profile, no browser), otherwise the real
779
+ * Weibo browser transport (R9).
780
+ * @param operation - Resolved operation (`post` or `article`).
781
+ * @param driver - Resolved driver; only `script` is declared.
782
+ * @returns The publish transport for this channel.
783
+ */
784
+ export function getPublishTransport(
785
+ operation: WeiboOperation = 'post',
786
+ driver: BrowserDriver = 'script',
787
+ ): PublishTransport {
788
+ if (process.env.KNOWLEDGE_KIT_PUBLISH_TRANSPORT === 'fake') return new FakeBrowserTransport('script');
789
+ return new WeiboBrowserTransport(operation, driver);
790
+ }
791
+
792
+ /** Writes the Result to `--out`, creating its directory when the caller named a nested path. */
793
+ async function writePublishResult(outputPath: string, result: Result): Promise<void> {
794
+ const outDir = dirname(outputPath);
795
+ if (outDir && outDir !== '.') await createNodeFileSystem().ensureDir(outDir);
796
+ await createNodeFileSystem().writeFile(outputPath, JSON.stringify(result, null, 2));
797
+ }
798
+
799
+ /**
800
+ * The plugin's `--in/--out` contract: validate the Content, resolve **and refuse** the operation and
801
+ * the driver before any transport exists, then publish through the injected or configured transport.
802
+ * @param inputPath - Path to the Content JSON.
803
+ * @param outputPath - Path the Result JSON is written to, on success and on refusal.
804
+ * @param transport - Optional transport injection for tests and offline runs.
805
+ */
806
+ export async function processPublishIO(
807
+ inputPath: string,
808
+ outputPath: string,
809
+ transport?: PublishTransport,
810
+ ): Promise<void> {
811
+ await createNodeFileSystem().deleteFile(outputPath);
812
+ let content: Content;
813
+ let options: Record<string, unknown> | undefined;
814
+ try {
815
+ const raw = JSON.parse(await createNodeFileSystem().readFile(inputPath)) as Record<string, unknown>;
816
+ content = ContentSchema.parse(raw);
817
+ if (raw.options !== undefined && typeof raw.options === 'object' && raw.options !== null) {
818
+ options = raw.options as Record<string, unknown>;
819
+ }
820
+ } catch (error: unknown) {
821
+ const message = boundedDetail(`Invalid Content input: ${errorMessage(error)}`);
822
+ await writePublishResult(outputPath, fail(message));
823
+ throw new Error(message);
824
+ }
825
+ const mode = resolveOperation(options, content);
826
+ if (mode.error !== undefined) {
827
+ await writePublishResult(outputPath, fail(mode.error));
828
+ throw new Error(mode.error);
829
+ }
830
+ const driverMode = resolveDriver(options);
831
+ if (driverMode.error !== undefined) {
832
+ await writePublishResult(outputPath, fail(driverMode.error));
833
+ throw new Error(driverMode.error);
834
+ }
835
+ const resolved = transport ?? getPublishTransport(mode.operation, driverMode.driver);
836
+ const result = ResultSchema.parse(await resolved.publish({ content, options }));
837
+ await writePublishResult(outputPath, result);
838
+ if (!result.ok) throw new Error(result.error ?? 'Publish failed');
839
+ }
840
+
841
+ /**
842
+ * CLI entry point: `--in <content.json> --out <result.json>`.
843
+ * @returns Process exit code (0 published, 1 refused or invalid arguments).
844
+ */
845
+ export async function main(): Promise<number> {
846
+ let values: { in?: string; out?: string };
847
+ try {
848
+ ({ values } = parseArgs({ options: { in: { type: 'string' }, out: { type: 'string' } } }));
849
+ } catch (error: unknown) {
850
+ echoError(`Invalid arguments: ${errorMessage(error)}`);
851
+ return 1;
852
+ }
853
+ if (!values.in || !values.out) {
854
+ echoError('Missing required arguments: --in and --out');
855
+ return 1;
856
+ }
857
+ try {
858
+ await processPublishIO(values.in, values.out);
859
+ return 0;
860
+ } catch (error: unknown) {
861
+ echoError(`weibo-pub failed: ${boundedDetail(errorMessage(error))}`);
862
+ return 1;
863
+ }
864
+ }
865
+
866
+ if (import.meta.main) {
867
+ process.exit(await main());
868
+ }