@gobing-ai/knowledge-kit 0.0.31 → 0.0.33
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/index.js +32320 -15021
- package/package.json +4 -4
- package/plugins/generations/content-gen/dist/index.js +10 -9
- package/plugins/generations/core-facts-gen/dist/index.js +13 -12
- package/plugins/generations/daily-article-gen/dist/index.js +14 -13
- package/plugins/generations/daily-article-gen/src/index.ts +1 -1
- package/plugins/generations/dailynews-gen/dist/index.js +10 -9
- package/plugins/generations/episode-plan-gen/dist/index.js +58 -15
- package/plugins/generations/episode-plan-gen/src/index.ts +76 -6
- package/plugins/generations/gemini-tts/README.md +11 -0
- package/plugins/generations/gemini-tts/dist/index.js +10 -9
- package/plugins/generations/image-gen/dist/index.js +154 -151
- package/plugins/generations/image-gen/src/compose/chromium.ts +60 -42
- package/plugins/generations/news-report-gen/dist/index.js +13 -12
- package/plugins/generations/voice-gen/dist/index.js +10 -9
- package/plugins/ingestions/aihot-ingest/dist/index.js +13 -12
- package/plugins/ingestions/horizon-ingest/dist/index.js +13 -12
- package/plugins/ingestions/karakeep-local/dist/index.js +13 -13
- package/plugins/ingestions/last30days-ingest/dist/index.js +13 -12
- package/plugins/ingestions/web-search/dist/index.js +11 -10
- package/plugins/kk/README.md +38 -0
- package/plugins/kk/decisions/extract/ai-news.yaml +21 -0
- package/plugins/kk/decisions/kk-items.yaml +113 -0
- package/plugins/kk/plugin.json +1 -1
- package/plugins/kk/schedules/cron.tmpl +15 -0
- package/plugins/kk/schedules/launchd.plist.tmpl +77 -0
- package/plugins/kk/scripts/daily-stages.ts +1533 -0
- package/plugins/kk/scripts/kk-workflow-stages.ts +268 -1228
- package/plugins/kk/skills/content-judge/SKILL.md +12 -2
- package/plugins/kk/skills/content-judge/references/rubrics.md +8 -2
- package/plugins/kk/skills/content-safety/SKILL.md +60 -18
- package/plugins/kk/skills/web-automation/SKILL.md +100 -0
- package/plugins/kk/skills/web-automation/references/browser-launch.md +86 -0
- package/plugins/kk/workflows/kk-collect.yaml +258 -0
- package/plugins/kk/workflows/kk-daily-ai-voice.yaml +74 -11
- package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +655 -0
- package/plugins/kk/workflows/kk-daily-safety.yaml +170 -0
- package/plugins/publishings/emdash-pub/dist/index.js +10 -9
- package/plugins/publishings/infoq-pub/dist/index.js +8695 -211
- package/plugins/publishings/infoq-pub/package.json +3 -1
- package/plugins/publishings/infoq-pub/plugin.json +2 -1
- package/plugins/publishings/infoq-pub/src/index.ts +358 -29
- package/plugins/publishings/infoq-pub/src/profile-check.ts +21 -0
- package/plugins/publishings/juejin-pub/dist/index.js +8746 -211
- package/plugins/publishings/juejin-pub/package.json +3 -1
- package/plugins/publishings/juejin-pub/plugin.json +2 -1
- package/plugins/publishings/juejin-pub/src/index.ts +345 -29
- package/plugins/publishings/juejin-pub/src/profile-check.ts +19 -0
- package/plugins/publishings/medium-pub/dist/index.js +10 -9
- package/plugins/publishings/podcast-pub/dist/index.js +10 -9
- package/plugins/publishings/qiita-pub/dist/index.js +10 -9
- package/plugins/publishings/substack-pub/dist/index.js +8687 -211
- package/plugins/publishings/substack-pub/package.json +3 -1
- package/plugins/publishings/substack-pub/plugin.json +2 -1
- package/plugins/publishings/substack-pub/src/index.ts +302 -29
- package/plugins/publishings/substack-pub/src/profile-check.ts +76 -0
- package/plugins/publishings/surfdash-pub/dist/index.js +10 -9
- package/plugins/publishings/wechatmp-pub/dist/index.js +1315 -352
- package/plugins/publishings/wechatmp-pub/plugin.json +1 -0
- package/plugins/publishings/wechatmp-pub/src/enrich.ts +229 -0
- package/plugins/publishings/wechatmp-pub/src/flow.ts +896 -0
- package/plugins/publishings/wechatmp-pub/src/index.ts +223 -520
- package/plugins/publishings/wechatmp-pub/src/modes.ts +403 -0
- package/plugins/publishings/wechatmp-pub/src/page.ts +260 -0
- package/plugins/publishings/wechatmp-pub/src/profile-check.ts +84 -38
- package/plugins/publishings/x-pub/dist/index.js +8707 -223
- package/plugins/publishings/x-pub/package.json +3 -1
- package/plugins/publishings/x-pub/plugin.json +2 -1
- package/plugins/publishings/x-pub/src/index.ts +315 -23
- package/plugins/publishings/x-pub/src/profile-check.ts +19 -0
- package/plugins/publishings/xhs-pub/dist/index.js +897 -354
- package/plugins/publishings/xhs-pub/plugin.json +2 -1
- package/plugins/publishings/xhs-pub/src/flow.ts +572 -0
- package/plugins/publishings/xhs-pub/src/index.ts +108 -596
- package/plugins/publishings/xhs-pub/src/modes.ts +300 -0
- package/plugins/publishings/xhs-pub/src/page.ts +254 -0
- package/plugins/publishings/xhs-pub/src/profile-check.ts +29 -17
- package/plugins/publishings/zenn-pub/dist/index.js +10 -9
|
@@ -6,172 +6,64 @@ import {
|
|
|
6
6
|
type PublishTransportPayload,
|
|
7
7
|
resolveBrowserProfileDir,
|
|
8
8
|
} from '@gobing-ai/publish-harness';
|
|
9
|
+
import { kkBrowserLauncher, waitUntil } from '@gobing-ai/publish-harness/browser';
|
|
9
10
|
import { BrowserProfileMissingError, openPersistentBrowser } from '@gobing-ai/ts-browser-automation';
|
|
10
11
|
import { createNodeFileSystem } from '@gobing-ai/ts-runtime';
|
|
11
12
|
import { echoError } from '@gobing-ai/ts-utils';
|
|
12
13
|
import type { BrowserContext, Cookie, Page } from 'playwright';
|
|
14
|
+
import { markdownToWechatHtml, readDraftBaseline, runMode } from './flow';
|
|
15
|
+
import {
|
|
16
|
+
CONSOLE_HOME_FRAGMENT,
|
|
17
|
+
CONSOLE_URL,
|
|
18
|
+
readConsoleToken,
|
|
19
|
+
resolveWechatmpOperation,
|
|
20
|
+
TARGET,
|
|
21
|
+
validateModeInput,
|
|
22
|
+
WECHATMP_OPERATIONS,
|
|
23
|
+
} from './modes';
|
|
24
|
+
import { type OpenConsole, type OpenedConsole, POLL_MS, STEP_TIMEOUT_MS, wrapPlaywrightContext } from './page';
|
|
13
25
|
|
|
14
|
-
|
|
15
|
-
|
|
26
|
+
/**
|
|
27
|
+
* The wechatmp publish CHANNEL (ADR-029, ADR-026): one plugin, four mode `operations`.
|
|
28
|
+
*
|
|
29
|
+
* This module owns what is channel-level — the profile dir, the session preflight (ADR-026: the
|
|
30
|
+
* preflight plus the draft default ARE the automation guard, so no ADR-025 cap is ported), the
|
|
31
|
+
* ADR-029 operation dispatch and pre-browser validation, the browser launch seam and the
|
|
32
|
+
* `Content → Result` mapping. The modes themselves (entry, artifacts, selectors, save/publish flow)
|
|
33
|
+
* live in `./modes`, `./flow` and `./enrich`.
|
|
34
|
+
*/
|
|
16
35
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
// The 2024+ console editor renders title/body as two ProseMirror instances; the legacy
|
|
23
|
-
// textarea#title is a hidden mirror. The title PM carries a 标题 placeholder — the only
|
|
24
|
-
// reliable discriminator between the two editors.
|
|
25
|
-
titleInput: '.ProseMirror[data-placeholder*="标题"]',
|
|
26
|
-
authorInput: '#author',
|
|
27
|
-
editor: '.ProseMirror:not([data-placeholder])',
|
|
28
|
-
summaryInput: '#js_description',
|
|
29
|
-
saveDraftBtn: 'button:has-text("保存为草稿")',
|
|
30
|
-
originalSwitch: '.setting-group__switch.js_edit_ori',
|
|
31
|
-
tagRow: '.allow_click_opr.js_article_tags_label',
|
|
32
|
-
tagInput: 'input[placeholder*="合集"]',
|
|
33
|
-
claimSourceCheckbox: '#js_claim_source_area .frm_checkbox_label',
|
|
34
|
-
coverBtn: '.js_cover_btn_area',
|
|
35
|
-
publishBtn: '.js_publish, button:has-text("发表"), button:has-text("发布")',
|
|
36
|
-
confirmBtn: '.weui-desktop-dialog .weui-desktop-btn_primary, button:has-text("确定")',
|
|
37
|
-
toast: '.weui-desktop-toast',
|
|
38
|
-
} as const;
|
|
36
|
+
export const CHANNEL = TARGET;
|
|
37
|
+
export type { ModeInput, WechatmpOperation } from './modes';
|
|
38
|
+
export type { OpenConsole, OpenedConsole, WechatLocator, WechatPage } from './page';
|
|
39
|
+
export { wrapPlaywrightPage } from './page';
|
|
40
|
+
export { CONSOLE_URL, markdownToWechatHtml, runMode, WECHATMP_OPERATIONS };
|
|
39
41
|
|
|
42
|
+
/** Console readiness deadline: the headless-visit scale, not the 180s interactive login wait. */
|
|
40
43
|
const CONSOLE_READY_TIMEOUT_MS = 30_000;
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
44
|
+
/** Settle before the save click: the editor must have registered the paste before it can be saved. */
|
|
45
|
+
const SUBMIT_SETTLE_MS = 1_500;
|
|
46
|
+
/** The observed-draft-signal window after a save click (the pre-ADR-029 toast deadline was 15s). */
|
|
47
|
+
const DRAFT_SIGNAL_TIMEOUT_MS = 15_000;
|
|
48
|
+
/** The save click is retried — the console can drop one that lands while the editor is settling. */
|
|
49
|
+
const SUBMIT_ATTEMPTS = 2;
|
|
50
|
+
|
|
51
|
+
function fail(error: string): Result {
|
|
52
|
+
return ResultSchema.parse({ ok: false, target: TARGET, error });
|
|
45
53
|
}
|
|
46
54
|
|
|
47
|
-
|
|
48
|
-
|
|
55
|
+
/** Console home readiness: an authenticated route carries the session token (discovery §1/§9-W9). */
|
|
56
|
+
export function isConsoleReady(page: { url(): string }): boolean {
|
|
57
|
+
return readConsoleToken(page.url()) !== undefined || page.url().includes(CONSOLE_HOME_FRAGMENT);
|
|
49
58
|
}
|
|
50
59
|
|
|
51
60
|
/**
|
|
52
|
-
*
|
|
53
|
-
*
|
|
61
|
+
* The recorded login signal for this channel (ADR-026): an authenticated session lands on
|
|
62
|
+
* `/cgi-bin/home`. Body text is unusable — `登录` also appears on a live console, which
|
|
63
|
+
* false-negatived a successful login (W9) — so the URL is the only safe read.
|
|
54
64
|
*/
|
|
55
|
-
export function
|
|
56
|
-
|
|
57
|
-
const out: string[] = [];
|
|
58
|
-
let inCode = false;
|
|
59
|
-
let code: string[] = [];
|
|
60
|
-
let listType: 'ul' | 'ol' | null = null;
|
|
61
|
-
|
|
62
|
-
const closeList = (): void => {
|
|
63
|
-
if (listType) {
|
|
64
|
-
out.push(`</${listType}>`);
|
|
65
|
-
listType = null;
|
|
66
|
-
}
|
|
67
|
-
};
|
|
68
|
-
|
|
69
|
-
const inline = (text: string): string =>
|
|
70
|
-
escapeHtml(text)
|
|
71
|
-
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
|
72
|
-
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
|
|
73
|
-
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
74
|
-
// `href` is already HTML-escaped (inline() escapes before replacing), so a `"` can no
|
|
75
|
-
// longer close the attribute. The scheme allowlist is still required: escaping does not
|
|
76
|
-
// neutralise `javascript:`. Non-http(s) links degrade to their label text.
|
|
77
|
-
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, text: string, href: string) =>
|
|
78
|
-
/^https?:\/\//i.test(href) ? `<a href="${href}">${text}</a>` : text,
|
|
79
|
-
);
|
|
80
|
-
|
|
81
|
-
for (const line of lines) {
|
|
82
|
-
if (line.startsWith('```')) {
|
|
83
|
-
if (inCode) {
|
|
84
|
-
out.push(`<pre><code>${escapeHtml(code.join('\n'))}</code></pre>`);
|
|
85
|
-
code = [];
|
|
86
|
-
inCode = false;
|
|
87
|
-
} else {
|
|
88
|
-
closeList();
|
|
89
|
-
inCode = true;
|
|
90
|
-
}
|
|
91
|
-
continue;
|
|
92
|
-
}
|
|
93
|
-
if (inCode) {
|
|
94
|
-
code.push(line);
|
|
95
|
-
continue;
|
|
96
|
-
}
|
|
97
|
-
const heading = line.match(/^(#{1,6})\s+(.+)$/);
|
|
98
|
-
if (heading?.[1] && heading[2]) {
|
|
99
|
-
closeList();
|
|
100
|
-
const level = Math.min(heading[1].length, 3);
|
|
101
|
-
out.push(`<h${level}>${inline(heading[2])}</h${level}>`);
|
|
102
|
-
continue;
|
|
103
|
-
}
|
|
104
|
-
const ul = line.match(/^[-*]\s+(.+)$/);
|
|
105
|
-
if (ul?.[1]) {
|
|
106
|
-
if (listType !== 'ul') {
|
|
107
|
-
closeList();
|
|
108
|
-
listType = 'ul';
|
|
109
|
-
out.push('<ul>');
|
|
110
|
-
}
|
|
111
|
-
out.push(`<li>${inline(ul[1])}</li>`);
|
|
112
|
-
continue;
|
|
113
|
-
}
|
|
114
|
-
const ol = line.match(/^\d+\.\s+(.+)$/);
|
|
115
|
-
if (ol?.[1]) {
|
|
116
|
-
if (listType !== 'ol') {
|
|
117
|
-
closeList();
|
|
118
|
-
listType = 'ol';
|
|
119
|
-
out.push('<ol>');
|
|
120
|
-
}
|
|
121
|
-
out.push(`<li>${inline(ol[1])}</li>`);
|
|
122
|
-
continue;
|
|
123
|
-
}
|
|
124
|
-
closeList();
|
|
125
|
-
if (line.trim().length === 0) continue;
|
|
126
|
-
out.push(`<p>${inline(line.trim())}</p>`);
|
|
127
|
-
}
|
|
128
|
-
closeList();
|
|
129
|
-
if (inCode) out.push(`<pre><code>${escapeHtml(code.join('\n'))}</code></pre>`);
|
|
130
|
-
return out.join('');
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function resolveTitle(content: Content): string {
|
|
134
|
-
if (typeof content.title === 'string' && content.title.trim() !== '') return content.title.trim();
|
|
135
|
-
return fail('wechatmp-pub: content.title is required');
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function resolveDigest(content: Content): string | undefined {
|
|
139
|
-
const body = typeof content.body === 'string' ? content.body : '';
|
|
140
|
-
if (body.trim() === '') return undefined;
|
|
141
|
-
const first = body
|
|
142
|
-
.split('\n')
|
|
143
|
-
.map((line) => line.trim())
|
|
144
|
-
.find((line) => line !== '');
|
|
145
|
-
return first ? first.slice(0, 54) : undefined;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// ---------------------------------------------------------------------------
|
|
149
|
-
// Browser flow (ts-browser-automation; pattern mirrors xhs-pub)
|
|
150
|
-
// ---------------------------------------------------------------------------
|
|
151
|
-
|
|
152
|
-
export interface WechatPage {
|
|
153
|
-
goto(url: string): Promise<void>;
|
|
154
|
-
url(): string;
|
|
155
|
-
fill(selector: string, value: string): Promise<void>;
|
|
156
|
-
click(selector: string, timeoutMs?: number): Promise<void>;
|
|
157
|
-
press?(key: string): Promise<void>;
|
|
158
|
-
setInnerHtml(selector: string, html: string): Promise<void>;
|
|
159
|
-
waitFor(selector: string, timeoutMs: number): Promise<void>;
|
|
160
|
-
waitForAny(selectors: readonly string[], timeoutMs: number): Promise<string>;
|
|
161
|
-
/** Resolves when the page URL contains the fragment (real browsers); absent on test fakes. */
|
|
162
|
-
waitForUrlFragment?(fragment: string, timeoutMs: number): Promise<void>;
|
|
163
|
-
setInputFiles?(selector: string, files: string): Promise<void>;
|
|
164
|
-
waitForTimeout?(ms: number): Promise<void>;
|
|
165
|
-
/** Evaluate a JS expression in the page (real browsers) - used for commit verification; absent on fakes. */
|
|
166
|
-
evalJs?(expression: string): Promise<unknown>;
|
|
167
|
-
/** Click and adopt the tab the console opens for the editor; absent on test fakes (same-page click). */
|
|
168
|
-
clickAndAwaitNewPage?(selector: string): Promise<WechatPage>;
|
|
169
|
-
/** Present on real browser pages; absent on test fakes. publish() closes it in finally. */
|
|
170
|
-
close?(): Promise<void>;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
export function isWechatLoggedIn(page: Page): boolean {
|
|
174
|
-
return page.url().includes(SEL.loggedInFragment);
|
|
65
|
+
export function isWechatLoggedIn(page: { url(): string }): boolean {
|
|
66
|
+
return page.url().includes(CONSOLE_HOME_FRAGMENT);
|
|
175
67
|
}
|
|
176
68
|
|
|
177
69
|
export type SessionCheck = (profileDir: string) => Promise<void>;
|
|
@@ -182,100 +74,66 @@ export function storageStatePath(profileDir: string): string {
|
|
|
182
74
|
return `${profileDir}/storage-state.json`;
|
|
183
75
|
}
|
|
184
76
|
|
|
185
|
-
/** Headless session preflight: throws (incl. BrowserProfileMissingError) when the profile cannot drive the console.
|
|
186
|
-
|
|
187
|
-
|
|
77
|
+
/** Headless session preflight: throws (incl. BrowserProfileMissingError) when the profile cannot drive the console.
|
|
78
|
+
* 0216: `headless: false` probes headed only and `timeoutMs` overrides the ready deadline. */
|
|
79
|
+
export async function checkWechatSession(
|
|
80
|
+
profileDir: string,
|
|
81
|
+
opts: { headless?: boolean; timeoutMs?: number } = {},
|
|
82
|
+
): Promise<void> {
|
|
83
|
+
const { page, context } = await openWithState(profileDir, opts.headless ?? true);
|
|
188
84
|
try {
|
|
189
|
-
await pollLogin(page);
|
|
85
|
+
await pollLogin(page, opts.timeoutMs);
|
|
190
86
|
} finally {
|
|
191
87
|
await context.close();
|
|
192
88
|
}
|
|
193
89
|
}
|
|
194
90
|
|
|
195
|
-
async function pollLogin(page: Page): Promise<void> {
|
|
196
|
-
const deadline =
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
}
|
|
204
|
-
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
205
|
-
}
|
|
91
|
+
async function pollLogin(page: Page, timeoutMs?: number): Promise<void> {
|
|
92
|
+
const deadline = timeoutMs ?? CONSOLE_READY_TIMEOUT_MS;
|
|
93
|
+
const ready = await waitUntil(() => Promise.resolve(isConsoleReady(page)), deadline, 1000);
|
|
94
|
+
if (ready) return;
|
|
95
|
+
throw new Error(
|
|
96
|
+
`Authentication was not confirmed within ${deadline}ms — the saved session state has ` +
|
|
97
|
+
'likely expired: re-run `bun src/profile-check.ts login` to sign in again.',
|
|
98
|
+
);
|
|
206
99
|
}
|
|
207
100
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
export function
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
page
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
.first()
|
|
235
|
-
.waitFor({ state: 'visible', timeout: timeoutMs })
|
|
236
|
-
.then(() => undefined),
|
|
237
|
-
waitForAny: async (selectors, timeoutMs) => {
|
|
238
|
-
const deadline = Date.now() + timeoutMs;
|
|
239
|
-
while (Date.now() < deadline) {
|
|
240
|
-
for (const selector of selectors) {
|
|
241
|
-
try {
|
|
242
|
-
await page.locator(selector).first().waitFor({ state: 'visible', timeout: 250 });
|
|
243
|
-
return selector;
|
|
244
|
-
} catch {
|
|
245
|
-
// try the next candidate
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
return '';
|
|
250
|
-
},
|
|
251
|
-
waitForUrlFragment: (fragment, timeoutMs) =>
|
|
252
|
-
page
|
|
253
|
-
.waitForURL(new RegExp(fragment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), { timeout: timeoutMs })
|
|
254
|
-
.then(() => undefined),
|
|
255
|
-
setInputFiles: (selector, files) => page.locator(selector).first().setInputFiles(files),
|
|
256
|
-
waitForTimeout: (ms) => page.waitForTimeout(ms),
|
|
257
|
-
evalJs: (expression) => page.evaluate(expression),
|
|
258
|
-
press: (key) => page.keyboard.press(key),
|
|
259
|
-
clickAndAwaitNewPage: context
|
|
260
|
-
? async (selector) => {
|
|
261
|
-
const popup = context.waitForEvent('page', { timeout: 15000 }).catch(() => undefined);
|
|
262
|
-
await page.locator(selector).first().click();
|
|
263
|
-
const opened = await popup;
|
|
264
|
-
if (!opened) return wrapPlaywrightPage(page, context);
|
|
265
|
-
await opened.waitForLoadState('domcontentloaded').catch(() => {});
|
|
266
|
-
return wrapPlaywrightPage(opened, context);
|
|
267
|
-
}
|
|
268
|
-
: undefined,
|
|
269
|
-
close: context ? () => context.close() : undefined,
|
|
270
|
-
};
|
|
101
|
+
/** Headed one-time login: open the console, wait for the QR scan to land on `/cgi-bin/home`, let
|
|
102
|
+
* the console finish its post-auth Set-Cookies, then capture the durable storageState file —
|
|
103
|
+
* mp.weixin.qq.com grant cookies do not survive a Chromium restart, so the saved state (not the
|
|
104
|
+
* profile cookie store) is the session artifact the publish flow re-injects. Shared by the CLI
|
|
105
|
+
* (`profile-check login`, 0216) so the publish flow and the probe use one login path. */
|
|
106
|
+
export async function openWechatLogin(opts: { profileDir: string; timeoutMs?: number }): Promise<void> {
|
|
107
|
+
const timeoutMs = opts.timeoutMs ?? 180_000;
|
|
108
|
+
const context = await openPersistentBrowser({
|
|
109
|
+
profileDir: opts.profileDir,
|
|
110
|
+
headless: false,
|
|
111
|
+
launcher: kkBrowserLauncher,
|
|
112
|
+
});
|
|
113
|
+
try {
|
|
114
|
+
const page = context.pages()[0] ?? (await context.newPage());
|
|
115
|
+
await page.goto(CONSOLE_URL);
|
|
116
|
+
const ready = await waitUntil(
|
|
117
|
+
() => Promise.resolve(page.url().includes(CONSOLE_HOME_FRAGMENT)),
|
|
118
|
+
timeoutMs,
|
|
119
|
+
1000,
|
|
120
|
+
);
|
|
121
|
+
if (!ready) throw new Error(`QR scan not completed within ${timeoutMs / 1000}s`);
|
|
122
|
+
await page.waitForTimeout(10_000); // settle: let the console finish its post-auth Set-Cookies
|
|
123
|
+
await context.storageState({ path: storageStatePath(opts.profileDir) });
|
|
124
|
+
} finally {
|
|
125
|
+
await context.close();
|
|
126
|
+
}
|
|
271
127
|
}
|
|
272
128
|
|
|
273
129
|
/** Launch, inject the saved login cookies, and land on the console. Caller owns closing the context. */
|
|
274
130
|
async function openWithState(profileDir: string, headless: boolean): Promise<{ page: Page; context: BrowserContext }> {
|
|
275
|
-
const context = await openPersistentBrowser({ profileDir, headless });
|
|
131
|
+
const context = await openPersistentBrowser({ profileDir, headless, launcher: kkBrowserLauncher });
|
|
276
132
|
try {
|
|
277
133
|
const stateFile = storageStatePath(profileDir);
|
|
278
|
-
let state:
|
|
134
|
+
let state:
|
|
135
|
+
| { cookies?: Cookie[]; origins?: { origin?: string; localStorage?: { name: string; value: string }[] }[] }
|
|
136
|
+
| undefined;
|
|
279
137
|
try {
|
|
280
138
|
state = JSON.parse(await createNodeFileSystem().readFile(stateFile));
|
|
281
139
|
} catch {
|
|
@@ -289,6 +147,23 @@ async function openWithState(profileDir: string, headless: boolean): Promise<{ p
|
|
|
289
147
|
}
|
|
290
148
|
await context.addCookies(cookies);
|
|
291
149
|
const page = context.pages()[0] ?? (await context.newPage());
|
|
150
|
+
// `addCookies` alone is NOT the session: the captured state also carries localStorage for
|
|
151
|
+
// mp.weixin.qq.com (its `origins` half), and discarding it lands the console on its login page
|
|
152
|
+
// even though every cookie was accepted — observed live 2026-10-05, where `check` reported OK
|
|
153
|
+
// while the flow sat on a QR page. Replay each origin's storage before the console navigation.
|
|
154
|
+
const origins = state?.origins ?? [];
|
|
155
|
+
for (const entry of origins) {
|
|
156
|
+
const items = entry.localStorage ?? [];
|
|
157
|
+
if (entry.origin === undefined || entry.origin.length === 0 || items.length === 0) continue;
|
|
158
|
+
await page.goto(entry.origin).catch(() => undefined);
|
|
159
|
+
await page
|
|
160
|
+
.evaluate(
|
|
161
|
+
// A string expression, not a callback: this plugin's tsconfig has no DOM lib.
|
|
162
|
+
'(pairs) => { for (const pair of pairs) { window.localStorage.setItem(pair.name, pair.value); } }',
|
|
163
|
+
items,
|
|
164
|
+
)
|
|
165
|
+
.catch(() => undefined);
|
|
166
|
+
}
|
|
292
167
|
await page.goto(CONSOLE_URL);
|
|
293
168
|
return { page, context };
|
|
294
169
|
} catch (error: unknown) {
|
|
@@ -297,37 +172,86 @@ async function openWithState(profileDir: string, headless: boolean): Promise<{ p
|
|
|
297
172
|
}
|
|
298
173
|
}
|
|
299
174
|
|
|
300
|
-
|
|
301
|
-
|
|
175
|
+
/**
|
|
176
|
+
* Default launcher: the console home wrapped as the `OpenConsole` seam (entry page + the sibling
|
|
177
|
+
* pages a creation-type click opens). Headless works end-to-end for the draft flow (probed
|
|
178
|
+
* 2026-09-29), so no window is popped — the preflight is what keeps a sessionless run from even
|
|
179
|
+
* trying.
|
|
180
|
+
*/
|
|
181
|
+
async function defaultOpenConsole(profileDir: string): Promise<OpenedConsole> {
|
|
302
182
|
const { page, context } = await openWithState(profileDir, true);
|
|
303
|
-
return
|
|
183
|
+
return wrapPlaywrightContext(context, page);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
type BrowserDriverName = 'script' | 'agent';
|
|
187
|
+
|
|
188
|
+
export interface WechatmpTransportOptions {
|
|
189
|
+
/** ADR-029 dispatch: the console modes need the Playwright script driver. */
|
|
190
|
+
driver?: BrowserDriverName;
|
|
191
|
+
openPage?: OpenConsole;
|
|
192
|
+
checkSession?: SessionCheck;
|
|
193
|
+
/** Deadline for the console to read ready after launch (default 30s). */
|
|
194
|
+
consoleReadyTimeoutMs?: number;
|
|
195
|
+
stepTimeoutMs?: number;
|
|
196
|
+
/** Re-poll interval for observed-signal waits (default 500ms); tests pass 0-5ms. */
|
|
197
|
+
pollMs?: number;
|
|
198
|
+
submitSettleMs?: number;
|
|
199
|
+
/** How long an observed draft signal is waited for after a save click (default 15s). */
|
|
200
|
+
draftTimeoutMs?: number;
|
|
201
|
+
submitAttempts?: number;
|
|
304
202
|
}
|
|
305
203
|
|
|
306
204
|
export class WechatmpBrowserTransport implements PublishTransport {
|
|
307
205
|
readonly kind = 'browser';
|
|
308
|
-
|
|
206
|
+
readonly driver: BrowserDriverName;
|
|
207
|
+
private readonly openPage: OpenConsole;
|
|
309
208
|
private readonly checkSession: SessionCheck;
|
|
209
|
+
private readonly consoleReadyTimeoutMs: number;
|
|
210
|
+
private readonly stepTimeoutMs: number;
|
|
211
|
+
private readonly pollMs: number;
|
|
212
|
+
private readonly submitSettleMs: number;
|
|
213
|
+
private readonly draftTimeoutMs: number;
|
|
214
|
+
private readonly submitAttempts: number;
|
|
310
215
|
|
|
311
216
|
constructor(options: WechatmpTransportOptions = {}) {
|
|
312
|
-
this.
|
|
217
|
+
this.driver = options.driver ?? 'script';
|
|
218
|
+
this.openPage = options.openPage ?? defaultOpenConsole;
|
|
313
219
|
this.checkSession = options.checkSession ?? ((profileDir) => checkWechatSession(profileDir));
|
|
220
|
+
this.consoleReadyTimeoutMs = options.consoleReadyTimeoutMs ?? CONSOLE_READY_TIMEOUT_MS;
|
|
221
|
+
this.stepTimeoutMs = options.stepTimeoutMs ?? STEP_TIMEOUT_MS;
|
|
222
|
+
this.pollMs = options.pollMs ?? POLL_MS;
|
|
223
|
+
this.submitSettleMs = options.submitSettleMs ?? SUBMIT_SETTLE_MS;
|
|
224
|
+
this.draftTimeoutMs = options.draftTimeoutMs ?? DRAFT_SIGNAL_TIMEOUT_MS;
|
|
225
|
+
this.submitAttempts = options.submitAttempts ?? SUBMIT_ATTEMPTS;
|
|
314
226
|
}
|
|
315
227
|
|
|
228
|
+
/**
|
|
229
|
+
* Publish through the console. Everything decidable without a browser is decided first (driver,
|
|
230
|
+
* operation, artifacts, field limits, publishStatus, session), so an impossible or misrouted run
|
|
231
|
+
* never opens — let alone touches — a real console.
|
|
232
|
+
*/
|
|
316
233
|
async publish(payload: PublishTransportPayload): Promise<Result> {
|
|
317
234
|
const { content, options } = payload;
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
if (want !== 'draft' && want !== 'public') {
|
|
323
|
-
return this.failed(`publishStatus "${want}" is not draft|public`);
|
|
324
|
-
}
|
|
325
|
-
const title = resolveTitle(content);
|
|
326
|
-
if (typeof content.body !== 'string' || content.body.trim() === '') {
|
|
327
|
-
return this.failed('wechatmp-pub: content.body is required');
|
|
235
|
+
if (this.driver !== 'script') {
|
|
236
|
+
return fail(
|
|
237
|
+
`unsupported driver '${this.driver}' for ${TARGET}: the console modes (${WECHATMP_OPERATIONS.join(', ')}) require the script driver (Playwright setInputFiles)`,
|
|
238
|
+
);
|
|
328
239
|
}
|
|
240
|
+
// ADR-029 dispatch: an undeclared operation is refused by name before a browser is launched
|
|
241
|
+
// or a profile is resolved, so a misrouted run cannot open a session it will not use.
|
|
242
|
+
const requested = resolveWechatmpOperation(options, content);
|
|
243
|
+
if (requested.error !== undefined) return fail(requested.error);
|
|
244
|
+
|
|
245
|
+
// Mode validation: a missing artifact (which the 视频/播客 media modal would otherwise hide
|
|
246
|
+
// the save bar for), an over-limit field or an unknown publishStatus all fail here.
|
|
247
|
+
const validation = await validateModeInput(requested.operation, content);
|
|
248
|
+
if (validation.error !== undefined) return fail(validation.error);
|
|
249
|
+
|
|
329
250
|
const profileDir = resolveBrowserProfileDir(TARGET, process.env.WECHATMP_PROFILE_DIR);
|
|
251
|
+
const publishStatus = content.metadata?.publishStatus === 'public' ? 'public' : 'draft';
|
|
330
252
|
|
|
253
|
+
// ADR-026 rail: a headless preflight refuses a sessionless run fast, so no console visit is
|
|
254
|
+
// ever attempted with a dead session (and the draft default is this channel's guard).
|
|
331
255
|
try {
|
|
332
256
|
await this.checkSession(profileDir);
|
|
333
257
|
} catch (error: unknown) {
|
|
@@ -335,296 +259,75 @@ export class WechatmpBrowserTransport implements PublishTransport {
|
|
|
335
259
|
error instanceof BrowserProfileMissingError
|
|
336
260
|
? 'no saved console profile — log in once via `bun src/profile-check.ts login`'
|
|
337
261
|
: `console session preflight failed (${error instanceof Error ? error.message : String(error)})`;
|
|
338
|
-
// Non-blocking contract (mirrors xhs-pub): a sessionless run refuses fast and stays
|
|
339
|
-
// it must never open a
|
|
340
|
-
return
|
|
262
|
+
// Non-blocking contract (mirrors xhs-pub): a sessionless run refuses fast and stays
|
|
263
|
+
// draftless — it must never open a login wall inside an unattended run.
|
|
264
|
+
return fail(`${TARGET}-pub: ${why}`);
|
|
341
265
|
}
|
|
342
266
|
|
|
343
|
-
|
|
344
|
-
let enrichWarnings: string[] = [];
|
|
267
|
+
let opened: OpenedConsole;
|
|
345
268
|
try {
|
|
346
|
-
await
|
|
347
|
-
// The console opens the article editor in a new tab — adopt it when it appears.
|
|
348
|
-
const editor = page.clickAndAwaitNewPage
|
|
349
|
-
? await page.clickAndAwaitNewPage(`${SEL.menuItem}:has-text("图文"), ${SEL.menuItem}`)
|
|
350
|
-
: page;
|
|
351
|
-
if (editor === page) {
|
|
352
|
-
await page.click(`${SEL.menuItem}:has-text("图文"), ${SEL.menuItem}`);
|
|
353
|
-
}
|
|
354
|
-
await editor.waitFor(SEL.titleInput, CONSOLE_READY_TIMEOUT_MS);
|
|
355
|
-
await editor.fill(SEL.titleInput, title);
|
|
356
|
-
await this.tryFill(editor, SEL.authorInput, String(content.metadata?.author ?? ''));
|
|
357
|
-
await editor.waitFor(SEL.editor, CONSOLE_READY_TIMEOUT_MS);
|
|
358
|
-
await editor.setInnerHtml(SEL.editor, markdownToWechatHtml(content.body));
|
|
359
|
-
const digest = resolveDigest(content);
|
|
360
|
-
if (digest) await this.tryFill(editor, SEL.summaryInput, digest);
|
|
361
|
-
|
|
362
|
-
const enrichMeta = content.metadata?.wechatmp;
|
|
363
|
-
if (enrichMeta && typeof enrichMeta === 'object' && !Array.isArray(enrichMeta)) {
|
|
364
|
-
enrichWarnings = await this.enrichDraft(editor, enrichMeta as Record<string, unknown>);
|
|
365
|
-
const audioTitle = (enrichMeta as Record<string, unknown>).audioTitle;
|
|
366
|
-
if (typeof audioTitle === 'string' && audioTitle.trim() !== '') {
|
|
367
|
-
const audioWarn = await this.insertAudio(editor, audioTitle.trim());
|
|
368
|
-
if (audioWarn !== '') enrichWarnings.push(audioWarn);
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
if (want === 'draft') {
|
|
373
|
-
await editor.click(SEL.saveDraftBtn);
|
|
374
|
-
} else {
|
|
375
|
-
await editor.click(SEL.publishBtn);
|
|
376
|
-
await this.tryClick(editor, SEL.confirmBtn);
|
|
377
|
-
}
|
|
378
|
-
// Real consoles confirm the draft save by navigating from the isNew editor
|
|
379
|
-
// (appmsg_edit_v2) to the saved-draft edit page carrying the new appmsgid.
|
|
380
|
-
if (editor.waitForUrlFragment) {
|
|
381
|
-
await editor.waitForUrlFragment('appmsg_edit&action=edit', CONSOLE_READY_TIMEOUT_MS);
|
|
382
|
-
} else {
|
|
383
|
-
const seen = await editor.waitForAny([SEL.toast], TOAST_TIMEOUT_MS);
|
|
384
|
-
if (seen === '') {
|
|
385
|
-
return this.failed(
|
|
386
|
-
`save was clicked but no confirmation toast appeared within ${TOAST_TIMEOUT_MS}ms — verify the console manually`,
|
|
387
|
-
);
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
const appmsgid = /appmsgid=(\d+)/.exec(editor.url())?.[1] ?? '';
|
|
391
|
-
return {
|
|
392
|
-
ok: true,
|
|
393
|
-
target: TARGET,
|
|
394
|
-
metadata: {
|
|
395
|
-
publishStatus: want,
|
|
396
|
-
note:
|
|
397
|
-
want === 'draft'
|
|
398
|
-
? `saved to the WeChat MP draft box (草稿箱)${appmsgid ? ` as appmsgid ${appmsgid}` : ''}`
|
|
399
|
-
: 'published via the console 发表 flow',
|
|
400
|
-
...(enrichWarnings.length > 0 ? { enrichWarnings } : {}),
|
|
401
|
-
},
|
|
402
|
-
};
|
|
269
|
+
opened = await this.openPage(profileDir);
|
|
403
270
|
} catch (error: unknown) {
|
|
404
|
-
|
|
405
|
-
const enrich = enrichWarnings.length > 0 ? ` (enrich warnings: ${enrichWarnings.join(' | ')})` : '';
|
|
406
|
-
return this.failed(`console flow failed: ${why}${enrich}`);
|
|
407
|
-
} finally {
|
|
408
|
-
await page.close?.();
|
|
271
|
+
return fail(error instanceof Error ? error.message : String(error));
|
|
409
272
|
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
* Runs on a FRESH draft only - the toggles are not state-aware (no idempotency for re-runs).
|
|
415
|
-
* Per-field failures are collected into warnings and surfaced in the Result; the draft still saves.
|
|
416
|
-
* Dialog mapping evidence (probes 2026-09-29, drafts 100000805/808): 原创 + 赞赏 share ONE dialog
|
|
417
|
-
* (声明类型 radios, 作者 input, 确定) - the 赞赏 switch stays hidden for this account, so 赞赏 is a manual step;
|
|
418
|
-
* 合集 dialog = 请选择合集 input + 确认; 创作来源 is an inline checkbox (no dialog); the 原文链接 row has
|
|
419
|
-
* no reachable URL input in headless - also manual; cover accepts a direct input[type=file] set.
|
|
420
|
-
* Commit verification (evalJs): the original dialog only closes when WeChat's validation passes - the
|
|
421
|
-
* author is the reward (shang) account and must be picked from its search, which does not resolve for
|
|
422
|
-
* this account headless; a non-closing dialog is swept and surfaced as a warning. Same for the
|
|
423
|
-
* creation-source checkbox (DOM re-check).
|
|
424
|
-
*/
|
|
425
|
-
private async enrichDraft(editor: WechatPage, meta: Record<string, unknown>): Promise<string[]> {
|
|
426
|
-
const DIALOG = '.weui-desktop-dialog:visible';
|
|
427
|
-
const CONFIRM = `${DIALOG} button:has-text("确认"), ${DIALOG} button:has-text("确定")`;
|
|
428
|
-
const warnings: string[] = [];
|
|
429
|
-
const str = (key: string) => (typeof meta[key] === 'string' ? (meta[key] as string) : '');
|
|
430
|
-
const on = (key: string) => meta[key] === true;
|
|
431
|
-
// Sweep any leaked dialog so the next step (especially 保存为草稿) is never overlay-blocked.
|
|
432
|
-
const sweep = async () => {
|
|
433
|
-
await this.tryClick(editor, 'button:has-text("取消")', 2000);
|
|
434
|
-
await editor.press?.('Escape');
|
|
435
|
-
await editor.waitForTimeout?.(400);
|
|
436
|
-
};
|
|
437
|
-
// One retry per step: the settings panel re-renders after dialog confirms and can briefly
|
|
438
|
-
// detach rows (observed on the tags row right after the original-declaration confirm).
|
|
439
|
-
const step = async (name: string, fn: () => Promise<void>) => {
|
|
440
|
-
try {
|
|
441
|
-
await fn();
|
|
442
|
-
return;
|
|
443
|
-
} catch {
|
|
444
|
-
// retry once below
|
|
445
|
-
}
|
|
446
|
-
await sweep();
|
|
447
|
-
await editor.waitForTimeout?.(1000);
|
|
448
|
-
try {
|
|
449
|
-
await fn();
|
|
450
|
-
} catch (error) {
|
|
451
|
-
warnings.push(`${name}: ${(error instanceof Error ? error.message : String(error)).slice(0, 120)}`);
|
|
452
|
-
await sweep();
|
|
453
|
-
}
|
|
454
|
-
};
|
|
455
|
-
if (on('original')) {
|
|
456
|
-
await step('原创声明', async () => {
|
|
457
|
-
await editor.click(SEL.originalSwitch, 10000);
|
|
458
|
-
await editor.click(`${DIALOG} :text("文字原创")`, 8000);
|
|
459
|
-
const author = str('author');
|
|
460
|
-
if (author) await this.tryFill(editor, `${DIALOG} input[placeholder*="作者"]`, author);
|
|
461
|
-
await editor.click(CONFIRM, 8000);
|
|
462
|
-
});
|
|
463
|
-
const committed = await editor.evalJs?.(
|
|
464
|
-
`[...document.querySelectorAll('.weui-desktop-dialog')].every((d) => !d.offsetWidth)`,
|
|
273
|
+
try {
|
|
274
|
+
const ready = await waitUntil(
|
|
275
|
+
() => Promise.resolve(isConsoleReady(opened.page)),
|
|
276
|
+
this.consoleReadyTimeoutMs,
|
|
465
277
|
);
|
|
466
|
-
if (
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
const collection = str('collection');
|
|
472
|
-
if (collection) {
|
|
473
|
-
await step('合集', async () => {
|
|
474
|
-
await editor.click(SEL.tagRow, 8000);
|
|
475
|
-
await editor.waitFor(SEL.tagInput, 8000);
|
|
476
|
-
await editor.fill(SEL.tagInput, collection);
|
|
477
|
-
await editor.waitForTimeout?.(1200);
|
|
478
|
-
await editor.click(`${DIALOG} :text("${collection}")`, 8000);
|
|
479
|
-
await editor.click(CONFIRM, 8000);
|
|
480
|
-
});
|
|
481
|
-
}
|
|
482
|
-
if (on('aiGenerated')) {
|
|
483
|
-
await step('创作来源', async () => {
|
|
484
|
-
await editor.click(SEL.claimSourceCheckbox, 8000);
|
|
485
|
-
// Probes show no dialog on current builds — the checkbox alone declares AI generation.
|
|
486
|
-
const dialog = await editor.waitForAny([DIALOG], 3000);
|
|
487
|
-
if (dialog) {
|
|
488
|
-
await editor.click(`${DIALOG} :text("内容由AI生成")`, 8000);
|
|
489
|
-
await editor.click(CONFIRM, 8000);
|
|
490
|
-
}
|
|
491
|
-
});
|
|
492
|
-
const checked = await editor.evalJs?.(`!!document.querySelector('#js_claim_source_area input:checked')`);
|
|
493
|
-
if (checked === false) warnings.push('创作来源: 勾选未生效,请手动设置');
|
|
494
|
-
}
|
|
495
|
-
const coverPath = str('coverPath');
|
|
496
|
-
const putFile = editor.setInputFiles?.bind(editor);
|
|
497
|
-
if (coverPath && putFile) {
|
|
498
|
-
await step('封面', async () => {
|
|
499
|
-
try {
|
|
500
|
-
await putFile('input[type="file"]', coverPath);
|
|
501
|
-
} catch {
|
|
502
|
-
await editor.click(SEL.coverBtn, 8000);
|
|
503
|
-
await editor.waitForTimeout?.(1500);
|
|
504
|
-
await putFile('input[type="file"]', coverPath);
|
|
505
|
-
}
|
|
506
|
-
const cropBtn = await editor.waitForAny(
|
|
507
|
-
[`${DIALOG} button:has-text("完成")`, `${DIALOG} button:has-text("确定")`],
|
|
508
|
-
5000,
|
|
278
|
+
if (!ready) {
|
|
279
|
+
return fail(
|
|
280
|
+
`no authenticated ${TARGET} console within ${this.consoleReadyTimeoutMs}ms — the preflight passed but the console did not report a session route (profile ${profileDir})`,
|
|
509
281
|
);
|
|
510
|
-
if (cropBtn) await editor.click(cropBtn, 8000);
|
|
511
|
-
});
|
|
512
|
-
}
|
|
513
|
-
await sweep();
|
|
514
|
-
return warnings;
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
/**
|
|
518
|
-
* Audio embed at the article top (operator request 2026-09-29): open the editor's audio menu
|
|
519
|
-
* (a hover dropdown - JS click bypasses the hover gate), reuse an existing same-title 素材库
|
|
520
|
-
* material, and insert at the caret (placed at the article start first). Upload of a missing
|
|
521
|
-
* material is deliberately manual for now: the 上传音频 form is iframe-based and unmapped.
|
|
522
|
-
* Fail-soft: every non-committed outcome returns a warning string; '' means placed at top.
|
|
523
|
-
*/
|
|
524
|
-
private async insertAudio(editor: WechatPage, title: string): Promise<string> {
|
|
525
|
-
const evalJs = editor.evalJs;
|
|
526
|
-
if (typeof evalJs !== 'function') return '';
|
|
527
|
-
const CARET = `(() => {
|
|
528
|
-
const pm = [...document.querySelectorAll('.ProseMirror')].find((e) => !(e.getAttribute('data-placeholder') ?? '').includes('标题'));
|
|
529
|
-
if (!pm || !pm.firstChild) return;
|
|
530
|
-
const r = document.createRange();
|
|
531
|
-
r.setStart(pm.firstChild, 0);
|
|
532
|
-
r.collapse(true);
|
|
533
|
-
const sel = window.getSelection();
|
|
534
|
-
sel?.removeAllRanges();
|
|
535
|
-
sel?.addRange(r);
|
|
536
|
-
})()`;
|
|
537
|
-
const DEDUPE = `(() => {
|
|
538
|
-
const want = ${JSON.stringify(title)}.replace(/\\s+/g, '');
|
|
539
|
-
const hit = [...document.querySelectorAll('label.frm_checkbox_label.audio_item')]
|
|
540
|
-
.find((l) => (l.textContent ?? '').replace(/\\s+/g, '').includes(want));
|
|
541
|
-
if (!hit) return 'missing';
|
|
542
|
-
const cb = (hit.closest('li') ?? hit.parentElement)?.querySelector('input[type=checkbox]');
|
|
543
|
-
if (!cb) return 'nobox';
|
|
544
|
-
cb.click();
|
|
545
|
-
return 'checked';
|
|
546
|
-
})()`;
|
|
547
|
-
const INSERT = `(() => {
|
|
548
|
-
const btn = [...document.querySelectorAll('button')].find((b) => (b.textContent ?? '').trim() === '插入' && b.offsetWidth > 0);
|
|
549
|
-
if (!btn) return false;
|
|
550
|
-
btn.click();
|
|
551
|
-
return true;
|
|
552
|
-
})()`;
|
|
553
|
-
const CANCEL = `(() => {
|
|
554
|
-
const btn = [...document.querySelectorAll('button')].find((b) => (b.textContent ?? '').trim() === '取消' && b.offsetWidth > 0);
|
|
555
|
-
btn?.click();
|
|
556
|
-
})()`;
|
|
557
|
-
const VERIFY = `(() => {
|
|
558
|
-
const pm = [...document.querySelectorAll('.ProseMirror')].find((e) => !(e.getAttribute('data-placeholder') ?? '').includes('标题'));
|
|
559
|
-
if (!pm) return 'noeditor';
|
|
560
|
-
const node = pm.querySelector('mpvoice, [class*="audio"]');
|
|
561
|
-
if (!node) return 'absent';
|
|
562
|
-
const first = pm.firstElementChild;
|
|
563
|
-
return first && (first === node || first.contains(node)) ? 'top' : 'notop';
|
|
564
|
-
})()`;
|
|
565
|
-
try {
|
|
566
|
-
const run = (expression: string) => evalJs.call(editor, expression);
|
|
567
|
-
await run(CARET);
|
|
568
|
-
await run("document.querySelector('li.js_insertaudio')?.click()");
|
|
569
|
-
await editor.waitFor('.audio_dialog', TOAST_TIMEOUT_MS);
|
|
570
|
-
// The 素材库 list loads async after the dialog opens - retry while rows stream in.
|
|
571
|
-
let dedupe = String(await run(DEDUPE));
|
|
572
|
-
for (let i = 0; dedupe === 'missing' && i < 2; i++) {
|
|
573
|
-
await editor.waitForTimeout?.(1200);
|
|
574
|
-
dedupe = String(await run(DEDUPE));
|
|
575
|
-
}
|
|
576
|
-
if (dedupe !== 'checked') {
|
|
577
|
-
await run(CANCEL);
|
|
578
|
-
return dedupe === 'missing'
|
|
579
|
-
? '音频: 素材库无同名音频,请手动上传后插入'
|
|
580
|
-
: '音频: 同名素材行未找到勾选框,请手动插入';
|
|
581
282
|
}
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
await page.click(selector, timeoutMs);
|
|
608
|
-
} catch {
|
|
609
|
-
// confirm dialogs are not shown for every account type
|
|
283
|
+
// The 草稿箱 baseline is read before the editor opens: MP auto-saves continuously, so a
|
|
284
|
+
// row that already carried a stamp says nothing about this run's save (discovery §5.1).
|
|
285
|
+
const title = typeof content.title === 'string' ? content.title.trim() : '';
|
|
286
|
+
const draftBaseline =
|
|
287
|
+
publishStatus === 'draft' ? await readDraftBaseline(opened.page, title) : { stamp: null };
|
|
288
|
+
|
|
289
|
+
return await runMode(validation.input, {
|
|
290
|
+
entry: opened.page,
|
|
291
|
+
pages: opened.pages,
|
|
292
|
+
newPage: opened.newPage,
|
|
293
|
+
content,
|
|
294
|
+
publishStatus,
|
|
295
|
+
profileDir,
|
|
296
|
+
driver: this.driver,
|
|
297
|
+
stepTimeoutMs: this.stepTimeoutMs,
|
|
298
|
+
pollMs: this.pollMs,
|
|
299
|
+
submitSettleMs: this.submitSettleMs,
|
|
300
|
+
draftTimeoutMs: this.draftTimeoutMs,
|
|
301
|
+
submitAttempts: this.submitAttempts,
|
|
302
|
+
draftBaseline,
|
|
303
|
+
});
|
|
304
|
+
} catch (error: unknown) {
|
|
305
|
+
return fail(error instanceof Error ? error.message : String(error));
|
|
306
|
+
} finally {
|
|
307
|
+
await opened.close();
|
|
610
308
|
}
|
|
611
309
|
}
|
|
612
|
-
|
|
613
|
-
private failed(error: string): Result {
|
|
614
|
-
return { ok: false, target: TARGET, error, metadata: { publishStatus: 'draft' } };
|
|
615
|
-
}
|
|
616
310
|
}
|
|
617
311
|
|
|
618
|
-
export function getPublishTransport(): PublishTransport {
|
|
312
|
+
export function getPublishTransport(driver: BrowserDriverName = 'script'): PublishTransport {
|
|
619
313
|
if (process.env.KNOWLEDGE_KIT_PUBLISH_TRANSPORT === 'fake') return new FakeFileCliTransport();
|
|
620
|
-
return new WechatmpBrowserTransport();
|
|
314
|
+
return new WechatmpBrowserTransport({ driver });
|
|
621
315
|
}
|
|
622
316
|
|
|
317
|
+
/**
|
|
318
|
+
* `--in`/`--out` IO. A top-level `options` object in the input file is forwarded, which is how a
|
|
319
|
+
* caller selects the ADR-029 operation (`options.operation`) without touching `Content`.
|
|
320
|
+
*/
|
|
623
321
|
export async function processPublishIO(inPath: string, outPath: string, transport?: PublishTransport): Promise<void> {
|
|
624
322
|
const fs = createNodeFileSystem();
|
|
625
|
-
const raw = JSON.parse(await fs.readFile(inPath))
|
|
626
|
-
const content = ContentSchema.parse(raw);
|
|
627
|
-
const
|
|
323
|
+
const raw = JSON.parse(await fs.readFile(inPath)) as Record<string, unknown>;
|
|
324
|
+
const content: Content = ContentSchema.parse(raw);
|
|
325
|
+
const provided = raw.options;
|
|
326
|
+
const options =
|
|
327
|
+
provided !== undefined && typeof provided === 'object' && provided !== null && !Array.isArray(provided)
|
|
328
|
+
? { target: TARGET, ...(provided as Record<string, unknown>) }
|
|
329
|
+
: { target: TARGET };
|
|
330
|
+
const result = await (transport ?? getPublishTransport()).publish({ content, options });
|
|
628
331
|
await fs.writeFile(outPath, JSON.stringify(ResultSchema.parse(result), null, 2));
|
|
629
332
|
}
|
|
630
333
|
|