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.
Files changed (42) hide show
  1. package/README.md +28 -22
  2. package/dist/adapters/csdn.d.ts +15 -0
  3. package/dist/adapters/csdn.js +136 -1
  4. package/dist/adapters/interface.d.ts +10 -0
  5. package/dist/adapters/qq.d.ts +19 -0
  6. package/dist/adapters/qq.js +185 -0
  7. package/dist/adapters/registry.js +2 -0
  8. package/dist/adapters/toutiao.d.ts +4 -0
  9. package/dist/adapters/toutiao.js +205 -40
  10. package/dist/adapters/wechat-publisher.d.ts +6 -1
  11. package/dist/adapters/wechat-publisher.js +58 -17
  12. package/dist/adapters/weixin.d.ts +5 -0
  13. package/dist/adapters/weixin.js +41 -5
  14. package/dist/cli/capture.d.ts +6 -0
  15. package/dist/cli/capture.js +49 -0
  16. package/dist/cli/index.js +8 -0
  17. package/dist/cli/login.js +3 -0
  18. package/dist/cli/platforms.js +2 -1
  19. package/dist/cli/publish-all.js +1 -1
  20. package/dist/cli/publish.d.ts +1 -0
  21. package/dist/cli/publish.js +29 -5
  22. package/dist/config.d.ts +8 -0
  23. package/dist/config.js +11 -0
  24. package/dist/core/parser.js +9 -1
  25. package/dist/core/renderer.d.ts +9 -1
  26. package/dist/core/renderer.js +86 -11
  27. package/dist/core/theme.d.ts +1 -1
  28. package/dist/core/theme.js +91 -336
  29. package/dist/runtime/browser-runtime.js +31 -2
  30. package/dist/tools/browser-upload.d.ts +13 -0
  31. package/dist/tools/browser-upload.js +349 -0
  32. package/dist/tools/capture.d.ts +26 -0
  33. package/dist/tools/capture.js +348 -0
  34. package/dist/tools/cover-fetcher.d.ts +10 -0
  35. package/dist/tools/cover-fetcher.js +100 -0
  36. package/dist/tools/imgbb-uploader.d.ts +18 -0
  37. package/dist/tools/imgbb-uploader.js +89 -0
  38. package/dist/tools/toutiao-upload.d.ts +10 -0
  39. package/dist/tools/toutiao-upload.js +166 -0
  40. package/package.json +1 -1
  41. package/themes/previews/cyberpunk.png +0 -0
  42. package/themes/previews/nord.png +0 -0
@@ -4,6 +4,13 @@
4
4
  */
5
5
  import { chromium } from 'playwright';
6
6
  import { ConfigStore } from '../config.js';
7
+ import { existsSync } from 'node:fs';
8
+ import { promises as fs } from 'node:fs';
9
+ import path from 'node:path';
10
+ import os from 'os';
11
+ import { downloadCoverUrl } from '../tools/cover-fetcher.js';
12
+ import { processMermaid } from '../core/renderer.js';
13
+ import { uploadImageToPublicUrl } from '../tools/imgbb-uploader.js';
7
14
  export class ToutiaoAdapter {
8
15
  meta = {
9
16
  id: 'toutiao',
@@ -22,10 +29,31 @@ export class ToutiaoAdapter {
22
29
  }
23
30
  const hasSession = this.cookieData['sessionid'] || this.cookieData['sid_tt'];
24
31
  if (!hasSession) {
25
- return { isAuthenticated: false, error: '未登录或登录已过期' };
32
+ return { isAuthenticated: false, error: '登录已过期' };
26
33
  }
27
34
  return { isAuthenticated: true };
28
35
  }
36
+ async processMermaid(html) {
37
+ const { html: processed, tempFiles } = await processMermaid(html, os.tmpdir());
38
+ // 上传图片到 ImgBB 获取公开 URL
39
+ let result = processed;
40
+ const imgPattern = /<img\s+[^>]*src=["']([^"']+)["'][^>]*>/gi;
41
+ for (const match of [...processed.matchAll(imgPattern)]) {
42
+ const original = match[0];
43
+ const src = match[1];
44
+ if (!src.startsWith('http://') && !src.startsWith('https://')) {
45
+ try {
46
+ const url = await uploadImageToPublicUrl(src);
47
+ result = result.replace(original, original.replace(src, url));
48
+ console.log(`[toutiao] mermaid 图片已上传: ${url}`);
49
+ }
50
+ catch (err) {
51
+ console.warn(`[toutiao] mermaid 图片上传失败 ${src}: ${err.message}`);
52
+ }
53
+ }
54
+ }
55
+ return { html: result, tempFiles };
56
+ }
29
57
  async publish(article) {
30
58
  const start = Date.now();
31
59
  if (!this.cookieData) {
@@ -36,6 +64,13 @@ export class ToutiaoAdapter {
36
64
  timestamp: Date.now() - start,
37
65
  };
38
66
  }
67
+ // 处理 Mermaid 代码块(转换为图片并上传到公开 URL)
68
+ let processedArticle = article;
69
+ if (this.processMermaid && article.html) {
70
+ const { html: mermaidHtml, tempFiles } = await this.processMermaid(article.html);
71
+ processedArticle = { ...article, html: mermaidHtml };
72
+ await Promise.all(tempFiles.map(f => fs.unlink(f).catch(() => { })));
73
+ }
39
74
  const browser = await chromium.launch({
40
75
  headless: false,
41
76
  args: [
@@ -93,73 +128,203 @@ export class ToutiaoAdapter {
93
128
  catch { }
94
129
  // 填写标题
95
130
  const titleTextarea = page.locator('textarea').first();
96
- await titleTextarea.fill(article.title);
131
+ await titleTextarea.fill(processedArticle.title);
97
132
  console.log('[toutiao] 已填写标题');
98
133
  // 填写内容
99
134
  const contentEl = page.locator('div[contenteditable="true"]').first();
100
135
  if (await contentEl.isVisible({ timeout: 2000 }).catch(() => false)) {
101
136
  await contentEl.click();
102
- // 使用 Ctrl+A 全选,然后输入内容(模拟用户输入)
103
137
  await page.keyboard.press('Control+a');
104
138
  await page.waitForTimeout(200);
105
- const htmlContent = article.html || article.markdown || '';
139
+ const htmlContent = processedArticle.html || processedArticle.markdown || '';
106
140
  await page.evaluate((el) => {
107
141
  const div = document.querySelector('[contenteditable="true"]');
108
142
  if (div) {
109
143
  div.innerHTML = el;
110
- // 触发 input 事件
111
144
  div.dispatchEvent(new InputEvent('input', { bubbles: true }));
112
145
  }
113
146
  }, htmlContent);
114
147
  console.log('[toutiao] 已填写内容');
115
148
  await page.waitForTimeout(1000);
116
149
  }
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;
150
+ // 如果有封面图片,先下载 URL 封面到本地,再上传
151
+ if (processedArticle.cover) {
152
+ let localCover = processedArticle.cover;
153
+ // 如果是 URL 封面,先下载到本地
154
+ if (!existsSync(processedArticle.cover)) {
155
+ console.log('[toutiao] 封面是 URL,正在下载到本地...');
156
+ const downloadResult = await downloadCoverUrl(processedArticle.cover);
157
+ if (!downloadResult.success || !downloadResult.localPath) {
158
+ console.warn(`[toutiao] 封面下载失败: ${downloadResult.error},跳过封面上传`);
159
+ }
160
+ else {
161
+ localCover = downloadResult.localPath;
162
+ console.log(`[toutiao] 封面下载成功: ${localCover}`);
163
+ }
164
+ }
165
+ if (existsSync(localCover)) {
166
+ console.log('[toutiao] 检测到封面图片,准备上传...');
167
+ // 设置 filechooser 监听(必须在点击之前设置)
168
+ const absolutePath = path.resolve(localCover);
169
+ console.log('[toutiao] 封面文件路径:', absolutePath);
170
+ // 使用 promise 等待 filechooser 事件
171
+ const fileChooserPromise = page.waitForEvent('filechooser', { timeout: 10000 });
172
+ // 点击"预览并发布"按钮打开预览弹窗
173
+ const previewBtn = page.locator('button:has-text("预览并发布")');
174
+ if (await previewBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
175
+ await previewBtn.click({ force: true });
176
+ console.log('[toutiao] 点击预览并发布按钮');
177
+ await page.waitForTimeout(2000);
178
+ // 选择"单图"选项
179
+ const singleImg = page.locator('text=单图').first();
180
+ if (await singleImg.isVisible({ timeout: 2000 }).catch(() => false)) {
181
+ await singleImg.click();
182
+ console.log('[toutiao] 选择单图');
183
+ await page.waitForTimeout(500);
184
+ }
185
+ // 点击封面添加区域
186
+ const coverAdd = page.locator('.article-cover-add');
187
+ if (await coverAdd.isVisible({ timeout: 2000 }).catch(() => false)) {
188
+ await coverAdd.click({ force: true });
189
+ console.log('[toutiao] 点击了封面添加区域');
190
+ await page.waitForTimeout(1500);
191
+ }
192
+ // 点击"本地上传"
193
+ const localUpload = page.locator('text=本地上传');
194
+ if (await localUpload.isVisible({ timeout: 3000 }).catch(() => false)) {
195
+ await localUpload.click();
196
+ console.log('[toutiao] 点击本地上传');
197
+ await page.waitForTimeout(1500);
198
+ }
199
+ let fileSet = false;
200
+ // 直接查找文件输入框,不依赖 filechooser 事件
201
+ console.log('[toutiao] 尝试直接设置文件输入框...');
202
+ const fileInput = page.locator('input[type="file"]').first();
203
+ if (await fileInput.isVisible({ timeout: 2000 }).catch(() => false)) {
204
+ console.log('[toutiao] 找到文件输入框,直接设置文件');
205
+ await fileInput.setInputFiles(absolutePath);
206
+ fileSet = true;
207
+ }
208
+ else {
209
+ // 文件输入框可能是 hidden,用 setInputFiles 即使不可见也可以设置
210
+ console.log('[toutiao] 文件输入框不可见,尝试直接 setInputFiles');
211
+ const allInputs = page.locator('input[type="file"]');
212
+ const count = await allInputs.count();
213
+ console.log(`[toutiao] 页面中有 ${count} 个 file 输入框`);
214
+ if (count > 0) {
215
+ await allInputs.first().setInputFiles(absolutePath);
216
+ fileSet = true;
217
+ console.log('[toutiao] 已对第一个 file 输入框设置文件');
218
+ }
219
+ }
220
+ if (!fileSet) {
221
+ // 尝试点击本地上传但使用另一种方式
222
+ const localUpload = page.locator('text=本地上传');
223
+ if (await localUpload.isVisible({ timeout: 3000 }).catch(() => false)) {
224
+ await localUpload.click();
225
+ console.log('[toutiao] 点击本地上传');
226
+ await page.waitForTimeout(1500);
227
+ // 点击后重新查找文件输入框
228
+ const newInputs = page.locator('input[type="file"]');
229
+ const newCount = await newInputs.count();
230
+ console.log(`[toutiao] 点击后有 ${newCount} 个 file 输入框`);
231
+ if (newCount > 0) {
232
+ await newInputs.first().setInputFiles(absolutePath);
233
+ fileSet = true;
234
+ console.log('[toutiao] 点击后设置文件成功');
235
+ }
236
+ }
237
+ }
238
+ if (fileSet) {
239
+ // 等待封面上传
240
+ console.log('[toutiao] 等待封面上传...');
241
+ await page.waitForTimeout(5000);
242
+ // 检测封面是否上传成功
243
+ const coverFound = await page.evaluate(() => {
244
+ const imgs = document.querySelectorAll('img');
245
+ for (const img of imgs) {
246
+ const htmlImg = img;
247
+ if (htmlImg.src && htmlImg.naturalWidth > 0) {
248
+ // 检查是否是封面相关的图片
249
+ const parent = htmlImg.closest('[class*="cover"], [class*="preview"]');
250
+ if (parent)
251
+ return true;
252
+ }
253
+ }
254
+ return false;
255
+ });
256
+ if (coverFound) {
257
+ console.log('[toutiao] 封面上传成功');
258
+ }
259
+ else {
260
+ console.log('[toutiao] 未检测到明确的上传成功标志');
261
+ }
262
+ }
263
+ else {
264
+ console.log('[toutiao] 未能设置封面文件');
265
+ }
266
+ // 点击"确定"按钮
267
+ const confirmBtn = page.locator('button:has-text("确定")').filter({ visible: true });
268
+ try {
269
+ if (await confirmBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
270
+ const disabled = await confirmBtn.first().isDisabled().catch(() => true);
271
+ if (!disabled) {
272
+ await confirmBtn.first().click();
273
+ console.log('[toutiao] 点击确定按钮,封面上传完成');
274
+ }
275
+ }
276
+ }
277
+ catch (e) {
278
+ console.log('[toutiao] 确定按钮点击失败:', e.message);
279
+ }
280
+ // 等待封面上传保存(头条号会自动保存)
281
+ await page.waitForTimeout(5000);
282
+ }
124
283
  }
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
- }
134
- else {
135
- console.log('[toutiao] URL 仍未变化:', currentUrl);
136
284
  }
137
- // 尝试点击"发布"按钮触发保存
138
- console.log('[toutiao] 尝试点击发布按钮...');
285
+ // 关闭预览弹窗(如果还在的话)
139
286
  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);
287
+ const continueBtn = page.locator('button:has-text("继续编辑")');
288
+ if (await continueBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
289
+ await continueBtn.click({ force: true });
290
+ await page.waitForTimeout(2000);
147
291
  }
148
292
  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);
293
+ const closeIcon = page.locator('[class*="close"], .ai-assistant-drawer-wrapper [class*="close"]');
294
+ if (await closeIcon.isVisible({ timeout: 1000 }).catch(() => false)) {
295
+ await closeIcon.click({ force: true });
296
+ await page.waitForTimeout(1000);
297
+ }
298
+ }
299
+ }
300
+ catch (e) {
301
+ // 关闭弹窗失败,继续
302
+ }
303
+ // 等待编辑器稳定
304
+ await page.waitForTimeout(2000);
305
+ // 点击发布按钮
306
+ console.log('[toutiao] 查找发布按钮...');
307
+ try {
308
+ // 尝试多种发布按钮选择器
309
+ const publishSelectors = [
310
+ 'button:has-text("发布")',
311
+ 'button:has-text("预览并发布")',
312
+ ];
313
+ for (const selector of publishSelectors) {
314
+ const btn = page.locator(selector);
315
+ if (await btn.isVisible({ timeout: 2000 }).catch(() => false)) {
316
+ await btn.click({ force: true });
317
+ console.log('[toutiao] 点击了:', selector);
318
+ break;
155
319
  }
156
320
  }
321
+ await page.waitForTimeout(10000);
157
322
  }
158
323
  catch (e) {
159
- console.log('[toutiao] 按钮点击失败:', e.message);
324
+ console.log('[toutiao] 发布按钮点击失败:', e.message);
160
325
  }
161
326
  const finalUrl = page.url();
162
- idMatch = finalUrl.match(/[?&]id=(\d+)/);
327
+ const idMatch = finalUrl.match(/[?&]id=(\d+)/);
163
328
  if (idMatch) {
164
329
  console.log('[toutiao] 保存成功,文章 ID:', idMatch[1]);
165
330
  return {
@@ -15,6 +15,10 @@ export declare class WechatPublisher {
15
15
  * 下载图片并以指定文件名上传到微信 CDN
16
16
  */
17
17
  uploadImageFromUrl(imageUrl: string): Promise<string>;
18
+ /**
19
+ * 上传图片到微信 CDN(图文消息专用接口,返回可直接访问的 URL)
20
+ */
21
+ uploadImageForArticle(filePath: string): Promise<string>;
18
22
  /**
19
23
  * 上传本地图片到微信 CDN(临时素材,用于正文嵌入)
20
24
  */
@@ -35,11 +39,12 @@ export declare class WechatPublisher {
35
39
  uploadPermanentImageFromUrl(imageUrl: string): Promise<string>;
36
40
  /**
37
41
  * 处理 HTML 中的图片:本地图片上传到微信 CDN,外部图片保留原 URL
38
- * 返回处理后的 HTML 和第一张可用的 media_id(用作封面)
42
+ * 返回处理后的 HTML、第一张 media_id(临时)和第一张本地路径(用于封面永久上传)
39
43
  */
40
44
  processImages(html: string): Promise<{
41
45
  html: string;
42
46
  firstMediaId?: string;
47
+ firstLocalPath?: string;
43
48
  }>;
44
49
  /**
45
50
  * 发布到微信公众号草稿箱
@@ -10,7 +10,14 @@ function extFromUrl(url) {
10
10
  return ext || '.jpg';
11
11
  }
12
12
  function nameFromUrl(url) {
13
- return path.basename(new URL(url).pathname) || `image${extFromUrl(url)}`;
13
+ const ext = extFromUrl(url);
14
+ const pathname = new URL(url).pathname;
15
+ const basename = path.basename(pathname);
16
+ // If basename has no extension (or is just numbers like "720"), generate a proper name
17
+ if (!path.extname(basename) || /^\d+$/.test(basename)) {
18
+ return `cover${ext}`;
19
+ }
20
+ return basename;
14
21
  }
15
22
  export class WechatPublisher {
16
23
  runtime;
@@ -65,6 +72,21 @@ export class WechatPublisher {
65
72
  this.uploadCache[imageUrl] = mediaId;
66
73
  return mediaId;
67
74
  }
75
+ /**
76
+ * 上传图片到微信 CDN(图文消息专用接口,返回可直接访问的 URL)
77
+ */
78
+ async uploadImageForArticle(filePath) {
79
+ const token = await this.getAccessToken();
80
+ const name = path.basename(filePath);
81
+ const form = new FormData();
82
+ form.append('media', readFileSync(filePath), { filename: name, contentType: 'image/png' });
83
+ const res = await axios.post(`https://api.weixin.qq.com/cgi-bin/media/uploadimg?access_token=${token}`, form, { headers: form.getHeaders(), maxBodyLength: Infinity, maxContentLength: Infinity });
84
+ const data = res.data;
85
+ if (!data.url) {
86
+ throw new Error(`图片上传失败: ${data.errmsg || JSON.stringify(data)}`);
87
+ }
88
+ return data.url;
89
+ }
68
90
  /**
69
91
  * 上传本地图片到微信 CDN(临时素材,用于正文嵌入)
70
92
  */
@@ -142,11 +164,12 @@ export class WechatPublisher {
142
164
  }
143
165
  /**
144
166
  * 处理 HTML 中的图片:本地图片上传到微信 CDN,外部图片保留原 URL
145
- * 返回处理后的 HTML 和第一张可用的 media_id(用作封面)
167
+ * 返回处理后的 HTML、第一张 media_id(临时)和第一张本地路径(用于封面永久上传)
146
168
  */
147
169
  async processImages(html) {
148
170
  const imgPattern = /<img\s+[^>]*src=["']([^"']+)["'][^>]*>/gi;
149
171
  let firstMediaId;
172
+ let firstLocalPath;
150
173
  let replaced = html;
151
174
  for (const match of [...html.matchAll(imgPattern)]) {
152
175
  const original = match[0];
@@ -160,24 +183,35 @@ export class WechatPublisher {
160
183
  continue;
161
184
  }
162
185
  try {
163
- const mediaId = await this.uploadImageFromUrl(src);
164
- if (!firstMediaId)
165
- firstMediaId = mediaId;
166
- const cdnUrl = `https://mmbiz.qpic.cn/mmbiz_png/${mediaId}/0`;
167
- replaced = replaced.replace(original, original.replace(src, cdnUrl));
186
+ const isLocalPath = !src.startsWith('http://') && !src.startsWith('https://');
187
+ if (isLocalPath) {
188
+ // 本地文件用 uploadimg 接口,返回直接可访问的 URL
189
+ const url = await this.uploadImageForArticle(src);
190
+ if (!firstLocalPath)
191
+ firstLocalPath = src;
192
+ replaced = replaced.replace(original, original.replace(src, url));
193
+ }
194
+ else {
195
+ // 外部 URL 下载后上传
196
+ const mediaId = await this.uploadImageFromUrl(src);
197
+ if (!firstMediaId)
198
+ firstMediaId = mediaId;
199
+ const cdnUrl = `https://mmbiz.qpic.cn/mmbiz_png/${mediaId}/0`;
200
+ replaced = replaced.replace(original, original.replace(src, cdnUrl));
201
+ }
168
202
  }
169
203
  catch (err) {
170
204
  console.warn(`[WechatPublisher] 上传图片失败 ${src}: ${err.message},保留原 URL`);
171
205
  }
172
206
  }
173
- return { html: replaced, firstMediaId };
207
+ return { html: replaced, firstMediaId, firstLocalPath };
174
208
  }
175
209
  /**
176
210
  * 发布到微信公众号草稿箱
177
211
  */
178
212
  async publishToDraft(options) {
179
213
  const token = await this.getAccessToken();
180
- const { html: processedHtml, firstMediaId } = await this.processImages(options.content);
214
+ const { html: processedHtml, firstMediaId, firstLocalPath } = await this.processImages(options.content);
181
215
  // 上传封面图:优先用 front-matter 指定的封面图,其次用正文第一张图片的 media_id
182
216
  let thumbMediaId;
183
217
  try {
@@ -185,10 +219,15 @@ export class WechatPublisher {
185
219
  }
186
220
  catch (err) {
187
221
  console.warn(`[WechatPublisher] 封面上传失败: ${err.message}`);
188
- // 用 processImages 返回的第一张图片 media_id 作为封面
189
- if (firstMediaId) {
222
+ // 用 processImages 返回的第一张图片(本地路径需要用永久素材接口)
223
+ if (firstLocalPath) {
224
+ thumbMediaId = await this.uploadPermanentImageFromPath(firstLocalPath);
225
+ console.log('[WechatPublisher] 使用正文第一张图片作为封面(永久素材)');
226
+ }
227
+ else if (firstMediaId) {
228
+ // 外部 URL 图片用临时素材
190
229
  thumbMediaId = firstMediaId;
191
- console.log('[WechatPublisher] 使用正文第一张图片作为封面');
230
+ console.log('[WechatPublisher] 使用正文第一张图片作为封面(临时素材)');
192
231
  }
193
232
  else {
194
233
  // fallback:直接从 processedHtml 中查找 img 标签
@@ -197,12 +236,14 @@ export class WechatPublisher {
197
236
  const src = m[1];
198
237
  if (src.includes('mmbiz.qpic.cn') || src.includes('mmbiz.qlogo.cn'))
199
238
  continue;
200
- try {
201
- thumbMediaId = await this.uploadImageFromUrl(src);
202
- console.log(`[WechatPublisher] 用正文图片作封面: ${src}`);
203
- break;
239
+ if (src.startsWith('http')) {
240
+ try {
241
+ thumbMediaId = await this.uploadPermanentImageFromUrl(src);
242
+ console.log(`[WechatPublisher] 用正文图片作封面: ${src}`);
243
+ break;
244
+ }
245
+ catch { /* continue */ }
204
246
  }
205
- catch { /* continue */ }
206
247
  }
207
248
  }
208
249
  }
@@ -8,7 +8,12 @@ export declare class WeixinAdapter implements IPlatformAdapter {
8
8
  readonly meta: PlatformMeta;
9
9
  private runtime;
10
10
  private publisher;
11
+ private getPublisher;
11
12
  init(runtime: RuntimeInterface): Promise<void>;
12
13
  checkAuth(): Promise<AuthResult>;
14
+ processMermaid(html: string): Promise<{
15
+ html: string;
16
+ tempFiles: string[];
17
+ }>;
13
18
  publish(article: Article): Promise<SyncResult>;
14
19
  }
@@ -1,4 +1,6 @@
1
1
  import { WechatPublisher } from './wechat-publisher.js';
2
+ import os from 'os';
3
+ import fs from 'node:fs/promises';
2
4
  export class WeixinAdapter {
3
5
  meta = {
4
6
  id: 'weixin',
@@ -9,13 +11,18 @@ export class WeixinAdapter {
9
11
  };
10
12
  runtime;
11
13
  publisher;
14
+ getPublisher() {
15
+ if (!this.publisher) {
16
+ this.publisher = new WechatPublisher(this.runtime);
17
+ }
18
+ return this.publisher;
19
+ }
12
20
  async init(runtime) {
13
21
  this.runtime = runtime;
14
- this.publisher = new WechatPublisher(runtime);
15
22
  }
16
23
  async checkAuth() {
17
24
  try {
18
- const token = await this.publisher.getAccessToken();
25
+ const token = this.getPublisher().getAccessToken();
19
26
  return {
20
27
  isAuthenticated: !!token,
21
28
  userId: 'authenticated',
@@ -26,20 +33,49 @@ export class WeixinAdapter {
26
33
  return { isAuthenticated: false, error: msg };
27
34
  }
28
35
  }
36
+ async processMermaid(html) {
37
+ const { processMermaid: convert } = await import('../core/renderer.js');
38
+ const { html: processed, tempFiles } = await convert(html, os.tmpdir());
39
+ // 上传图片到微信 CDN 并替换 URL
40
+ const imgPattern = /<img\s+[^>]*src=["']([^"']+)["'][^>]*>/gi;
41
+ let result = processed;
42
+ for (const match of [...processed.matchAll(imgPattern)]) {
43
+ const original = match[0];
44
+ const src = match[1];
45
+ if (!src.startsWith('http://') && !src.startsWith('https://')) {
46
+ try {
47
+ const url = await this.getPublisher().uploadImageForArticle(src);
48
+ result = result.replace(original, original.replace(src, url));
49
+ }
50
+ catch (err) {
51
+ console.warn(`[weixin] mermaid 图片上传失败 ${src}: ${err.message}`);
52
+ }
53
+ }
54
+ }
55
+ return { html: result, tempFiles };
56
+ }
29
57
  async publish(article) {
30
58
  const start = Date.now();
31
59
  try {
32
60
  if (!article.html) {
33
61
  throw new Error('article.html is required for WeChat publishing');
34
62
  }
35
- const result = await this.publisher.publishToDraft({
63
+ // 处理 Mermaid 代码块(转换为图片并上传 CDN)
64
+ let processedHtml = article.html;
65
+ if (this.processMermaid) {
66
+ const { html: mermaidHtml, tempFiles } = await this.processMermaid(article.html);
67
+ processedHtml = mermaidHtml;
68
+ // 清理临时文件
69
+ await Promise.all(tempFiles.map(f => fs.unlink(f).catch(() => { })));
70
+ }
71
+ const result = await this.getPublisher().publishToDraft({
36
72
  title: article.title,
37
- content: article.html,
73
+ content: processedHtml,
38
74
  cover: article.cover ?? '',
39
75
  author: article.author ?? '',
40
76
  source_url: article.source_url ?? '',
41
77
  });
42
- const token = await this.publisher.getAccessToken();
78
+ const token = await this.getPublisher().getAccessToken();
43
79
  const draftUrl = `https://mp.weixin.qq.com/cgi-bin/appmsg?t=media/appmsg_edit&action=edit&type=77&appmsgid=${result.media_id}&token=${token}&lang=zh_CN`;
44
80
  return {
45
81
  platform: this.meta.id,
@@ -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
@@ -23,6 +24,7 @@ export function createProgram() {
23
24
  .requiredOption('-f, --file <path>', 'Markdown 文件路径(支持本地文件和 URL)')
24
25
  .option('-p, --platform <platform>', '目标平台 (weixin|zhihu|juejin|csdn)', 'weixin')
25
26
  .option('-t, --theme <theme-id>', '主题 ID', 'default')
27
+ .option('-m, --cover-mode <mode>', '封面模式 (sharp|network|auto)', 'auto')
26
28
  .option('--app-id <appId>', '微信公众号 AppID(可省略,从配置文件读取)')
27
29
  .option('--no-mac-style', '禁用 Mac 风格代码块')
28
30
  .option('--auto-cover', '当文章无封面图时,根据标题自动从网络获取封面')
@@ -68,6 +70,12 @@ export function createProgram() {
68
70
  .option('--mac-style', '启用 Mac 风格代码块', true)
69
71
  .option('--no-mac-style', '禁用 Mac 风格代码块')
70
72
  .action(runPublishAll);
73
+ // capture 命令
74
+ const captureCmd = program.command('capture')
75
+ .description('抓包工具 - 打开浏览器并拦截上传请求')
76
+ .option('-p, --platform <platform>', '平台名称 (csdn|juejin|zhihu|toutiao|jianshu|weibo)', 'csdn')
77
+ .option('-t, --timeout <seconds>', '超时时间(秒)', '60')
78
+ .action(runCapture);
71
79
  return program;
72
80
  }
73
81
  const program = createProgram();
package/dist/cli/login.js CHANGED
@@ -95,6 +95,9 @@ async function saveCookies(platformId, cookies) {
95
95
  case 'cto51':
96
96
  await ConfigStore.setCto51Cookies(cookies);
97
97
  break;
98
+ case 'qq':
99
+ await ConfigStore.setQQCookies(cookies);
100
+ break;
98
101
  default:
99
102
  console.warn(`⚠️ 尚未为 ${platformId} 实现 Cookie 保存,请手动保存`);
100
103
  }