@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,380 @@
1
+ /**
2
+ * WeChat MP article body media preparation, R3+R4 (task 0289): bounded in-body image CDN
3
+ * migration plus external-anchor normalization, executed against the already-authenticated
4
+ * console page (ADR-029 seam) on top of the chunk-1 cover pipeline (`./cgi`).
5
+ *
6
+ * HTML is only ever parsed with DOMParser inside the page (never regex-only rewriting); every
7
+ * dispatch asserts the exact https://mp.weixin.qq.com origin first; a failed required image
8
+ * throws before any mutated HTML is returned, so the caller's markup survives intact.
9
+ */
10
+
11
+ import type { CoverPair, WechatCgiSession, WechatUploadedImage } from './cgi';
12
+ import { isAllowedCdnUrl, readLocalImageBytes, uploadAndCropCover, uploadCoverImage } from './cgi';
13
+ import type { WechatPage } from './page';
14
+
15
+ const MP_ORIGIN = 'https://mp.weixin.qq.com';
16
+ const UPLOADIMG2CDN_URL = `${MP_ORIGIN}/cgi-bin/uploadimg2cdn`;
17
+ const MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
18
+ const REQUEST_TIMEOUT_MS = 30_000;
19
+ const TOTAL_DEADLINE_MS = 180_000;
20
+ const MAX_AGGREGATE_BYTES = 20 * 1024 * 1024;
21
+ const MAX_BODY_SOURCES = 30;
22
+ const MAX_SOURCE_DETAIL_LENGTH = 120;
23
+
24
+ /** Bounded failure vocabulary for body-media preparation (task 0289 Design). */
25
+ export class WechatMediaError extends Error {
26
+ constructor(
27
+ readonly reason:
28
+ | 'wrong-origin'
29
+ | 'unmapped-source'
30
+ | 'too-many-sources'
31
+ | 'aggregate-too-large'
32
+ | 'deadline-exceeded'
33
+ | 'transfer-failed'
34
+ | 'bad-transfer-receipt',
35
+ message: string,
36
+ ) {
37
+ super(message);
38
+ this.name = 'WechatMediaError';
39
+ }
40
+ }
41
+
42
+ export interface WechatArticleMediaInput {
43
+ html: string;
44
+ coverPath: string;
45
+ localImages: Record<string, string>;
46
+ }
47
+
48
+ export interface WechatArticleMedia {
49
+ html: string;
50
+ cover: CoverPair;
51
+ images: WechatUploadedImage[];
52
+ }
53
+
54
+ /** Signed/query strings are secrets: error detail keeps the bare, truncated source only. */
55
+ function safeSource(source: string): string {
56
+ const bare = source.replace(/[?#].*$/, '');
57
+ return bare.length > MAX_SOURCE_DETAIL_LENGTH ? `${bare.slice(0, MAX_SOURCE_DETAIL_LENGTH)}…` : bare;
58
+ }
59
+
60
+ /** Query strings carry ticket/token — strip them before any failure detail is surfaced. */
61
+ function stripQuery(detail: string): string {
62
+ return detail.replace(/\?[^'"\\\s]*/g, '?…');
63
+ }
64
+
65
+ function assertMpOrigin(page: WechatPage): void {
66
+ let origin: string;
67
+ try {
68
+ origin = new URL(page.url()).origin;
69
+ } catch {
70
+ throw new WechatMediaError('wrong-origin', `page URL is not usable for media preparation: ${page.url()}`);
71
+ }
72
+ if (origin !== MP_ORIGIN) {
73
+ throw new WechatMediaError(
74
+ 'wrong-origin',
75
+ `media preparation requires page origin ${MP_ORIGIN}, got ${origin}`,
76
+ );
77
+ }
78
+ }
79
+
80
+ function assertDeadline(deadlineAt: number): void {
81
+ if (Date.now() > deadlineAt) {
82
+ throw new WechatMediaError(
83
+ 'deadline-exceeded',
84
+ `article media preparation exceeded the ${TOTAL_DEADLINE_MS}ms deadline`,
85
+ );
86
+ }
87
+ }
88
+
89
+ /**
90
+ * The frozen input carries no verified article origin, so relative sources without a localImages
91
+ * mapping are always refused, and every href anchor is normalized (external-only detection needs
92
+ * that origin — fail-safe direction is to normalize).
93
+ */
94
+ function assertPublicHttpSource(source: string): void {
95
+ let url: URL;
96
+ try {
97
+ url = new URL(source);
98
+ } catch {
99
+ throw new WechatMediaError(
100
+ 'unmapped-source',
101
+ `body image source is not absolute and has no localImages mapping: ${safeSource(source)}`,
102
+ );
103
+ }
104
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') {
105
+ throw new WechatMediaError(
106
+ 'unmapped-source',
107
+ `body image source scheme ${url.protocol} requires a localImages mapping: ${safeSource(source)}`,
108
+ );
109
+ }
110
+ if (url.username !== '' || url.password !== '') {
111
+ throw new WechatMediaError(
112
+ 'unmapped-source',
113
+ `body image source carries credentials and is not transferable: ${safeSource(source)}`,
114
+ );
115
+ }
116
+ if (isLocalHostLiteral(url.hostname)) {
117
+ throw new WechatMediaError(
118
+ 'unmapped-source',
119
+ `body image source is a local-IP literal and is not transferable: ${safeSource(source)}`,
120
+ );
121
+ }
122
+ }
123
+
124
+ function isLocalHostLiteral(hostname: string): boolean {
125
+ const host = hostname.toLowerCase().replace(/^\[|\]$/g, '');
126
+ return (
127
+ host === 'localhost' ||
128
+ host === '::1' ||
129
+ host === '0.0.0.0' ||
130
+ /^127\./.test(host) ||
131
+ /^10\./.test(host) ||
132
+ /^192\.168\./.test(host) ||
133
+ /^169\.254\./.test(host) ||
134
+ /^172\.(1[6-9]|2\d|3[01])\./.test(host) ||
135
+ /^(f[cd]|fe80)/.test(host)
136
+ );
137
+ }
138
+
139
+ /**
140
+ * Self-contained page script: anchors with href become spans (children preserved), event-handler
141
+ * and dangerous-scheme attributes are dropped (never the content), img sources are collected
142
+ * exact-string deduplicated in document order.
143
+ */
144
+ function parseArticleExpression(html: string): string {
145
+ return `(function () {
146
+ const doc = new DOMParser().parseFromString(${JSON.stringify(html)}, 'text/html');
147
+ const dangerous = (value) => {
148
+ const compact = String(value).toLowerCase().replace(/\\s+/g, '');
149
+ return ['javascript:', 'data:', 'vbscript:'].some((scheme) => compact.startsWith(scheme));
150
+ };
151
+ doc.body.querySelectorAll('*').forEach((el) => {
152
+ [...el.attributes].forEach((attr) => {
153
+ const name = attr.name.toLowerCase();
154
+ if (name.startsWith('on') || dangerous(attr.value)) el.removeAttribute(attr.name);
155
+ });
156
+ });
157
+ doc.body.querySelectorAll('a[href]').forEach((a) => {
158
+ const span = doc.createElement('span');
159
+ while (a.firstChild) span.appendChild(a.firstChild);
160
+ a.replaceWith(span);
161
+ });
162
+ const sources = [];
163
+ const seen = new Set();
164
+ doc.body.querySelectorAll('img').forEach((img) => {
165
+ const src = img.getAttribute('src') || '';
166
+ if (src !== '' && !seen.has(src)) {
167
+ seen.add(src);
168
+ sources.push(src);
169
+ }
170
+ });
171
+ return JSON.stringify({ kind: 'ok', sources, html: doc.body.innerHTML });
172
+ })()`;
173
+ }
174
+
175
+ /** Second DOMParser pass: swap every affected img node's src for its migrated CDN URL. */
176
+ function applyReplacementsExpression(html: string, replacements: Record<string, string>): string {
177
+ return `(function () {
178
+ const doc = new DOMParser().parseFromString(${JSON.stringify(html)}, 'text/html');
179
+ const map = ${JSON.stringify(replacements)};
180
+ doc.body.querySelectorAll('img').forEach((img) => {
181
+ const src = img.getAttribute('src') || '';
182
+ if (map[src]) img.setAttribute('src', map[src]);
183
+ });
184
+ return JSON.stringify({ kind: 'ok', html: doc.body.innerHTML });
185
+ })()`;
186
+ }
187
+
188
+ /**
189
+ * Field-only POST with the page's cookies: redirect refusal, streamed against the byte bound,
190
+ * only a bounded JSON verdict crosses back (same closed shape as the chunk-1 CGI dispatch).
191
+ */
192
+ function transferExpression(spec: { url: string; fields: Record<string, string>; maxBytes: number }): string {
193
+ return `(async () => {
194
+ const spec = ${JSON.stringify(spec)};
195
+ const controller = new AbortController();
196
+ const timer = setTimeout(() => controller.abort(), ${REQUEST_TIMEOUT_MS});
197
+ try {
198
+ const form = new FormData();
199
+ for (const key of Object.keys(spec.fields)) form.append(key, spec.fields[key]);
200
+ const response = await fetch(spec.url, {
201
+ method: 'POST',
202
+ body: form,
203
+ credentials: 'include',
204
+ redirect: 'error',
205
+ signal: controller.signal,
206
+ });
207
+ if (!response.ok) return JSON.stringify({ kind: 'http-status', status: response.status });
208
+ if (!response.body) return JSON.stringify({ kind: 'error', detail: 'response has no body stream' });
209
+ const reader = response.body.getReader();
210
+ let total = 0;
211
+ const chunks = [];
212
+ for (;;) {
213
+ const { done, value } = await reader.read();
214
+ if (done) break;
215
+ total += value.byteLength;
216
+ if (total > spec.maxBytes) {
217
+ controller.abort();
218
+ return JSON.stringify({ kind: 'too-large' });
219
+ }
220
+ chunks.push(value);
221
+ }
222
+ const text = await new Blob(chunks).text();
223
+ return JSON.stringify({ kind: 'ok', text });
224
+ } catch (error) {
225
+ return JSON.stringify({ kind: 'error', detail: String(error) });
226
+ } finally {
227
+ clearTimeout(timer);
228
+ }
229
+ })()`;
230
+ }
231
+
232
+ type PageVerdict =
233
+ | { kind: 'ok'; sources?: string[]; html?: string; text?: string }
234
+ | { kind: 'http-status'; status: number }
235
+ | { kind: 'too-large' }
236
+ | { kind: 'error'; detail: string };
237
+
238
+ async function evaluateJson(page: WechatPage, expression: string, what: string): Promise<PageVerdict> {
239
+ const raw: unknown = await page.evaluate(expression);
240
+ if (typeof raw !== 'string') {
241
+ throw new WechatMediaError('transfer-failed', `${what}: page returned no usable verdict`);
242
+ }
243
+ try {
244
+ return JSON.parse(raw) as PageVerdict;
245
+ } catch {
246
+ throw new WechatMediaError('transfer-failed', `${what}: page verdict is not JSON`);
247
+ }
248
+ }
249
+
250
+ function isRecord(value: unknown): value is Record<string, unknown> {
251
+ return typeof value === 'object' && value !== null;
252
+ }
253
+
254
+ /** uploadimg2cdn receipts carry no dimensions (frozen contract): 0 marks unknown. */
255
+ function parseTransferReceipt(text: string, source: string): WechatUploadedImage {
256
+ let body: unknown;
257
+ try {
258
+ body = JSON.parse(text);
259
+ } catch {
260
+ throw new WechatMediaError(
261
+ 'bad-transfer-receipt',
262
+ `uploadimg2cdn: response is not JSON for ${safeSource(source)}`,
263
+ );
264
+ }
265
+ if (!isRecord(body) || body.errcode !== 0) {
266
+ throw new WechatMediaError(
267
+ 'bad-transfer-receipt',
268
+ `uploadimg2cdn: errcode=${String(isRecord(body) ? body.errcode : body)} for ${safeSource(source)}`,
269
+ );
270
+ }
271
+ const fileId = body.file_id;
272
+ const url = body.url;
273
+ if (typeof fileId !== 'string' || fileId === '' || typeof url !== 'string' || !isAllowedCdnUrl(url)) {
274
+ throw new WechatMediaError(
275
+ 'bad-transfer-receipt',
276
+ `uploadimg2cdn: file_id/url missing or outside the allowed HTTPS CDN host for ${safeSource(source)}`,
277
+ );
278
+ }
279
+ return { fileId, url, width: 0, height: 0 };
280
+ }
281
+
282
+ /** One frozen uploadimg2cdn dispatch: query lang/token/t; multipart imgurl + editor contract. */
283
+ async function transferPublicImage(
284
+ page: WechatPage,
285
+ session: WechatCgiSession,
286
+ source: string,
287
+ ): Promise<WechatUploadedImage> {
288
+ const query = new URLSearchParams({
289
+ lang: 'zh_CN',
290
+ token: session.token,
291
+ t: String(Math.floor(Math.random() * 1_000_000_000)),
292
+ });
293
+ const verdict = await evaluateJson(
294
+ page,
295
+ transferExpression({
296
+ url: `${UPLOADIMG2CDN_URL}?${query.toString()}`,
297
+ fields: {
298
+ imgurl: source,
299
+ t: 'ajax-editor-upload-img',
300
+ token: session.token,
301
+ lang: 'zh_CN',
302
+ f: 'json',
303
+ ajax: '1',
304
+ },
305
+ maxBytes: MAX_RESPONSE_BYTES,
306
+ }),
307
+ 'uploadimg2cdn',
308
+ );
309
+ if (verdict.kind === 'http-status') {
310
+ throw new WechatMediaError('transfer-failed', `uploadimg2cdn returned HTTP ${verdict.status}`);
311
+ }
312
+ if (verdict.kind === 'too-large') {
313
+ throw new WechatMediaError(
314
+ 'transfer-failed',
315
+ `uploadimg2cdn response exceeded the ${MAX_RESPONSE_BYTES}-byte bound`,
316
+ );
317
+ }
318
+ if (verdict.kind !== 'ok' || typeof verdict.text !== 'string') {
319
+ const detail = verdict.kind === 'error' ? stripQuery(verdict.detail) : 'page returned no usable verdict';
320
+ throw new WechatMediaError('transfer-failed', `uploadimg2cdn failed before a usable response: ${detail}`);
321
+ }
322
+ return parseTransferReceipt(verdict.text, source);
323
+ }
324
+
325
+ /**
326
+ * R1..R4 composition: normalize anchors, migrate every required body image source (already-CDN
327
+ * kept as-is, localImages mappings transferred as base64 via the chunk-1 upload, public HTTP(S)
328
+ * fetched by WeChat in-page), then the cover pair. Validates the whole result or throws; the
329
+ * caller's input HTML is never returned mutated on failure.
330
+ */
331
+ export async function prepareArticleMedia(
332
+ page: WechatPage,
333
+ session: WechatCgiSession,
334
+ input: WechatArticleMediaInput,
335
+ ): Promise<WechatArticleMedia> {
336
+ assertMpOrigin(page);
337
+ const deadlineAt = Date.now() + TOTAL_DEADLINE_MS;
338
+ const parsed = await evaluateJson(page, parseArticleExpression(input.html), 'article body parse');
339
+ if (parsed.kind !== 'ok' || !Array.isArray(parsed.sources) || typeof parsed.html !== 'string') {
340
+ throw new WechatMediaError('transfer-failed', 'article body parse did not return a usable verdict');
341
+ }
342
+ if (parsed.sources.length > MAX_BODY_SOURCES) {
343
+ throw new WechatMediaError(
344
+ 'too-many-sources',
345
+ `article body has ${parsed.sources.length} distinct image sources, bound is ${MAX_BODY_SOURCES}`,
346
+ );
347
+ }
348
+ assertDeadline(deadlineAt);
349
+ const cover = await uploadAndCropCover(page, session, { path: input.coverPath });
350
+ const replacements: Record<string, string> = {};
351
+ const images: WechatUploadedImage[] = [];
352
+ let aggregateBytes = 0;
353
+ for (const source of parsed.sources) {
354
+ assertDeadline(deadlineAt);
355
+ if (isAllowedCdnUrl(source)) continue; // exact mmbiz.qpic.cn HTTPS host: already migrated
356
+ const mappedPath = input.localImages[source];
357
+ let uploaded: WechatUploadedImage;
358
+ if (mappedPath !== undefined) {
359
+ const image = await readLocalImageBytes(mappedPath);
360
+ aggregateBytes += image.bytes.byteLength;
361
+ if (aggregateBytes > MAX_AGGREGATE_BYTES) {
362
+ throw new WechatMediaError(
363
+ 'aggregate-too-large',
364
+ `local body images exceed the ${MAX_AGGREGATE_BYTES}-byte aggregate bound`,
365
+ );
366
+ }
367
+ uploaded = await uploadCoverImage(page, session, { path: mappedPath });
368
+ } else {
369
+ assertPublicHttpSource(source);
370
+ uploaded = await transferPublicImage(page, session, source);
371
+ }
372
+ replacements[source] = uploaded.url;
373
+ images.push(uploaded);
374
+ }
375
+ const applied = await evaluateJson(page, applyReplacementsExpression(parsed.html, replacements), 'CDN replacement');
376
+ if (applied.kind !== 'ok' || typeof applied.html !== 'string') {
377
+ throw new WechatMediaError('transfer-failed', 'CDN replacement did not return a usable verdict');
378
+ }
379
+ return { html: applied.html, cover, images };
380
+ }