@gobing-ai/knowledge-kit 0.0.37 → 0.1.0
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 +477 -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 +202 -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,540 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WeChat MP same-origin CGI draft creation (task 0290): eligible plain 文章 drafts dispatch through
|
|
3
|
+
* the authenticated console page's own `/cgi-bin/operate_appmsg` subpath (ADR-026 amendment,
|
|
4
|
+
* 2026-10-15) on top of the 0289 media pipeline (`./cgi`, `./cgi-media`). Plain metadata only —
|
|
5
|
+
* advanced article metadata, other modes and public operations stay on the DOM flow (`./flow`).
|
|
6
|
+
*
|
|
7
|
+
* The create is ONE request validated against its receipt plus an id+title readback before it
|
|
8
|
+
* counts; once dispatched, any unverifiable outcome is an ambiguous submission that never retries
|
|
9
|
+
* and never falls back to DOM re-creation. Request shapes are frozen from the recovered v0.1.55
|
|
10
|
+
* extension weixin.js createArticleDraft (task 0290 Design) — versioned compatibility behavior,
|
|
11
|
+
* not an official WeChat API guarantee. Session values are secrets: error messages and metadata
|
|
12
|
+
* never carry token/ticket/uin, and the page snapshot used for extraction is never returned.
|
|
13
|
+
*/
|
|
14
|
+
import type { Content } from '@gobing-ai/kk-core';
|
|
15
|
+
import type { WechatCgiSession } from './cgi';
|
|
16
|
+
import type { WechatArticleMedia } from './cgi-media';
|
|
17
|
+
import { deriveDigest } from './modes';
|
|
18
|
+
import type { WechatPage } from './page';
|
|
19
|
+
|
|
20
|
+
const MP_ORIGIN = 'https://mp.weixin.qq.com';
|
|
21
|
+
const CREATE_URL = `${MP_ORIGIN}/cgi-bin/operate_appmsg`;
|
|
22
|
+
const APPMSG_URL = `${MP_ORIGIN}/cgi-bin/appmsg`;
|
|
23
|
+
const MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
|
|
24
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
25
|
+
/** The inline-script scan reads at most this many chars of the page (≈2 MiB; token sits in the head). */
|
|
26
|
+
const MAX_SNAPSHOT_CHARS = 2 * 1024 * 1024;
|
|
27
|
+
const DEFAULT_OPERATION = '文章';
|
|
28
|
+
|
|
29
|
+
/** Bounded failure vocabulary for every CGI draft refusal (task 0290 Design). */
|
|
30
|
+
export class WechatCgiDraftError extends Error {
|
|
31
|
+
constructor(
|
|
32
|
+
readonly code:
|
|
33
|
+
| 'session-missing'
|
|
34
|
+
| 'ineligible'
|
|
35
|
+
| 'request-failed'
|
|
36
|
+
| 'receipt-invalid'
|
|
37
|
+
| 'readback-failed'
|
|
38
|
+
| 'ambiguous-submission',
|
|
39
|
+
readonly stage: string,
|
|
40
|
+
message: string,
|
|
41
|
+
readonly metadata: Record<string, unknown> = {},
|
|
42
|
+
) {
|
|
43
|
+
super(message);
|
|
44
|
+
this.name = 'WechatCgiDraftError';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The plain metadata the frozen operate_appmsg baseline consumes; anything richer stays on DOM. */
|
|
49
|
+
export interface CgiDraftEligibility {
|
|
50
|
+
operation: '文章';
|
|
51
|
+
publishStatus: 'draft';
|
|
52
|
+
title: string;
|
|
53
|
+
body: string;
|
|
54
|
+
digest: string;
|
|
55
|
+
author: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Explicit allowed metadata set (task 0290 Design): plain article fields plus generic
|
|
60
|
+
* routing/provenance keys. Any other key — advanced layout/settings/original/reward/collection
|
|
61
|
+
* detail such as `metadata.wechatmp.*` — makes the content ineligible so the DOM flow keeps it.
|
|
62
|
+
*/
|
|
63
|
+
const ALLOWED_METADATA_KEYS: ReadonlySet<string> = new Set([
|
|
64
|
+
'operation',
|
|
65
|
+
'publishStatus',
|
|
66
|
+
'title',
|
|
67
|
+
'body',
|
|
68
|
+
'digest',
|
|
69
|
+
'author',
|
|
70
|
+
'contentId',
|
|
71
|
+
'source',
|
|
72
|
+
'sourceUrl',
|
|
73
|
+
'tags',
|
|
74
|
+
'categories',
|
|
75
|
+
'coverPath',
|
|
76
|
+
'images',
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
function isNonemptyString(value: unknown): value is string {
|
|
80
|
+
return typeof value === 'string' && value.length > 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
84
|
+
return typeof value === 'object' && value !== null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Deterministic certification of the plain-article shape; never mutates content. */
|
|
88
|
+
function certifyCgiDraft(content: Content): CgiDraftEligibility {
|
|
89
|
+
const metadata = content.metadata ?? {};
|
|
90
|
+
function refuse(detail: string): never {
|
|
91
|
+
throw new WechatCgiDraftError(
|
|
92
|
+
'ineligible',
|
|
93
|
+
'eligibility',
|
|
94
|
+
`content is not eligible for the CGI draft path: ${detail}`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
const operation = metadata.operation ?? DEFAULT_OPERATION;
|
|
98
|
+
if (operation !== DEFAULT_OPERATION) {
|
|
99
|
+
refuse(`operation is not ${DEFAULT_OPERATION}`);
|
|
100
|
+
}
|
|
101
|
+
if (metadata.publishStatus !== undefined && metadata.publishStatus !== 'draft') {
|
|
102
|
+
refuse('publishStatus is not draft');
|
|
103
|
+
}
|
|
104
|
+
if (!isNonemptyString(content.title)) {
|
|
105
|
+
refuse('title is missing or empty');
|
|
106
|
+
}
|
|
107
|
+
if (!isNonemptyString(content.body)) {
|
|
108
|
+
refuse('body is missing or empty');
|
|
109
|
+
}
|
|
110
|
+
for (const key of ['digest', 'author'] as const) {
|
|
111
|
+
if (metadata[key] !== undefined && typeof metadata[key] !== 'string') {
|
|
112
|
+
refuse(`${key} is present but not a string`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
for (const key of Object.keys(metadata)) {
|
|
116
|
+
if (!ALLOWED_METADATA_KEYS.has(key)) {
|
|
117
|
+
refuse(`metadata key '${key}' is outside the plain-article set`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
operation: DEFAULT_OPERATION,
|
|
122
|
+
publishStatus: 'draft',
|
|
123
|
+
title: content.title,
|
|
124
|
+
body: content.body,
|
|
125
|
+
digest: typeof metadata.digest === 'string' ? metadata.digest : '',
|
|
126
|
+
author: typeof metadata.author === 'string' ? metadata.author : '',
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Deterministic eligibility gate, checked BEFORE any upload/create mutation: operation=文章,
|
|
132
|
+
* publishStatus draft (absent defaults to draft), nonempty title/body, string-only digest/author
|
|
133
|
+
* and no metadata outside the plain set. Anything else must stay on the DOM flow.
|
|
134
|
+
*/
|
|
135
|
+
export function assertEligibleCgiDraft(content: Content): void {
|
|
136
|
+
certifyCgiDraft(content);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Query strings carry ticket/token — strip them before any failure detail is surfaced. */
|
|
140
|
+
function stripQuery(detail: string): string {
|
|
141
|
+
return detail.replace(/\?[^'"\\\s]*/g, '?…');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The page must already BE the authenticated console origin before anything is evaluated. */
|
|
145
|
+
function assertMpOrigin(page: WechatPage): void {
|
|
146
|
+
let origin: string;
|
|
147
|
+
try {
|
|
148
|
+
origin = new URL(page.url()).origin;
|
|
149
|
+
} catch {
|
|
150
|
+
throw new WechatCgiDraftError(
|
|
151
|
+
'request-failed',
|
|
152
|
+
'assert-origin',
|
|
153
|
+
`page URL is not usable for CGI dispatch: ${page.url()}`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
if (origin !== MP_ORIGIN) {
|
|
157
|
+
throw new WechatCgiDraftError(
|
|
158
|
+
'request-failed',
|
|
159
|
+
'assert-origin',
|
|
160
|
+
`CGI dispatch requires page origin ${MP_ORIGIN}, got ${origin}`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Self-contained page script: the URL token wins when numeric-valid, otherwise KNOWN inline field
|
|
167
|
+
* assignments are read from a bounded head-truncated snapshot via bounded regexes only — no eval,
|
|
168
|
+
* no Function constructor, and the snapshot itself never crosses back, only the extracted fields.
|
|
169
|
+
* `ticket_id` is read because the vendor's filetransfer contract sends it AS the user_name.
|
|
170
|
+
*/
|
|
171
|
+
function extractSessionExpression(): string {
|
|
172
|
+
return `(function () {
|
|
173
|
+
const fields = {};
|
|
174
|
+
const urlToken = new URLSearchParams(location.search).get('token');
|
|
175
|
+
if (urlToken !== null && /^\\d{4,32}$/.test(urlToken)) fields.token = urlToken;
|
|
176
|
+
const snapshot = document.documentElement.outerHTML.slice(0, ${MAX_SNAPSHOT_CHARS});
|
|
177
|
+
const first = (regex) => {
|
|
178
|
+
const match = regex.exec(snapshot);
|
|
179
|
+
return match === null ? undefined : match[1];
|
|
180
|
+
};
|
|
181
|
+
// URL token wins, but ticket/user_name/uin are scan-only: a URL-token page still needs them,
|
|
182
|
+
// so only the token lookup is skipped when the URL already supplied it (task 0290 chunk C bugfix).
|
|
183
|
+
if (!fields.token) {
|
|
184
|
+
const token = first(/\\btoken\\s*=\\s*['"](\\d{4,32})['"]/);
|
|
185
|
+
if (token !== undefined) fields.token = token;
|
|
186
|
+
}
|
|
187
|
+
const ticketId = first(/\\bticket_id\\s*=\\s*['"]([A-Za-z0-9]{8,128})['"]/);
|
|
188
|
+
if (ticketId !== undefined) fields.ticketId = ticketId;
|
|
189
|
+
const ticket = first(/\\bticket\\s*=\\s*['"]([A-Za-z0-9]{8,128})['"]/);
|
|
190
|
+
if (ticket !== undefined) fields.ticket = ticket;
|
|
191
|
+
const userName = first(/\\buser_name\\s*=\\s*['"](@[A-Za-z0-9_-]{1,63}|gh_[A-Za-z0-9_-]{1,61})['"]/);
|
|
192
|
+
if (userName !== undefined) fields.userName = userName;
|
|
193
|
+
const uin = first(/\\buin\\s*=\\s*['"](\\d{1,32})['"]/);
|
|
194
|
+
if (uin !== undefined) fields.uin = uin;
|
|
195
|
+
return JSON.stringify({ kind: 'ok', ...fields });
|
|
196
|
+
})()`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
interface SessionFields {
|
|
200
|
+
token?: string;
|
|
201
|
+
ticket?: string;
|
|
202
|
+
ticketId?: string;
|
|
203
|
+
userName?: string;
|
|
204
|
+
uin?: string;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function readSessionFields(raw: unknown): SessionFields {
|
|
208
|
+
if (typeof raw !== 'string') return {};
|
|
209
|
+
let parsed: unknown;
|
|
210
|
+
try {
|
|
211
|
+
parsed = JSON.parse(raw);
|
|
212
|
+
} catch {
|
|
213
|
+
return {};
|
|
214
|
+
}
|
|
215
|
+
if (!isRecord(parsed)) return {};
|
|
216
|
+
const field = (key: string): string | undefined => {
|
|
217
|
+
const value = parsed[key];
|
|
218
|
+
return typeof value === 'string' && value !== '' ? value : undefined;
|
|
219
|
+
};
|
|
220
|
+
return {
|
|
221
|
+
token: field('token'),
|
|
222
|
+
ticket: field('ticket'),
|
|
223
|
+
ticketId: field('ticketId'),
|
|
224
|
+
userName: field('userName'),
|
|
225
|
+
uin: field('uin'),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Extracts the transient console session in-page (task 0290 R1): URL token first, then the known
|
|
231
|
+
* inline fields. token/ticket/user_name are all required (media preparation always needs
|
|
232
|
+
* ticket+user_name); uin is optional. Missing fields refuse by NAME only — never by value.
|
|
233
|
+
*/
|
|
234
|
+
export async function extractWechatCgiSession(page: WechatPage): Promise<WechatCgiSession> {
|
|
235
|
+
assertMpOrigin(page);
|
|
236
|
+
let raw: unknown;
|
|
237
|
+
try {
|
|
238
|
+
raw = await page.evaluate(extractSessionExpression());
|
|
239
|
+
} catch (error) {
|
|
240
|
+
throw new WechatCgiDraftError(
|
|
241
|
+
'session-missing',
|
|
242
|
+
'extract-session',
|
|
243
|
+
`session extraction failed inside the console page: ${stripQuery(String(error))}`,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
const fields = readSessionFields(raw);
|
|
247
|
+
const userName = fields.userName ?? fields.ticketId;
|
|
248
|
+
const { token, ticket } = fields;
|
|
249
|
+
if (token === undefined || ticket === undefined || userName === undefined) {
|
|
250
|
+
const missing = [
|
|
251
|
+
...(token === undefined ? ['token'] : []),
|
|
252
|
+
...(ticket === undefined ? ['ticket'] : []),
|
|
253
|
+
...(userName === undefined ? ['user_name'] : []),
|
|
254
|
+
];
|
|
255
|
+
throw new WechatCgiDraftError(
|
|
256
|
+
'session-missing',
|
|
257
|
+
'extract-session',
|
|
258
|
+
`authenticated session field(s) missing on the console page: ${missing.join(', ')}`,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
return { token, ticket, userName, uin: fields.uin };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function buildUrl(base: string, params: Record<string, string>): string {
|
|
265
|
+
return `${base}?${new URLSearchParams(params).toString()}`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function randomHex16(): string {
|
|
269
|
+
const bytes = crypto.getRandomValues(new Uint8Array(8));
|
|
270
|
+
let hex = '';
|
|
271
|
+
for (const byte of bytes) hex += byte.toString(16).padStart(2, '0');
|
|
272
|
+
return hex;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** The frozen operate_appmsg create payload: url plus the exact baseline multipart fields. */
|
|
276
|
+
export interface CgiCreateArticlePayload {
|
|
277
|
+
url: string;
|
|
278
|
+
fields: Record<string, string>;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Frozen baseline fields (task 0290 Design, from recovered createArticleDraft lines 385–447) in
|
|
283
|
+
* frozen key order — no invented vendor scalars. Cover URLs/crops come from the 0289
|
|
284
|
+
* uploadAndCropCover receipt with its ACTUAL coordinates (CoverPair.crops), never vendor pixels.
|
|
285
|
+
* Digest/author default to '' when absent; limits stay owned by modes.ts validation, no clamping.
|
|
286
|
+
*/
|
|
287
|
+
export function buildCreateArticlePayload(
|
|
288
|
+
session: WechatCgiSession,
|
|
289
|
+
content: Content,
|
|
290
|
+
media: WechatArticleMedia,
|
|
291
|
+
opts?: { random?: string },
|
|
292
|
+
): CgiCreateArticlePayload {
|
|
293
|
+
const metadata = content.metadata ?? {};
|
|
294
|
+
const plain = (key: string): string => {
|
|
295
|
+
const value = metadata[key];
|
|
296
|
+
return typeof value === 'string' ? value : '';
|
|
297
|
+
};
|
|
298
|
+
const fields: Record<string, string> = {
|
|
299
|
+
token: session.token,
|
|
300
|
+
lang: 'zh_CN',
|
|
301
|
+
f: 'json',
|
|
302
|
+
ajax: '1',
|
|
303
|
+
random: opts?.random ?? randomHex16(),
|
|
304
|
+
AppMsgId: '',
|
|
305
|
+
count: '1',
|
|
306
|
+
data_seq: '0',
|
|
307
|
+
operate_from: 'Chrome',
|
|
308
|
+
isnew: '0',
|
|
309
|
+
type: '77',
|
|
310
|
+
title0: content.title ?? '',
|
|
311
|
+
author0: plain('author'),
|
|
312
|
+
// Parity with the DOM path: an absent metadata.digest still sends the body-derived 摘要
|
|
313
|
+
// (`deriveDigest`, capped at DERIVED_DIGEST_MAX) instead of dropping it to an empty digest0.
|
|
314
|
+
digest0: deriveDigest(content) ?? '',
|
|
315
|
+
auto_gen_digest0: '0',
|
|
316
|
+
content0: media.html,
|
|
317
|
+
sourceurl0: '',
|
|
318
|
+
cdn_url0: media.cover.wide.url,
|
|
319
|
+
cdn_235_1_url0: media.cover.wide.url,
|
|
320
|
+
cdn_1_1_url0: media.cover.square.url,
|
|
321
|
+
cdn_url_back0: media.cover.wide.url,
|
|
322
|
+
crop_list0: JSON.stringify(media.cover.crops),
|
|
323
|
+
share_page_type0: '0',
|
|
324
|
+
share_imageinfo0: JSON.stringify({ list: [] }),
|
|
325
|
+
categories_list0: '[]',
|
|
326
|
+
compose_info0: JSON.stringify({ list: '' }),
|
|
327
|
+
};
|
|
328
|
+
return {
|
|
329
|
+
url: buildUrl(CREATE_URL, {
|
|
330
|
+
t: 'ajax-response',
|
|
331
|
+
sub: 'create',
|
|
332
|
+
type: '77',
|
|
333
|
+
token: session.token,
|
|
334
|
+
lang: 'zh_CN',
|
|
335
|
+
}),
|
|
336
|
+
fields,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
interface CgiFetchSpec {
|
|
341
|
+
url: string;
|
|
342
|
+
method: 'POST' | 'GET';
|
|
343
|
+
fields: Record<string, string>;
|
|
344
|
+
maxBytes: number;
|
|
345
|
+
timeoutMs: number;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Self-contained page script (same closed shape as the 0289 CGI dispatch): the page's
|
|
350
|
+
* authenticated cookies, redirect refusal, streamed against the byte bound, only a bounded JSON
|
|
351
|
+
* verdict crosses back.
|
|
352
|
+
*/
|
|
353
|
+
function cgiFetchExpression(spec: CgiFetchSpec): string {
|
|
354
|
+
return `(async () => {
|
|
355
|
+
const spec = ${JSON.stringify(spec)};
|
|
356
|
+
try {
|
|
357
|
+
const init = {
|
|
358
|
+
method: spec.method,
|
|
359
|
+
credentials: 'include',
|
|
360
|
+
redirect: 'error',
|
|
361
|
+
signal: AbortSignal.timeout(spec.timeoutMs),
|
|
362
|
+
};
|
|
363
|
+
if (spec.method === 'POST') {
|
|
364
|
+
const form = new FormData();
|
|
365
|
+
for (const key of Object.keys(spec.fields)) form.append(key, spec.fields[key]);
|
|
366
|
+
init.body = form;
|
|
367
|
+
}
|
|
368
|
+
const response = await fetch(spec.url, init);
|
|
369
|
+
if (!response.ok) return JSON.stringify({ kind: 'http-status', status: response.status });
|
|
370
|
+
if (!response.body) return JSON.stringify({ kind: 'error', detail: 'response has no body stream' });
|
|
371
|
+
const reader = response.body.getReader();
|
|
372
|
+
let total = 0;
|
|
373
|
+
const chunks = [];
|
|
374
|
+
for (;;) {
|
|
375
|
+
const { done, value } = await reader.read();
|
|
376
|
+
if (done) break;
|
|
377
|
+
total += value.byteLength;
|
|
378
|
+
if (total > spec.maxBytes) return JSON.stringify({ kind: 'too-large' });
|
|
379
|
+
chunks.push(value);
|
|
380
|
+
}
|
|
381
|
+
const text = await new Blob(chunks).text();
|
|
382
|
+
return JSON.stringify({ kind: 'ok', status: response.status, text });
|
|
383
|
+
} catch (error) {
|
|
384
|
+
return JSON.stringify({ kind: 'error', detail: String(error) });
|
|
385
|
+
}
|
|
386
|
+
})()`;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
type CgiVerdict =
|
|
390
|
+
| { kind: 'ok'; status: number; text: string }
|
|
391
|
+
| { kind: 'http-status'; status: number }
|
|
392
|
+
| { kind: 'too-large' }
|
|
393
|
+
| { kind: 'error'; detail: string };
|
|
394
|
+
|
|
395
|
+
/** Undefined when the page gave back nothing usable; the caller owns the failure semantics. */
|
|
396
|
+
async function evaluateCgiVerdict(page: WechatPage, expression: string): Promise<CgiVerdict | undefined> {
|
|
397
|
+
const raw: unknown = await page.evaluate(expression);
|
|
398
|
+
if (typeof raw !== 'string') return undefined;
|
|
399
|
+
try {
|
|
400
|
+
return JSON.parse(raw) as CgiVerdict;
|
|
401
|
+
} catch {
|
|
402
|
+
return undefined;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Dispatched but unverifiable — the create may exist server-side, so it must never be retried. */
|
|
407
|
+
function ambiguousSubmission(stage: string, detail: string): WechatCgiDraftError {
|
|
408
|
+
return new WechatCgiDraftError('ambiguous-submission', stage, detail, { ambiguousSubmission: true });
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* The R2 create: eligibility re-asserted at the mutation boundary, ONE same-origin POST, receipt
|
|
413
|
+
* gated (HTTP ok, ret absent|0, safe numeric appMsgId), then the id+title readback. Any
|
|
414
|
+
* unverifiable create outcome throws ambiguous with metadata.ambiguousSubmission=true; readback
|
|
415
|
+
* failures throw readback-failed (the create receipt itself already verified). Never retries.
|
|
416
|
+
*/
|
|
417
|
+
export async function createWechatArticleDraft(
|
|
418
|
+
page: WechatPage,
|
|
419
|
+
session: WechatCgiSession,
|
|
420
|
+
content: Content,
|
|
421
|
+
media: WechatArticleMedia,
|
|
422
|
+
): Promise<{ appMsgId: string; editPath: string }> {
|
|
423
|
+
assertEligibleCgiDraft(content);
|
|
424
|
+
assertMpOrigin(page);
|
|
425
|
+
const payload = buildCreateArticlePayload(session, content, media);
|
|
426
|
+
let createVerdict: CgiVerdict | undefined;
|
|
427
|
+
try {
|
|
428
|
+
createVerdict = await evaluateCgiVerdict(
|
|
429
|
+
page,
|
|
430
|
+
cgiFetchExpression({
|
|
431
|
+
url: payload.url,
|
|
432
|
+
method: 'POST',
|
|
433
|
+
fields: payload.fields,
|
|
434
|
+
maxBytes: MAX_RESPONSE_BYTES,
|
|
435
|
+
timeoutMs: REQUEST_TIMEOUT_MS,
|
|
436
|
+
}),
|
|
437
|
+
);
|
|
438
|
+
} catch (error) {
|
|
439
|
+
throw ambiguousSubmission(
|
|
440
|
+
'create-dispatch',
|
|
441
|
+
`operate_appmsg create ended before a receipt returned: ${stripQuery(String(error))}`,
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
if (createVerdict?.kind === 'http-status') {
|
|
445
|
+
throw ambiguousSubmission('create-dispatch', `operate_appmsg create returned HTTP ${createVerdict.status}`);
|
|
446
|
+
}
|
|
447
|
+
if (createVerdict?.kind === 'too-large') {
|
|
448
|
+
throw ambiguousSubmission('create-dispatch', `create response exceeded the ${MAX_RESPONSE_BYTES}-byte bound`);
|
|
449
|
+
}
|
|
450
|
+
if (createVerdict?.kind !== 'ok') {
|
|
451
|
+
const detail =
|
|
452
|
+
createVerdict?.kind === 'error' ? stripQuery(createVerdict.detail) : 'page returned no usable verdict';
|
|
453
|
+
throw ambiguousSubmission('create-dispatch', `create failed before a usable response: ${detail}`);
|
|
454
|
+
}
|
|
455
|
+
let body: unknown;
|
|
456
|
+
try {
|
|
457
|
+
body = JSON.parse(createVerdict.text);
|
|
458
|
+
} catch {
|
|
459
|
+
throw ambiguousSubmission('create-receipt', 'create receipt is not JSON');
|
|
460
|
+
}
|
|
461
|
+
if (!isRecord(body)) {
|
|
462
|
+
throw ambiguousSubmission('create-receipt', 'create receipt is not a JSON object');
|
|
463
|
+
}
|
|
464
|
+
if (body.ret !== undefined && body.ret !== 0) {
|
|
465
|
+
// The server answered with an explicit refusal — definitive, not ambiguous.
|
|
466
|
+
throw new WechatCgiDraftError(
|
|
467
|
+
'receipt-invalid',
|
|
468
|
+
'create-receipt',
|
|
469
|
+
`operate_appmsg create refused: ret=${String(body.ret)}`,
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
const receipt = isRecord(body.appMsgInfo) ? body.appMsgInfo : body;
|
|
473
|
+
const rawId = receipt.appMsgId;
|
|
474
|
+
const appMsgId = typeof rawId === 'string' || typeof rawId === 'number' ? String(rawId) : undefined;
|
|
475
|
+
if (appMsgId === undefined || !/^-?\d{1,12}$/.test(appMsgId)) {
|
|
476
|
+
throw ambiguousSubmission('create-receipt', 'create receipt did not return a safe numeric appMsgId');
|
|
477
|
+
}
|
|
478
|
+
const listUrl = buildUrl(APPMSG_URL, {
|
|
479
|
+
action: 'list_ex',
|
|
480
|
+
begin: '0',
|
|
481
|
+
count: '5',
|
|
482
|
+
token: session.token,
|
|
483
|
+
lang: 'zh_CN',
|
|
484
|
+
f: 'json',
|
|
485
|
+
});
|
|
486
|
+
let listVerdict: CgiVerdict | undefined;
|
|
487
|
+
try {
|
|
488
|
+
listVerdict = await evaluateCgiVerdict(
|
|
489
|
+
page,
|
|
490
|
+
cgiFetchExpression({
|
|
491
|
+
url: listUrl,
|
|
492
|
+
method: 'GET',
|
|
493
|
+
fields: {},
|
|
494
|
+
maxBytes: MAX_RESPONSE_BYTES,
|
|
495
|
+
timeoutMs: REQUEST_TIMEOUT_MS,
|
|
496
|
+
}),
|
|
497
|
+
);
|
|
498
|
+
} catch (error) {
|
|
499
|
+
throw new WechatCgiDraftError(
|
|
500
|
+
'readback-failed',
|
|
501
|
+
'readback',
|
|
502
|
+
`draft list readback ended before a receipt returned: ${stripQuery(String(error))}`,
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
if (listVerdict?.kind !== 'ok') {
|
|
506
|
+
const detail =
|
|
507
|
+
listVerdict?.kind === 'http-status'
|
|
508
|
+
? `HTTP ${listVerdict.status}`
|
|
509
|
+
: listVerdict?.kind === 'too-large'
|
|
510
|
+
? `${MAX_RESPONSE_BYTES}-byte bound exceeded`
|
|
511
|
+
: listVerdict?.kind === 'error'
|
|
512
|
+
? stripQuery(listVerdict.detail)
|
|
513
|
+
: 'page returned no usable verdict';
|
|
514
|
+
throw new WechatCgiDraftError('readback-failed', 'readback', `draft list readback failed: ${detail}`);
|
|
515
|
+
}
|
|
516
|
+
let list: unknown;
|
|
517
|
+
try {
|
|
518
|
+
list = JSON.parse(listVerdict.text);
|
|
519
|
+
} catch {
|
|
520
|
+
throw new WechatCgiDraftError('readback-failed', 'readback', 'draft list receipt is not JSON');
|
|
521
|
+
}
|
|
522
|
+
const items = isRecord(list) && Array.isArray(list.app_msg_list) ? list.app_msg_list : [];
|
|
523
|
+
const confirmed = items.some(
|
|
524
|
+
(item) =>
|
|
525
|
+
isRecord(item) &&
|
|
526
|
+
item.title === content.title &&
|
|
527
|
+
(String(item.app_id) === appMsgId || String(item.appmsgid) === appMsgId),
|
|
528
|
+
);
|
|
529
|
+
if (!confirmed) {
|
|
530
|
+
throw new WechatCgiDraftError(
|
|
531
|
+
'readback-failed',
|
|
532
|
+
'readback',
|
|
533
|
+
'draft list did not confirm an item matching the created appMsgId and title',
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
return {
|
|
537
|
+
appMsgId,
|
|
538
|
+
editPath: `/cgi-bin/appmsg?t=media/appmsg_edit&action=edit&isNew=0&type=77&appmsgid=${appMsgId}`,
|
|
539
|
+
};
|
|
540
|
+
}
|