multi-publisher 1.0.4 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  **一行命令,Markdown 发布到全网。**
4
4
 
5
- 将排版好的 Markdown 文章一键发布到微信公众号、知乎、掘金、CSDN 等 20+ 平台,无需手动复制粘贴。支持浏览器自动登录获取 Cookie,12 套渲染主题,是内容创作者的效率神器。
5
+ 将排版好的 Markdown 文章一键发布到微信公众号、知乎、掘金、CSDN 等 20+ 平台,无需手动复制粘贴。支持浏览器自动登录获取 Cookie,14 套渲染主题,是内容创作者的效率神器。
6
6
 
7
7
  ---
8
8
 
@@ -13,6 +13,7 @@
13
13
  | **多平台支持** | 微信公众号、知乎、掘金、CSDN、微博、小红书、B站等 20+ 平台 |
14
14
  | **浏览器自动登录** | Playwright 驱动扫码/账号登录,Cookie 自动获取保存 |
15
15
  | **14 套渲染主题** | default、wechat、modern、minimal、cyberpunk、nord、paper、darkelite、sunset、zen、retro、midnight、brutalism、neumorphism |
16
+ | **自动封面图生成** | AI 根据标题生成封面图,自动上传到微信 CDN |
16
17
  | **Markdown 直发** | front-matter 元数据、代码高亮、LaTeX 公式 |
17
18
  | **CI/CD 友好** | 纯命令行无需浏览器,配置文件统一管理 |
18
19
  | **草稿箱发布** | 微信公众号 → 草稿箱,其他平台 → 各自草稿箱 |
@@ -95,6 +96,12 @@ mpub render -f article.md
95
96
 
96
97
  # 指定主题渲染
97
98
  mpub render -f article.md -t cyberpunk
99
+
100
+ # 自动封面图(文章无封面时 AI 生成)
101
+ mpub publish -f article.md -p weixin --auto-cover
102
+
103
+ # 指定封面图
104
+ mpub publish -f article.md -p weixin -c cover.jpg
98
105
  ```
99
106
 
100
107
  ---
@@ -199,14 +206,16 @@ pre { background: #f6f8fa; border-radius: 6px; }
199
206
 
200
207
  ### front-matter 元数据
201
208
 
209
+ > **重要**:front-matter 是必需的!缺少 front-matter 会导致标题显示为"无标题"。
210
+
202
211
  ```markdown
203
212
  ---
204
- title: 文章标题
205
- author: 作者名
206
- cover: https://example.com/cover.jpg # 封面图片 URL
207
- summary: 文章摘要(可选) # 微信作者留言摘要
208
- source_url: https://original.url # 原文链接(可选)
209
- tags: [技术, 前端, JavaScript] # 标签(可选)
213
+ title: 文章标题 # 必填,用于公众号标题
214
+ author: 作者名 # 必填
215
+ description: 文章描述(可选) # 用于公众号摘要
216
+ cover: https://example.com/cover.jpg # 可选,有 --auto-cover 可省略
217
+ source_url: https://original.url # 原文链接(可选)
218
+ tags: [技术, 前端, JavaScript] # 标签(可选)
210
219
  ---
211
220
 
212
221
  正文内容...
package/dist/cli/index.js CHANGED
@@ -25,6 +25,7 @@ export function createProgram() {
25
25
  .option('-t, --theme <theme-id>', '主题 ID', 'default')
26
26
  .option('--app-id <appId>', '微信公众号 AppID(可省略,从配置文件读取)')
27
27
  .option('--no-mac-style', '禁用 Mac 风格代码块')
28
+ .option('--auto-cover', '当文章无封面图时,根据标题自动从网络获取封面')
28
29
  .action(runPublish);
29
30
  // render 命令
30
31
  const renderCmd = program.command('render')
@@ -4,4 +4,5 @@ export declare function runPublish(options: {
4
4
  theme?: string;
5
5
  appId?: string;
6
6
  macStyle?: boolean;
7
+ autoCover?: boolean;
7
8
  }, input?: string): Promise<void>;
@@ -5,6 +5,7 @@ import { readFile } from 'node:fs/promises';
5
5
  import { renderMarkdown } from '../core/renderer.js';
6
6
  import { createNodeRuntime } from '../runtime/node-runtime.js';
7
7
  import { initAdapterRegistry, getAdapter } from '../adapters/index.js';
8
+ import { cleanupCoverFile } from '../tools/cover-fetcher.js';
8
9
  export async function runPublish(options, input) {
9
10
  try {
10
11
  // 1. 读取内容
@@ -30,6 +31,7 @@ export async function runPublish(options, input) {
30
31
  const result = await renderMarkdown(content, {
31
32
  theme: options.theme || 'default',
32
33
  macStyle: options.macStyle !== false,
34
+ autoCover: options.autoCover || false,
33
35
  });
34
36
  // 3. 选择适配器
35
37
  const platformId = options.platform || 'weixin';
@@ -48,14 +50,20 @@ export async function runPublish(options, input) {
48
50
  if (options.appId) {
49
51
  process.env.WECHAT_APP_ID = options.appId;
50
52
  }
53
+ // 使用自动获取的封面图(如果文章没有指定封面)
54
+ const coverImage = result.cover || result.autoCoverPath;
51
55
  const syncResult = await adapter.publish({
52
56
  title: result.title,
53
57
  markdown: content,
54
58
  html: result.html,
55
59
  author: result.author,
56
- cover: result.cover,
60
+ cover: coverImage,
57
61
  source_url: result.source_url,
58
62
  });
63
+ // 清理自动获取的临时封面图
64
+ if (result.autoCoverPath) {
65
+ await cleanupCoverFile(result.autoCoverPath).catch(() => { });
66
+ }
59
67
  // 4. 输出结果
60
68
  if (syncResult.success) {
61
69
  console.log(`✅ 发布成功!`);
@@ -4,6 +4,8 @@ export interface RenderOptions {
4
4
  highlight?: string;
5
5
  macStyle?: boolean;
6
6
  footnote?: boolean;
7
+ /** 是否在无封面时自动根据标题获取封面图 */
8
+ autoCover?: boolean;
7
9
  }
8
10
  export interface RenderResult {
9
11
  title: string;
@@ -11,6 +13,8 @@ export interface RenderResult {
11
13
  author?: string;
12
14
  cover?: string;
13
15
  source_url?: string;
16
+ /** 自动获取的封面图本地路径(需在使用后清理) */
17
+ autoCoverPath?: string;
14
18
  }
15
19
  /**
16
20
  * 渲染 Markdown 文件为平台 HTML
@@ -5,11 +5,12 @@ import { parseMarkdown } from './parser.js';
5
5
  import { processMath } from './mathjax.js';
6
6
  import { inlineStyles } from './styler.js';
7
7
  import { loadThemeCss, DEFAULT_CSS } from './theme.js';
8
+ import { generateCover } from '../tools/cover-generator.js';
8
9
  /**
9
10
  * 渲染 Markdown 文件为平台 HTML
10
11
  */
11
12
  export async function renderMarkdown(content, options = {}) {
12
- const { theme = 'default', customCss, macStyle = true, } = options;
13
+ const { theme = 'default', customCss, macStyle = true, autoCover = false, } = options;
13
14
  // 1. 解析 front-matter + Markdown → HTML
14
15
  const parsed = parseMarkdown(content);
15
16
  // 2. 处理 LaTeX 公式($...$ 和 $$...$$)
@@ -20,11 +21,25 @@ export async function renderMarkdown(content, options = {}) {
20
21
  html = inlineStyles(html, themeCss);
21
22
  // 5. 包裹 section 标签(微信公众号要求)
22
23
  html = `<section style="margin-left:6px;margin-right:6px;line-height:1.75em">${html}</section>`;
24
+ let autoCoverPath;
25
+ // 6. 自动生成封面图(当未指定封面图时)
26
+ if (autoCover && !parsed.meta.cover && parsed.meta.title) {
27
+ console.log(`[renderer] 未指定封面图,正在根据标题生成封面...`);
28
+ const result = await generateCover(parsed.meta.title, parsed.meta.description || '');
29
+ if (result.success && result.localPath) {
30
+ autoCoverPath = result.localPath;
31
+ console.log(`[renderer] 自动封面图生成成功: ${result.localPath}`);
32
+ }
33
+ else {
34
+ console.warn(`[renderer] 自动封面图生成失败: ${result.error}`);
35
+ }
36
+ }
23
37
  return {
24
38
  title: parsed.meta.title,
25
39
  html,
26
40
  author: parsed.meta.author,
27
41
  cover: parsed.meta.cover,
28
42
  source_url: parsed.meta.source_url,
43
+ autoCoverPath,
29
44
  };
30
45
  }
@@ -4,6 +4,6 @@ export interface ThemeInfo {
4
4
  description?: string;
5
5
  isBuiltin: boolean;
6
6
  }
7
- export declare const DEFAULT_CSS = "\np {\n color: rgb(51, 51, 51);\n font-size: 15px;\n line-height: 1.75em;\n margin: 0 0 1em 0;\n word-wrap: break-word;\n}\nh1, h2, h3, h4, h5, h6 {\n font-weight: bold;\n margin: 1em 0 0.5em 0;\n}\nh1 { font-size: 1.25em; line-height: 1.4em; }\nh2 { font-size: 1.125em; }\nh3 { font-size: 1.05em; }\nh4, h5, h6 { font-size: 1em; }\nli p { margin: 0; }\nul, ol { margin: 1em 0; padding-left: 2em; }\nli { margin-bottom: 0.4em; }\npre {\n background-color: #f6f8fa;\n border-radius: 6px;\n padding: 16px;\n overflow-x: auto;\n font-size: 14px;\n line-height: 1.6;\n margin: 1em 0;\n}\ncode {\n background-color: rgba(175, 184, 193, 0.2);\n border-radius: 3px;\n padding: 0.2em 0.4em;\n font-size: 0.9em;\n font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;\n}\npre code { background: none; padding: 0; font-size: 14px; }\nblockquote { border-left: 4px solid #ddd; padding-left: 1em; margin: 1em 0; color: #666; }\nhr { border: none; border-top: 1px solid #ddd; margin: 1.5em 0; }\ni, cite, em, var, address { font-style: italic; }\nb, strong { font-weight: bolder; }\nimg { max-width: 100%; height: auto; display: block; margin: 1em auto; }\ntable { border-collapse: collapse; width: 100%; margin: 1em 0; }\ntable th, table td { border: 1px solid #ddd; padding: 8px 12px; }\ntable th { background-color: #f6f8fa; font-weight: bold; }\na { color: #0579b7; text-decoration: none; }\n";
7
+ export declare const DEFAULT_CSS = "\np {\n color: rgb(51, 51, 51);\n font-size: 15px;\n line-height: 1.75em;\n margin: 0 0 1em 0;\n word-wrap: break-word;\n}\nh1, h2, h3, h4, h5, h6 {\n font-weight: bold;\n margin: 1em 0 0.5em 0;\n}\nh1 { font-size: 1.25em; line-height: 1.4em; }\nh2 { font-size: 1.125em; }\nh3 { font-size: 1.05em; }\nh4, h5, h6 { font-size: 1em; }\nli p { margin: 0; }\nul, ol { margin: 0; padding-left: 2em; }\nli { margin: 0; padding: 0; line-height: normal; }\nli + li { margin-top: 0.3em; }\npre {\n background-color: #f6f8fa;\n border-radius: 6px;\n padding: 16px;\n overflow-x: auto;\n font-size: 14px;\n line-height: 1.6;\n margin: 1em 0;\n}\ncode {\n background-color: rgba(175, 184, 193, 0.2);\n border-radius: 3px;\n padding: 0.2em 0.4em;\n font-size: 0.9em;\n font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;\n}\npre code { background: none; padding: 0; font-size: 14px; }\nblockquote { border-left: 4px solid #ddd; padding-left: 1em; margin: 1em 0; color: #666; }\nhr { border: none; border-top: 1px solid #ddd; margin: 1.5em 0; }\ni, cite, em, var, address { font-style: italic; }\nb, strong { font-weight: bolder; }\nimg { max-width: 100%; height: auto; display: block; margin: 1em auto; }\ntable { border-collapse: collapse; width: 100%; margin: 1em 0; }\ntable th, table td { border: 1px solid #ddd; padding: 8px 12px; }\ntable th { background-color: #f6f8fa; font-weight: bold; }\na { color: #0579b7; text-decoration: none; }\n";
8
8
  export declare function listThemes(): Promise<ThemeInfo[]>;
9
9
  export declare function loadThemeCss(themeId: string): Promise<string | null>;
@@ -25,8 +25,9 @@ h2 { font-size: 1.125em; }
25
25
  h3 { font-size: 1.05em; }
26
26
  h4, h5, h6 { font-size: 1em; }
27
27
  li p { margin: 0; }
28
- ul, ol { margin: 1em 0; padding-left: 2em; }
29
- li { margin-bottom: 0.4em; }
28
+ ul, ol { margin: 0; padding-left: 2em; }
29
+ li { margin: 0; padding: 0; line-height: normal; }
30
+ li + li { margin-top: 0.3em; }
30
31
  pre {
31
32
  background-color: #f6f8fa;
32
33
  border-radius: 6px;
@@ -64,11 +65,52 @@ const BUILTIN_THEMES = {
64
65
  wechat: {
65
66
  name: 'Wechat',
66
67
  description: '微信风格,仿微信官方文章样式',
67
- css: DEFAULT_CSS + `
68
- h1 { font-size: 1.35em; border-bottom: 1px solid #e8e8e8; padding-bottom: 0.3em; }
69
- blockquote { border-left: 3px solid #c8a96e; background: #faf9f7; }
70
- img { border-radius: 4px; } table th { background-color: #f8f8f8; }
71
- a { color: #576b95; border-bottom: 1px solid rgba(87, 107, 149, 0.3); }
68
+ css: `
69
+ p {
70
+ color: rgb(51, 51, 51);
71
+ font-size: 15px;
72
+ line-height: 1.75em;
73
+ margin: 0 0 1em 0;
74
+ word-wrap: break-word;
75
+ }
76
+ h1, h2, h3, h4, h5, h6 {
77
+ font-weight: bold;
78
+ margin: 1em 0 0.5em 0;
79
+ }
80
+ h1 { font-size: 1.35em; line-height: 1.4em; border-bottom: 1px solid #e8e8e8; padding-bottom: 0.3em; }
81
+ h2 { font-size: 1.125em; }
82
+ h3 { font-size: 1.05em; }
83
+ h4, h5, h6 { font-size: 1em; }
84
+ li p { margin: 0; }
85
+ ul, ol { margin: 0; padding-left: 2em; }
86
+ li { margin: 0; padding: 0; line-height: normal; }
87
+ li + li { margin-top: 0.3em; }
88
+ pre {
89
+ background-color: #f6f8fa;
90
+ border-radius: 6px;
91
+ padding: 16px;
92
+ overflow-x: auto;
93
+ font-size: 14px;
94
+ line-height: 1.6;
95
+ margin: 1em 0;
96
+ }
97
+ code {
98
+ background-color: rgba(175, 184, 193, 0.2);
99
+ border-radius: 3px;
100
+ padding: 0.2em 0.4em;
101
+ font-size: 0.9em;
102
+ font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
103
+ }
104
+ pre code { background: none; padding: 0; font-size: 14px; }
105
+ blockquote { border-left: 3px solid #c8a96e; background: #faf9f7; padding-left: 1em; margin: 1em 0; color: #666; }
106
+ hr { border: none; border-top: 1px solid #ddd; margin: 1.5em 0; }
107
+ i, cite, em, var, address { font-style: italic; }
108
+ b, strong { font-weight: bolder; }
109
+ img { max-width: 100%; height: auto; display: block; margin: 1em auto; border-radius: 4px; }
110
+ table { border-collapse: collapse; width: 100%; margin: 1em 0; }
111
+ table th, table td { border: 1px solid #ddd; padding: 8px 12px; }
112
+ table th { background-color: #f8f8f8; font-weight: bold; }
113
+ a { color: #576b95; border-bottom: 1px solid rgba(87, 107, 149, 0.3); text-decoration: none; }
72
114
  `,
73
115
  },
74
116
  modern: {
@@ -0,0 +1,21 @@
1
+ export interface CoverFetchResult {
2
+ success: boolean;
3
+ localPath?: string;
4
+ imageUrl?: string;
5
+ error?: string;
6
+ }
7
+ /**
8
+ * 根据标题获取封面图片
9
+ * @param title 文章标题
10
+ * @param options 配置选项
11
+ * @returns 封面图片本地路径
12
+ */
13
+ export declare function fetchCoverByTitle(title: string, options?: {
14
+ headless?: boolean;
15
+ timeout?: number;
16
+ outputDir?: string;
17
+ }): Promise<CoverFetchResult>;
18
+ /**
19
+ * 清理临时封面文件
20
+ */
21
+ export declare function cleanupCoverFile(localPath: string | undefined): Promise<void>;
@@ -0,0 +1,353 @@
1
+ /**
2
+ * 根据文章标题自动获取封面图片
3
+ * 使用 Bing 图片搜索
4
+ */
5
+ import { chromium } from 'playwright';
6
+ import { tmpdir } from 'node:os';
7
+ import fs from 'node:fs/promises';
8
+ import path from 'node:path';
9
+ import crypto from 'node:crypto';
10
+ /**
11
+ * 生成随机文件名
12
+ */
13
+ function generateFilename(ext = '.jpg') {
14
+ const random = crypto.randomBytes(8).toString('hex');
15
+ return `cover-${Date.now()}-${random}${ext}`;
16
+ }
17
+ /**
18
+ * 获取文件扩展名
19
+ */
20
+ function getExtension(url) {
21
+ try {
22
+ const pathname = new URL(url).pathname;
23
+ const ext = path.extname(pathname).toLowerCase();
24
+ if (ext && ['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext)) {
25
+ return ext;
26
+ }
27
+ }
28
+ catch { }
29
+ return '.jpg';
30
+ }
31
+ /**
32
+ * 根据 Content-Type 获取文件扩展名
33
+ */
34
+ function getExtensionFromContentType(contentType) {
35
+ const map = {
36
+ 'image/jpeg': '.jpg',
37
+ 'image/jpg': '.jpg',
38
+ 'image/png': '.png',
39
+ 'image/gif': '.gif',
40
+ 'image/webp': '.webp',
41
+ };
42
+ return map[contentType.toLowerCase()] || null;
43
+ }
44
+ /**
45
+ * 将 WebP 图片转换为 PNG
46
+ */
47
+ async function convertWebpToPng(webpPath, outputDir) {
48
+ const sharp = await import('sharp');
49
+ const filename = generateFilename('.png');
50
+ const pngPath = path.join(outputDir, filename);
51
+ await sharp.default(webpPath)
52
+ .png()
53
+ .toFile(pngPath);
54
+ // 删除 WebP 源文件
55
+ await fs.unlink(webpPath).catch(() => { });
56
+ console.log(`[cover-fetcher] WebP 已转换为 PNG: ${filename}`);
57
+ return pngPath;
58
+ }
59
+ /**
60
+ * 根据文件魔术字节检测图片真实类型
61
+ */
62
+ function detectImageType(buffer) {
63
+ // PNG: 89 50 4E 47
64
+ if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4E && buffer[3] === 0x47) {
65
+ return '.png';
66
+ }
67
+ // JPEG: FF D8 FF
68
+ if (buffer[0] === 0xFF && buffer[1] === 0xD8 && buffer[2] === 0xFF) {
69
+ return '.jpg';
70
+ }
71
+ // GIF: 47 49 46 38
72
+ if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x38) {
73
+ return '.gif';
74
+ }
75
+ // WebP: 52 49 46 46 ... 57 45 42 50 (RIFF....WEBP)
76
+ if (buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46 &&
77
+ buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50) {
78
+ return '.webp';
79
+ }
80
+ return null;
81
+ }
82
+ /**
83
+ * 根据标题获取封面图片
84
+ * @param title 文章标题
85
+ * @param options 配置选项
86
+ * @returns 封面图片本地路径
87
+ */
88
+ export async function fetchCoverByTitle(title, options = {}) {
89
+ const { headless = true, timeout = 15000, outputDir = tmpdir(), } = options;
90
+ let browser = null;
91
+ try {
92
+ console.log(`[cover-fetcher] 开始搜索封面图: "${title}"`);
93
+ browser = await chromium.launch({ headless });
94
+ const context = await browser.newContext({
95
+ viewport: { width: 1280, height: 900 },
96
+ userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
97
+ });
98
+ const page = await context.newPage();
99
+ // 绕过反爬检测
100
+ await page.addInitScript(() => {
101
+ Object.defineProperty(navigator, 'webdriver', { get: () => false });
102
+ });
103
+ // 访问 Bing 图片搜索
104
+ const searchUrl = `https://cn.bing.com/images/search?q=${encodeURIComponent(title)}&first=1&rdr=1`;
105
+ console.log(`[cover-fetcher] 访问: ${searchUrl}`);
106
+ await page.goto(searchUrl, {
107
+ waitUntil: 'domcontentloaded',
108
+ timeout: 30000
109
+ });
110
+ // 等待图片加载
111
+ try {
112
+ await page.waitForSelector('.img_container img, .iusc img, a[rel="nofollow"] img', {
113
+ timeout: timeout / 2
114
+ });
115
+ }
116
+ catch {
117
+ // 尝试备用选择器
118
+ try {
119
+ await page.waitForSelector('.dgControl', { timeout: 3000 });
120
+ }
121
+ catch {
122
+ // 继续尝试其他方式
123
+ }
124
+ }
125
+ // 提取第一张图片 URL(尝试多种方式)
126
+ let imageUrl = null;
127
+ // 方式1:查找 iusc 中的大图 URL (murl 是中等尺寸,turl 是缩略图)
128
+ const iuscElement = await page.$('.iusc');
129
+ if (iuscElement) {
130
+ const m = await iuscElement.getAttribute('m');
131
+ if (m) {
132
+ try {
133
+ const mData = JSON.parse(m);
134
+ // 优先使用 murl(中等尺寸),避免使用带参数的缩略图 URL
135
+ imageUrl = mData.murl || mData.EmbedUrl || mData.turl;
136
+ console.log(`[cover-fetcher] 从 iusc 获取到图片 URL: ${imageUrl?.substring(0, 60)}...`);
137
+ }
138
+ catch { }
139
+ }
140
+ }
141
+ // 方式2:如果没找到,尝试从 .mimg 获取
142
+ if (!imageUrl) {
143
+ const mimgElement = await page.$('.mimg');
144
+ if (mimgElement) {
145
+ // 尝试获取 data-src(通常是较大图片)
146
+ imageUrl = await mimgElement.getAttribute('data-src');
147
+ if (!imageUrl) {
148
+ imageUrl = await mimgElement.getAttribute('src');
149
+ }
150
+ console.log(`[cover-fetcher] 从 mimg 获取到图片 URL`);
151
+ }
152
+ }
153
+ // 方式3:直接找 img 标签,排除 bing 缩略图
154
+ if (!imageUrl) {
155
+ const images = await page.$$('img');
156
+ for (const img of images) {
157
+ const src = await img.getAttribute('src');
158
+ const dataSrc = await img.getAttribute('data-src');
159
+ const url = src || dataSrc;
160
+ // 排除 bing 缩略图和太短的 URL
161
+ if (url && url.startsWith('http') && !url.includes('bing.com/th?id=') && url.length > 50) {
162
+ imageUrl = url;
163
+ console.log(`[cover-fetcher] 从 img 标签获取到图片 URL`);
164
+ break;
165
+ }
166
+ }
167
+ }
168
+ // 方式4:查找 .dam_u 容器中的链接
169
+ if (!imageUrl) {
170
+ const thumbLinks = await page.$$('.dam_u');
171
+ for (const link of thumbLinks) {
172
+ const href = await link.getAttribute('href');
173
+ if (href && href.includes('imgurl=')) {
174
+ const match = href.match(/imgurl=([^&]+)/);
175
+ if (match) {
176
+ imageUrl = decodeURIComponent(match[1]);
177
+ break;
178
+ }
179
+ }
180
+ }
181
+ }
182
+ // 方式5:备用 - 使用 Picsum 随机图片(基于标题生成种子保证一致性)
183
+ if (!imageUrl) {
184
+ console.log(`[cover-fetcher] Bing 未找到图片,使用 Picsum 备用方案...`);
185
+ // 使用标题的 hash 作为种子,保证相同标题得到相同图片
186
+ const titleHash = title.split('').reduce((a, b) => {
187
+ a = ((a << 5) - a) + b.charCodeAt(0);
188
+ return a & a;
189
+ }, 0);
190
+ const seed = Math.abs(titleHash) % 1000000;
191
+ imageUrl = `https://picsum.photos/seed/${seed}/800/450`;
192
+ console.log(`[cover-fetcher] 使用 Picsum: ${imageUrl}`);
193
+ }
194
+ if (!imageUrl) {
195
+ await browser.close();
196
+ return {
197
+ success: false,
198
+ error: '未找到合适的图片'
199
+ };
200
+ }
201
+ console.log(`[cover-fetcher] 找到图片: ${imageUrl.substring(0, 80)}...`);
202
+ // 下载图片
203
+ let buffer = null;
204
+ let actualContentType = '';
205
+ // 方法1:使用 page.request.get
206
+ try {
207
+ const imageResponse = await page.request.get(imageUrl, {
208
+ headers: {
209
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
210
+ 'Referer': 'https://cn.bing.com/',
211
+ 'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*',
212
+ 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
213
+ },
214
+ timeout: timeout,
215
+ });
216
+ if (imageResponse.ok()) {
217
+ buffer = await imageResponse.body();
218
+ actualContentType = imageResponse.headers()['content-type'] || '';
219
+ }
220
+ }
221
+ catch (e) {
222
+ console.log(`[cover-fetcher] 方法1失败: ${e.message}`);
223
+ }
224
+ // 方法2:如果 Picsum,直接用浏览器加载
225
+ if (!buffer || buffer.length < 1000) {
226
+ if (imageUrl.includes('picsum.photos')) {
227
+ try {
228
+ console.log(`[cover-fetcher] 使用浏览器加载 Picsum 图片...`);
229
+ const imgPage = await context.newPage();
230
+ await imgPage.goto(imageUrl, { waitUntil: 'networkidle', timeout: 15000 });
231
+ // Picsum 会自动重定向到真实图片
232
+ const finalUrl = imgPage.url();
233
+ console.log(`[cover-fetcher] Picsum 重定向到: ${finalUrl}`);
234
+ // 从 Picsum 获取真实的图片 URL
235
+ if (finalUrl.includes('picsum.photos')) {
236
+ // 使用 finalUrl 作为新 URL 重新下载
237
+ const imgResponse = await imgPage.request.get(finalUrl, {
238
+ timeout: timeout,
239
+ });
240
+ if (imgResponse.ok()) {
241
+ buffer = await imgResponse.body();
242
+ actualContentType = imgResponse.headers()['content-type'] || '';
243
+ }
244
+ }
245
+ await imgPage.close();
246
+ }
247
+ catch (e) {
248
+ console.log(`[cover-fetcher] Picsum 加载失败: ${e.message}`);
249
+ }
250
+ }
251
+ }
252
+ // 方法3:直接从浏览器截图方式获取图片尺寸
253
+ if (!buffer || buffer.length < 1000) {
254
+ try {
255
+ console.log(`[cover-fetcher] 尝试从页面提取图片信息...`);
256
+ const imgPage = await context.newPage();
257
+ await imgPage.goto(imageUrl, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => { });
258
+ // 检查是否加载成功
259
+ const loaded = await imgPage.evaluate(() => {
260
+ return document.body && document.body.children.length > 0;
261
+ }).catch(() => false);
262
+ if (loaded) {
263
+ console.log(`[cover-fetcher] 页面加载成功`);
264
+ }
265
+ await imgPage.close();
266
+ }
267
+ catch (e) {
268
+ console.log(`[cover-fetcher] 方法3失败: ${e.message}`);
269
+ }
270
+ }
271
+ // 如果以上方法都失败,使用 Picsum 作为后备方案
272
+ if (!buffer || buffer.length < 1000) {
273
+ console.log(`[cover-fetcher] 使用 Picsum 后备方案...`);
274
+ const titleHash = title.split('').reduce((a, b) => {
275
+ a = ((a << 5) - a) + b.charCodeAt(0);
276
+ return a & a;
277
+ }, 0);
278
+ const seed = Math.abs(titleHash) % 1000000;
279
+ const picsumUrl = `https://picsum.photos/seed/${seed}/800/450`;
280
+ try {
281
+ const response = await page.request.get(picsumUrl, {
282
+ headers: {
283
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
284
+ },
285
+ timeout: timeout,
286
+ });
287
+ if (response.ok()) {
288
+ buffer = await response.body();
289
+ actualContentType = response.headers()['content-type'] || '';
290
+ imageUrl = picsumUrl;
291
+ }
292
+ }
293
+ catch (e) {
294
+ console.log(`[cover-fetcher] Picsum 后备也失败: ${e.message}`);
295
+ }
296
+ }
297
+ if (!buffer || buffer.length < 1000) {
298
+ await browser.close();
299
+ return {
300
+ success: false,
301
+ error: '无法下载图片'
302
+ };
303
+ }
304
+ // 从魔术字节检测真实图片类型
305
+ const detectedExt = detectImageType(buffer);
306
+ const fromHeader = getExtensionFromContentType(actualContentType);
307
+ const ext = detectedExt || fromHeader || getExtension(imageUrl);
308
+ const filename = generateFilename(ext);
309
+ let localPath = path.join(outputDir, filename);
310
+ await fs.writeFile(localPath, buffer);
311
+ // 如果是 WebP 格式,转换为 PNG(微信不支持 WebP)
312
+ if (ext === '.webp') {
313
+ try {
314
+ localPath = await convertWebpToPng(localPath, outputDir);
315
+ }
316
+ catch (e) {
317
+ console.warn(`[cover-fetcher] WebP 转换失败: ${e.message},保留原文件`);
318
+ }
319
+ }
320
+ const typeInfo = detectedExt ? `magic:${detectedExt}` : (fromHeader ? `header:${fromHeader}` : `url:${ext}`);
321
+ console.log(`[cover-fetcher] 下载成功: ${path.basename(localPath)} (${typeInfo}), 大小: ${(buffer.length / 1024).toFixed(1)} KB`);
322
+ await browser.close();
323
+ return {
324
+ success: true,
325
+ localPath,
326
+ imageUrl
327
+ };
328
+ }
329
+ catch (error) {
330
+ console.error(`[cover-fetcher] 获取封面图失败:`, error);
331
+ return {
332
+ success: false,
333
+ error: error.message
334
+ };
335
+ }
336
+ finally {
337
+ if (browser) {
338
+ await browser.close().catch(() => { });
339
+ }
340
+ }
341
+ }
342
+ /**
343
+ * 清理临时封面文件
344
+ */
345
+ export async function cleanupCoverFile(localPath) {
346
+ if (!localPath)
347
+ return;
348
+ try {
349
+ await fs.unlink(localPath);
350
+ console.log(`[cover-fetcher] 已清理临时文件: ${localPath}`);
351
+ }
352
+ catch { }
353
+ }
@@ -0,0 +1,13 @@
1
+ export interface CoverResult {
2
+ success: boolean;
3
+ localPath?: string;
4
+ error?: string;
5
+ }
6
+ /**
7
+ * 生成封面图
8
+ * @param title 标题
9
+ * @param subtitle 副标题
10
+ * @param outputDir 输出目录(默认系统临时目录)
11
+ * @returns 封面图片本地路径
12
+ */
13
+ export declare function generateCover(title: string, subtitle?: string, outputDir?: string): Promise<CoverResult>;
@@ -0,0 +1,235 @@
1
+ /**
2
+ * 封面图生成器 - SVG 方式生成精美封面
3
+ */
4
+ import sharp from 'sharp';
5
+ import path from 'node:path';
6
+ import { tmpdir } from 'node:os';
7
+ import crypto from 'node:crypto';
8
+ // 预设的颜色主题
9
+ const THEMES = [
10
+ { name: '科技蓝紫', primary: '#667eea', secondary: '#764ba2', bg: '#1a1a2e' },
11
+ { name: '火焰橙红', primary: '#f093fb', secondary: '#f5576c', bg: '#1a0a0a' },
12
+ { name: '森林青绿', primary: '#4fd1c5', secondary: '#38b2ac', bg: '#0a1a1a' },
13
+ { name: '金色典雅', primary: '#f6d365', secondary: '#fda085', bg: '#1a1408' },
14
+ { name: '极光青蓝', primary: '#00d4ff', secondary: '#7c3aed', bg: '#0a0a1a' },
15
+ { name: '玫瑰粉紫', primary: '#ff6b6b', secondary: '#c471ed', bg: '#1a0a14' },
16
+ { name: '薄荷绿', primary: '#00f5a0', secondary: '#00d9f5', bg: '#0a1a14' },
17
+ { name: '落日橙', primary: '#ff7e5f', secondary: '#feb47b', bg: '#1a0f05' },
18
+ ];
19
+ // 装饰风格
20
+ const DECOR_STYLES = ['circles', 'rings', 'dots', 'lines', 'grid', 'hexagon'];
21
+ /**
22
+ * 根据种子生成确定性随机数
23
+ */
24
+ function seededRandom(seed, index) {
25
+ const hash = crypto.createHash('md5').update(seed + index).digest('hex');
26
+ return parseInt(hash.substring(0, 8), 16) / 0xffffffff;
27
+ }
28
+ /**
29
+ * 随机选择数组元素
30
+ */
31
+ function pick(arr, rand) {
32
+ return arr[Math.floor(rand() * arr.length)];
33
+ }
34
+ /**
35
+ * 调整颜色亮度
36
+ */
37
+ function adjustColor(hex, amount) {
38
+ const num = parseInt(hex.slice(1), 16);
39
+ let r = (num >> 16) + Math.round(255 * amount);
40
+ let g = ((num >> 8) & 0x00ff) + Math.round(255 * amount);
41
+ let b = (num & 0x0000ff) + Math.round(255 * amount);
42
+ r = Math.max(0, Math.min(255, r));
43
+ g = Math.max(0, Math.min(255, g));
44
+ b = Math.max(0, Math.min(255, b));
45
+ return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}`;
46
+ }
47
+ /**
48
+ * XML 转义
49
+ */
50
+ function escapeXml(str) {
51
+ return str
52
+ .replace(/&/g, '&amp;')
53
+ .replace(/</g, '&lt;')
54
+ .replace(/>/g, '&gt;')
55
+ .replace(/"/g, '&quot;')
56
+ .replace(/'/g, '&apos;');
57
+ }
58
+ /**
59
+ * 生成封面图
60
+ * @param title 标题
61
+ * @param subtitle 副标题
62
+ * @param outputDir 输出目录(默认系统临时目录)
63
+ * @returns 封面图片本地路径
64
+ */
65
+ export async function generateCover(title, subtitle = '', outputDir = tmpdir()) {
66
+ try {
67
+ // 使用标题作为种子,保证相同标题生成相同封面
68
+ const seed = title;
69
+ // 随机选择主题和风格
70
+ const theme = pick(THEMES, () => seededRandom(seed, 0));
71
+ const decorStyle = pick(DECOR_STYLES, () => seededRandom(seed, 1));
72
+ const { primary, secondary, bg } = theme;
73
+ const width = 900;
74
+ const height = 500;
75
+ // 生成 SVG
76
+ const svg = buildSvg({ title, subtitle, width, height, primary, secondary, bg, decorStyle });
77
+ // 生成随机文件名
78
+ const random = crypto.randomBytes(8).toString('hex');
79
+ const filename = `cover-${Date.now()}-${random}.png`;
80
+ const localPath = path.join(outputDir, filename);
81
+ await sharp(Buffer.from(svg))
82
+ .png()
83
+ .toFile(localPath);
84
+ console.log(`[cover-generator] 封面图已生成: ${filename} [主题: ${theme.name}] [风格: ${decorStyle}]`);
85
+ return {
86
+ success: true,
87
+ localPath,
88
+ };
89
+ }
90
+ catch (error) {
91
+ console.error(`[cover-generator] 生成封面失败:`, error);
92
+ return {
93
+ success: false,
94
+ error: error.message,
95
+ };
96
+ }
97
+ }
98
+ function buildSvg(p) {
99
+ const { title, subtitle, width, height, primary, secondary, bg, decorStyle } = p;
100
+ const primaryLight = adjustColor(primary, 0.3);
101
+ const secondaryLight = adjustColor(secondary, 0.3);
102
+ const gradTop = adjustColor(primary, -0.2);
103
+ const gradBottom = adjustColor(secondary, -0.2);
104
+ // 生成装饰元素
105
+ let decorElements = '';
106
+ switch (decorStyle) {
107
+ case 'circles':
108
+ decorElements = `
109
+ <circle cx="${width - 80}" cy="70" r="100" fill="${primary}" fill-opacity="0.08"/>
110
+ <circle cx="${width - 80}" cy="70" r="70" fill="${secondary}" fill-opacity="0.06"/>
111
+ <circle cx="80" cy="${height - 80}" r="120" fill="${secondary}" fill-opacity="0.08"/>
112
+ <circle cx="80" cy="${height - 80}" r="80" fill="${primary}" fill-opacity="0.05"/>
113
+ <circle cx="${width / 2}" cy="${height + 50}" r="150" fill="${primary}" fill-opacity="0.04"/>
114
+ `;
115
+ break;
116
+ case 'rings':
117
+ decorElements = `
118
+ <circle cx="${width - 100}" cy="100" r="120" fill="none" stroke="${primary}" stroke-width="1" stroke-opacity="0.2"/>
119
+ <circle cx="${width - 100}" cy="100" r="90" fill="none" stroke="${secondary}" stroke-width="1" stroke-opacity="0.15"/>
120
+ <circle cx="${width - 100}" cy="100" r="60" fill="none" stroke="${primary}" stroke-width="1" stroke-opacity="0.1"/>
121
+ <circle cx="100" cy="${height - 100}" r="100" fill="none" stroke="${secondary}" stroke-width="1" stroke-opacity="0.2"/>
122
+ <circle cx="100" cy="${height - 100}" r="70" fill="none" stroke="${primary}" stroke-width="1" stroke-opacity="0.15"/>
123
+ `;
124
+ break;
125
+ case 'dots':
126
+ decorElements = `
127
+ <circle cx="${width - 60}" cy="60" r="80" fill="${secondary}" fill-opacity="0.1"/>
128
+ ${Array.from({ length: 8 }, (_, i) => {
129
+ const x = 60 + i * 30;
130
+ const y = height - 40;
131
+ return `<circle cx="${x}" cy="${y}" r="2" fill="${primary}" fill-opacity="0.3"/>`;
132
+ }).join('')}
133
+ ${Array.from({ length: 6 }, (_, i) => {
134
+ const x = width - 180 + i * 25;
135
+ const y = 80;
136
+ return `<circle cx="${x}" cy="${y}" r="2" fill="${secondary}" fill-opacity="0.3"/>`;
137
+ }).join('')}
138
+ `;
139
+ break;
140
+ case 'lines':
141
+ decorElements = `
142
+ <line x1="${width - 150}" y1="50" x2="${width - 150}" y2="${height - 50}" stroke="${primary}" stroke-width="1" stroke-opacity="0.15"/>
143
+ <line x1="${width - 120}" y1="50" x2="${width - 120}" y2="${height - 50}" stroke="${secondary}" stroke-width="1" stroke-opacity="0.1"/>
144
+ <line x1="50" y1="${height - 150}" x2="${width - 50}" y2="${height - 150}" stroke="${primary}" stroke-width="1" stroke-opacity="0.1"/>
145
+ <line x1="50" y1="${height - 120}" x2="${width - 50}" y2="${height - 120}" stroke="${secondary}" stroke-width="1" stroke-opacity="0.08"/>
146
+ `;
147
+ break;
148
+ case 'grid':
149
+ decorElements = `
150
+ <pattern id="decoGrid" width="50" height="50" patternUnits="userSpaceOnUse">
151
+ <path d="M 50 0 L 0 0 0 50" fill="none" stroke="${primary}" stroke-width="0.5" stroke-opacity="0.08"/>
152
+ </pattern>
153
+ <rect width="100%" height="100%" fill="url(#decoGrid)"/>
154
+ `;
155
+ break;
156
+ case 'hexagon':
157
+ const hx = width - 100, hy = 100;
158
+ decorElements = `
159
+ <polygon points="${hx},${hy - 60} ${hx + 52},${hy - 30} ${hx + 52},${hy + 30} ${hx},${hy + 60} ${hx - 52},${hy + 30} ${hx - 52},${hy - 30}"
160
+ fill="none" stroke="${primary}" stroke-width="1" stroke-opacity="0.2"/>
161
+ <polygon points="${hx},${hy - 40} ${hx + 35},${hy - 20} ${hx + 35},${hy + 20} ${hx},${hy + 40} ${hx - 35},${hy + 20} ${hx - 35},${hy - 20}"
162
+ fill="${secondary}" fill-opacity="0.08"/>
163
+ <circle cx="80" cy="${height - 80}" r="60" fill="${secondary}" fill-opacity="0.08"/>
164
+ `;
165
+ break;
166
+ }
167
+ return `
168
+ <svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
169
+ <defs>
170
+ <linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
171
+ <stop offset="0%" style="stop-color:${bg};stop-opacity:1" />
172
+ <stop offset="50%" style="stop-color:${adjustColor(bg, -0.1)};stop-opacity:1" />
173
+ <stop offset="100%" style="stop-color:${adjustColor(secondary, -0.7)};stop-opacity:1" />
174
+ </linearGradient>
175
+ <radialGradient id="topGlow" cx="70%" cy="0%" r="70%">
176
+ <stop offset="0%" style="stop-color:${primary};stop-opacity:0.3" />
177
+ <stop offset="100%" style="stop-color:${primary};stop-opacity:0" />
178
+ </radialGradient>
179
+ <radialGradient id="bottomGlow" cx="30%" cy="100%" r="50%">
180
+ <stop offset="0%" style="stop-color:${secondary};stop-opacity:0.25" />
181
+ <stop offset="100%" style="stop-color:${secondary};stop-opacity:0" />
182
+ </radialGradient>
183
+ <linearGradient id="titleGrad" x1="0%" y1="0%" x2="100%" y2="100%">
184
+ <stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
185
+ <stop offset="50%" style="stop-color:${adjustColor(primary, 0.3)};stop-opacity:1" />
186
+ <stop offset="100%" style="stop-color:${primaryLight};stop-opacity:1" />
187
+ </linearGradient>
188
+ <linearGradient id="lineGrad" x1="0%" y1="0%" x2="100%" y2="0%">
189
+ <stop offset="0%" style="stop-color:${primary};stop-opacity:0" />
190
+ <stop offset="50%" style="stop-color:${primary};stop-opacity:0.8" />
191
+ <stop offset="100%" style="stop-color:${secondary};stop-opacity:0" />
192
+ </linearGradient>
193
+ <filter id="glow" x="-30%" y="-30%" width="160%" height="160%">
194
+ <feGaussianBlur stdDeviation="4" result="blur"/>
195
+ <feMerge>
196
+ <feMergeNode in="blur"/>
197
+ <feMergeNode in="SourceGraphic"/>
198
+ </feMerge>
199
+ </filter>
200
+ <filter id="textShadow" x="-10%" y="-10%" width="120%" height="120%">
201
+ <feDropShadow dx="0" dy="3" stdDeviation="6" flood-color="#000000" flood-opacity="0.4"/>
202
+ </filter>
203
+ </defs>
204
+
205
+ <rect width="100%" height="100%" fill="url(#bgGrad)"/>
206
+ <ellipse cx="${width}" cy="-50" rx="${width * 0.8}" ry="250" fill="url(#topGlow)"/>
207
+ <ellipse cx="0" cy="${height + 50}" rx="${width * 0.6}" ry="200" fill="url(#bottomGlow)"/>
208
+ ${decorElements}
209
+ <rect x="60" y="140" width="2" height="220" fill="url(#lineGrad)" opacity="0.5"/>
210
+
211
+ <text x="${width / 2}" y="${height / 2 - 30}" font-family="Arial, sans-serif" font-size="56" font-weight="bold"
212
+ fill="url(#titleGrad)" text-anchor="middle" filter="url(#textShadow)">
213
+ ${escapeXml(title)}
214
+ </text>
215
+
216
+ <rect x="${width / 2 - 150}" y="${height / 2 + 5}" width="300" height="2" rx="1" fill="url(#lineGrad)" opacity="0.7"/>
217
+
218
+ ${subtitle ? `
219
+ <text x="${width / 2}" y="${height / 2 + 55}" font-family="Arial, sans-serif" font-size="28" fill="#ffffff" fill-opacity="0.8" text-anchor="middle" letter-spacing="3">
220
+ ${escapeXml(subtitle)}
221
+ </text>
222
+ ` : ''}
223
+
224
+ <rect x="${width / 2 - 140}" y="${height - 70}" width="280" height="36" rx="6"
225
+ fill="#ffffff" fill-opacity="0.06" stroke="${primary}" stroke-width="1" stroke-opacity="0.2"/>
226
+ <text x="${width / 2}" y="${height - 45}" font-family="Consolas, monospace" font-size="14" fill="${adjustColor(primary, 0.3)}" text-anchor="middle">
227
+ npm install -g multi-publisher
228
+ </text>
229
+
230
+ <circle cx="${width / 2 - 80}" cy="${height - 18}" r="2" fill="${primary}" fill-opacity="0.4"/>
231
+ <circle cx="${width / 2 - 60}" cy="${height - 18}" r="3" fill="${secondary}" fill-opacity="0.5"/>
232
+ <circle cx="${width / 2 - 40}" cy="${height - 18}" r="2" fill="${primary}" fill-opacity="0.3"/>
233
+ </svg>
234
+ `;
235
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "multi-publisher",
3
- "version": "1.0.4",
3
+ "version": "1.1.1",
4
4
  "description": "Markdown → 微信公众号 / 知乎 / 掘金等多平台 CLI 发布工具",
5
5
  "type": "module",
6
6
  "repository": {
@@ -51,6 +51,7 @@
51
51
  "marked-highlight": "^2.2.0",
52
52
  "mathjax-full": "^3.2.0",
53
53
  "playwright": "^1.59.1",
54
+ "sharp": "^0.34.5",
54
55
  "yaml": "^2.8.3"
55
56
  },
56
57
  "devDependencies": {