multi-publisher 1.1.1 → 1.1.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 +28 -22
- package/dist/adapters/csdn.d.ts +15 -0
- package/dist/adapters/csdn.js +136 -1
- package/dist/adapters/interface.d.ts +10 -0
- package/dist/adapters/qq.d.ts +19 -0
- package/dist/adapters/qq.js +185 -0
- package/dist/adapters/registry.js +2 -0
- package/dist/adapters/toutiao.d.ts +4 -0
- package/dist/adapters/toutiao.js +205 -40
- package/dist/adapters/wechat-publisher.d.ts +6 -1
- package/dist/adapters/wechat-publisher.js +58 -17
- package/dist/adapters/weixin.d.ts +5 -0
- package/dist/adapters/weixin.js +41 -5
- package/dist/cli/capture.d.ts +6 -0
- package/dist/cli/capture.js +49 -0
- package/dist/cli/index.js +8 -0
- package/dist/cli/login.js +3 -0
- package/dist/cli/platforms.js +2 -1
- package/dist/cli/publish-all.js +1 -1
- package/dist/cli/publish.d.ts +1 -0
- package/dist/cli/publish.js +29 -5
- package/dist/config.d.ts +8 -0
- package/dist/config.js +11 -0
- package/dist/core/parser.js +9 -1
- package/dist/core/renderer.d.ts +9 -1
- package/dist/core/renderer.js +86 -11
- package/dist/core/theme.d.ts +1 -1
- package/dist/core/theme.js +91 -336
- package/dist/runtime/browser-runtime.js +31 -2
- package/dist/tools/browser-upload.d.ts +13 -0
- package/dist/tools/browser-upload.js +349 -0
- package/dist/tools/capture.d.ts +26 -0
- package/dist/tools/capture.js +348 -0
- package/dist/tools/cover-fetcher.d.ts +10 -0
- package/dist/tools/cover-fetcher.js +100 -0
- package/dist/tools/imgbb-uploader.d.ts +18 -0
- package/dist/tools/imgbb-uploader.js +89 -0
- package/dist/tools/toutiao-upload.d.ts +10 -0
- package/dist/tools/toutiao-upload.js +166 -0
- package/package.json +1 -1
- package/themes/previews/cyberpunk.png +0 -0
- package/themes/previews/nord.png +0 -0
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
**一行命令,Markdown 发布到全网。**
|
|
4
4
|
|
|
5
|
-
将排版好的 Markdown 文章一键发布到微信公众号、知乎、掘金、CSDN 等 20+ 平台,无需手动复制粘贴。支持浏览器自动登录获取 Cookie,
|
|
5
|
+
将排版好的 Markdown 文章一键发布到微信公众号、知乎、掘金、CSDN 等 20+ 平台,无需手动复制粘贴。支持浏览器自动登录获取 Cookie,4 套精排渲染主题(按平台自动匹配),是内容创作者的效率神器。
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|------|------|
|
|
13
13
|
| **多平台支持** | 微信公众号、知乎、掘金、CSDN、微博、小红书、B站等 20+ 平台 |
|
|
14
14
|
| **浏览器自动登录** | Playwright 驱动扫码/账号登录,Cookie 自动获取保存 |
|
|
15
|
-
| **
|
|
15
|
+
| **4 套精排主题** | default(通用)、wechat(公众号)、modern(技术/知乎)、minimal(轻阅读/头条),均按平台自动匹配 |
|
|
16
16
|
| **自动封面图生成** | AI 根据标题生成封面图,自动上传到微信 CDN |
|
|
17
17
|
| **Markdown 直发** | front-matter 元数据、代码高亮、LaTeX 公式 |
|
|
18
18
|
| **CI/CD 友好** | 纯命令行无需浏览器,配置文件统一管理 |
|
|
@@ -39,7 +39,7 @@ mpub --version
|
|
|
39
39
|
```bash
|
|
40
40
|
# 在任意 Claude Code 对话中,直接描述你的需求:
|
|
41
41
|
# "帮我把这篇 article.md 发布到微信公众号"
|
|
42
|
-
# "用
|
|
42
|
+
# "用 wechat 主题渲染预览一下"
|
|
43
43
|
# AI 会调用 mpub 完成操作
|
|
44
44
|
```
|
|
45
45
|
|
|
@@ -95,7 +95,7 @@ mpub publish-all -f article.md
|
|
|
95
95
|
mpub render -f article.md
|
|
96
96
|
|
|
97
97
|
# 指定主题渲染
|
|
98
|
-
mpub render -f article.md -t
|
|
98
|
+
mpub render -f article.md -t wechat
|
|
99
99
|
|
|
100
100
|
# 自动封面图(文章无封面时 AI 生成)
|
|
101
101
|
mpub publish -f article.md -p weixin --auto-cover
|
|
@@ -152,37 +152,43 @@ mpub platforms
|
|
|
152
152
|
|
|
153
153
|
## 主题系统
|
|
154
154
|
|
|
155
|
-
### 🎨
|
|
155
|
+
### 🎨 4 套内置主题(按平台自动匹配)
|
|
156
156
|
|
|
157
|
-
|
|
157
|
+
mpub 内置 4 套浅色阅读主题,均针对公众号 / 知乎 / 头条等白底平台的真实排版规范做了优化:
|
|
158
|
+
|
|
159
|
+
| 主题 | 风格 | 适配平台 |
|
|
160
|
+
|------|------|----------|
|
|
161
|
+
| `default` | 通用简洁,绿调标题/引用 | 兜底,所有平台可用 |
|
|
162
|
+
| `wechat` | 微信官方排版(微信蓝链接、浅灰代码块、圆角图) | 微信公众号 |
|
|
163
|
+
| `modern` | 深色代码块、清晰层次(知乎/网页友好) | 知乎、技术博客 |
|
|
164
|
+
| `minimal` | 极简留白、轻装饰 | 头条号等轻阅读平台 |
|
|
158
165
|
|
|
159
|
-
|
|
166
|
+
**平台自动匹配**:未用 `-t` 指定主题时,发布按平台自动选默认主题——
|
|
167
|
+
微信 → `wechat`,知乎 → `modern`,头条 → `minimal`,其余回退 `default`。
|
|
168
|
+
(手动 `-t` 仍可覆盖。)
|
|
160
169
|
|
|
161
|
-
|
|
162
|
-
|------|----------|------|
|
|
163
|
-
| 技术博客 | Cyberpunk |  |
|
|
164
|
-
| 程序员 | Nord |  |
|
|
165
|
-
| 微信风格 | Wechat |  |
|
|
166
|
-
| 简约 | Minimal |  |
|
|
170
|
+
完整预览:[themes/all-themes-preview.html](themes/all-themes-preview.html)
|
|
167
171
|
|
|
168
172
|
**命令行预览:**
|
|
169
173
|
```bash
|
|
170
|
-
mpub render -f article.md -t
|
|
171
|
-
mpub render -f article.md -t
|
|
172
|
-
mpub render -f article.md -t
|
|
174
|
+
mpub render -f article.md -t wechat
|
|
175
|
+
mpub render -f article.md -t modern
|
|
176
|
+
mpub render -f article.md -t minimal
|
|
173
177
|
```
|
|
174
178
|
|
|
175
|
-
**全部
|
|
179
|
+
**全部 4 主题:** default、wechat、modern、minimal
|
|
176
180
|
|
|
177
181
|
### 使用主题
|
|
178
182
|
|
|
179
183
|
```bash
|
|
180
|
-
#
|
|
181
|
-
mpub
|
|
182
|
-
mpub
|
|
183
|
-
|
|
184
|
+
# 不指定主题:发布到微信自动用 wechat,知乎自动用 modern
|
|
185
|
+
mpub publish -f article.md -p weixin
|
|
186
|
+
mpub publish -f article.md -p zhihu
|
|
187
|
+
|
|
188
|
+
# 渲染时手动指定主题
|
|
189
|
+
mpub render -f article.md -t wechat
|
|
184
190
|
|
|
185
|
-
#
|
|
191
|
+
# 发布时手动覆盖主题
|
|
186
192
|
mpub publish -f article.md -t modern -p zhihu
|
|
187
193
|
```
|
|
188
194
|
|
package/dist/adapters/csdn.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/adapters/csdn.js
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import { ConfigStore } from '../config.js';
|
|
2
|
+
import { uploadCoverViaBrowser } from '../tools/browser-upload.js';
|
|
3
|
+
import { downloadCoverUrl } from '../tools/cover-fetcher.js';
|
|
2
4
|
import crypto from 'node:crypto';
|
|
5
|
+
import fs from 'node:fs/promises';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { existsSync } from 'node:fs';
|
|
3
8
|
// CSDN API 签名密钥
|
|
4
9
|
const API_KEY = '203803574';
|
|
5
10
|
const API_SECRET = '9znpamsyl2c7cdrr9sas0le9vbc3r6ba';
|
|
11
|
+
// 封面图上传 API 的 key 和 secret
|
|
12
|
+
const COVER_API_KEY = '260196572';
|
|
13
|
+
const COVER_API_SECRET = 't5PaqxVQpWoHgLGt7XPIvd5ipJcwJTU7';
|
|
6
14
|
export class CSDNAdapter {
|
|
7
15
|
meta = {
|
|
8
16
|
id: 'csdn',
|
|
@@ -75,6 +83,106 @@ export class CSDNAdapter {
|
|
|
75
83
|
}
|
|
76
84
|
return headers;
|
|
77
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* 上传图片到 CSDN
|
|
88
|
+
* 流程:1. 调用签名 API 获取 policy 2. 直接上传到华为云 OBS
|
|
89
|
+
* @param imagePath 本地文件路径或网络 URL
|
|
90
|
+
* @returns CSDN 图片 URL
|
|
91
|
+
*/
|
|
92
|
+
async uploadImage(imagePath) {
|
|
93
|
+
if (!this.cookieData?.cookies) {
|
|
94
|
+
throw new Error('未配置 CSDN Cookie');
|
|
95
|
+
}
|
|
96
|
+
// 如果是网络 URL,直接返回
|
|
97
|
+
if (imagePath.startsWith('http://') || imagePath.startsWith('https://')) {
|
|
98
|
+
return imagePath;
|
|
99
|
+
}
|
|
100
|
+
// 读取本地文件
|
|
101
|
+
const fileBuffer = await fs.readFile(imagePath);
|
|
102
|
+
const filename = path.basename(imagePath);
|
|
103
|
+
const ext = path.extname(filename).toLowerCase();
|
|
104
|
+
const imageSuffix = ext.replace('.', '');
|
|
105
|
+
try {
|
|
106
|
+
// 步骤 1: 获取上传签名 (policy)
|
|
107
|
+
const signatureData = await this.getUploadSignature(imageSuffix);
|
|
108
|
+
const { accessId, policy, signature, host, dir } = signatureData;
|
|
109
|
+
// 步骤 2: 直接上传到华为云 OBS
|
|
110
|
+
const objectKey = `${dir}${filename}`;
|
|
111
|
+
const obsUrl = `${host}/${objectKey}`;
|
|
112
|
+
const formData = new FormData();
|
|
113
|
+
formData.append('key', objectKey);
|
|
114
|
+
formData.append('OSSAccessKeyId', accessId);
|
|
115
|
+
formData.append('policy', policy);
|
|
116
|
+
formData.append('signature', signature);
|
|
117
|
+
formData.append('expire', signatureData.expire);
|
|
118
|
+
formData.append('file', new Blob([fileBuffer]), filename);
|
|
119
|
+
const response = await this.runtime.fetch(obsUrl, {
|
|
120
|
+
method: 'POST',
|
|
121
|
+
body: formData,
|
|
122
|
+
});
|
|
123
|
+
if (!response.ok) {
|
|
124
|
+
throw new Error(`OBS 上传失败: ${response.status}`);
|
|
125
|
+
}
|
|
126
|
+
// 返回 CSDN 图片 URL
|
|
127
|
+
return `https://csdn-img-blog.obs.cn-north-4.myhuaweicloud.com/${objectKey}`;
|
|
128
|
+
}
|
|
129
|
+
catch (apiError) {
|
|
130
|
+
throw new Error(`封面 API 上传失败: ${apiError.message}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* 获取华为云 OBS 上传签名
|
|
135
|
+
*/
|
|
136
|
+
async getUploadSignature(imageSuffix) {
|
|
137
|
+
const cookieStr = this.buildCookieString(this.cookieData);
|
|
138
|
+
// CSDN 封面图签名 API 使用的 key 和之前的不同
|
|
139
|
+
const coverApiKey = '260196572';
|
|
140
|
+
const nonce = this.createUuid();
|
|
141
|
+
const timestamp = Date.now().toString();
|
|
142
|
+
const apiPath = '/resource-api/v1/image/direct/upload/signature';
|
|
143
|
+
// 按照 Lt 函数格式: method\naccept\n\ncontentType\ntimestamp\n(sorted headers)\nurl
|
|
144
|
+
const signStr = `POST\n*/*\n\napplication/json\n${timestamp}\nx-ca-key:${coverApiKey}\nx-ca-nonce:${nonce}\nx-ca-timestamp:${timestamp}\n${apiPath}`;
|
|
145
|
+
// 封面签名使用专用的 secret
|
|
146
|
+
const signature = await this.hmacSha256(signStr, COVER_API_SECRET);
|
|
147
|
+
const response = await this.runtime.fetch(`https://bizapi.csdn.net${apiPath}`, {
|
|
148
|
+
method: 'POST',
|
|
149
|
+
headers: {
|
|
150
|
+
'accept': 'application/json',
|
|
151
|
+
'x-ca-key': coverApiKey,
|
|
152
|
+
'x-ca-nonce': nonce,
|
|
153
|
+
'x-ca-timestamp': timestamp,
|
|
154
|
+
'x-ca-signature': signature,
|
|
155
|
+
'x-ca-signature-headers': 'x-ca-key,x-ca-nonce,x-ca-timestamp',
|
|
156
|
+
'content-type': 'application/json',
|
|
157
|
+
'Cookie': cookieStr,
|
|
158
|
+
},
|
|
159
|
+
body: JSON.stringify({
|
|
160
|
+
imageTemplate: '',
|
|
161
|
+
appName: 'direct_blog_coverimage',
|
|
162
|
+
imageSuffix,
|
|
163
|
+
}),
|
|
164
|
+
});
|
|
165
|
+
const res = await response.json();
|
|
166
|
+
if (res.code !== 200 || !res.data) {
|
|
167
|
+
throw new Error(res.message || '获取上传签名失败');
|
|
168
|
+
}
|
|
169
|
+
return res.data;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* 处理封面图:上传本地文件或使用网络 URL
|
|
173
|
+
*/
|
|
174
|
+
async processCover(cover) {
|
|
175
|
+
if (!cover)
|
|
176
|
+
return [];
|
|
177
|
+
try {
|
|
178
|
+
const coverUrl = await this.uploadImage(cover);
|
|
179
|
+
return [coverUrl];
|
|
180
|
+
}
|
|
181
|
+
catch (err) {
|
|
182
|
+
console.warn(`[CSDN] 封面上传失败: ${err.message}`);
|
|
183
|
+
return [];
|
|
184
|
+
}
|
|
185
|
+
}
|
|
78
186
|
async checkAuth() {
|
|
79
187
|
if (!this.cookieData?.cookies) {
|
|
80
188
|
return { isAuthenticated: false, error: '未配置 CSDN Cookie,请运行: mpub cookie --platform csdn --set' };
|
|
@@ -118,6 +226,7 @@ export class CSDNAdapter {
|
|
|
118
226
|
// 生成签名
|
|
119
227
|
const apiPath = '/blog-console-api/v3/mdeditor/saveArticle';
|
|
120
228
|
const headers = await this.signRequest(apiPath);
|
|
229
|
+
// 先发布文章(不带封面,因为 API 签名有问题)
|
|
121
230
|
const response = await this.runtime.fetch(`https://bizapi.csdn.net${apiPath}`, {
|
|
122
231
|
method: 'POST',
|
|
123
232
|
headers: {
|
|
@@ -139,7 +248,7 @@ export class CSDNAdapter {
|
|
|
139
248
|
not_auto_saved: '1',
|
|
140
249
|
source: 'pc_mdeditor',
|
|
141
250
|
cover_images: [],
|
|
142
|
-
cover_type:
|
|
251
|
+
cover_type: 0,
|
|
143
252
|
is_new: 1,
|
|
144
253
|
vote_id: 0,
|
|
145
254
|
resource_id: '',
|
|
@@ -153,6 +262,32 @@ export class CSDNAdapter {
|
|
|
153
262
|
}
|
|
154
263
|
const postId = res.data.id;
|
|
155
264
|
const draftUrl = `https://editor.csdn.net/md?articleId=${postId}`;
|
|
265
|
+
// 如果有封面图片,先下载 URL 封面到本地,再通过浏览器上传
|
|
266
|
+
if (article.cover) {
|
|
267
|
+
let localCover = article.cover;
|
|
268
|
+
// 如果是 URL 封面,先下载到本地
|
|
269
|
+
if (!existsSync(article.cover)) {
|
|
270
|
+
console.log(`[CSDN] 封面是 URL,正在下载到本地...`);
|
|
271
|
+
const downloadResult = await downloadCoverUrl(article.cover);
|
|
272
|
+
if (!downloadResult.success || !downloadResult.localPath) {
|
|
273
|
+
console.warn(`[CSDN] 封面下载失败: ${downloadResult.error},跳过封面上传`);
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
localCover = downloadResult.localPath;
|
|
277
|
+
console.log(`[CSDN] 封面下载成功: ${localCover}`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (existsSync(localCover)) {
|
|
281
|
+
console.log(`[CSDN] 封面上传中...`);
|
|
282
|
+
const coverResult = await uploadCoverViaBrowser(postId, localCover);
|
|
283
|
+
if (coverResult.success && coverResult.coverUrl) {
|
|
284
|
+
console.log(`[CSDN] 封面 URL: ${coverResult.coverUrl}`);
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
console.warn(`[CSDN] 封面上传失败: ${coverResult.error}`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
156
291
|
return {
|
|
157
292
|
platform: this.meta.id,
|
|
158
293
|
success: true,
|
|
@@ -36,9 +36,19 @@ export interface AuthResult {
|
|
|
36
36
|
avatar?: string;
|
|
37
37
|
error?: string;
|
|
38
38
|
}
|
|
39
|
+
export interface MermaidProcessResult {
|
|
40
|
+
html: string;
|
|
41
|
+
tempFiles: string[];
|
|
42
|
+
}
|
|
39
43
|
export interface IPlatformAdapter {
|
|
40
44
|
readonly meta: PlatformMeta;
|
|
41
45
|
init(runtime: RuntimeInterface): Promise<void>;
|
|
42
46
|
checkAuth(): Promise<AuthResult>;
|
|
47
|
+
/**
|
|
48
|
+
* 可选钩子:自定义处理 Mermaid 代码块
|
|
49
|
+
* 如果平台不支持原生 Mermaid 渲染,实现此方法进行转换
|
|
50
|
+
* 返回处理后的 HTML 和需要清理的临时文件路径
|
|
51
|
+
*/
|
|
52
|
+
processMermaid?(html: string): Promise<MermaidProcessResult>;
|
|
43
53
|
publish(article: Article): Promise<SyncResult>;
|
|
44
54
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 企鹅号适配器
|
|
3
|
+
* 认证方式: Cookie
|
|
4
|
+
* 发布方式: API (editorCache/update)
|
|
5
|
+
*/
|
|
6
|
+
import type { IPlatformAdapter, Article, SyncResult, AuthResult, PlatformMeta } from './interface.js';
|
|
7
|
+
import type { RuntimeInterface } from '../runtime/index.js';
|
|
8
|
+
export declare class QQAdapter implements IPlatformAdapter {
|
|
9
|
+
readonly meta: PlatformMeta;
|
|
10
|
+
private runtime;
|
|
11
|
+
init(runtime: RuntimeInterface): Promise<void>;
|
|
12
|
+
private getCookies;
|
|
13
|
+
checkAuth(): Promise<AuthResult>;
|
|
14
|
+
/**
|
|
15
|
+
* 上传封面图片到企鹅号,返回图片 URL
|
|
16
|
+
*/
|
|
17
|
+
private uploadCover;
|
|
18
|
+
publish(article: Article): Promise<SyncResult>;
|
|
19
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { ConfigStore } from '../config.js';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
export class QQAdapter {
|
|
4
|
+
meta = {
|
|
5
|
+
id: 'qq',
|
|
6
|
+
name: '企鹅号',
|
|
7
|
+
icon: 'https://inews.gtimg.com/news_lite/20210806/favicon.ico',
|
|
8
|
+
homepage: 'https://om.qq.com/',
|
|
9
|
+
capabilities: ['article', 'draft', 'image_upload'],
|
|
10
|
+
};
|
|
11
|
+
runtime;
|
|
12
|
+
async init(runtime) {
|
|
13
|
+
this.runtime = runtime;
|
|
14
|
+
}
|
|
15
|
+
async getCookies() {
|
|
16
|
+
return await ConfigStore.getQQCookies();
|
|
17
|
+
}
|
|
18
|
+
async checkAuth() {
|
|
19
|
+
const cookieData = await this.getCookies();
|
|
20
|
+
if (!cookieData || Object.keys(cookieData).length === 0) {
|
|
21
|
+
return { isAuthenticated: false, error: '未配置企鹅号 Cookie,请运行: mpub login --platform qq' };
|
|
22
|
+
}
|
|
23
|
+
if (!cookieData['userid']) {
|
|
24
|
+
return { isAuthenticated: false, error: 'Cookie 中缺少 userid,可能登录已失效' };
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
return {
|
|
28
|
+
isAuthenticated: true,
|
|
29
|
+
userId: cookieData['userid'],
|
|
30
|
+
username: cookieData['pt2gguin'] ? `o${cookieData['pt2gguin']}` : undefined,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
return { isAuthenticated: false, error: err.message };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* 上传封面图片到企鹅号,返回图片 URL
|
|
39
|
+
*/
|
|
40
|
+
async uploadCover(imagePath, cookieStr) {
|
|
41
|
+
const FormData = require('form-data');
|
|
42
|
+
const fs = require('fs');
|
|
43
|
+
const form = new FormData();
|
|
44
|
+
const fileBuffer = fs.readFileSync(imagePath);
|
|
45
|
+
const fileName = imagePath.split(/[\\/]/).pop() || 'image.jpg';
|
|
46
|
+
form.append('file', fileBuffer, {
|
|
47
|
+
filename: fileName,
|
|
48
|
+
contentType: 'image/jpeg',
|
|
49
|
+
});
|
|
50
|
+
const response = await this.runtime.fetch('https://image.om.qq.com/cpom_pimage/ArchacaleUploadViaFile', {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
headers: {
|
|
53
|
+
'Cookie': cookieStr,
|
|
54
|
+
'Referer': 'https://om.qq.com/',
|
|
55
|
+
'X-Requested-With': 'XMLHttpRequest',
|
|
56
|
+
...form.getHeaders(),
|
|
57
|
+
},
|
|
58
|
+
body: form,
|
|
59
|
+
});
|
|
60
|
+
if (!response.ok) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
const data = await response.json();
|
|
65
|
+
if (data.code === 0 && data.data?.url) {
|
|
66
|
+
const urlData = data.data.url;
|
|
67
|
+
return urlData['640']?.imageUrl || urlData['0']?.imageUrl || Object.values(urlData)[0]?.imageUrl || null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch { }
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
async publish(article) {
|
|
74
|
+
const start = Date.now();
|
|
75
|
+
try {
|
|
76
|
+
const cookieData = await this.getCookies();
|
|
77
|
+
if (!cookieData || Object.keys(cookieData).length === 0) {
|
|
78
|
+
throw new Error('未配置企鹅号 Cookie,请运行: mpub login --platform qq');
|
|
79
|
+
}
|
|
80
|
+
const cookieStr = Object.entries(cookieData)
|
|
81
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
82
|
+
.join('; ');
|
|
83
|
+
const mediaId = cookieData['userid'];
|
|
84
|
+
// 上传封面(如果有)
|
|
85
|
+
let coverUrl = '';
|
|
86
|
+
if (article.cover && existsSync(article.cover)) {
|
|
87
|
+
try {
|
|
88
|
+
coverUrl = await this.uploadCover(article.cover, cookieStr) || '';
|
|
89
|
+
console.log(`[QQ] 封面上传结果: ${coverUrl || '失败'}`);
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
console.warn(`封面图上传失败: ${err.message}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// 构建文章数据
|
|
96
|
+
const articleData = {
|
|
97
|
+
title: article.title,
|
|
98
|
+
title2: '',
|
|
99
|
+
tag: '',
|
|
100
|
+
video: '',
|
|
101
|
+
cover_type: coverUrl ? '1' : '1',
|
|
102
|
+
imgurl_ext: '[]',
|
|
103
|
+
category_id: '',
|
|
104
|
+
content: `<p>${(article.markdown || article.html || '').replace(/<[^>]+>/g, '')}</p>`,
|
|
105
|
+
orignal: 0,
|
|
106
|
+
user_original: 0,
|
|
107
|
+
music: '',
|
|
108
|
+
activity: '',
|
|
109
|
+
apply_olympic_flag: 0,
|
|
110
|
+
apply_push_flag: 0,
|
|
111
|
+
apply_reward_flag: 0,
|
|
112
|
+
reward_flag: 0,
|
|
113
|
+
survey_id: '',
|
|
114
|
+
survey_name: '',
|
|
115
|
+
imgurlsrc: coverUrl || null,
|
|
116
|
+
om_activity_id: '',
|
|
117
|
+
om_activity_name: '',
|
|
118
|
+
activityInfo: '',
|
|
119
|
+
commercialization_source: '',
|
|
120
|
+
caiMaiInfo: '',
|
|
121
|
+
isHowto: '0',
|
|
122
|
+
howtoInfo: '',
|
|
123
|
+
daihuoInfo: '',
|
|
124
|
+
novel: '',
|
|
125
|
+
needpub: 1,
|
|
126
|
+
event_id: '',
|
|
127
|
+
event_name: '',
|
|
128
|
+
activity_scene_id: 0,
|
|
129
|
+
hotBreak: '',
|
|
130
|
+
self_declare: '',
|
|
131
|
+
resource_aigc_mark_info: '{}',
|
|
132
|
+
parent_article_id: '',
|
|
133
|
+
conclusion: '',
|
|
134
|
+
summary: article.summary || '',
|
|
135
|
+
failedImage: [],
|
|
136
|
+
adContentImgs: [],
|
|
137
|
+
mediaId: mediaId,
|
|
138
|
+
type: 0,
|
|
139
|
+
unmount: false,
|
|
140
|
+
};
|
|
141
|
+
// 发送保存请求
|
|
142
|
+
const postData = new URLSearchParams();
|
|
143
|
+
postData.append('cache', JSON.stringify(articleData));
|
|
144
|
+
postData.append('mediaid', mediaId);
|
|
145
|
+
const response = await this.runtime.fetch('https://om.qq.com/editorCache/update', {
|
|
146
|
+
method: 'POST',
|
|
147
|
+
headers: {
|
|
148
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
149
|
+
'Cookie': cookieStr,
|
|
150
|
+
'Referer': 'https://om.qq.com/main/creation/article',
|
|
151
|
+
},
|
|
152
|
+
body: postData.toString(),
|
|
153
|
+
});
|
|
154
|
+
const responseText = await response.text();
|
|
155
|
+
if (!response.ok) {
|
|
156
|
+
throw new Error(`保存文章失败: ${response.status} - ${responseText.substring(0, 100)}`);
|
|
157
|
+
}
|
|
158
|
+
let resultData;
|
|
159
|
+
try {
|
|
160
|
+
resultData = JSON.parse(responseText);
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
throw new Error(`保存响应无效: ${responseText.substring(0, 100)}`);
|
|
164
|
+
}
|
|
165
|
+
if (resultData.response?.code !== '0') {
|
|
166
|
+
throw new Error(resultData.response?.msg || '保存文章失败');
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
platform: this.meta.id,
|
|
170
|
+
success: true,
|
|
171
|
+
draftOnly: true, // 企鹅号需要审核
|
|
172
|
+
timestamp: Date.now() - start,
|
|
173
|
+
postUrl: 'https://om.qq.com/main/creation/article',
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
return {
|
|
178
|
+
platform: this.meta.id,
|
|
179
|
+
success: false,
|
|
180
|
+
error: err.message,
|
|
181
|
+
timestamp: Date.now() - start,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -18,6 +18,7 @@ import { YuqueAdapter } from './yuque.js';
|
|
|
18
18
|
import { Cto51Adapter } from './cto51.js';
|
|
19
19
|
import { ToutiaoAdapter } from './toutiao.js';
|
|
20
20
|
import { XiaohongshuAdapter } from './xiaohongshu.js';
|
|
21
|
+
import { QQAdapter } from './qq.js';
|
|
21
22
|
// 所有适配器类
|
|
22
23
|
const ADAPTER_CLASSES = {
|
|
23
24
|
weixin: WeixinAdapter,
|
|
@@ -40,6 +41,7 @@ const ADAPTER_CLASSES = {
|
|
|
40
41
|
cto51: Cto51Adapter,
|
|
41
42
|
toutiao: ToutiaoAdapter,
|
|
42
43
|
xiaohongshu: XiaohongshuAdapter,
|
|
44
|
+
qq: QQAdapter,
|
|
43
45
|
};
|
|
44
46
|
/**
|
|
45
47
|
* 适配器注册表
|
|
@@ -4,6 +4,10 @@ export declare class ToutiaoAdapter implements IPlatformAdapter {
|
|
|
4
4
|
private cookieData;
|
|
5
5
|
init(): Promise<void>;
|
|
6
6
|
checkAuth(): Promise<AuthResult>;
|
|
7
|
+
processMermaid(html: string): Promise<{
|
|
8
|
+
html: string;
|
|
9
|
+
tempFiles: string[];
|
|
10
|
+
}>;
|
|
7
11
|
publish(article: Article): Promise<SyncResult>;
|
|
8
12
|
}
|
|
9
13
|
import type { IPlatformAdapter } from './interface.js';
|