@gobing-ai/knowledge-kit 0.0.37 → 0.1.1

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 (157) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/board/collect/index.css +654 -16
  3. package/dist/board/collect/index.js +191 -64
  4. package/dist/board/daily-voice/index.css +679 -12
  5. package/dist/board/daily-voice/index.js +185 -66
  6. package/dist/board/itc/index.css +679 -13
  7. package/dist/board/itc/index.js +188 -66
  8. package/dist/index.js +2864 -139
  9. package/package.json +3 -2
  10. package/plugins/generations/content-gen/dist/index.js +158 -12
  11. package/plugins/generations/core-facts-gen/dist/index.js +158 -12
  12. package/plugins/generations/daily-article-gen/dist/index.js +158 -12
  13. package/plugins/generations/dailynews-gen/dist/index.js +158 -12
  14. package/plugins/generations/episode-plan-gen/dist/index.js +158 -12
  15. package/plugins/generations/gemini-tts/README.md +4 -3
  16. package/plugins/generations/gemini-tts/dist/index.js +158 -12
  17. package/plugins/generations/image-gen/dist/index.js +158 -12
  18. package/plugins/generations/news-report-gen/dist/index.js +158 -12
  19. package/plugins/generations/omni-voice-gen/README.md +15 -4
  20. package/plugins/generations/omni-voice-gen/profiles.json +1 -2
  21. package/plugins/generations/voice-gen/dist/index.js +158 -12
  22. package/plugins/generations/voxcpm-tts/README.md +21 -1
  23. package/plugins/generations/voxcpm-tts/dist/index.js +173 -13
  24. package/plugins/generations/voxcpm-tts/scripts/audit-speaker-consistency.py +109 -0
  25. package/plugins/generations/voxcpm-tts/src/voxcpm-client.ts +28 -1
  26. package/plugins/ingestions/aihot-ingest/dist/index.js +158 -12
  27. package/plugins/ingestions/horizon-ingest/dist/index.js +158 -12
  28. package/plugins/ingestions/last30days-ingest/dist/index.js +158 -12
  29. package/plugins/ingestions/web-search/dist/index.js +158 -12
  30. package/plugins/kk/commands/workflow-run.md +56 -18
  31. package/plugins/kk/decisions/kk-itc.yaml +46 -0
  32. package/plugins/kk/decisions/kk-seo-keyword.yaml +57 -0
  33. package/plugins/kk/decisions/kk-seo-page.yaml +230 -0
  34. package/plugins/kk/decisions/kk-seo-pair.yaml +21 -0
  35. package/plugins/kk/decisions/kk-seo-site.yaml +90 -0
  36. package/plugins/kk/plugin.json +1 -1
  37. package/plugins/kk/publish-presets/chinese-article.json +14 -0
  38. package/plugins/kk/publish-presets/technical-article.json +14 -0
  39. package/plugins/kk/references/smart-seo-attribution.md +85 -0
  40. package/plugins/kk/scripts/daily-stages.ts +42 -2
  41. package/plugins/kk/scripts/itc-stages.ts +25 -118
  42. package/plugins/kk/scripts/kk-workflow-stages.ts +38 -1
  43. package/plugins/kk/scripts/seo-decisions.ts +491 -0
  44. package/plugins/kk/scripts/seo-enrich.ts +728 -0
  45. package/plugins/kk/scripts/seo-facts.ts +610 -0
  46. package/plugins/kk/scripts/seo-fetch.ts +766 -0
  47. package/plugins/kk/scripts/seo-report.ts +460 -0
  48. package/plugins/kk/scripts/seo-rules.ts +1882 -0
  49. package/plugins/kk/scripts/seo-stages.ts +1044 -0
  50. package/plugins/kk/skills/article-adapt/SKILL.md +2 -2
  51. package/plugins/kk/skills/smart-seo/SKILL.md +183 -0
  52. package/plugins/kk/skills/smart-seo/references/method.md +126 -0
  53. package/plugins/kk/skills/smart-seo/references/narrative.md +125 -0
  54. package/plugins/kk/skills/smart-seo/references/operations.md +209 -0
  55. package/plugins/kk/skills/smart-seo/references/seo-geo-sources.md +90 -0
  56. package/plugins/kk/workflows/feature-verification.yaml +28 -24
  57. package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +85 -17
  58. package/plugins/kk/workflows/kk-itc-author.yaml +406 -0
  59. package/plugins/kk/workflows/kk-itc.yaml +208 -682
  60. package/plugins/kk/workflows/kk-smart-seo.yaml +251 -0
  61. package/plugins/publishings/bilibili-pub/dist/index.js +21782 -0
  62. package/plugins/publishings/bilibili-pub/dist/profile-check.js +20410 -0
  63. package/plugins/publishings/bilibili-pub/package.json +20 -0
  64. package/plugins/publishings/bilibili-pub/plugin.json +12 -0
  65. package/plugins/publishings/bilibili-pub/src/flow.ts +1296 -0
  66. package/plugins/publishings/bilibili-pub/src/index.ts +139 -0
  67. package/plugins/publishings/bilibili-pub/src/profile-check.ts +19 -0
  68. package/plugins/publishings/bilibili-pub/src/selectors.ts +222 -0
  69. package/plugins/publishings/bilibili-pub/tsconfig.json +4 -0
  70. package/plugins/publishings/cnblogs-pub/dist/index.js +21383 -0
  71. package/plugins/publishings/cnblogs-pub/dist/profile-check.js +20412 -0
  72. package/plugins/publishings/cnblogs-pub/package.json +19 -0
  73. package/plugins/publishings/cnblogs-pub/plugin.json +12 -0
  74. package/plugins/publishings/cnblogs-pub/src/flow.ts +708 -0
  75. package/plugins/publishings/cnblogs-pub/src/index.ts +172 -0
  76. package/plugins/publishings/cnblogs-pub/src/profile-check.ts +24 -0
  77. package/plugins/publishings/cnblogs-pub/src/selectors.ts +104 -0
  78. package/plugins/publishings/cnblogs-pub/tsconfig.json +4 -0
  79. package/plugins/publishings/csdn-pub/dist/index.js +21552 -0
  80. package/plugins/publishings/csdn-pub/dist/profile-check.js +20963 -0
  81. package/plugins/publishings/csdn-pub/package.json +19 -0
  82. package/plugins/publishings/csdn-pub/plugin.json +12 -0
  83. package/plugins/publishings/csdn-pub/src/index.ts +1178 -0
  84. package/plugins/publishings/csdn-pub/src/profile-check.ts +23 -0
  85. package/plugins/publishings/csdn-pub/tsconfig.json +4 -0
  86. package/plugins/publishings/emdash-pub/dist/index.js +158 -12
  87. package/plugins/publishings/emdash-pub/plugin.json +22 -1
  88. package/plugins/publishings/infoq-pub/dist/index.js +160 -12
  89. package/plugins/publishings/infoq-pub/dist/profile-check.js +160 -12
  90. package/plugins/publishings/infoq-pub/plugin.json +21 -0
  91. package/plugins/publishings/jike-pub/dist/index.js +21379 -0
  92. package/plugins/publishings/jike-pub/dist/profile-check.js +20951 -0
  93. package/plugins/publishings/jike-pub/package.json +19 -0
  94. package/plugins/publishings/jike-pub/plugin.json +12 -0
  95. package/plugins/publishings/jike-pub/src/index.ts +800 -0
  96. package/plugins/publishings/jike-pub/src/profile-check.ts +21 -0
  97. package/plugins/publishings/jike-pub/tsconfig.json +4 -0
  98. package/plugins/publishings/juejin-pub/dist/index.js +160 -12
  99. package/plugins/publishings/juejin-pub/dist/profile-check.js +160 -12
  100. package/plugins/publishings/juejin-pub/plugin.json +21 -0
  101. package/plugins/publishings/medium-pub/dist/index.js +158 -12
  102. package/plugins/publishings/medium-pub/plugin.json +22 -1
  103. package/plugins/publishings/podcast-pub/dist/index.js +158 -12
  104. package/plugins/publishings/qiita-pub/dist/index.js +158 -12
  105. package/plugins/publishings/qiita-pub/plugin.json +24 -1
  106. package/plugins/publishings/shipinhao-pub/dist/index.js +21715 -0
  107. package/plugins/publishings/shipinhao-pub/dist/profile-check.js +20431 -0
  108. package/plugins/publishings/shipinhao-pub/package.json +19 -0
  109. package/plugins/publishings/shipinhao-pub/plugin.json +12 -0
  110. package/plugins/publishings/shipinhao-pub/src/flow.ts +1323 -0
  111. package/plugins/publishings/shipinhao-pub/src/index.ts +173 -0
  112. package/plugins/publishings/shipinhao-pub/src/profile-check.ts +25 -0
  113. package/plugins/publishings/shipinhao-pub/src/selectors.ts +241 -0
  114. package/plugins/publishings/shipinhao-pub/tsconfig.json +4 -0
  115. package/plugins/publishings/sspai-pub/dist/index.js +21371 -0
  116. package/plugins/publishings/sspai-pub/dist/profile-check.js +20414 -0
  117. package/plugins/publishings/sspai-pub/package.json +19 -0
  118. package/plugins/publishings/sspai-pub/plugin.json +12 -0
  119. package/plugins/publishings/sspai-pub/src/flow.ts +624 -0
  120. package/plugins/publishings/sspai-pub/src/index.ts +167 -0
  121. package/plugins/publishings/sspai-pub/src/profile-check.ts +25 -0
  122. package/plugins/publishings/sspai-pub/src/selectors.ts +122 -0
  123. package/plugins/publishings/sspai-pub/tsconfig.json +4 -0
  124. package/plugins/publishings/substack-pub/dist/index.js +160 -12
  125. package/plugins/publishings/substack-pub/dist/profile-check.js +160 -12
  126. package/plugins/publishings/substack-pub/plugin.json +21 -0
  127. package/plugins/publishings/surfdash-pub/dist/index.js +158 -12
  128. package/plugins/publishings/surfdash-pub/plugin.json +22 -1
  129. package/plugins/publishings/threads-pub/dist/index.js +158 -12
  130. package/plugins/publishings/wechatmp-pub/dist/index.js +1669 -502
  131. package/plugins/publishings/wechatmp-pub/dist/profile-check.js +204 -21
  132. package/plugins/publishings/wechatmp-pub/plugin.json +32 -0
  133. package/plugins/publishings/wechatmp-pub/src/cgi-draft.ts +540 -0
  134. package/plugins/publishings/wechatmp-pub/src/cgi-media.ts +380 -0
  135. package/plugins/publishings/wechatmp-pub/src/cgi.ts +485 -0
  136. package/plugins/publishings/wechatmp-pub/src/flow.ts +104 -0
  137. package/plugins/publishings/weibo-pub/dist/index.js +160 -12
  138. package/plugins/publishings/weibo-pub/dist/profile-check.js +160 -12
  139. package/plugins/publishings/weibo-pub/plugin.json +35 -0
  140. package/plugins/publishings/x-pub/dist/index.js +160 -12
  141. package/plugins/publishings/x-pub/dist/profile-check.js +160 -12
  142. package/plugins/publishings/x-pub/plugin.json +40 -0
  143. package/plugins/publishings/xhs-pub/dist/index.js +160 -12
  144. package/plugins/publishings/xhs-pub/dist/profile-check.js +160 -12
  145. package/plugins/publishings/xhs-pub/plugin.json +37 -0
  146. package/plugins/publishings/xiaoyuzhou-pub/dist/index.js +21491 -0
  147. package/plugins/publishings/xiaoyuzhou-pub/dist/profile-check.js +20417 -0
  148. package/plugins/publishings/xiaoyuzhou-pub/package.json +19 -0
  149. package/plugins/publishings/xiaoyuzhou-pub/plugin.json +12 -0
  150. package/plugins/publishings/xiaoyuzhou-pub/src/flow.ts +826 -0
  151. package/plugins/publishings/xiaoyuzhou-pub/src/index.ts +174 -0
  152. package/plugins/publishings/xiaoyuzhou-pub/src/profile-check.ts +26 -0
  153. package/plugins/publishings/xiaoyuzhou-pub/src/selectors.ts +125 -0
  154. package/plugins/publishings/xiaoyuzhou-pub/tsconfig.json +4 -0
  155. package/plugins/publishings/zenn-pub/dist/index.js +158 -12
  156. package/plugins/publishings/zenn-pub/plugin.json +22 -1
  157. package/plugins/generations/omni-voice-gen/voices/robin-news.wav +0 -0
@@ -0,0 +1,708 @@
1
+ /**
2
+ * cnblogs-pub transport + flow (task 0293).
3
+ *
4
+ * The live editor flow in `runArticleFlow`, stage-typed on every `Result.metadata.stage`:
5
+ * editor-readiness → title-readback → body-readback → (summary-commit) → (category-commit) → the
6
+ * publishStatus branch. Absent or 'draft' (the frozen default) activates only the exact-visible
7
+ * 保存草稿|存为草稿 control; 'public' activates only 发布|发布博文. Both legs confirm success only
8
+ * by a FRESH persisted receipt (an id-bearing draft/post URL that was not on screen before the
9
+ * click) plus re-read persisted title/body/category state — never a stale assignment or toast.
10
+ *
11
+ * The vendor's `upload.cnblogs.com/v2/images/cors-upload` XSRF endpoint (cnblogs.js:5-18) is never
12
+ * ported: cover/tags/images are unsupported-field refusals, and local (non-HTTPS) Markdown image
13
+ * references are rejected before any browser opens, without echoing the path. Existing HTTPS image
14
+ * URLs stay literal Markdown.
15
+ */
16
+ import { type Result, ResultSchema } from '@gobing-ai/kk-core';
17
+ import {
18
+ type BrowserDriver,
19
+ type PublishTransport,
20
+ type PublishTransportPayload,
21
+ trySelectors,
22
+ } from '@gobing-ai/publish-harness';
23
+ import {
24
+ checkChannelSession,
25
+ expectOutcome,
26
+ kkBrowserLauncher,
27
+ resolveChannelProfileDir,
28
+ resolveSelector,
29
+ } from '@gobing-ai/publish-harness/browser';
30
+ import {
31
+ type BrowserLauncher,
32
+ BrowserProfileMissingError,
33
+ LoginTimeoutError,
34
+ withProfilePage,
35
+ } from '@gobing-ai/ts-browser-automation';
36
+ import type { Page } from 'playwright';
37
+ import {
38
+ CNBLOGS_ARTICLE_SELECTORS,
39
+ CNBLOGS_CONFIG,
40
+ CNBLOGS_EDIT_URL,
41
+ CNBLOGS_RECEIPT_URL_RES,
42
+ CNBLOGS_TARGET,
43
+ isCnblogsAuthenticated,
44
+ } from './selectors';
45
+
46
+ const STEP_TIMEOUT_MS = 30_000;
47
+ /** Operator recovery command every session failure names — never a cookie, DOM dump or stack. */
48
+ export const CNBLOGS_LOGIN_INSTRUCTION = 'bun plugins/publishings/cnblogs-pub/src/profile-check.ts login';
49
+
50
+ /** `ok: false` refusal Result with a bounded, single-line error. */
51
+ export function fail(target: string, error: string): Result {
52
+ return ResultSchema.parse({ ok: false, target, error: boundedDetail(error) });
53
+ }
54
+
55
+ /** A browser-stage failure: `ok: false` with `stage` (and optional evidence) in the Result metadata. */
56
+ export function stageFail(
57
+ target: string,
58
+ error: string,
59
+ stage: string,
60
+ metadata: Record<string, unknown>,
61
+ evidence?: string,
62
+ ): Result {
63
+ return ResultSchema.parse({
64
+ ok: false,
65
+ target,
66
+ error: boundedDetail(error),
67
+ metadata: { ...metadata, stage, ...(evidence === undefined ? {} : { evidence: boundedDetail(evidence) }) },
68
+ });
69
+ }
70
+
71
+ /** One short line, bounded: no DOM dump, cookie, token, stack or leaked image path ever reaches the Result. */
72
+ export function boundedDetail(detail: string): string {
73
+ return detail.replace(/\s+/g, ' ').trim().slice(0, 300);
74
+ }
75
+
76
+ /** A bounded, single-line rendering of an operator-supplied value for a refusal message. */
77
+ export function bounded(value: unknown): string {
78
+ if (Array.isArray(value)) return `${value.length} entr${value.length === 1 ? 'y' : 'ies'}`;
79
+ return String(value).replace(/\s+/g, ' ').slice(0, 100);
80
+ }
81
+
82
+ export function errorMessage(error: unknown): string {
83
+ return error instanceof Error ? error.message : String(error);
84
+ }
85
+
86
+ /** Session failures whose one-liner must name the recovery command. */
87
+ function needsLogin(error: unknown): boolean {
88
+ return error instanceof LoginTimeoutError || error instanceof BrowserProfileMissingError;
89
+ }
90
+
91
+ /** CommonMark label matching: case-insensitive with internal whitespace collapsed. */
92
+ function normalizeLabel(label: string): string {
93
+ return label.trim().replace(/\s+/g, ' ').toLowerCase();
94
+ }
95
+
96
+ /**
97
+ * Counts Markdown image references whose target is not an HTTPS URL — inline `![…](src)` plus
98
+ * reference-style `![…][id]` / `![id][]` / `![id]` resolved through `[id]: src` definitions.
99
+ * Local/relative and plain-http refs are unsupported by this flow: the vendor's upload endpoint
100
+ * (cnblogs.js:5-18) is never ported, so they are refused before submit — the count only, never
101
+ * the path. Definitions used only by plain links, and undefined labels, are not images.
102
+ */
103
+ export function countNonHttpsImageRefs(markdown: string): number {
104
+ let count = 0;
105
+ for (const ref of markdown.matchAll(/!\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g)) {
106
+ if (!(ref[1] ?? '').startsWith('https://')) count += 1;
107
+ }
108
+ const definitions = new Map<string, string>();
109
+ for (const def of markdown.matchAll(/^ {0,3}\[([^\]]+)\]:[ \t]*<?([^\s>]+)>?/gm)) {
110
+ definitions.set(normalizeLabel(def[1] ?? ''), def[2] ?? '');
111
+ }
112
+ for (const ref of markdown.matchAll(/!\[([^\]]*)\](?:\[([^\]]*)\])?(?![([:])/g)) {
113
+ const src = definitions.get(normalizeLabel(ref[2] || ref[1] || ''));
114
+ if (src !== undefined && !src.startsWith('https://')) count += 1;
115
+ }
116
+ return count;
117
+ }
118
+
119
+ /** Whitespace-insensitive comparison: site normalizations must not fail a strict readback. */
120
+ function normalizeText(value: string): string {
121
+ return value.replace(/\s+/g, '');
122
+ }
123
+
124
+ /** Input/textarea `value` property readback (title input, body editor, summary). */
125
+ function inputValueScript(selector: string): string {
126
+ return `(() => { // kk-cnblogs-input-value
127
+ const el = document.querySelector(${JSON.stringify(selector)});
128
+ return el === null ? '' : String(el.value ?? '');
129
+ })()`;
130
+ }
131
+
132
+ /**
133
+ * One category commit probe over the panel: finds the checkbox whose associated label text equals
134
+ * `wanted` exactly (wrapping label or `label[for]`), clicks it when unchecked, and reports the
135
+ * observed checked state. No create, no first-match/fuzzy fallback — `offered:false` refuses.
136
+ */
137
+ function commitCategoryScript(panelSelector: string, wanted: string): string {
138
+ return `(() => { // kk-cnblogs-category-commit
139
+ const panel = document.querySelector(${JSON.stringify(panelSelector)});
140
+ if (panel === null) return JSON.stringify({ offered: false, checked: false });
141
+ const wanted = ${JSON.stringify(wanted)};
142
+ for (const box of panel.querySelectorAll('input[type="checkbox"]')) {
143
+ const label = box.closest('label') ?? (box.id ? panel.querySelector('label[for="' + CSS.escape(box.id) + '"]') : null);
144
+ if ((label?.textContent ?? '').trim() !== wanted) continue;
145
+ if (!box.checked) box.click();
146
+ return JSON.stringify({ offered: true, checked: box.checked === true });
147
+ }
148
+ return JSON.stringify({ offered: false, checked: false });
149
+ })()`;
150
+ }
151
+
152
+ /**
153
+ * Persisted-state readback for save confirmation: the title/body textareas still hold the
154
+ * requested text and every requested category checkbox is still checked (frozen success rule —
155
+ * a fresh id alone never confirms).
156
+ */
157
+ function persistedStateScript(title: string, body: string, categories: readonly string[]): string {
158
+ return `(() => { // kk-cnblogs-persisted
159
+ const title = document.querySelector('input#post-title');
160
+ const editor = document.querySelector('textarea#md-editor');
161
+ const squish = (v) => String(v ?? '').replace(/\\s+/g, '');
162
+ const titleOk = title !== null && squish(title.value) === squish(${JSON.stringify(title)});
163
+ const bodyOk = editor !== null && squish(editor.value) === squish(${JSON.stringify(body)});
164
+ const missing = [];
165
+ for (const wanted of ${JSON.stringify(categories)}) {
166
+ let found = false;
167
+ for (const panel of document.querySelectorAll(${JSON.stringify([...CNBLOGS_ARTICLE_SELECTORS.categoryPanel])})) {
168
+ for (const box of panel.querySelectorAll('input[type="checkbox"]')) {
169
+ const label = box.closest('label') ?? (box.id ? panel.querySelector('label[for="' + CSS.escape(box.id) + '"]') : null);
170
+ if ((label?.textContent ?? '').trim() === wanted && box.checked === true) found = true;
171
+ }
172
+ }
173
+ if (!found) missing.push(wanted);
174
+ }
175
+ return JSON.stringify({ titleOk, bodyOk, missing });
176
+ })()`;
177
+ }
178
+
179
+ /** Receipt-shaped URLs on screen (location + anchors) — the save-time baseline and fresh read. */
180
+ async function readReceiptUrls(page: Page): Promise<readonly string[]> {
181
+ const raw = await page.evaluate(`(() => { // kk-cnblogs-receipt-urls
182
+ const patterns = [${CNBLOGS_RECEIPT_URL_RES.map((re) => re.toString()).join(', ')}];
183
+ const matches = (url) => patterns.some((re) => re.test(url));
184
+ const urls = [];
185
+ if (matches(location.href)) urls.push(location.href);
186
+ for (const a of document.querySelectorAll('a[href]')) {
187
+ if (matches(a.href) && !urls.includes(a.href)) urls.push(a.href);
188
+ }
189
+ return JSON.stringify(urls);
190
+ })()`);
191
+ try {
192
+ const parsed: unknown = JSON.parse(String(raw));
193
+ return Array.isArray(parsed) ? parsed.map((entry) => String(entry)) : [];
194
+ } catch {
195
+ return [];
196
+ }
197
+ }
198
+
199
+ /** Draft/post id from an observed receipt URL (shapes: `CNBLOGS_RECEIPT_URL_RES`, provisional). */
200
+ function articleIdFrom(url: string): string | undefined {
201
+ return /(?:postId=|\/p\/)(\d+)/.exec(url)?.[1];
202
+ }
203
+
204
+ interface CommitOutcome {
205
+ readonly ok: true;
206
+ readonly evidence: string;
207
+ }
208
+ interface Refusal {
209
+ readonly ok: false;
210
+ readonly error: string;
211
+ }
212
+
213
+ /**
214
+ * Cnblogs browser transport on the shared rails (ADR-027): a headless session preflight, one
215
+ * headed flow, and success only from a fresh persisted receipt plus persisted field state.
216
+ * publishStatus absent or 'draft' takes the draft leg (exact-visible 保存草稿|存为草稿); 'public'
217
+ * activates 发布|发布博文; any other value refuses before the browser (ladder in
218
+ * `src/index.ts` → `publish()`).
219
+ */
220
+ export class CnblogsBrowserTransport implements PublishTransport {
221
+ public readonly kind = 'browser';
222
+ public readonly driver: BrowserDriver;
223
+ private readonly operation: 'article';
224
+ private readonly launcher: BrowserLauncher;
225
+ private readonly stepTimeoutMs: number;
226
+ constructor(
227
+ operation: 'article' = 'article',
228
+ driver: BrowserDriver = 'script',
229
+ deps: { launcher?: BrowserLauncher; stepTimeoutMs?: number } = {},
230
+ ) {
231
+ this.operation = operation;
232
+ this.driver = driver;
233
+ this.launcher = deps.launcher ?? kkBrowserLauncher;
234
+ this.stepTimeoutMs = deps.stepTimeoutMs ?? STEP_TIMEOUT_MS;
235
+ }
236
+
237
+ /**
238
+ * Publishes one Content as a live cnblogs Markdown article.
239
+ * @param payload - Target content plus per-call options.
240
+ * @returns The publish `Result`, `ok: true` only from an observed persisted receipt.
241
+ */
242
+ public async publish(payload: PublishTransportPayload): Promise<Result> {
243
+ // The operation was resolved (and unknown names refused) before this transport existed;
244
+ // the remaining ladder is all pre-browser: driver → publishStatus → title → body → digest
245
+ // → unsupported fields → categories → local Markdown images → session preflight.
246
+ if (this.driver !== 'script') {
247
+ return fail(
248
+ CNBLOGS_TARGET,
249
+ `unsupported driver '${bounded(this.driver)}' for ${CNBLOGS_TARGET}: expected one of script (the cnblogs-pub browser flow has no agent path)`,
250
+ );
251
+ }
252
+ const requested = payload.content.metadata?.publishStatus;
253
+ if (requested !== undefined && requested !== 'draft' && requested !== 'public') {
254
+ return fail(
255
+ CNBLOGS_TARGET,
256
+ `unknown publishStatus '${bounded(requested)}' for ${CNBLOGS_TARGET}: expected 'public', 'draft' or absent (absent defaults to draft)`,
257
+ );
258
+ }
259
+ const publishStatus = requested === 'public' ? 'public' : 'draft';
260
+ const title = payload.content.title?.trim() ?? '';
261
+ if (title.length === 0) {
262
+ return fail(
263
+ CNBLOGS_TARGET,
264
+ `cnblogs article requires a non-empty Content.title (the Markdown editor has an independent title field, cnblogs.js:22)`,
265
+ );
266
+ }
267
+ const body = payload.content.body.trim();
268
+ if (body.length === 0) {
269
+ return fail(
270
+ CNBLOGS_TARGET,
271
+ 'cnblogs article requires non-empty Content.body Markdown (the body is its own saved field, cnblogs.js:23)',
272
+ );
273
+ }
274
+ const digest = payload.content.metadata?.digest;
275
+ if (digest !== undefined && (typeof digest !== 'string' || digest.trim().length === 0)) {
276
+ return fail(
277
+ CNBLOGS_TARGET,
278
+ `metadata.digest must be a non-empty string when present, got '${bounded(digest)}'`,
279
+ );
280
+ }
281
+ const unsupported = unsupportedFields(payload);
282
+ if (unsupported.length > 0) {
283
+ return fail(
284
+ CNBLOGS_TARGET,
285
+ `unsupported field(s) ${unsupported.join(', ')} for cnblogs: this flow implements title/body/digest/categories only, and the vendor's upload.cnblogs.com XSRF image endpoint (cnblogs.js:5-18) is never ported`,
286
+ );
287
+ }
288
+ const categories = resolveCategoryList(payload.options?.categories ?? payload.content.metadata?.categories);
289
+ if ('error' in categories) return fail(CNBLOGS_TARGET, categories.error);
290
+ const localImages = countNonHttpsImageRefs(body);
291
+ if (localImages > 0) {
292
+ return fail(
293
+ CNBLOGS_TARGET,
294
+ `${localImages} non-HTTPS Markdown image reference(s) in Content.body: cnblogs-pub does not upload images (the upload.cnblogs.com XSRF endpoint is never ported); keep existing https:// image URLs literal and remove/local-check local references before publishing`,
295
+ );
296
+ }
297
+
298
+ const override = typeof payload.options?.profileDir === 'string' ? payload.options.profileDir : undefined;
299
+ const profileDir = resolveChannelProfileDir(CNBLOGS_TARGET, override);
300
+
301
+ // ADR-025 preflight: an unauthenticated or absent profile must never produce a headed visit,
302
+ // so the read-only headless check runs first and a refusal stays a fast refusal.
303
+ const session = await checkChannelSession(CNBLOGS_CONFIG, {
304
+ profileDir,
305
+ launcher: this.launcher,
306
+ headedFallback: false,
307
+ timeoutMs: this.stepTimeoutMs,
308
+ });
309
+ if (session.status !== 'verified') {
310
+ // The recovery command leads the message: `fail()` bounds the line, and the operator's
311
+ // next action must never be the part that gets truncated.
312
+ return fail(
313
+ CNBLOGS_TARGET,
314
+ `${CNBLOGS_TARGET} session preflight refused — run: ${CNBLOGS_LOGIN_INSTRUCTION} — detail: ${session.detail}`,
315
+ );
316
+ }
317
+
318
+ try {
319
+ return await withProfilePage(
320
+ {
321
+ profileDir,
322
+ url: CNBLOGS_EDIT_URL,
323
+ isAuthenticated: isCnblogsAuthenticated,
324
+ // Headed by default; the headless preflight above is the unattended pass and
325
+ // `options.headless === true` the opt-in.
326
+ headless: payload.options?.headless === true,
327
+ launcher: this.launcher,
328
+ // The session is already preflight-verified, so the post-goto confirmation is a
329
+ // bounded step like every other one, not the 180s interactive login wait.
330
+ timeoutMs: this.stepTimeoutMs,
331
+ },
332
+ async ({ page }) =>
333
+ this.runArticleFlow(
334
+ page,
335
+ title,
336
+ body,
337
+ digest as string | undefined,
338
+ categories.names,
339
+ profileDir,
340
+ publishStatus,
341
+ ),
342
+ );
343
+ } catch (error) {
344
+ // Same ordering rule as the preflight refusal: the recovery command is never the tail
345
+ // that a bounded message drops.
346
+ const detail = errorMessage(error);
347
+ return fail(
348
+ CNBLOGS_TARGET,
349
+ needsLogin(error)
350
+ ? `${CNBLOGS_TARGET} session failed — run: ${CNBLOGS_LOGIN_INSTRUCTION} — detail: ${detail}`
351
+ : detail,
352
+ );
353
+ }
354
+ }
355
+
356
+ /**
357
+ * The live article flow on the already-navigated post-edit page (`CNBLOGS_EDIT_URL`):
358
+ * read-only capability probe → title/body fill with strict value readback (native value +
359
+ * input events via Playwright fill, never innerHTML) → optional digest → optional exact
360
+ * category checkbox commits → the publishStatus branch. Success on either leg is a fresh
361
+ * id-bearing receipt URL plus persisted title/body/category state.
362
+ */
363
+ private async runArticleFlow(
364
+ page: Page,
365
+ title: string,
366
+ body: string,
367
+ digest: string | undefined,
368
+ categories: readonly string[],
369
+ profileDir: string,
370
+ publishStatus: 'draft' | 'public',
371
+ ): Promise<Result> {
372
+ const metadata = { operation: this.operation, driver: this.driver, publishStatus, profileDir };
373
+
374
+ // Read-only capability probe: visibility only — nothing is typed or submitted, so a
375
+ // refusal here can never leave a half-filled form behind. The grounded pair must be
376
+ // visible (else the page is wrong or logged out).
377
+ const grounded: Record<string, boolean> = {};
378
+ for (const key of ['titleInput', 'bodyEditor'] as const) {
379
+ let selector: string;
380
+ try {
381
+ selector = await resolveSelector(page, CNBLOGS_ARTICLE_SELECTORS[key], {
382
+ timeoutMs: this.stepTimeoutMs,
383
+ label: `cnblogs ${key}`,
384
+ });
385
+ } catch (error) {
386
+ return stageFail(
387
+ CNBLOGS_TARGET,
388
+ `cnblogs grounded editor control ${key} never attached on ${CNBLOGS_EDIT_URL}: ${errorMessage(error)} — run: ${CNBLOGS_LOGIN_INSTRUCTION}`,
389
+ 'editor-readiness',
390
+ metadata,
391
+ );
392
+ }
393
+ grounded[key] = (
394
+ await trySelectors(page, [selector], { visible: true, timeout: this.stepTimeoutMs })
395
+ ).found;
396
+ }
397
+ if (!grounded.titleInput || !grounded.bodyEditor) {
398
+ return stageFail(
399
+ CNBLOGS_TARGET,
400
+ `cnblogs grounded editor controls found but not visible on ${CNBLOGS_EDIT_URL} — run: ${CNBLOGS_LOGIN_INSTRUCTION}`,
401
+ 'editor-readiness',
402
+ metadata,
403
+ `grounded visible: ${JSON.stringify(grounded)}`,
404
+ );
405
+ }
406
+
407
+ // Title: native value fill with real input events, then strict readback (cnblogs.js:28).
408
+ try {
409
+ const titleSelector = await resolveSelector(page, CNBLOGS_ARTICLE_SELECTORS.titleInput, {
410
+ timeoutMs: this.stepTimeoutMs,
411
+ label: 'cnblogs title input',
412
+ });
413
+ await page.locator(titleSelector).first().fill(title);
414
+ const readback = await expectOutcome(
415
+ async () =>
416
+ normalizeText(String(await page.evaluate(inputValueScript(titleSelector)))) === normalizeText(title)
417
+ ? 'title read back'
418
+ : null,
419
+ { timeoutMs: this.stepTimeoutMs, label: 'cnblogs title readback' },
420
+ );
421
+ if (!readback.ok)
422
+ return stageFail(
423
+ CNBLOGS_TARGET,
424
+ `cnblogs title readback failed — the editor did not retain the requested title — ${readback.error}`,
425
+ 'title-readback',
426
+ metadata,
427
+ );
428
+ } catch (error) {
429
+ return stageFail(
430
+ CNBLOGS_TARGET,
431
+ `cnblogs title fill failed — ${errorMessage(error)}`,
432
+ 'title-readback',
433
+ metadata,
434
+ );
435
+ }
436
+
437
+ // Body: native value fill with real input events on the Markdown textarea, then strict
438
+ // readback (cnblogs.js:23, 29) — a dropped body must refuse before anything is saved.
439
+ try {
440
+ const bodySelector = await resolveSelector(page, CNBLOGS_ARTICLE_SELECTORS.bodyEditor, {
441
+ timeoutMs: this.stepTimeoutMs,
442
+ label: 'cnblogs Markdown body editor',
443
+ });
444
+ await page.locator(bodySelector).first().fill(body);
445
+ const readback = await expectOutcome(
446
+ async () =>
447
+ normalizeText(String(await page.evaluate(inputValueScript(bodySelector)))) === normalizeText(body)
448
+ ? 'body read back'
449
+ : null,
450
+ { timeoutMs: this.stepTimeoutMs, label: 'cnblogs body readback' },
451
+ );
452
+ if (!readback.ok)
453
+ return stageFail(
454
+ CNBLOGS_TARGET,
455
+ `cnblogs body readback failed — the editor did not retain the requested Markdown — ${readback.error}`,
456
+ 'body-readback',
457
+ metadata,
458
+ );
459
+ } catch (error) {
460
+ return stageFail(
461
+ CNBLOGS_TARGET,
462
+ `cnblogs body fill failed — ${errorMessage(error)}`,
463
+ 'body-readback',
464
+ metadata,
465
+ );
466
+ }
467
+
468
+ // Summary/digest: only when a digest was requested (cnblogs.js:30-31 fills only then);
469
+ // the control is grounded, so absence refuses instead of dropping the digest silently.
470
+ if (digest !== undefined) {
471
+ try {
472
+ const summarySelector = await resolveSelector(page, CNBLOGS_ARTICLE_SELECTORS.summaryInput, {
473
+ timeoutMs: this.stepTimeoutMs,
474
+ label: 'cnblogs summary textarea',
475
+ });
476
+ await page.locator(summarySelector).first().fill(digest);
477
+ const readback = await expectOutcome(
478
+ async () =>
479
+ normalizeText(String(await page.evaluate(inputValueScript(summarySelector)))) ===
480
+ normalizeText(digest)
481
+ ? 'summary read back'
482
+ : null,
483
+ { timeoutMs: this.stepTimeoutMs, label: 'cnblogs summary readback' },
484
+ );
485
+ if (!readback.ok)
486
+ return stageFail(
487
+ CNBLOGS_TARGET,
488
+ `cnblogs summary readback failed — the editor did not retain the requested digest — ${readback.error}`,
489
+ 'summary-commit',
490
+ metadata,
491
+ );
492
+ } catch (error) {
493
+ return stageFail(
494
+ CNBLOGS_TARGET,
495
+ `cnblogs summary fill failed — ${errorMessage(error)}`,
496
+ 'summary-commit',
497
+ metadata,
498
+ );
499
+ }
500
+ }
501
+
502
+ // Categories: exact existing account names only, matched through label-associated
503
+ // checkboxes and verified checked — never created, never first-matched.
504
+ if (categories.length > 0) {
505
+ const committed = await this.commitCategories(page, categories);
506
+ if (!committed.ok) return stageFail(CNBLOGS_TARGET, committed.error, 'category-commit', metadata);
507
+ }
508
+
509
+ // The status branch. Draft leg: only the exact-visible draft-labeled control. Public leg:
510
+ // only the exact-visible publish control. Both fail closed when absent — no broad
511
+ // primary-button fallback — and confirm only a fresh persisted receipt plus persisted state.
512
+ if (publishStatus === 'draft')
513
+ return this.save(
514
+ page,
515
+ CNBLOGS_ARTICLE_SELECTORS.draftSave,
516
+ 'draft-save',
517
+ metadata,
518
+ title,
519
+ body,
520
+ categories,
521
+ profileDir,
522
+ );
523
+ return this.save(
524
+ page,
525
+ CNBLOGS_ARTICLE_SELECTORS.publishButton,
526
+ 'publish-open',
527
+ { ...metadata },
528
+ title,
529
+ body,
530
+ categories,
531
+ profileDir,
532
+ 'publish-confirmation',
533
+ );
534
+ }
535
+
536
+ /**
537
+ * Commits each requested category by exact label text in the category panel and verifies the
538
+ * checkbox is checked. A name the panel does not offer (or that will not stay checked)
539
+ * refuses the save — no create, no first-match fallback.
540
+ */
541
+ private async commitCategories(page: Page, categories: readonly string[]): Promise<CommitOutcome | Refusal> {
542
+ try {
543
+ const panelSelector = await resolveSelector(page, CNBLOGS_ARTICLE_SELECTORS.categoryPanel, {
544
+ timeoutMs: this.stepTimeoutMs,
545
+ label: 'cnblogs category panel',
546
+ });
547
+ for (const category of categories) {
548
+ const outcome = await expectOutcome(
549
+ async () => {
550
+ const raw = String(await page.evaluate(commitCategoryScript(panelSelector, category)));
551
+ const state = JSON.parse(raw) as { offered: boolean; checked: boolean };
552
+ return state.offered && state.checked ? `category 「${bounded(category)}」 checked` : null;
553
+ },
554
+ {
555
+ timeoutMs: this.stepTimeoutMs,
556
+ label: `cnblogs category 「${bounded(category)}」 exact checkbox commit`,
557
+ },
558
+ );
559
+ if (!outcome.ok) {
560
+ return {
561
+ ok: false,
562
+ error: `cnblogs category panel has no checked checkbox labelled 「${bounded(category)}」; refusing to save with a substituted, created or first-matched category`,
563
+ };
564
+ }
565
+ }
566
+ return { ok: true, evidence: `${categories.length} category checkbox(es) verified checked` };
567
+ } catch (error) {
568
+ return { ok: false, error: `cnblogs category commit failed — ${errorMessage(error)}` };
569
+ }
570
+ }
571
+
572
+ /**
573
+ * One status leg: resolve the exact-visible control (`stage` names the leg — draft-save or
574
+ * publish-open), require it visible and enabled, snapshot receipt URLs, click, then confirm
575
+ * only a FRESH id-bearing receipt URL (not on screen beforehand) plus persisted
576
+ * title/body/category state (`confirmStage`). Stale or absent never passes.
577
+ */
578
+ private async save(
579
+ page: Page,
580
+ controls: readonly string[],
581
+ stage: 'draft-save' | 'publish-open',
582
+ metadata: Record<string, unknown>,
583
+ title: string,
584
+ body: string,
585
+ categories: readonly string[],
586
+ profileDir: string,
587
+ confirmStage: 'draft-save' | 'publish-open' | 'publish-confirmation' = stage,
588
+ ): Promise<Result> {
589
+ let selector: string;
590
+ try {
591
+ selector = await resolveSelector(page, controls, {
592
+ timeoutMs: this.stepTimeoutMs,
593
+ label: `cnblogs ${stage} control`,
594
+ });
595
+ } catch (error) {
596
+ return stageFail(
597
+ CNBLOGS_TARGET,
598
+ `cnblogs ${stage} control never attached: ${errorMessage(error)}`,
599
+ stage,
600
+ metadata,
601
+ );
602
+ }
603
+ const button = page.locator(selector).first();
604
+ const visible = (await trySelectors(page, [selector], { visible: true, timeout: this.stepTimeoutMs })).found;
605
+ if (!visible)
606
+ return stageFail(
607
+ CNBLOGS_TARGET,
608
+ `cnblogs ${stage} control attached but not visible; refusing to save through a hidden control`,
609
+ stage,
610
+ metadata,
611
+ );
612
+ const enabled = await expectOutcome(async () => ((await button.isEnabled()) ? 'enabled' : null), {
613
+ timeoutMs: this.stepTimeoutMs,
614
+ label: `cnblogs ${stage} control enabled`,
615
+ });
616
+ if (!enabled.ok)
617
+ return stageFail(
618
+ CNBLOGS_TARGET,
619
+ `cnblogs ${stage} control never became clickable — ${enabled.error}`,
620
+ stage,
621
+ metadata,
622
+ );
623
+ // Baseline before the click: only a receipt absent beforehand can confirm (fresh-signal rule).
624
+ const beforeReceipts = new Set(await readReceiptUrls(page));
625
+ await button.click();
626
+ const confirmed = await expectOutcome(
627
+ async () => {
628
+ const url = (await readReceiptUrls(page)).find((candidate) => !beforeReceipts.has(candidate));
629
+ if (url === undefined) return null;
630
+ const raw = String(await page.evaluate(persistedStateScript(title, body, categories)));
631
+ const state = JSON.parse(raw) as { titleOk: boolean; bodyOk: boolean; missing: string[] };
632
+ if (!state.titleOk || !state.bodyOk || state.missing.length > 0) {
633
+ return `stale-state: ${url}`; // a receipt whose fields no longer match is not success
634
+ }
635
+ return url;
636
+ },
637
+ { timeoutMs: this.stepTimeoutMs, label: `cnblogs ${confirmStage} confirmation` },
638
+ );
639
+ if (!confirmed.ok) {
640
+ return stageFail(
641
+ CNBLOGS_TARGET,
642
+ `cnblogs ${confirmStage} not confirmed — no fresh id-bearing receipt with persisted title/body/category state appeared`,
643
+ confirmStage,
644
+ metadata,
645
+ `still on ${page.url()}; ${beforeReceipts.size} pre-existing receipt-shaped url(s)`,
646
+ );
647
+ }
648
+ if (confirmed.evidence.startsWith('stale-state: ')) {
649
+ return stageFail(
650
+ CNBLOGS_TARGET,
651
+ `cnblogs ${confirmStage} receipt appeared but the persisted title/body/category state no longer matches the requested content; refusing to claim success`,
652
+ confirmStage,
653
+ metadata,
654
+ confirmed.evidence,
655
+ );
656
+ }
657
+ const url = confirmed.evidence;
658
+ return ResultSchema.parse({
659
+ ok: true,
660
+ target: CNBLOGS_TARGET,
661
+ id: articleIdFrom(url),
662
+ url,
663
+ message: `cnblogs article ${stage === 'draft-save' ? 'draft saved' : 'published'} in profile ${profileDir}`,
664
+ metadata: { ...metadata, confirmedBy: confirmStage },
665
+ });
666
+ }
667
+ }
668
+
669
+ /**
670
+ * Unsupported requested fields for this flow: cover/tags/images are not implemented (the vendor's
671
+ * upload.cnblogs.com XSRF image endpoint, cnblogs.js:5-18, is never ported), so any of them — in
672
+ * options or metadata — is a typed unsupported-field refusal, never a silent claim of applied.
673
+ */
674
+ function unsupportedFields(payload: PublishTransportPayload): readonly string[] {
675
+ const refused: string[] = [];
676
+ for (const [container, keys] of [
677
+ ['options', ['coverPath', 'tags', 'images'] as const],
678
+ ['metadata', ['coverPath', 'tags', 'images'] as const],
679
+ ] as const) {
680
+ const source: unknown = container === 'options' ? payload.options : payload.content.metadata;
681
+ for (const key of keys) {
682
+ if (source !== undefined && source !== null && typeof source === 'object' && key in source) {
683
+ refused.push(`${container}.${key}`);
684
+ }
685
+ }
686
+ }
687
+ return refused;
688
+ }
689
+
690
+ /**
691
+ * Shape-check for the exact-name category list: an array of non-empty names, refused — never
692
+ * sliced or guessed — when wrong-typed or empty-entry-ed. Exact checkbox matching against the
693
+ * category panel happens in the flow; this gate only keeps wrong shapes from reaching a browser.
694
+ */
695
+ function resolveCategoryList(raw: unknown): { names: string[] } | { error: string } {
696
+ if (raw === undefined) return { names: [] };
697
+ if (!Array.isArray(raw)) {
698
+ return { error: `metadata.categories must be an array of non-empty exact names, got '${bounded(raw)}'` };
699
+ }
700
+ const names: string[] = [];
701
+ for (const entry of raw) {
702
+ if (typeof entry !== 'string' || entry.trim().length === 0) {
703
+ return { error: `metadata.categories entries must be non-empty exact names, got '${bounded(entry)}'` };
704
+ }
705
+ names.push(entry.trim());
706
+ }
707
+ return { names };
708
+ }