@gobing-ai/knowledge-kit 0.1.6 → 0.1.8

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.
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "plugins": [
8
8
  {
9
- "version": "0.1.6",
9
+ "version": "0.1.8",
10
10
  "name": "kk",
11
11
  "source": "./plugins/kk",
12
12
  "description": "knowledge-kit — a set of toolkit for knowledge management"
@@ -73,6 +73,19 @@ function isLoopbackHttpOrigin(origin) {
73
73
  return false;
74
74
  return url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "[::1]";
75
75
  }
76
+ function isHttpOrigin(origin) {
77
+ let url;
78
+ try {
79
+ url = new URL(origin);
80
+ } catch {
81
+ return false;
82
+ }
83
+ if (url.protocol !== "http:" && url.protocol !== "https:")
84
+ return false;
85
+ if (url.username !== "" || url.password !== "")
86
+ return false;
87
+ return url.pathname === "/" && url.search === "" && url.hash === "";
88
+ }
76
89
  function origin(base) {
77
90
  return base.replace(/\/+$/, "");
78
91
  }
@@ -96,16 +109,16 @@ function parseSpurDbRows(pageData) {
96
109
  });
97
110
  }
98
111
  function createBoardClient(options) {
99
- if (!isLoopbackHttpOrigin(options.readerOrigin)) {
100
- throw new Error(`reader origin must be loopback http, got '${options.readerOrigin}'`);
101
- }
102
- if (!isLoopbackHttpOrigin(options.boardOrigin)) {
103
- throw new Error(`board origin must be loopback http, got '${options.boardOrigin}'`);
104
- }
105
112
  const reader = origin(options.readerOrigin);
106
113
  const board = origin(options.boardOrigin);
107
- const doFetch = options.fetchImpl ?? fetch;
108
114
  const isNativeSpur = reader === board;
115
+ if (!isHttpOrigin(options.boardOrigin)) {
116
+ throw new Error(`board origin must be an http origin, got '${options.boardOrigin}'`);
117
+ }
118
+ if (!isNativeSpur && !isLoopbackHttpOrigin(options.readerOrigin)) {
119
+ throw new Error(`reader origin must be loopback http, got '${options.readerOrigin}'`);
120
+ }
121
+ const doFetch = options.fetchImpl ?? fetch;
109
122
  async function getJson(url) {
110
123
  const response = await doFetch(url, { headers: { accept: "application/json" } });
111
124
  if (!response.ok) {
@@ -67,6 +67,19 @@ function isLoopbackHttpOrigin(origin) {
67
67
  return false;
68
68
  return url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "[::1]";
69
69
  }
70
+ function isHttpOrigin(origin) {
71
+ let url;
72
+ try {
73
+ url = new URL(origin);
74
+ } catch {
75
+ return false;
76
+ }
77
+ if (url.protocol !== "http:" && url.protocol !== "https:")
78
+ return false;
79
+ if (url.username !== "" || url.password !== "")
80
+ return false;
81
+ return url.pathname === "/" && url.search === "" && url.hash === "";
82
+ }
70
83
  function origin(base) {
71
84
  return base.replace(/\/+$/, "");
72
85
  }
@@ -90,16 +103,16 @@ function parseSpurDbRows(pageData) {
90
103
  });
91
104
  }
92
105
  function createBoardClient(options) {
93
- if (!isLoopbackHttpOrigin(options.readerOrigin)) {
94
- throw new Error(`reader origin must be loopback http, got '${options.readerOrigin}'`);
95
- }
96
- if (!isLoopbackHttpOrigin(options.boardOrigin)) {
97
- throw new Error(`board origin must be loopback http, got '${options.boardOrigin}'`);
98
- }
99
106
  const reader = origin(options.readerOrigin);
100
107
  const board = origin(options.boardOrigin);
101
- const doFetch = options.fetchImpl ?? fetch;
102
108
  const isNativeSpur = reader === board;
109
+ if (!isHttpOrigin(options.boardOrigin)) {
110
+ throw new Error(`board origin must be an http origin, got '${options.boardOrigin}'`);
111
+ }
112
+ if (!isNativeSpur && !isLoopbackHttpOrigin(options.readerOrigin)) {
113
+ throw new Error(`reader origin must be loopback http, got '${options.readerOrigin}'`);
114
+ }
115
+ const doFetch = options.fetchImpl ?? fetch;
103
116
  async function getJson(url) {
104
117
  const response = await doFetch(url, { headers: { accept: "application/json" } });
105
118
  if (!response.ok) {
@@ -67,6 +67,19 @@ function isLoopbackHttpOrigin(origin) {
67
67
  return false;
68
68
  return url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "[::1]";
69
69
  }
70
+ function isHttpOrigin(origin) {
71
+ let url;
72
+ try {
73
+ url = new URL(origin);
74
+ } catch {
75
+ return false;
76
+ }
77
+ if (url.protocol !== "http:" && url.protocol !== "https:")
78
+ return false;
79
+ if (url.username !== "" || url.password !== "")
80
+ return false;
81
+ return url.pathname === "/" && url.search === "" && url.hash === "";
82
+ }
70
83
  function origin(base) {
71
84
  return base.replace(/\/+$/, "");
72
85
  }
@@ -90,16 +103,16 @@ function parseSpurDbRows(pageData) {
90
103
  });
91
104
  }
92
105
  function createBoardClient(options) {
93
- if (!isLoopbackHttpOrigin(options.readerOrigin)) {
94
- throw new Error(`reader origin must be loopback http, got '${options.readerOrigin}'`);
95
- }
96
- if (!isLoopbackHttpOrigin(options.boardOrigin)) {
97
- throw new Error(`board origin must be loopback http, got '${options.boardOrigin}'`);
98
- }
99
106
  const reader = origin(options.readerOrigin);
100
107
  const board = origin(options.boardOrigin);
101
- const doFetch = options.fetchImpl ?? fetch;
102
108
  const isNativeSpur = reader === board;
109
+ if (!isHttpOrigin(options.boardOrigin)) {
110
+ throw new Error(`board origin must be an http origin, got '${options.boardOrigin}'`);
111
+ }
112
+ if (!isNativeSpur && !isLoopbackHttpOrigin(options.readerOrigin)) {
113
+ throw new Error(`reader origin must be loopback http, got '${options.readerOrigin}'`);
114
+ }
115
+ const doFetch = options.fetchImpl ?? fetch;
103
116
  async function getJson(url) {
104
117
  const response = await doFetch(url, { headers: { accept: "application/json" } });
105
118
  if (!response.ok) {
package/dist/index.js CHANGED
@@ -49085,7 +49085,7 @@ function checkOne(record, options) {
49085
49085
  if (!existsSync6(record.check)) {
49086
49086
  return { name: record.name, kind: record.kind, status: "error", detail: "probe entry missing" };
49087
49087
  }
49088
- const timeoutMs = options.timeout !== undefined ? Number(options.timeout) : 30000;
49088
+ const timeoutMs = options.timeout !== undefined ? Number(options.timeout) : 60000;
49089
49089
  const result = spawnPluginProbe({
49090
49090
  entryPath: record.check,
49091
49091
  cwd: record.dir,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gobing-ai/knowledge-kit",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "An ingest → create → publish content pipeline CLI (Bun).",
5
5
  "repository": {
6
6
  "type": "git",
@@ -12,7 +12,13 @@
12
12
  # `kk store extract --limit` overrides it.
13
13
  topic: ai-news
14
14
  where:
15
- kk-item-category: { in: [ai-news, ai-industry] }
15
+ # 2026-10-21: the allowlist is the whole AI taxonomy, in lockstep with the workflow's
16
+ # qc_categories (`ai-news,ai-technical,ai-research,ai-industry`) and episode-plan-gen's
17
+ # DEFAULT_FILTER_CONFIG. The two-category gate here front-ran that widening one layer upstream:
18
+ # a live pool labelled 20 ai-industry + 7 ai-research + 7 ai-technical + 3 ai-news came out of
19
+ # extract as 6 items, so `daily-qc-content`'s floor refusal closed the day as no-content before
20
+ # the widened QC allowlist could admit a technical or research story.
21
+ kk-item-category: { in: [ai-news, ai-industry, ai-technical, ai-research] }
16
22
  # quality 2, not 3: real ingest bodies (last30days/aihot summaries) are stub-length, so the
17
23
  # heuristic labeler tops out at 1-2 and a gte-3 gate empties every real pool (task 0212 dogfood
18
24
  # finding). Raise this back when a full-text ingest source lands.
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "kk",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "knowledge-kit Claude Code plugin: skills, commands, subagents, hooks, and rules"
5
5
  }
@@ -74,6 +74,7 @@ import {
74
74
  coverBrief,
75
75
  coverNormalizeBody,
76
76
  dedupPlanByTitle,
77
+ expandHome,
77
78
  fail,
78
79
  failWith,
79
80
  firstArtifact,
@@ -937,11 +938,14 @@ export function dailyGenerate(args: string[]): void {
937
938
  if (existsSync(planPath)) {
938
939
  try {
939
940
  const plan = readJson(planPath) as { items?: unknown[] };
940
- if (Array.isArray(plan.items) && plan.items.length !== itemSections.length) {
941
- failWith(
942
- 1,
943
- `generate: plan item count (${plan.items.length}) does not match script item sections (${itemSections.length})`,
944
- );
941
+ if (Array.isArray(plan.items)) {
942
+ const expected = expectedItemSections(workDir, runDate, plan.items);
943
+ if (expected !== itemSections.length) {
944
+ failWith(
945
+ 1,
946
+ `generate: plan item count (${plan.items.length}, ${plan.items.length - expected} recorded as excluded) does not match script item sections (${itemSections.length})`,
947
+ );
948
+ }
945
949
  }
946
950
  } catch (e: unknown) {
947
951
  if (e instanceof StageFailure) throw e;
@@ -1317,7 +1321,10 @@ export function dailyIntro(args: string[]): void {
1317
1321
  if (rawFile === undefined) {
1318
1322
  failWith(1, 'intro-music: voice content carries no mp3Path/audioPath');
1319
1323
  }
1320
- const introPath = resolve(introSrc);
1324
+ // The sting is an operator asset under the config root (ADR-042), not a works artifact:
1325
+ // workflow vars carry it as `~/.config/kk/voices/…`, and a shell does not expand `~` inside
1326
+ // the quoted "${vars.intro_music}" argument, so expansion happens here.
1327
+ const introPath = resolve(expandHome(introSrc));
1321
1328
  if (!existsSync(introPath)) failWith(1, `intro-music: intro file not found: ${introPath}`);
1322
1329
  const mainPath = resolve(rawFile);
1323
1330
  if (!existsSync(mainPath)) failWith(1, `intro-music: generated audio not found: ${mainPath}`);
@@ -2087,6 +2094,16 @@ export function dailySafetyPrep(args: string[]): void {
2087
2094
  const [workDir = '', runDate = '', platforms = ''] = args;
2088
2095
  timed(workDir, 'safety-review', () => {
2089
2096
  const digest = safetyDigest(workDir, runDate);
2097
+ // Structural snapshot of the VoiceScript BEFORE any remediation edit. `daily-safety-revise`
2098
+ // restores it when the remediation pass changes the item-section structure without a recorded
2099
+ // exclusion: the agent is allowed to drop a 播报形态 line, not an item the article kept,
2100
+ // because `daily-wrap-docs` derives the spoken item sections from `gap_ms: 1200` and fails the
2101
+ // run when they no longer match the episode's included items (2026-10-21 04:30 run: 3
2102
+ // segments removed → 5 sections vs a 6-item plan → `generate` refused at 05:03 and the whole
2103
+ // day published nothing).
2104
+ const [, scriptPath] = safetyDraftPaths(workDir, runDate);
2105
+ const snapshot = safetyPath(workDir, runDate, 'safety-prep_script-snapshot.yaml');
2106
+ if (isFile(scriptPath)) sted('write', snapshot, () => writeRaw(snapshot, readFileSync(scriptPath, 'utf-8')));
2090
2107
  for (const name of [
2091
2108
  'safety-review_report.json',
2092
2109
  'safety-judge_verdict.json',
@@ -2146,6 +2163,7 @@ export function dailySafetyRevise(args: string[]): void {
2146
2163
  }
2147
2164
  }
2148
2165
  assertScriptContent(scriptPath);
2166
+ restoreStructureIfBroken(workDir, runDate, scriptPath);
2149
2167
  const path = join(workDir, SAFETY_REVISE_COUNT);
2150
2168
  const next = readSafetyReviseCount(workDir) + 1;
2151
2169
  sted('write', path, () => writeFileSync(path, `${next}\n`));
@@ -2153,6 +2171,71 @@ export function dailySafetyRevise(args: string[]): void {
2153
2171
  });
2154
2172
  }
2155
2173
 
2174
+ /**
2175
+ * The spoken item-section count the episode is contracted to carry: the script plan's items minus the
2176
+ * ones the article boundary recorded in `metadata.excludedIds`.
2177
+ *
2178
+ * The article drops boundary docs at its own stage, the safety gate aligns the VoiceScript with the
2179
+ * article it reviews, and `daily-show-notes` already filters the plan copy by the same ids — but
2180
+ * `daily-generate` compared the sections against the raw plan length, so the 2026-10-21 run refused a
2181
+ * 5-section script against a 6-item plan (the 6th was the article-excluded 通用汽车 buyback) after two
2182
+ * hours of safety review and rendered nothing. Every consumer must count the same set.
2183
+ */
2184
+ export function expectedItemSections(workDir: string, runDate: string, planItems: unknown[]): number {
2185
+ const excluded = metadataOf(
2186
+ readJson(join(workDir, '22-article', `${runDate}_06_article_content.json`)),
2187
+ ).excludedIds;
2188
+ if (!Array.isArray(excluded)) return planItems.length;
2189
+ const dropped = new Set(excluded.filter((id): id is string => typeof id === 'string' && id !== ''));
2190
+ if (dropped.size === 0) return planItems.length;
2191
+ return planItems.filter(
2192
+ (item) => !(item !== null && typeof item === 'object' && dropped.has(String((item as { id?: unknown }).id))),
2193
+ ).length;
2194
+ }
2195
+
2196
+ /**
2197
+ * Repair a remediation pass that changed the VoiceScript's item-section structure.
2198
+ *
2199
+ * `daily-wrap-docs` (and `daily-generate`) derive the spoken item sections from `gap_ms: 1200` and
2200
+ * refuse the run when they disagree with the episode's included items. The safety agent is allowed
2201
+ * to reshape lines, and to drop an item the article boundary already excluded (that drop is the
2202
+ * contract, and `expectedItemSections` counts it out), but an unrecorded drop breaks the one-to-one
2203
+ * audio/notes/plan relation — and on 2026-10-21 one killed the 04:30 run at `generate` after two
2204
+ * hours of safety review. The pre-revise bytes are snapshotted by `daily-safety-prep`, so an
2205
+ * unaccounted structural change is undone here, inside the budget loop, and named for the operator.
2206
+ * The budget is still spent, so the loop still converges (the re-review sees the unchanged script and
2207
+ * the gate classifies the residue) instead of the day failing hours later at the TTS seam.
2208
+ */
2209
+ function restoreStructureIfBroken(workDir: string, runDate: string, scriptPath: string): void {
2210
+ const planPath = join(workDir, '24-script', `${runDate}_08_script-write_plan.json`);
2211
+ const plan = readJson(planPath) as { items?: unknown[] } | null;
2212
+ if (plan === null || !Array.isArray(plan.items)) return;
2213
+ const snapshot = safetyPath(workDir, runDate, 'safety-prep_script-snapshot.yaml');
2214
+ if (!isFile(snapshot)) return;
2215
+ const sections = (path: string): number => {
2216
+ const parsed = Bun.YAML.parse(readFileSync(path, 'utf-8')) as { segments?: Array<Record<string, unknown>> };
2217
+ const segments = Array.isArray(parsed.segments) ? parsed.segments : [];
2218
+ let count = 0;
2219
+ let pending = false;
2220
+ for (const segment of segments.slice(1, -1)) {
2221
+ pending = true;
2222
+ if (segment.gap_ms === 1200) {
2223
+ count += 1;
2224
+ pending = false;
2225
+ }
2226
+ }
2227
+ return count + (pending ? 1 : 0);
2228
+ };
2229
+ const expected = expectedItemSections(workDir, runDate, plan.items);
2230
+ const actual = sections(scriptPath);
2231
+ if (actual === expected) return;
2232
+ const original = sections(snapshot);
2233
+ sted('write', scriptPath, () => writeRaw(scriptPath, readFileSync(snapshot, 'utf-8')));
2234
+ console.error(
2235
+ `safety-revise: the edit changed the VoiceScript item sections (${expected} included plan items, ${original} before, ${actual} after) — restored the pre-revision structure from ${basename(snapshot)}; audio/notes stay one-to-one with the plan`,
2236
+ );
2237
+ }
2238
+
2156
2239
  /** Block YAML for the fields a fitted VoiceScript actually carries. Strings use JSON quoting. */
2157
2240
  function voicescriptToBlockYaml(script: Record<string, unknown>): string {
2158
2241
  const lines: string[] = [];
@@ -751,7 +751,7 @@ function formatRunDate(): string {
751
751
  }
752
752
 
753
753
  /** Expand the `~/` and `$HOME/` prefixes the config examples use. */
754
- function expandHome(p: string): string {
754
+ export function expandHome(p: string): string {
755
755
  if (p.startsWith('~/')) return join(homedir(), p.slice(2));
756
756
  if (p.startsWith('$HOME/')) return join(homedir(), p.slice('$HOME/'.length));
757
757
  return p;
@@ -30,7 +30,9 @@ vars:
30
30
  # env block; smoke render 6.8s). Flip back with vdriver="voicebox".
31
31
  vdriver: "gemini"
32
32
  # 20260917 review #7: 8s intro sting prepended to every daily output; empty = skip.
33
- intro_music: ".works/kk-daily-ai-voice/AI每日资讯_8秒播客片头.mp3"
33
+ # The sting is an operator asset: it lives under the config root (ADR-042), never in the works
34
+ # tree. `~` is expanded by the daily-intro stage, so it stays machine-portable.
35
+ intro_music: "~/.config/kk/voices/AI每日资讯_8秒播客片头.mp3"
34
36
  # 20260917 review #4/#6: surfdash checkout the patch stage commits to.
35
37
  surfdash_root: "/Users/robin/xprojects/surfdash"
36
38
  title: ""
@@ -35,7 +35,7 @@ vars:
35
35
  # exists; flipping this one var to "gemini" is the escape hatch (measured dry: 20-25 ms / -34..-37 dB)
36
36
  # at the price of a 1h47m-2h21m remote render.
37
37
  vdriver: "voxcpm"
38
- intro_music: ".works/kk-daily-ai-voice/AI每日资讯_8秒播客片头.mp3"
38
+ intro_music: "~/.config/kk/voices/AI每日资讯_8秒播客片头.mp3"
39
39
  surfdash_root: "/Users/robin/xprojects/surfdash"
40
40
  title: ""
41
41
  skip_review: "false"
@@ -48,7 +48,12 @@ vars:
48
48
  cover_fallback: ""
49
49
  # 0219 R3: image-gen vendor fallback for the XHS render; "" restores strict fail-fast.
50
50
  xhs_fallback: "true"
51
- xhs_enabled: "true"
51
+ # 2026-10-21 operator decision: OFF for now (was on since 2026-09-29). The plugin reported today's
52
+ # draft as saved (`草稿箱` 12 -> 13 plus a scoped `保存成功` toast) while the console showed the
53
+ # operator no draft, so the channel's success evidence is not trustworthy until that is resolved.
54
+ # Flip back to "true" to resume the card plan/render/draft series; `xhs_live` stays a separate
55
+ # launch-time switch and nothing else in the run depends on this target.
56
+ xhs_enabled: "false"
52
57
  xhs_cards: "4"
53
58
  xhs_style: "xhs-knowledge"
54
59
  xhs_provider: "minimax"
@@ -185,7 +190,7 @@ nodes:
185
190
  options:
186
191
  onError: continue
187
192
  command: >-
188
- kk plugin check wechatmp-pub xhs-pub
193
+ kk plugin check wechatmp-pub xhs-pub --timeout 60000
189
194
  || echo "session-preflight: a publish channel's saved session was refused — fix it before this run's publish step with: bun plugins/publishings/<channel>-pub/src/profile-check.ts login (wechatmp or xhs)"
190
195
 
191
196
  - id: extract
@@ -22166,12 +22166,7 @@ async function waitForVisible(page, selector, timeoutMs, opts = {}) {
22166
22166
  // ../../plugins/publishings/wechatmp-pub/src/enrich.ts
22167
22167
  var DIALOG = ".weui-desktop-dialog:visible";
22168
22168
  var CONFIRM = `${DIALOG} button:has-text("\u786E\u8BA4"), ${DIALOG} button:has-text("\u786E\u5B9A")`;
22169
- var ROW_ORIGINAL = [
22170
- ".setting-group__switch.js_original_apply.js_edit_ori",
22171
- ".setting-group__switch.js_edit_ori",
22172
- ".js_edit_ori",
22173
- "text=\u539F\u521B"
22174
- ];
22169
+ var ROW_ORIGINAL = ["#js_original .setting-group__content-info", "#js_original .setting-group__title"];
22175
22170
  var ROW_REWARD = [
22176
22171
  ".setting-group__switch.js_reward_open",
22177
22172
  ".js_reward_open",
@@ -22180,18 +22175,28 @@ var ROW_REWARD = [
22180
22175
  "text=\u8D5E\u8D4F"
22181
22176
  ];
22182
22177
  var ROW_COLLECTION = [".allow_click_opr.js_article_tags_label", "text=\u5408\u96C6"];
22183
- var ROW_URL = [".allow_click_opr.js_url_checkbox", "text=\u539F\u6587\u94FE\u63A5"];
22184
- var ROW_CLAIM = ["#js_claim_source_area .frm_checkbox_label", "text=\u521B\u4F5C\u6765\u6E90"];
22178
+ var ROW_URL = ["#js_article_url_area .js_article_url_allow_click"];
22179
+ var ROW_CLAIM = ["#js_claim_source_area .allow_click_opr"];
22185
22180
  var DIALOG_CONFIRM = CONFIRM;
22181
+ var ORIGINAL_CONFIRM = `${DIALOG} button:text-is("\u786E\u5B9A")`;
22182
+ var CLAIM_CONFIRM = `${DIALOG} button:text-is("\u786E\u8BA4")`;
22186
22183
  var TAG_INPUT = 'input[placeholder*="\u5408\u96C6"]';
22187
22184
  var REWARD_ACCOUNT = [
22188
22185
  `${DIALOG} input[placeholder*="\u8D5E\u8D4F"]`,
22189
22186
  `${DIALOG} input[placeholder*="\u8D26\u6237"]`,
22190
22187
  `${DIALOG} input.frm_input`
22191
22188
  ];
22192
- var URL_INPUT = [`${DIALOG} input[type="url"]`, `${DIALOG} input[placeholder*="\u94FE\u63A5"]`, `${DIALOG} input.frm_input`];
22189
+ var URL_POPOVER = ".popover_inner:has(.popover-article-setting__content):visible";
22190
+ var URL_INPUT = [`${URL_POPOVER} input.js_url[name="source_url"]`];
22191
+ var URL_CONFIRM = `${URL_POPOVER} a.jsPopoverBt:text-is("\u786E\u5B9A")`;
22192
+ var URL_CANCEL = `${URL_POPOVER} a.jsPopoverBt:text-is("\u53D6\u6D88")`;
22193
+ var URL_COMMITTED = `(() => {
22194
+ const area = document.querySelector('#js_article_url_area');
22195
+ const checkbox = area?.querySelector('input.js_url_checkbox');
22196
+ return checkbox?.checked === true ? area.querySelector('.article_url_setting')?.textContent ?? null : null;
22197
+ })()`;
22193
22198
  var ORIGINAL_DECLARATION = `${DIALOG} label:has(.js_original_type_radio):has-text("\u6587\u5B57\u539F\u521B")`;
22194
- var ORIGINAL_AGREEMENT = `${DIALOG} .original_agreement`;
22199
+ var ORIGINAL_AGREEMENT = `${DIALOG} label.original_agreement:has(input[type="checkbox"]:not(:checked))`;
22195
22200
  var COLLECTION_SETTLE_MS = 1200;
22196
22201
  var STEP_RETRY_SETTLE_MS = 1000;
22197
22202
  var SWEEP_SETTLE_MS = 400;
@@ -22203,7 +22208,8 @@ function norm(value) {
22203
22208
  }
22204
22209
  var COMMITTED = {
22205
22210
  original: `(() => {
22206
- const el = document.querySelector('#js_original, .js_ori_setting_checkbox');
22211
+ const el = [...document.querySelectorAll('#js_original input.weui-desktop-switch__input')]
22212
+ .find(input => input.parentElement?.getClientRects().length > 0);
22207
22213
  return el ? el.checked === true : null;
22208
22214
  })()`,
22209
22215
  reward: `(() => {
@@ -22234,7 +22240,7 @@ async function enrichArticle(page, meta) {
22234
22240
  const str = (key) => typeof meta[key] === "string" ? meta[key] : "";
22235
22241
  const on = (key) => meta[key] === true;
22236
22242
  const sweep = async () => {
22237
- const cancel = await trySelectors(page, ['button:has-text("\u53D6\u6D88")']);
22243
+ const cancel = await trySelectors(page, ['button:has-text("\u53D6\u6D88")', URL_CANCEL]);
22238
22244
  if (cancel.found) {
22239
22245
  try {
22240
22246
  await page.locator(cancel.selector ?? "").first().click({ timeout: 2000 });
@@ -22305,7 +22311,7 @@ async function enrichArticle(page, meta) {
22305
22311
  if (agreement.found) {
22306
22312
  await clickSelector(page, ORIGINAL_AGREEMENT, 3000);
22307
22313
  }
22308
- await clickSelector(page, DIALOG_CONFIRM, 8000);
22314
+ await clickSelector(page, ORIGINAL_CONFIRM, 8000);
22309
22315
  });
22310
22316
  if (outcome !== "capability" && await readChecked(page, COMMITTED.original) !== true && !await residual(name)) {
22311
22317
  warnings.push(`${name}: \u539F\u521B\u58F0\u660E\u672A\u751F\u6548\uFF0C\u8BF7\u624B\u52A8\u8BBE\u7F6E`);
@@ -22358,23 +22364,32 @@ async function enrichArticle(page, meta) {
22358
22364
  const originalUrl = str("originalUrl");
22359
22365
  if (originalUrl !== "") {
22360
22366
  const name = "\u539F\u6587\u94FE\u63A5";
22361
- const urlRow = await tryFindSelector(page, ROW_URL, 2000);
22367
+ const readUrl = async () => {
22368
+ try {
22369
+ return norm(await page.evaluate(URL_COMMITTED));
22370
+ } catch {
22371
+ return "";
22372
+ }
22373
+ };
22374
+ const urlRow = await readUrl() === norm(originalUrl) ? "committed" : await tryFindSelector(page, ROW_URL, 2000);
22362
22375
  if (urlRow === undefined) {
22363
22376
  warnings.push(`${name}: \u884C\u4E0D\u5B58\u5728\uFF08\u5F53\u524D\u516C\u4F17\u53F7\u4E0D\u652F\u6301\uFF09\uFF0C\u8BF7\u624B\u52A8\u5904\u7406\u6216\u79FB\u9664 originalUrl`);
22364
- } else {
22377
+ } else if (urlRow !== "committed") {
22365
22378
  await step(name, async () => {
22379
+ if (await readUrl() === norm(originalUrl))
22380
+ return;
22366
22381
  await openRow(ROW_URL, "\u539F\u6587\u94FE\u63A5");
22367
22382
  const field = await trySelectors(page, URL_INPUT);
22368
22383
  if (!field.found)
22369
- throw new Error("no URL field in the \u539F\u6587\u94FE\u63A5 dialog");
22384
+ throw new Error("no URL field in the \u539F\u6587\u94FE\u63A5 popover");
22370
22385
  await fillSelector(page, field.selector ?? URL_INPUT.join(", "), originalUrl);
22371
- await clickSelector(page, DIALOG_CONFIRM, 8000);
22386
+ await clickSelector(page, URL_CONFIRM, 8000);
22387
+ await page.locator(URL_POPOVER).first().waitFor?.({ state: "hidden", timeout: 8000 });
22372
22388
  });
22373
- await residual(name);
22374
- const locator = page.locator(URL_INPUT[0] ?? "").first();
22375
- const committed = typeof locator.inputValue === "function" ? await locator.inputValue().catch(() => null) : null;
22376
- if (norm(committed) !== norm(originalUrl))
22389
+ if (await readUrl() !== norm(originalUrl))
22377
22390
  warnings.push(`${name}: \u5DF2\u63D0\u4EA4\u4F46\u672A\u8BFB\u53D6\u5230\u5DF2\u4FDD\u5B58\u7684\u94FE\u63A5\uFF0C\u8BF7\u6838\u5BF9`);
22391
+ if ((await trySelectors(page, [URL_POPOVER])).found)
22392
+ await sweep();
22378
22393
  }
22379
22394
  }
22380
22395
  const claimSource = str("claimSource");
@@ -22390,17 +22405,22 @@ async function enrichArticle(page, meta) {
22390
22405
  return null;
22391
22406
  }
22392
22407
  };
22408
+ let unavailable = false;
22393
22409
  if (await claimMatched() !== true) {
22394
- await step(name, async () => {
22410
+ const outcome = await step(name, async () => {
22395
22411
  await openRow(ROW_CLAIM, "\u521B\u4F5C\u6765\u6E90");
22396
- const dialog = await waitForVisible(page, DIALOG, 3000, { required: false });
22397
- if (dialog !== undefined) {
22398
- await clickSelector(page, `${DIALOG} :text("${claimSource}")`, 8000);
22399
- await clickSelector(page, DIALOG_CONFIRM, 8000);
22412
+ await waitForVisible(page, DIALOG, 3000);
22413
+ const stance = `${DIALOG} label:has(input[type="radio"]):has(:text-is(${JSON.stringify(claimSource)}))`;
22414
+ if (await tryFindSelector(page, [stance], 1500) === undefined) {
22415
+ await sweep();
22416
+ throw new CapabilityError(`\u5E73\u53F0\u672A\u63D0\u4F9B\u58F0\u660E\u7C7B\u578B\u300C${claimSource}\u300D\uFF0C\u5DF2\u8DF3\u8FC7`);
22400
22417
  }
22418
+ await clickSelector(page, stance, 8000);
22419
+ await clickSelector(page, CLAIM_CONFIRM, 8000);
22401
22420
  });
22421
+ unavailable = outcome === "capability";
22402
22422
  }
22403
- if (await claimMatched() !== true && !await residual(name)) {
22423
+ if (!unavailable && await claimMatched() !== true && !await residual(name)) {
22404
22424
  warnings.push(`${name}: \u672A\u751F\u6548\uFF0C\u8BF7\u624B\u52A8\u8BBE\u7F6E`);
22405
22425
  }
22406
22426
  }
@@ -22507,13 +22527,17 @@ var AUDIO_UPLOAD_INPUT = [
22507
22527
  '.weui-desktop-dialog input[type="file"][accept*="audio"]',
22508
22528
  '.weui-desktop-dialog input[type="file"][accept*=".mp3"]',
22509
22529
  '.audio_dialog input[type="file"][accept*="audio"]',
22510
- 'input[type="file"][accept*=".mp3"]'
22530
+ 'input[type="file"][accept*=".mp3"]',
22531
+ '.weui-desktop-dialog input[type="file"]:not([accept*="image"])',
22532
+ '.audio_dialog input[type="file"]:not([accept*="image"])'
22511
22533
  ];
22512
22534
  var UPLOAD_COVER = ["text=\u4E0A\u4F20\u5C01\u9762", 'button:has-text("\u4E0A\u4F20\u5C01\u9762")'];
22513
22535
  var AUDIO_COVER_INPUT = [
22514
22536
  '.weui-desktop-dialog input[type="file"][accept*="image/"]',
22515
22537
  '.audio_dialog input[type="file"][accept*="image/"]',
22516
- 'input[type="file"][accept*=".png"]'
22538
+ 'input[type="file"][accept*=".png"]',
22539
+ '.weui-desktop-dialog input[type="file"][accept*="image"]',
22540
+ '.audio_dialog input[type="file"][accept*="image"]'
22517
22541
  ];
22518
22542
  var AUDIO_TITLE_INPUT = [
22519
22543
  '.weui-desktop-dialog input[placeholder*="\u6807\u9898"]',
@@ -22702,7 +22726,7 @@ async function applyLayout(page, deps) {
22702
22726
  }
22703
22727
  const option = await tryFindSelector(page, LAYOUT_TARGET, 5000);
22704
22728
  if (option === undefined) {
22705
- return "\u4E00\u952E\u6392\u7248: clicked, but no \u5339\u914D\u76EE\u6807\u683C\u5F0F option appeared \u2014 the body may be unformatted; check the draft";
22729
+ return "\u4E00\u952E\u6392\u7248: the control exists but this build renders no \u5339\u914D\u76EE\u6807\u683C\u5F0F option (measured 2026-10-21 with a body in place) \u2014 layout skipped, apply \u4E00\u952E\u6392\u7248 by hand before publishing";
22706
22730
  }
22707
22731
  try {
22708
22732
  await clickSelector(page, option, 5000);
@@ -20729,14 +20729,19 @@ var MAX_AGGREGATE_BYTES = 20 * 1024 * 1024;
20729
20729
  // ../../plugins/publishings/wechatmp-pub/src/enrich.ts
20730
20730
  var DIALOG = ".weui-desktop-dialog:visible";
20731
20731
  var CONFIRM = `${DIALOG} button:has-text("\u786E\u8BA4"), ${DIALOG} button:has-text("\u786E\u5B9A")`;
20732
+ var ORIGINAL_CONFIRM = `${DIALOG} button:text-is("\u786E\u5B9A")`;
20733
+ var CLAIM_CONFIRM = `${DIALOG} button:text-is("\u786E\u8BA4")`;
20732
20734
  var REWARD_ACCOUNT = [
20733
20735
  `${DIALOG} input[placeholder*="\u8D5E\u8D4F"]`,
20734
20736
  `${DIALOG} input[placeholder*="\u8D26\u6237"]`,
20735
20737
  `${DIALOG} input.frm_input`
20736
20738
  ];
20737
- var URL_INPUT = [`${DIALOG} input[type="url"]`, `${DIALOG} input[placeholder*="\u94FE\u63A5"]`, `${DIALOG} input.frm_input`];
20739
+ var URL_POPOVER = ".popover_inner:has(.popover-article-setting__content):visible";
20740
+ var URL_INPUT = [`${URL_POPOVER} input.js_url[name="source_url"]`];
20741
+ var URL_CONFIRM = `${URL_POPOVER} a.jsPopoverBt:text-is("\u786E\u5B9A")`;
20742
+ var URL_CANCEL = `${URL_POPOVER} a.jsPopoverBt:text-is("\u53D6\u6D88")`;
20738
20743
  var ORIGINAL_DECLARATION = `${DIALOG} label:has(.js_original_type_radio):has-text("\u6587\u5B57\u539F\u521B")`;
20739
- var ORIGINAL_AGREEMENT = `${DIALOG} .original_agreement`;
20744
+ var ORIGINAL_AGREEMENT = `${DIALOG} label.original_agreement:has(input[type="checkbox"]:not(:checked))`;
20740
20745
 
20741
20746
  // ../../plugins/publishings/wechatmp-pub/src/index.ts
20742
20747
  var CHANNEL = TARGET;
@@ -20828,7 +20833,7 @@ if (false) {}
20828
20833
  // ../../plugins/publishings/wechatmp-pub/src/profile-check.ts
20829
20834
  var USAGE = "usage: wechatmp profile-check [login|check|open] [--profile <dir>] [--url <url>] [--timeout <ms>] [--headed]";
20830
20835
  var LOGIN_TIMEOUT_MS = 180000;
20831
- var PROFILE_OVERHEAD_MS = 8000;
20836
+ var PROFILE_OVERHEAD_MS = 25000;
20832
20837
  var MIN_READINESS_MS = 1000;
20833
20838
  async function main2(argv = process.argv.slice(2)) {
20834
20839
  let command;
@@ -21,16 +21,10 @@ import { clickSelector, fillSelector, modalState, tryFindSelector, type WechatPa
21
21
  const DIALOG = '.weui-desktop-dialog:visible';
22
22
  const CONFIRM = `${DIALOG} button:has-text("确认"), ${DIALOG} button:has-text("确定")`;
23
23
  /**
24
- * Every settings row follows one shape (operator walk 2026-10-10): a row control that opens a dialog,
25
- * an action inside it, then 确定. The row's own label is listed alongside its `>` expander because both
26
- * dispatch the same handler; the first selector that resolves wins.
24
+ * Dialog entries measured 2026-10-21. Confirm labels belong to each dialog: 原创 uses 确定,
25
+ * 创作来源 uses 确认. A generic text match can hit an inert row heading instead of its opener.
27
26
  */
28
- const ROW_ORIGINAL = [
29
- '.setting-group__switch.js_original_apply.js_edit_ori',
30
- '.setting-group__switch.js_edit_ori',
31
- '.js_edit_ori',
32
- 'text=原创',
33
- ];
27
+ const ROW_ORIGINAL = ['#js_original .setting-group__content-info', '#js_original .setting-group__title'];
34
28
  // Measured 2026-10-13: the 赞-tipped rows are `.js_reward_open` / `.js_reward_allow_click` — the
35
29
  // old `.setting-group__switch.js_can_reward` no longer resolves. The 原创 dialog's own 赞赏
36
30
  // switch (`js_reward_switch`) must never be clicked by this row list (R3, drift task 0272).
@@ -42,9 +36,11 @@ const ROW_REWARD = [
42
36
  'text=赞赏',
43
37
  ];
44
38
  const ROW_COLLECTION = ['.allow_click_opr.js_article_tags_label', 'text=合集'];
45
- const ROW_URL = ['.allow_click_opr.js_url_checkbox', 'text=原文链接'];
46
- const ROW_CLAIM = ['#js_claim_source_area .frm_checkbox_label', 'text=创作来源'];
39
+ const ROW_URL = ['#js_article_url_area .js_article_url_allow_click'];
40
+ const ROW_CLAIM = ['#js_claim_source_area .allow_click_opr'];
47
41
  const DIALOG_CONFIRM = CONFIRM;
42
+ const ORIGINAL_CONFIRM = `${DIALOG} button:text-is("确定")`;
43
+ const CLAIM_CONFIRM = `${DIALOG} button:text-is("确认")`;
48
44
  const TAG_INPUT = 'input[placeholder*="合集"]';
49
45
  /** The 赞赏 dialog's account field, filled only when the platform leaves it empty. */
50
46
  const REWARD_ACCOUNT = [
@@ -53,15 +49,23 @@ const REWARD_ACCOUNT = [
53
49
  `${DIALOG} input.frm_input`,
54
50
  ];
55
51
  /**
56
- * The 原文链接 dialog's URL field.
57
- * `URL_INPUT[0]` doubles as the committed-readback locator, so it must be concrete (not `:visible`).
52
+ * 原文链接 is a popover outside the dialog tree (measured 2026-10-21, task 0318).
53
+ * Its field disappears on commit; committed readback belongs to the settings row.
58
54
  */
59
- const URL_INPUT = [`${DIALOG} input[type="url"]`, `${DIALOG} input[placeholder*="链接"]`, `${DIALOG} input.frm_input`];
55
+ const URL_POPOVER = '.popover_inner:has(.popover-article-setting__content):visible';
56
+ const URL_INPUT = [`${URL_POPOVER} input.js_url[name="source_url"]`];
57
+ const URL_CONFIRM = `${URL_POPOVER} a.jsPopoverBt:text-is("确定")`;
58
+ const URL_CANCEL = `${URL_POPOVER} a.jsPopoverBt:text-is("取消")`;
59
+ const URL_COMMITTED = `(() => {
60
+ const area = document.querySelector('#js_article_url_area');
61
+ const checkbox = area?.querySelector('input.js_url_checkbox');
62
+ return checkbox?.checked === true ? area.querySelector('.article_url_setting')?.textContent ?? null : null;
63
+ })()`;
60
64
  /** The 原创 dialog's declaration radio (measured 2026-10-13): the platform requires an explicit
61
65
  * declaration type + the agreement check before 确定. `无需声明` is the undo choice, never a target. */
62
66
  const ORIGINAL_DECLARATION = `${DIALOG} label:has(.js_original_type_radio):has-text("文字原创")`;
63
- /** The 原创 agreement checkbox (measured 2026-10-13); absent on some account states — then 确定 speaks. */
64
- const ORIGINAL_AGREEMENT = `${DIALOG} .original_agreement`;
67
+ /** Drive the hidden checkbox through its label, preserving a pre-checked agreement (probe 6). */
68
+ const ORIGINAL_AGREEMENT = `${DIALOG} label.original_agreement:has(input[type="checkbox"]:not(:checked))`;
65
69
  /** The 合集 picker's options load after the dialog opens (the pre-ADR-029 settle, kept). */
66
70
  const COLLECTION_SETTLE_MS = 1_200;
67
71
  /** The settings panel re-renders after a dialog confirm and can briefly detach rows. */
@@ -86,9 +90,11 @@ function norm(value: unknown): string {
86
90
  * Playwright-only syntax (`:visible`, `:has-text`) never enters `querySelector` — it throws there.
87
91
  */
88
92
  const COMMITTED = {
89
- /** 原创 enabled = the settings row's checkbox checked (`#js_original` / `.js_ori_setting_checkbox`). */
93
+ /** Both undeclared and declared templates keep hidden inputs. Read the switch in the visible
94
+ * row branch, never the hidden undeclared template or the dialog's matching checkbox. */
90
95
  original: `(() => {
91
- const el = document.querySelector('#js_original, .js_ori_setting_checkbox');
96
+ const el = [...document.querySelectorAll('#js_original input.weui-desktop-switch__input')]
97
+ .find(input => input.parentElement?.getClientRects().length > 0);
92
98
  return el ? el.checked === true : null;
93
99
  })()`,
94
100
  /** 赞赏 enabled = the reward settings checkbox checked (area-scoped, else the visible cell's). */
@@ -133,7 +139,7 @@ export async function enrichArticle(page: WechatPage, meta: Record<string, unkno
133
139
  const on = (key: string) => meta[key] === true;
134
140
  // Sweep any leaked dialog so the next step (especially 保存为草稿) is never overlay-blocked.
135
141
  const sweep = async (): Promise<'closed' | 'unknown'> => {
136
- const cancel = await trySelectors(page, ['button:has-text("取消")']);
142
+ const cancel = await trySelectors(page, ['button:has-text("取消")', URL_CANCEL]);
137
143
  if (cancel.found) {
138
144
  try {
139
145
  await page
@@ -222,7 +228,7 @@ export async function enrichArticle(page: WechatPage, meta: Record<string, unkno
222
228
  if ((await readChecked(page, COMMITTED.original)) !== true) {
223
229
  const outcome = await step(name, async () => {
224
230
  await openRow(ROW_ORIGINAL, '原创声明');
225
- // Measured 2026-10-13: pick the 文字原创 radio, check the agreement when it renders,
231
+ // Measured 2026-10-21: keep the 文字原创 radio, click the agreement's label when it renders,
226
232
  // then 确定. The plain-text probe the old rail used could hit the dialog heading.
227
233
  await clickSelector(page, ORIGINAL_DECLARATION, 5_000);
228
234
  // trySelectors absorbs its own probe failures (returns found:false) — no .catch here.
@@ -230,7 +236,7 @@ export async function enrichArticle(page: WechatPage, meta: Record<string, unkno
230
236
  if (agreement.found) {
231
237
  await clickSelector(page, ORIGINAL_AGREEMENT, 3_000);
232
238
  }
233
- await clickSelector(page, DIALOG_CONFIRM, 8_000);
239
+ await clickSelector(page, ORIGINAL_CONFIRM, 8_000);
234
240
  });
235
241
  if (
236
242
  outcome !== 'capability' &&
@@ -305,27 +311,36 @@ export async function enrichArticle(page: WechatPage, meta: Record<string, unkno
305
311
  const originalUrl = str('originalUrl');
306
312
  if (originalUrl !== '') {
307
313
  const name = '原文链接';
314
+ const readUrl = async () => {
315
+ try {
316
+ return norm(await page.evaluate(URL_COMMITTED));
317
+ } catch {
318
+ return '';
319
+ }
320
+ };
308
321
  // Measured 2026-10-13: some accounts no longer render the 原文链接 row at all. That is an
309
322
  // unavailable capability — report it once, never fall back to the composer's unrelated
310
323
  // `链接` row (`btm-card-link__switch`).
311
- const urlRow = await tryFindSelector(page, ROW_URL, 2_000);
324
+ const urlRow =
325
+ (await readUrl()) === norm(originalUrl) ? 'committed' : await tryFindSelector(page, ROW_URL, 2_000);
312
326
  if (urlRow === undefined) {
313
327
  warnings.push(`${name}: 行不存在(当前公众号不支持),请手动处理或移除 originalUrl`);
314
- } else {
328
+ } else if (urlRow !== 'committed') {
315
329
  await step(name, async () => {
330
+ if ((await readUrl()) === norm(originalUrl)) return;
316
331
  await openRow(ROW_URL, '原文链接');
317
332
  const field = await trySelectors(page, URL_INPUT);
318
- if (!field.found) throw new Error('no URL field in the 原文链接 dialog');
333
+ if (!field.found) throw new Error('no URL field in the 原文链接 popover');
319
334
  await fillSelector(page, field.selector ?? URL_INPUT.join(', '), originalUrl);
320
- await clickSelector(page, DIALOG_CONFIRM, 8_000);
335
+ await clickSelector(page, URL_CONFIRM, 8_000);
336
+ // URL validation closes the popover asynchronously. Sweeping before it closes
337
+ // cancels that pending commit and turns a valid URL into a false mismatch.
338
+ await page.locator(URL_POPOVER).first().waitFor?.({ state: 'hidden', timeout: 8_000 });
321
339
  });
322
- await residual(name);
323
- // Only a readable committed URL equal to the requested trimmed value counts; the
324
- // composer `链接` row is never consulted.
325
- const locator = page.locator(URL_INPUT[0] ?? '').first();
326
- const committed =
327
- typeof locator.inputValue === 'function' ? await locator.inputValue().catch(() => null) : null;
328
- if (norm(committed) !== norm(originalUrl)) warnings.push(`${name}: 已提交但未读取到已保存的链接,请核对`);
340
+ if ((await readUrl()) !== norm(originalUrl)) warnings.push(`${name}: 已提交但未读取到已保存的链接,请核对`);
341
+ // Popovers are outside modalState's dialog inventory; release one even on a moved field
342
+ // or refused confirmation, before later settings and the draft save.
343
+ if ((await trySelectors(page, [URL_POPOVER])).found) await sweep();
329
344
  }
330
345
  }
331
346
 
@@ -343,17 +358,22 @@ export async function enrichArticle(page: WechatPage, meta: Record<string, unkno
343
358
  return null;
344
359
  }
345
360
  };
361
+ let unavailable = false;
346
362
  if ((await claimMatched()) !== true) {
347
- await step(name, async () => {
363
+ const outcome = await step(name, async () => {
348
364
  await openRow(ROW_CLAIM, '创作来源');
349
- const dialog = await waitForVisible(page, DIALOG, 3_000, { required: false });
350
- if (dialog !== undefined) {
351
- await clickSelector(page, `${DIALOG} :text("${claimSource}")`, 8_000);
352
- await clickSelector(page, DIALOG_CONFIRM, 8_000);
365
+ await waitForVisible(page, DIALOG, 3_000);
366
+ const stance = `${DIALOG} label:has(input[type="radio"]):has(:text-is(${JSON.stringify(claimSource)}))`;
367
+ if ((await tryFindSelector(page, [stance], 1_500)) === undefined) {
368
+ await sweep();
369
+ throw new CapabilityError(`平台未提供声明类型「${claimSource}」,已跳过`);
353
370
  }
371
+ await clickSelector(page, stance, 8_000);
372
+ await clickSelector(page, CLAIM_CONFIRM, 8_000);
354
373
  });
374
+ unavailable = outcome === 'capability';
355
375
  }
356
- if ((await claimMatched()) !== true && !(await residual(name))) {
376
+ if (!unavailable && (await claimMatched()) !== true && !(await residual(name))) {
357
377
  warnings.push(`${name}: 未生效,请手动设置`);
358
378
  }
359
379
  }
@@ -210,12 +210,25 @@ const AUDIO_UPLOAD_INPUT = [
210
210
  '.weui-desktop-dialog input[type="file"][accept*=".mp3"]',
211
211
  '.audio_dialog input[type="file"][accept*="audio"]',
212
212
  'input[type="file"][accept*=".mp3"]',
213
+ // 2026-10-21 measured: this build's upload form exposes a plain `input[type=file]` with NO accept
214
+ // attribute, so every accept-qualified candidate above missed and the run reported `音频: no audio
215
+ // file input appeared` — which costs the draft its audio player, the audio material's cover, and
216
+ // the library cover the 封面 step reuses. Dialog-scoped on purpose: the editor's own image input
217
+ // lives outside `.weui-desktop-dialog`, so it can never be the winner here.
218
+ '.weui-desktop-dialog input[type="file"]:not([accept*="image"])',
219
+ '.audio_dialog input[type="file"]:not([accept*="image"])',
213
220
  ];
214
221
  const UPLOAD_COVER = ['text=上传封面', 'button:has-text("上传封面")'];
215
222
  const AUDIO_COVER_INPUT = [
216
223
  '.weui-desktop-dialog input[type="file"][accept*="image/"]',
217
224
  '.audio_dialog input[type="file"][accept*="image/"]',
218
225
  'input[type="file"][accept*=".png"]',
226
+ // 2026-10-21 measured: the upload form's cover input carries `image/bmp, image/png, image/jpeg,
227
+ // image/jpg, image/gif, image/webp` — space-separated MIME names WITHOUT a slash-terminated
228
+ // `image/` prefix match (`accept*="image/"` misses `image/bmp`), so the cover upload silently
229
+ // skipped on every run and the draft shipped with no cover.
230
+ '.weui-desktop-dialog input[type="file"][accept*="image"]',
231
+ '.audio_dialog input[type="file"][accept*="image"]',
219
232
  ];
220
233
  const AUDIO_TITLE_INPUT = [
221
234
  '.weui-desktop-dialog input[placeholder*="标题"]',
@@ -585,7 +598,11 @@ async function applyLayout(page: WechatPage, deps: ModeRunDeps): Promise<string
585
598
  }
586
599
  const option = await tryFindSelector(page, LAYOUT_TARGET, 5_000);
587
600
  if (option === undefined) {
588
- return '一键排版: clicked, but no 匹配目标格式 option appeared — the body may be unformatted; check the draft';
601
+ // Measured 2026-10-21 with a body in the editor: `div#ai_layout_container > div.ailayout-btn_wrp`
602
+ // is present and clickable, and no option set ever renders (only a hidden `ailayout-edu_tips`
603
+ // onboarding block). Nothing programmable is left to click, so name the finding instead of
604
+ // guessing at the body's formatting.
605
+ return '一键排版: the control exists but this build renders no 匹配目标格式 option (measured 2026-10-21 with a body in place) — layout skipped, apply 一键排版 by hand before publishing';
589
606
  }
590
607
  try {
591
608
  await clickSelector(page, option, 5_000);
@@ -25,8 +25,14 @@ const LOGIN_TIMEOUT_MS = 180_000;
25
25
  * at that instant — so the poll must not spend all of it: a dead session then dies mid-report and
26
26
  * surfaces as `probe exit 1 (malformed probe output)` instead of `refused` (observed 2026-10-16, where
27
27
  * the 30s poll plus overhead overran `kk plugin check`'s 30s default).
28
+ *
29
+ * 25s, not 8s (2026-10-21): a dead session spends the WHOLE poll before it can report, so the reserve
30
+ * has to cover a cold `KK_BROWSER_CHANNEL=chrome` start, not a warm one — at 8s the first
31
+ * `kk plugin check wechatmp-pub xhs-pub --timeout 60000` of the day SIGKILLed the probe at 60s and
32
+ * reported `malformed probe output` while the immediately following run answered the real
33
+ * `refused — Authentication was not confirmed` in the same 60s budget.
28
34
  */
29
- const PROFILE_OVERHEAD_MS = 8_000;
35
+ const PROFILE_OVERHEAD_MS = 25_000;
30
36
  /** Floor for the readiness poll, so a tiny `--timeout` still probes the console instead of refusing blind. */
31
37
  const MIN_READINESS_MS = 1_000;
32
38