@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,485 @@
1
+ /**
2
+ * WeChat MP private CGI media pipeline, R1+R2 (task 0289): the article-cover `filetransfer
3
+ * scene=8` upload and the `cropimage action=crop_multi` dual-aspect crop, executed inside the
4
+ * already-authenticated console page (ADR-029 seam) with the session inputs 0290 extracts.
5
+ *
6
+ * Request shapes are frozen from the recovered v0.1.55 extension weixin.js (task 0289 Design);
7
+ * this is versioned compatibility behavior, not an official WeChat API guarantee. Every
8
+ * credentialed call asserts the exact https://mp.weixin.qq.com origin before any dispatch, posts
9
+ * multipart through a page-side AbortController, refuses redirects, bounds each response to 2 MiB
10
+ * and 30 s with no retry, and never logs or embeds session secrets in errors.
11
+ */
12
+ import type { WechatPage } from './page';
13
+
14
+ const MP_ORIGIN = 'https://mp.weixin.qq.com';
15
+ const FILETRANSFER_URL = `${MP_ORIGIN}/cgi-bin/filetransfer`;
16
+ const CROPIMAGE_URL = `${MP_ORIGIN}/cgi-bin/cropimage`;
17
+ const MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
18
+ const REQUEST_TIMEOUT_MS = 30_000;
19
+ const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
20
+ const WIDE_COVER_ASPECT = 2.35;
21
+ const SQUARE_COVER_ASPECT = 1;
22
+
23
+ const EXTENSION_MIME: Record<string, string> = {
24
+ '.png': 'image/png',
25
+ '.jpg': 'image/jpeg',
26
+ '.jpeg': 'image/jpeg',
27
+ '.webp': 'image/webp',
28
+ };
29
+
30
+ /** Bounded failure vocabulary for every CGI upload/crop refusal (task 0289 Design). */
31
+ export class WechatCgiError extends Error {
32
+ constructor(
33
+ readonly reason:
34
+ | 'wrong-origin'
35
+ | 'unreadable-file'
36
+ | 'unsupported-extension'
37
+ | 'image-too-large'
38
+ | 'empty-image'
39
+ | 'invalid-dimensions'
40
+ | 'http-status'
41
+ | 'response-too-large'
42
+ | 'network-error'
43
+ | 'bad-receipt',
44
+ message: string,
45
+ ) {
46
+ super(message);
47
+ this.name = 'WechatCgiError';
48
+ }
49
+ }
50
+
51
+ export interface WechatCgiSession {
52
+ token: string;
53
+ ticket: string;
54
+ userName: string;
55
+ uin?: string;
56
+ }
57
+
58
+ export interface WechatUploadedImage {
59
+ fileId: string;
60
+ url: string;
61
+ width: number;
62
+ height: number;
63
+ }
64
+
65
+ export interface CropRect {
66
+ x1: number;
67
+ y1: number;
68
+ x2: number;
69
+ y2: number;
70
+ }
71
+
72
+ export interface CoverPair {
73
+ wide: WechatUploadedImage;
74
+ square: WechatUploadedImage;
75
+ /**
76
+ * The actual crop_multi boxes in [wide 2.35:1, square 1:1] order — task 0290's operate_appmsg
77
+ * payload must carry these exact coordinates, never vendor hard-coded pixels.
78
+ */
79
+ crops: [CropRect, CropRect];
80
+ }
81
+
82
+ export type CgiImageInput = { path: string } | { bytes: Uint8Array; name: string };
83
+
84
+ export interface CgiImageBytes {
85
+ bytes: Uint8Array;
86
+ name: string;
87
+ mime: string;
88
+ }
89
+
90
+ /** Exact-host CDN allowlist (task 0289 Design: mmbiz.qpic.cn HTTPS only, never suffix matching). */
91
+ export function isAllowedCdnUrl(candidate: string): boolean {
92
+ try {
93
+ const url = new URL(candidate);
94
+ return url.protocol === 'https:' && url.host === 'mmbiz.qpic.cn';
95
+ } catch {
96
+ return false;
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Centered cover-fit rectangle for one target aspect ratio, from ACTUAL image dimensions — never
102
+ * the vendor's hard-coded pixels (0289 refine correction). Coordinates are integers inside the
103
+ * image bounds, the form the crop_multi fields expect.
104
+ */
105
+ export function centerCropRect(width: number, height: number, aspect: number): CropRect {
106
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width < 1 || height < 1) {
107
+ throw new WechatCgiError(
108
+ 'invalid-dimensions',
109
+ `image dimensions must be positive numbers, got ${width}x${height}`,
110
+ );
111
+ }
112
+ if (!Number.isFinite(aspect) || aspect <= 0) {
113
+ throw new WechatCgiError('invalid-dimensions', `crop aspect must be positive, got ${aspect}`);
114
+ }
115
+ let cropWidth = width;
116
+ let cropHeight = height;
117
+ if (width / height > aspect) {
118
+ cropWidth = height * aspect;
119
+ } else {
120
+ cropHeight = width / aspect;
121
+ }
122
+ const x1 = Math.floor((width - cropWidth) / 2);
123
+ const y1 = Math.floor((height - cropHeight) / 2);
124
+ return {
125
+ x1,
126
+ y1,
127
+ x2: Math.min(width, x1 + Math.round(cropWidth)),
128
+ y2: Math.min(height, y1 + Math.round(cropHeight)),
129
+ };
130
+ }
131
+
132
+ function extensionMime(name: string): string {
133
+ const lower = name.toLowerCase();
134
+ const dot = lower.lastIndexOf('.');
135
+ const mime = dot === -1 ? undefined : EXTENSION_MIME[lower.slice(dot)];
136
+ if (mime === undefined) {
137
+ throw new WechatCgiError('unsupported-extension', `accepted image extensions are PNG/JPEG/WebP, got '${name}'`);
138
+ }
139
+ return mime;
140
+ }
141
+
142
+ /** Validates in-memory image bytes (nonempty, ≤10 MiB, PNG/JPEG/WebP by extension). */
143
+ export function validateImageBytes(bytes: Uint8Array, name: string): CgiImageBytes {
144
+ if (bytes.byteLength === 0) {
145
+ throw new WechatCgiError('empty-image', `image '${name}' is empty`);
146
+ }
147
+ if (bytes.byteLength > MAX_IMAGE_BYTES) {
148
+ throw new WechatCgiError('image-too-large', `image '${name}' exceeds the ${MAX_IMAGE_BYTES}-byte bound`);
149
+ }
150
+ return { bytes, name, mime: extensionMime(name) };
151
+ }
152
+
153
+ /**
154
+ * Local cover read via the Bun FileSystem: regular, readable, nonempty file within the 10 MiB
155
+ * per-file bound; payload crosses the page boundary as base64 only (task 0289 Design).
156
+ */
157
+ export async function readLocalImageBytes(path: string): Promise<CgiImageBytes> {
158
+ const name = path.replaceAll('\\', '/').split('/').pop() ?? path;
159
+ extensionMime(name); // fail fast on the extension before any read
160
+ const file = Bun.file(path);
161
+ let stat: { size: number; isFile(): boolean };
162
+ try {
163
+ stat = await file.stat();
164
+ } catch {
165
+ throw new WechatCgiError('unreadable-file', `image is not a readable file: ${path}`);
166
+ }
167
+ if (!stat.isFile() || stat.size === 0) {
168
+ throw new WechatCgiError('unreadable-file', `image is not a nonempty regular file: ${path}`);
169
+ }
170
+ if (stat.size > MAX_IMAGE_BYTES) {
171
+ throw new WechatCgiError('image-too-large', `image '${path}' exceeds the ${MAX_IMAGE_BYTES}-byte bound`);
172
+ }
173
+ return validateImageBytes(new Uint8Array(await file.arrayBuffer()), name);
174
+ }
175
+
176
+ interface CgiFetchRequest {
177
+ url: string;
178
+ fields: Record<string, string>;
179
+ file?: { field: string; filename: string; mime: string; base64: string };
180
+ maxBytes: number;
181
+ timeoutMs: number;
182
+ }
183
+
184
+ /**
185
+ * Self-contained page script: data enters as one JSON.stringify'd literal (never interpolated
186
+ * code), the fetch runs with the page's authenticated cookies, redirect: 'error' fails closed on
187
+ * ANY redirect (subsumes the foreign-origin refusal), and the body is streamed against the 2 MiB
188
+ * bound. Only a bounded JSON verdict string crosses back.
189
+ */
190
+ function cgiFetchExpression(spec: CgiFetchRequest): string {
191
+ return `(async () => {
192
+ const spec = ${JSON.stringify(spec)};
193
+ const controller = new AbortController();
194
+ const timer = setTimeout(() => controller.abort(), spec.timeoutMs);
195
+ try {
196
+ const form = new FormData();
197
+ for (const key of Object.keys(spec.fields)) form.append(key, spec.fields[key]);
198
+ if (spec.file) {
199
+ const binary = atob(spec.file.base64);
200
+ const bytes = new Uint8Array(binary.length);
201
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
202
+ form.append(spec.file.field, new Blob([bytes], { type: spec.file.mime }), spec.file.filename);
203
+ }
204
+ const response = await fetch(spec.url, {
205
+ method: 'POST',
206
+ body: form,
207
+ credentials: 'include',
208
+ redirect: 'error',
209
+ signal: controller.signal,
210
+ });
211
+ if (!response.ok) return JSON.stringify({ kind: 'http-status', status: response.status });
212
+ if (!response.body) return JSON.stringify({ kind: 'error', detail: 'response has no body stream' });
213
+ const reader = response.body.getReader();
214
+ let total = 0;
215
+ const chunks = [];
216
+ for (;;) {
217
+ const { done, value } = await reader.read();
218
+ if (done) break;
219
+ total += value.byteLength;
220
+ if (total > spec.maxBytes) {
221
+ controller.abort();
222
+ return JSON.stringify({ kind: 'too-large' });
223
+ }
224
+ chunks.push(value);
225
+ }
226
+ const text = await new Blob(chunks).text();
227
+ return JSON.stringify({ kind: 'ok', status: response.status, text });
228
+ } catch (error) {
229
+ return JSON.stringify({ kind: 'error', detail: String(error) });
230
+ } finally {
231
+ clearTimeout(timer);
232
+ }
233
+ })()`;
234
+ }
235
+
236
+ type CgiVerdict =
237
+ | { kind: 'ok'; status: number; text: string }
238
+ | { kind: 'http-status'; status: number }
239
+ | { kind: 'too-large' }
240
+ | { kind: 'error'; detail: string };
241
+
242
+ /** Query strings carry ticket/token — strip them before any failure detail is surfaced. */
243
+ function stripQuery(detail: string): string {
244
+ return detail.replace(/\?[^'"\\\s]*/g, '?…');
245
+ }
246
+
247
+ async function postCgiForm(page: WechatPage, spec: CgiFetchRequest): Promise<{ status: number; text: string }> {
248
+ let verdict: CgiVerdict | undefined;
249
+ const raw: unknown = await page.evaluate(cgiFetchExpression(spec));
250
+ if (typeof raw === 'string') {
251
+ try {
252
+ verdict = JSON.parse(raw) as CgiVerdict;
253
+ } catch {
254
+ verdict = undefined;
255
+ }
256
+ }
257
+ if (verdict?.kind === 'http-status') {
258
+ throw new WechatCgiError('http-status', `CGI call returned HTTP ${verdict.status}`);
259
+ }
260
+ if (verdict?.kind === 'too-large') {
261
+ throw new WechatCgiError('response-too-large', `CGI response exceeded the ${MAX_RESPONSE_BYTES}-byte bound`);
262
+ }
263
+ if (verdict?.kind !== 'ok') {
264
+ const detail = verdict?.kind === 'error' ? stripQuery(verdict.detail) : 'page returned no usable verdict';
265
+ throw new WechatCgiError('network-error', `CGI call failed before a usable response: ${detail}`);
266
+ }
267
+ return { status: verdict.status, text: verdict.text };
268
+ }
269
+
270
+ function isRecord(value: unknown): value is Record<string, unknown> {
271
+ return typeof value === 'object' && value !== null;
272
+ }
273
+
274
+ function parseJsonBody(text: string, what: string): Record<string, unknown> {
275
+ let body: unknown;
276
+ try {
277
+ body = JSON.parse(text);
278
+ } catch {
279
+ throw new WechatCgiError('bad-receipt', `${what}: response is not JSON`);
280
+ }
281
+ if (!isRecord(body)) {
282
+ throw new WechatCgiError('bad-receipt', `${what}: response is not a JSON object`);
283
+ }
284
+ return body;
285
+ }
286
+
287
+ /** ret absent or 0 is the hard gate; a present err_msg must agree with it. */
288
+ function assertBaseResp(body: Record<string, unknown>, what: string): void {
289
+ const base = body.base_resp;
290
+ if (base === undefined) return;
291
+ if (!isRecord(base)) {
292
+ throw new WechatCgiError('bad-receipt', `${what}: malformed base_resp`);
293
+ }
294
+ if (base.ret !== undefined && base.ret !== 0) {
295
+ throw new WechatCgiError('bad-receipt', `${what}: base_resp.ret=${String(base.ret)}`);
296
+ }
297
+ if (base.err_msg !== undefined && base.err_msg !== 'ok') {
298
+ throw new WechatCgiError('bad-receipt', `${what}: err_msg=${String(base.err_msg)}`);
299
+ }
300
+ }
301
+
302
+ function parseFiletransferReceipt(text: string): WechatUploadedImage {
303
+ const body = parseJsonBody(text, 'filetransfer');
304
+ assertBaseResp(body, 'filetransfer');
305
+ const content = body.content;
306
+ if (typeof content !== 'string' || !/^\d+$/.test(content)) {
307
+ throw new WechatCgiError('bad-receipt', 'filetransfer: content file ID is missing or not numeric');
308
+ }
309
+ const cdnUrl = body.cdn_url;
310
+ if (typeof cdnUrl !== 'string' || !isAllowedCdnUrl(cdnUrl)) {
311
+ throw new WechatCgiError(
312
+ 'bad-receipt',
313
+ 'filetransfer: cdn_url is missing or outside the allowed HTTPS CDN host',
314
+ );
315
+ }
316
+ const width = body.width;
317
+ const height = body.height;
318
+ if (typeof width !== 'number' || typeof height !== 'number' || width < 1 || height < 1) {
319
+ throw new WechatCgiError('bad-receipt', 'filetransfer: receipt did not return usable image dimensions');
320
+ }
321
+ return { fileId: content, url: cdnUrl, width, height };
322
+ }
323
+
324
+ function parseCropMultiReceipt(
325
+ text: string,
326
+ crops: readonly [CropRect, CropRect],
327
+ ): [WechatUploadedImage, WechatUploadedImage] {
328
+ const body = parseJsonBody(text, 'crop_multi');
329
+ assertBaseResp(body, 'crop_multi');
330
+ const entries: unknown[] = Array.isArray(body.result) ? body.result : [];
331
+ const entryCount = entries.length;
332
+ if (entryCount !== 2) {
333
+ throw new WechatCgiError('bad-receipt', `crop_multi: expected exactly two result entries, got ${entryCount}`);
334
+ }
335
+ let wide: WechatUploadedImage | undefined;
336
+ let square: WechatUploadedImage | undefined;
337
+ entries.forEach((entry, index) => {
338
+ const dims = index === 0 ? crops[0] : crops[1];
339
+ if (!isRecord(entry)) {
340
+ throw new WechatCgiError('bad-receipt', `crop_multi: result entry ${index} is not an object`);
341
+ }
342
+ const cdnurl = entry.cdnurl;
343
+ const fileId = entry.file_id;
344
+ if (typeof cdnurl !== 'string' || !isAllowedCdnUrl(cdnurl)) {
345
+ throw new WechatCgiError(
346
+ 'bad-receipt',
347
+ `crop_multi: entry ${index} cdnurl is outside the allowed HTTPS CDN host`,
348
+ );
349
+ }
350
+ if (typeof fileId !== 'string' || fileId === '') {
351
+ throw new WechatCgiError('bad-receipt', `crop_multi: entry ${index} file_id is missing`);
352
+ }
353
+ const image: WechatUploadedImage = {
354
+ fileId,
355
+ url: cdnurl,
356
+ width: Math.max(1, dims.x2 - dims.x1),
357
+ height: Math.max(1, dims.y2 - dims.y1),
358
+ };
359
+ if (index === 0) {
360
+ wide = image;
361
+ } else {
362
+ square = image;
363
+ }
364
+ });
365
+ if (wide === undefined || square === undefined) {
366
+ throw new WechatCgiError('bad-receipt', 'crop_multi: receipt did not fill both crop entries');
367
+ }
368
+ return [wide, square];
369
+ }
370
+
371
+ function buildUrl(base: string, params: Record<string, string>): string {
372
+ return `${base}?${new URLSearchParams(params).toString()}`;
373
+ }
374
+
375
+ function randomRequestSalt(): number {
376
+ return Math.floor(Math.random() * 1_000_000_000);
377
+ }
378
+
379
+ /** The frozen filetransfer scene=8 query (task 0289 Design) — order and values are compatibility. */
380
+ function filetransferUrl(session: WechatCgiSession): string {
381
+ return buildUrl(FILETRANSFER_URL, {
382
+ action: 'upload_material',
383
+ f: 'json',
384
+ scene: '8',
385
+ writetype: 'doublewrite',
386
+ groupid: '1',
387
+ ticket_id: session.userName,
388
+ ticket: session.ticket,
389
+ svr_time: String(Math.floor(Date.now() / 1000)),
390
+ token: session.token,
391
+ lang: 'zh_CN',
392
+ seq: String(randomRequestSalt()),
393
+ t: String(randomRequestSalt()),
394
+ });
395
+ }
396
+
397
+ /** The page must already BE the authenticated console origin before anything is evaluated. */
398
+ function assertMpOrigin(page: WechatPage): void {
399
+ let origin: string;
400
+ try {
401
+ origin = new URL(page.url()).origin;
402
+ } catch {
403
+ throw new WechatCgiError('wrong-origin', `page URL is not usable for CGI dispatch: ${page.url()}`);
404
+ }
405
+ if (origin !== MP_ORIGIN) {
406
+ throw new WechatCgiError('wrong-origin', `CGI dispatch requires page origin ${MP_ORIGIN}, got ${origin}`);
407
+ }
408
+ }
409
+
410
+ /** R1: one filetransfer scene=8 upload, validated whole or a named WechatCgiError. */
411
+ export async function uploadCoverImage(
412
+ page: WechatPage,
413
+ session: WechatCgiSession,
414
+ input: CgiImageInput,
415
+ ): Promise<WechatUploadedImage> {
416
+ assertMpOrigin(page);
417
+ const image = 'path' in input ? await readLocalImageBytes(input.path) : validateImageBytes(input.bytes, input.name);
418
+ const now = Date.now();
419
+ const fields: Record<string, string> = {
420
+ type: image.mime,
421
+ id: `kk_${now}_${randomRequestSalt()}`,
422
+ name: image.name,
423
+ lastModifiedDate: new Date(now).toString(),
424
+ size: String(image.bytes.byteLength),
425
+ };
426
+ const { text } = await postCgiForm(page, {
427
+ url: filetransferUrl(session),
428
+ fields,
429
+ file: {
430
+ field: 'file',
431
+ filename: image.name,
432
+ mime: image.mime,
433
+ base64: Buffer.from(image.bytes).toString('base64'),
434
+ },
435
+ maxBytes: MAX_RESPONSE_BYTES,
436
+ timeoutMs: REQUEST_TIMEOUT_MS,
437
+ });
438
+ return parseFiletransferReceipt(text);
439
+ }
440
+
441
+ /** R2: one crop_multi dispatch producing both aspect crops, receipts validated in expected order. */
442
+ export async function cropCoverMulti(
443
+ page: WechatPage,
444
+ session: WechatCgiSession,
445
+ imageUrl: string,
446
+ crops: readonly [CropRect, CropRect],
447
+ ): Promise<[WechatUploadedImage, WechatUploadedImage]> {
448
+ assertMpOrigin(page);
449
+ const fields: Record<string, string> = {
450
+ imgurl: imageUrl,
451
+ size_count: '2',
452
+ token: session.token,
453
+ lang: 'zh_CN',
454
+ f: 'json',
455
+ ajax: '1',
456
+ };
457
+ crops.forEach((crop, index) => {
458
+ fields[`size${index}_x1`] = String(crop.x1);
459
+ fields[`size${index}_y1`] = String(crop.y1);
460
+ fields[`size${index}_x2`] = String(crop.x2);
461
+ fields[`size${index}_y2`] = String(crop.y2);
462
+ });
463
+ const { text } = await postCgiForm(page, {
464
+ url: buildUrl(CROPIMAGE_URL, { action: 'crop_multi' }),
465
+ fields,
466
+ maxBytes: MAX_RESPONSE_BYTES,
467
+ timeoutMs: REQUEST_TIMEOUT_MS,
468
+ });
469
+ return parseCropMultiReceipt(text, crops);
470
+ }
471
+
472
+ /** The R1+R2 pair: upload once, crop from the receipt's ACTUAL dimensions, return wide+square. */
473
+ export async function uploadAndCropCover(
474
+ page: WechatPage,
475
+ session: WechatCgiSession,
476
+ input: CgiImageInput,
477
+ ): Promise<CoverPair> {
478
+ const uploaded = await uploadCoverImage(page, session, input);
479
+ const crops: [CropRect, CropRect] = [
480
+ centerCropRect(uploaded.width, uploaded.height, WIDE_COVER_ASPECT),
481
+ centerCropRect(uploaded.width, uploaded.height, SQUARE_COVER_ASPECT),
482
+ ];
483
+ const [wide, square] = await cropCoverMulti(page, session, uploaded.url, crops);
484
+ return { wide, square, crops };
485
+ }
@@ -3,6 +3,14 @@ import { ResultSchema } from '@gobing-ai/kk-core';
3
3
  import { trySelectors } from '@gobing-ai/publish-harness';
4
4
  import { expectOutcome, pasteHtml, readScopedSignal, uploadFile } from '@gobing-ai/publish-harness/browser';
5
5
  import type { Page } from 'playwright';
6
+ import { readLocalImageBytes } from './cgi';
7
+ import {
8
+ assertEligibleCgiDraft,
9
+ createWechatArticleDraft,
10
+ extractWechatCgiSession,
11
+ WechatCgiDraftError,
12
+ } from './cgi-draft';
13
+ import { prepareArticleMedia, type WechatArticleMedia } from './cgi-media';
6
14
  import { enrichArticle } from './enrich';
7
15
  import {
8
16
  artifactPaths,
@@ -1130,11 +1138,107 @@ async function openDialogInventory(page: WechatPage): Promise<string> {
1130
1138
  return typeof raw === 'string' ? raw.slice(0, 600) : '';
1131
1139
  }
1132
1140
 
1141
+ /**
1142
+ * The task 0290 CGI draft route: eligible plain 文章 drafts dispatch through the console page's own
1143
+ * operate_appmsg subpath (./cgi-draft on the 0289 media pipeline) without ever entering the DOM
1144
+ * editor. Ineligible content or an invalid required cover returns undefined BEFORE any dispatch, so
1145
+ * the DOM flow keeps every advanced feature; once the route is taken it NEVER retries and NEVER
1146
+ * falls back to DOM — an unverifiable create is an ambiguous submission, reported as ok:false only.
1147
+ */
1148
+ async function runCgiDraft(input: ModeInput, deps: ModeRunDeps): Promise<Result | undefined> {
1149
+ if (input.operation !== '文章') return undefined; // narrow the ModeInput union (runMode already gates this)
1150
+ try {
1151
+ assertEligibleCgiDraft(deps.content);
1152
+ } catch (error) {
1153
+ // Advanced metadata (e.g. metadata.wechatmp.*) is the DOM flow's input by design — a silent
1154
+ // hand-back, no warning. Any other eligibility throw would be a library bug: fail loud.
1155
+ if (error instanceof WechatCgiDraftError && error.code === 'ineligible') return undefined;
1156
+ throw error;
1157
+ }
1158
+ const metadata = deps.content.metadata ?? {};
1159
+ const bodyImages = input.images; // already resolved + validated by modes.resolveBodyImages pre-runMode
1160
+ if (bodyImages.length === 0) return undefined;
1161
+ // Cover parity: top-level coverPath wins, else the first body image (默认首图为封面).
1162
+ const cover =
1163
+ typeof metadata.coverPath === 'string' && metadata.coverPath.length > 0
1164
+ ? metadata.coverPath
1165
+ : (bodyImages[0] ?? '');
1166
+ try {
1167
+ await readLocalImageBytes(cover); // still local-only: validates readable, nonempty, ≤10 MiB, known mime
1168
+ } catch {
1169
+ return undefined;
1170
+ }
1171
+ // Plain-article body images ride the 0289 media pipeline (P2 fix: they are never silently
1172
+ // dropped — the DOM path attaches these same files). localImages maps img src → local path;
1173
+ // prepareArticleMedia reads + uploads each (src values are raw paths so they match keys).
1174
+ const localImages: Record<string, string> = {};
1175
+ for (const path of bodyImages) localImages[path] = path;
1176
+ const imageBlock = bodyImages.map((path) => `<img src="${path}">`).join('');
1177
+ try {
1178
+ const session = await extractWechatCgiSession(deps.entry);
1179
+ let media: WechatArticleMedia;
1180
+ try {
1181
+ media = await prepareArticleMedia(deps.entry, session, {
1182
+ html: imageBlock + markdownToWechatHtml(bodyOf(deps.content)),
1183
+ coverPath: cover,
1184
+ localImages,
1185
+ });
1186
+ } catch (error) {
1187
+ // 0289 media vocabulary failing inside this branch is dispatch-side (never retried, never
1188
+ // DOM-fallback); report it in the branch's own shape with the pre-create stage.
1189
+ if (error instanceof WechatCgiDraftError) throw error;
1190
+ return ResultSchema.parse({
1191
+ ok: false,
1192
+ target: TARGET,
1193
+ error: error instanceof Error ? error.message : String(error),
1194
+ metadata: { operation: input.operation, publishStatus: deps.publishStatus, stage: 'media-preparation' },
1195
+ });
1196
+ }
1197
+ const draft = await createWechatArticleDraft(deps.entry, session, deps.content, media);
1198
+ return ResultSchema.parse({
1199
+ ok: true,
1200
+ target: TARGET,
1201
+ // The verified draft id read back from the appmsg list receipt (Design R3).
1202
+ id: draft.appMsgId,
1203
+ // The canonical edit route: no token in it, so the URL is secret-free by construction.
1204
+ url: `https://mp.weixin.qq.com${draft.editPath}`,
1205
+ metadata: {
1206
+ operation: input.operation,
1207
+ publishStatus: deps.publishStatus,
1208
+ stage: 'draft-created',
1209
+ appMsgId: draft.appMsgId,
1210
+ coverFileIds: [media.cover.wide.fileId, media.cover.square.fileId],
1211
+ cdnMigratedImages: media.images.length,
1212
+ },
1213
+ });
1214
+ } catch (error) {
1215
+ // Only WechatCgiDraftError can reach this outer catch (media vocabulary is wrapped by the
1216
+ // inner media-preparation catch above); anything else is a library bug — propagate loudly.
1217
+ if (!(error instanceof WechatCgiDraftError)) throw error;
1218
+ return ResultSchema.parse({
1219
+ ok: false,
1220
+ target: TARGET,
1221
+ error: error.message,
1222
+ metadata: {
1223
+ operation: input.operation,
1224
+ publishStatus: deps.publishStatus,
1225
+ stage: error.stage,
1226
+ ...(error.metadata.ambiguousSubmission === true ? { ambiguousSubmission: true } : {}),
1227
+ },
1228
+ });
1229
+ }
1230
+ }
1231
+
1133
1232
  /**
1134
1233
  * Run one validated operation end to end: enter its mode, compose the post, then take it to a draft
1135
1234
  * or — only on an explicit live request — publish it.
1136
1235
  */
1137
1236
  export async function runMode(input: ModeInput, deps: ModeRunDeps): Promise<Result> {
1237
+ // The CGI draft branch runs BEFORE mode entry and consumes neither enterMode nor compose.
1238
+ if (input.operation === '文章' && deps.publishStatus === 'draft') {
1239
+ const cgi = await runCgiDraft(input, deps);
1240
+ if (cgi !== undefined) return cgi;
1241
+ }
1138
1242
  const entry = await enterMode(deps, input.operation);
1139
1243
  const composed = await compose(input, deps, entry);
1140
1244
  // 2026-10-12: a throw from the submit step drops every diagnostic the compose phase collected —