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.
@@ -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,
package/dist/cli/index.js CHANGED
@@ -24,6 +24,7 @@ export function createProgram() {
24
24
  .requiredOption('-f, --file <path>', 'Markdown 文件路径(支持本地文件和 URL)')
25
25
  .option('-p, --platform <platform>', '目标平台 (weixin|zhihu|juejin|csdn)', 'weixin')
26
26
  .option('-t, --theme <theme-id>', '主题 ID', 'default')
27
+ .option('-m, --cover-mode <mode>', '封面模式 (sharp|network|auto)', 'auto')
27
28
  .option('--app-id <appId>', '微信公众号 AppID(可省略,从配置文件读取)')
28
29
  .option('--no-mac-style', '禁用 Mac 风格代码块')
29
30
  .option('--auto-cover', '当文章无封面图时,根据标题自动从网络获取封面')
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
  }
@@ -19,9 +19,10 @@ const PLATFORM_LIST = [
19
19
  { id: 'sohu', name: '搜狐号', auth: 'Cookie', capabilities: ['article', 'draft'], note: 'Cookie 登录支持', status: '🔄 待测试' },
20
20
  { id: 'eastmoney', name: '东方财富', auth: 'Cookie', capabilities: ['article', 'draft'], note: 'Cookie 登录支持', status: '🔄 待测试' },
21
21
  { id: 'cto51', name: '51CTO', auth: 'Cookie', capabilities: ['article', 'draft'], note: 'Cookie 登录支持', status: '🔄 待测试' },
22
+ { id: 'qq', name: '企鹅号', auth: 'Cookie', capabilities: ['article', 'draft', 'image_upload'], note: '企鹅号平台', status: '🔄 待测试' },
22
23
  ];
23
24
  export async function runPlatforms() {
24
- console.log('\n🖥️ 支持的平台(共 20 个):\n');
25
+ console.log('\n🖥️ 支持的平台(共 21 个):\n');
25
26
  console.log('状态说明:');
26
27
  console.log(' ✅ 已验证 - 登录和发布功能已测试通过');
27
28
  console.log(' 🔄 待测试 - Cookie 登录支持,但发布功能尚未测试\n');
@@ -55,7 +55,7 @@ export async function runPublishAll(options) {
55
55
  cover = coverMatch[1].trim();
56
56
  markdownContent = markdownContent.slice(frontmatterMatch[0].length);
57
57
  }
58
- // 渲染 HTML
58
+ // 渲染 HTML(不处理 mermaid,由各平台适配器自行处理)
59
59
  const renderResult = await renderMarkdown(markdownContent, {
60
60
  theme,
61
61
  highlight,
@@ -5,4 +5,5 @@ export declare function runPublish(options: {
5
5
  appId?: string;
6
6
  macStyle?: boolean;
7
7
  autoCover?: boolean;
8
+ coverMode?: 'sharp' | 'network' | 'auto';
8
9
  }, input?: string): Promise<void>;
@@ -27,14 +27,30 @@ export async function runPublish(options, input) {
27
27
  else {
28
28
  throw new Error('请提供 -f 选项指定 Markdown 文件');
29
29
  }
30
- // 2. 渲染
30
+ // 平台未显式指定主题时,按平台绑定合适的默认主题
31
+ const platformId = options.platform || 'weixin';
32
+ const PLATFORM_DEFAULT_THEME = {
33
+ weixin: 'wechat',
34
+ zhihu: 'modern',
35
+ toutiao: 'minimal',
36
+ // 其余平台回退到 default
37
+ };
38
+ const theme = options.theme || PLATFORM_DEFAULT_THEME[platformId] || 'default';
39
+ // 2. 渲染(不处理 mermaid,由各平台适配器自行处理)
31
40
  const result = await renderMarkdown(content, {
32
- theme: options.theme || 'default',
41
+ theme,
33
42
  macStyle: options.macStyle !== false,
34
43
  autoCover: options.autoCover || false,
44
+ coverMode: options.coverMode || 'auto',
35
45
  });
36
46
  // 3. 选择适配器
37
- const platformId = options.platform || 'weixin';
47
+ // 校验 title
48
+ if (!result.title || result.title === '无标题') {
49
+ console.warn(`[publish] ⚠️ 警告:文章标题为空!`);
50
+ }
51
+ else {
52
+ console.log(`[publish] ✅ 文章标题: "${result.title}"`);
53
+ }
38
54
  // 初始化适配器注册表
39
55
  const runtime = createNodeRuntime();
40
56
  await initAdapterRegistry(runtime);
@@ -50,8 +66,16 @@ export async function runPublish(options, input) {
50
66
  if (options.appId) {
51
67
  process.env.WECHAT_APP_ID = options.appId;
52
68
  }
53
- // 使用自动获取的封面图(如果文章没有指定封面)
54
- const coverImage = result.cover || result.autoCoverPath;
69
+ // 使用封面图:
70
+ // - coverMode=sharp/network: 强制用 autoCoverPath,忽略 front-matter 封面
71
+ // - coverMode=auto: 优先用 front-matter 封面,其次 autoCoverPath
72
+ let coverImage;
73
+ if (options.coverMode === 'sharp' || options.coverMode === 'network') {
74
+ coverImage = result.autoCoverPath;
75
+ }
76
+ else {
77
+ coverImage = result.cover || result.autoCoverPath;
78
+ }
55
79
  const syncResult = await adapter.publish({
56
80
  title: result.title,
57
81
  markdown: content,
package/dist/config.d.ts CHANGED
@@ -61,6 +61,9 @@ export interface SohuConfig {
61
61
  export interface EastmoneyConfig {
62
62
  cookies?: Record<string, string>;
63
63
  }
64
+ export interface QQConfig {
65
+ cookies?: Record<string, string>;
66
+ }
64
67
  export interface GlobalConfig {
65
68
  version: number;
66
69
  weixin?: WeixinConfig;
@@ -83,6 +86,7 @@ export interface GlobalConfig {
83
86
  douban?: DoubanConfig;
84
87
  sohu?: SohuConfig;
85
88
  eastmoney?: EastmoneyConfig;
89
+ qq?: QQConfig;
86
90
  }
87
91
  /** 读取配置(带缓存) */
88
92
  export declare function loadConfig(): Promise<GlobalConfig>;
@@ -172,6 +176,10 @@ export declare class ConfigStore {
172
176
  static getEastmoneyCookies(): Promise<Record<string, string> | null>;
173
177
  /** 设置东方财富 Cookie */
174
178
  static setEastmoneyCookies(cookies: Record<string, string>): Promise<void>;
179
+ /** 获取企鹅号 Cookie */
180
+ static getQQCookies(): Promise<Record<string, string> | null>;
181
+ /** 设置企鹅号 Cookie */
182
+ static setQQCookies(cookies: Record<string, string>): Promise<void>;
175
183
  /** 获取配置目录路径 */
176
184
  static getDir(): string;
177
185
  }
package/dist/config.js CHANGED
@@ -326,6 +326,17 @@ export class ConfigStore {
326
326
  c.eastmoney = { ...c.eastmoney, cookies };
327
327
  });
328
328
  }
329
+ /** 获取企鹅号 Cookie */
330
+ static async getQQCookies() {
331
+ const config = await loadConfig();
332
+ return config.qq?.cookies || null;
333
+ }
334
+ /** 设置企鹅号 Cookie */
335
+ static async setQQCookies(cookies) {
336
+ await updateConfig((c) => {
337
+ c.qq = { ...c.qq, cookies };
338
+ });
339
+ }
329
340
  /** 获取配置目录路径 */
330
341
  static getDir() {
331
342
  return getConfigDir();
@@ -27,9 +27,17 @@ const markedInstance = createMarked();
27
27
  export function parseMarkdown(content) {
28
28
  const parsed = fm(content);
29
29
  const html = markedInstance.parse(parsed.body);
30
+ // Fallback title extraction: try to get first # heading if front-matter title is missing
31
+ let title = parsed.attributes.title;
32
+ if (!title) {
33
+ const headingMatch = parsed.body.match(/^#\s+(.+)/m);
34
+ if (headingMatch) {
35
+ title = headingMatch[1].trim();
36
+ }
37
+ }
30
38
  return {
31
39
  meta: {
32
- title: parsed.attributes.title || '无标题',
40
+ title: title || '无标题',
33
41
  author: parsed.attributes.author,
34
42
  cover: parsed.attributes.cover,
35
43
  source_url: parsed.attributes.source_url,
@@ -1,3 +1,4 @@
1
+ export type CoverMode = 'sharp' | 'network' | 'auto';
1
2
  export interface RenderOptions {
2
3
  theme?: string;
3
4
  customCss?: string;
@@ -6,6 +7,8 @@ export interface RenderOptions {
6
7
  footnote?: boolean;
7
8
  /** 是否在无封面时自动根据标题获取封面图 */
8
9
  autoCover?: boolean;
10
+ /** 封面模式: sharp(SVG生成)|network(网络抓取)|auto(自动选择) */
11
+ coverMode?: CoverMode;
9
12
  }
10
13
  export interface RenderResult {
11
14
  title: string;
@@ -17,6 +20,11 @@ export interface RenderResult {
17
20
  autoCoverPath?: string;
18
21
  }
19
22
  /**
20
- * 渲染 Markdown 文件为平台 HTML
23
+ * 处理 Mermaid 代码块,将它们转换为图片
24
+ * 各平台适配器可调用此函数
21
25
  */
26
+ export declare function processMermaid(html: string, outputDir: string): Promise<{
27
+ html: string;
28
+ tempFiles: string[];
29
+ }>;
22
30
  export declare function renderMarkdown(content: string, options?: RenderOptions): Promise<RenderResult>;
@@ -6,32 +6,107 @@ import { processMath } from './mathjax.js';
6
6
  import { inlineStyles } from './styler.js';
7
7
  import { loadThemeCss, DEFAULT_CSS } from './theme.js';
8
8
  import { generateCover } from '../tools/cover-generator.js';
9
+ import { fetchCoverByTitle } from '../tools/cover-fetcher.js';
10
+ import { execSync } from 'child_process';
11
+ import path from 'path';
12
+ import fs from 'fs/promises';
13
+ import os from 'os';
9
14
  /**
10
- * 渲染 Markdown 文件为平台 HTML
15
+ * 处理 Mermaid 代码块,将它们转换为图片
16
+ * 各平台适配器可调用此函数
11
17
  */
18
+ export async function processMermaid(html, outputDir) {
19
+ const mermaidBlockRegex = /<pre[^>]*><code[^>]*class="hljs language-mermaid"[^>]*>([\s\S]*?)<\/code><\/pre>/gi;
20
+ const matches = [...html.matchAll(mermaidBlockRegex)];
21
+ const tempFiles = [];
22
+ if (matches.length === 0)
23
+ return { html, tempFiles };
24
+ console.log(`[renderer] 发现 ${matches.length} 个 mermaid 代码块,开始转换为图片...`);
25
+ for (const match of matches) {
26
+ let mermaidCode = match[1].trim();
27
+ // 解码 HTML 实体
28
+ mermaidCode = mermaidCode
29
+ .replace(/&lt;/g, '<')
30
+ .replace(/&gt;/g, '>')
31
+ .replace(/&amp;/g, '&')
32
+ .replace(/&quot;/g, '"');
33
+ const tmpMmd = path.join(os.tmpdir(), `mermaid-${Date.now()}-${Math.random()}.mmd`);
34
+ const outputPng = path.join(outputDir, `mermaid-${Date.now()}-${Math.random()}.png`);
35
+ try {
36
+ // 写入临时 mmd 文件
37
+ await fs.writeFile(tmpMmd, mermaidCode, 'utf8');
38
+ tempFiles.push(tmpMmd);
39
+ // 调用 mmdc 渲染
40
+ execSync(`mmdc -i "${tmpMmd}" -o "${outputPng}" -b white`, {
41
+ stdio: 'pipe',
42
+ timeout: 60000
43
+ });
44
+ // 验证图片生成
45
+ await fs.access(outputPng);
46
+ tempFiles.push(outputPng);
47
+ // 替换 mermaid 代码块为图片
48
+ const imageTag = `<img src="${outputPng.replace(/\\/g, '/')}" />`;
49
+ html = html.replace(match[0], imageTag);
50
+ console.log(`[renderer] Mermaid 图片生成成功: ${outputPng}`);
51
+ }
52
+ catch (err) {
53
+ console.warn(`[renderer] Mermaid 渲染失败: ${err.message},保留原代码块`);
54
+ await fs.unlink(tmpMmd).catch(() => { });
55
+ }
56
+ }
57
+ return { html, tempFiles };
58
+ }
12
59
  export async function renderMarkdown(content, options = {}) {
13
- const { theme = 'default', customCss, macStyle = true, autoCover = false, } = options;
60
+ const { theme = 'default', customCss, macStyle = true, autoCover = false, coverMode = 'auto', } = options;
14
61
  // 1. 解析 front-matter + Markdown → HTML
15
62
  const parsed = parseMarkdown(content);
16
63
  // 2. 处理 LaTeX 公式($...$ 和 $$...$$)
17
64
  let html = processMath(parsed.html);
65
+ // 注意:Mermaid 处理移到各平台适配器的 processMermaid 钩子
66
+ // 各 adapter 自己决定是否转换 mermaid 代码块
18
67
  // 3. 加载主题 CSS
19
68
  const themeCss = customCss ?? (await loadThemeCss(theme)) ?? DEFAULT_CSS;
20
69
  // 4. CSS 内联
21
70
  html = inlineStyles(html, themeCss);
22
71
  // 5. 包裹 section 标签(微信公众号要求)
23
72
  html = `<section style="margin-left:6px;margin-right:6px;line-height:1.75em">${html}</section>`;
73
+ // 5.5 校验 title
74
+ if (!parsed.meta.title || parsed.meta.title === '无标题') {
75
+ console.warn(`[renderer] ⚠️ 标题为空或为默认值,将尝试从正文提取。原始 content 前200字符: ${content.substring(0, 200)}`);
76
+ }
77
+ else {
78
+ console.log(`[renderer] ✅ 标题: "${parsed.meta.title}"`);
79
+ }
24
80
  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}`);
81
+ // 6. 自动生成封面图
82
+ // 条件:有 --auto-cover 且(front-matter 无封面 或 coverMode=sharp/network 强制覆盖)
83
+ const shouldGenerateCover = autoCover && (!parsed.meta.cover ||
84
+ coverMode === 'sharp' ||
85
+ coverMode === 'network');
86
+ if (shouldGenerateCover && parsed.meta.title) {
87
+ if (coverMode === 'sharp') {
88
+ // sharp 模式:强制用 SVG 生成封面
89
+ console.log(`[renderer] cover-mode=sharp,强制用 SVG 生成封面...`);
90
+ const result = await generateCover(parsed.meta.title, parsed.meta.description || '');
91
+ if (result.success && result.localPath) {
92
+ autoCoverPath = result.localPath;
93
+ console.log(`[renderer] SVG 封面生成成功: ${result.localPath}`);
94
+ }
95
+ else {
96
+ console.warn(`[renderer] SVG 封面生成失败: ${result.error}`);
97
+ }
32
98
  }
33
- else {
34
- console.warn(`[renderer] 自动封面图生成失败: ${result.error}`);
99
+ else if (coverMode === 'network' || (coverMode === 'auto' && !parsed.meta.cover)) {
100
+ // network 模式 或 auto 模式(无 front-matter 封面):从网络抓取
101
+ console.log(`[renderer] cover-mode=${coverMode},从网络抓取封面...`);
102
+ const result = await fetchCoverByTitle(parsed.meta.title);
103
+ if (result.success && result.localPath) {
104
+ autoCoverPath = result.localPath;
105
+ console.log(`[renderer] 网络封面获取成功: ${result.localPath}`);
106
+ }
107
+ else {
108
+ console.warn(`[renderer] 网络封面获取失败: ${result.error}`);
109
+ }
35
110
  }
36
111
  }
37
112
  return {
@@ -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: 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";
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 letter-spacing: 0.5px;\n}\nh1, h2, h3, h4, h5, h6 {\n font-weight: bold;\n color: #2c3e50;\n margin: 1.2em 0 0.6em 0;\n}\nh1 { font-size: 1.4em; line-height: 1.4em; border-bottom: 1px solid #e8e8e8; padding-bottom: 0.3em; }\nh2 { font-size: 1.2em; border-left: 4px solid #07a35a; padding-left: 0.5em; }\nh3 { font-size: 1.05em; }\nh4, h5, h6 { font-size: 1em; }\nul, ol { margin: 0 0 1em 0; padding-left: 1.8em; }\nli { margin: 0.3em 0; line-height: 1.7em; }\nli p { margin: 0; }\npre {\n background-color: #f6f8fa;\n border: 1px solid #eaecef;\n border-radius: 6px;\n padding: 14px 16px;\n overflow-x: auto;\n font-size: 13.5px;\n line-height: 1.6;\n margin: 1em 0;\n color: #24292e;\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; border: none; font-size: 13.5px; }\nblockquote {\n border-left: 4px solid #07a35a;\n background-color: #f6fbf8;\n padding: 0.8em 1em;\n margin: 1em 0;\n color: #555;\n border-radius: 0 4px 4px 0;\n}\nhr { border: none; border-top: 1px solid #e8e8e8; margin: 1.5em 0; }\ni, cite, em, var, address { font-style: italic; }\nb, strong { font-weight: bold; color: #1a1a1a; }\nimg { max-width: 100%; height: auto; display: block; margin: 1em auto; border-radius: 4px; }\ntable { border-collapse: collapse; width: 100%; margin: 1em 0; font-size: 14px; }\ntable th, table td { border: 1px solid #e8e8e8; padding: 8px 12px; }\ntable th { background-color: #f6f8fa; font-weight: bold; color: #2c3e50; }\ntable tr:nth-child(even) { background-color: #fafafa; }\na { color: #576b95; text-decoration: none; border-bottom: 1px solid rgba(87, 107, 149, 0.3); }\n";
8
8
  export declare function listThemes(): Promise<ThemeInfo[]>;
9
9
  export declare function loadThemeCss(themeId: string): Promise<string | null>;