@rongyan/wxpost-server 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/src/draft.js ADDED
@@ -0,0 +1,151 @@
1
+ 'use strict';
2
+
3
+ const https = require('https');
4
+
5
+ const DRAFT_ADD_URL = 'https://api.weixin.qq.com/cgi-bin/draft/add';
6
+ const REQUEST_TIMEOUT_MS = 10 * 1000;
7
+
8
+ const VALID_ARTICLE_TYPES = new Set(['news', 'newspic']);
9
+
10
+ /**
11
+ * 校验单篇文章字段,违规返回错误描述字符串,合法返回 null。
12
+ */
13
+ function validateArticle(a, idx) {
14
+ const p = `articles[${idx}]`;
15
+
16
+ // article_type
17
+ if (a.article_type !== undefined && !VALID_ARTICLE_TYPES.has(a.article_type)) {
18
+ return `${p}.article_type 须为 "news" 或 "newspic"`;
19
+ }
20
+ const type = a.article_type || 'news';
21
+
22
+ // title:必填,超过 64 字自动截断
23
+ if (!a.title) return `${p}.title 必填`;
24
+ if (typeof a.title !== 'string') return `${p}.title 须为字符串`;
25
+ if (a.title.length > 64) a.title = a.title.slice(0, 64);
26
+
27
+ // author:选填,最多 16 字
28
+ if (a.author !== undefined) {
29
+ if (typeof a.author !== 'string') return `${p}.author 须为字符串`;
30
+ if (a.author.length > 16) return `${p}.author 最多 16 字,当前 ${a.author.length} 字`;
31
+ }
32
+
33
+ // digest:选填,最多 128 字
34
+ if (a.digest !== undefined) {
35
+ if (typeof a.digest !== 'string') return `${p}.digest 须为字符串`;
36
+ if (a.digest.length > 128) return `${p}.digest 最多 128 字,当前 ${a.digest.length} 字`;
37
+ }
38
+
39
+ // content:必填,最多 2 万字
40
+ if (!a.content) return `${p}.content 必填`;
41
+ if (typeof a.content !== 'string') return `${p}.content 须为字符串`;
42
+ if (a.content.length > 20000) return `${p}.content 最多 2 万字,当前 ${a.content.length} 字`;
43
+
44
+ // content_source_url:选填
45
+ if (a.content_source_url !== undefined && typeof a.content_source_url !== 'string') {
46
+ return `${p}.content_source_url 须为字符串`;
47
+ }
48
+
49
+ // thumb_media_id:news 类型必填
50
+ if (type === 'news') {
51
+ if (!a.thumb_media_id) return `${p}.thumb_media_id 在 news 类型中必填`;
52
+ if (typeof a.thumb_media_id !== 'string') return `${p}.thumb_media_id 须为字符串`;
53
+ }
54
+
55
+ // image_info:newspic 类型必填,最多 20 张
56
+ if (type === 'newspic') {
57
+ if (!a.image_info) return `${p}.image_info 在 newspic 类型中必填`;
58
+ if (!Array.isArray(a.image_info.list)) return `${p}.image_info.list 须为数组`;
59
+ if (a.image_info.list.length === 0) return `${p}.image_info.list 不能为空`;
60
+ if (a.image_info.list.length > 20) return `${p}.image_info.list 最多 20 张,当前 ${a.image_info.list.length} 张`;
61
+ }
62
+
63
+ // need_open_comment:选填,0 或 1
64
+ if (a.need_open_comment !== undefined) {
65
+ if (a.need_open_comment !== 0 && a.need_open_comment !== 1) {
66
+ return `${p}.need_open_comment 须为 0 或 1`;
67
+ }
68
+ }
69
+
70
+ // only_fans_can_comment:选填,0 或 1
71
+ if (a.only_fans_can_comment !== undefined) {
72
+ if (a.only_fans_can_comment !== 0 && a.only_fans_can_comment !== 1) {
73
+ return `${p}.only_fans_can_comment 须为 0 或 1`;
74
+ }
75
+ }
76
+
77
+ // pic_crop_235_1:选填,字符串,封面裁剪坐标 2.35:1
78
+ if (a.pic_crop_235_1 !== undefined && typeof a.pic_crop_235_1 !== 'string') {
79
+ return `${p}.pic_crop_235_1 须为字符串`;
80
+ }
81
+
82
+ // pic_crop_1_1:选填,字符串,封面裁剪坐标 1:1
83
+ if (a.pic_crop_1_1 !== undefined && typeof a.pic_crop_1_1 !== 'string') {
84
+ return `${p}.pic_crop_1_1 须为字符串`;
85
+ }
86
+
87
+ // cover_info:选填,对象
88
+ if (a.cover_info !== undefined && (typeof a.cover_info !== 'object' || Array.isArray(a.cover_info))) {
89
+ return `${p}.cover_info 须为对象`;
90
+ }
91
+
92
+ // product_info:选填,对象
93
+ if (a.product_info !== undefined && (typeof a.product_info !== 'object' || Array.isArray(a.product_info))) {
94
+ return `${p}.product_info 须为对象`;
95
+ }
96
+
97
+ return null;
98
+ }
99
+
100
+ /**
101
+ * 新增草稿,返回 media_id。
102
+ * @param {string} accessToken
103
+ * @param {Array<object>} articles
104
+ */
105
+ function addDraft(accessToken, articles) {
106
+ if (!Array.isArray(articles) || articles.length === 0) {
107
+ return Promise.reject(new Error('articles 不能为空'));
108
+ }
109
+
110
+ for (let i = 0; i < articles.length; i++) {
111
+ const err = validateArticle(articles[i], i);
112
+ if (err) return Promise.reject(new Error(err));
113
+ }
114
+
115
+ const body = JSON.stringify({ articles });
116
+
117
+ return new Promise((resolve, reject) => {
118
+ const req = https.request(
119
+ `${DRAFT_ADD_URL}?access_token=${accessToken}`,
120
+ {
121
+ method: 'POST',
122
+ headers: {
123
+ 'Content-Type': 'application/json; charset=utf-8',
124
+ 'Content-Length': Buffer.byteLength(body),
125
+ },
126
+ },
127
+ (res) => {
128
+ let raw = '';
129
+ res.on('data', (chunk) => { raw += chunk; });
130
+ res.on('end', () => {
131
+ let result;
132
+ try {
133
+ result = JSON.parse(raw);
134
+ } catch {
135
+ return reject(new Error(`微信接口响应解析失败: ${raw}`));
136
+ }
137
+ if (result.errcode) {
138
+ return reject(new Error(`新增草稿失败 [${result.errcode}]: ${result.errmsg}`));
139
+ }
140
+ resolve(result.media_id);
141
+ });
142
+ }
143
+ );
144
+ req.setTimeout(REQUEST_TIMEOUT_MS, () => req.destroy(new Error('新增草稿请求超时')));
145
+ req.on('error', reject);
146
+ req.write(body);
147
+ req.end();
148
+ });
149
+ }
150
+
151
+ module.exports = { addDraft };
package/src/index.js ADDED
@@ -0,0 +1,297 @@
1
+ 'use strict';
2
+
3
+ const http = require('http');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const busboy = require('busboy');
7
+ const { loadConfig, getAccount, getUploadDir } = require('./config');
8
+ const { getStableToken } = require('./token');
9
+ const { uploadImageToWx, MAX_SIZE } = require('./upload-image');
10
+ const { addDraft } = require('./draft');
11
+ const { getDraftList } = require('./draft-list');
12
+ const { publishDraft } = require('./publish');
13
+ const { addImageMaterial, MAX_SIZE: MATERIAL_MAX_SIZE } = require('./material');
14
+ const { checkApiKey } = require('./auth');
15
+ const logger = require('./logger');
16
+
17
+ const BODY_LIMIT = 2 * 1024 * 1024; // 2MB
18
+
19
+ function sendJSON(res, statusCode, data) {
20
+ if (statusCode >= 400 && data.error) res._logError = data.error;
21
+ res.writeHead(statusCode, { 'Content-Type': 'application/json' });
22
+ res.end(JSON.stringify(data));
23
+ }
24
+
25
+ function readJSON(req, limit = BODY_LIMIT) {
26
+ return new Promise((resolve, reject) => {
27
+ let raw = '';
28
+ let size = 0;
29
+ req.setEncoding('utf-8');
30
+ req.on('data', (chunk) => {
31
+ size += Buffer.byteLength(chunk);
32
+ if (size > limit) return;
33
+ raw += chunk;
34
+ });
35
+ req.on('end', () => {
36
+ if (size > limit) return reject({ status: 413, message: '请求体超过 2MB 限制' });
37
+ try {
38
+ resolve(raw ? JSON.parse(raw) : {});
39
+ } catch {
40
+ reject({ status: 400, message: '请求体须为合法 JSON' });
41
+ }
42
+ });
43
+ req.on('error', (e) => reject({ status: 400, message: e.message }));
44
+ });
45
+ }
46
+
47
+ async function withAuth(req, res, handler) {
48
+ const authErr = checkApiKey(req);
49
+ if (authErr) return sendJSON(res, 401, { ok: false, error: authErr });
50
+
51
+ const qs = new URL(req.url, 'http://localhost').searchParams;
52
+ const appId = qs.get('appid') || undefined;
53
+
54
+ let account;
55
+ try {
56
+ account = getAccount(appId);
57
+ } catch (e) {
58
+ return sendJSON(res, 400, { ok: false, error: e.message });
59
+ }
60
+
61
+ let accessToken;
62
+ try {
63
+ accessToken = await getStableToken(account.appId, account.appSecret);
64
+ } catch (e) {
65
+ return sendJSON(res, 502, { ok: false, error: e.message });
66
+ }
67
+
68
+ return handler({ account, accessToken });
69
+ }
70
+
71
+ /**
72
+ * 通用文件接收:busboy 接收 multipart 文件后调用 processor(buffer, filename)。
73
+ * processor 抛出的错误若带 statusCode 属性则使用该状态码,否则默认 502。
74
+ */
75
+ function receiveFile(req, res, account, sizeLimit, processor, sizeLimitLabel) {
76
+ const uploadDir = getUploadDir();
77
+ fs.mkdirSync(uploadDir, { recursive: true });
78
+
79
+ const bb = busboy({ headers: req.headers, limits: { files: 1, fileSize: sizeLimit } });
80
+ let settled = false;
81
+
82
+ bb.on('file', (_fieldname, stream, info) => {
83
+ const { filename } = info;
84
+ const chunks = [];
85
+
86
+ stream.on('data', (chunk) => chunks.push(chunk));
87
+ stream.on('limit', () => {
88
+ settled = true;
89
+ stream.resume();
90
+ logger.warn(`[${account.appId}] 上传图片超过限制: ${filename}`);
91
+ sendJSON(res, 413, { ok: false, error: `图片超过 ${sizeLimitLabel} 限制` });
92
+ });
93
+ stream.on('end', async () => {
94
+ if (settled) return;
95
+ settled = true;
96
+
97
+ const buffer = Buffer.concat(chunks);
98
+ const now = new Date();
99
+ const datestamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`;
100
+ const safeBasename = filename.split(/[\\/]/).pop() || 'image';
101
+ const localPath = path.join(uploadDir, `${datestamp}_${safeBasename}`);
102
+
103
+ try {
104
+ fs.writeFileSync(localPath, buffer);
105
+ } catch (e) {
106
+ logger.error(`[${account.appId}] 本地保存失败: ${e.stack || e.message}`);
107
+ return sendJSON(res, 500, { ok: false, error: `本地保存失败: ${e.message}` });
108
+ }
109
+
110
+ try {
111
+ const result = await processor(buffer, filename);
112
+ fs.unlink(localPath, () => {});
113
+ sendJSON(res, 200, { ok: true, ...result });
114
+ } catch (e) {
115
+ fs.unlink(localPath, () => {});
116
+ const status = e.statusCode || 502;
117
+ if (status < 500) {
118
+ logger.warn(`[${account.appId}] 上传校验失败: ${e.message}`);
119
+ } else {
120
+ logger.error(`[${account.appId}] 上传微信失败: ${e.stack || e.message}`);
121
+ }
122
+ sendJSON(res, status, { ok: false, error: e.message });
123
+ }
124
+ });
125
+ });
126
+
127
+ bb.on('finish', () => {
128
+ if (!settled) {
129
+ settled = true;
130
+ sendJSON(res, 400, { ok: false, error: '请求中未包含文件' });
131
+ }
132
+ });
133
+
134
+ bb.on('error', (e) => {
135
+ if (!settled) {
136
+ settled = true;
137
+ logger.error(`[${account.appId}] 请求解析失败: ${e.stack || e.message}`);
138
+ sendJSON(res, 400, { ok: false, error: `请求解析失败: ${e.message}` });
139
+ }
140
+ });
141
+
142
+ req.pipe(bb);
143
+ }
144
+
145
+ async function handleUploadImage(req, res) {
146
+ await withAuth(req, res, async ({ account, accessToken }) => {
147
+ receiveFile(req, res, account, MAX_SIZE, async (buffer, filename) => {
148
+ const url = await uploadImageToWx({ accessToken, buffer });
149
+ logger.info(`[${account.appId}] 内容图上传成功: ${filename} -> ${url}`);
150
+ return { url };
151
+ }, '1MB');
152
+ });
153
+ }
154
+
155
+ async function handleUploadMaterial(req, res) {
156
+ await withAuth(req, res, async ({ account, accessToken }) => {
157
+ receiveFile(req, res, account, MATERIAL_MAX_SIZE, async (buffer, filename) => {
158
+ const result = await addImageMaterial(accessToken, { buffer });
159
+ logger.info(`[${account.appId}] 永久素材上传成功: ${filename} -> media_id=${result.media_id}`);
160
+ return result;
161
+ }, '10MB');
162
+ });
163
+ }
164
+
165
+ async function handleAddDraft(req, res) {
166
+ await withAuth(req, res, async ({ account, accessToken }) => {
167
+ let payload;
168
+ try {
169
+ payload = await readJSON(req);
170
+ } catch (e) {
171
+ return sendJSON(res, e.status, { ok: false, error: e.message });
172
+ }
173
+
174
+ if (!Array.isArray(payload.articles)) {
175
+ return sendJSON(res, 400, { ok: false, error: '缺少 articles 数组' });
176
+ }
177
+
178
+ try {
179
+ const mediaId = await addDraft(accessToken, payload.articles);
180
+ logger.info(`[${account.appId}] 新增草稿成功: media_id=${mediaId}, 共 ${payload.articles.length} 篇`);
181
+ sendJSON(res, 200, { ok: true, media_id: mediaId });
182
+ } catch (e) {
183
+ logger.error(`[${account.appId}] 新增草稿失败: ${e.stack || e.message}`);
184
+ sendJSON(res, 400, { ok: false, error: e.message });
185
+ }
186
+ });
187
+ }
188
+
189
+ async function handleDraftList(req, res) {
190
+ await withAuth(req, res, async ({ account, accessToken }) => {
191
+ let payload;
192
+ try {
193
+ payload = await readJSON(req);
194
+ } catch (e) {
195
+ return sendJSON(res, e.status, { ok: false, error: e.message });
196
+ }
197
+
198
+ const offset = Number(payload.offset ?? 0);
199
+ const count = Number(payload.count ?? 20);
200
+ const no_content = Number(payload.no_content ?? 0);
201
+
202
+ try {
203
+ const result = await getDraftList(accessToken, { offset, count, no_content });
204
+ logger.info(`[${account.appId}] 获取草稿列表: offset=${offset}, count=${count}, 返回 ${result.item_count}/${result.total_count}`);
205
+ sendJSON(res, 200, { ok: true, ...result });
206
+ } catch (e) {
207
+ logger.error(`[${account.appId}] 获取草稿列表失败: ${e.stack || e.message}`);
208
+ sendJSON(res, 400, { ok: false, error: e.message });
209
+ }
210
+ });
211
+ }
212
+
213
+ async function handlePublishDraft(req, res) {
214
+ await withAuth(req, res, async ({ account, accessToken }) => {
215
+ let payload;
216
+ try {
217
+ payload = await readJSON(req);
218
+ } catch (e) {
219
+ return sendJSON(res, e.status, { ok: false, error: e.message });
220
+ }
221
+
222
+ if (!payload.media_id) {
223
+ return sendJSON(res, 400, { ok: false, error: '缺少 media_id' });
224
+ }
225
+
226
+ try {
227
+ const result = await publishDraft(accessToken, payload.media_id);
228
+ logger.info(`[${account.appId}] 发布草稿成功: media_id=${payload.media_id}, publish_id=${result.publish_id}`);
229
+ sendJSON(res, 200, { ok: true, ...result });
230
+ } catch (e) {
231
+ logger.error(`[${account.appId}] 发布草稿失败: ${e.stack || e.message}`);
232
+ sendJSON(res, 400, { ok: false, error: e.message });
233
+ }
234
+ });
235
+ }
236
+
237
+ async function handleRequest(req, res) {
238
+ const { method, url } = req;
239
+
240
+ if (method === 'POST' && url.startsWith('/upload-material')) {
241
+ return handleUploadMaterial(req, res);
242
+ }
243
+ if (method === 'POST' && url.startsWith('/upload-image')) {
244
+ return handleUploadImage(req, res);
245
+ }
246
+ if (method === 'POST' && url.startsWith('/draft/add')) {
247
+ return handleAddDraft(req, res);
248
+ }
249
+ if (method === 'POST' && url.startsWith('/draft/list')) {
250
+ return handleDraftList(req, res);
251
+ }
252
+ if (method === 'POST' && url.startsWith('/draft/publish')) {
253
+ return handlePublishDraft(req, res);
254
+ }
255
+
256
+ sendJSON(res, 404, { ok: false, error: 'Not Found' });
257
+ }
258
+
259
+ function resolvePort(opts = {}) {
260
+ if (opts.port) return opts.port;
261
+ if (process.env.PORT) return parseInt(process.env.PORT, 10);
262
+ const config = loadConfig();
263
+ return config.port || 3000;
264
+ }
265
+
266
+ function startServer(opts = {}) {
267
+ loadConfig();
268
+ const PORT = resolvePort(opts);
269
+
270
+ const server = http.createServer((req, res) => {
271
+ const start = Date.now();
272
+ // X-Forwarded-For 取第一个 IP(代理场景),否则用直连 IP
273
+ const ip = (req.headers['x-forwarded-for'] || '').split(',')[0].trim()
274
+ || req.socket.remoteAddress
275
+ || '-';
276
+
277
+ res.on('finish', () => {
278
+ const ms = Date.now() - start;
279
+ const level = res.statusCode >= 500 ? 'error' : res.statusCode >= 400 ? 'warn' : 'info';
280
+ const detail = res._logError ? ` — ${res._logError}` : '';
281
+ logger[level](`${ip} ${req.method} ${req.url} ${res.statusCode} +${ms}ms${detail}`);
282
+ });
283
+
284
+ handleRequest(req, res).catch((err) => {
285
+ logger.error(`未处理的请求异常: ${err.stack || err.message}`);
286
+ if (!res.headersSent) sendJSON(res, 500, { ok: false, error: '服务器内部错误' });
287
+ });
288
+ });
289
+
290
+ server.listen(PORT, () => {
291
+ logger.info(`wxpost-server 启动,监听 http://localhost:${PORT}`);
292
+ });
293
+
294
+ return server;
295
+ }
296
+
297
+ module.exports = { startServer };
package/src/logger.js ADDED
@@ -0,0 +1,84 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { getLogDir } = require('./config');
6
+
7
+ const RETAIN_DAYS = 7;
8
+
9
+ // 当前写入的日志文件描述符缓存
10
+ let _currentDate = '';
11
+ let _stream = null;
12
+ let _logDir = null;
13
+
14
+ function getDateString(date = new Date()) {
15
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
16
+ }
17
+
18
+ function getStream() {
19
+ const today = getDateString();
20
+ if (_currentDate === today && _stream) return _stream;
21
+
22
+ // 日期变了,关闭旧 stream
23
+ if (_stream) {
24
+ _stream.end();
25
+ _stream = null;
26
+ }
27
+
28
+ if (!_logDir) {
29
+ _logDir = getLogDir();
30
+ fs.mkdirSync(_logDir, { recursive: true });
31
+ pruneOldLogs(_logDir);
32
+ }
33
+
34
+ const logFile = path.join(_logDir, `${today}.log`);
35
+ _stream = fs.createWriteStream(logFile, { flags: 'a', encoding: 'utf-8' });
36
+ _stream.on('error', () => {
37
+ // 写文件出错(如磁盘满)时重置 stream,下次重试创建;不崩进程
38
+ _stream = null;
39
+ _currentDate = '';
40
+ });
41
+ _currentDate = today;
42
+ return _stream;
43
+ }
44
+
45
+ function pruneOldLogs(logDir) {
46
+ const cutoff = Date.now() - RETAIN_DAYS * 24 * 60 * 60 * 1000;
47
+ let entries;
48
+ try {
49
+ entries = fs.readdirSync(logDir);
50
+ } catch {
51
+ return;
52
+ }
53
+ for (const entry of entries) {
54
+ if (!/^\d{4}-\d{2}-\d{2}\.log$/.test(entry)) continue;
55
+ const filePath = path.join(logDir, entry);
56
+ try {
57
+ const stat = fs.statSync(filePath);
58
+ if (stat.mtimeMs < cutoff) fs.unlinkSync(filePath);
59
+ } catch {
60
+ // 删除失败忽略
61
+ }
62
+ }
63
+ }
64
+
65
+ function write(level, message) {
66
+ const now = new Date();
67
+ const ts = now.toISOString().replace('T', ' ').slice(0, 23);
68
+ const line = `[${ts}] [${level}] ${message}\n`;
69
+ process.stdout.write(line);
70
+ try {
71
+ const stream = getStream();
72
+ if (stream) stream.write(line);
73
+ } catch {
74
+ // 写文件失败不影响主流程
75
+ }
76
+ }
77
+
78
+ const logger = {
79
+ info: (msg) => write('INFO ', msg),
80
+ warn: (msg) => write('WARN ', msg),
81
+ error: (msg) => write('ERROR', msg),
82
+ };
83
+
84
+ module.exports = logger;
@@ -0,0 +1,83 @@
1
+ 'use strict';
2
+
3
+ const https = require('https');
4
+
5
+ const ADD_MATERIAL_URL = 'https://api.weixin.qq.com/cgi-bin/material/add_material';
6
+ const MAX_SIZE = 10 * 1024 * 1024; // 10MB,微信要求严格小于
7
+ const REQUEST_TIMEOUT_MS = 10 * 1000;
8
+
9
+ const FORMAT_MAP = {
10
+ jpeg: { mimetype: 'image/jpeg', ext: '.jpg' },
11
+ png: { mimetype: 'image/png', ext: '.png' },
12
+ gif: { mimetype: 'image/gif', ext: '.gif' },
13
+ bmp: { mimetype: 'image/bmp', ext: '.bmp' },
14
+ };
15
+
16
+ function detectFormat(buffer) {
17
+ if (buffer.length < 4) return null;
18
+ if (buffer[0] === 0xFF && buffer[1] === 0xD8 && buffer[2] === 0xFF) return 'jpeg';
19
+ if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4E && buffer[3] === 0x47) return 'png';
20
+ if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46) return 'gif';
21
+ if (buffer[0] === 0x42 && buffer[1] === 0x4D) return 'bmp';
22
+ return null;
23
+ }
24
+
25
+ function clientError(msg) {
26
+ const err = new Error(msg);
27
+ err.statusCode = 400;
28
+ return err;
29
+ }
30
+
31
+ function addImageMaterial(accessToken, { buffer }) {
32
+ const format = detectFormat(buffer);
33
+ if (!format) {
34
+ return Promise.reject(clientError('不支持的图片格式,永久素材仅支持 JPG/PNG/GIF/BMP'));
35
+ }
36
+ if (buffer.length >= MAX_SIZE) {
37
+ return Promise.reject(clientError(`图片大小 ${(buffer.length / 1024 / 1024).toFixed(1)}MB 须小于 10MB`));
38
+ }
39
+
40
+ const { mimetype, ext } = FORMAT_MAP[format];
41
+ const boundary = `----WxMaterialBoundary${Date.now()}`;
42
+
43
+ const head = Buffer.from(
44
+ `--${boundary}\r\n` +
45
+ `Content-Disposition: form-data; name="media"; filename="material${ext}"\r\n` +
46
+ `Content-Type: ${mimetype}\r\n\r\n`
47
+ );
48
+ const tail = Buffer.from(`\r\n--${boundary}--\r\n`);
49
+ const body = Buffer.concat([head, buffer, tail]);
50
+
51
+ return new Promise((resolve, reject) => {
52
+ const req = https.request(
53
+ `${ADD_MATERIAL_URL}?access_token=${accessToken}&type=image`,
54
+ {
55
+ method: 'POST',
56
+ headers: {
57
+ 'Content-Type': `multipart/form-data; boundary=${boundary}`,
58
+ 'Content-Length': body.length,
59
+ },
60
+ },
61
+ (res) => {
62
+ let raw = '';
63
+ res.on('data', (chunk) => { raw += chunk; });
64
+ res.on('end', () => {
65
+ let result;
66
+ try {
67
+ result = JSON.parse(raw);
68
+ } catch {
69
+ return reject(new Error(`微信接口响应解析失败: ${raw}`));
70
+ }
71
+ if (result.errcode) return reject(new Error(`上传永久素材失败 [${result.errcode}]: ${result.errmsg}`));
72
+ resolve({ media_id: result.media_id, url: result.url });
73
+ });
74
+ }
75
+ );
76
+ req.setTimeout(REQUEST_TIMEOUT_MS, () => req.destroy(new Error('上传永久素材超时')));
77
+ req.on('error', reject);
78
+ req.write(body);
79
+ req.end();
80
+ });
81
+ }
82
+
83
+ module.exports = { addImageMaterial, MAX_SIZE };
package/src/publish.js ADDED
@@ -0,0 +1,54 @@
1
+ 'use strict';
2
+
3
+ const https = require('https');
4
+
5
+ const PUBLISH_URL = 'https://api.weixin.qq.com/cgi-bin/freepublish/submit';
6
+ const REQUEST_TIMEOUT_MS = 10 * 1000;
7
+
8
+ /**
9
+ * 发布草稿,返回 { publish_id, msg_data_id }。
10
+ * @param {string} accessToken
11
+ * @param {string} mediaId 草稿的 media_id
12
+ */
13
+ function publishDraft(accessToken, mediaId) {
14
+ if (!mediaId || typeof mediaId !== 'string') {
15
+ return Promise.reject(new Error('media_id 必填且须为字符串'));
16
+ }
17
+
18
+ const body = JSON.stringify({ media_id: mediaId });
19
+
20
+ return new Promise((resolve, reject) => {
21
+ const req = https.request(
22
+ `${PUBLISH_URL}?access_token=${accessToken}`,
23
+ {
24
+ method: 'POST',
25
+ headers: {
26
+ 'Content-Type': 'application/json; charset=utf-8',
27
+ 'Content-Length': Buffer.byteLength(body),
28
+ },
29
+ },
30
+ (res) => {
31
+ let raw = '';
32
+ res.on('data', (chunk) => { raw += chunk; });
33
+ res.on('end', () => {
34
+ let result;
35
+ try {
36
+ result = JSON.parse(raw);
37
+ } catch {
38
+ return reject(new Error(`微信接口响应解析失败: ${raw}`));
39
+ }
40
+ if (result.errcode && result.errcode !== 0) {
41
+ return reject(new Error(`发布草稿失败 [${result.errcode}]: ${result.errmsg}`));
42
+ }
43
+ resolve({ publish_id: result.publish_id, msg_data_id: result.msg_data_id });
44
+ });
45
+ }
46
+ );
47
+ req.setTimeout(REQUEST_TIMEOUT_MS, () => req.destroy(new Error('发布草稿请求超时')));
48
+ req.on('error', reject);
49
+ req.write(body);
50
+ req.end();
51
+ });
52
+ }
53
+
54
+ module.exports = { publishDraft };