@gobing-ai/knowledge-kit 0.0.34 → 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.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.md +1 -1
- package/dist/board/collect/index.css +42 -0
- package/dist/board/collect/index.js +787 -0
- package/dist/board/daily-voice/index.css +13 -0
- package/dist/board/daily-voice/index.js +902 -0
- package/dist/board/itc/index.css +14 -0
- package/dist/board/itc/index.js +1026 -0
- package/dist/index.js +11769 -9250
- package/package.json +10 -9
- package/plugins/generations/content-gen/dist/index.js +1015 -10814
- package/plugins/generations/content-gen/package.json +1 -1
- package/plugins/generations/core-facts-gen/dist/index.js +1002 -10801
- package/plugins/generations/core-facts-gen/package.json +1 -1
- package/plugins/generations/daily-article-gen/dist/index.js +1019 -10804
- package/plugins/generations/daily-article-gen/package.json +1 -1
- package/plugins/generations/daily-article-gen/src/index.ts +32 -3
- package/plugins/generations/dailynews-gen/dist/index.js +1094 -10892
- package/plugins/generations/dailynews-gen/package.json +1 -1
- package/plugins/generations/dailynews-gen/src/script-builder.ts +6 -1
- package/plugins/generations/episode-plan-gen/dist/index.js +1124 -10923
- package/plugins/generations/episode-plan-gen/package.json +1 -1
- package/plugins/generations/gemini-tts/dist/index.js +1108 -10906
- package/plugins/generations/gemini-tts/package.json +1 -1
- package/plugins/generations/image-gen/dist/index.js +1239 -11035
- package/plugins/generations/image-gen/package.json +1 -1
- package/plugins/generations/news-report-gen/dist/index.js +1048 -10847
- package/plugins/generations/news-report-gen/package.json +1 -1
- package/plugins/generations/omni-voice-gen/plugin.json +1 -1
- package/plugins/generations/voice-gen/dist/index.js +1066 -10864
- package/plugins/generations/voice-gen/package.json +1 -1
- package/plugins/generations/voice-gen/src/index.ts +9 -3
- package/plugins/generations/voxcpm-tts/README.md +72 -0
- package/plugins/generations/voxcpm-tts/dist/index.js +14006 -0
- package/plugins/generations/voxcpm-tts/package.json +16 -0
- package/plugins/generations/voxcpm-tts/plugin.json +6 -0
- package/plugins/generations/voxcpm-tts/profiles.json +10 -0
- package/plugins/generations/voxcpm-tts/pyproject.toml +9 -0
- package/plugins/generations/voxcpm-tts/python/worker.py +353 -0
- package/plugins/generations/voxcpm-tts/src/index.ts +42 -0
- package/plugins/generations/voxcpm-tts/src/voxcpm-client.ts +765 -0
- package/plugins/generations/voxcpm-tts/tsconfig.json +8 -0
- package/plugins/generations/voxcpm-tts/uv.lock +2571 -0
- package/plugins/ingestions/aihot-ingest/dist/index.js +1081 -10628
- package/plugins/ingestions/aihot-ingest/package.json +1 -1
- package/plugins/ingestions/horizon-ingest/dist/index.js +1013 -10812
- package/plugins/ingestions/horizon-ingest/package.json +1 -1
- package/plugins/ingestions/karakeep-local/dist/index.js +689 -10774
- package/plugins/ingestions/karakeep-local/package.json +1 -1
- package/plugins/ingestions/last30days-ingest/dist/index.js +1011 -10810
- package/plugins/ingestions/last30days-ingest/package.json +1 -1
- package/plugins/ingestions/web-search/dist/index.js +1250 -11033
- package/plugins/ingestions/web-search/package.json +1 -1
- package/plugins/kk/README.md +29 -2
- package/plugins/kk/plugin.json +1 -1
- package/plugins/kk/schedules/cron.tmpl +9 -1
- package/plugins/kk/schedules/kk-serve.launchd.plist.tmpl +79 -0
- package/plugins/kk/schedules/launchd.plist.tmpl +11 -1
- package/plugins/kk/scripts/daily-stages.ts +702 -63
- package/plugins/kk/scripts/kk-workflow-stages.ts +116 -146
- package/plugins/kk/skills/content-safety/references/platforms/wechatmp.md +2 -2
- package/plugins/kk/skills/content-safety/references/platforms/xiaoyuzhou.md +4 -2
- package/plugins/kk/skills/publish/SKILL.md +8 -4
- package/plugins/kk/workflows/feature-verification.yaml +93 -0
- package/plugins/kk/workflows/judge-gated-publish-example.yaml +1 -0
- package/plugins/kk/workflows/kk-collect.yaml +43 -11
- package/plugins/kk/workflows/kk-daily-ai-voice.yaml +36 -9
- package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +92 -18
- package/plugins/kk/workflows/kk-daily-safety.yaml +16 -3
- package/plugins/kk/workflows/kk-itc.yaml +8 -1
- package/plugins/kk/workflows/kk-solo-podcast.yaml +2 -0
- package/plugins/publishings/emdash-pub/dist/index.js +987 -10814
- package/plugins/publishings/emdash-pub/package.json +1 -1
- package/plugins/publishings/infoq-pub/dist/index.js +1928 -11444
- package/plugins/publishings/infoq-pub/dist/profile-check.js +20814 -0
- package/plugins/publishings/infoq-pub/package.json +1 -1
- package/plugins/publishings/infoq-pub/plugin.json +6 -2
- package/plugins/publishings/juejin-pub/dist/index.js +1940 -11456
- package/plugins/publishings/juejin-pub/dist/profile-check.js +20800 -0
- package/plugins/publishings/juejin-pub/package.json +1 -1
- package/plugins/publishings/juejin-pub/plugin.json +6 -2
- package/plugins/publishings/medium-pub/dist/index.js +978 -10805
- package/plugins/publishings/medium-pub/package.json +1 -1
- package/plugins/publishings/podcast-pub/dist/index.js +1016 -10843
- package/plugins/publishings/podcast-pub/package.json +1 -1
- package/plugins/publishings/qiita-pub/dist/index.js +976 -10803
- package/plugins/publishings/qiita-pub/package.json +1 -1
- package/plugins/publishings/substack-pub/dist/index.js +1932 -11448
- package/plugins/publishings/substack-pub/dist/profile-check.js +20890 -0
- package/plugins/publishings/substack-pub/package.json +1 -1
- package/plugins/publishings/substack-pub/plugin.json +5 -2
- package/plugins/publishings/substack-pub/src/profile-check.ts +36 -3
- package/plugins/publishings/surfdash-pub/dist/index.js +1151 -10834
- package/plugins/publishings/surfdash-pub/package.json +1 -1
- package/plugins/publishings/surfdash-pub/src/index.ts +137 -15
- package/plugins/publishings/surfdash-pub/src/patch.ts +144 -0
- package/plugins/publishings/threads-pub/dist/index.js +12394 -0
- package/plugins/publishings/threads-pub/package.json +17 -0
- package/plugins/publishings/threads-pub/plugin.json +7 -0
- package/plugins/publishings/threads-pub/src/index.ts +340 -0
- package/plugins/publishings/threads-pub/tsconfig.json +4 -0
- package/plugins/publishings/wechatmp-pub/dist/index.js +2638 -11650
- package/plugins/publishings/wechatmp-pub/dist/profile-check.js +20739 -0
- package/plugins/publishings/wechatmp-pub/package.json +1 -1
- package/plugins/publishings/wechatmp-pub/plugin.json +6 -2
- package/plugins/publishings/wechatmp-pub/src/enrich.ts +297 -163
- package/plugins/publishings/wechatmp-pub/src/flow.ts +611 -54
- package/plugins/publishings/wechatmp-pub/src/index.ts +24 -3
- package/plugins/publishings/wechatmp-pub/src/page.ts +36 -0
- package/plugins/publishings/wechatmp-pub/src/profile-check.ts +40 -4
- package/plugins/publishings/weibo-pub/dist/index.js +21345 -0
- package/plugins/publishings/weibo-pub/dist/profile-check.js +20817 -0
- package/plugins/publishings/weibo-pub/package.json +19 -0
- package/plugins/publishings/weibo-pub/plugin.json +12 -0
- package/plugins/publishings/weibo-pub/src/index.ts +868 -0
- package/plugins/publishings/weibo-pub/src/profile-check.ts +20 -0
- package/plugins/publishings/weibo-pub/tsconfig.json +4 -0
- package/plugins/publishings/x-pub/dist/index.js +1948 -11440
- package/plugins/publishings/x-pub/dist/profile-check.js +20806 -0
- package/plugins/publishings/x-pub/package.json +1 -1
- package/plugins/publishings/x-pub/plugin.json +6 -2
- package/plugins/publishings/xhs-pub/dist/index.js +1993 -11509
- package/plugins/publishings/xhs-pub/dist/profile-check.js +21023 -0
- package/plugins/publishings/xhs-pub/package.json +1 -1
- package/plugins/publishings/xhs-pub/plugin.json +6 -2
- package/plugins/publishings/xhs-pub/src/profile-check.ts +72 -2
- package/plugins/publishings/zenn-pub/dist/index.js +988 -10815
- package/plugins/publishings/zenn-pub/package.json +1 -1
- package/plugins/generations/omni-voice-gen/dist/omni-voice-gen-prr8skpb. +0 -2
- package/plugins/generations/omni-voice-gen/dist/omni-voice-gen.js +0 -6
|
@@ -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
|
+
}
|