multi-publisher 1.0.3 → 1.1.0
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/dist/adapters/toutiao.js +3 -6
- package/dist/cli/index.js +1 -0
- package/dist/cli/publish.d.ts +1 -0
- package/dist/cli/publish.js +9 -1
- package/dist/core/renderer.d.ts +4 -0
- package/dist/core/renderer.js +16 -1
- package/dist/core/theme.d.ts +1 -1
- package/dist/core/theme.js +49 -7
- package/dist/tools/cover-fetcher.d.ts +21 -0
- package/dist/tools/cover-fetcher.js +353 -0
- package/dist/tools/cover-generator.d.ts +13 -0
- package/dist/tools/cover-generator.js +235 -0
- package/package.json +2 -1
package/dist/adapters/toutiao.js
CHANGED
|
@@ -114,18 +114,15 @@ export class ToutiaoAdapter {
|
|
|
114
114
|
console.log('[toutiao] 已填写内容');
|
|
115
115
|
await page.waitForTimeout(1000);
|
|
116
116
|
}
|
|
117
|
-
// 等待编辑器自动保存(最多5
|
|
118
|
-
console.log('[toutiao]
|
|
119
|
-
for (let i = 0; i <
|
|
117
|
+
// 等待编辑器自动保存(最多5秒)
|
|
118
|
+
console.log('[toutiao] 等待编辑器自动保存(5秒)...');
|
|
119
|
+
for (let i = 0; i < 5; i++) {
|
|
120
120
|
const url = page.url();
|
|
121
121
|
if (url.includes('id=')) {
|
|
122
122
|
console.log('[toutiao] 检测到文章 ID,保存完成');
|
|
123
123
|
break;
|
|
124
124
|
}
|
|
125
125
|
await page.waitForTimeout(1000);
|
|
126
|
-
if (i % 30 === 0 && i > 0) {
|
|
127
|
-
console.log(`[toutiao] 已等待 ${i} 秒...`);
|
|
128
|
-
}
|
|
129
126
|
}
|
|
130
127
|
// 检查 URL 是否包含文章 ID
|
|
131
128
|
let currentUrl = page.url();
|
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')
|
package/dist/cli/publish.d.ts
CHANGED
package/dist/cli/publish.js
CHANGED
|
@@ -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:
|
|
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(`✅ 发布成功!`);
|
package/dist/core/renderer.d.ts
CHANGED
|
@@ -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
|
package/dist/core/renderer.js
CHANGED
|
@@ -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
|
}
|
package/dist/core/theme.d.ts
CHANGED
|
@@ -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:
|
|
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>;
|
package/dist/core/theme.js
CHANGED
|
@@ -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:
|
|
29
|
-
li { margin
|
|
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:
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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, '&')
|
|
53
|
+
.replace(/</g, '<')
|
|
54
|
+
.replace(/>/g, '>')
|
|
55
|
+
.replace(/"/g, '"')
|
|
56
|
+
.replace(/'/g, ''');
|
|
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
|
|
3
|
+
"version": "1.1.0",
|
|
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": {
|