@gobing-ai/knowledge-kit 0.0.30 → 0.0.32
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 +10 -10
- package/package.json +4 -4
- package/plugins/generations/content-gen/dist/index.js +9 -9
- package/plugins/generations/core-facts-gen/dist/index.js +12 -12
- package/plugins/generations/daily-article-gen/dist/index.js +12 -12
- package/plugins/generations/dailynews-gen/dist/index.js +10 -10
- package/plugins/generations/dailynews-gen/src/script-builder.ts +4 -2
- package/plugins/generations/episode-plan-gen/dist/index.js +23 -13
- package/plugins/generations/episode-plan-gen/src/index.ts +23 -2
- package/plugins/generations/gemini-tts/dist/index.js +9 -9
- package/plugins/generations/image-gen/dist/index.js +9 -9
- package/plugins/generations/news-report-gen/dist/index.js +12 -12
- package/plugins/generations/voice-gen/dist/index.js +9 -9
- package/plugins/ingestions/aihot-ingest/dist/index.js +12 -12
- package/plugins/ingestions/horizon-ingest/dist/index.js +12 -12
- package/plugins/ingestions/karakeep-local/dist/index.js +13 -13
- package/plugins/ingestions/last30days-ingest/dist/index.js +12 -12
- package/plugins/ingestions/web-search/dist/index.js +10 -10
- package/plugins/kk/plugin.json +1 -1
- package/plugins/kk/scripts/kk-workflow-stages.ts +173 -15
- package/plugins/kk/skills/content-judge/SKILL.md +16 -4
- package/plugins/kk/skills/content-judge/references/rubrics.md +8 -2
- package/plugins/kk/skills/content-judge/references/workflow-integration.md +2 -1
- package/plugins/kk/skills/content-safety/SKILL.md +24 -13
- package/plugins/kk/workflows/kk-daily-ai-voice.yaml +65 -8
- package/plugins/publishings/emdash-pub/dist/index.js +9 -9
- package/plugins/publishings/infoq-pub/dist/index.js +8602 -211
- package/plugins/publishings/infoq-pub/package.json +3 -1
- package/plugins/publishings/infoq-pub/plugin.json +1 -1
- package/plugins/publishings/infoq-pub/src/index.ts +246 -29
- package/plugins/publishings/infoq-pub/src/profile-check.ts +19 -0
- package/plugins/publishings/juejin-pub/dist/index.js +8665 -211
- package/plugins/publishings/juejin-pub/package.json +3 -1
- package/plugins/publishings/juejin-pub/plugin.json +1 -1
- package/plugins/publishings/juejin-pub/src/index.ts +305 -29
- package/plugins/publishings/juejin-pub/src/profile-check.ts +19 -0
- package/plugins/publishings/medium-pub/dist/index.js +9 -9
- package/plugins/publishings/podcast-pub/dist/index.js +9 -9
- package/plugins/publishings/qiita-pub/dist/index.js +9 -9
- package/plugins/publishings/substack-pub/dist/index.js +8610 -211
- package/plugins/publishings/substack-pub/package.json +3 -1
- package/plugins/publishings/substack-pub/plugin.json +1 -1
- package/plugins/publishings/substack-pub/src/index.ts +271 -29
- package/plugins/publishings/substack-pub/src/profile-check.ts +74 -0
- package/plugins/publishings/surfdash-pub/dist/index.js +9 -9
- package/plugins/publishings/wechatmp-pub/dist/index.js +1227 -348
- 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 +186 -516
- 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 +7 -5
- package/plugins/publishings/x-pub/dist/index.js +8626 -221
- package/plugins/publishings/x-pub/package.json +3 -1
- package/plugins/publishings/x-pub/plugin.json +1 -1
- package/plugins/publishings/x-pub/src/index.ts +258 -20
- package/plugins/publishings/x-pub/src/profile-check.ts +19 -0
- package/plugins/publishings/xhs-pub/dist/index.js +843 -346
- package/plugins/publishings/xhs-pub/plugin.json +1 -1
- package/plugins/publishings/xhs-pub/src/flow.ts +572 -0
- package/plugins/publishings/xhs-pub/src/index.ts +101 -593
- package/plugins/publishings/xhs-pub/src/modes.ts +300 -0
- package/plugins/publishings/xhs-pub/src/page.ts +267 -0
- package/plugins/publishings/xhs-pub/src/profile-check.ts +2 -3
- package/plugins/publishings/zenn-pub/dist/index.js +9 -9
|
@@ -0,0 +1,896 @@
|
|
|
1
|
+
import type { Content, Result } from '@gobing-ai/kk-core';
|
|
2
|
+
import { ResultSchema } from '@gobing-ai/kk-core';
|
|
3
|
+
import { trySelectors } from '@gobing-ai/publish-harness';
|
|
4
|
+
import { expectOutcome, pasteHtml, readScopedSignal, uploadFile } from '@gobing-ai/publish-harness/browser';
|
|
5
|
+
import type { Page } from 'playwright';
|
|
6
|
+
import { enrichArticle, insertAudio } from './enrich';
|
|
7
|
+
import {
|
|
8
|
+
artifactPaths,
|
|
9
|
+
CREATE_TYPES,
|
|
10
|
+
coverPath,
|
|
11
|
+
editorUrl,
|
|
12
|
+
type ModeInput,
|
|
13
|
+
readConsoleToken,
|
|
14
|
+
readCreateType,
|
|
15
|
+
TARGET,
|
|
16
|
+
type WechatmpOperation,
|
|
17
|
+
} from './modes';
|
|
18
|
+
import {
|
|
19
|
+
clickSelector,
|
|
20
|
+
fillSelector,
|
|
21
|
+
findAttachedSelector,
|
|
22
|
+
findSelector,
|
|
23
|
+
followNewPage,
|
|
24
|
+
tryFindSelector,
|
|
25
|
+
type WechatPage,
|
|
26
|
+
} from './page';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The four wechatmp flows (ADR-029: the operation owns its entry, selector table, artifacts and
|
|
30
|
+
* success signal) plus the rails they share — mode entry, the 一键排版 steps, the 从正文选择 cover
|
|
31
|
+
* step, the 草稿箱 signal probe and the publish path.
|
|
32
|
+
*
|
|
33
|
+
* `docs/design/wechatmp-ui-discovery.md` is the evidence for every selector here; where the
|
|
34
|
+
* recorded evidence does not cover a detail the code says so rather than inventing one, and the
|
|
35
|
+
* candidate lists are the live dogfood's re-measurement surface.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/** The console home's creation menu: items carry `div.new-creation__menu-title` labels (§1). */
|
|
39
|
+
const CREATION_MENU_ITEM = 'div.new-creation__menu-item';
|
|
40
|
+
/** The menu is opened from the console's own 新的创作 control (§1). */
|
|
41
|
+
const CREATION_MENU_OPEN = ['text=新的创作', 'div.new-creation__menu-title:has-text("新的创作")'];
|
|
42
|
+
|
|
43
|
+
/** The title is the ProseMirror block; 文章's placeholder is 请在这里输入标题, 贴图's …(选填). */
|
|
44
|
+
const TITLE_BLOCK = [
|
|
45
|
+
'div.ProseMirror[data-placeholder^="请在这里输入标题"]',
|
|
46
|
+
'div.ProseMirror[data-placeholder*="标题"]',
|
|
47
|
+
];
|
|
48
|
+
/** `textarea#title` is the HIDDEN form mirror — filling it alone saves an EMPTY title (§5.2 trap 1). */
|
|
49
|
+
const TITLE_MIRROR = ['textarea#title', 'textarea.js_title'];
|
|
50
|
+
/** The body is the second ProseMirror instance: `ProseMirror-focused`, no `data-placeholder` (§5.2). */
|
|
51
|
+
const BODY_BLOCK = [
|
|
52
|
+
'div.ProseMirror.ProseMirror-focused:not([data-placeholder])',
|
|
53
|
+
'div.ProseMirror:not([data-placeholder])',
|
|
54
|
+
'div.ProseMirror[contenteditable="true"]',
|
|
55
|
+
];
|
|
56
|
+
/** 文章's 摘要 (`textarea#js_description`, 0/120, 选填) and 作者 (`input#author`, 0/8, 原创-gated). */
|
|
57
|
+
const DIGEST_INPUT = ['textarea#js_description', 'textarea.js_desc'];
|
|
58
|
+
const AUTHOR_INPUT = ['input#author', 'input.js_author'];
|
|
59
|
+
/** The editor's own image input (§3: `accept="image/gif,image/jpeg,image/jpg,image/png,image/svg,image/webp"`). */
|
|
60
|
+
const IMAGE_FILE_INPUT = ['input[type="file"][accept*="image/"]', 'input[type="file"][accept*=".png"]'];
|
|
61
|
+
|
|
62
|
+
/** `一键排版` (the format hint's trio is a LABEL, not a menu — see applyLayout). */
|
|
63
|
+
const LAYOUT_CONTROL = ['button:has-text("一键排版")', 'div.ailayout-btn_wrp', 'div.ailayout-btn_label'];
|
|
64
|
+
const LAYOUT_TARGET = ['div[role="menuitem"]:has-text("匹配目标格式")', 'li:has-text("匹配目标格式")'];
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 封面 has NO file input (§5.2): the panel offers `修改` / `换一张` / `从正文选择`
|
|
68
|
+
* (`a.js_selectCoverFromContent`) / 从图片库选择 / 微信扫码上传 / AI 配图, and states 默认首图为封面.
|
|
69
|
+
* Only the body-image path is implemented; the other three are recorded non-goals (0205).
|
|
70
|
+
*/
|
|
71
|
+
const COVER_PANEL_OPEN = ['a.js_selectCoverFromContent', '.js_cover_btn_area', 'text=换一张', 'text=修改'];
|
|
72
|
+
const COVER_FROM_CONTENT = ['a.js_selectCoverFromContent', 'text=从正文选择'];
|
|
73
|
+
/**
|
|
74
|
+
* The panel's own image list is NOT `<img>`-based (measured `panels: 5, imgs: 0`), so the item and
|
|
75
|
+
* confirm selectors below are candidate shapes and **not** re-measured (§6 still lists the widget as
|
|
76
|
+
* to do). A miss is recorded as a warning, never as a false cover claim.
|
|
77
|
+
*/
|
|
78
|
+
const COVER_ITEM = [
|
|
79
|
+
'div[class*="cover" i] li',
|
|
80
|
+
'div[class*="imgList" i] li',
|
|
81
|
+
'div[class*="weui-desktop-img-picker" i] li',
|
|
82
|
+
];
|
|
83
|
+
const COVER_CONFIRM = ['button:has-text("确定")', 'button:has-text("确认")', 'button:has-text("完成")'];
|
|
84
|
+
|
|
85
|
+
/** 视频's media modal (§5.2): tabs 素材库/视频号动态/视频链接 + 本地上传 + 确定/取消. */
|
|
86
|
+
const VIDEO_LOCAL_UPLOAD = ['text=本地上传', 'button:has-text("本地上传")'];
|
|
87
|
+
const VIDEO_FILE_INPUT = [
|
|
88
|
+
'div.weui-desktop-dialog input[type="file"][accept*="video"]',
|
|
89
|
+
'input[type="file"][accept*="video"]',
|
|
90
|
+
'input[type="file"][accept*=".mp4"]',
|
|
91
|
+
'div.weui-desktop-dialog input[type="file"]',
|
|
92
|
+
];
|
|
93
|
+
const VIDEO_CONFIRM = ['div.weui-desktop-dialog button:has-text("确定")', 'button:has-text("确定")'];
|
|
94
|
+
|
|
95
|
+
/** 播客's media modal (§5.2): 从素材库选择 + 搜索音频 + 上传音频 + 插入/取消. */
|
|
96
|
+
const PODCAST_UPLOAD = ['text=上传音频', 'button:has-text("上传音频")'];
|
|
97
|
+
const PODCAST_AUDIO_INPUT = [
|
|
98
|
+
'div.weui-desktop-dialog input[type="file"][accept*="audio"]',
|
|
99
|
+
'div.weui-desktop-dialog input[type="file"][accept*=".mp3"]',
|
|
100
|
+
'input[type="file"][accept*="audio"]',
|
|
101
|
+
'input[type="file"][accept*=".mp3"]',
|
|
102
|
+
];
|
|
103
|
+
/**
|
|
104
|
+
* The 播客 cover stays scoped to the modal: the editor's own image input carries an `image/…`
|
|
105
|
+
* accept list too, and picking that one would attach the cover as *body* content instead.
|
|
106
|
+
*/
|
|
107
|
+
const PODCAST_COVER_INPUT = [
|
|
108
|
+
'div.weui-desktop-dialog input[type="file"][accept*="image/"]',
|
|
109
|
+
'div.weui-desktop-dialog input[type="file"][accept*=".png"]',
|
|
110
|
+
'div.weui-desktop-dialog input[type="file"][accept*=".jpg"]',
|
|
111
|
+
];
|
|
112
|
+
const PODCAST_INSERT = ['div.weui-desktop-dialog button:has-text("插入")', 'button:has-text("插入")'];
|
|
113
|
+
|
|
114
|
+
/** The recorded save control. `发表` is never clicked on a draft run (ADR-022/ADR-026). */
|
|
115
|
+
const SAVE_DRAFT = ['button:has-text("保存为草稿")', 'text=保存为草稿'];
|
|
116
|
+
const PUBLISH_SEND = ['button.mass_send', '.mass_send', 'button:has-text("发表")'];
|
|
117
|
+
const PUBLISH_CONFIRM = ['div.weui-desktop-dialog button:has-text("确定")', 'button:has-text("确定")'];
|
|
118
|
+
const PUBLISH_SUCCESS = ['发表成功', '已发表'];
|
|
119
|
+
/**
|
|
120
|
+
* The platform's own refusal/hint lines (§5, §5.3) — relayed as the Result's actionable message
|
|
121
|
+
* instead of a generic failure. `系统繁忙,请稍后再试` is deliberately absent: it appears routinely
|
|
122
|
+
* and is NOT a failure indicator (§5.1).
|
|
123
|
+
*/
|
|
124
|
+
const PLATFORM_REFUSAL = ['内容不完整,无法发表', '请补充封面图', '未设置头像和名称', '公众号尚未实名'];
|
|
125
|
+
|
|
126
|
+
/** The 草稿箱 row's own update label (§5.1: `kk-discovery-文章 请忽略 … 更新于 13:12`). */
|
|
127
|
+
const DRAFT_STAMP = '更新于';
|
|
128
|
+
|
|
129
|
+
/** A confirm dialog the platform itself raises mounts immediately; it must not cost a step timeout. */
|
|
130
|
+
const CONFIRM_TIMEOUT_MS = 5_000;
|
|
131
|
+
/** Page-side scan for a console link carrying the session token (`token=<N>`, §1). */
|
|
132
|
+
const TOKEN_LINK_SCRIPT = `(() => {
|
|
133
|
+
const link = [...document.querySelectorAll('a[href*="token="]')]
|
|
134
|
+
.map((a) => a.href)
|
|
135
|
+
.find((href) => /[?&]token=\\d+/.test(href));
|
|
136
|
+
return link ? (new URL(link).searchParams.get('token') ?? '') : '';
|
|
137
|
+
})()`;
|
|
138
|
+
|
|
139
|
+
/** Everything the flows need besides the validated mode input. */
|
|
140
|
+
export interface ModeRunDeps {
|
|
141
|
+
/** The console home (entry page): the creation menu and the 草稿箱 signal live here. */
|
|
142
|
+
entry: WechatPage;
|
|
143
|
+
/** Every page the context holds — the editor is the NEWEST one (discovery §1). */
|
|
144
|
+
pages: () => WechatPage[];
|
|
145
|
+
/** Opens an extra tab for the token-derived editor route, so the home stays readable. */
|
|
146
|
+
newPage: () => Promise<WechatPage>;
|
|
147
|
+
content: Content;
|
|
148
|
+
publishStatus: 'draft' | 'public';
|
|
149
|
+
profileDir: string;
|
|
150
|
+
driver: string;
|
|
151
|
+
stepTimeoutMs: number;
|
|
152
|
+
/** Re-poll interval for the observed-signal waits — injectable so tests need no real-time waiting. */
|
|
153
|
+
pollMs: number;
|
|
154
|
+
/** Settle before the save click. */
|
|
155
|
+
submitSettleMs: number;
|
|
156
|
+
/** How long the observed draft signal is waited for after a save click. */
|
|
157
|
+
draftTimeoutMs: number;
|
|
158
|
+
submitAttempts: number;
|
|
159
|
+
/** 草稿箱 evidence captured before the editor opened (MP auto-saves, so a row can pre-exist). */
|
|
160
|
+
draftBaseline: DraftBaseline;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The 草稿箱 stamp observed before the editor opened — the freshness baseline of the signal. */
|
|
164
|
+
export interface DraftBaseline {
|
|
165
|
+
stamp: string | null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
interface ModeEntry {
|
|
169
|
+
/** The page every editor step acts on: the tab the creation click opened, or the route tab. */
|
|
170
|
+
editor: WechatPage;
|
|
171
|
+
/** Which verified entry path was used. */
|
|
172
|
+
path: 'menu' | 'route';
|
|
173
|
+
/** The `createType` the editor URL reported, or null when the URL did not carry it. */
|
|
174
|
+
createType: number | null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function fail(error: string): Result {
|
|
178
|
+
return ResultSchema.parse({ ok: false, target: TARGET, error });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function sleep(ms: number): Promise<void> {
|
|
182
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function titleOf(content: Content): string {
|
|
186
|
+
return (content.title ?? '').trim();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function bodyOf(content: Content): string {
|
|
190
|
+
return content.body ?? '';
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The shared helpers are declared against Playwright's `Page`, while the flows drive the structural
|
|
195
|
+
* `WechatPage` seam (which is what lets the offline tests script the console). Every member those
|
|
196
|
+
* helpers touch — `locator`, `evaluate`, `setInputFiles` — is implemented by `wrapPlaywrightPage`,
|
|
197
|
+
* so this is a type-level bridge over the same object, not a second page.
|
|
198
|
+
*/
|
|
199
|
+
function asPlaywrightPage(page: WechatPage): Page {
|
|
200
|
+
return page as unknown as Page;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The 草稿箱 row's scope for one title.
|
|
205
|
+
*
|
|
206
|
+
* The walk recorded the row's TEXT (`… 更新于 13:12`), not its class, so the containers are the
|
|
207
|
+
* platform's own draft/appmsg naming (its editor routes are `appmsg*`) plus the row's title text —
|
|
208
|
+
* a candidate list keyed to this run's title, never the document at large, where the article the
|
|
209
|
+
* flow just pasted would satisfy the probe (ADR-029 mandatory rail).
|
|
210
|
+
*/
|
|
211
|
+
export function draftRowContainers(title: string): string[] {
|
|
212
|
+
const keyed = title.length > 0 ? `:has-text(${JSON.stringify(title)})` : '';
|
|
213
|
+
// Kept narrow: a broad list (`li` / `[class*="list" i]` / media wrappers) made the per-poll
|
|
214
|
+
// `:has-text` probe slow enough on the real list to look like a hang, and it still missed the row —
|
|
215
|
+
// the draft rows appear to live inside an iframe on the list route (2026-10-05).
|
|
216
|
+
// `.weui-desktop-card` is the real row: live 2026-10-05 the 近期草稿 card on the console home is
|
|
217
|
+
// `div.weui-desktop-card.weui-desktop-publish` under `div.publish_card_container`, and it carries
|
|
218
|
+
// both the title and the `更新于` stamp. Kept narrow — a broad list (`li` / `[class*="list" i]`)
|
|
219
|
+
// made the per-poll `:has-text` probe slow enough on the real page to look like a hang.
|
|
220
|
+
return [
|
|
221
|
+
`.weui-desktop-card${keyed}`,
|
|
222
|
+
`.publish_card_container${keyed}`,
|
|
223
|
+
`[class*="draft" i]${keyed}`,
|
|
224
|
+
`[class*="appmsg" i]${keyed}`,
|
|
225
|
+
`.weui-desktop-dialog${keyed}`,
|
|
226
|
+
];
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The console home route — which is where the 草稿箱 rows actually are.
|
|
231
|
+
*
|
|
232
|
+
* Live 2026-10-05 (authenticated probe): the home page (`t=home/index`) carries the draft cards with
|
|
233
|
+
* their `更新于` stamps, while `t=media/appmsg_list&action=list_card` — the route the 草稿箱 entry
|
|
234
|
+
* links to — renders **no** draft rows at all, and every page is a single frame (no iframes).
|
|
235
|
+
* Navigating to the appmsg route was therefore the bug that produced `before=none, after=none`.
|
|
236
|
+
*/
|
|
237
|
+
const draftBoxUrl = (token: string): string =>
|
|
238
|
+
`https://mp.weixin.qq.com/cgi-bin/home?t=home/index&lang=zh_CN&token=${encodeURIComponent(token)}`;
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Land on the console home and wait (bounded) for its 近期草稿 cards to render.
|
|
242
|
+
*
|
|
243
|
+
* The cards stream in after the navigation, so a fixed sleep can read too early — that is the other
|
|
244
|
+
* half of the observed `before=none, after=none` miss. It navigates instead of clicking the nav entry:
|
|
245
|
+
* a click can open a new tab and never settle, which hung a run for 7 min with no output (no step
|
|
246
|
+
* timeout fires for that), while a bounded `goto` always returns or throws. Best-effort — a failure
|
|
247
|
+
* leaves the read to the probe loop, which reports an honest `ok:false` rather than a fabricated one.
|
|
248
|
+
*/
|
|
249
|
+
export async function openDraftBox(
|
|
250
|
+
page: WechatPage,
|
|
251
|
+
title: string,
|
|
252
|
+
timeoutMs: number,
|
|
253
|
+
pollMs: number,
|
|
254
|
+
): Promise<boolean> {
|
|
255
|
+
try {
|
|
256
|
+
const token = await resolveConsoleToken(page);
|
|
257
|
+
if (token === undefined) return false;
|
|
258
|
+
await page.goto(draftBoxUrl(token), { timeout: timeoutMs });
|
|
259
|
+
const ready = await expectOutcome(
|
|
260
|
+
async () => {
|
|
261
|
+
// Container-qualified and title-keyed exactly like the read — a bare `:text(更新于)` probe
|
|
262
|
+
// would be satisfied by the pasted article body (the scoping test enforces this), and a
|
|
263
|
+
// fixed sleep can read before the cards stream in, which is the other half of the miss.
|
|
264
|
+
for (const container of draftRowContainers(title)) {
|
|
265
|
+
const selector = `${container} :text("${DRAFT_STAMP}")`;
|
|
266
|
+
if ((await page.locator(selector).count()) > 0) return selector;
|
|
267
|
+
}
|
|
268
|
+
return null;
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
timeoutMs,
|
|
272
|
+
label: `草稿箱 cards on the console home (:text("${DRAFT_STAMP}") inside the row containers)`,
|
|
273
|
+
pollMs,
|
|
274
|
+
},
|
|
275
|
+
);
|
|
276
|
+
return ready.ok;
|
|
277
|
+
} catch {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* The 草稿箱 row's `更新于` stamp for one title, read through `readScopedSignal` so it can only come
|
|
284
|
+
* from a 草稿箱 container — never from the editor body this flow just typed into.
|
|
285
|
+
*/
|
|
286
|
+
/**
|
|
287
|
+
* A bounded frame inventory for the 草稿箱 failure message.
|
|
288
|
+
*
|
|
289
|
+
* Live 2026-10-05 the row demonstrably existed and the list route was reached, yet the main frame read
|
|
290
|
+
* empty and no selector change helped. So report the frames that were actually there (and how many
|
|
291
|
+
* `更新于` stamps each held) instead of guessing again. The fakes expose no `frames()`; they get ''.
|
|
292
|
+
*/
|
|
293
|
+
async function describeFrames(page: WechatPage, limit: number): Promise<string> {
|
|
294
|
+
const frames = typeof page.frames === 'function' ? page.frames() : [];
|
|
295
|
+
const parts: string[] = [];
|
|
296
|
+
for (const frame of frames.slice(0, limit)) {
|
|
297
|
+
const stamped = await frame
|
|
298
|
+
.locator(`:text("${DRAFT_STAMP}")`)
|
|
299
|
+
.count()
|
|
300
|
+
.catch(() => -1);
|
|
301
|
+
parts.push(`${frame.url().slice(0, 60)}:${stamped}`);
|
|
302
|
+
}
|
|
303
|
+
return parts.join(' | ');
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export async function readDraftStamp(page: WechatPage, title: string): Promise<string | null> {
|
|
307
|
+
return readScopedSignal(asPlaywrightPage(page), [DRAFT_STAMP], draftRowContainers(title));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** 草稿箱 evidence captured before the editor is entered (MP auto-saves continuously, §5.1). */
|
|
311
|
+
export async function readDraftBaseline(page: WechatPage, title: string): Promise<DraftBaseline> {
|
|
312
|
+
return { stamp: await readDraftStamp(page, title) };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** The session token: the console URL first, then a console link on the page (§1). */
|
|
316
|
+
async function resolveConsoleToken(page: WechatPage): Promise<string | undefined> {
|
|
317
|
+
const fromUrl = readConsoleToken(page.url());
|
|
318
|
+
if (fromUrl !== undefined) return fromUrl;
|
|
319
|
+
const fromLink = String(await page.evaluate(TOKEN_LINK_SCRIPT)).trim();
|
|
320
|
+
return fromLink.length > 0 ? fromLink : undefined;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** The creation-menu item carrying this mode's label. */
|
|
324
|
+
function creationItemSelectors(operation: WechatmpOperation): string[] {
|
|
325
|
+
return [
|
|
326
|
+
`${CREATION_MENU_ITEM}:has-text("${operation}")`,
|
|
327
|
+
`${CREATION_MENU_ITEM} .new-creation__menu-title:has-text("${operation}")`,
|
|
328
|
+
];
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Enter the mode through the console's own 新的创作 menu — the path the walk used, which opens the
|
|
333
|
+
* editor in a NEW TAB (§1). Returns `undefined` when the menu offers no item for the mode, or opens
|
|
334
|
+
* no tab, so the caller can fall back to the token-derived route.
|
|
335
|
+
*/
|
|
336
|
+
async function enterViaMenu(deps: ModeRunDeps, operation: WechatmpOperation): Promise<WechatPage | undefined> {
|
|
337
|
+
const selectors = creationItemSelectors(operation);
|
|
338
|
+
let item = await tryFindSelector(deps.entry, selectors, Math.min(deps.stepTimeoutMs, 5_000));
|
|
339
|
+
if (item === undefined) {
|
|
340
|
+
// The menu renders closed until the console's own 新的创作 control opens it (§1).
|
|
341
|
+
const opener = await tryFindSelector(deps.entry, CREATION_MENU_OPEN, deps.stepTimeoutMs);
|
|
342
|
+
if (opener === undefined) return undefined;
|
|
343
|
+
await clickSelector(deps.entry, opener, deps.stepTimeoutMs);
|
|
344
|
+
item = await tryFindSelector(deps.entry, selectors, deps.stepTimeoutMs);
|
|
345
|
+
if (item === undefined) return undefined;
|
|
346
|
+
}
|
|
347
|
+
const before = deps.pages().length;
|
|
348
|
+
await clickSelector(deps.entry, item, deps.stepTimeoutMs);
|
|
349
|
+
return followNewPage(deps.pages, before, deps.stepTimeoutMs);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Refuse to type into the wrong editor: every mode's route carries `createType=<N>` (§2), so an
|
|
354
|
+
* observed mismatch fails loud. A route that carries no `createType` at all (the platform rewrote it)
|
|
355
|
+
* is recorded as unverified rather than guessed at.
|
|
356
|
+
*/
|
|
357
|
+
function assertEditorMode(editor: WechatPage, operation: WechatmpOperation, path: 'menu' | 'route'): ModeEntry {
|
|
358
|
+
const expected = CREATE_TYPES[operation];
|
|
359
|
+
const createType = readCreateType(editor.url());
|
|
360
|
+
if (createType !== undefined && createType !== expected) {
|
|
361
|
+
throw new Error(
|
|
362
|
+
`mode entry: the editor landed on createType=${createType} but ${operation} is createType=${expected} — refusing to type into another mode's editor (${editor.url()})`,
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
return { editor, path, createType: createType ?? null };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Enter one mode. Primary path: the console's own creation menu, following the tab it opens. When
|
|
370
|
+
* that menu offers no item (drift, or an account type that hides it), the flow derives the editor
|
|
371
|
+
* route from the console's session token and deep-links it — the route template is verified for all
|
|
372
|
+
* four types (§2), while a *stored* editor URL is not reusable (§1/W2).
|
|
373
|
+
*/
|
|
374
|
+
async function enterMode(deps: ModeRunDeps, operation: WechatmpOperation): Promise<ModeEntry> {
|
|
375
|
+
const viaMenu = await enterViaMenu(deps, operation);
|
|
376
|
+
if (viaMenu !== undefined) return assertEditorMode(viaMenu, operation, 'menu');
|
|
377
|
+
|
|
378
|
+
const token = await resolveConsoleToken(deps.entry);
|
|
379
|
+
if (token === undefined) {
|
|
380
|
+
throw new Error(
|
|
381
|
+
`mode entry: the console offers no '${operation}' creation item and carries no session token to derive its route — is the console logged in? (${deps.entry.url()})`,
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
const editor = await deps.newPage();
|
|
385
|
+
await editor.goto(editorUrl(operation, token), { timeout: deps.stepTimeoutMs });
|
|
386
|
+
return assertEditorMode(editor, operation, 'route');
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Attach the body images through the editor's own image input, in payload order in ONE call.
|
|
391
|
+
* The FIRST attached image is the one the cover panel's 默认首图为封面 rule turns into the cover
|
|
392
|
+
* (§5.2), which is why `metadata.wechatmp.coverPath` is ordered first (`./modes`).
|
|
393
|
+
*/
|
|
394
|
+
async function attachImages(page: WechatPage, images: string[], deps: ModeRunDeps): Promise<void> {
|
|
395
|
+
const selector = await findAttachedSelector(page, IMAGE_FILE_INPUT, 'image file input', deps.stepTimeoutMs);
|
|
396
|
+
await page.setInputFiles(selector, images);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Set the title through the ProseMirror block and verify the form mirror.
|
|
401
|
+
*
|
|
402
|
+
* Trap measured in §5.2: filling `textarea#title` alone saves a draft with an EMPTY title — the
|
|
403
|
+
* block is the input and the textarea a hidden mirror. So the flow types into the block, then reads
|
|
404
|
+
* the mirror back: a readable mirror that disagrees is the trap firing, and it fails loud instead of
|
|
405
|
+
* letting an untitled draft through.
|
|
406
|
+
*/
|
|
407
|
+
async function typeTitle(page: WechatPage, deps: ModeRunDeps): Promise<'absent' | 'mirror-agreed' | 'mirror-unread'> {
|
|
408
|
+
const title = titleOf(deps.content);
|
|
409
|
+
if (title.length === 0) return 'absent';
|
|
410
|
+
const selector = await findSelector(page, TITLE_BLOCK, 'title block', deps.stepTimeoutMs);
|
|
411
|
+
const block = page.locator(selector).first();
|
|
412
|
+
await block.click({ timeout: deps.stepTimeoutMs });
|
|
413
|
+
// Type rather than fill: a synthetic value set does not register in ProseMirror.
|
|
414
|
+
if (typeof block.type === 'function') await block.type(title, { delay: 5 });
|
|
415
|
+
else await block.fill(title);
|
|
416
|
+
|
|
417
|
+
const mirror = await readTitleMirror(page);
|
|
418
|
+
if (mirror.observed && mirror.value !== title) {
|
|
419
|
+
throw new Error(
|
|
420
|
+
`title: the title block holds '${title}' but its form mirror textarea#title reads '${mirror.value}' — the mirror is what a save submits, so the draft would be saved without the title (discovery §5.2 trap 1)`,
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
return mirror.observed ? 'mirror-agreed' : 'mirror-unread';
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** The hidden form mirror's value; `observed: false` when the console does not expose one. */
|
|
427
|
+
async function readTitleMirror(page: WechatPage): Promise<{ observed: boolean; value: string }> {
|
|
428
|
+
const found = await trySelectors(page, TITLE_MIRROR, { visible: false }).catch(() => ({
|
|
429
|
+
found: false,
|
|
430
|
+
selector: null,
|
|
431
|
+
}));
|
|
432
|
+
if (!found.found || !found.selector) return { observed: false, value: '' };
|
|
433
|
+
const locator = page.locator(found.selector).first();
|
|
434
|
+
if (typeof locator.inputValue !== 'function') return { observed: false, value: '' };
|
|
435
|
+
try {
|
|
436
|
+
return { observed: true, value: await locator.inputValue() };
|
|
437
|
+
} catch {
|
|
438
|
+
return { observed: false, value: '' };
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Insert the markdown-derived body into the main-frame ProseMirror editor through the shared paste
|
|
444
|
+
* helper (ADR-029 rail). The retired iframe UEditor and its `setInnerHtml` were the drift this
|
|
445
|
+
* replaces (W1): the body only registers on a real paste, which is what `pasteHtml` sends.
|
|
446
|
+
*/
|
|
447
|
+
async function insertBody(page: WechatPage, deps: ModeRunDeps): Promise<void> {
|
|
448
|
+
const body = bodyOf(deps.content);
|
|
449
|
+
if (body.trim().length === 0) return;
|
|
450
|
+
const selector = await findSelector(page, BODY_BLOCK, 'article body', deps.stepTimeoutMs);
|
|
451
|
+
await pasteHtml(asPlaywrightPage(page), selector, markdownToWechatHtml(body), { timeoutMs: deps.stepTimeoutMs });
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* `一键排版` applies the layout directly. Measured live (dogfood 2026-10-05): after the click the only
|
|
456
|
+
* new text was ONE element carrying `保留源格式 匹配目标格式 仅保留文本` as a label — a format hint, not a
|
|
457
|
+
* clickable option set (the discovery note misread that concatenation as a menu). Waiting 30 s for
|
|
458
|
+
* `匹配目标格式` therefore failed a real draft. Click the control when present, honour an option only
|
|
459
|
+
* if one appears promptly, and never let the layout step gate the save.
|
|
460
|
+
*/
|
|
461
|
+
async function applyLayout(page: WechatPage, deps: ModeRunDeps): Promise<void> {
|
|
462
|
+
try {
|
|
463
|
+
const control = await findSelector(page, LAYOUT_CONTROL, '一键排版 control', deps.stepTimeoutMs);
|
|
464
|
+
await clickSelector(page, control, deps.stepTimeoutMs);
|
|
465
|
+
const option = await findSelector(page, LAYOUT_TARGET, '匹配目标格式 option', 1500).catch(() => null);
|
|
466
|
+
if (option !== null) await clickSelector(page, option, 1500);
|
|
467
|
+
} catch {
|
|
468
|
+
// No layout control on this editor build — the draft still saves.
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** Fill an optional field, recording a warning instead of failing the run. */
|
|
473
|
+
async function fillOptional(
|
|
474
|
+
page: WechatPage,
|
|
475
|
+
selectors: readonly string[],
|
|
476
|
+
value: string,
|
|
477
|
+
what: string,
|
|
478
|
+
deps: ModeRunDeps,
|
|
479
|
+
): Promise<string | null> {
|
|
480
|
+
if (value.length === 0) return null;
|
|
481
|
+
const selector = await tryFindSelector(page, selectors, Math.min(deps.stepTimeoutMs, 5_000));
|
|
482
|
+
if (selector === undefined) return `optional field '${what}': none of [${selectors.join(' | ')}] visible`;
|
|
483
|
+
try {
|
|
484
|
+
await fillSelector(page, selector, value);
|
|
485
|
+
return null;
|
|
486
|
+
} catch (error) {
|
|
487
|
+
// The console hides some fields for some account types; the setting stays manual.
|
|
488
|
+
return `optional field '${what}': ${error instanceof Error ? error.message : String(error)}`;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* 封面 from the body (§5.2): there is no cover file input, so the panel's `从正文选择` picks the first
|
|
494
|
+
* body image. Best-effort and recorded: the panel states 默认首图为封面, so the first body image is
|
|
495
|
+
* already the cover, and only the explicit pick's failure is reported as a warning.
|
|
496
|
+
*/
|
|
497
|
+
async function setCoverFromBody(page: WechatPage, deps: ModeRunDeps): Promise<string | null> {
|
|
498
|
+
const open = await tryFindSelector(page, COVER_PANEL_OPEN, Math.min(deps.stepTimeoutMs, 5_000));
|
|
499
|
+
if (open === undefined)
|
|
500
|
+
return 'cover: the panel offers neither 从正文选择 nor its openers — the platform default (默认首图为封面) is all this run has';
|
|
501
|
+
try {
|
|
502
|
+
await clickSelector(page, open, deps.stepTimeoutMs);
|
|
503
|
+
const pick = await tryFindSelector(page, COVER_FROM_CONTENT, Math.min(deps.stepTimeoutMs, 5_000));
|
|
504
|
+
if (pick === undefined) return 'cover: 从正文选择 was not offered by the open cover panel';
|
|
505
|
+
await clickSelector(page, pick, deps.stepTimeoutMs);
|
|
506
|
+
const item = await tryFindSelector(page, COVER_ITEM, Math.min(deps.stepTimeoutMs, 5_000));
|
|
507
|
+
if (item === undefined)
|
|
508
|
+
return 'cover: the panel listed no body image to choose (its list is not <img>-based, §5.2)';
|
|
509
|
+
await clickSelector(page, item, deps.stepTimeoutMs);
|
|
510
|
+
const confirm = await tryFindSelector(page, COVER_CONFIRM, Math.min(deps.stepTimeoutMs, 5_000));
|
|
511
|
+
if (confirm === undefined) return 'cover: the choice panel offered no confirm control';
|
|
512
|
+
await clickSelector(page, confirm, deps.stepTimeoutMs);
|
|
513
|
+
return null;
|
|
514
|
+
} catch (error) {
|
|
515
|
+
return `cover: ${error instanceof Error ? error.message : String(error)}`;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** 文章's `metadata.wechatmp` enrichment rail, plus its opt-in audio embed (§5.2, ADR-026). */
|
|
520
|
+
async function enrichArticleDraft(page: WechatPage, content: Content): Promise<string[]> {
|
|
521
|
+
const enrichMeta = content.metadata?.wechatmp;
|
|
522
|
+
if (enrichMeta === undefined || typeof enrichMeta !== 'object' || Array.isArray(enrichMeta)) return [];
|
|
523
|
+
const warnings = await enrichArticle(page, enrichMeta as Record<string, unknown>);
|
|
524
|
+
const audioTitle = (enrichMeta as Record<string, unknown>).audioTitle;
|
|
525
|
+
if (typeof audioTitle === 'string' && audioTitle.trim() !== '') {
|
|
526
|
+
const audioWarning = await insertAudio(page, audioTitle.trim());
|
|
527
|
+
if (audioWarning !== '') warnings.push(audioWarning);
|
|
528
|
+
}
|
|
529
|
+
return warnings;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** Compose 文章 (createType 0): body images, title, body + 一键排版, 摘要, 作者, cover from the body. */
|
|
533
|
+
async function composeArticle(
|
|
534
|
+
input: { operation: '文章'; images: string[]; digest?: string },
|
|
535
|
+
deps: ModeRunDeps,
|
|
536
|
+
entry: ModeEntry,
|
|
537
|
+
): Promise<ComposeReport> {
|
|
538
|
+
const page = entry.editor;
|
|
539
|
+
const warnings: string[] = [];
|
|
540
|
+
await attachImages(page, input.images, deps);
|
|
541
|
+
const titlePath = await typeTitle(page, deps);
|
|
542
|
+
await insertBody(page, deps);
|
|
543
|
+
await applyLayout(page, deps);
|
|
544
|
+
for (const warning of [
|
|
545
|
+
await fillOptional(page, DIGEST_INPUT, input.digest ?? '', '摘要', deps),
|
|
546
|
+
await fillOptional(page, AUTHOR_INPUT, authorOf(deps.content), '作者', deps),
|
|
547
|
+
]) {
|
|
548
|
+
if (warning !== null) warnings.push(warning);
|
|
549
|
+
}
|
|
550
|
+
const cover = await setCoverFromBody(page, deps);
|
|
551
|
+
if (cover !== null) warnings.push(cover);
|
|
552
|
+
return { warnings, titlePath, enrichWarnings: await enrichArticleDraft(page, deps.content) };
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** Compose 贴图 (createType 8): ≥1 image is the message; title and body are 选填. */
|
|
556
|
+
async function composeSticker(
|
|
557
|
+
input: { operation: '贴图'; images: string[] },
|
|
558
|
+
deps: ModeRunDeps,
|
|
559
|
+
entry: ModeEntry,
|
|
560
|
+
): Promise<ComposeReport> {
|
|
561
|
+
const page = entry.editor;
|
|
562
|
+
await attachImages(page, input.images, deps);
|
|
563
|
+
const titlePath = await typeTitle(page, deps);
|
|
564
|
+
await insertBody(page, deps);
|
|
565
|
+
return { warnings: [], titlePath, enrichWarnings: [] };
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Compose 视频 (createType 5). The media modal COVERS the save bar (§5.1), so the artifact is
|
|
570
|
+
* attached first and the bar's appearance is the proof it landed; a missing artifact never reaches
|
|
571
|
+
* here (it fails before the browser opens).
|
|
572
|
+
*/
|
|
573
|
+
async function composeVideo(
|
|
574
|
+
input: { operation: '视频'; video: string },
|
|
575
|
+
deps: ModeRunDeps,
|
|
576
|
+
entry: ModeEntry,
|
|
577
|
+
): Promise<ComposeReport> {
|
|
578
|
+
const page = entry.editor;
|
|
579
|
+
const upload = await findSelector(page, VIDEO_LOCAL_UPLOAD, '本地上传 control', deps.stepTimeoutMs);
|
|
580
|
+
await clickSelector(page, upload, deps.stepTimeoutMs);
|
|
581
|
+
const fileInput = await findAttachedSelector(page, VIDEO_FILE_INPUT, 'video file input', deps.stepTimeoutMs);
|
|
582
|
+
await uploadFile(asPlaywrightPage(page), fileInput, input.video, { timeoutMs: deps.stepTimeoutMs });
|
|
583
|
+
const confirm = await findSelector(page, VIDEO_CONFIRM, 'video media confirm', deps.stepTimeoutMs);
|
|
584
|
+
await clickSelector(page, confirm, deps.stepTimeoutMs);
|
|
585
|
+
await awaitSaveBar(page, deps);
|
|
586
|
+
const titlePath = await typeTitle(page, deps);
|
|
587
|
+
await insertBody(page, deps);
|
|
588
|
+
return { warnings: [], titlePath, enrichWarnings: [] };
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Compose 播客 (createType 7). The modal needs audio AND a cover (§5.2), and it covers the save bar,
|
|
593
|
+
* so `插入` + the bar's appearance gate every later step.
|
|
594
|
+
*/
|
|
595
|
+
async function composePodcast(
|
|
596
|
+
input: { operation: '播客'; audio: string; cover: string },
|
|
597
|
+
deps: ModeRunDeps,
|
|
598
|
+
entry: ModeEntry,
|
|
599
|
+
): Promise<ComposeReport> {
|
|
600
|
+
const page = entry.editor;
|
|
601
|
+
const upload = await findSelector(page, PODCAST_UPLOAD, '上传音频 control', deps.stepTimeoutMs);
|
|
602
|
+
await clickSelector(page, upload, deps.stepTimeoutMs);
|
|
603
|
+
const audioInput = await findAttachedSelector(page, PODCAST_AUDIO_INPUT, 'audio file input', deps.stepTimeoutMs);
|
|
604
|
+
await uploadFile(asPlaywrightPage(page), audioInput, input.audio, { timeoutMs: deps.stepTimeoutMs });
|
|
605
|
+
const coverInput = await findAttachedSelector(page, PODCAST_COVER_INPUT, 'cover file input', deps.stepTimeoutMs);
|
|
606
|
+
await uploadFile(asPlaywrightPage(page), coverInput, input.cover, { timeoutMs: deps.stepTimeoutMs });
|
|
607
|
+
const insert = await findSelector(page, PODCAST_INSERT, '插入 control', deps.stepTimeoutMs);
|
|
608
|
+
await clickSelector(page, insert, deps.stepTimeoutMs);
|
|
609
|
+
await awaitSaveBar(page, deps);
|
|
610
|
+
const titlePath = await typeTitle(page, deps);
|
|
611
|
+
await insertBody(page, deps);
|
|
612
|
+
return { warnings: [], titlePath, enrichWarnings: [] };
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* The save bar exists only once a 视频/播客 artifact is attached (§5.1) — waiting for it is the proof
|
|
617
|
+
* the artifact landed, and it fails loud naming the control when it never appears.
|
|
618
|
+
*/
|
|
619
|
+
async function awaitSaveBar(page: WechatPage, deps: ModeRunDeps): Promise<void> {
|
|
620
|
+
await findSelector(
|
|
621
|
+
page,
|
|
622
|
+
SAVE_DRAFT,
|
|
623
|
+
'保存为草稿 control (it appears only after the media is attached)',
|
|
624
|
+
deps.stepTimeoutMs,
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** What composing one mode reported: warnings, the title route, and the 文章 enrichment warnings. */
|
|
629
|
+
interface ComposeReport {
|
|
630
|
+
warnings: string[];
|
|
631
|
+
titlePath: 'absent' | 'mirror-agreed' | 'mirror-unread';
|
|
632
|
+
enrichWarnings: string[];
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/** Compose the requested mode's post. Exhaustive over {@link ModeInput}. */
|
|
636
|
+
async function compose(input: ModeInput, deps: ModeRunDeps, entry: ModeEntry): Promise<ComposeReport> {
|
|
637
|
+
switch (input.operation) {
|
|
638
|
+
case '文章':
|
|
639
|
+
return composeArticle(input, deps, entry);
|
|
640
|
+
case '贴图':
|
|
641
|
+
return composeSticker(input, deps, entry);
|
|
642
|
+
case '视频':
|
|
643
|
+
return composeVideo(input, deps, entry);
|
|
644
|
+
case '播客':
|
|
645
|
+
return composePodcast(input, deps, entry);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function authorOf(content: Content): string {
|
|
650
|
+
const author = content.metadata?.author;
|
|
651
|
+
return typeof author === 'string' ? author : '';
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function baseMetadata(input: ModeInput, deps: ModeRunDeps, entry: ModeEntry): Record<string, unknown> {
|
|
655
|
+
const cover = coverPath(input);
|
|
656
|
+
return {
|
|
657
|
+
operation: input.operation,
|
|
658
|
+
driver: deps.driver,
|
|
659
|
+
publishStatus: deps.publishStatus,
|
|
660
|
+
profileDir: deps.profileDir,
|
|
661
|
+
artifactPaths: artifactPaths(input),
|
|
662
|
+
entryPath: entry.path,
|
|
663
|
+
editorCreateType: entry.createType,
|
|
664
|
+
editorUrl: entry.editor.url(),
|
|
665
|
+
...(cover === undefined ? {} : { coverPath: cover }),
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Save as a draft: settle, click `保存为草稿`, then require an OBSERVED signal.
|
|
671
|
+
*
|
|
672
|
+
* The signal is the 草稿箱 row's `更新于` stamp for this title, observed FRESH — different from the
|
|
673
|
+
* baseline read before the editor opened (MP auto-saves continuously, so a row that already existed
|
|
674
|
+
* says nothing about this save, §5.1). The click is retried (the console drops one that lands while
|
|
675
|
+
* the editor is still settling), but the signal is never assumed: an unobserved save is `ok:false`
|
|
676
|
+
* naming the control, the title and the deadline. `发表` is not clicked on this path at all.
|
|
677
|
+
*/
|
|
678
|
+
async function saveDraft(
|
|
679
|
+
input: ModeInput,
|
|
680
|
+
deps: ModeRunDeps,
|
|
681
|
+
entry: ModeEntry,
|
|
682
|
+
composed: ComposeReport,
|
|
683
|
+
): Promise<Result> {
|
|
684
|
+
const title = titleOf(deps.content);
|
|
685
|
+
let control: string | null = null;
|
|
686
|
+
let signal: string | null = null;
|
|
687
|
+
let attempts = 0;
|
|
688
|
+
let stampAfter: string | null = deps.draftBaseline.stamp;
|
|
689
|
+
while (signal === null && attempts < deps.submitAttempts) {
|
|
690
|
+
attempts += 1;
|
|
691
|
+
await sleep(deps.submitSettleMs);
|
|
692
|
+
control = await findSelector(entry.editor, SAVE_DRAFT, '保存为草稿 control', deps.stepTimeoutMs);
|
|
693
|
+
await clickSelector(entry.editor, control, deps.stepTimeoutMs);
|
|
694
|
+
// The editor lives in its own tab, so re-read the console entry page — and ENTER the 草稿箱
|
|
695
|
+
// list first, because the home page does not render the draft rows (live 2026-10-05).
|
|
696
|
+
await deps.entry.goto(deps.entry.url(), { timeout: deps.stepTimeoutMs });
|
|
697
|
+
await openDraftBox(deps.entry, title, deps.stepTimeoutMs, deps.pollMs);
|
|
698
|
+
const outcome = await expectOutcome(
|
|
699
|
+
async () => {
|
|
700
|
+
const stamp = await readDraftStamp(deps.entry, title);
|
|
701
|
+
stampAfter = stamp;
|
|
702
|
+
return stamp !== null && stamp !== deps.draftBaseline.stamp ? stamp : null;
|
|
703
|
+
},
|
|
704
|
+
{
|
|
705
|
+
timeoutMs: deps.draftTimeoutMs,
|
|
706
|
+
label: `draft confirmation '${control}'`,
|
|
707
|
+
pollMs: deps.pollMs,
|
|
708
|
+
},
|
|
709
|
+
);
|
|
710
|
+
if (outcome.ok) signal = outcome.evidence;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
if (signal === null || control === null) {
|
|
714
|
+
return fail(
|
|
715
|
+
`draft confirmation: '${SAVE_DRAFT[0]}' was clicked ${attempts}× but no fresh 草稿箱 row for '${title}' (its ${DRAFT_STAMP} stamp) was observed within ${deps.draftTimeoutMs}ms (before=${deps.draftBaseline.stamp ?? 'none'}, after=${stampAfter ?? 'none'}, page=${deps.entry.url()}, frames=${await describeFrames(deps.entry, 5)})`,
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
return ResultSchema.parse({
|
|
719
|
+
ok: true,
|
|
720
|
+
target: TARGET,
|
|
721
|
+
message: `${TARGET} ${input.operation} draft saved (unpublished) in profile ${deps.profileDir} via '${control}'`,
|
|
722
|
+
metadata: {
|
|
723
|
+
...baseMetadata(input, deps, entry),
|
|
724
|
+
draftControl: control,
|
|
725
|
+
draftAttempts: attempts,
|
|
726
|
+
draftSignal: `草稿箱:${signal}`,
|
|
727
|
+
draftStampBefore: deps.draftBaseline.stamp,
|
|
728
|
+
draftStampAfter: signal,
|
|
729
|
+
titlePath: composed.titlePath,
|
|
730
|
+
...(composed.warnings.length > 0 ? { warnings: composed.warnings } : {}),
|
|
731
|
+
...(composed.enrichWarnings.length > 0 ? { enrichWarnings: composed.enrichWarnings } : {}),
|
|
732
|
+
},
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* The platform's own answer after 发表: its success marker, or one of the recorded refusal/hint lines
|
|
738
|
+
* (§5, §5.3) which are relayed as the actionable message the operator needs.
|
|
739
|
+
*/
|
|
740
|
+
async function publishOutcome(page: WechatPage): Promise<string | null> {
|
|
741
|
+
const success = await readScopedSignal(asPlaywrightPage(page), PUBLISH_SUCCESS);
|
|
742
|
+
if (success !== null) return `sent:${success}`;
|
|
743
|
+
const refusal = await readScopedSignal(asPlaywrightPage(page), PLATFORM_REFUSAL);
|
|
744
|
+
if (refusal !== null) return `refused:${refusal}`;
|
|
745
|
+
return null;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Publish. Reached only for an explicit `publishStatus: 'public'` — the transport refuses every
|
|
750
|
+
* other value before a browser opens, so no other path can click a publish control. ONE click, no
|
|
751
|
+
* retry: an unobserved publish must never be repeated (the platform's own completeness gate is the
|
|
752
|
+
* last line of defence, and a second click could duplicate the message).
|
|
753
|
+
*/
|
|
754
|
+
async function publishContent(
|
|
755
|
+
input: ModeInput,
|
|
756
|
+
deps: ModeRunDeps,
|
|
757
|
+
entry: ModeEntry,
|
|
758
|
+
composed: ComposeReport,
|
|
759
|
+
): Promise<Result> {
|
|
760
|
+
await sleep(deps.submitSettleMs);
|
|
761
|
+
const control = await findSelector(entry.editor, PUBLISH_SEND, '发表 control', deps.stepTimeoutMs);
|
|
762
|
+
const urlBefore = entry.editor.url();
|
|
763
|
+
await clickSelector(entry.editor, control, deps.stepTimeoutMs);
|
|
764
|
+
const confirm = await tryFindSelector(entry.editor, PUBLISH_CONFIRM, CONFIRM_TIMEOUT_MS);
|
|
765
|
+
if (confirm !== undefined) await clickSelector(entry.editor, confirm, deps.stepTimeoutMs);
|
|
766
|
+
|
|
767
|
+
const outcome = await expectOutcome(() => publishOutcome(entry.editor), {
|
|
768
|
+
timeoutMs: deps.stepTimeoutMs,
|
|
769
|
+
label: `publish confirmation '${control}'`,
|
|
770
|
+
pollMs: deps.pollMs,
|
|
771
|
+
});
|
|
772
|
+
if (!outcome.ok) {
|
|
773
|
+
return fail(
|
|
774
|
+
`publish submission: state unknown — '${control}' was clicked from ${urlBefore} but neither the platform's success marker nor one of its recorded refusals was observed within ${deps.stepTimeoutMs}ms (now ${entry.editor.url()}). Check the ${TARGET} console before retrying: a retry could publish twice.`,
|
|
775
|
+
);
|
|
776
|
+
}
|
|
777
|
+
if (outcome.evidence.startsWith('refused:')) {
|
|
778
|
+
return fail(`publish refused by ${TARGET}: ${outcome.evidence.slice('refused:'.length)}`);
|
|
779
|
+
}
|
|
780
|
+
return ResultSchema.parse({
|
|
781
|
+
ok: true,
|
|
782
|
+
target: TARGET,
|
|
783
|
+
message: `${TARGET} ${input.operation} published`,
|
|
784
|
+
metadata: {
|
|
785
|
+
...baseMetadata(input, deps, entry),
|
|
786
|
+
publishControl: control,
|
|
787
|
+
publishSignal: outcome.evidence,
|
|
788
|
+
titlePath: composed.titlePath,
|
|
789
|
+
...(composed.warnings.length > 0 ? { warnings: composed.warnings } : {}),
|
|
790
|
+
...(composed.enrichWarnings.length > 0 ? { enrichWarnings: composed.enrichWarnings } : {}),
|
|
791
|
+
},
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* Run one validated operation end to end: enter its mode, compose the post, then take it to a draft
|
|
797
|
+
* or — only on an explicit live request — publish it.
|
|
798
|
+
*/
|
|
799
|
+
export async function runMode(input: ModeInput, deps: ModeRunDeps): Promise<Result> {
|
|
800
|
+
const entry = await enterMode(deps, input.operation);
|
|
801
|
+
const composed = await compose(input, deps, entry);
|
|
802
|
+
return deps.publishStatus === 'public'
|
|
803
|
+
? publishContent(input, deps, entry, composed)
|
|
804
|
+
: saveDraft(input, deps, entry, composed);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* Harvested subset of wt md-to-wechat: headings, paragraphs, fenced code, lists, inline.
|
|
809
|
+
* TypeScript only — no Python, no theme CLI.
|
|
810
|
+
*
|
|
811
|
+
* Kept instead of the shared `markdownToHtml` because the two differ on safety, measured on Bun
|
|
812
|
+
* 1.3.14: `Bun.markdown.html` passes raw HTML straight through (`<script>` included) and renders
|
|
813
|
+
* `javascript:` link destinations, while this renderer escapes text and refuses non-http(s) link
|
|
814
|
+
* schemes (R9). The *insertion* is the shared `pasteHtml` either way, so the ADR-029 rail holds.
|
|
815
|
+
*/
|
|
816
|
+
export function markdownToWechatHtml(markdown: string): string {
|
|
817
|
+
const lines = markdown.replace(/\r\n/g, '\n').split('\n');
|
|
818
|
+
const out: string[] = [];
|
|
819
|
+
let inCode = false;
|
|
820
|
+
let code: string[] = [];
|
|
821
|
+
let listType: 'ul' | 'ol' | null = null;
|
|
822
|
+
|
|
823
|
+
const closeList = (): void => {
|
|
824
|
+
if (listType) {
|
|
825
|
+
out.push(`</${listType}>`);
|
|
826
|
+
listType = null;
|
|
827
|
+
}
|
|
828
|
+
};
|
|
829
|
+
|
|
830
|
+
const inline = (text: string): string =>
|
|
831
|
+
escapeHtml(text)
|
|
832
|
+
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
|
833
|
+
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
|
|
834
|
+
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
835
|
+
// `href` is already HTML-escaped (inline() escapes before replacing), so a `"` can no
|
|
836
|
+
// longer close the attribute. The scheme allowlist is still required: escaping does not
|
|
837
|
+
// neutralise `javascript:`. Non-http(s) links degrade to their label text.
|
|
838
|
+
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, text: string, href: string) =>
|
|
839
|
+
/^https?:\/\//i.test(href) ? `<a href="${href}">${text}</a>` : text,
|
|
840
|
+
);
|
|
841
|
+
|
|
842
|
+
for (const line of lines) {
|
|
843
|
+
if (line.startsWith('```')) {
|
|
844
|
+
if (inCode) {
|
|
845
|
+
out.push(`<pre><code>${escapeHtml(code.join('\n'))}</code></pre>`);
|
|
846
|
+
code = [];
|
|
847
|
+
inCode = false;
|
|
848
|
+
} else {
|
|
849
|
+
closeList();
|
|
850
|
+
inCode = true;
|
|
851
|
+
}
|
|
852
|
+
continue;
|
|
853
|
+
}
|
|
854
|
+
if (inCode) {
|
|
855
|
+
code.push(line);
|
|
856
|
+
continue;
|
|
857
|
+
}
|
|
858
|
+
const heading = line.match(/^(#{1,6})\s+(.+)$/);
|
|
859
|
+
if (heading?.[1] && heading[2]) {
|
|
860
|
+
closeList();
|
|
861
|
+
const level = Math.min(heading[1].length, 3);
|
|
862
|
+
out.push(`<h${level}>${inline(heading[2])}</h${level}>`);
|
|
863
|
+
continue;
|
|
864
|
+
}
|
|
865
|
+
const ul = line.match(/^[-*]\s+(.+)$/);
|
|
866
|
+
if (ul?.[1]) {
|
|
867
|
+
if (listType !== 'ul') {
|
|
868
|
+
closeList();
|
|
869
|
+
listType = 'ul';
|
|
870
|
+
out.push('<ul>');
|
|
871
|
+
}
|
|
872
|
+
out.push(`<li>${inline(ul[1])}</li>`);
|
|
873
|
+
continue;
|
|
874
|
+
}
|
|
875
|
+
const ol = line.match(/^\d+\.\s+(.+)$/);
|
|
876
|
+
if (ol?.[1]) {
|
|
877
|
+
if (listType !== 'ol') {
|
|
878
|
+
closeList();
|
|
879
|
+
listType = 'ol';
|
|
880
|
+
out.push('<ol>');
|
|
881
|
+
}
|
|
882
|
+
out.push(`<li>${inline(ol[1])}</li>`);
|
|
883
|
+
continue;
|
|
884
|
+
}
|
|
885
|
+
closeList();
|
|
886
|
+
if (line.trim().length === 0) continue;
|
|
887
|
+
out.push(`<p>${inline(line.trim())}</p>`);
|
|
888
|
+
}
|
|
889
|
+
closeList();
|
|
890
|
+
if (inCode) out.push(`<pre><code>${escapeHtml(code.join('\n'))}</code></pre>`);
|
|
891
|
+
return out.join('');
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function escapeHtml(text: string): string {
|
|
895
|
+
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
896
|
+
}
|