@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,1178 @@
1
+ /**
2
+ * csdn-pub — CSDN (blog.csdn.net / mp.csdn.net) Markdown-article publisher (task 0292).
3
+ *
4
+ * The live editor flow in `runArticleFlow`: a read-only capability probe, native title/body fill
5
+ * with strict readback, then the status branch — public runs open the publish modal for tag/category
6
+ * exact commits, cover upload and a scoped public submit confirmed only by a fresh persisted receipt;
7
+ * draft runs (publishStatus absent or 'draft', the frozen default) enter that modal only to commit the
8
+ * requested fields and then choose only the top-level 保存草稿 action (`DRAFT_SAVE_SELECTORS`),
9
+ * confirmed only by a fresh draft id — never the vendor's status-blind signed saveArticle API
10
+ * (csdn.js:183-248).
11
+ *
12
+ * Evidence: the recovered extension adapter
13
+ * `.spur/memory/evidence/publish-ready-0288-0297-20261014-01/source/platforms/csdn.js` is an
14
+ * **API** adapter (saveArticle, csdn.js:183-248) — it pins the field semantics cited below but
15
+ * contains **no DOM selectors** and its vendor signing credentials, OBS protocol and fixed
16
+ * 经验分享 tag (csdn.js:32-41, 206) are deliberately NOT ported. DOM evidence is the current
17
+ * public editor bundle (https://editor.csdn.net/md/ → app.chunk.3e8e17a6.js, observed 2026-10-14,
18
+ * task 0292 Design); selectors the bundle evidence does not pin are marked **Provisional** and
19
+ * fail loud when absent.
20
+ *
21
+ * publishStatus contract (task 0292 review fix): absent defaults to 'draft', 'draft' saves a draft
22
+ * through the top-level 保存草稿 control, 'public' publishes live; any other value refuses before
23
+ * any browser opens. The API adapter's save and publish are one status-blind call (csdn.js:13-14),
24
+ * so the browser receipt — never the API — is the only confirmation on either leg.
25
+ */
26
+ import { dirname, extname } from 'node:path';
27
+ import { parseArgs } from 'node:util';
28
+ import { type Content, ContentSchema, type Result, ResultSchema } from '@gobing-ai/kk-core';
29
+ import {
30
+ type BrowserDriver,
31
+ FakeBrowserTransport,
32
+ type PublishTransport,
33
+ type PublishTransportPayload,
34
+ trySelectors,
35
+ } from '@gobing-ai/publish-harness';
36
+ import {
37
+ type ChannelBrowserConfig,
38
+ checkChannelSession,
39
+ expectOutcome,
40
+ kkBrowserLauncher,
41
+ resolveChannelProfileDir,
42
+ resolveSelector,
43
+ uploadFile,
44
+ } from '@gobing-ai/publish-harness/browser';
45
+ import {
46
+ type BrowserLauncher,
47
+ BrowserProfileMissingError,
48
+ type IsAuthenticatedPredicate,
49
+ LoginTimeoutError,
50
+ withProfilePage,
51
+ } from '@gobing-ai/ts-browser-automation';
52
+ import { createNodeFileSystem } from '@gobing-ai/ts-runtime';
53
+ import { echoError } from '@gobing-ai/ts-utils';
54
+ import type { Page } from 'playwright';
55
+
56
+ /** Site-owned target slug; also the profile channel key (`CSDN_PROFILE_DIR`). */
57
+ const TARGET = 'csdn';
58
+ /** Home entry. Grounded: csdn.js:1 header — 「CSDN(blog.csdn.net / mp.csdn.net)文章发布适配器」. */
59
+ export const CSDN_HOME_URL = 'https://blog.csdn.net';
60
+ /**
61
+ * Markdown editor entry. Grounded: the task 0292 Design fetched it 2026-10-14
62
+ * (https://editor.csdn.net/md/ → app.chunk.3e8e17a6.js); the article flow composes here, not on
63
+ * the blog home. The headless session check also uses this page so the readiness predicate below
64
+ * observes one strong site-owned gate (the grounded title input), the weibo-pub multi-surface
65
+ * pattern narrowed to the one surface this channel composes on.
66
+ */
67
+ export const CSDN_EDITOR_URL = 'https://editor.csdn.net/md/';
68
+ /**
69
+ * Login/passport redirect marker. **Provisional**: csdn.js runs inside an already-authenticated
70
+ * tab and never observes a redirect URL; passport.csdn.net is CSDN's known SSO host. The editor
71
+ * readiness predicate below carries the real gate.
72
+ */
73
+ export const CSDN_LOGIN_URL_RE = /(passport\.csdn\.net|\/login)/;
74
+
75
+ /** Declared operations (ADR-029 dispatch): the source handles article only (csdn.js:249). */
76
+ export const CSDN_OPERATIONS = ['article'] as const;
77
+ export type CsdnOperation = (typeof CSDN_OPERATIONS)[number];
78
+
79
+ /**
80
+ * Article-editor selectors, ordered fallbacks. Field semantics are grounded on csdn.js (cited per
81
+ * key); CSS values are the 2026-10-14 editor-bundle observations from the task 0292 Design, with
82
+ * anything that evidence does not pin marked **Provisional** — a missing control is a loud typed
83
+ * refusal, never a silent no-op.
84
+ */
85
+ export const CSDN_ARTICLE_SELECTORS = {
86
+ /**
87
+ * Editor-page readiness scope. **Provisional**: BEM parent `.article-bar` of the grounded
88
+ * title classes below; the flow activates `titleDisplay` first (task 0292 Design) before the
89
+ * title input is editable.
90
+ */
91
+ composer: ['.article-bar', '.editor__wrapper'],
92
+ /**
93
+ * Title-display activator. **Grounded** (2026-10-14 editor bundle, task 0292 Design): the
94
+ * title input is not editable until this display is clicked.
95
+ */
96
+ titleDisplay: ['.article-bar__title-display'],
97
+ /**
98
+ * Title input. **Grounded** (2026-10-14 editor bundle, task 0292 Design). Title is the
99
+ * article's primary field (vendor bounded it to 100 chars, csdn.js:202).
100
+ */
101
+ titleInput: ['input.article-bar__title--input[placeholder*="文章标题"]'],
102
+ /**
103
+ * Markdown body editor. **Grounded** (2026-10-14 editor bundle, task 0292 Design): a
104
+ * cledit-managed `pre`, not CodeMirror — Chunk B must use real keyboard/paste events, not
105
+ * DOM innerHTML assignment. Body is its own saved field (csdn.js:204-205).
106
+ */
107
+ bodyEditor: ['pre.editor__inner.markdown-highlighting'],
108
+ /**
109
+ * Publish-modal tag input, scoped to the modal. **Provisional** inner node: the bundle
110
+ * evidence pins the modal scope and the 文章标签 label but not the input itself. Tags must be
111
+ * committed as the requested exact names — the vendor's fixed 经验分享 tag (csdn.js:206, 257)
112
+ * is never substituted.
113
+ */
114
+ tagInput: [
115
+ '.modal__publish-article input[placeholder*="标签"]',
116
+ '.modal__publish-article :text("文章标签") ~ input',
117
+ ],
118
+ /**
119
+ * Publish-modal category control, scoped to the modal. **Provisional** inner node (same
120
+ * grounding as `tagInput`). Categories must be exact-match choices — the vendor silently sent
121
+ * empty categories (csdn.js:207), which is precisely the silent fallback this channel refuses.
122
+ */
123
+ categoryControl: [
124
+ '.modal__publish-article input[placeholder*="分类"]',
125
+ '.modal__publish-article :text("分类专栏") ~ input',
126
+ ],
127
+ /**
128
+ * The modal's final public submit. **Provisional** exact node: the bundle evidence places the
129
+ * final public submit inside the 发布文章 modal (never its draft-labeled actions) but does not
130
+ * pin the button. Success needs a fresh published receipt — the API returns success without an
131
+ * observable final status (csdn.js:13-18), so the UI receipt is the only evidence.
132
+ */
133
+ publishButton: ['.modal__publish-article button:has-text("发布")'],
134
+ /**
135
+ * The publish-modal entry in the editor chrome. **Provisional** exact node: the bundle
136
+ * evidence pins the 发布文章 modal itself (`.modal__publish-article`, task 0292 Design) but not
137
+ * the button that opens it; absent entry is a loud refusal, never a modal guess.
138
+ */
139
+ publishOpen: ['button:has-text("发布文章")'],
140
+ /**
141
+ * Cover file input, scoped to the publish modal. **Provisional** node: the bundle evidence
142
+ * pins the 封面 label inside `.modal__publish-article` (task 0292 Design) but not the input.
143
+ * The vendor's OBS direct-upload protocol and signing keys (csdn.js:32-41) are never used
144
+ * here — an absent input is a typed refusal, not a clipboard or API bridge.
145
+ */
146
+ coverInput: ['.modal__publish-article input[type="file"]'],
147
+ } as const;
148
+
149
+ const STEP_TIMEOUT_MS = 30_000;
150
+ const LOGIN_TIMEOUT_MS = 180_000;
151
+ /** Operator recovery command every session failure names — never a cookie, DOM dump or stack. */
152
+ export const CSDN_LOGIN_INSTRUCTION = 'bun plugins/publishings/csdn-pub/src/profile-check.ts login';
153
+ /**
154
+ * Category cap: the observed CSDN publish-modal UI allows at most three 分类专栏 (2026-10-14
155
+ * editor bundle, task 0292 Design); excess is refused, not sliced.
156
+ */
157
+ const MAX_CATEGORIES = 3;
158
+ /** Cover extensions (frozen payload rules: an existing local PNG/JPEG/WebP file; single slot per csdn.js:223). */
159
+ const COVER_EXTS = new Set(['.png', '.jpg', '.jpeg', '.webp']);
160
+ /**
161
+ * Persisted article-receipt URL shapes. `mp_blog/creation/editor/<id>` is grounded — the
162
+ * extension's own post-save redirect target carrying the persisted article id (csdn.js:340);
163
+ * `/article/details/<id>` is the public article page shape, **Provisional** (the API adapter
164
+ * never visits the public page). Only shapes in this list count as receipts.
165
+ */
166
+ const RECEIPT_URL_RES = [/mp\.csdn\.net\/mp_blog\/creation\/editor\/\d+/, /\/article\/details\/\d+/];
167
+ /**
168
+ * Persisted draft-receipt URL shapes (draft leg only): the public receipt shapes plus the editor
169
+ * URL gaining an `articleId` param (the frozen draft save's fresh id gain, task 0292 Design). Only
170
+ * an id-bearing URL confirms a draft save — a toast-only or otherwise idless state never does.
171
+ */
172
+ const DRAFT_RECEIPT_URL_RES = [/editor\.csdn\.net\/md\/\?[^\s]*articleId=\d+/, ...RECEIPT_URL_RES];
173
+ /**
174
+ * The draft save control (frozen task 0292 Design): the editor's top-level
175
+ * `button.button-save[data-title="保存草稿"]`. A draft run activates only this draft-labeled
176
+ * action — after modal field prep it still never clicks the modal's final public submit — and the
177
+ * vendor's signed status-blind saveArticle API (csdn.js:183-248) is never called.
178
+ */
179
+ export const DRAFT_SAVE_SELECTORS: readonly string[] = ['button.button-save[data-title="保存草稿"]'];
180
+ /** Success toast words. **Provisional**: csdn.js is an API adapter and never reads a site toast; the words mirror its success vocabulary (csdn.js:242, 339). */
181
+ const SUCCESS_TOAST_RE = /发布成功|文章保存成功/;
182
+ /** Failure toast words. **Provisional**, same grounding (csdn.js:245); a fresh failure toast fails fast. */
183
+ const FAILURE_TOAST_RE = /发布失败|文章保存失败|上传失败/;
184
+ /** Toast containers. **Provisional** (csdn.js never reads one); jike-pub's probe shape. */
185
+ const TOAST_CONTAINERS = '[role="alert"], [role="status"], [class*="toast" i], [class*="message" i]';
186
+
187
+ function fail(error: string): Result {
188
+ return ResultSchema.parse({ ok: false, target: TARGET, error: boundedDetail(error) });
189
+ }
190
+
191
+ /** A browser-stage failure: `ok: false` with `stage` (and optional evidence) in the Result metadata. */
192
+ function stageFail(error: string, stage: string, metadata: Record<string, unknown>, evidence?: string): Result {
193
+ return ResultSchema.parse({
194
+ ok: false,
195
+ target: TARGET,
196
+ error: boundedDetail(error),
197
+ metadata: { ...metadata, stage, ...(evidence === undefined ? {} : { evidence: boundedDetail(evidence) }) },
198
+ });
199
+ }
200
+
201
+ /** One short line, bounded: no DOM dump, cookie, token or stack ever reaches the Result. */
202
+ function boundedDetail(detail: string): string {
203
+ return detail.replace(/\s+/g, ' ').trim().slice(0, 300);
204
+ }
205
+
206
+ /** A bounded, single-line rendering of an operator-supplied value for a refusal message. */
207
+ function bounded(value: unknown): string {
208
+ if (Array.isArray(value)) return `${value.length} entr${value.length === 1 ? 'y' : 'ies'}`;
209
+ return String(value).replace(/\s+/g, ' ').slice(0, 100);
210
+ }
211
+
212
+ function errorMessage(error: unknown): string {
213
+ return error instanceof Error ? error.message : String(error);
214
+ }
215
+
216
+ /**
217
+ * Resolves the requested operation. An undeclared value is refused by name, before any transport —
218
+ * and therefore before any browser — is resolved (the source handles article only, csdn.js:249-255).
219
+ * @param options - Per-call publish options (`operation`).
220
+ * @param content - Content whose `metadata.operation` is the fallback source.
221
+ * @returns The resolved operation, or the refusal message.
222
+ */
223
+ export function resolveOperation(
224
+ options?: Record<string, unknown>,
225
+ content?: Content,
226
+ ): { operation: CsdnOperation; error?: undefined } | { operation?: undefined; error: string } {
227
+ const raw = options?.operation ?? content?.metadata?.operation ?? 'article';
228
+ if (typeof raw !== 'string' || !(CSDN_OPERATIONS as readonly string[]).includes(raw)) {
229
+ return {
230
+ error: `unsupported operation '${bounded(raw)}' for csdn: expected one of ${CSDN_OPERATIONS.join(', ')}`,
231
+ };
232
+ }
233
+ return { operation: raw as CsdnOperation };
234
+ }
235
+
236
+ /**
237
+ * Resolves the driver from `options.driver ?? 'script'`. This channel ships one driver and no
238
+ * `agent` path, so anything else — including a wrong type — is refused by name before a transport
239
+ * is built.
240
+ * @param options - Per-call publish options (`driver`).
241
+ * @returns The resolved driver, or the refusal message.
242
+ */
243
+ function resolveDriver(
244
+ options?: Record<string, unknown>,
245
+ ): { driver: BrowserDriver; error?: undefined } | { driver?: undefined; error: string } {
246
+ const raw = options?.driver ?? 'script';
247
+ if (raw !== 'script') {
248
+ return {
249
+ error: `unsupported driver '${bounded(raw)}' for csdn: expected one of script (the csdn-pub browser flow has no agent path)`,
250
+ };
251
+ }
252
+ return { driver: 'script' };
253
+ }
254
+
255
+ /**
256
+ * Shape-check for optional exact-name lists (`metadata.tags` / `metadata.categories`): an array of
257
+ * non-empty names, refused — never sliced or guessed — when wrong-typed, empty-entry-ed, or (for
258
+ * categories) over the observed modal cap. Exact chip matching against the publish modal happens
259
+ * in the Chunk B flow; this gate only keeps wrong shapes from reaching a browser.
260
+ */
261
+ function resolveNameList(
262
+ field: 'tags' | 'categories',
263
+ raw: unknown,
264
+ max?: number,
265
+ ): { names: string[] } | { error: string } {
266
+ if (raw === undefined) return { names: [] };
267
+ if (!Array.isArray(raw)) {
268
+ return { error: `metadata.${field} must be an array of non-empty exact names, got '${bounded(raw)}'` };
269
+ }
270
+ if (max !== undefined && raw.length > max) {
271
+ return {
272
+ error: `metadata.${field} requests ${raw.length} entries, over the observed CSDN publish-modal limit of ${max}; refusing rather than truncating`,
273
+ };
274
+ }
275
+ const names: string[] = [];
276
+ for (const entry of raw) {
277
+ if (typeof entry !== 'string' || entry.trim().length === 0) {
278
+ return { error: `metadata.${field} entries must be non-empty exact names, got '${bounded(entry)}'` };
279
+ }
280
+ names.push(entry.trim());
281
+ }
282
+ return { names };
283
+ }
284
+
285
+ /**
286
+ * Resolves the optional cover (`options.coverPath ?? metadata.coverPath`) without uploading:
287
+ * Chunk A validates the payload — a non-empty local PNG/JPEG/WebP path (frozen payload rules;
288
+ * the vendor model has a single cover slot, csdn.js:223) — and Chunk B performs the upload at the
289
+ * named seam. No size cap is asserted: no dated primary source states one for this channel.
290
+ */
291
+ async function resolveCover(
292
+ options?: Record<string, unknown>,
293
+ content?: Content,
294
+ ): Promise<{ path?: string; error?: string }> {
295
+ const raw = options?.coverPath ?? content?.metadata?.coverPath;
296
+ if (raw === undefined) return {};
297
+ if (typeof raw !== 'string' || raw.trim().length === 0) {
298
+ return { error: `metadata.coverPath must be a non-empty local image path, got '${bounded(raw)}'` };
299
+ }
300
+ const path = raw.trim();
301
+ if (!COVER_EXTS.has(extname(path).toLowerCase())) {
302
+ return {
303
+ error: `metadata.coverPath must be one of ${[...COVER_EXTS].join(', ')} (PNG/JPEG/WebP), got '${path}'`,
304
+ };
305
+ }
306
+ if (!(await createNodeFileSystem().exists(path))) {
307
+ return { error: `metadata.coverPath file does not exist: '${path}'` };
308
+ }
309
+ return { path };
310
+ }
311
+
312
+ /**
313
+ * Csdn-owned readiness predicate: no login redirect and the site-owned Markdown editor reachable
314
+ * (the grounded title input). Shared by the publish preflight and the operator profile CLI, so
315
+ * both apply one gate. The login-redirect regex is provisional (see `CSDN_LOGIN_URL_RE`); the
316
+ * title-input probe carries the real gate.
317
+ */
318
+ export const isCsdnAuthenticated: IsAuthenticatedPredicate = async (page) => {
319
+ if (CSDN_LOGIN_URL_RE.test(page.url())) return false;
320
+ return (await trySelectors(page, CSDN_ARTICLE_SELECTORS.titleInput, { visible: false })).found;
321
+ };
322
+
323
+ /**
324
+ * Site-owned config for the shared session check and operator CLI (ADR-032). The check opens the
325
+ * editor entry (see `CSDN_EDITOR_URL`). The publish preflight never forwards `checkHeadedOnly`:
326
+ * it is the operator CLI's setting, and a refusal must never escalate to a headed window on its own.
327
+ */
328
+ export const CSDN_CONFIG: ChannelBrowserConfig = {
329
+ channel: TARGET,
330
+ homeUrl: CSDN_EDITOR_URL,
331
+ isAuthenticated: isCsdnAuthenticated,
332
+ loginTimeoutMs: LOGIN_TIMEOUT_MS,
333
+ checkHeadedOnly: false,
334
+ };
335
+
336
+ /**
337
+ * CSDN browser transport on the shared rails (ADR-027): a headless session preflight, one headed
338
+ * flow, and success only from an observed persisted receipt. publishStatus absent or 'draft' takes
339
+ * the draft leg (top-level 保存草稿, `DRAFT_SAVE_SELECTORS`); 'public' publishes live; any other
340
+ * value refuses before the browser.
341
+ */
342
+ export class CsdnBrowserTransport implements PublishTransport {
343
+ public readonly kind = 'browser';
344
+ public readonly driver: BrowserDriver;
345
+ private readonly operation: CsdnOperation;
346
+ private readonly launcher: BrowserLauncher;
347
+ private readonly stepTimeoutMs: number;
348
+ constructor(
349
+ operation: CsdnOperation = 'article',
350
+ driver: BrowserDriver = 'script',
351
+ deps: { launcher?: BrowserLauncher; stepTimeoutMs?: number } = {},
352
+ ) {
353
+ this.operation = operation;
354
+ this.driver = driver;
355
+ this.launcher = deps.launcher ?? kkBrowserLauncher;
356
+ this.stepTimeoutMs = deps.stepTimeoutMs ?? STEP_TIMEOUT_MS;
357
+ }
358
+
359
+ /**
360
+ * Publishes one Content as a live CSDN Markdown article.
361
+ * @param payload - Target content plus per-call options.
362
+ * @returns The publish `Result`, `ok: true` only from an observed site receipt.
363
+ */
364
+ public async publish(payload: PublishTransportPayload): Promise<Result> {
365
+ // Refusal order (all pre-browser): driver → publishStatus → title → body → tags →
366
+ // categories → cover → session preflight. (The operation is resolved and refused in
367
+ // `resolveOperation` before any transport exists.)
368
+ if (this.driver !== 'script') {
369
+ return fail(
370
+ `unsupported driver '${bounded(this.driver)}' for csdn: expected one of script (the csdn-pub browser flow has no agent path)`,
371
+ );
372
+ }
373
+ const requested = payload.content.metadata?.publishStatus;
374
+ if (requested !== undefined && requested !== 'draft' && requested !== 'public') {
375
+ return fail(
376
+ `unknown publishStatus '${bounded(requested)}' for csdn: expected 'public', 'draft' or absent (absent defaults to draft)`,
377
+ );
378
+ }
379
+ const publishStatus = requested === 'public' ? 'public' : 'draft';
380
+ const title = payload.content.title?.trim() ?? '';
381
+ if (title.length === 0) {
382
+ return fail(
383
+ 'csdn article requires a non-empty Content.title (the Markdown editor has an independent title field)',
384
+ );
385
+ }
386
+ const body = payload.content.body.trim();
387
+ if (body.length === 0) {
388
+ return fail(
389
+ 'csdn article requires non-empty Content.body Markdown (the article body is its own saved field, csdn.js:204-205)',
390
+ );
391
+ }
392
+ const tags = resolveNameList('tags', payload.options?.tags ?? payload.content.metadata?.tags);
393
+ if ('error' in tags) return fail(tags.error);
394
+ const categories = resolveNameList(
395
+ 'categories',
396
+ payload.options?.categories ?? payload.content.metadata?.categories,
397
+ MAX_CATEGORIES,
398
+ );
399
+ if ('error' in categories) return fail(categories.error);
400
+ const cover = await resolveCover(payload.options, payload.content);
401
+ if (cover.error !== undefined) return fail(cover.error);
402
+
403
+ const override = typeof payload.options?.profileDir === 'string' ? payload.options.profileDir : undefined;
404
+ const profileDir = resolveChannelProfileDir(TARGET, override);
405
+
406
+ // ADR-025 preflight: an unauthenticated or absent profile must never produce a headed visit,
407
+ // so the read-only headless check runs first and a refusal stays a fast refusal.
408
+ const session = await checkChannelSession(CSDN_CONFIG, {
409
+ profileDir,
410
+ launcher: this.launcher,
411
+ headedFallback: false,
412
+ timeoutMs: this.stepTimeoutMs,
413
+ });
414
+ if (session.status !== 'verified') {
415
+ // The recovery command leads the message: `fail()` bounds the line, and the operator's
416
+ // next action must never be the part that gets truncated.
417
+ return fail(
418
+ `${TARGET} session preflight refused — run: ${CSDN_LOGIN_INSTRUCTION} — detail: ${session.detail}`,
419
+ );
420
+ }
421
+
422
+ try {
423
+ return await withProfilePage(
424
+ {
425
+ profileDir,
426
+ url: CSDN_EDITOR_URL,
427
+ isAuthenticated: isCsdnAuthenticated,
428
+ // Headed by default; the headless preflight above is the unattended pass and
429
+ // `options.headless === true` the opt-in.
430
+ headless: payload.options?.headless === true,
431
+ launcher: this.launcher,
432
+ // The session is already preflight-verified, so the post-goto confirmation is a
433
+ // bounded step like every other one, not the 180s interactive login wait.
434
+ timeoutMs: this.stepTimeoutMs,
435
+ },
436
+ async ({ page }) =>
437
+ this.runArticleFlow(
438
+ page,
439
+ title,
440
+ tags.names,
441
+ categories.names,
442
+ cover.path,
443
+ body,
444
+ profileDir,
445
+ publishStatus,
446
+ ),
447
+ );
448
+ } catch (error) {
449
+ // Same ordering rule as the preflight refusal: the recovery command is never the tail
450
+ // that a bounded message drops.
451
+ const detail = errorMessage(error);
452
+ return fail(
453
+ needsLogin(error)
454
+ ? `${TARGET} session failed — run: ${CSDN_LOGIN_INSTRUCTION} — detail: ${detail}`
455
+ : detail,
456
+ );
457
+ }
458
+ }
459
+
460
+ /**
461
+ * The live article flow on the already-navigated editor page (`CSDN_EDITOR_URL`): read-only
462
+ * capability probe → title/body fill with strict readback → status branch. Public runs:
463
+ * publish-modal tag/category exact commits, cover upload, baselines, scoped public submit,
464
+ * fresh persisted receipt. Draft runs (absent or 'draft' publishStatus) enter the modal only
465
+ * to commit requested fields, then activate only the top-level 保存草稿 action and confirm a
466
+ * fresh draft id — the modal's final public submit is never clicked and the vendor's
467
+ * status-blind signed saveArticle API (csdn.js:183-248) is never called.
468
+ */
469
+ private async runArticleFlow(
470
+ page: Page,
471
+ title: string,
472
+ tags: readonly string[],
473
+ categories: readonly string[],
474
+ coverPath: string | undefined,
475
+ body: string,
476
+ profileDir: string,
477
+ publishStatus: 'draft' | 'public',
478
+ ): Promise<Result> {
479
+ const metadata = { operation: this.operation, driver: this.driver, publishStatus, profileDir };
480
+
481
+ // Read-only capability probe: visibility only — nothing is typed, uploaded or submitted,
482
+ // so a refusal here can never leave a half-filled form behind. The grounded pair must be
483
+ // visible (else the page is wrong or logged out); provisional controls are only recorded,
484
+ // since the publish modal is closed at probe time by design.
485
+ let probe: EditorProbe;
486
+ try {
487
+ probe = await this.probeEditor(page);
488
+ } catch (error) {
489
+ return stageFail(
490
+ `csdn editor controls not found on ${CSDN_EDITOR_URL} — run: ${CSDN_LOGIN_INSTRUCTION} — detail: ${errorMessage(error)}`,
491
+ 'editor-readiness',
492
+ metadata,
493
+ );
494
+ }
495
+ if (!probe.grounded.titleInput || !probe.grounded.bodyEditor) {
496
+ return stageFail(
497
+ `csdn grounded editor controls found but not visible on ${CSDN_EDITOR_URL} — run: ${CSDN_LOGIN_INSTRUCTION}`,
498
+ 'editor-readiness',
499
+ metadata,
500
+ `grounded visible: ${JSON.stringify(probe.grounded)}; provisional visible: ${JSON.stringify(probe.provisional)}`,
501
+ );
502
+ }
503
+ const flowMetadata = { ...metadata, probe };
504
+
505
+ // Title: activate the display first (task 0292 Design — the title input is not editable
506
+ // until clicked), native fill, then strict readback of the input's value property; the
507
+ // title is the article's primary field, vendor-bounded to 100 chars (csdn.js:202).
508
+ try {
509
+ const displaySelector = await resolveSelector(page, CSDN_ARTICLE_SELECTORS.titleDisplay, {
510
+ timeoutMs: this.stepTimeoutMs,
511
+ label: 'csdn title display',
512
+ });
513
+ await page.locator(displaySelector).first().click();
514
+ const titleSelector = await resolveSelector(page, CSDN_ARTICLE_SELECTORS.titleInput, {
515
+ timeoutMs: this.stepTimeoutMs,
516
+ label: 'csdn title input',
517
+ });
518
+ await page.locator(titleSelector).first().fill(title);
519
+ const readback = await expectOutcome(
520
+ async () =>
521
+ normalizeText(String(await page.evaluate(inputValueScript(titleSelector)))) === normalizeText(title)
522
+ ? 'title read back'
523
+ : null,
524
+ { timeoutMs: this.stepTimeoutMs, label: 'csdn title readback' },
525
+ );
526
+ if (!readback.ok)
527
+ return stageFail(
528
+ `csdn title readback failed — the editor did not retain the requested title — ${readback.error}`,
529
+ 'title-readback',
530
+ flowMetadata,
531
+ );
532
+ } catch (error) {
533
+ return stageFail(`csdn title fill failed — ${errorMessage(error)}`, 'title-readback', flowMetadata);
534
+ }
535
+
536
+ // Body: Playwright fill() dispatches real input events on the cledit-managed pre (task
537
+ // 0292 Design forbids innerHTML assignment); the body is its own saved field
538
+ // (csdn.js:204-205), so a dropped body must refuse before anything is submitted.
539
+ try {
540
+ const bodySelector = await resolveSelector(page, CSDN_ARTICLE_SELECTORS.bodyEditor, {
541
+ timeoutMs: this.stepTimeoutMs,
542
+ label: 'csdn Markdown body editor',
543
+ });
544
+ await page.locator(bodySelector).first().fill(body);
545
+ const readback = await expectOutcome(
546
+ async () =>
547
+ normalizeText(String(await page.evaluate(editorTextScript(bodySelector)))) === normalizeText(body)
548
+ ? 'body read back'
549
+ : null,
550
+ { timeoutMs: this.stepTimeoutMs, label: 'csdn body readback' },
551
+ );
552
+ if (!readback.ok)
553
+ return stageFail(
554
+ `csdn body readback failed — the editor did not retain the requested Markdown — ${readback.error}`,
555
+ 'body-readback',
556
+ flowMetadata,
557
+ );
558
+ } catch (error) {
559
+ return stageFail(`csdn body fill failed — ${errorMessage(error)}`, 'body-readback', flowMetadata);
560
+ }
561
+
562
+ // The publish modal carries tags/categories/cover and the final submit (task 0292 Design:
563
+ // `.modal__publish-article`); it opens only through the scoped 发布文章 entry — the entry
564
+ // node is provisional (see `publishOpen`), so absence refuses before any field is touched.
565
+ // A draft run enters it only to commit requested fields (its submit is never clicked); with
566
+ // nothing requested the modal is never opened at all.
567
+ const needsFieldPrep = tags.length > 0 || categories.length > 0 || coverPath !== undefined;
568
+ if (publishStatus === 'public' || needsFieldPrep) {
569
+ try {
570
+ const openSelector = await resolveSelector(page, CSDN_ARTICLE_SELECTORS.publishOpen, {
571
+ timeoutMs: this.stepTimeoutMs,
572
+ label: 'csdn 发布文章 entry',
573
+ });
574
+ await page.locator(openSelector).first().click();
575
+ } catch (error) {
576
+ return stageFail(
577
+ `csdn publish-modal entry not found — ${errorMessage(error)}`,
578
+ 'publish-open',
579
+ flowMetadata,
580
+ );
581
+ }
582
+ }
583
+
584
+ // Tags: exact committed chips only — the vendor's fixed 经验分享 tag (csdn.js:206) is never
585
+ // substituted, and an unconfirmed tag refuses the submit.
586
+ if (tags.length > 0) {
587
+ const committed = await this.commitTags(page, tags);
588
+ if (!committed.ok) return stageFail(committed.error, 'tag-commit', flowMetadata);
589
+ }
590
+
591
+ // Categories: exact options only — the vendor silently sent empty categories (csdn.js:207),
592
+ // which is precisely the silent fallback this channel refuses.
593
+ if (categories.length > 0) {
594
+ const selected = await this.selectCategories(page, categories);
595
+ if (!selected.ok) return stageFail(selected.error, 'category-select', flowMetadata);
596
+ }
597
+
598
+ // Cover: the single cover slot (csdn.js:223) is filled through the real file input; an
599
+ // unconfirmed upload refuses before submit (never a text-only publish).
600
+ if (coverPath !== undefined) {
601
+ const uploaded = await this.uploadCover(page, coverPath);
602
+ if (!uploaded.ok) return stageFail(uploaded.error, 'cover-upload', flowMetadata);
603
+ }
604
+
605
+ // Draft leg: only the top-level draft-labeled action, never the modal's public submit.
606
+ if (publishStatus === 'draft') return this.saveDraft(page, flowMetadata, profileDir);
607
+
608
+ // Baselines before submit: only a receipt that was not on screen before the click can
609
+ // confirm (fresh-signal rule; URL shapes grounded csdn.js:340) — stale or absent never passes.
610
+ const toastsBefore = new Set(await this.readToasts(page));
611
+ const beforeReceipts = new Set(await this.readReceiptUrls(page));
612
+
613
+ // Submit: the modal's final public submit. Wait enabled first — a disabled control is a
614
+ // state the site owns, not something to click through.
615
+ const publishSelector = await resolveSelector(page, CSDN_ARTICLE_SELECTORS.publishButton, {
616
+ timeoutMs: this.stepTimeoutMs,
617
+ label: 'csdn 发布 button',
618
+ });
619
+ const publish = page.locator(publishSelector).first();
620
+ const enabled = await expectOutcome(async () => ((await publish.isEnabled()) ? 'enabled' : null), {
621
+ timeoutMs: this.stepTimeoutMs,
622
+ label: 'csdn 发布 button enabled',
623
+ });
624
+ if (!enabled.ok)
625
+ return stageFail(
626
+ `csdn 发布 button never became clickable — ${enabled.error}`,
627
+ 'send-enabled',
628
+ flowMetadata,
629
+ );
630
+ await publish.click();
631
+
632
+ // Confirmation: a failure toast fails fast; otherwise a fresh persisted receipt (article
633
+ // URL or fresh success toast). Stale/absent carries the on-screen receipt snapshot —
634
+ // never "editor cleared", which alone is not a receipt (task 0292 Design).
635
+ const confirmed = await expectOutcome(() => this.probeConfirmation(page, beforeReceipts, toastsBefore), {
636
+ timeoutMs: this.stepTimeoutMs,
637
+ label: 'csdn publish confirmation',
638
+ });
639
+ if (!confirmed.ok) {
640
+ return stageFail(
641
+ 'csdn publish not confirmed — no fresh persisted receipt appeared',
642
+ 'confirmation',
643
+ flowMetadata,
644
+ await this.receiptSnapshot(page),
645
+ );
646
+ }
647
+ if (confirmed.evidence.startsWith('failure-toast: ')) {
648
+ return stageFail(
649
+ `csdn publish failed — site reported: ${confirmed.evidence.slice('failure-toast: '.length)}`,
650
+ 'failure-toast',
651
+ flowMetadata,
652
+ );
653
+ }
654
+ const url = confirmed.evidence.startsWith('article-url: ')
655
+ ? confirmed.evidence.slice('article-url: '.length)
656
+ : undefined;
657
+ return ResultSchema.parse({
658
+ ok: true,
659
+ target: TARGET,
660
+ ...(url === undefined ? {} : { id: articleIdFrom(url), url }),
661
+ message: `csdn article published live in profile ${profileDir}${url === undefined ? ' (success-toast receipt; no article URL read back)' : ''}`,
662
+ metadata: flowMetadata,
663
+ });
664
+ }
665
+
666
+ /**
667
+ * Read-only visibility probe. The grounded pair (`titleInput`/`bodyEditor`, 2026-10-14 editor
668
+ * bundle) must attach, and its visibility is asserted by the caller; the provisional controls
669
+ * (`composer`, modal fields, submit) are only recorded — the modal is closed at probe time by
670
+ * design, so their absence is expected, never a refusal.
671
+ */
672
+ private async probeEditor(page: Page): Promise<EditorProbe> {
673
+ const grounded: Record<string, boolean> = {};
674
+ for (const key of ['titleInput', 'bodyEditor'] as const) {
675
+ const selector = await resolveSelector(page, CSDN_ARTICLE_SELECTORS[key], {
676
+ timeoutMs: this.stepTimeoutMs,
677
+ label: `csdn ${key}`,
678
+ });
679
+ grounded[key] = (
680
+ await trySelectors(page, [selector], { visible: true, timeout: this.stepTimeoutMs })
681
+ ).found;
682
+ }
683
+ const provisional: Record<string, boolean> = {};
684
+ for (const key of ['composer', 'tagInput', 'categoryControl', 'coverInput', 'publishButton'] as const) {
685
+ // Short visibility probe: these live in the publish modal, which is closed here.
686
+ provisional[key] = (
687
+ await trySelectors(page, [...CSDN_ARTICLE_SELECTORS[key]], { visible: true, timeout: 2_000 })
688
+ ).found;
689
+ }
690
+ return { grounded, provisional };
691
+ }
692
+
693
+ /**
694
+ * Commits each requested tag and verifies the exact chip in the modal (task 0292 R2). The
695
+ * input node and Enter-commit gesture are **Provisional** (see `tagInput`); no fuzzy or
696
+ * first-item chip fallback — an unverified tag refuses the submit.
697
+ */
698
+ private async commitTags(
699
+ page: Page,
700
+ tags: readonly string[],
701
+ ): Promise<{ ok: true; evidence: string } | { ok: false; error: string }> {
702
+ try {
703
+ const inputSelector = await resolveSelector(page, CSDN_ARTICLE_SELECTORS.tagInput, {
704
+ timeoutMs: this.stepTimeoutMs,
705
+ label: 'csdn tag input',
706
+ });
707
+ const input = page.locator(inputSelector).first();
708
+ await input.click();
709
+ const committed: string[] = [];
710
+ for (const tag of tags) {
711
+ await input.fill(tag);
712
+ await input.press('Enter');
713
+ const chip = await expectOutcome(
714
+ async () => {
715
+ const hit = String(await page.evaluate(modalExactTextScript(tag))).trim();
716
+ return hit.length > 0 ? hit : null;
717
+ },
718
+ { timeoutMs: this.stepTimeoutMs, label: `csdn tag chip 「${bounded(tag)}」 exact match` },
719
+ );
720
+ if (!chip.ok) {
721
+ return {
722
+ ok: false,
723
+ error: `csdn did not show a committed tag chip 「${bounded(tag)}」 in .modal__publish-article; refusing to submit without the requested tags`,
724
+ };
725
+ }
726
+ committed.push(tag);
727
+ }
728
+ return { ok: true, evidence: `${committed.length} tag chip(s) verified` };
729
+ } catch (error) {
730
+ return { ok: false, error: `csdn tag commit failed — ${errorMessage(error)}` };
731
+ }
732
+ }
733
+
734
+ /**
735
+ * Selects each requested category by exact option text and verifies it persisted in the
736
+ * control's value. Option nodes and the persisted readback are **Provisional** (see
737
+ * `categoryControl`); no substituted or empty category ever passes (csdn.js:207).
738
+ */
739
+ private async selectCategories(
740
+ page: Page,
741
+ categories: readonly string[],
742
+ ): Promise<{ ok: true; evidence: string } | { ok: false; error: string }> {
743
+ try {
744
+ const controlSelector = await resolveSelector(page, CSDN_ARTICLE_SELECTORS.categoryControl, {
745
+ timeoutMs: this.stepTimeoutMs,
746
+ label: 'csdn category control',
747
+ });
748
+ const control = page.locator(controlSelector).first();
749
+ const selected: string[] = [];
750
+ for (const category of categories) {
751
+ await control.click(); // opens the category dropdown
752
+ const clicked = await expectOutcome(
753
+ async () => String(await page.evaluate(clickExactTextScript(category))).trim(),
754
+ {
755
+ timeoutMs: this.stepTimeoutMs,
756
+ label: `csdn category option 「${bounded(category)}」 exact match`,
757
+ },
758
+ );
759
+ if (!clicked.ok) {
760
+ return {
761
+ ok: false,
762
+ error: `csdn did not offer a category option 「${bounded(category)}」; refusing to submit with a substituted or empty category (csdn.js:207)`,
763
+ };
764
+ }
765
+ const persisted = await expectOutcome(
766
+ async () =>
767
+ normalizeText(String(await page.evaluate(inputValueScript(controlSelector)))) ===
768
+ normalizeText(category)
769
+ ? 'category persisted'
770
+ : null,
771
+ { timeoutMs: this.stepTimeoutMs, label: 'csdn category persisted readback' },
772
+ );
773
+ if (!persisted.ok) {
774
+ return {
775
+ ok: false,
776
+ error: `csdn category 「${bounded(category)}」 did not persist in the category control; refusing to submit`,
777
+ };
778
+ }
779
+ selected.push(category);
780
+ }
781
+ return { ok: true, evidence: `${selected.length} category selection(s) persisted` };
782
+ } catch (error) {
783
+ return { ok: false, error: `csdn category select failed — ${errorMessage(error)}` };
784
+ }
785
+ }
786
+
787
+ /**
788
+ * Uploads the cover through the modal-scoped file input (harness `uploadFile`/setInputFiles —
789
+ * Playwright's CDP path, no clipboard bridge) and waits for the upload to settle plus fresh
790
+ * preview evidence. The input and preview nodes are **Provisional** (see `coverInput`); the
791
+ * vendor's OBS direct-upload protocol and signing keys (csdn.js:32-41) are never used.
792
+ */
793
+ private async uploadCover(
794
+ page: Page,
795
+ coverPath: string,
796
+ ): Promise<{ ok: true; evidence: string } | { ok: false; error: string }> {
797
+ try {
798
+ const before = Number(await page.evaluate(modalMediaCountScript()));
799
+ const inputSelector = await resolveSelector(page, CSDN_ARTICLE_SELECTORS.coverInput, {
800
+ timeoutMs: this.stepTimeoutMs,
801
+ label: 'csdn cover file input',
802
+ });
803
+ await uploadFile(page, inputSelector, coverPath, { timeoutMs: this.stepTimeoutMs });
804
+ const settled = await expectOutcome(
805
+ async () =>
806
+ String(await page.evaluate(modalUploadingStatusScript())).length > 0
807
+ ? null
808
+ : 'cover upload settled',
809
+ { timeoutMs: this.stepTimeoutMs, label: 'csdn cover upload settle' },
810
+ );
811
+ if (!settled.ok) return { ok: false, error: `csdn cover upload did not settle — ${settled.error}` };
812
+ const preview = await expectOutcome(
813
+ async () => {
814
+ const count = Number(await page.evaluate(modalMediaCountScript()));
815
+ return count > before ? `modal media ${before} → ${count}` : null;
816
+ },
817
+ { timeoutMs: this.stepTimeoutMs, label: 'csdn cover preview evidence' },
818
+ );
819
+ if (!preview.ok) {
820
+ return {
821
+ ok: false,
822
+ error: `csdn cover preview never appeared after upload (baseline ${before} media nodes in .modal__publish-article)`,
823
+ };
824
+ }
825
+ return { ok: true, evidence: preview.evidence };
826
+ } catch (error) {
827
+ return { ok: false, error: `csdn cover upload failed — ${errorMessage(error)}` };
828
+ }
829
+ }
830
+
831
+ /** Visible toast texts (provisional containers, see `TOAST_CONTAINERS`) — the pre-send baseline. */
832
+ private async readToasts(page: Page): Promise<readonly string[]> {
833
+ return parseStringArray(await page.evaluate(toastTextsScript()));
834
+ }
835
+
836
+ /** A toast carrying `pattern` that was not already on screen before the submit. */
837
+ private async findFreshToast(page: Page, pattern: RegExp, before: ReadonlySet<string>): Promise<string | null> {
838
+ return (await this.readToasts(page)).find((text) => !before.has(text) && pattern.test(text)) ?? null;
839
+ }
840
+
841
+ /** Receipt-shaped URLs currently on screen (location + anchors) — the submit-time baseline. */
842
+ private async readReceiptUrls(
843
+ page: Page,
844
+ patterns: readonly RegExp[] = RECEIPT_URL_RES,
845
+ ): Promise<readonly string[]> {
846
+ return parseStringArray(await page.evaluate(receiptUrlsScript(patterns)));
847
+ }
848
+
849
+ /** One confirmation probe per poll: failure toast fails fast; then fresh receipt URL; then fresh success toast. */
850
+ private async probeConfirmation(
851
+ page: Page,
852
+ beforeReceipts: ReadonlySet<string>,
853
+ toastsBefore: ReadonlySet<string>,
854
+ ): Promise<string | null> {
855
+ const failure = await this.findFreshToast(page, FAILURE_TOAST_RE, toastsBefore);
856
+ if (failure !== null) return `failure-toast: ${failure}`;
857
+ const url = (await this.readReceiptUrls(page)).find((candidate) => !beforeReceipts.has(candidate));
858
+ if (url !== undefined) return `article-url: ${url}`;
859
+ const success = await this.findFreshToast(page, SUCCESS_TOAST_RE, toastsBefore);
860
+ return success === null ? null : `success-toast: ${success}`;
861
+ }
862
+
863
+ /** Bounded on-screen snapshot for an unconfirmed submit — no DOM dump, ever. */
864
+ private async receiptSnapshot(page: Page): Promise<string> {
865
+ const receipts = await this.readReceiptUrls(page);
866
+ const toasts = await this.readToasts(page);
867
+ return `still on ${page.url()}; ${receipts.length} receipt-shaped url(s), ${toasts.length} toast(s) on screen`;
868
+ }
869
+
870
+ /**
871
+ * The draft save leg (frozen task 0292 Design): activate only the top-level 保存草稿 control
872
+ * (`DRAFT_SAVE_SELECTORS`) — never the modal's final public submit, never the vendor's signed
873
+ * status-blind saveArticle API (csdn.js:183-248). Confirmation is only a FRESH persisted draft
874
+ * receipt (the editor URL gaining an `articleId` param, or the mp_blog editor shape) that was
875
+ * not on screen before the click; stale toasts and toast-only idless states never confirm.
876
+ */
877
+ private async saveDraft(page: Page, metadata: Record<string, unknown>, profileDir: string): Promise<Result> {
878
+ const selector = await resolveSelector(page, DRAFT_SAVE_SELECTORS, {
879
+ timeoutMs: this.stepTimeoutMs,
880
+ label: 'csdn 保存草稿 button',
881
+ });
882
+ const button = page.locator(selector).first();
883
+ const enabled = await expectOutcome(async () => ((await button.isEnabled()) ? 'enabled' : null), {
884
+ timeoutMs: this.stepTimeoutMs,
885
+ label: 'csdn 保存草稿 button enabled',
886
+ });
887
+ if (!enabled.ok)
888
+ return stageFail(
889
+ `csdn 保存草稿 button never became clickable — ${enabled.error}`,
890
+ 'draft-enabled',
891
+ metadata,
892
+ );
893
+ // Baselines before the click: only a receipt absent beforehand can confirm (fresh-signal rule).
894
+ const toastsBefore = new Set(await this.readToasts(page));
895
+ const beforeReceipts = new Set(await this.readReceiptUrls(page, DRAFT_RECEIPT_URL_RES));
896
+ await button.click();
897
+ const confirmed = await expectOutcome(() => this.probeDraftConfirmation(page, beforeReceipts, toastsBefore), {
898
+ timeoutMs: this.stepTimeoutMs,
899
+ label: 'csdn draft save confirmation',
900
+ });
901
+ if (!confirmed.ok) {
902
+ return stageFail(
903
+ 'csdn draft save not confirmed — no fresh draft id appeared',
904
+ 'draft-save',
905
+ metadata,
906
+ await this.receiptSnapshot(page),
907
+ );
908
+ }
909
+ if (confirmed.evidence.startsWith('failure-toast: ')) {
910
+ return stageFail(
911
+ `csdn draft save failed — site reported: ${confirmed.evidence.slice('failure-toast: '.length)}`,
912
+ 'failure-toast',
913
+ metadata,
914
+ );
915
+ }
916
+ const url = confirmed.evidence.slice('draft-url: '.length);
917
+ return ResultSchema.parse({
918
+ ok: true,
919
+ target: TARGET,
920
+ id: articleIdFrom(url),
921
+ url,
922
+ message: `csdn draft saved in profile ${profileDir}`,
923
+ metadata,
924
+ });
925
+ }
926
+
927
+ /** One draft-confirmation probe: failure toast fails fast; then a fresh id-bearing draft URL. */
928
+ private async probeDraftConfirmation(
929
+ page: Page,
930
+ beforeReceipts: ReadonlySet<string>,
931
+ toastsBefore: ReadonlySet<string>,
932
+ ): Promise<string | null> {
933
+ const failure = await this.findFreshToast(page, FAILURE_TOAST_RE, toastsBefore);
934
+ if (failure !== null) return `failure-toast: ${failure}`;
935
+ // No success-toast acceptance: a toast without an article id is exactly the idless state
936
+ // the frozen draft contract rejects.
937
+ const url = (await this.readReceiptUrls(page, DRAFT_RECEIPT_URL_RES)).find((c) => !beforeReceipts.has(c));
938
+ return url === undefined ? null : `draft-url: ${url}`;
939
+ }
940
+ }
941
+
942
+ /** Session failures whose one-liner must name the recovery command. */
943
+ function needsLogin(error: unknown): boolean {
944
+ return error instanceof LoginTimeoutError || error instanceof BrowserProfileMissingError;
945
+ }
946
+
947
+ /** Probe observation for the Result metadata: which grounded/provisional controls were visible. */
948
+ interface EditorProbe {
949
+ grounded: Record<string, boolean>;
950
+ provisional: Record<string, boolean>;
951
+ }
952
+
953
+ /** Whitespace-insensitive comparison: cledit/editor normalizations must not fail a strict readback. */
954
+ function normalizeText(value: string): string {
955
+ return value.replace(/\s+/g, '');
956
+ }
957
+
958
+ /** Parses a JSON string array from a page probe, tolerating anything else as "no snapshot". */
959
+ function parseStringArray(raw: unknown): readonly string[] {
960
+ try {
961
+ const parsed: unknown = JSON.parse(String(raw));
962
+ return Array.isArray(parsed) ? parsed.map((entry) => String(entry)) : [];
963
+ } catch {
964
+ return [];
965
+ }
966
+ }
967
+
968
+ /** Article id from an observed receipt URL (shapes: csdn.js:340 grounded; `/article/details/` provisional). */
969
+ function articleIdFrom(url: string): string | undefined {
970
+ return /(?:creation\/editor\/|article\/details\/|articleId=)(\d+)/.exec(url)?.[1];
971
+ }
972
+
973
+ /** Input `value` property readback (title input, category control — both are inputs). */
974
+ function inputValueScript(selector: string): string {
975
+ return `(() => { // kk-csdn-input-value
976
+ const el = document.querySelector(${JSON.stringify(selector)});
977
+ return el === null ? '' : String(el.value ?? '');
978
+ })()`;
979
+ }
980
+
981
+ /** Editor pre `textContent` readback (the cledit-managed body, csdn.js:204-205 field semantics). */
982
+ function editorTextScript(selector: string): string {
983
+ return `(() => { // kk-csdn-editor-text
984
+ const el = document.querySelector(${JSON.stringify(selector)});
985
+ return el === null ? '' : String(el.textContent ?? '');
986
+ })()`;
987
+ }
988
+
989
+ /**
990
+ * Exact-text match inside the publish modal (tag chips): only childless elements count, so an
991
+ * input's value or a container wrapping several chips never reads as a committed chip. The chip
992
+ * node is **Provisional** (bundle evidence pins the 文章标签 label, not the chip); `''` = not offered.
993
+ */
994
+ function modalExactTextScript(wanted: string): string {
995
+ return `(() => { // kk-csdn-modal-exact-text
996
+ const wanted = ${JSON.stringify(wanted)};
997
+ const modal = document.querySelector('.modal__publish-article');
998
+ if (modal === null) return '';
999
+ for (const el of modal.querySelectorAll('*')) {
1000
+ if (el.childElementCount > 0) continue;
1001
+ if ((el.textContent || '').trim() === wanted) return wanted;
1002
+ }
1003
+ return '';
1004
+ })()`;
1005
+ }
1006
+
1007
+ /**
1008
+ * Clicks the one category option whose trimmed text equals `wanted`. Options may render in the
1009
+ * modal or a body-level dropdown layer — page chrome (`.article-bar`, `.editor__wrapper`) is
1010
+ * excluded so sidebar lists and the flow's own title/body can never satisfy the match. Both the
1011
+ * option node and the layer scope are **Provisional** (bundle evidence pins 分类专栏 only); `''` =
1012
+ * not offered, and no first-item or fuzzy fallback (the vendor's silent empty category,
1013
+ * csdn.js:207, is the fallback this refuses).
1014
+ */
1015
+ function clickExactTextScript(wanted: string): string {
1016
+ return `(() => { // kk-csdn-category-exact-click
1017
+ const wanted = ${JSON.stringify(wanted)};
1018
+ const scopes = [];
1019
+ const modal = document.querySelector('.modal__publish-article');
1020
+ if (modal !== null) scopes.push(modal);
1021
+ for (const layer of document.querySelectorAll('[class*="popper" i], [class*="dropdown" i]')) {
1022
+ if (layer.closest('.article-bar, .editor__wrapper, .modal__publish-article') === null) scopes.push(layer);
1023
+ }
1024
+ for (const scope of scopes) {
1025
+ for (const el of scope.querySelectorAll('*')) {
1026
+ if (el.childElementCount > 0) continue;
1027
+ if ((el.textContent || '').trim() === wanted) { el.click(); return wanted; }
1028
+ }
1029
+ }
1030
+ return '';
1031
+ })()`;
1032
+ }
1033
+
1034
+ /**
1035
+ * Media-node count inside the publish modal (img with src or a background-image style) — the
1036
+ * cover preview evidence scope; jike-pub's landed-preview predicate rescoped to the modal. The
1037
+ * preview node is **Provisional** (bundle evidence pins the 封面 label, not the preview).
1038
+ */
1039
+ function modalMediaCountScript(): string {
1040
+ return `(() => { // kk-csdn-modal-media-count
1041
+ const modal = document.querySelector('.modal__publish-article');
1042
+ if (modal === null) return 0;
1043
+ return modal.querySelectorAll('img[src], [style*="background-image"]').length;
1044
+ })()`;
1045
+ }
1046
+
1047
+ /** The modal's upload-progress leaf text; `''` when settled. **Provisional** progress wording. */
1048
+ function modalUploadingStatusScript(): string {
1049
+ return `(() => { // kk-csdn-modal-uploading-status
1050
+ const modal = document.querySelector('.modal__publish-article');
1051
+ if (modal === null) return '';
1052
+ const uploading = Array.from(modal.querySelectorAll('*')).find(
1053
+ (el) => el.childElementCount === 0 && (el.textContent || '').includes('上传中'),
1054
+ );
1055
+ return uploading === undefined ? '' : (uploading.textContent || '').trim();
1056
+ })()`;
1057
+ }
1058
+
1059
+ /** Visible toast texts — the pre-submit baseline and the fresh-signal read (provisional containers). */
1060
+ function toastTextsScript(): string {
1061
+ return `(() => { // kk-csdn-toasts
1062
+ const nodes = document.querySelectorAll('${TOAST_CONTAINERS}');
1063
+ const texts = [];
1064
+ for (const node of nodes) {
1065
+ const text = (node.textContent || '').trim();
1066
+ if (text.length > 0) texts.push(text);
1067
+ }
1068
+ return JSON.stringify(texts);
1069
+ })()`;
1070
+ }
1071
+
1072
+ /** Receipt-shaped URLs on screen (location + anchors) — the submit-time baseline and fresh read. */
1073
+ function receiptUrlsScript(patterns: readonly RegExp[]): string {
1074
+ return `(() => { // kk-csdn-receipt-urls
1075
+ const patterns = [${patterns.map((re) => re.toString()).join(', ')}];
1076
+ const matches = (url) => patterns.some((re) => re.test(url));
1077
+ const urls = [];
1078
+ if (matches(location.href)) urls.push(location.href);
1079
+ for (const a of document.querySelectorAll('a[href]')) {
1080
+ if (matches(a.href) && !urls.includes(a.href)) urls.push(a.href);
1081
+ }
1082
+ return JSON.stringify(urls);
1083
+ })()`;
1084
+ }
1085
+
1086
+ /**
1087
+ * Resolves the channel transport: the publish-harness fake under
1088
+ * `KNOWLEDGE_KIT_PUBLISH_TRANSPORT=fake` (offline, no profile, no browser), otherwise the real
1089
+ * CSDN browser transport.
1090
+ * @param operation - Resolved operation (`article`).
1091
+ * @param driver - Resolved driver; only `script` is declared.
1092
+ * @returns The publish transport for this channel.
1093
+ */
1094
+ export function getPublishTransport(
1095
+ operation: CsdnOperation = 'article',
1096
+ driver: BrowserDriver = 'script',
1097
+ ): PublishTransport {
1098
+ if (process.env.KNOWLEDGE_KIT_PUBLISH_TRANSPORT === 'fake') return new FakeBrowserTransport('script');
1099
+ return new CsdnBrowserTransport(operation, driver);
1100
+ }
1101
+
1102
+ /** Writes the Result to `--out`, creating its directory when the caller named a nested path. */
1103
+ async function writePublishResult(outputPath: string, result: Result): Promise<void> {
1104
+ const outDir = dirname(outputPath);
1105
+ if (outDir && outDir !== '.') await createNodeFileSystem().ensureDir(outDir);
1106
+ await createNodeFileSystem().writeFile(outputPath, JSON.stringify(result, null, 2));
1107
+ }
1108
+
1109
+ /**
1110
+ * The plugin's `--in/--out` contract: validate the Content, resolve **and refuse** the operation and
1111
+ * the driver before any transport exists, then publish through the injected or configured transport.
1112
+ * @param inputPath - Path to the Content JSON.
1113
+ * @param outputPath - Path the Result JSON is written to, on success and on refusal.
1114
+ * @param transport - Optional transport injection for tests and offline runs.
1115
+ */
1116
+ export async function processPublishIO(
1117
+ inputPath: string,
1118
+ outputPath: string,
1119
+ transport?: PublishTransport,
1120
+ ): Promise<void> {
1121
+ await createNodeFileSystem().deleteFile(outputPath);
1122
+ let content: Content;
1123
+ let options: Record<string, unknown> | undefined;
1124
+ try {
1125
+ const raw = JSON.parse(await createNodeFileSystem().readFile(inputPath)) as Record<string, unknown>;
1126
+ content = ContentSchema.parse(raw);
1127
+ if (raw.options !== undefined && typeof raw.options === 'object' && raw.options !== null) {
1128
+ options = raw.options as Record<string, unknown>;
1129
+ }
1130
+ } catch (error: unknown) {
1131
+ const message = boundedDetail(`Invalid Content input: ${errorMessage(error)}`);
1132
+ await writePublishResult(outputPath, fail(message));
1133
+ throw new Error(message);
1134
+ }
1135
+ const mode = resolveOperation(options, content);
1136
+ if (mode.error !== undefined) {
1137
+ await writePublishResult(outputPath, fail(mode.error));
1138
+ throw new Error(mode.error);
1139
+ }
1140
+ const driverMode = resolveDriver(options);
1141
+ if (driverMode.error !== undefined) {
1142
+ await writePublishResult(outputPath, fail(driverMode.error));
1143
+ throw new Error(driverMode.error);
1144
+ }
1145
+ const resolved = transport ?? getPublishTransport(mode.operation, driverMode.driver);
1146
+ const result = ResultSchema.parse(await resolved.publish({ content, options }));
1147
+ await writePublishResult(outputPath, result);
1148
+ if (!result.ok) throw new Error(result.error ?? 'Publish failed');
1149
+ }
1150
+
1151
+ /**
1152
+ * CLI entry point: `--in <content.json> --out <result.json>`.
1153
+ * @returns Process exit code (0 published, 1 refused or invalid arguments).
1154
+ */
1155
+ export async function main(): Promise<number> {
1156
+ let values: { in?: string; out?: string };
1157
+ try {
1158
+ ({ values } = parseArgs({ options: { in: { type: 'string' }, out: { type: 'string' } } }));
1159
+ } catch (error: unknown) {
1160
+ echoError(`Invalid arguments: ${errorMessage(error)}`);
1161
+ return 1;
1162
+ }
1163
+ if (!values.in || !values.out) {
1164
+ echoError('Missing required arguments: --in and --out');
1165
+ return 1;
1166
+ }
1167
+ try {
1168
+ await processPublishIO(values.in, values.out);
1169
+ return 0;
1170
+ } catch (error: unknown) {
1171
+ echoError(`csdn-pub failed: ${boundedDetail(errorMessage(error))}`);
1172
+ return 1;
1173
+ }
1174
+ }
1175
+
1176
+ if (import.meta.main) {
1177
+ process.exit(await main());
1178
+ }