@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/dist/board/collect/index.css +654 -16
- package/dist/board/collect/index.js +191 -64
- package/dist/board/daily-voice/index.css +679 -12
- package/dist/board/daily-voice/index.js +185 -66
- package/dist/board/itc/index.css +679 -13
- package/dist/board/itc/index.js +188 -66
- package/dist/index.js +2864 -139
- package/package.json +3 -2
- package/plugins/generations/content-gen/dist/index.js +158 -12
- package/plugins/generations/core-facts-gen/dist/index.js +158 -12
- package/plugins/generations/daily-article-gen/dist/index.js +158 -12
- package/plugins/generations/dailynews-gen/dist/index.js +158 -12
- package/plugins/generations/episode-plan-gen/dist/index.js +158 -12
- package/plugins/generations/gemini-tts/README.md +4 -3
- package/plugins/generations/gemini-tts/dist/index.js +158 -12
- package/plugins/generations/image-gen/dist/index.js +158 -12
- package/plugins/generations/news-report-gen/dist/index.js +158 -12
- package/plugins/generations/omni-voice-gen/README.md +15 -4
- package/plugins/generations/omni-voice-gen/profiles.json +1 -2
- package/plugins/generations/voice-gen/dist/index.js +158 -12
- package/plugins/generations/voxcpm-tts/README.md +21 -1
- package/plugins/generations/voxcpm-tts/dist/index.js +173 -13
- package/plugins/generations/voxcpm-tts/scripts/audit-speaker-consistency.py +109 -0
- package/plugins/generations/voxcpm-tts/src/voxcpm-client.ts +28 -1
- package/plugins/ingestions/aihot-ingest/dist/index.js +158 -12
- package/plugins/ingestions/horizon-ingest/dist/index.js +158 -12
- package/plugins/ingestions/last30days-ingest/dist/index.js +158 -12
- package/plugins/ingestions/web-search/dist/index.js +158 -12
- package/plugins/kk/commands/workflow-run.md +56 -18
- package/plugins/kk/decisions/kk-itc.yaml +46 -0
- package/plugins/kk/decisions/kk-seo-keyword.yaml +57 -0
- package/plugins/kk/decisions/kk-seo-page.yaml +230 -0
- package/plugins/kk/decisions/kk-seo-pair.yaml +21 -0
- package/plugins/kk/decisions/kk-seo-site.yaml +90 -0
- package/plugins/kk/plugin.json +1 -1
- package/plugins/kk/publish-presets/chinese-article.json +14 -0
- package/plugins/kk/publish-presets/technical-article.json +14 -0
- package/plugins/kk/references/smart-seo-attribution.md +85 -0
- package/plugins/kk/scripts/daily-stages.ts +42 -2
- package/plugins/kk/scripts/itc-stages.ts +25 -118
- package/plugins/kk/scripts/kk-workflow-stages.ts +38 -1
- package/plugins/kk/scripts/seo-decisions.ts +491 -0
- package/plugins/kk/scripts/seo-enrich.ts +728 -0
- package/plugins/kk/scripts/seo-facts.ts +610 -0
- package/plugins/kk/scripts/seo-fetch.ts +766 -0
- package/plugins/kk/scripts/seo-report.ts +460 -0
- package/plugins/kk/scripts/seo-rules.ts +1882 -0
- package/plugins/kk/scripts/seo-stages.ts +1044 -0
- package/plugins/kk/skills/article-adapt/SKILL.md +2 -2
- package/plugins/kk/skills/smart-seo/SKILL.md +183 -0
- package/plugins/kk/skills/smart-seo/references/method.md +126 -0
- package/plugins/kk/skills/smart-seo/references/narrative.md +125 -0
- package/plugins/kk/skills/smart-seo/references/operations.md +209 -0
- package/plugins/kk/skills/smart-seo/references/seo-geo-sources.md +90 -0
- package/plugins/kk/workflows/feature-verification.yaml +28 -24
- package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +85 -17
- package/plugins/kk/workflows/kk-itc-author.yaml +406 -0
- package/plugins/kk/workflows/kk-itc.yaml +208 -682
- package/plugins/kk/workflows/kk-smart-seo.yaml +251 -0
- package/plugins/publishings/bilibili-pub/dist/index.js +21782 -0
- package/plugins/publishings/bilibili-pub/dist/profile-check.js +20410 -0
- package/plugins/publishings/bilibili-pub/package.json +20 -0
- package/plugins/publishings/bilibili-pub/plugin.json +12 -0
- package/plugins/publishings/bilibili-pub/src/flow.ts +1296 -0
- package/plugins/publishings/bilibili-pub/src/index.ts +139 -0
- package/plugins/publishings/bilibili-pub/src/profile-check.ts +19 -0
- package/plugins/publishings/bilibili-pub/src/selectors.ts +222 -0
- package/plugins/publishings/bilibili-pub/tsconfig.json +4 -0
- package/plugins/publishings/cnblogs-pub/dist/index.js +21383 -0
- package/plugins/publishings/cnblogs-pub/dist/profile-check.js +20412 -0
- package/plugins/publishings/cnblogs-pub/package.json +19 -0
- package/plugins/publishings/cnblogs-pub/plugin.json +12 -0
- package/plugins/publishings/cnblogs-pub/src/flow.ts +708 -0
- package/plugins/publishings/cnblogs-pub/src/index.ts +172 -0
- package/plugins/publishings/cnblogs-pub/src/profile-check.ts +24 -0
- package/plugins/publishings/cnblogs-pub/src/selectors.ts +104 -0
- package/plugins/publishings/cnblogs-pub/tsconfig.json +4 -0
- package/plugins/publishings/csdn-pub/dist/index.js +21552 -0
- package/plugins/publishings/csdn-pub/dist/profile-check.js +20963 -0
- package/plugins/publishings/csdn-pub/package.json +19 -0
- package/plugins/publishings/csdn-pub/plugin.json +12 -0
- package/plugins/publishings/csdn-pub/src/index.ts +1178 -0
- package/plugins/publishings/csdn-pub/src/profile-check.ts +23 -0
- package/plugins/publishings/csdn-pub/tsconfig.json +4 -0
- package/plugins/publishings/emdash-pub/dist/index.js +158 -12
- package/plugins/publishings/emdash-pub/plugin.json +22 -1
- package/plugins/publishings/infoq-pub/dist/index.js +160 -12
- package/plugins/publishings/infoq-pub/dist/profile-check.js +160 -12
- package/plugins/publishings/infoq-pub/plugin.json +21 -0
- package/plugins/publishings/jike-pub/dist/index.js +21379 -0
- package/plugins/publishings/jike-pub/dist/profile-check.js +20951 -0
- package/plugins/publishings/jike-pub/package.json +19 -0
- package/plugins/publishings/jike-pub/plugin.json +12 -0
- package/plugins/publishings/jike-pub/src/index.ts +800 -0
- package/plugins/publishings/jike-pub/src/profile-check.ts +21 -0
- package/plugins/publishings/jike-pub/tsconfig.json +4 -0
- package/plugins/publishings/juejin-pub/dist/index.js +160 -12
- package/plugins/publishings/juejin-pub/dist/profile-check.js +160 -12
- package/plugins/publishings/juejin-pub/plugin.json +21 -0
- package/plugins/publishings/medium-pub/dist/index.js +158 -12
- package/plugins/publishings/medium-pub/plugin.json +22 -1
- package/plugins/publishings/podcast-pub/dist/index.js +158 -12
- package/plugins/publishings/qiita-pub/dist/index.js +158 -12
- package/plugins/publishings/qiita-pub/plugin.json +24 -1
- package/plugins/publishings/shipinhao-pub/dist/index.js +21715 -0
- package/plugins/publishings/shipinhao-pub/dist/profile-check.js +20431 -0
- package/plugins/publishings/shipinhao-pub/package.json +19 -0
- package/plugins/publishings/shipinhao-pub/plugin.json +12 -0
- package/plugins/publishings/shipinhao-pub/src/flow.ts +1323 -0
- package/plugins/publishings/shipinhao-pub/src/index.ts +173 -0
- package/plugins/publishings/shipinhao-pub/src/profile-check.ts +25 -0
- package/plugins/publishings/shipinhao-pub/src/selectors.ts +241 -0
- package/plugins/publishings/shipinhao-pub/tsconfig.json +4 -0
- package/plugins/publishings/sspai-pub/dist/index.js +21371 -0
- package/plugins/publishings/sspai-pub/dist/profile-check.js +20414 -0
- package/plugins/publishings/sspai-pub/package.json +19 -0
- package/plugins/publishings/sspai-pub/plugin.json +12 -0
- package/plugins/publishings/sspai-pub/src/flow.ts +624 -0
- package/plugins/publishings/sspai-pub/src/index.ts +167 -0
- package/plugins/publishings/sspai-pub/src/profile-check.ts +25 -0
- package/plugins/publishings/sspai-pub/src/selectors.ts +122 -0
- package/plugins/publishings/sspai-pub/tsconfig.json +4 -0
- package/plugins/publishings/substack-pub/dist/index.js +160 -12
- package/plugins/publishings/substack-pub/dist/profile-check.js +160 -12
- package/plugins/publishings/substack-pub/plugin.json +21 -0
- package/plugins/publishings/surfdash-pub/dist/index.js +158 -12
- package/plugins/publishings/surfdash-pub/plugin.json +22 -1
- package/plugins/publishings/threads-pub/dist/index.js +158 -12
- package/plugins/publishings/wechatmp-pub/dist/index.js +1669 -502
- package/plugins/publishings/wechatmp-pub/dist/profile-check.js +204 -21
- package/plugins/publishings/wechatmp-pub/plugin.json +32 -0
- package/plugins/publishings/wechatmp-pub/src/cgi-draft.ts +540 -0
- package/plugins/publishings/wechatmp-pub/src/cgi-media.ts +380 -0
- package/plugins/publishings/wechatmp-pub/src/cgi.ts +485 -0
- package/plugins/publishings/wechatmp-pub/src/flow.ts +104 -0
- package/plugins/publishings/weibo-pub/dist/index.js +160 -12
- package/plugins/publishings/weibo-pub/dist/profile-check.js +160 -12
- package/plugins/publishings/weibo-pub/plugin.json +35 -0
- package/plugins/publishings/x-pub/dist/index.js +160 -12
- package/plugins/publishings/x-pub/dist/profile-check.js +160 -12
- package/plugins/publishings/x-pub/plugin.json +40 -0
- package/plugins/publishings/xhs-pub/dist/index.js +160 -12
- package/plugins/publishings/xhs-pub/dist/profile-check.js +160 -12
- package/plugins/publishings/xhs-pub/plugin.json +37 -0
- package/plugins/publishings/xiaoyuzhou-pub/dist/index.js +21491 -0
- package/plugins/publishings/xiaoyuzhou-pub/dist/profile-check.js +20417 -0
- package/plugins/publishings/xiaoyuzhou-pub/package.json +19 -0
- package/plugins/publishings/xiaoyuzhou-pub/plugin.json +12 -0
- package/plugins/publishings/xiaoyuzhou-pub/src/flow.ts +826 -0
- package/plugins/publishings/xiaoyuzhou-pub/src/index.ts +174 -0
- package/plugins/publishings/xiaoyuzhou-pub/src/profile-check.ts +26 -0
- package/plugins/publishings/xiaoyuzhou-pub/src/selectors.ts +125 -0
- package/plugins/publishings/xiaoyuzhou-pub/tsconfig.json +4 -0
- package/plugins/publishings/zenn-pub/dist/index.js +158 -12
- package/plugins/publishings/zenn-pub/plugin.json +22 -1
- 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
|
+
}
|