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
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 通用抓包工具
|
|
3
|
+
*
|
|
4
|
+
* 使用方式:
|
|
5
|
+
* node dist/tools/capture.js <platform> [timeout]
|
|
6
|
+
*
|
|
7
|
+
* 示例:
|
|
8
|
+
* node dist/tools/capture.js csdn # 抓取 CSDN (60s 超时)
|
|
9
|
+
* node dist/tools/capture.js csdn 120 # 抓取 CSDN (120s 超时)
|
|
10
|
+
* node dist/tools/capture.js juejin # 抓取掘金
|
|
11
|
+
*/
|
|
12
|
+
import { chromium } from 'playwright';
|
|
13
|
+
import { ConfigStore } from '../config.js';
|
|
14
|
+
import fs from 'fs/promises';
|
|
15
|
+
import path from 'path';
|
|
16
|
+
// 平台配置
|
|
17
|
+
const PLATFORM_CONFIGS = {
|
|
18
|
+
csdn: {
|
|
19
|
+
editorUrl: 'https://editor.csdn.net/md/',
|
|
20
|
+
uploadPatterns: [/uploadImg/i, /upload/i, /file/i, /image/i],
|
|
21
|
+
},
|
|
22
|
+
juejin: {
|
|
23
|
+
editorUrl: 'https://editor.juejin.cn/',
|
|
24
|
+
uploadPatterns: [/upload/i, /file/i, /image/i, /img/i],
|
|
25
|
+
},
|
|
26
|
+
zhihu: {
|
|
27
|
+
editorUrl: 'https://zhuanlan.zhihu.com/write',
|
|
28
|
+
uploadPatterns: [/upload/i, /file/i, /image/i, /img/i],
|
|
29
|
+
},
|
|
30
|
+
toutiao: {
|
|
31
|
+
editorUrl: 'https://mp.toutiao.com/',
|
|
32
|
+
uploadPatterns: [/upload/i, /file/i, /image/i, /img/i],
|
|
33
|
+
},
|
|
34
|
+
jianshu: {
|
|
35
|
+
editorUrl: 'https://www.jianshu.com/writer',
|
|
36
|
+
uploadPatterns: [/upload/i, /file/i, /image/i, /img/i],
|
|
37
|
+
},
|
|
38
|
+
weibo: {
|
|
39
|
+
editorUrl: 'https://weibo.com/compose',
|
|
40
|
+
uploadPatterns: [/upload/i, /file/i, /image/i, /img/i],
|
|
41
|
+
},
|
|
42
|
+
qq: {
|
|
43
|
+
editorUrl: 'https://om.qq.com/main/creation/article',
|
|
44
|
+
uploadPatterns: [/upload/i, /file/i, /image/i, /img/i, /cover/i],
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
function isUploadRequest(url, headers, postData) {
|
|
48
|
+
const contentType = headers['content-type'] || '';
|
|
49
|
+
const isMultipart = contentType.includes('multipart/form-data');
|
|
50
|
+
const isUploadUrl = PLATFORM_CONFIGS[Object.keys(PLATFORM_CONFIGS)[0]]?.uploadPatterns.some(p => p.test(url));
|
|
51
|
+
// 检查 URL 是否匹配上传模式
|
|
52
|
+
for (const config of Object.values(PLATFORM_CONFIGS)) {
|
|
53
|
+
if (config.uploadPatterns.some(p => p.test(url))) {
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// 如果是 multipart/form-data,很可能是上传
|
|
58
|
+
if (isMultipart) {
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
async function loadCookies(platform) {
|
|
64
|
+
try {
|
|
65
|
+
switch (platform) {
|
|
66
|
+
case 'csdn': return await ConfigStore.getCSDNCookies() || {};
|
|
67
|
+
case 'zhihu': return await ConfigStore.getZhihuCookies() || {};
|
|
68
|
+
case 'juejin': return await ConfigStore.getJuejinCookies() || {};
|
|
69
|
+
case 'toutiao': return await ConfigStore.getToutiaoCookies() || {};
|
|
70
|
+
case 'jianshu': return await ConfigStore.getJianshuCookies() || {};
|
|
71
|
+
case 'weibo': return await ConfigStore.getWeiboCookies() || {};
|
|
72
|
+
case 'qq': return await ConfigStore.getQQCookies() || {};
|
|
73
|
+
default: return {};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return {};
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function getCookieDomains(platform) {
|
|
81
|
+
const domains = {
|
|
82
|
+
csdn: ['.csdn.net', '.bizapi.csdn.net'],
|
|
83
|
+
zhihu: ['.zhihu.com'],
|
|
84
|
+
juejin: ['.juejin.cn', '.掘金.com'],
|
|
85
|
+
toutiao: ['.toutiao.com'],
|
|
86
|
+
jianshu: ['.jianshu.com'],
|
|
87
|
+
weibo: ['.weibo.com', '.sina.com.cn'],
|
|
88
|
+
qq: ['.qq.com', '.om.qq.com'],
|
|
89
|
+
};
|
|
90
|
+
return domains[platform] || [];
|
|
91
|
+
}
|
|
92
|
+
export async function capture(platform, timeoutMs = 60000) {
|
|
93
|
+
const config = PLATFORM_CONFIGS[platform];
|
|
94
|
+
if (!config) {
|
|
95
|
+
console.error(`不支持的平台: ${platform}`);
|
|
96
|
+
console.error(`支持的平台: ${Object.keys(PLATFORM_CONFIGS).join(', ')}`);
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
console.log(`
|
|
100
|
+
========================================
|
|
101
|
+
通用抓包工具 - ${platform.toUpperCase()}
|
|
102
|
+
========================================
|
|
103
|
+
平台: ${platform}
|
|
104
|
+
编辑器: ${config.editorUrl}
|
|
105
|
+
超时: ${timeoutMs / 1000}秒
|
|
106
|
+
========================================
|
|
107
|
+
`);
|
|
108
|
+
// 加载 cookies
|
|
109
|
+
const cookies = await loadCookies(platform);
|
|
110
|
+
if (Object.keys(cookies).length === 0) {
|
|
111
|
+
console.warn(`⚠️ 未找到 ${platform} 的 cookies,可能需要先登录`);
|
|
112
|
+
console.warn(` 运行: node dist/cli/index.js login --platform ${platform}`);
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
console.log(`✅ 已加载 ${Object.keys(cookies).length} 个 cookies`);
|
|
116
|
+
}
|
|
117
|
+
// 启动浏览器
|
|
118
|
+
const browser = await chromium.launch({
|
|
119
|
+
headless: false,
|
|
120
|
+
channel: 'chromium',
|
|
121
|
+
args: [
|
|
122
|
+
'--disable-blink-features=AutomationControlled',
|
|
123
|
+
'--disable-devtools-shm-usage',
|
|
124
|
+
'--no-sandbox',
|
|
125
|
+
],
|
|
126
|
+
});
|
|
127
|
+
const context = await browser.newContext({
|
|
128
|
+
viewport: { width: 1280, height: 900 },
|
|
129
|
+
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
130
|
+
});
|
|
131
|
+
const page = await context.newPage();
|
|
132
|
+
// 使用 CDP 隐藏自动化特征
|
|
133
|
+
const cdp = await page.context().newCDPSession(page);
|
|
134
|
+
await cdp.send('Page.addScriptToEvaluateOnNewDocument', {
|
|
135
|
+
source: `
|
|
136
|
+
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
|
137
|
+
Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
|
|
138
|
+
Object.defineProperty(navigator, 'languages', { get: () => ['zh-CN', 'zh', 'en'] });
|
|
139
|
+
window.chrome = { runtime: {}, loadTimes: () => {}, csi: () => {} };
|
|
140
|
+
`
|
|
141
|
+
});
|
|
142
|
+
// 设置 cookies
|
|
143
|
+
const cookieDomains = getCookieDomains(platform);
|
|
144
|
+
for (const [name, value] of Object.entries(cookies)) {
|
|
145
|
+
for (const domain of cookieDomains) {
|
|
146
|
+
try {
|
|
147
|
+
await context.addCookies([{
|
|
148
|
+
name,
|
|
149
|
+
value,
|
|
150
|
+
domain,
|
|
151
|
+
path: '/',
|
|
152
|
+
}]);
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
// 忽略无效 cookie
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// 抓包数据
|
|
161
|
+
const requests = [];
|
|
162
|
+
let lastActivityTime = Date.now();
|
|
163
|
+
// 拦截所有请求
|
|
164
|
+
page.on('request', async (request) => {
|
|
165
|
+
const url = request.url();
|
|
166
|
+
const headers = request.headers();
|
|
167
|
+
const method = request.method();
|
|
168
|
+
const contentType = headers['content-type'] || '';
|
|
169
|
+
// 只记录 API 请求(放宽过滤条件以捕获 QQ 的特殊接口)
|
|
170
|
+
if (!url.includes('api') && !url.includes('bizapi') && !url.includes('.om.qq.com') && !url.includes('editorCache')) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const captured = {
|
|
174
|
+
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
|
175
|
+
timestamp: Date.now(),
|
|
176
|
+
method,
|
|
177
|
+
url,
|
|
178
|
+
headers,
|
|
179
|
+
matched: isUploadRequest(url, headers),
|
|
180
|
+
};
|
|
181
|
+
// 获取 post data
|
|
182
|
+
try {
|
|
183
|
+
const postData = await request.postData();
|
|
184
|
+
if (postData) {
|
|
185
|
+
captured.postData = postData;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
// ignore
|
|
190
|
+
}
|
|
191
|
+
requests.push(captured);
|
|
192
|
+
lastActivityTime = Date.now();
|
|
193
|
+
// 打印关键请求
|
|
194
|
+
if (captured.matched || url.includes('upload')) {
|
|
195
|
+
console.log(`\n📤 [${method}] ${url}`);
|
|
196
|
+
if (contentType) {
|
|
197
|
+
console.log(` Content-Type: ${contentType.substring(0, 50)}...`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
// 拦截响应
|
|
202
|
+
page.on('response', async (response) => {
|
|
203
|
+
const url = response.url();
|
|
204
|
+
const status = response.status();
|
|
205
|
+
// 找到对应的请求并更新
|
|
206
|
+
const captured = requests.find(r => r.url === url);
|
|
207
|
+
if (captured) {
|
|
208
|
+
captured.responseStatus = status;
|
|
209
|
+
// 尝试获取响应体
|
|
210
|
+
try {
|
|
211
|
+
const body = await response.text();
|
|
212
|
+
if (body && body.length < 10000) {
|
|
213
|
+
captured.responseBody = body;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
// ignore
|
|
218
|
+
}
|
|
219
|
+
// 打印上传响应
|
|
220
|
+
if (captured.matched) {
|
|
221
|
+
console.log(` ← ${status} ${captured.responseBody?.substring(0, 100) || ''}...`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
// 监听浏览器关闭
|
|
226
|
+
const browserClosedPromise = new Promise((resolve) => {
|
|
227
|
+
browser.on('disconnected', () => {
|
|
228
|
+
console.log('\n🔌 浏览器已关闭,停止抓包...');
|
|
229
|
+
resolve();
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
// 超时 Promise
|
|
233
|
+
const timeoutPromise = new Promise((resolve) => {
|
|
234
|
+
setTimeout(() => {
|
|
235
|
+
console.log(`\n⏱️ 超时 (${timeoutMs / 1000}秒),停止抓包...`);
|
|
236
|
+
resolve();
|
|
237
|
+
}, timeoutMs);
|
|
238
|
+
});
|
|
239
|
+
// 无操作超时检测
|
|
240
|
+
const activityCheckPromise = new Promise((resolve) => {
|
|
241
|
+
const checkInterval = setInterval(() => {
|
|
242
|
+
if (Date.now() - lastActivityTime > timeoutMs) {
|
|
243
|
+
console.log('\n⏱️ 长时间无活动,停止抓包...');
|
|
244
|
+
clearInterval(checkInterval);
|
|
245
|
+
resolve();
|
|
246
|
+
}
|
|
247
|
+
}, 5000);
|
|
248
|
+
});
|
|
249
|
+
// 打开编辑器
|
|
250
|
+
console.log(`\n🌐 打开编辑器: ${config.editorUrl}`);
|
|
251
|
+
await page.goto(config.editorUrl, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
|
252
|
+
console.log('✅ 编辑器已加载');
|
|
253
|
+
console.log('\n📋 操作提示:');
|
|
254
|
+
console.log(' 1. 在编辑器中找到上传图片功能');
|
|
255
|
+
console.log(' 2. 上传一张图片');
|
|
256
|
+
console.log(' 3. 完成后关闭浏览器');
|
|
257
|
+
console.log('\n🕐 开始抓包,等待操作...\n');
|
|
258
|
+
// 等待浏览器关闭或超时
|
|
259
|
+
await Promise.race([
|
|
260
|
+
browserClosedPromise,
|
|
261
|
+
Promise.race([timeoutPromise, activityCheckPromise]),
|
|
262
|
+
]);
|
|
263
|
+
// 关闭浏览器
|
|
264
|
+
try {
|
|
265
|
+
await browser.close();
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
// ignore
|
|
269
|
+
}
|
|
270
|
+
// 分析抓包数据
|
|
271
|
+
const uploadEndpoints = [...new Set(requests.filter(r => r.matched).map(r => {
|
|
272
|
+
try {
|
|
273
|
+
return new URL(r.url).pathname;
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
return r.url;
|
|
277
|
+
}
|
|
278
|
+
}))];
|
|
279
|
+
const captureData = {
|
|
280
|
+
platform,
|
|
281
|
+
captureTime: new Date().toISOString(),
|
|
282
|
+
timeout: timeoutMs,
|
|
283
|
+
editorUrl: config.editorUrl,
|
|
284
|
+
uploadApiUrl: uploadEndpoints[0],
|
|
285
|
+
requests: requests.map(r => ({
|
|
286
|
+
...r,
|
|
287
|
+
responseBody: r.responseBody?.substring(0, 500), // 截断响应体
|
|
288
|
+
})),
|
|
289
|
+
summary: {
|
|
290
|
+
totalRequests: requests.length,
|
|
291
|
+
matchedRequests: requests.filter(r => r.matched).length,
|
|
292
|
+
uploadEndpoints,
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
return captureData;
|
|
296
|
+
}
|
|
297
|
+
async function main() {
|
|
298
|
+
const args = process.argv.slice(2);
|
|
299
|
+
const platform = args[0] || 'csdn';
|
|
300
|
+
const timeout = parseInt(args[1] || '60', 10) * 1000;
|
|
301
|
+
if (!PLATFORM_CONFIGS[platform]) {
|
|
302
|
+
console.error(`不支持的平台: ${platform}`);
|
|
303
|
+
console.error(`支持的平台: ${Object.keys(PLATFORM_CONFIGS).join(', ')}`);
|
|
304
|
+
process.exit(1);
|
|
305
|
+
}
|
|
306
|
+
const data = await capture(platform, timeout);
|
|
307
|
+
if (!data) {
|
|
308
|
+
process.exit(1);
|
|
309
|
+
}
|
|
310
|
+
// 保存到临时文件
|
|
311
|
+
const timestamp = Date.now();
|
|
312
|
+
const filename = `temp/capture-${platform}-${timestamp}.json`;
|
|
313
|
+
const filepath = path.resolve(process.cwd(), filename);
|
|
314
|
+
// 确保 temp 目录存在
|
|
315
|
+
await fs.mkdir(path.dirname(filepath), { recursive: true });
|
|
316
|
+
await fs.writeFile(filepath, JSON.stringify(data, null, 2), 'utf-8');
|
|
317
|
+
console.log(`
|
|
318
|
+
========================================
|
|
319
|
+
抓包完成
|
|
320
|
+
========================================
|
|
321
|
+
文件: ${filepath}
|
|
322
|
+
|
|
323
|
+
📊 统计:
|
|
324
|
+
总请求数: ${data.summary.totalRequests}
|
|
325
|
+
命中请求: ${data.summary.matchedRequests}
|
|
326
|
+
上传端点: ${data.summary.uploadEndpoints.join(', ') || '无'}
|
|
327
|
+
|
|
328
|
+
========================================
|
|
329
|
+
`);
|
|
330
|
+
if (data.summary.uploadEndpoints.length > 0) {
|
|
331
|
+
console.log('🎯 检测到上传请求!');
|
|
332
|
+
console.log(' 把这个文件路径告诉 AI,让它分析签名格式');
|
|
333
|
+
}
|
|
334
|
+
else {
|
|
335
|
+
console.log('⚠️ 未检测到上传请求');
|
|
336
|
+
console.log(' 可能原因:');
|
|
337
|
+
console.log(' 1. 没有执行上传操作');
|
|
338
|
+
console.log(' 2. 上传功能需要先登录');
|
|
339
|
+
console.log(' 3. 平台上传 API 不在拦截范围内');
|
|
340
|
+
}
|
|
341
|
+
console.log('\n运行 AI 分析:');
|
|
342
|
+
console.log(` 帮我分析 ${platform} 的抓包数据: ${filepath}`);
|
|
343
|
+
}
|
|
344
|
+
// 仅在直接运行时执行(不在被 import 时执行)
|
|
345
|
+
const isMainModule = import.meta.url === `file://${process.argv[1]}`;
|
|
346
|
+
if (isMainModule) {
|
|
347
|
+
main().catch(console.error);
|
|
348
|
+
}
|
|
@@ -19,3 +19,13 @@ export declare function fetchCoverByTitle(title: string, options?: {
|
|
|
19
19
|
* 清理临时封面文件
|
|
20
20
|
*/
|
|
21
21
|
export declare function cleanupCoverFile(localPath: string | undefined): Promise<void>;
|
|
22
|
+
/**
|
|
23
|
+
* 下载远程封面图片到本地临时目录
|
|
24
|
+
* @param coverUrl 远程图片 URL
|
|
25
|
+
* @param options 配置选项
|
|
26
|
+
* @returns 本地临时文件路径
|
|
27
|
+
*/
|
|
28
|
+
export declare function downloadCoverUrl(coverUrl: string, options?: {
|
|
29
|
+
timeout?: number;
|
|
30
|
+
outputDir?: string;
|
|
31
|
+
}): Promise<CoverFetchResult>;
|
|
@@ -351,3 +351,103 @@ export async function cleanupCoverFile(localPath) {
|
|
|
351
351
|
}
|
|
352
352
|
catch { }
|
|
353
353
|
}
|
|
354
|
+
/**
|
|
355
|
+
* 下载远程封面图片到本地临时目录
|
|
356
|
+
* @param coverUrl 远程图片 URL
|
|
357
|
+
* @param options 配置选项
|
|
358
|
+
* @returns 本地临时文件路径
|
|
359
|
+
*/
|
|
360
|
+
export async function downloadCoverUrl(coverUrl, options = {}) {
|
|
361
|
+
const { timeout = 15000, outputDir = tmpdir(), } = options;
|
|
362
|
+
if (!coverUrl || !coverUrl.startsWith('http')) {
|
|
363
|
+
return { success: false, error: '无效的 URL' };
|
|
364
|
+
}
|
|
365
|
+
let browser = null;
|
|
366
|
+
try {
|
|
367
|
+
console.log(`[cover-fetcher] 下载封面: ${coverUrl.substring(0, 80)}...`);
|
|
368
|
+
browser = await chromium.launch({ headless: true });
|
|
369
|
+
const context = await browser.newContext({
|
|
370
|
+
viewport: { width: 1280, height: 900 },
|
|
371
|
+
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
372
|
+
});
|
|
373
|
+
const page = await context.newPage();
|
|
374
|
+
// 绕过反爬检测
|
|
375
|
+
await page.addInitScript(() => {
|
|
376
|
+
Object.defineProperty(navigator, 'webdriver', { get: () => false });
|
|
377
|
+
});
|
|
378
|
+
let buffer = null;
|
|
379
|
+
let actualContentType = '';
|
|
380
|
+
// 方法1:直接下载
|
|
381
|
+
try {
|
|
382
|
+
const response = await page.request.get(coverUrl, {
|
|
383
|
+
headers: {
|
|
384
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
|
385
|
+
'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*',
|
|
386
|
+
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
|
387
|
+
},
|
|
388
|
+
timeout: timeout,
|
|
389
|
+
});
|
|
390
|
+
if (response.ok()) {
|
|
391
|
+
buffer = await response.body();
|
|
392
|
+
actualContentType = response.headers()['content-type'] || '';
|
|
393
|
+
console.log(`[cover-fetcher] 下载成功,大小: ${(buffer.length / 1024).toFixed(1)} KB`);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
catch (e) {
|
|
397
|
+
console.log(`[cover-fetcher] 直接下载失败: ${e.message}`);
|
|
398
|
+
}
|
|
399
|
+
// 方法2:如果失败,尝试通过浏览器加载
|
|
400
|
+
if (!buffer || buffer.length < 1000) {
|
|
401
|
+
try {
|
|
402
|
+
await page.goto(coverUrl, { waitUntil: 'networkidle', timeout: 15000 });
|
|
403
|
+
const finalUrl = page.url();
|
|
404
|
+
if (finalUrl !== coverUrl) {
|
|
405
|
+
const response2 = await page.request.get(finalUrl, { timeout: timeout });
|
|
406
|
+
if (response2.ok()) {
|
|
407
|
+
buffer = await response2.body();
|
|
408
|
+
actualContentType = response2.headers()['content-type'] || '';
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
catch (e) {
|
|
413
|
+
console.log(`[cover-fetcher] 浏览器加载失败: ${e.message}`);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
if (!buffer || buffer.length < 1000) {
|
|
417
|
+
return { success: false, error: '无法下载图片' };
|
|
418
|
+
}
|
|
419
|
+
// 检测图片类型
|
|
420
|
+
const detectedExt = detectImageType(buffer);
|
|
421
|
+
const fromHeader = getExtensionFromContentType(actualContentType);
|
|
422
|
+
const ext = detectedExt || fromHeader || getExtension(coverUrl);
|
|
423
|
+
const filename = generateFilename(ext);
|
|
424
|
+
const localPath = path.join(outputDir, filename);
|
|
425
|
+
await fs.writeFile(localPath, buffer);
|
|
426
|
+
// WebP 转换为 PNG
|
|
427
|
+
if (ext === '.webp') {
|
|
428
|
+
try {
|
|
429
|
+
const convertedPath = await convertWebpToPng(localPath, outputDir);
|
|
430
|
+
return { success: true, localPath: convertedPath, imageUrl: coverUrl };
|
|
431
|
+
}
|
|
432
|
+
catch {
|
|
433
|
+
console.warn(`[cover-fetcher] WebP 转换失败,保留原文件`);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return {
|
|
437
|
+
success: true,
|
|
438
|
+
localPath,
|
|
439
|
+
imageUrl: coverUrl
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
catch (error) {
|
|
443
|
+
return {
|
|
444
|
+
success: false,
|
|
445
|
+
error: error.message
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
finally {
|
|
449
|
+
if (browser) {
|
|
450
|
+
await browser.close().catch(() => { });
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 上传本地图片到图床(自动重试多个服务)
|
|
3
|
+
* 返回公开 URL
|
|
4
|
+
*/
|
|
5
|
+
export declare function uploadImageToPublicUrl(filePath: string): Promise<string>;
|
|
6
|
+
/**
|
|
7
|
+
* 上传到 Litterbox(临时,1小时有效期,无需认证)
|
|
8
|
+
*/
|
|
9
|
+
declare function uploadToLitterbox(filePath: string): Promise<string>;
|
|
10
|
+
/**
|
|
11
|
+
* 上传到 Catbox.moe(永久,无需认证)
|
|
12
|
+
*/
|
|
13
|
+
declare function uploadToCatbox(filePath: string): Promise<string>;
|
|
14
|
+
/**
|
|
15
|
+
* 上传到 0x0.st(匿名,无需 API key)- 已禁用
|
|
16
|
+
*/
|
|
17
|
+
export declare function uploadTo0x0st(filePath: string): Promise<string>;
|
|
18
|
+
export { uploadToLitterbox, uploadToCatbox };
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 图片上传工具 - 提供公共 URL 供各平台使用
|
|
3
|
+
* 尝试多个图床服务,按优先级 fallback
|
|
4
|
+
*/
|
|
5
|
+
import axios from 'axios';
|
|
6
|
+
import FormData from 'form-data';
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
/**
|
|
10
|
+
* 上传本地图片到图床(自动重试多个服务)
|
|
11
|
+
* 返回公开 URL
|
|
12
|
+
*/
|
|
13
|
+
export async function uploadImageToPublicUrl(filePath) {
|
|
14
|
+
const services = [
|
|
15
|
+
() => uploadToLitterbox(filePath),
|
|
16
|
+
() => uploadToCatbox(filePath),
|
|
17
|
+
];
|
|
18
|
+
for (const attempt of services) {
|
|
19
|
+
try {
|
|
20
|
+
const url = await attempt();
|
|
21
|
+
return url;
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
console.warn(`[imgbb-uploader] 上传失败: ${err.message},尝试下一个服务`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
throw new Error('所有图床上传服务均失败');
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* 上传到 Litterbox(临时,1小时有效期,无需认证)
|
|
31
|
+
*/
|
|
32
|
+
async function uploadToLitterbox(filePath) {
|
|
33
|
+
const form = new FormData();
|
|
34
|
+
form.append('reqtype', 'fileupload');
|
|
35
|
+
form.append('time', '1h');
|
|
36
|
+
form.append('fileToUpload', readFileSync(filePath), {
|
|
37
|
+
filename: path.basename(filePath),
|
|
38
|
+
contentType: 'image/png',
|
|
39
|
+
});
|
|
40
|
+
const res = await axios.post('https://litterbox.catbox.moe/resources/internals/api.php', form, {
|
|
41
|
+
headers: form.getHeaders(),
|
|
42
|
+
timeout: 30000,
|
|
43
|
+
});
|
|
44
|
+
const url = res.data.trim();
|
|
45
|
+
if (!url.startsWith('http')) {
|
|
46
|
+
throw new Error(`Litterbox 上传失败: ${url}`);
|
|
47
|
+
}
|
|
48
|
+
return url;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* 上传到 Catbox.moe(永久,无需认证)
|
|
52
|
+
*/
|
|
53
|
+
async function uploadToCatbox(filePath) {
|
|
54
|
+
const form = new FormData();
|
|
55
|
+
form.append('reqtype', 'fileupload');
|
|
56
|
+
form.append('fileToUpload', readFileSync(filePath), {
|
|
57
|
+
filename: path.basename(filePath),
|
|
58
|
+
contentType: 'image/png',
|
|
59
|
+
});
|
|
60
|
+
const res = await axios.post('https://catbox.moe/user/api.php', form, {
|
|
61
|
+
headers: form.getHeaders(),
|
|
62
|
+
timeout: 30000,
|
|
63
|
+
});
|
|
64
|
+
const url = res.data;
|
|
65
|
+
if (!url.startsWith('http')) {
|
|
66
|
+
throw new Error(`Catbox 上传失败: ${url}`);
|
|
67
|
+
}
|
|
68
|
+
return url;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* 上传到 0x0.st(匿名,无需 API key)- 已禁用
|
|
72
|
+
*/
|
|
73
|
+
export async function uploadTo0x0st(filePath) {
|
|
74
|
+
const form = new FormData();
|
|
75
|
+
form.append('file', readFileSync(filePath), {
|
|
76
|
+
filename: path.basename(filePath),
|
|
77
|
+
contentType: 'image/png',
|
|
78
|
+
});
|
|
79
|
+
const res = await axios.post('https://0x0.st', form, {
|
|
80
|
+
headers: form.getHeaders(),
|
|
81
|
+
timeout: 30000,
|
|
82
|
+
});
|
|
83
|
+
const url = res.data.trim();
|
|
84
|
+
if (!url.startsWith('http')) {
|
|
85
|
+
throw new Error(`0x0.st 上传失败: ${url}`);
|
|
86
|
+
}
|
|
87
|
+
return url;
|
|
88
|
+
}
|
|
89
|
+
export { uploadToLitterbox, uploadToCatbox };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface ToutiaoUploadResult {
|
|
2
|
+
success: boolean;
|
|
3
|
+
coverUrl?: string;
|
|
4
|
+
error?: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* 通过浏览器自动化上传头条号封面
|
|
8
|
+
* 流程:打开发布页 -> 填写内容 -> 点击预览并发布 -> 在预览弹窗中上传封面 -> 确认
|
|
9
|
+
*/
|
|
10
|
+
export declare function uploadCoverViaBrowser(imagePath: string): Promise<ToutiaoUploadResult>;
|