multi-publisher 1.1.2 → 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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  **一行命令,Markdown 发布到全网。**
4
4
 
5
- 将排版好的 Markdown 文章一键发布到微信公众号、知乎、掘金、CSDN 等 20+ 平台,无需手动复制粘贴。支持浏览器自动登录获取 Cookie,14 套渲染主题,是内容创作者的效率神器。
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
- | **14 套渲染主题** | defaultwechatmodernminimal、cyberpunk、nord、paper、darkelite、sunset、zen、retro、midnight、brutalism、neumorphism |
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
- # "用 cyberpunk 主题渲染预览一下"
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 cyberpunk
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
- ### 🎨 14 套内置主题
155
+ ### 🎨 4 套内置主题(按平台自动匹配)
156
156
 
157
- 完整预览:[themes/all-themes-preview.html](themes/all-themes-preview.html)
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 | ![Cyberpunk](themes/previews/cyberpunk.png) |
164
- | 程序员 | Nord | ![Nord](themes/previews/nord.png) |
165
- | 微信风格 | Wechat | ![Wechat](themes/previews/wechat.png) |
166
- | 简约 | Minimal | ![Minimal](themes/previews/minimal.png) |
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 cyberpunk
171
- mpub render -f article.md -t nord
172
- mpub render -f article.md -t paper
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
- **全部 14 主题:** default、wechat、modern、minimal、cyberpunk、nord、paper、darkelite、sunset、zen、retro、midnight、brutalism、neumorphism
179
+ **全部 4 主题:** default、wechat、modern、minimal
176
180
 
177
181
  ### 使用主题
178
182
 
179
183
  ```bash
180
- # 渲染时指定主题
181
- mpub render -f article.md -t cyberpunk
182
- mpub render -f article.md -t nord
183
- mpub render -f article.md -t paper
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
 
@@ -1,5 +1,6 @@
1
1
  import { ConfigStore } from '../config.js';
2
2
  import { uploadCoverViaBrowser } from '../tools/browser-upload.js';
3
+ import { downloadCoverUrl } from '../tools/cover-fetcher.js';
3
4
  import crypto from 'node:crypto';
4
5
  import fs from 'node:fs/promises';
5
6
  import path from 'node:path';
@@ -261,15 +262,30 @@ export class CSDNAdapter {
261
262
  }
262
263
  const postId = res.data.id;
263
264
  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}`);
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
+ }
270
279
  }
271
- else {
272
- console.warn(`[CSDN] 封面上传失败: ${coverResult.error}`);
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
+ }
273
289
  }
274
290
  }
275
291
  return {
@@ -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';
@@ -5,7 +5,12 @@
5
5
  import { chromium } from 'playwright';
6
6
  import { ConfigStore } from '../config.js';
7
7
  import { existsSync } from 'node:fs';
8
+ import { promises as fs } from 'node:fs';
8
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';
9
14
  export class ToutiaoAdapter {
10
15
  meta = {
11
16
  id: 'toutiao',
@@ -28,6 +33,27 @@ export class ToutiaoAdapter {
28
33
  }
29
34
  return { isAuthenticated: true };
30
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
+ }
31
57
  async publish(article) {
32
58
  const start = Date.now();
33
59
  if (!this.cookieData) {
@@ -38,6 +64,13 @@ export class ToutiaoAdapter {
38
64
  timestamp: Date.now() - start,
39
65
  };
40
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
+ }
41
74
  const browser = await chromium.launch({
42
75
  headless: false,
43
76
  args: [
@@ -95,7 +128,7 @@ export class ToutiaoAdapter {
95
128
  catch { }
96
129
  // 填写标题
97
130
  const titleTextarea = page.locator('textarea').first();
98
- await titleTextarea.fill(article.title);
131
+ await titleTextarea.fill(processedArticle.title);
99
132
  console.log('[toutiao] 已填写标题');
100
133
  // 填写内容
101
134
  const contentEl = page.locator('div[contenteditable="true"]').first();
@@ -103,7 +136,7 @@ export class ToutiaoAdapter {
103
136
  await contentEl.click();
104
137
  await page.keyboard.press('Control+a');
105
138
  await page.waitForTimeout(200);
106
- const htmlContent = article.html || article.markdown || '';
139
+ const htmlContent = processedArticle.html || processedArticle.markdown || '';
107
140
  await page.evaluate((el) => {
108
141
  const div = document.querySelector('[contenteditable="true"]');
109
142
  if (div) {
@@ -114,58 +147,139 @@ export class ToutiaoAdapter {
114
147
  console.log('[toutiao] 已填写内容');
115
148
  await page.waitForTimeout(1000);
116
149
  }
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);
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},跳过封面上传`);
145
159
  }
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);
160
+ else {
161
+ localCover = downloadResult.localPath;
162
+ console.log(`[toutiao] 封面下载成功: ${localCover}`);
152
163
  }
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] 点击确定按钮,封面上传完成');
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 输入框设置文件');
161
218
  }
162
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);
163
282
  }
164
- catch (e) {
165
- console.log('[toutiao] 确定按钮点击失败:', e.message);
166
- }
167
- // 等待封面上传保存(头条号会自动保存)
168
- await page.waitForTimeout(5000);
169
283
  }
170
284
  }
171
285
  // 关闭预览弹窗(如果还在的话)