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