multi-publisher 1.0.2 → 1.0.3

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/README.md CHANGED
@@ -22,23 +22,34 @@
22
22
  ## 安装
23
23
 
24
24
  ```bash
25
- # 源码安装
26
- git clone <repo-url>
27
- cd multi-publisher
28
- npm install
29
- npm run build
25
+ # 推荐:一键安装(Node.js ≥ 18)
26
+ npm install -g multi-publisher
30
27
 
31
- # 链接为全局命令
32
- npm link
33
- mpub --help
28
+ # 验证安装
29
+ mpub --version
34
30
  ```
35
31
 
36
32
  **Node.js ≥ 18** required.
37
33
 
34
+ ### 配合 AI 使用
35
+
36
+ 本工具已制作为 Claude Code Skill,可让 AI 直接帮你发布文章:
37
+
38
+ ```bash
39
+ # 在任意 Claude Code 对话中,直接描述你的需求:
40
+ # "帮我把这篇 article.md 发布到微信公众号"
41
+ # "用 cyberpunk 主题渲染预览一下"
42
+ # AI 会调用 mpub 完成操作
43
+ ```
44
+
45
+ 查看支持的所有命令:`mpub --help`
46
+
38
47
  ---
39
48
 
40
49
  ## 快速开始
41
50
 
51
+ > **配合 AI 使用**:安装本工具后,在 Claude Code 中直接说"帮我发布文章到微信公众号",AI 会调用 `mpub` 自动完成。
52
+
42
53
  ### 1. 配置平台登录
43
54
 
44
55
  **方式一:浏览器自动登录(推荐)**
@@ -15,9 +15,6 @@ export declare class WechatPublisher {
15
15
  * 下载图片并以指定文件名上传到微信 CDN
16
16
  */
17
17
  uploadImageFromUrl(imageUrl: string): Promise<string>;
18
- /**
19
- * 上传本地图片到微信 CDN
20
- */
21
18
  /**
22
19
  * 上传本地图片到微信 CDN(临时素材,用于正文嵌入)
23
20
  */
@@ -27,6 +24,11 @@ export declare class WechatPublisher {
27
24
  * 使用 material/add_material 接口
28
25
  */
29
26
  uploadPermanentImageFromPath(filePath: string, filename?: string): Promise<string>;
27
+ /**
28
+ * 生成占位封面图(当文章无图片时使用)
29
+ * 从占位图服务下载图片并上传为永久素材
30
+ */
31
+ uploadPlaceholderCover(): Promise<string>;
30
32
  /**
31
33
  * 从 URL 下载图片并上传为永久素材(用于封面)
32
34
  */
@@ -1,5 +1,5 @@
1
1
  import axios from 'axios';
2
- import { createReadStream } from 'node:fs';
2
+ import { readFileSync } from 'node:fs';
3
3
  import FormData from 'form-data';
4
4
  import path from 'node:path';
5
5
  import os from 'node:os';
@@ -65,9 +65,6 @@ export class WechatPublisher {
65
65
  this.uploadCache[imageUrl] = mediaId;
66
66
  return mediaId;
67
67
  }
68
- /**
69
- * 上传本地图片到微信 CDN
70
- */
71
68
  /**
72
69
  * 上传本地图片到微信 CDN(临时素材,用于正文嵌入)
73
70
  */
@@ -75,7 +72,7 @@ export class WechatPublisher {
75
72
  const token = await this.getAccessToken();
76
73
  const name = filename ?? path.basename(filePath);
77
74
  const form = new FormData();
78
- form.append('media', createReadStream(filePath), name);
75
+ form.append('media', readFileSync(filePath), { filename: name, contentType: 'image/png' });
79
76
  const res = await axios.post(`https://api.weixin.qq.com/cgi-bin/media/upload?access_token=${token}&type=image`, form, { headers: form.getHeaders(), maxBodyLength: Infinity, maxContentLength: Infinity });
80
77
  const data = res.data;
81
78
  if (!data.media_id) {
@@ -91,7 +88,7 @@ export class WechatPublisher {
91
88
  const token = await this.getAccessToken();
92
89
  const name = filename ?? path.basename(filePath);
93
90
  const form = new FormData();
94
- form.append('media', createReadStream(filePath), name);
91
+ form.append('media', readFileSync(filePath), { filename: name, contentType: 'image/png' });
95
92
  form.append('type', 'image');
96
93
  const res = await axios.post(`https://api.weixin.qq.com/cgi-bin/material/add_material?access_token=${token}&type=image`, form, { headers: form.getHeaders(), maxBodyLength: Infinity, maxContentLength: Infinity });
97
94
  const data = res.data;
@@ -100,6 +97,27 @@ export class WechatPublisher {
100
97
  }
101
98
  return data.media_id;
102
99
  }
100
+ /**
101
+ * 生成占位封面图(当文章无图片时使用)
102
+ * 从占位图服务下载图片并上传为永久素材
103
+ */
104
+ async uploadPlaceholderCover() {
105
+ const token = await this.getAccessToken();
106
+ // 从 httpbin 下载标准测试图(8090 bytes 有效 PNG)
107
+ const res = await this.runtime.fetch('https://httpbin.org/image/png');
108
+ if (!res.ok)
109
+ throw new Error(`占位图下载失败: ${res.status}`);
110
+ const buf = await res.arrayBuffer();
111
+ const png = Buffer.from(buf);
112
+ const form = new FormData();
113
+ form.append('media', png, { filename: 'cover.png', contentType: 'image/png' });
114
+ form.append('type', 'image');
115
+ const uploadRes = await axios.post(`https://api.weixin.qq.com/cgi-bin/material/add_material?access_token=${token}&type=image`, form, { headers: form.getHeaders(), maxBodyLength: Infinity, maxContentLength: Infinity });
116
+ const data = uploadRes.data;
117
+ if (!data.media_id)
118
+ throw new Error(`占位图上传失败: ${data.errmsg || JSON.stringify(data)}`);
119
+ return data.media_id;
120
+ }
103
121
  /**
104
122
  * 从 URL 下载图片并上传为永久素材(用于封面)
105
123
  */
@@ -160,28 +178,42 @@ export class WechatPublisher {
160
178
  async publishToDraft(options) {
161
179
  const token = await this.getAccessToken();
162
180
  const { html: processedHtml, firstMediaId } = await this.processImages(options.content);
163
- // 上传封面图
181
+ // 上传封面图:优先用 front-matter 指定的封面图,其次用正文第一张图片的 media_id
164
182
  let thumbMediaId;
165
183
  try {
166
184
  thumbMediaId = await this.uploadCover(options.cover);
167
185
  }
168
186
  catch (err) {
169
- console.warn(`[WechatPublisher] 封面上传失败,遍历正文图片: ${err.message}`);
170
- const imgMatches = [...options.content.matchAll(/<img\s+[^>]*src=["']([^"']+)["'][^>]*>/gi)];
171
- for (const m of imgMatches) {
172
- const src = m[1];
173
- if (src.includes('mmbiz.qpic.cn') || src.includes('mmbiz.qlogo.cn'))
174
- continue;
175
- try {
176
- thumbMediaId = await this.uploadImageFromUrl(src);
177
- console.log(`[WechatPublisher] 用正文图片作封面: ${src}`);
178
- break;
187
+ console.warn(`[WechatPublisher] 封面上传失败: ${err.message}`);
188
+ // processImages 返回的第一张图片 media_id 作为封面
189
+ if (firstMediaId) {
190
+ thumbMediaId = firstMediaId;
191
+ console.log('[WechatPublisher] 使用正文第一张图片作为封面');
192
+ }
193
+ else {
194
+ // fallback:直接从 processedHtml 中查找 img 标签
195
+ const imgMatches = [...processedHtml.matchAll(/<img\s+[^>]*src=["']([^"']+)["'][^>]*>/gi)];
196
+ for (const m of imgMatches) {
197
+ const src = m[1];
198
+ if (src.includes('mmbiz.qpic.cn') || src.includes('mmbiz.qlogo.cn'))
199
+ continue;
200
+ try {
201
+ thumbMediaId = await this.uploadImageFromUrl(src);
202
+ console.log(`[WechatPublisher] 用正文图片作封面: ${src}`);
203
+ break;
204
+ }
205
+ catch { /* continue */ }
179
206
  }
180
- catch { /* continue */ }
181
207
  }
182
208
  }
183
209
  if (!thumbMediaId) {
184
- console.warn('[WechatPublisher] 没有可用封面,将使用微信默认封面');
210
+ try {
211
+ thumbMediaId = await this.uploadPlaceholderCover();
212
+ console.log('[WechatPublisher] 使用占位图作为封面');
213
+ }
214
+ catch (err) {
215
+ console.warn('[WechatPublisher] 占位图上传失败,不传 thumb_media_id:', err.message);
216
+ }
185
217
  }
186
218
  const article = {
187
219
  title: options.title,
@@ -190,8 +222,10 @@ export class WechatPublisher {
190
222
  digest: options.title,
191
223
  content_source_url: options.source_url,
192
224
  };
193
- if (thumbMediaId)
225
+ // 只在有有效 thumbMediaId 时才传 thumb_media_id
226
+ if (thumbMediaId && thumbMediaId.trim() !== '') {
194
227
  article.thumb_media_id = thumbMediaId;
228
+ }
195
229
  const payload = { articles: [article] };
196
230
  const res = await axios.post(`https://api.weixin.qq.com/cgi-bin/draft/add?access_token=${token}`, payload, { headers: { 'Content-Type': 'application/json' }, responseType: 'json' });
197
231
  const data = res.data;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "multi-publisher",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "Markdown → 微信公众号 / 知乎 / 掘金等多平台 CLI 发布工具",
5
5
  "type": "module",
6
6
  "repository": {