multi-publisher 1.1.0 → 1.1.2

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
@@ -2,7 +2,7 @@
2
2
 
3
3
  **一行命令,Markdown 发布到全网。**
4
4
 
5
- 将排版好的 Markdown 文章一键发布到微信公众号、知乎、掘金、CSDN 等 20+ 平台,无需手动复制粘贴。支持浏览器自动登录获取 Cookie,12 套渲染主题,是内容创作者的效率神器。
5
+ 将排版好的 Markdown 文章一键发布到微信公众号、知乎、掘金、CSDN 等 20+ 平台,无需手动复制粘贴。支持浏览器自动登录获取 Cookie,14 套渲染主题,是内容创作者的效率神器。
6
6
 
7
7
  ---
8
8
 
@@ -13,6 +13,7 @@
13
13
  | **多平台支持** | 微信公众号、知乎、掘金、CSDN、微博、小红书、B站等 20+ 平台 |
14
14
  | **浏览器自动登录** | Playwright 驱动扫码/账号登录,Cookie 自动获取保存 |
15
15
  | **14 套渲染主题** | default、wechat、modern、minimal、cyberpunk、nord、paper、darkelite、sunset、zen、retro、midnight、brutalism、neumorphism |
16
+ | **自动封面图生成** | AI 根据标题生成封面图,自动上传到微信 CDN |
16
17
  | **Markdown 直发** | front-matter 元数据、代码高亮、LaTeX 公式 |
17
18
  | **CI/CD 友好** | 纯命令行无需浏览器,配置文件统一管理 |
18
19
  | **草稿箱发布** | 微信公众号 → 草稿箱,其他平台 → 各自草稿箱 |
@@ -95,6 +96,12 @@ mpub render -f article.md
95
96
 
96
97
  # 指定主题渲染
97
98
  mpub render -f article.md -t cyberpunk
99
+
100
+ # 自动封面图(文章无封面时 AI 生成)
101
+ mpub publish -f article.md -p weixin --auto-cover
102
+
103
+ # 指定封面图
104
+ mpub publish -f article.md -p weixin -c cover.jpg
98
105
  ```
99
106
 
100
107
  ---
@@ -199,14 +206,16 @@ pre { background: #f6f8fa; border-radius: 6px; }
199
206
 
200
207
  ### front-matter 元数据
201
208
 
209
+ > **重要**:front-matter 是必需的!缺少 front-matter 会导致标题显示为"无标题"。
210
+
202
211
  ```markdown
203
212
  ---
204
- title: 文章标题
205
- author: 作者名
206
- cover: https://example.com/cover.jpg # 封面图片 URL
207
- summary: 文章摘要(可选) # 微信作者留言摘要
208
- source_url: https://original.url # 原文链接(可选)
209
- tags: [技术, 前端, JavaScript] # 标签(可选)
213
+ title: 文章标题 # 必填,用于公众号标题
214
+ author: 作者名 # 必填
215
+ description: 文章描述(可选) # 用于公众号摘要
216
+ cover: https://example.com/cover.jpg # 可选,有 --auto-cover 可省略
217
+ source_url: https://original.url # 原文链接(可选)
218
+ tags: [技术, 前端, JavaScript] # 标签(可选)
210
219
  ---
211
220
 
212
221
  正文内容...
@@ -24,6 +24,21 @@ export declare class CSDNAdapter implements IPlatformAdapter {
24
24
  * 生成 CSDN API 签名
25
25
  */
26
26
  private signRequest;
27
+ /**
28
+ * 上传图片到 CSDN
29
+ * 流程:1. 调用签名 API 获取 policy 2. 直接上传到华为云 OBS
30
+ * @param imagePath 本地文件路径或网络 URL
31
+ * @returns CSDN 图片 URL
32
+ */
33
+ uploadImage(imagePath: string): Promise<string>;
34
+ /**
35
+ * 获取华为云 OBS 上传签名
36
+ */
37
+ private getUploadSignature;
38
+ /**
39
+ * 处理封面图:上传本地文件或使用网络 URL
40
+ */
41
+ private processCover;
27
42
  checkAuth(): Promise<AuthResult>;
28
43
  publish(article: Article): Promise<SyncResult>;
29
44
  }
@@ -1,8 +1,15 @@
1
1
  import { ConfigStore } from '../config.js';
2
+ import { uploadCoverViaBrowser } from '../tools/browser-upload.js';
2
3
  import crypto from 'node:crypto';
4
+ import fs from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ import { existsSync } from 'node:fs';
3
7
  // CSDN API 签名密钥
4
8
  const API_KEY = '203803574';
5
9
  const API_SECRET = '9znpamsyl2c7cdrr9sas0le9vbc3r6ba';
10
+ // 封面图上传 API 的 key 和 secret
11
+ const COVER_API_KEY = '260196572';
12
+ const COVER_API_SECRET = 't5PaqxVQpWoHgLGt7XPIvd5ipJcwJTU7';
6
13
  export class CSDNAdapter {
7
14
  meta = {
8
15
  id: 'csdn',
@@ -75,6 +82,106 @@ export class CSDNAdapter {
75
82
  }
76
83
  return headers;
77
84
  }
85
+ /**
86
+ * 上传图片到 CSDN
87
+ * 流程:1. 调用签名 API 获取 policy 2. 直接上传到华为云 OBS
88
+ * @param imagePath 本地文件路径或网络 URL
89
+ * @returns CSDN 图片 URL
90
+ */
91
+ async uploadImage(imagePath) {
92
+ if (!this.cookieData?.cookies) {
93
+ throw new Error('未配置 CSDN Cookie');
94
+ }
95
+ // 如果是网络 URL,直接返回
96
+ if (imagePath.startsWith('http://') || imagePath.startsWith('https://')) {
97
+ return imagePath;
98
+ }
99
+ // 读取本地文件
100
+ const fileBuffer = await fs.readFile(imagePath);
101
+ const filename = path.basename(imagePath);
102
+ const ext = path.extname(filename).toLowerCase();
103
+ const imageSuffix = ext.replace('.', '');
104
+ try {
105
+ // 步骤 1: 获取上传签名 (policy)
106
+ const signatureData = await this.getUploadSignature(imageSuffix);
107
+ const { accessId, policy, signature, host, dir } = signatureData;
108
+ // 步骤 2: 直接上传到华为云 OBS
109
+ const objectKey = `${dir}${filename}`;
110
+ const obsUrl = `${host}/${objectKey}`;
111
+ const formData = new FormData();
112
+ formData.append('key', objectKey);
113
+ formData.append('OSSAccessKeyId', accessId);
114
+ formData.append('policy', policy);
115
+ formData.append('signature', signature);
116
+ formData.append('expire', signatureData.expire);
117
+ formData.append('file', new Blob([fileBuffer]), filename);
118
+ const response = await this.runtime.fetch(obsUrl, {
119
+ method: 'POST',
120
+ body: formData,
121
+ });
122
+ if (!response.ok) {
123
+ throw new Error(`OBS 上传失败: ${response.status}`);
124
+ }
125
+ // 返回 CSDN 图片 URL
126
+ return `https://csdn-img-blog.obs.cn-north-4.myhuaweicloud.com/${objectKey}`;
127
+ }
128
+ catch (apiError) {
129
+ throw new Error(`封面 API 上传失败: ${apiError.message}`);
130
+ }
131
+ }
132
+ /**
133
+ * 获取华为云 OBS 上传签名
134
+ */
135
+ async getUploadSignature(imageSuffix) {
136
+ const cookieStr = this.buildCookieString(this.cookieData);
137
+ // CSDN 封面图签名 API 使用的 key 和之前的不同
138
+ const coverApiKey = '260196572';
139
+ const nonce = this.createUuid();
140
+ const timestamp = Date.now().toString();
141
+ const apiPath = '/resource-api/v1/image/direct/upload/signature';
142
+ // 按照 Lt 函数格式: method\naccept\n\ncontentType\ntimestamp\n(sorted headers)\nurl
143
+ const signStr = `POST\n*/*\n\napplication/json\n${timestamp}\nx-ca-key:${coverApiKey}\nx-ca-nonce:${nonce}\nx-ca-timestamp:${timestamp}\n${apiPath}`;
144
+ // 封面签名使用专用的 secret
145
+ const signature = await this.hmacSha256(signStr, COVER_API_SECRET);
146
+ const response = await this.runtime.fetch(`https://bizapi.csdn.net${apiPath}`, {
147
+ method: 'POST',
148
+ headers: {
149
+ 'accept': 'application/json',
150
+ 'x-ca-key': coverApiKey,
151
+ 'x-ca-nonce': nonce,
152
+ 'x-ca-timestamp': timestamp,
153
+ 'x-ca-signature': signature,
154
+ 'x-ca-signature-headers': 'x-ca-key,x-ca-nonce,x-ca-timestamp',
155
+ 'content-type': 'application/json',
156
+ 'Cookie': cookieStr,
157
+ },
158
+ body: JSON.stringify({
159
+ imageTemplate: '',
160
+ appName: 'direct_blog_coverimage',
161
+ imageSuffix,
162
+ }),
163
+ });
164
+ const res = await response.json();
165
+ if (res.code !== 200 || !res.data) {
166
+ throw new Error(res.message || '获取上传签名失败');
167
+ }
168
+ return res.data;
169
+ }
170
+ /**
171
+ * 处理封面图:上传本地文件或使用网络 URL
172
+ */
173
+ async processCover(cover) {
174
+ if (!cover)
175
+ return [];
176
+ try {
177
+ const coverUrl = await this.uploadImage(cover);
178
+ return [coverUrl];
179
+ }
180
+ catch (err) {
181
+ console.warn(`[CSDN] 封面上传失败: ${err.message}`);
182
+ return [];
183
+ }
184
+ }
78
185
  async checkAuth() {
79
186
  if (!this.cookieData?.cookies) {
80
187
  return { isAuthenticated: false, error: '未配置 CSDN Cookie,请运行: mpub cookie --platform csdn --set' };
@@ -118,6 +225,7 @@ export class CSDNAdapter {
118
225
  // 生成签名
119
226
  const apiPath = '/blog-console-api/v3/mdeditor/saveArticle';
120
227
  const headers = await this.signRequest(apiPath);
228
+ // 先发布文章(不带封面,因为 API 签名有问题)
121
229
  const response = await this.runtime.fetch(`https://bizapi.csdn.net${apiPath}`, {
122
230
  method: 'POST',
123
231
  headers: {
@@ -139,7 +247,7 @@ export class CSDNAdapter {
139
247
  not_auto_saved: '1',
140
248
  source: 'pc_mdeditor',
141
249
  cover_images: [],
142
- cover_type: 1,
250
+ cover_type: 0,
143
251
  is_new: 1,
144
252
  vote_id: 0,
145
253
  resource_id: '',
@@ -153,6 +261,17 @@ export class CSDNAdapter {
153
261
  }
154
262
  const postId = res.data.id;
155
263
  const draftUrl = `https://editor.csdn.net/md?articleId=${postId}`;
264
+ // 如果有封面图片,通过浏览器自动化上传
265
+ if (article.cover && existsSync(article.cover)) {
266
+ console.log(`[CSDN] 封面上传中...`);
267
+ const coverResult = await uploadCoverViaBrowser(postId, article.cover);
268
+ if (coverResult.success && coverResult.coverUrl) {
269
+ console.log(`[CSDN] 封面 URL: ${coverResult.coverUrl}`);
270
+ }
271
+ else {
272
+ console.warn(`[CSDN] 封面上传失败: ${coverResult.error}`);
273
+ }
274
+ }
156
275
  return {
157
276
  platform: this.meta.id,
158
277
  success: true,
@@ -4,6 +4,8 @@
4
4
  */
5
5
  import { chromium } from 'playwright';
6
6
  import { ConfigStore } from '../config.js';
7
+ import { existsSync } from 'node:fs';
8
+ import path from 'node:path';
7
9
  export class ToutiaoAdapter {
8
10
  meta = {
9
11
  id: 'toutiao',
@@ -22,7 +24,7 @@ export class ToutiaoAdapter {
22
24
  }
23
25
  const hasSession = this.cookieData['sessionid'] || this.cookieData['sid_tt'];
24
26
  if (!hasSession) {
25
- return { isAuthenticated: false, error: '未登录或登录已过期' };
27
+ return { isAuthenticated: false, error: '登录已过期' };
26
28
  }
27
29
  return { isAuthenticated: true };
28
30
  }
@@ -99,7 +101,6 @@ export class ToutiaoAdapter {
99
101
  const contentEl = page.locator('div[contenteditable="true"]').first();
100
102
  if (await contentEl.isVisible({ timeout: 2000 }).catch(() => false)) {
101
103
  await contentEl.click();
102
- // 使用 Ctrl+A 全选,然后输入内容(模拟用户输入)
103
104
  await page.keyboard.press('Control+a');
104
105
  await page.waitForTimeout(200);
105
106
  const htmlContent = article.html || article.markdown || '';
@@ -107,59 +108,109 @@ export class ToutiaoAdapter {
107
108
  const div = document.querySelector('[contenteditable="true"]');
108
109
  if (div) {
109
110
  div.innerHTML = el;
110
- // 触发 input 事件
111
111
  div.dispatchEvent(new InputEvent('input', { bubbles: true }));
112
112
  }
113
113
  }, htmlContent);
114
114
  console.log('[toutiao] 已填写内容');
115
115
  await page.waitForTimeout(1000);
116
116
  }
117
- // 等待编辑器自动保存(最多5秒)
118
- console.log('[toutiao] 等待编辑器自动保存(5秒)...');
119
- for (let i = 0; i < 5; i++) {
120
- const url = page.url();
121
- if (url.includes('id=')) {
122
- console.log('[toutiao] 检测到文章 ID,保存完成');
123
- break;
117
+ // 如果有封面图片,先上传封面再发布
118
+ if (article.cover && existsSync(article.cover)) {
119
+ console.log('[toutiao] 检测到封面图片,准备上传...');
120
+ // 点击"预览并发布"按钮打开预览弹窗
121
+ const previewBtn = page.locator('button:has-text("预览并发布")');
122
+ if (await previewBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
123
+ await previewBtn.click({ force: true });
124
+ console.log('[toutiao] 点击预览并发布按钮');
125
+ await page.waitForTimeout(2000);
126
+ // 选择"单图"选项
127
+ const singleImg = page.locator('text=单图').first();
128
+ if (await singleImg.isVisible({ timeout: 2000 }).catch(() => false)) {
129
+ await singleImg.click();
130
+ console.log('[toutiao] 选择单图');
131
+ await page.waitForTimeout(500);
132
+ }
133
+ // 设置 filechooser 监听
134
+ const absolutePath = path.resolve(article.cover);
135
+ page.on('filechooser', async (fileChooser) => {
136
+ console.log('[toutiao] 收到 filechooser,设置封面文件');
137
+ await fileChooser.setFiles(absolutePath);
138
+ });
139
+ // 点击封面添加区域
140
+ const coverAdd = page.locator('.article-cover-add');
141
+ if (await coverAdd.isVisible({ timeout: 2000 }).catch(() => false)) {
142
+ await coverAdd.click({ force: true });
143
+ console.log('[toutiao] 点击了封面添加区域');
144
+ await page.waitForTimeout(1500);
145
+ }
146
+ // 点击"本地上传"
147
+ const localUpload = page.locator('text=本地上传');
148
+ if (await localUpload.isVisible({ timeout: 3000 }).catch(() => false)) {
149
+ await localUpload.click();
150
+ console.log('[toutiao] 点击本地上传');
151
+ await page.waitForTimeout(1500);
152
+ }
153
+ // 点击"确定"按钮
154
+ const confirmBtn = page.locator('button:has-text("确定")').filter({ visible: true });
155
+ try {
156
+ if (await confirmBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
157
+ const disabled = await confirmBtn.first().isDisabled().catch(() => true);
158
+ if (!disabled) {
159
+ await confirmBtn.first().click();
160
+ console.log('[toutiao] 点击确定按钮,封面上传完成');
161
+ }
162
+ }
163
+ }
164
+ catch (e) {
165
+ console.log('[toutiao] 确定按钮点击失败:', e.message);
166
+ }
167
+ // 等待封面上传保存(头条号会自动保存)
168
+ await page.waitForTimeout(5000);
124
169
  }
125
- await page.waitForTimeout(1000);
126
- }
127
- // 检查 URL 是否包含文章 ID
128
- let currentUrl = page.url();
129
- let idMatch = currentUrl.match(/[?&]id=(\d+)/);
130
- let savedId = idMatch ? idMatch[1] : null;
131
- if (idMatch) {
132
- console.log('[toutiao] 检测到文章 ID:', idMatch[1]);
133
170
  }
134
- else {
135
- console.log('[toutiao] URL 仍未变化:', currentUrl);
136
- }
137
- // 尝试点击"发布"按钮触发保存
138
- console.log('[toutiao] 尝试点击发布按钮...');
171
+ // 关闭预览弹窗(如果还在的话)
139
172
  try {
140
- // 查找发布按钮(可能在右上角或底部)
141
- const publishBtn = page.locator('button').filter({ hasText: /^发布$/ }).first();
142
- if (await publishBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
143
- await publishBtn.click({ force: true });
144
- console.log('[toutiao] 已点击发布按钮');
145
- // 等待保存完成
146
- await page.waitForTimeout(10000);
173
+ const continueBtn = page.locator('button:has-text("继续编辑")');
174
+ if (await continueBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
175
+ await continueBtn.click({ force: true });
176
+ await page.waitForTimeout(2000);
147
177
  }
148
178
  else {
149
- // 如果没找到发布按钮,尝试保存按钮
150
- const saveBtn = page.locator('button').filter({ hasText: /保存草稿|保存/ }).first();
151
- if (await saveBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
152
- await saveBtn.click({ force: true });
153
- console.log('[toutiao] 已点击保存按钮');
154
- await page.waitForTimeout(10000);
179
+ const closeIcon = page.locator('[class*="close"], .ai-assistant-drawer-wrapper [class*="close"]');
180
+ if (await closeIcon.isVisible({ timeout: 1000 }).catch(() => false)) {
181
+ await closeIcon.click({ force: true });
182
+ await page.waitForTimeout(1000);
183
+ }
184
+ }
185
+ }
186
+ catch (e) {
187
+ // 关闭弹窗失败,继续
188
+ }
189
+ // 等待编辑器稳定
190
+ await page.waitForTimeout(2000);
191
+ // 点击发布按钮
192
+ console.log('[toutiao] 查找发布按钮...');
193
+ try {
194
+ // 尝试多种发布按钮选择器
195
+ const publishSelectors = [
196
+ 'button:has-text("发布")',
197
+ 'button:has-text("预览并发布")',
198
+ ];
199
+ for (const selector of publishSelectors) {
200
+ const btn = page.locator(selector);
201
+ if (await btn.isVisible({ timeout: 2000 }).catch(() => false)) {
202
+ await btn.click({ force: true });
203
+ console.log('[toutiao] 点击了:', selector);
204
+ break;
155
205
  }
156
206
  }
207
+ await page.waitForTimeout(10000);
157
208
  }
158
209
  catch (e) {
159
- console.log('[toutiao] 按钮点击失败:', e.message);
210
+ console.log('[toutiao] 发布按钮点击失败:', e.message);
160
211
  }
161
212
  const finalUrl = page.url();
162
- idMatch = finalUrl.match(/[?&]id=(\d+)/);
213
+ const idMatch = finalUrl.match(/[?&]id=(\d+)/);
163
214
  if (idMatch) {
164
215
  console.log('[toutiao] 保存成功,文章 ID:', idMatch[1]);
165
216
  return {
@@ -0,0 +1,6 @@
1
+ interface CaptureOptions {
2
+ platform?: string;
3
+ timeout?: string;
4
+ }
5
+ export declare function runCapture(options: CaptureOptions): Promise<void>;
6
+ export {};
@@ -0,0 +1,49 @@
1
+ /**
2
+ * capture 命令入口
3
+ */
4
+ import { capture } from '../tools/capture.js';
5
+ import fs from 'fs/promises';
6
+ import path from 'path';
7
+ export async function runCapture(options) {
8
+ const platform = options.platform || 'csdn';
9
+ const timeoutSeconds = parseInt(options.timeout || '60', 10);
10
+ const timeoutMs = timeoutSeconds * 1000;
11
+ const data = await capture(platform, timeoutMs);
12
+ if (!data) {
13
+ console.error('抓包失败');
14
+ process.exit(1);
15
+ }
16
+ // 保存到临时文件
17
+ const timestamp = Date.now();
18
+ const filename = `temp/capture-${platform}-${timestamp}.json`;
19
+ const filepath = path.resolve(process.cwd(), filename);
20
+ // 确保 temp 目录存在
21
+ await fs.mkdir(path.dirname(filepath), { recursive: true });
22
+ await fs.writeFile(filepath, JSON.stringify(data, null, 2), 'utf-8');
23
+ console.log(`
24
+ ========================================
25
+ 抓包完成
26
+ ========================================
27
+ 文件: ${filepath}
28
+
29
+ 📊 统计:
30
+ 总请求数: ${data.summary.totalRequests}
31
+ 命中请求: ${data.summary.matchedRequests}
32
+ 上传端点: ${data.summary.uploadEndpoints.join(', ') || '无'}
33
+
34
+ ========================================
35
+ `);
36
+ if (data.summary.uploadEndpoints.length > 0) {
37
+ console.log('🎯 检测到上传请求!');
38
+ console.log(' 把这个文件路径告诉 AI,让它分析签名格式');
39
+ }
40
+ else {
41
+ console.log('⚠️ 未检测到上传请求');
42
+ console.log(' 可能原因:');
43
+ console.log(' 1. 没有执行上传操作');
44
+ console.log(' 2. 上传功能需要先登录');
45
+ console.log(' 3. 平台上传 API 不在拦截范围内');
46
+ }
47
+ console.log('\n运行 AI 分析:');
48
+ console.log(` 帮我分析 ${platform} 的抓包数据: ${filepath}`);
49
+ }
package/dist/cli/index.js CHANGED
@@ -11,6 +11,7 @@ import { runCredential } from './credential.js';
11
11
  import { runCookie } from './cookie.js';
12
12
  import { runLogin } from './login.js';
13
13
  import { runPublishAll } from './publish-all.js';
14
+ import { runCapture } from './capture.js';
14
15
  export function createProgram() {
15
16
  const program = new Command();
16
17
  program
@@ -68,6 +69,12 @@ export function createProgram() {
68
69
  .option('--mac-style', '启用 Mac 风格代码块', true)
69
70
  .option('--no-mac-style', '禁用 Mac 风格代码块')
70
71
  .action(runPublishAll);
72
+ // capture 命令
73
+ const captureCmd = program.command('capture')
74
+ .description('抓包工具 - 打开浏览器并拦截上传请求')
75
+ .option('-p, --platform <platform>', '平台名称 (csdn|juejin|zhihu|toutiao|jianshu|weibo)', 'csdn')
76
+ .option('-t, --timeout <seconds>', '超时时间(秒)', '60')
77
+ .action(runCapture);
71
78
  return program;
72
79
  }
73
80
  const program = createProgram();
@@ -0,0 +1,13 @@
1
+ export interface BrowserUploadResult {
2
+ success: boolean;
3
+ coverUrl?: string;
4
+ error?: string;
5
+ }
6
+ /**
7
+ * 通过浏览器自动化上传封面图片
8
+ * 流程:打开文章编辑页 -> 在右侧设置面板上传封面
9
+ * @param articleId 文章ID(发布后返回的)
10
+ * @param imagePath 本地图片路径
11
+ * @returns 上传后的封面 URL
12
+ */
13
+ export declare function uploadCoverViaBrowser(articleId: string, imagePath: string): Promise<BrowserUploadResult>;