@gobing-ai/knowledge-kit 0.0.34 → 0.0.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +1 -1
  3. package/dist/board/collect/index.css +42 -0
  4. package/dist/board/collect/index.js +787 -0
  5. package/dist/board/daily-voice/index.css +13 -0
  6. package/dist/board/daily-voice/index.js +902 -0
  7. package/dist/board/itc/index.css +14 -0
  8. package/dist/board/itc/index.js +1026 -0
  9. package/dist/index.js +11769 -9250
  10. package/package.json +10 -9
  11. package/plugins/generations/content-gen/dist/index.js +1015 -10814
  12. package/plugins/generations/content-gen/package.json +1 -1
  13. package/plugins/generations/core-facts-gen/dist/index.js +1002 -10801
  14. package/plugins/generations/core-facts-gen/package.json +1 -1
  15. package/plugins/generations/daily-article-gen/dist/index.js +1019 -10804
  16. package/plugins/generations/daily-article-gen/package.json +1 -1
  17. package/plugins/generations/daily-article-gen/src/index.ts +32 -3
  18. package/plugins/generations/dailynews-gen/dist/index.js +1094 -10892
  19. package/plugins/generations/dailynews-gen/package.json +1 -1
  20. package/plugins/generations/dailynews-gen/src/script-builder.ts +6 -1
  21. package/plugins/generations/episode-plan-gen/dist/index.js +1124 -10923
  22. package/plugins/generations/episode-plan-gen/package.json +1 -1
  23. package/plugins/generations/gemini-tts/dist/index.js +1108 -10906
  24. package/plugins/generations/gemini-tts/package.json +1 -1
  25. package/plugins/generations/image-gen/dist/index.js +1239 -11035
  26. package/plugins/generations/image-gen/package.json +1 -1
  27. package/plugins/generations/news-report-gen/dist/index.js +1048 -10847
  28. package/plugins/generations/news-report-gen/package.json +1 -1
  29. package/plugins/generations/omni-voice-gen/plugin.json +1 -1
  30. package/plugins/generations/voice-gen/dist/index.js +1066 -10864
  31. package/plugins/generations/voice-gen/package.json +1 -1
  32. package/plugins/generations/voice-gen/src/index.ts +9 -3
  33. package/plugins/generations/voxcpm-tts/README.md +72 -0
  34. package/plugins/generations/voxcpm-tts/dist/index.js +14006 -0
  35. package/plugins/generations/voxcpm-tts/package.json +16 -0
  36. package/plugins/generations/voxcpm-tts/plugin.json +6 -0
  37. package/plugins/generations/voxcpm-tts/profiles.json +10 -0
  38. package/plugins/generations/voxcpm-tts/pyproject.toml +9 -0
  39. package/plugins/generations/voxcpm-tts/python/worker.py +353 -0
  40. package/plugins/generations/voxcpm-tts/src/index.ts +42 -0
  41. package/plugins/generations/voxcpm-tts/src/voxcpm-client.ts +765 -0
  42. package/plugins/generations/voxcpm-tts/tsconfig.json +8 -0
  43. package/plugins/generations/voxcpm-tts/uv.lock +2571 -0
  44. package/plugins/ingestions/aihot-ingest/dist/index.js +1081 -10628
  45. package/plugins/ingestions/aihot-ingest/package.json +1 -1
  46. package/plugins/ingestions/horizon-ingest/dist/index.js +1013 -10812
  47. package/plugins/ingestions/horizon-ingest/package.json +1 -1
  48. package/plugins/ingestions/karakeep-local/dist/index.js +689 -10774
  49. package/plugins/ingestions/karakeep-local/package.json +1 -1
  50. package/plugins/ingestions/last30days-ingest/dist/index.js +1011 -10810
  51. package/plugins/ingestions/last30days-ingest/package.json +1 -1
  52. package/plugins/ingestions/web-search/dist/index.js +1250 -11033
  53. package/plugins/ingestions/web-search/package.json +1 -1
  54. package/plugins/kk/README.md +29 -2
  55. package/plugins/kk/plugin.json +1 -1
  56. package/plugins/kk/schedules/cron.tmpl +9 -1
  57. package/plugins/kk/schedules/kk-serve.launchd.plist.tmpl +79 -0
  58. package/plugins/kk/schedules/launchd.plist.tmpl +11 -1
  59. package/plugins/kk/scripts/daily-stages.ts +702 -63
  60. package/plugins/kk/scripts/kk-workflow-stages.ts +116 -146
  61. package/plugins/kk/skills/content-safety/references/platforms/wechatmp.md +2 -2
  62. package/plugins/kk/skills/content-safety/references/platforms/xiaoyuzhou.md +4 -2
  63. package/plugins/kk/skills/publish/SKILL.md +8 -4
  64. package/plugins/kk/workflows/feature-verification.yaml +93 -0
  65. package/plugins/kk/workflows/judge-gated-publish-example.yaml +1 -0
  66. package/plugins/kk/workflows/kk-collect.yaml +43 -11
  67. package/plugins/kk/workflows/kk-daily-ai-voice.yaml +36 -9
  68. package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +92 -18
  69. package/plugins/kk/workflows/kk-daily-safety.yaml +16 -3
  70. package/plugins/kk/workflows/kk-itc.yaml +8 -1
  71. package/plugins/kk/workflows/kk-solo-podcast.yaml +2 -0
  72. package/plugins/publishings/emdash-pub/dist/index.js +987 -10814
  73. package/plugins/publishings/emdash-pub/package.json +1 -1
  74. package/plugins/publishings/infoq-pub/dist/index.js +1928 -11444
  75. package/plugins/publishings/infoq-pub/dist/profile-check.js +20814 -0
  76. package/plugins/publishings/infoq-pub/package.json +1 -1
  77. package/plugins/publishings/infoq-pub/plugin.json +6 -2
  78. package/plugins/publishings/juejin-pub/dist/index.js +1940 -11456
  79. package/plugins/publishings/juejin-pub/dist/profile-check.js +20800 -0
  80. package/plugins/publishings/juejin-pub/package.json +1 -1
  81. package/plugins/publishings/juejin-pub/plugin.json +6 -2
  82. package/plugins/publishings/medium-pub/dist/index.js +978 -10805
  83. package/plugins/publishings/medium-pub/package.json +1 -1
  84. package/plugins/publishings/podcast-pub/dist/index.js +1016 -10843
  85. package/plugins/publishings/podcast-pub/package.json +1 -1
  86. package/plugins/publishings/qiita-pub/dist/index.js +976 -10803
  87. package/plugins/publishings/qiita-pub/package.json +1 -1
  88. package/plugins/publishings/substack-pub/dist/index.js +1932 -11448
  89. package/plugins/publishings/substack-pub/dist/profile-check.js +20890 -0
  90. package/plugins/publishings/substack-pub/package.json +1 -1
  91. package/plugins/publishings/substack-pub/plugin.json +5 -2
  92. package/plugins/publishings/substack-pub/src/profile-check.ts +36 -3
  93. package/plugins/publishings/surfdash-pub/dist/index.js +1151 -10834
  94. package/plugins/publishings/surfdash-pub/package.json +1 -1
  95. package/plugins/publishings/surfdash-pub/src/index.ts +137 -15
  96. package/plugins/publishings/surfdash-pub/src/patch.ts +144 -0
  97. package/plugins/publishings/threads-pub/dist/index.js +12394 -0
  98. package/plugins/publishings/threads-pub/package.json +17 -0
  99. package/plugins/publishings/threads-pub/plugin.json +7 -0
  100. package/plugins/publishings/threads-pub/src/index.ts +340 -0
  101. package/plugins/publishings/threads-pub/tsconfig.json +4 -0
  102. package/plugins/publishings/wechatmp-pub/dist/index.js +2638 -11650
  103. package/plugins/publishings/wechatmp-pub/dist/profile-check.js +20739 -0
  104. package/plugins/publishings/wechatmp-pub/package.json +1 -1
  105. package/plugins/publishings/wechatmp-pub/plugin.json +6 -2
  106. package/plugins/publishings/wechatmp-pub/src/enrich.ts +297 -163
  107. package/plugins/publishings/wechatmp-pub/src/flow.ts +611 -54
  108. package/plugins/publishings/wechatmp-pub/src/index.ts +24 -3
  109. package/plugins/publishings/wechatmp-pub/src/page.ts +36 -0
  110. package/plugins/publishings/wechatmp-pub/src/profile-check.ts +40 -4
  111. package/plugins/publishings/weibo-pub/dist/index.js +21345 -0
  112. package/plugins/publishings/weibo-pub/dist/profile-check.js +20817 -0
  113. package/plugins/publishings/weibo-pub/package.json +19 -0
  114. package/plugins/publishings/weibo-pub/plugin.json +12 -0
  115. package/plugins/publishings/weibo-pub/src/index.ts +868 -0
  116. package/plugins/publishings/weibo-pub/src/profile-check.ts +20 -0
  117. package/plugins/publishings/weibo-pub/tsconfig.json +4 -0
  118. package/plugins/publishings/x-pub/dist/index.js +1948 -11440
  119. package/plugins/publishings/x-pub/dist/profile-check.js +20806 -0
  120. package/plugins/publishings/x-pub/package.json +1 -1
  121. package/plugins/publishings/x-pub/plugin.json +6 -2
  122. package/plugins/publishings/xhs-pub/dist/index.js +1993 -11509
  123. package/plugins/publishings/xhs-pub/dist/profile-check.js +21023 -0
  124. package/plugins/publishings/xhs-pub/package.json +1 -1
  125. package/plugins/publishings/xhs-pub/plugin.json +6 -2
  126. package/plugins/publishings/xhs-pub/src/profile-check.ts +72 -2
  127. package/plugins/publishings/zenn-pub/dist/index.js +988 -10815
  128. package/plugins/publishings/zenn-pub/package.json +1 -1
  129. package/plugins/generations/omni-voice-gen/dist/omni-voice-gen-prr8skpb. +0 -2
  130. package/plugins/generations/omni-voice-gen/dist/omni-voice-gen.js +0 -6
@@ -14,6 +14,6 @@
14
14
  "playwright": "catalog:"
15
15
  },
16
16
  "devDependencies": {
17
- "@types/bun": "1.3.14"
17
+ "@types/bun": "1.4.3"
18
18
  }
19
19
  }
@@ -2,7 +2,11 @@
2
2
  "name": "wechatmp-pub",
3
3
  "kind": "publish",
4
4
  "entry": "./dist/index.js",
5
- "check": "./src/profile-check.ts",
5
+ "check": "./dist/profile-check.js",
6
6
  "version": "1.0.0",
7
- "description": "Publish plugin for the WeChat MP console via ts-browser-automation: draft-box first (ADR-022), headed publish behind a session preflight"
7
+ "description": "Publish plugin for the WeChat MP console via ts-browser-automation: draft-box first (ADR-022), headed publish behind a session preflight",
8
+ "browserSession": {
9
+ "profileName": "wechatmp",
10
+ "homeUrl": "https://mp.weixin.qq.com/"
11
+ }
8
12
  }
@@ -1,34 +1,67 @@
1
1
  import { trySelectors } from '@gobing-ai/publish-harness';
2
- import { waitUntil } from '@gobing-ai/publish-harness/browser';
3
- import { clickSelector, fillSelector, POLL_MS, type WechatPage } from './page';
2
+ import { clickSelector, fillSelector, modalState, tryFindSelector, type WechatPage, waitForVisible } from './page';
4
3
 
5
4
  /**
6
- * The 文章 operation's `metadata.wechatmp` enrichment rail (2026-09-29, ADR-026).
5
+ * The 文章 operation's `metadata.wechatmp` enrichment rail (2026-09-29, ADR-026; committed-value
6
+ * contract reworked in task 0260).
7
7
  *
8
8
  * The daily MP stage wires operator preferences into `metadata.wechatmp` (原创声明 + 作者, 合集,
9
9
  * AI 创作来源, audio embed) and this module drives the v2 editor's settings panel for them. Every
10
- * step is commit-verified (a DOM re-read), so a step that reports success but does not stick becomes
11
- * a loud `warnings` entry — the draft still saves, and the operator sees exactly which setting needs
12
- * a manual pass in the draft box.
10
+ * step reads the REQUESTED committed editor state BEFORE acting (an already-satisfied setting is
11
+ * never toggled) and again AFTER its dialog closes; only the exact requested committed value
12
+ * establishes success. Missing, throwing or unverifiable probes are named warnings — never truthy
13
+ * defaults, and account/capability refusals (赞赏账户, 原文链接 row) warn ONCE without retry.
13
14
  *
14
15
  * What is deliberately NOT here: the cover. The panel has NO file input (discovery §5.2) — the
15
16
  * pre-ADR-029 cover step uploaded the file into the editor's *image* input, i.e. into the body. The
16
- * cover is now the `从正文选择` step over the body images (`./flow`), and `metadata.wechatmp.coverPath`
17
- * is attached as the first body image there.
17
+ * cover is now the `从正文选择`/图片库 step over identified assets (`./flow`), and
18
+ * `metadata.wechatmp.coverPath` is attached as the first body image there.
18
19
  */
19
20
 
20
21
  const DIALOG = '.weui-desktop-dialog:visible';
21
22
  const CONFIRM = `${DIALOG} button:has-text("确认"), ${DIALOG} button:has-text("确定")`;
22
- const ORIGINAL_SWITCH = '.setting-group__switch.js_edit_ori';
23
- const TAG_ROW = '.allow_click_opr.js_article_tags_label';
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.
27
+ */
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
+ ];
34
+ // Measured 2026-10-13: the 赞-tipped rows are `.js_reward_open` / `.js_reward_allow_click` — the
35
+ // old `.setting-group__switch.js_can_reward` no longer resolves. The 原创 dialog's own 赞赏
36
+ // switch (`js_reward_switch`) must never be clicked by this row list (R3, drift task 0272).
37
+ const ROW_REWARD = [
38
+ '.setting-group__switch.js_reward_open',
39
+ '.js_reward_open',
40
+ '.js_reward_allow_click',
41
+ '.setting-group__switch.js_can_reward',
42
+ 'text=赞赏',
43
+ ];
44
+ 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=创作来源'];
47
+ const DIALOG_CONFIRM = CONFIRM;
24
48
  const TAG_INPUT = 'input[placeholder*="合集"]';
25
- const CLAIM_SOURCE = '#js_claim_source_area .frm_checkbox_label';
26
- const AUDIO_MENU = 'li.js_insertaudio';
27
- const AUDIO_DIALOG = '.audio_dialog';
28
- /** The 素材库 rows stream in after the dialog opens, so the dedupe probe retries while they load. */
29
- const AUDIO_DIALOG_TIMEOUT_MS = 15_000;
30
- const AUDIO_ROW_RETRIES = 2;
31
- const AUDIO_ROW_SETTLE_MS = 1_200;
49
+ /** The 赞赏 dialog's account field, filled only when the platform leaves it empty. */
50
+ const REWARD_ACCOUNT = [
51
+ `${DIALOG} input[placeholder*="赞赏"]`,
52
+ `${DIALOG} input[placeholder*="账户"]`,
53
+ `${DIALOG} input.frm_input`,
54
+ ];
55
+ /**
56
+ * The 原文链接 dialog's URL field.
57
+ * `URL_INPUT[0]` doubles as the committed-readback locator, so it must be concrete (not `:visible`).
58
+ */
59
+ const URL_INPUT = [`${DIALOG} input[type="url"]`, `${DIALOG} input[placeholder*="链接"]`, `${DIALOG} input.frm_input`];
60
+ /** The 原创 dialog's declaration radio (measured 2026-10-13): the platform requires an explicit
61
+ * declaration type + the agreement check before 确定. `无需声明` is the undo choice, never a target. */
62
+ 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`;
32
65
  /** The 合集 picker's options load after the dialog opens (the pre-ADR-029 settle, kept). */
33
66
  const COLLECTION_SETTLE_MS = 1_200;
34
67
  /** The settings panel re-renders after a dialog confirm and can briefly detach rows. */
@@ -36,194 +69,295 @@ const STEP_RETRY_SETTLE_MS = 1_000;
36
69
  const SWEEP_SETTLE_MS = 400;
37
70
 
38
71
  /**
39
- * Set the operator's draft metadata (原创/作者/合集/创作来源) in the v2 editor.
72
+ * A capability/account refusal the next run cannot fix by retrying (no 赏账户, no 原文链接 row).
73
+ * `step` never retries one of these — the warning goes out once.
74
+ */
75
+ class CapabilityError extends Error {}
76
+
77
+ /** trim + collapse internal whitespace — the matching normalization for every text/value readback. */
78
+ function norm(value: unknown): string {
79
+ return typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : '';
80
+ }
81
+
82
+ /**
83
+ * Native-CSS committed-state probes. Runtime DOM properties only (a checked state read from the
84
+ * HTML attribute would see the template, not the editor) — and `null` when the shape is absent, so
85
+ * an unobservable state can never read as "satisfied".
86
+ * Playwright-only syntax (`:visible`, `:has-text`) never enters `querySelector` — it throws there.
87
+ */
88
+ const COMMITTED = {
89
+ /** 原创 enabled = the settings row's checkbox checked (`#js_original` / `.js_ori_setting_checkbox`). */
90
+ original: `(() => {
91
+ const el = document.querySelector('#js_original, .js_ori_setting_checkbox');
92
+ return el ? el.checked === true : null;
93
+ })()`,
94
+ /** 赞赏 enabled = the reward settings checkbox checked (area-scoped, else the visible cell's). */
95
+ reward: `(() => {
96
+ const el = document.querySelector('#js_reward_setting_area .js_reward_setting_checkbox') ||
97
+ document.querySelector('.js_reward_allow_click_cell .js_reward_setting');
98
+ return el ? el.checked === true : null;
99
+ })()`,
100
+ } as const;
101
+
102
+ /** The claim probe's JSON answer: `{ enabled, matched }` or bare `null` (unobservable). */
103
+ const CLAIM_COMMITTED = `(() => {
104
+ const area = document.querySelector('#js_claim_source_area');
105
+ if (!area) return null;
106
+ const label = area.querySelector('.js_claim_source_selected');
107
+ const text = (label ? (label.textContent ?? '') : '').replace(/\\s+/g, ' ').trim();
108
+ const checkbox = area.querySelector('.js_claim_source, input[type=checkbox]');
109
+ const enabled = checkbox ? checkbox.checked === true : null;
110
+ return { enabled, text };
111
+ })()`;
112
+
113
+ /** Read a settings checkbox's committed enabled state; `null` = unobservable (never "satisfied"). */
114
+ async function readChecked(page: WechatPage, expression: string): Promise<boolean | null> {
115
+ try {
116
+ const raw = await page.evaluate(expression);
117
+ return typeof raw === 'boolean' ? raw : null;
118
+ } catch {
119
+ return null;
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Set the operator's draft metadata (原创/赞赏/合集/原文链接/创作来源) in the v2 editor.
40
125
  *
41
- * Runs on a FRESH draft only — the toggles are not state-aware (no idempotency for re-runs).
42
- * Per-field failures are collected into warnings and surfaced in the `Result`; the draft still
43
- * saves. Dialog mapping evidence (probes 2026-09-29, drafts 100000805/808): 原创 + 赞赏 share ONE
44
- * dialog (声明类型 radios, 作者 input, 确定) and the 赞赏 switch stays hidden for this account, so
45
- * 赞赏 is a manual step; the 合集 dialog is a 请选择合集 input + 确认; 创作来源 is an inline checkbox
46
- * (no dialog). The 原创 dialog only closes when WeChat's own validation passes — the author must be
47
- * the reward (赏) account picked from its search, which does not resolve headless for this account,
48
- * so a non-closing dialog is swept and surfaced as a warning.
126
+ * Runs on a FRESH draft only — unrequested toggles are never turned off, but the toggles are not
127
+ * otherwise state-aware for re-runs beyond the committed pre-read. Per-field failures are collected
128
+ * into warnings and surfaced in the `Result`; the draft still saves.
49
129
  */
50
130
  export async function enrichArticle(page: WechatPage, meta: Record<string, unknown>): Promise<string[]> {
51
131
  const warnings: string[] = [];
52
132
  const str = (key: string) => (typeof meta[key] === 'string' ? (meta[key] as string) : '');
53
133
  const on = (key: string) => meta[key] === true;
54
134
  // Sweep any leaked dialog so the next step (especially 保存为草稿) is never overlay-blocked.
55
- const sweep = async () => {
135
+ const sweep = async (): Promise<'closed' | 'unknown'> => {
56
136
  const cancel = await trySelectors(page, ['button:has-text("取消")']);
57
- if (cancel.found)
58
- await page
59
- .locator(cancel.selector ?? '')
60
- .first()
61
- .click({ timeout: 2_000 })
62
- .catch(() => undefined);
137
+ if (cancel.found) {
138
+ try {
139
+ await page
140
+ .locator(cancel.selector ?? '')
141
+ .first()
142
+ .click({ timeout: 2_000 });
143
+ } catch {
144
+ // The dialog may already be gone; the sweep is best-effort.
145
+ }
146
+ }
63
147
  await page.press?.('Escape');
64
148
  await page.waitForTimeout?.(SWEEP_SETTLE_MS);
149
+ const state = await modalState(page);
150
+ if (state === 'open') {
151
+ // One explicit close-button pass before giving up: some dialogs ignore Escape.
152
+ for (const close of ['.weui-desktop-dialog__close-btn', '.weui-desktop-dialog__close']) {
153
+ const control = await tryFindSelector(page, [close], 1_500);
154
+ if (control === undefined) continue;
155
+ try {
156
+ await clickSelector(page, control, 2_000);
157
+ } catch {
158
+ // Gone between the probe and the click.
159
+ }
160
+ break;
161
+ }
162
+ return (await modalState(page)) === 'closed' ? 'closed' : 'unknown';
163
+ }
164
+ return state === 'closed' ? 'closed' : 'unknown';
165
+ };
166
+ /** Open a settings row: its label or `>` expander, whichever the build exposes. */
167
+ const openRow = async (row: readonly string[], what: string) => {
168
+ for (const selector of row) {
169
+ // `trySelectors` already absorbs a throwing probe and returns {found:false}, so a
170
+ // `.catch()` here would be unreachable.
171
+ const found = await trySelectors(page, [selector]);
172
+ if (found.found) {
173
+ await clickSelector(page, selector, 8_000);
174
+ return;
175
+ }
176
+ }
177
+ throw new Error(`no ${what} row control among [${row.join(' | ')}]`);
65
178
  };
66
- // One retry per step: the settings panel re-renders after dialog confirms and can briefly
67
- // detach rows (observed on the tags row right after the original-declaration confirm).
68
- const step = async (name: string, fn: () => Promise<void>) => {
179
+ /**
180
+ * One bounded retry per step: act, and on a non-capability failure sweep, RE-READ the committed
181
+ * state, and retry once — a step that closed its dialog successfully on the sweep never clicks
182
+ * again. A capability refusal (`CapabilityError`) is reported ONCE here and the caller skips its
183
+ * post-read warning (the refusal is the outcome, not a missed commit).
184
+ */
185
+ const step = async (name: string, fn: () => Promise<void>): Promise<'ok' | 'failed' | 'capability'> => {
69
186
  try {
70
187
  await fn();
71
- return;
72
- } catch {
188
+ return 'ok';
189
+ } catch (error) {
190
+ if (error instanceof CapabilityError) {
191
+ warnings.push(`${name}: ${error.message}`);
192
+ return 'capability';
193
+ }
73
194
  // retry once below
74
195
  }
75
196
  await sweep();
76
197
  await page.waitForTimeout?.(STEP_RETRY_SETTLE_MS);
77
198
  try {
78
199
  await fn();
200
+ return 'ok';
79
201
  } catch (error) {
80
202
  warnings.push(`${name}: ${(error instanceof Error ? error.message : String(error)).slice(0, 120)}`);
81
203
  await sweep();
204
+ return 'failed';
82
205
  }
83
206
  };
207
+ /** The named warning for a dialog that is open or unreadable after a step — never "fine". */
208
+ const residual = async (name: string): Promise<boolean> => {
209
+ const state = await modalState(page);
210
+ if (state === 'closed') return false;
211
+ warnings.push(
212
+ state === 'open'
213
+ ? `${name}: dialog did not close — check the draft`
214
+ : `${name}: dialog state unknown after cleanup — a residual overlay may block later steps`,
215
+ );
216
+ return true;
217
+ };
84
218
 
85
219
  if (on('original')) {
86
- await step('原创声明', async () => {
87
- await clickSelector(page, ORIGINAL_SWITCH, 10_000);
88
- await clickSelector(page, `${DIALOG} :text("文字原创")`, 8_000);
89
- const author = str('author');
90
- if (author !== '') await fillSelector(page, `${DIALOG} input[placeholder*="作者"]`, author);
91
- await clickSelector(page, CONFIRM, 8_000);
92
- });
93
- const committed = await page.evaluate(
94
- `[...document.querySelectorAll('.weui-desktop-dialog')].every((d) => !d.offsetWidth)`,
95
- );
96
- if (committed === false) {
97
- warnings.push('原创声明: 未生效 - 作者须为赞赏账户(搜索选择),请手动设置');
98
- await sweep();
220
+ const name = '原创声明';
221
+ // Already committed: never open rows (and never toggle a satisfied setting off).
222
+ if ((await readChecked(page, COMMITTED.original)) !== true) {
223
+ const outcome = await step(name, async () => {
224
+ await openRow(ROW_ORIGINAL, '原创声明');
225
+ // Measured 2026-10-13: pick the 文字原创 radio, check the agreement when it renders,
226
+ // then 确定. The plain-text probe the old rail used could hit the dialog heading.
227
+ await clickSelector(page, ORIGINAL_DECLARATION, 5_000);
228
+ // trySelectors absorbs its own probe failures (returns found:false) — no .catch here.
229
+ const agreement = await trySelectors(page, [ORIGINAL_AGREEMENT]);
230
+ if (agreement.found) {
231
+ await clickSelector(page, ORIGINAL_AGREEMENT, 3_000);
232
+ }
233
+ await clickSelector(page, DIALOG_CONFIRM, 8_000);
234
+ });
235
+ if (
236
+ outcome !== 'capability' &&
237
+ (await readChecked(page, COMMITTED.original)) !== true &&
238
+ !(await residual(name))
239
+ ) {
240
+ warnings.push(`${name}: 原创声明未生效,请手动设置`);
241
+ }
242
+ }
243
+ }
244
+
245
+ if (on('reward')) {
246
+ const name = '赞赏';
247
+ if ((await readChecked(page, COMMITTED.reward)) !== true) {
248
+ const outcome = await step(name, async () => {
249
+ await openRow(ROW_REWARD, '赞赏');
250
+ // Measured 2026-10-13: without a 赏账户 the dialog renders the 前往开通 link + a
251
+ // 无法开启-style prerequisite line instead of the account field — an operator action
252
+ // (开通赞赏), never a selector failure, and no retry of this run can change it.
253
+ const blocked = await tryFindSelector(
254
+ page,
255
+ [`${DIALOG} :has-text("前往开通")`, `${DIALOG} :has-text("无法开启")`],
256
+ 1_500,
257
+ );
258
+ if (blocked !== undefined)
259
+ throw new CapabilityError('需要赞赏账户(运营者先在平台开通),已跳过自动填写');
260
+ // 赞赏账户 defaults from 作者; fill it only when the platform left it empty.
261
+ const author = str('author');
262
+ if (author !== '') {
263
+ for (const selector of REWARD_ACCOUNT) {
264
+ // trySelectors absorbs its own probe failures (returns found:false), so no
265
+ // extra .catch is needed here.
266
+ const found = await trySelectors(page, [selector]);
267
+ if (!found.found) continue;
268
+ // Locator read, not native querySelector: the expression must stay native CSS
269
+ // (Playwright's `:visible` throws inside `evaluate`, task 0260 AC4).
270
+ const locator = page.locator(selector).first();
271
+ if (typeof locator.inputValue !== 'function') break;
272
+ const empty = norm(await locator.inputValue().catch(() => null)) === '';
273
+ if (empty) await fillSelector(page, selector, author);
274
+ break;
275
+ }
276
+ }
277
+ await clickSelector(page, DIALOG_CONFIRM, 8_000);
278
+ });
279
+ if (
280
+ outcome !== 'capability' &&
281
+ (await readChecked(page, COMMITTED.reward)) !== true &&
282
+ !(await residual(name))
283
+ ) {
284
+ warnings.push(`${name}: 赞赏未生效,请手动设置`);
285
+ }
99
286
  }
100
287
  }
101
288
 
102
289
  const collection = str('collection');
103
290
  if (collection !== '') {
104
- await step('合集', async () => {
105
- await clickSelector(page, TAG_ROW, 8_000);
291
+ const name = '合集';
292
+ await step(name, async () => {
293
+ await openRow(ROW_COLLECTION, '合集');
106
294
  await waitForVisible(page, TAG_INPUT, 8_000);
107
295
  await fillSelector(page, TAG_INPUT, collection);
108
296
  await page.waitForTimeout?.(COLLECTION_SETTLE_MS);
109
297
  await clickSelector(page, `${DIALOG} :text("${collection}")`, 8_000);
110
- await clickSelector(page, CONFIRM, 8_000);
298
+ await clickSelector(page, DIALOG_CONFIRM, 8_000);
111
299
  });
300
+ // 合集 keeps its existing behavior (no committed-value probe is shipped for the picker);
301
+ // only its cleanup is isolated here.
302
+ await residual(name);
112
303
  }
113
304
 
114
- if (on('aiGenerated')) {
115
- await step('创作来源', async () => {
116
- await clickSelector(page, CLAIM_SOURCE, 8_000);
117
- // Probes show no dialog on current builds — the checkbox alone declares AI generation.
118
- const dialog = await waitForVisible(page, DIALOG, 3_000, { required: false });
119
- if (dialog !== undefined) {
120
- await clickSelector(page, `${DIALOG} :text("内容由AI生成")`, 8_000);
121
- await clickSelector(page, CONFIRM, 8_000);
122
- }
123
- });
124
- const checked = await page.evaluate(`!!document.querySelector('#js_claim_source_area input:checked')`);
125
- if (checked === false) warnings.push('创作来源: 勾选未生效,请手动设置');
305
+ const originalUrl = str('originalUrl');
306
+ if (originalUrl !== '') {
307
+ const name = '原文链接';
308
+ // Measured 2026-10-13: some accounts no longer render the 原文链接 row at all. That is an
309
+ // unavailable capability — report it once, never fall back to the composer's unrelated
310
+ // `链接` row (`btm-card-link__switch`).
311
+ const urlRow = await tryFindSelector(page, ROW_URL, 2_000);
312
+ if (urlRow === undefined) {
313
+ warnings.push(`${name}: 行不存在(当前公众号不支持),请手动处理或移除 originalUrl`);
314
+ } else {
315
+ await step(name, async () => {
316
+ await openRow(ROW_URL, '原文链接');
317
+ const field = await trySelectors(page, URL_INPUT);
318
+ if (!field.found) throw new Error('no URL field in the 原文链接 dialog');
319
+ await fillSelector(page, field.selector ?? URL_INPUT.join(', '), originalUrl);
320
+ await clickSelector(page, DIALOG_CONFIRM, 8_000);
321
+ });
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}: 已提交但未读取到已保存的链接,请核对`);
329
+ }
126
330
  }
127
331
 
128
- await sweep();
129
- return warnings;
130
- }
131
-
132
- /**
133
- * Audio embed at the article top (operator request 2026-09-29): open the editor's audio menu (a
134
- * hover dropdown — a JS click bypasses the hover gate), reuse an existing same-title 素材库
135
- * material, and insert it at the caret (placed at the article start first).
136
- *
137
- * Uploading a missing material is deliberately manual: docs/04 records the 上传音频 form as
138
- * iframe-based and unmapped, and the walk did not re-measure it. Fail-soft — every non-committed
139
- * outcome returns a warning string, `''` means placed at the top.
140
- */
141
- export async function insertAudio(page: WechatPage, title: string): Promise<string> {
142
- const CARET = `(() => {
143
- const pm = [...document.querySelectorAll('.ProseMirror')].find((e) => !(e.getAttribute('data-placeholder') ?? '').includes('标题'));
144
- if (!pm || !pm.firstChild) return;
145
- const r = document.createRange();
146
- r.setStart(pm.firstChild, 0);
147
- r.collapse(true);
148
- const sel = window.getSelection();
149
- sel?.removeAllRanges();
150
- sel?.addRange(r);
151
- })()`;
152
- const DEDUPE = `(() => {
153
- const want = ${JSON.stringify(title)}.replace(/\\s+/g, '');
154
- const hit = [...document.querySelectorAll('label.frm_checkbox_label.audio_item')]
155
- .find((l) => (l.textContent ?? '').replace(/\\s+/g, '').includes(want));
156
- if (!hit) return 'missing';
157
- const cb = (hit.closest('li') ?? hit.parentElement)?.querySelector('input[type=checkbox]');
158
- if (!cb) return 'nobox';
159
- cb.click();
160
- return 'checked';
161
- })()`;
162
- const INSERT = `(() => {
163
- const btn = [...document.querySelectorAll('button')].find((b) => (b.textContent ?? '').trim() === '插入' && b.offsetWidth > 0);
164
- if (!btn) return false;
165
- btn.click();
166
- return true;
167
- })()`;
168
- const CANCEL = `(() => {
169
- const btn = [...document.querySelectorAll('button')].find((b) => (b.textContent ?? '').trim() === '取消' && b.offsetWidth > 0);
170
- btn?.click();
171
- })()`;
172
- const VERIFY = `(() => {
173
- const pm = [...document.querySelectorAll('.ProseMirror')].find((e) => !(e.getAttribute('data-placeholder') ?? '').includes('标题'));
174
- if (!pm) return 'noeditor';
175
- const node = pm.querySelector('mpvoice, [class*="audio"]');
176
- if (!node) return 'absent';
177
- const first = pm.firstElementChild;
178
- return first && (first === node || first.contains(node)) ? 'top' : 'notop';
179
- })()`;
180
-
181
- try {
182
- await page.evaluate(CARET);
183
- await page.evaluate(`document.querySelector(${JSON.stringify(AUDIO_MENU)})?.click()`);
184
- await waitForVisible(page, AUDIO_DIALOG, AUDIO_DIALOG_TIMEOUT_MS);
185
- // The 素材库 list loads async after the dialog opens — retry while rows stream in.
186
- let dedupe = String(await page.evaluate(DEDUPE));
187
- for (let i = 0; dedupe === 'missing' && i < AUDIO_ROW_RETRIES; i += 1) {
188
- await page.waitForTimeout?.(AUDIO_ROW_SETTLE_MS);
189
- dedupe = String(await page.evaluate(DEDUPE));
190
- }
191
- if (dedupe !== 'checked') {
192
- await page.evaluate(CANCEL);
193
- return dedupe === 'missing'
194
- ? '音频: 素材库无同名音频,请手动上传后插入'
195
- : '音频: 同名素材行未找到勾选框,请手动插入';
332
+ // 创作来源 is the operator's chosen stance (`个人观点,仅供参考` for this column), not a fixed
333
+ // "AI generated" declaration — the label is the metadata value so the operator owns the wording.
334
+ const claimSource = str('claimSource');
335
+ if (claimSource !== '') {
336
+ const name = '创作来源';
337
+ const claimMatched = async (): Promise<boolean | null> => {
338
+ try {
339
+ const raw = (await page.evaluate(CLAIM_COMMITTED)) as { enabled: boolean | null; text: string } | null;
340
+ if (raw === null || typeof raw !== 'object') return null;
341
+ return raw.enabled === true && norm(raw.text) === norm(claimSource);
342
+ } catch {
343
+ return null;
344
+ }
345
+ };
346
+ if ((await claimMatched()) !== true) {
347
+ await step(name, async () => {
348
+ 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);
353
+ }
354
+ });
196
355
  }
197
- if ((await page.evaluate(INSERT)) !== true) {
198
- await page.evaluate(CANCEL);
199
- return '音频: 插入按钮未找到,请手动插入';
356
+ if ((await claimMatched()) !== true && !(await residual(name))) {
357
+ warnings.push(`${name}: 未生效,请手动设置`);
200
358
  }
201
- await page.waitForTimeout?.(2_000);
202
- const state = String(await page.evaluate(VERIFY));
203
- if (state === 'absent' || state === 'noeditor') return '音频: 插入未生效,请手动检查';
204
- if (state === 'notop') return '音频: 已插入但未在文首,请手动调整位置';
205
- return '';
206
- } catch {
207
- return '音频: 自动插入失败,请手动插入';
208
359
  }
209
- }
210
360
 
211
- /** Polls a selector for visibility; optionally returns `undefined` instead of throwing. */
212
- async function waitForVisible(
213
- page: WechatPage,
214
- selector: string,
215
- timeoutMs: number,
216
- opts: { required?: boolean } = {},
217
- ): Promise<string | undefined> {
218
- const visible = await waitUntil(
219
- async () => {
220
- const found = await trySelectors(page, [selector]).catch(() => ({ found: false, selector: null }));
221
- return found.found;
222
- },
223
- timeoutMs,
224
- POLL_MS,
225
- );
226
- if (visible) return selector;
227
- if (opts.required === false) return undefined;
228
- throw new Error(`${selector}: not visible within ${timeoutMs}ms`);
361
+ await sweep();
362
+ return warnings;
229
363
  }