@zhin.js/plugin-link-poster 0.0.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/lib/index.d.ts +17 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +38 -0
- package/lib/index.js.map +1 -0
- package/lib/platforms.d.ts +7 -0
- package/lib/platforms.d.ts.map +1 -0
- package/lib/platforms.js +218 -0
- package/lib/platforms.js.map +1 -0
- package/lib/render.d.ts +6 -0
- package/lib/render.d.ts.map +1 -0
- package/lib/render.js +91 -0
- package/lib/render.js.map +1 -0
- package/lib/types.d.ts +19 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +2 -0
- package/lib/types.js.map +1 -0
- package/package.json +32 -0
- package/src/index.ts +57 -0
- package/src/platforms.ts +242 -0
- package/src/render.ts +103 -0
- package/src/types.ts +19 -0
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
declare module 'zhin.js' {
|
|
2
|
+
namespace Plugin {
|
|
3
|
+
interface Contexts {
|
|
4
|
+
'html-renderer': {
|
|
5
|
+
render(html: string, options?: {
|
|
6
|
+
width?: number;
|
|
7
|
+
}): Promise<{
|
|
8
|
+
data: Buffer | string;
|
|
9
|
+
format: string;
|
|
10
|
+
mimeType: string;
|
|
11
|
+
}>;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export {};
|
|
17
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAaA,OAAO,QAAQ,SAAS,CAAC;IACvB,UAAU,MAAM,CAAC;QACf,UAAU,QAAQ;YAChB,eAAe,EAAE;gBACf,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;oBAAE,KAAK,CAAC,EAAE,MAAM,CAAA;iBAAE,GAAG,OAAO,CAAC;oBAC1D,IAAI,EAAE,MAAM,GAAG,MAAM,CAAA;oBACrB,MAAM,EAAE,MAAM,CAAA;oBACd,QAAQ,EAAE,MAAM,CAAA;iBACjB,CAAC,CAAA;aACH,CAAA;SACF;KACF;CACF"}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zhin.js/plugin-link-poster
|
|
3
|
+
*
|
|
4
|
+
* 自动检测消息中的 Bilibili / GitHub / 抖音 / 小红书链接,
|
|
5
|
+
* 生成精美内容海报图片并回复。
|
|
6
|
+
*
|
|
7
|
+
* 依赖:@zhin.js/plugin-html-renderer(提供 html-renderer Context)
|
|
8
|
+
*/
|
|
9
|
+
import { usePlugin, segment } from 'zhin.js';
|
|
10
|
+
import { detectAndParse } from './platforms.js';
|
|
11
|
+
import { renderPoster } from './render.js';
|
|
12
|
+
const { addMiddleware, useContext, logger } = usePlugin();
|
|
13
|
+
useContext('html-renderer', (renderer) => {
|
|
14
|
+
logger.info('link-poster: html-renderer 就绪,已启用链接海报');
|
|
15
|
+
addMiddleware(async (message, next) => {
|
|
16
|
+
const text = message.$raw || '';
|
|
17
|
+
// 快速跳过:消息中没有 http 链接
|
|
18
|
+
if (!text.includes('http'))
|
|
19
|
+
return next();
|
|
20
|
+
try {
|
|
21
|
+
const meta = await detectAndParse(text);
|
|
22
|
+
if (meta) {
|
|
23
|
+
const html = renderPoster(meta);
|
|
24
|
+
const result = await renderer.render(html, { width: 480 });
|
|
25
|
+
if (result.format === 'png' && typeof result.data === 'object') {
|
|
26
|
+
const base64 = Buffer.from(result.data).toString('base64');
|
|
27
|
+
const dataUrl = `data:${result.mimeType};base64,${base64}`;
|
|
28
|
+
await message.$reply(segment('image', { url: dataUrl }));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
catch (e) {
|
|
33
|
+
logger.warn(`link-poster: 海报生成失败 - ${e instanceof Error ? e.message : e}`);
|
|
34
|
+
}
|
|
35
|
+
return next();
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
//# sourceMappingURL=index.js.map
|
package/lib/index.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAAE,SAAS,EAAE,OAAO,EAAE,MAAM,SAAS,CAAA;AAC5C,OAAO,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAA;AAC/C,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAiB1C,MAAM,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,EAAE,GAAG,SAAS,EAAE,CAAA;AAEzD,UAAU,CAAC,eAAe,EAAE,CAAC,QAAQ,EAAE,EAAE;IACvC,MAAM,CAAC,IAAI,CAAC,uCAAuC,CAAC,CAAA;IAEpD,aAAa,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,EAAE;QACpC,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,IAAI,EAAE,CAAA;QAE/B,qBAAqB;QACrB,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC;YAAE,OAAO,IAAI,EAAE,CAAA;QAEzC,IAAI,CAAC;YACH,MAAM,IAAI,GAAG,MAAM,cAAc,CAAC,IAAI,CAAC,CAAA;YACvC,IAAI,IAAI,EAAE,CAAC;gBACT,MAAM,IAAI,GAAG,YAAY,CAAC,IAAI,CAAC,CAAA;gBAC/B,MAAM,MAAM,GAAG,MAAM,QAAQ,CAAC,MAAM,CAAC,IAAI,EAAE,EAAE,KAAK,EAAE,GAAG,EAAE,CAAC,CAAA;gBAE1D,IAAI,MAAM,CAAC,MAAM,KAAK,KAAK,IAAI,OAAO,MAAM,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;oBAC/D,MAAM,MAAM,GAAG,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,IAAc,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAA;oBACpE,MAAM,OAAO,GAAG,QAAQ,MAAM,CAAC,QAAQ,WAAW,MAAM,EAAE,CAAA;oBAC1D,MAAM,OAAO,CAAC,MAAM,CAAC,OAAO,CAAC,OAAO,EAAE,EAAE,GAAG,EAAE,OAAO,EAAE,CAAC,CAAC,CAAA;gBAC1D,CAAC;YACH,CAAC;QACH,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,MAAM,CAAC,IAAI,CAAC,yBAAyB,CAAC,YAAY,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAA;QAC5E,CAAC;QAED,OAAO,IAAI,EAAE,CAAA;IACf,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"platforms.d.ts","sourceRoot":"","sources":["../src/platforms.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAA;AA+N1C;;;GAGG;AACH,wBAAsB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC,CAc3E"}
|
package/lib/platforms.js
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// ── 工具函数 ──────────────────────────────────────────────────────────────────
|
|
2
|
+
const FETCH_TIMEOUT = 10_000;
|
|
3
|
+
async function fetchWithTimeout(url, init) {
|
|
4
|
+
const controller = new AbortController();
|
|
5
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
|
|
6
|
+
try {
|
|
7
|
+
return await fetch(url, { ...init, signal: controller.signal });
|
|
8
|
+
}
|
|
9
|
+
finally {
|
|
10
|
+
clearTimeout(timer);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/** 将远程图片下载并转为 data URI,避免 satori 渲染时遇到防盗链/CORS */
|
|
14
|
+
async function imageToDataUri(url) {
|
|
15
|
+
if (!url)
|
|
16
|
+
return undefined;
|
|
17
|
+
try {
|
|
18
|
+
if (url.startsWith('//'))
|
|
19
|
+
url = 'https:' + url;
|
|
20
|
+
if (url.startsWith('http:'))
|
|
21
|
+
url = url.replace('http:', 'https:');
|
|
22
|
+
const res = await fetchWithTimeout(url, {
|
|
23
|
+
headers: { Referer: new URL(url).origin, 'User-Agent': 'Mozilla/5.0' },
|
|
24
|
+
});
|
|
25
|
+
if (!res.ok)
|
|
26
|
+
return undefined;
|
|
27
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
28
|
+
const mime = res.headers.get('content-type') || 'image/jpeg';
|
|
29
|
+
return `data:${mime};base64,${buf.toString('base64')}`;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function formatNumber(n) {
|
|
36
|
+
if (n == null)
|
|
37
|
+
return '-';
|
|
38
|
+
if (n >= 100_000_000)
|
|
39
|
+
return (n / 100_000_000).toFixed(1) + '亿';
|
|
40
|
+
if (n >= 10_000)
|
|
41
|
+
return (n / 10_000).toFixed(1) + '万';
|
|
42
|
+
return String(n);
|
|
43
|
+
}
|
|
44
|
+
function extractMeta(html, property) {
|
|
45
|
+
// 匹配 <meta property="og:title" content="..."> 或 <meta name="..." content="...">
|
|
46
|
+
const re = new RegExp(`<meta\\s+(?:property|name)=["']${property}["']\\s+content=["']([^"']*?)["']`, 'i');
|
|
47
|
+
const m = html.match(re);
|
|
48
|
+
if (m)
|
|
49
|
+
return m[1];
|
|
50
|
+
// 反转属性顺序:content 在前
|
|
51
|
+
const re2 = new RegExp(`<meta\\s+content=["']([^"']*?)["']\\s+(?:property|name)=["']${property}["']`, 'i');
|
|
52
|
+
return html.match(re2)?.[1];
|
|
53
|
+
}
|
|
54
|
+
// ── Bilibili ──────────────────────────────────────────────────────────────────
|
|
55
|
+
const BILIBILI_RE = /(?:bilibili\.com\/video\/|b23\.tv\/)/i;
|
|
56
|
+
async function parseBilibili(url) {
|
|
57
|
+
// 短链跳转
|
|
58
|
+
if (url.includes('b23.tv')) {
|
|
59
|
+
const res = await fetchWithTimeout(url, { redirect: 'follow' });
|
|
60
|
+
url = res.url;
|
|
61
|
+
}
|
|
62
|
+
const bvMatch = url.match(/BV[\w]+/);
|
|
63
|
+
if (!bvMatch)
|
|
64
|
+
return null;
|
|
65
|
+
const api = `https://api.bilibili.com/x/web-interface/view?bvid=${bvMatch[0]}`;
|
|
66
|
+
const res = await fetchWithTimeout(api, {
|
|
67
|
+
headers: { 'User-Agent': 'Mozilla/5.0', Referer: 'https://www.bilibili.com/' },
|
|
68
|
+
});
|
|
69
|
+
const json = await res.json();
|
|
70
|
+
if (json.code !== 0)
|
|
71
|
+
return null;
|
|
72
|
+
const d = json.data;
|
|
73
|
+
return {
|
|
74
|
+
platform: 'bilibili',
|
|
75
|
+
title: d.title,
|
|
76
|
+
description: d.desc || undefined,
|
|
77
|
+
author: d.owner?.name,
|
|
78
|
+
authorAvatar: await imageToDataUri(d.owner?.face),
|
|
79
|
+
cover: await imageToDataUri(d.pic),
|
|
80
|
+
url: `https://www.bilibili.com/video/${bvMatch[0]}`,
|
|
81
|
+
stats: {
|
|
82
|
+
'播放': formatNumber(d.stat?.view),
|
|
83
|
+
'弹幕': formatNumber(d.stat?.danmaku),
|
|
84
|
+
'点赞': formatNumber(d.stat?.like),
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
// ── GitHub ────────────────────────────────────────────────────────────────────
|
|
89
|
+
const GITHUB_RE = /github\.com\/([\w.-]+)\/([\w.-]+)/i;
|
|
90
|
+
async function parseGithub(url) {
|
|
91
|
+
const repoMatch = url.match(GITHUB_RE);
|
|
92
|
+
if (!repoMatch)
|
|
93
|
+
return null;
|
|
94
|
+
const [, owner, repo] = repoMatch;
|
|
95
|
+
const headers = { 'User-Agent': 'zhin-bot', Accept: 'application/vnd.github+json' };
|
|
96
|
+
// Issue 或 PR
|
|
97
|
+
const issueMatch = url.match(/\/(issues|pull)\/(\d+)/);
|
|
98
|
+
if (issueMatch) {
|
|
99
|
+
const [, type, num] = issueMatch;
|
|
100
|
+
const apiType = type === 'pull' ? 'pulls' : 'issues';
|
|
101
|
+
const res = await fetchWithTimeout(`https://api.github.com/repos/${owner}/${repo}/${apiType}/${num}`, { headers });
|
|
102
|
+
if (!res.ok)
|
|
103
|
+
return null;
|
|
104
|
+
const d = await res.json();
|
|
105
|
+
return {
|
|
106
|
+
platform: 'github',
|
|
107
|
+
title: `${type === 'pull' ? 'PR' : 'Issue'} #${num}: ${d.title}`,
|
|
108
|
+
description: d.body?.slice(0, 200) || undefined,
|
|
109
|
+
author: d.user?.login,
|
|
110
|
+
authorAvatar: await imageToDataUri(d.user?.avatar_url),
|
|
111
|
+
url,
|
|
112
|
+
stats: {
|
|
113
|
+
'状态': d.state === 'open' ? '🟢 Open' : '🟣 Closed',
|
|
114
|
+
'评论': String(d.comments ?? 0),
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
// 仓库
|
|
119
|
+
const res = await fetchWithTimeout(`https://api.github.com/repos/${owner}/${repo}`, { headers });
|
|
120
|
+
if (!res.ok)
|
|
121
|
+
return null;
|
|
122
|
+
const d = await res.json();
|
|
123
|
+
return {
|
|
124
|
+
platform: 'github',
|
|
125
|
+
title: d.full_name || `${owner}/${repo}`,
|
|
126
|
+
description: d.description || undefined,
|
|
127
|
+
author: d.owner?.login,
|
|
128
|
+
authorAvatar: await imageToDataUri(d.owner?.avatar_url),
|
|
129
|
+
cover: await imageToDataUri(`https://opengraph.githubassets.com/1/${owner}/${repo}`),
|
|
130
|
+
url,
|
|
131
|
+
stats: {
|
|
132
|
+
'Star': formatNumber(d.stargazers_count),
|
|
133
|
+
'Fork': formatNumber(d.forks_count),
|
|
134
|
+
'语言': d.language || '-',
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
// ── 抖音 ──────────────────────────────────────────────────────────────────────
|
|
139
|
+
const DOUYIN_RE = /(?:douyin\.com\/video\/|v\.douyin\.com\/)/i;
|
|
140
|
+
async function parseDouyin(url) {
|
|
141
|
+
try {
|
|
142
|
+
const res = await fetchWithTimeout(url, {
|
|
143
|
+
headers: {
|
|
144
|
+
'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1',
|
|
145
|
+
},
|
|
146
|
+
redirect: 'follow',
|
|
147
|
+
});
|
|
148
|
+
const html = await res.text();
|
|
149
|
+
const title = extractMeta(html, 'og:title');
|
|
150
|
+
if (!title)
|
|
151
|
+
return null;
|
|
152
|
+
return {
|
|
153
|
+
platform: 'douyin',
|
|
154
|
+
title,
|
|
155
|
+
description: extractMeta(html, 'og:description'),
|
|
156
|
+
cover: await imageToDataUri(extractMeta(html, 'og:image')),
|
|
157
|
+
url: res.url || url,
|
|
158
|
+
author: extractMeta(html, 'og:site_name') || '抖音',
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
// ── 小红书 ────────────────────────────────────────────────────────────────────
|
|
166
|
+
const XIAOHONGSHU_RE = /(?:xiaohongshu\.com\/(?:explore|discovery)|xhslink\.com\/)/i;
|
|
167
|
+
async function parseXiaohongshu(url) {
|
|
168
|
+
try {
|
|
169
|
+
const res = await fetchWithTimeout(url, {
|
|
170
|
+
headers: {
|
|
171
|
+
'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1',
|
|
172
|
+
},
|
|
173
|
+
redirect: 'follow',
|
|
174
|
+
});
|
|
175
|
+
const html = await res.text();
|
|
176
|
+
const title = extractMeta(html, 'og:title');
|
|
177
|
+
if (!title)
|
|
178
|
+
return null;
|
|
179
|
+
return {
|
|
180
|
+
platform: 'xiaohongshu',
|
|
181
|
+
title,
|
|
182
|
+
description: extractMeta(html, 'og:description'),
|
|
183
|
+
cover: await imageToDataUri(extractMeta(html, 'og:image')),
|
|
184
|
+
url: res.url || url,
|
|
185
|
+
author: extractMeta(html, 'author') || extractMeta(html, 'og:site_name') || '小红书',
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const PARSERS = [
|
|
193
|
+
{ test: BILIBILI_RE, parse: parseBilibili },
|
|
194
|
+
{ test: GITHUB_RE, parse: parseGithub },
|
|
195
|
+
{ test: DOUYIN_RE, parse: parseDouyin },
|
|
196
|
+
{ test: XIAOHONGSHU_RE, parse: parseXiaohongshu },
|
|
197
|
+
];
|
|
198
|
+
/**
|
|
199
|
+
* 从消息文本中检测第一个匹配的平台链接并解析元数据。
|
|
200
|
+
* 仅返回第一个命中结果,避免单条消息产生过多海报。
|
|
201
|
+
*/
|
|
202
|
+
export async function detectAndParse(text) {
|
|
203
|
+
// 提取所有 URL
|
|
204
|
+
const urls = text.match(/https?:\/\/[^\s<>)"']+/gi);
|
|
205
|
+
if (!urls)
|
|
206
|
+
return null;
|
|
207
|
+
for (const url of urls) {
|
|
208
|
+
for (const { test, parse } of PARSERS) {
|
|
209
|
+
if (test.test(url)) {
|
|
210
|
+
const meta = await parse(url);
|
|
211
|
+
if (meta)
|
|
212
|
+
return meta;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
//# sourceMappingURL=platforms.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"platforms.js","sourceRoot":"","sources":["../src/platforms.ts"],"names":[],"mappings":"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"}
|
package/lib/render.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"render.d.ts","sourceRoot":"","sources":["../src/render.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAiB,MAAM,YAAY,CAAA;AAoCzD;;GAEG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,QAAQ,GAAG,MAAM,CA+DnD"}
|
package/lib/render.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
const THEMES = {
|
|
2
|
+
bilibili: { name: 'Bilibili', color: '#00A1D6', bg: '#E3F6FC' },
|
|
3
|
+
github: { name: 'GitHub', color: '#24292e', bg: '#F0F0F0' },
|
|
4
|
+
douyin: { name: '抖音', color: '#FE2C55', bg: '#FFF0F3' },
|
|
5
|
+
xiaohongshu: { name: '小红书', color: '#FF2442', bg: '#FFF0F2' },
|
|
6
|
+
};
|
|
7
|
+
function escapeHtml(s) {
|
|
8
|
+
return s
|
|
9
|
+
.replace(/&/g, '&')
|
|
10
|
+
.replace(/</g, '<')
|
|
11
|
+
.replace(/>/g, '>')
|
|
12
|
+
.replace(/"/g, '"');
|
|
13
|
+
}
|
|
14
|
+
function truncate(s, max) {
|
|
15
|
+
if (!s)
|
|
16
|
+
return '';
|
|
17
|
+
return s.length > max ? s.slice(0, max) + '…' : s;
|
|
18
|
+
}
|
|
19
|
+
function renderStats(stats, color) {
|
|
20
|
+
if (!stats)
|
|
21
|
+
return '';
|
|
22
|
+
const items = Object.entries(stats)
|
|
23
|
+
.map(([k, v]) => `<span style="display:flex;align-items:center;margin-right:16px;font-size:12px;color:#888;">`
|
|
24
|
+
+ `<span style="color:${color};font-weight:600;margin-right:4px;">${escapeHtml(k)}</span>`
|
|
25
|
+
+ `<span>${escapeHtml(v)}</span>`
|
|
26
|
+
+ `</span>`)
|
|
27
|
+
.join('');
|
|
28
|
+
return `<div style="display:flex;flex-wrap:wrap;margin-top:12px;">${items}</div>`;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* 生成 satori 兼容的海报 HTML
|
|
32
|
+
*/
|
|
33
|
+
export function renderPoster(meta) {
|
|
34
|
+
const theme = THEMES[meta.platform];
|
|
35
|
+
// ── 封面区域 ──
|
|
36
|
+
const coverHtml = meta.cover
|
|
37
|
+
? `<img src="${meta.cover}" style="width:480px;height:252px;object-fit:cover;" />`
|
|
38
|
+
: `<div style="display:flex;align-items:center;justify-content:center;width:480px;height:80px;background:${theme.bg};">`
|
|
39
|
+
+ `<span style="font-size:28px;font-weight:700;color:${theme.color};">${escapeHtml(theme.name)}</span>`
|
|
40
|
+
+ `</div>`;
|
|
41
|
+
// ── 头像 + 作者 ──
|
|
42
|
+
const avatarHtml = meta.authorAvatar
|
|
43
|
+
? `<img src="${meta.authorAvatar}" style="width:28px;height:28px;border-radius:14px;margin-right:8px;" />`
|
|
44
|
+
: '';
|
|
45
|
+
const authorHtml = meta.author
|
|
46
|
+
? `<div style="display:flex;align-items:center;margin-top:12px;">`
|
|
47
|
+
+ avatarHtml
|
|
48
|
+
+ `<span style="font-size:13px;color:#666;">${escapeHtml(meta.author)}</span>`
|
|
49
|
+
+ `</div>`
|
|
50
|
+
: '';
|
|
51
|
+
// ── 描述 ──
|
|
52
|
+
const descHtml = meta.description
|
|
53
|
+
? `<span style="font-size:13px;color:#888;margin-top:8px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">`
|
|
54
|
+
+ `${escapeHtml(truncate(meta.description, 80))}`
|
|
55
|
+
+ `</span>`
|
|
56
|
+
: '';
|
|
57
|
+
// ── 统计 ──
|
|
58
|
+
const statsHtml = renderStats(meta.stats, theme.color);
|
|
59
|
+
// ── 链接 ──
|
|
60
|
+
const displayUrl = truncate(meta.url.replace(/^https?:\/\//, ''), 60);
|
|
61
|
+
return `<div style="display:flex;flex-direction:column;width:480px;background:#ffffff;border-radius:16px;overflow:hidden;font-family:Noto Sans SC,sans-serif;box-shadow:0 2px 12px rgba(0,0,0,0.08);">`
|
|
62
|
+
// 封面
|
|
63
|
+
+ coverHtml
|
|
64
|
+
// 内容区
|
|
65
|
+
+ `<div style="display:flex;flex-direction:column;padding:18px 20px 16px;">`
|
|
66
|
+
// 平台标签
|
|
67
|
+
+ `<div style="display:flex;align-items:center;margin-bottom:10px;">`
|
|
68
|
+
+ `<span style="display:flex;align-items:center;background:${theme.color};color:#ffffff;padding:3px 10px;border-radius:4px;font-size:11px;font-weight:700;letter-spacing:0.5px;">`
|
|
69
|
+
+ escapeHtml(theme.name)
|
|
70
|
+
+ `</span>`
|
|
71
|
+
+ `</div>`
|
|
72
|
+
// 标题
|
|
73
|
+
+ `<span style="font-size:18px;font-weight:700;color:#1a1a1a;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">`
|
|
74
|
+
+ escapeHtml(truncate(meta.title, 50))
|
|
75
|
+
+ `</span>`
|
|
76
|
+
// 描述
|
|
77
|
+
+ descHtml
|
|
78
|
+
// 作者
|
|
79
|
+
+ authorHtml
|
|
80
|
+
// 统计
|
|
81
|
+
+ statsHtml
|
|
82
|
+
// 底部链接
|
|
83
|
+
+ `<div style="display:flex;margin-top:14px;padding-top:12px;border-top:1px solid #f0f0f0;">`
|
|
84
|
+
+ `<span style="font-size:11px;color:#bbb;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">`
|
|
85
|
+
+ escapeHtml(displayUrl)
|
|
86
|
+
+ `</span>`
|
|
87
|
+
+ `</div>`
|
|
88
|
+
+ `</div>`
|
|
89
|
+
+ `</div>`;
|
|
90
|
+
}
|
|
91
|
+
//# sourceMappingURL=render.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"render.js","sourceRoot":"","sources":["../src/render.ts"],"names":[],"mappings":"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"}
|
package/lib/types.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** 链接解析结果 */
|
|
2
|
+
export interface LinkMeta {
|
|
3
|
+
platform: 'bilibili' | 'github' | 'douyin' | 'xiaohongshu';
|
|
4
|
+
title: string;
|
|
5
|
+
description?: string;
|
|
6
|
+
author?: string;
|
|
7
|
+
authorAvatar?: string;
|
|
8
|
+
/** 封面图(已转为 data URI) */
|
|
9
|
+
cover?: string;
|
|
10
|
+
url: string;
|
|
11
|
+
stats?: Record<string, string>;
|
|
12
|
+
}
|
|
13
|
+
/** 平台视觉主题 */
|
|
14
|
+
export interface PlatformTheme {
|
|
15
|
+
name: string;
|
|
16
|
+
color: string;
|
|
17
|
+
bg: string;
|
|
18
|
+
}
|
|
19
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,aAAa;AACb,MAAM,WAAW,QAAQ;IACvB,QAAQ,EAAE,UAAU,GAAG,QAAQ,GAAG,QAAQ,GAAG,aAAa,CAAA;IAC1D,KAAK,EAAE,MAAM,CAAA;IACb,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,wBAAwB;IACxB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAC/B;AAED,aAAa;AACb,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,MAAM,CAAA;IACb,EAAE,EAAE,MAAM,CAAA;CACX"}
|
package/lib/types.js
ADDED
package/lib/types.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zhin.js/plugin-link-poster",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Auto-detect Bilibili/GitHub/Douyin/Xiaohongshu links and generate beautiful poster cards",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./lib/index.js",
|
|
7
|
+
"types": "./lib/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./lib/index.d.ts",
|
|
11
|
+
"development": "./src/index.ts",
|
|
12
|
+
"import": "./lib/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./package.json": "./package.json"
|
|
15
|
+
},
|
|
16
|
+
"files": ["src", "lib", "README.md"],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsc --build",
|
|
19
|
+
"clean": "rimraf lib"
|
|
20
|
+
},
|
|
21
|
+
"keywords": ["zhin", "zhin.js", "bot", "plugin", "link-poster", "bilibili", "github", "douyin", "xiaohongshu"],
|
|
22
|
+
"author": { "name": "lc-cn", "email": "admin@liucl.cn", "url": "https://github.com/lc-cn" },
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"dependencies": {},
|
|
25
|
+
"devDependencies": { "typescript": "^5.9.3", "zhin.js": "workspace:*" },
|
|
26
|
+
"peerDependencies": { "zhin.js": ">=1.0.60" },
|
|
27
|
+
"repository": { "type": "git", "url": "git+https://github.com/zhinjs/zhin.git", "directory": "plugins/utils/link-poster" },
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public",
|
|
30
|
+
"registry": "https://registry.npmjs.org"
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zhin.js/plugin-link-poster
|
|
3
|
+
*
|
|
4
|
+
* 自动检测消息中的 Bilibili / GitHub / 抖音 / 小红书链接,
|
|
5
|
+
* 生成精美内容海报图片并回复。
|
|
6
|
+
*
|
|
7
|
+
* 依赖:@zhin.js/plugin-html-renderer(提供 html-renderer Context)
|
|
8
|
+
*/
|
|
9
|
+
import { usePlugin, segment } from 'zhin.js'
|
|
10
|
+
import { detectAndParse } from './platforms.js'
|
|
11
|
+
import { renderPoster } from './render.js'
|
|
12
|
+
|
|
13
|
+
// html-renderer Context 的最小类型声明(运行时由 html-renderer 插件注入)
|
|
14
|
+
declare module 'zhin.js' {
|
|
15
|
+
namespace Plugin {
|
|
16
|
+
interface Contexts {
|
|
17
|
+
'html-renderer': {
|
|
18
|
+
render(html: string, options?: { width?: number }): Promise<{
|
|
19
|
+
data: Buffer | string
|
|
20
|
+
format: string
|
|
21
|
+
mimeType: string
|
|
22
|
+
}>
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const { addMiddleware, useContext, logger } = usePlugin()
|
|
29
|
+
|
|
30
|
+
useContext('html-renderer', (renderer) => {
|
|
31
|
+
logger.info('link-poster: html-renderer 就绪,已启用链接海报')
|
|
32
|
+
|
|
33
|
+
addMiddleware(async (message, next) => {
|
|
34
|
+
const text = message.$raw || ''
|
|
35
|
+
|
|
36
|
+
// 快速跳过:消息中没有 http 链接
|
|
37
|
+
if (!text.includes('http')) return next()
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const meta = await detectAndParse(text)
|
|
41
|
+
if (meta) {
|
|
42
|
+
const html = renderPoster(meta)
|
|
43
|
+
const result = await renderer.render(html, { width: 480 })
|
|
44
|
+
|
|
45
|
+
if (result.format === 'png' && typeof result.data === 'object') {
|
|
46
|
+
const base64 = Buffer.from(result.data as Buffer).toString('base64')
|
|
47
|
+
const dataUrl = `data:${result.mimeType};base64,${base64}`
|
|
48
|
+
await message.$reply(segment('image', { url: dataUrl }))
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
} catch (e) {
|
|
52
|
+
logger.warn(`link-poster: 海报生成失败 - ${e instanceof Error ? e.message : e}`)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return next()
|
|
56
|
+
})
|
|
57
|
+
})
|
package/src/platforms.ts
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import type { LinkMeta } from './types.js'
|
|
2
|
+
|
|
3
|
+
// ── 工具函数 ──────────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
const FETCH_TIMEOUT = 10_000
|
|
6
|
+
|
|
7
|
+
async function fetchWithTimeout(url: string, init?: RequestInit): Promise<Response> {
|
|
8
|
+
const controller = new AbortController()
|
|
9
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT)
|
|
10
|
+
try {
|
|
11
|
+
return await fetch(url, { ...init, signal: controller.signal })
|
|
12
|
+
} finally {
|
|
13
|
+
clearTimeout(timer)
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** 将远程图片下载并转为 data URI,避免 satori 渲染时遇到防盗链/CORS */
|
|
18
|
+
async function imageToDataUri(url: string | undefined): Promise<string | undefined> {
|
|
19
|
+
if (!url) return undefined
|
|
20
|
+
try {
|
|
21
|
+
if (url.startsWith('//')) url = 'https:' + url
|
|
22
|
+
if (url.startsWith('http:')) url = url.replace('http:', 'https:')
|
|
23
|
+
const res = await fetchWithTimeout(url, {
|
|
24
|
+
headers: { Referer: new URL(url).origin, 'User-Agent': 'Mozilla/5.0' },
|
|
25
|
+
})
|
|
26
|
+
if (!res.ok) return undefined
|
|
27
|
+
const buf = Buffer.from(await res.arrayBuffer())
|
|
28
|
+
const mime = res.headers.get('content-type') || 'image/jpeg'
|
|
29
|
+
return `data:${mime};base64,${buf.toString('base64')}`
|
|
30
|
+
} catch {
|
|
31
|
+
return undefined
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function formatNumber(n: number | undefined): string {
|
|
36
|
+
if (n == null) return '-'
|
|
37
|
+
if (n >= 100_000_000) return (n / 100_000_000).toFixed(1) + '亿'
|
|
38
|
+
if (n >= 10_000) return (n / 10_000).toFixed(1) + '万'
|
|
39
|
+
return String(n)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function extractMeta(html: string, property: string): string | undefined {
|
|
43
|
+
// 匹配 <meta property="og:title" content="..."> 或 <meta name="..." content="...">
|
|
44
|
+
const re = new RegExp(
|
|
45
|
+
`<meta\\s+(?:property|name)=["']${property}["']\\s+content=["']([^"']*?)["']`,
|
|
46
|
+
'i',
|
|
47
|
+
)
|
|
48
|
+
const m = html.match(re)
|
|
49
|
+
if (m) return m[1]
|
|
50
|
+
// 反转属性顺序:content 在前
|
|
51
|
+
const re2 = new RegExp(
|
|
52
|
+
`<meta\\s+content=["']([^"']*?)["']\\s+(?:property|name)=["']${property}["']`,
|
|
53
|
+
'i',
|
|
54
|
+
)
|
|
55
|
+
return html.match(re2)?.[1]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── Bilibili ──────────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
const BILIBILI_RE = /(?:bilibili\.com\/video\/|b23\.tv\/)/i
|
|
61
|
+
|
|
62
|
+
async function parseBilibili(url: string): Promise<LinkMeta | null> {
|
|
63
|
+
// 短链跳转
|
|
64
|
+
if (url.includes('b23.tv')) {
|
|
65
|
+
const res = await fetchWithTimeout(url, { redirect: 'follow' })
|
|
66
|
+
url = res.url
|
|
67
|
+
}
|
|
68
|
+
const bvMatch = url.match(/BV[\w]+/)
|
|
69
|
+
if (!bvMatch) return null
|
|
70
|
+
|
|
71
|
+
const api = `https://api.bilibili.com/x/web-interface/view?bvid=${bvMatch[0]}`
|
|
72
|
+
const res = await fetchWithTimeout(api, {
|
|
73
|
+
headers: { 'User-Agent': 'Mozilla/5.0', Referer: 'https://www.bilibili.com/' },
|
|
74
|
+
})
|
|
75
|
+
const json = await res.json() as any
|
|
76
|
+
if (json.code !== 0) return null
|
|
77
|
+
|
|
78
|
+
const d = json.data
|
|
79
|
+
return {
|
|
80
|
+
platform: 'bilibili',
|
|
81
|
+
title: d.title,
|
|
82
|
+
description: d.desc || undefined,
|
|
83
|
+
author: d.owner?.name,
|
|
84
|
+
authorAvatar: await imageToDataUri(d.owner?.face),
|
|
85
|
+
cover: await imageToDataUri(d.pic),
|
|
86
|
+
url: `https://www.bilibili.com/video/${bvMatch[0]}`,
|
|
87
|
+
stats: {
|
|
88
|
+
'播放': formatNumber(d.stat?.view),
|
|
89
|
+
'弹幕': formatNumber(d.stat?.danmaku),
|
|
90
|
+
'点赞': formatNumber(d.stat?.like),
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── GitHub ────────────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
const GITHUB_RE = /github\.com\/([\w.-]+)\/([\w.-]+)/i
|
|
98
|
+
|
|
99
|
+
async function parseGithub(url: string): Promise<LinkMeta | null> {
|
|
100
|
+
const repoMatch = url.match(GITHUB_RE)
|
|
101
|
+
if (!repoMatch) return null
|
|
102
|
+
const [, owner, repo] = repoMatch
|
|
103
|
+
const headers = { 'User-Agent': 'zhin-bot', Accept: 'application/vnd.github+json' }
|
|
104
|
+
|
|
105
|
+
// Issue 或 PR
|
|
106
|
+
const issueMatch = url.match(/\/(issues|pull)\/(\d+)/)
|
|
107
|
+
if (issueMatch) {
|
|
108
|
+
const [, type, num] = issueMatch
|
|
109
|
+
const apiType = type === 'pull' ? 'pulls' : 'issues'
|
|
110
|
+
const res = await fetchWithTimeout(
|
|
111
|
+
`https://api.github.com/repos/${owner}/${repo}/${apiType}/${num}`,
|
|
112
|
+
{ headers },
|
|
113
|
+
)
|
|
114
|
+
if (!res.ok) return null
|
|
115
|
+
const d = await res.json() as any
|
|
116
|
+
return {
|
|
117
|
+
platform: 'github',
|
|
118
|
+
title: `${type === 'pull' ? 'PR' : 'Issue'} #${num}: ${d.title}`,
|
|
119
|
+
description: d.body?.slice(0, 200) || undefined,
|
|
120
|
+
author: d.user?.login,
|
|
121
|
+
authorAvatar: await imageToDataUri(d.user?.avatar_url),
|
|
122
|
+
url,
|
|
123
|
+
stats: {
|
|
124
|
+
'状态': d.state === 'open' ? '🟢 Open' : '🟣 Closed',
|
|
125
|
+
'评论': String(d.comments ?? 0),
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 仓库
|
|
131
|
+
const res = await fetchWithTimeout(`https://api.github.com/repos/${owner}/${repo}`, { headers })
|
|
132
|
+
if (!res.ok) return null
|
|
133
|
+
const d = await res.json() as any
|
|
134
|
+
return {
|
|
135
|
+
platform: 'github',
|
|
136
|
+
title: d.full_name || `${owner}/${repo}`,
|
|
137
|
+
description: d.description || undefined,
|
|
138
|
+
author: d.owner?.login,
|
|
139
|
+
authorAvatar: await imageToDataUri(d.owner?.avatar_url),
|
|
140
|
+
cover: await imageToDataUri(`https://opengraph.githubassets.com/1/${owner}/${repo}`),
|
|
141
|
+
url,
|
|
142
|
+
stats: {
|
|
143
|
+
'Star': formatNumber(d.stargazers_count),
|
|
144
|
+
'Fork': formatNumber(d.forks_count),
|
|
145
|
+
'语言': d.language || '-',
|
|
146
|
+
},
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── 抖音 ──────────────────────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
const DOUYIN_RE = /(?:douyin\.com\/video\/|v\.douyin\.com\/)/i
|
|
153
|
+
|
|
154
|
+
async function parseDouyin(url: string): Promise<LinkMeta | null> {
|
|
155
|
+
try {
|
|
156
|
+
const res = await fetchWithTimeout(url, {
|
|
157
|
+
headers: {
|
|
158
|
+
'User-Agent':
|
|
159
|
+
'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1',
|
|
160
|
+
},
|
|
161
|
+
redirect: 'follow',
|
|
162
|
+
})
|
|
163
|
+
const html = await res.text()
|
|
164
|
+
const title = extractMeta(html, 'og:title')
|
|
165
|
+
if (!title) return null
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
platform: 'douyin',
|
|
169
|
+
title,
|
|
170
|
+
description: extractMeta(html, 'og:description'),
|
|
171
|
+
cover: await imageToDataUri(extractMeta(html, 'og:image')),
|
|
172
|
+
url: res.url || url,
|
|
173
|
+
author: extractMeta(html, 'og:site_name') || '抖音',
|
|
174
|
+
}
|
|
175
|
+
} catch {
|
|
176
|
+
return null
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ── 小红书 ────────────────────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
const XIAOHONGSHU_RE = /(?:xiaohongshu\.com\/(?:explore|discovery)|xhslink\.com\/)/i
|
|
183
|
+
|
|
184
|
+
async function parseXiaohongshu(url: string): Promise<LinkMeta | null> {
|
|
185
|
+
try {
|
|
186
|
+
const res = await fetchWithTimeout(url, {
|
|
187
|
+
headers: {
|
|
188
|
+
'User-Agent':
|
|
189
|
+
'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1',
|
|
190
|
+
},
|
|
191
|
+
redirect: 'follow',
|
|
192
|
+
})
|
|
193
|
+
const html = await res.text()
|
|
194
|
+
const title = extractMeta(html, 'og:title')
|
|
195
|
+
if (!title) return null
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
platform: 'xiaohongshu',
|
|
199
|
+
title,
|
|
200
|
+
description: extractMeta(html, 'og:description'),
|
|
201
|
+
cover: await imageToDataUri(extractMeta(html, 'og:image')),
|
|
202
|
+
url: res.url || url,
|
|
203
|
+
author: extractMeta(html, 'author') || extractMeta(html, 'og:site_name') || '小红书',
|
|
204
|
+
}
|
|
205
|
+
} catch {
|
|
206
|
+
return null
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── 统一检测 ──────────────────────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
interface PlatformParser {
|
|
213
|
+
test: RegExp
|
|
214
|
+
parse: (url: string) => Promise<LinkMeta | null>
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const PARSERS: PlatformParser[] = [
|
|
218
|
+
{ test: BILIBILI_RE, parse: parseBilibili },
|
|
219
|
+
{ test: GITHUB_RE, parse: parseGithub },
|
|
220
|
+
{ test: DOUYIN_RE, parse: parseDouyin },
|
|
221
|
+
{ test: XIAOHONGSHU_RE, parse: parseXiaohongshu },
|
|
222
|
+
]
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* 从消息文本中检测第一个匹配的平台链接并解析元数据。
|
|
226
|
+
* 仅返回第一个命中结果,避免单条消息产生过多海报。
|
|
227
|
+
*/
|
|
228
|
+
export async function detectAndParse(text: string): Promise<LinkMeta | null> {
|
|
229
|
+
// 提取所有 URL
|
|
230
|
+
const urls = text.match(/https?:\/\/[^\s<>)"']+/gi)
|
|
231
|
+
if (!urls) return null
|
|
232
|
+
|
|
233
|
+
for (const url of urls) {
|
|
234
|
+
for (const { test, parse } of PARSERS) {
|
|
235
|
+
if (test.test(url)) {
|
|
236
|
+
const meta = await parse(url)
|
|
237
|
+
if (meta) return meta
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return null
|
|
242
|
+
}
|
package/src/render.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { LinkMeta, PlatformTheme } from './types.js'
|
|
2
|
+
|
|
3
|
+
const THEMES: Record<LinkMeta['platform'], PlatformTheme> = {
|
|
4
|
+
bilibili: { name: 'Bilibili', color: '#00A1D6', bg: '#E3F6FC' },
|
|
5
|
+
github: { name: 'GitHub', color: '#24292e', bg: '#F0F0F0' },
|
|
6
|
+
douyin: { name: '抖音', color: '#FE2C55', bg: '#FFF0F3' },
|
|
7
|
+
xiaohongshu: { name: '小红书', color: '#FF2442', bg: '#FFF0F2' },
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function escapeHtml(s: string): string {
|
|
11
|
+
return s
|
|
12
|
+
.replace(/&/g, '&')
|
|
13
|
+
.replace(/</g, '<')
|
|
14
|
+
.replace(/>/g, '>')
|
|
15
|
+
.replace(/"/g, '"')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function truncate(s: string | undefined, max: number): string {
|
|
19
|
+
if (!s) return ''
|
|
20
|
+
return s.length > max ? s.slice(0, max) + '…' : s
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function renderStats(stats: Record<string, string> | undefined, color: string): string {
|
|
24
|
+
if (!stats) return ''
|
|
25
|
+
const items = Object.entries(stats)
|
|
26
|
+
.map(
|
|
27
|
+
([k, v]) =>
|
|
28
|
+
`<span style="display:flex;align-items:center;margin-right:16px;font-size:12px;color:#888;">`
|
|
29
|
+
+ `<span style="color:${color};font-weight:600;margin-right:4px;">${escapeHtml(k)}</span>`
|
|
30
|
+
+ `<span>${escapeHtml(v)}</span>`
|
|
31
|
+
+ `</span>`,
|
|
32
|
+
)
|
|
33
|
+
.join('')
|
|
34
|
+
return `<div style="display:flex;flex-wrap:wrap;margin-top:12px;">${items}</div>`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 生成 satori 兼容的海报 HTML
|
|
39
|
+
*/
|
|
40
|
+
export function renderPoster(meta: LinkMeta): string {
|
|
41
|
+
const theme = THEMES[meta.platform]
|
|
42
|
+
|
|
43
|
+
// ── 封面区域 ──
|
|
44
|
+
const coverHtml = meta.cover
|
|
45
|
+
? `<img src="${meta.cover}" style="width:480px;height:252px;object-fit:cover;" />`
|
|
46
|
+
: `<div style="display:flex;align-items:center;justify-content:center;width:480px;height:80px;background:${theme.bg};">`
|
|
47
|
+
+ `<span style="font-size:28px;font-weight:700;color:${theme.color};">${escapeHtml(theme.name)}</span>`
|
|
48
|
+
+ `</div>`
|
|
49
|
+
|
|
50
|
+
// ── 头像 + 作者 ──
|
|
51
|
+
const avatarHtml = meta.authorAvatar
|
|
52
|
+
? `<img src="${meta.authorAvatar}" style="width:28px;height:28px;border-radius:14px;margin-right:8px;" />`
|
|
53
|
+
: ''
|
|
54
|
+
const authorHtml = meta.author
|
|
55
|
+
? `<div style="display:flex;align-items:center;margin-top:12px;">`
|
|
56
|
+
+ avatarHtml
|
|
57
|
+
+ `<span style="font-size:13px;color:#666;">${escapeHtml(meta.author)}</span>`
|
|
58
|
+
+ `</div>`
|
|
59
|
+
: ''
|
|
60
|
+
|
|
61
|
+
// ── 描述 ──
|
|
62
|
+
const descHtml = meta.description
|
|
63
|
+
? `<span style="font-size:13px;color:#888;margin-top:8px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">`
|
|
64
|
+
+ `${escapeHtml(truncate(meta.description, 80))}`
|
|
65
|
+
+ `</span>`
|
|
66
|
+
: ''
|
|
67
|
+
|
|
68
|
+
// ── 统计 ──
|
|
69
|
+
const statsHtml = renderStats(meta.stats, theme.color)
|
|
70
|
+
|
|
71
|
+
// ── 链接 ──
|
|
72
|
+
const displayUrl = truncate(meta.url.replace(/^https?:\/\//, ''), 60)
|
|
73
|
+
|
|
74
|
+
return `<div style="display:flex;flex-direction:column;width:480px;background:#ffffff;border-radius:16px;overflow:hidden;font-family:Noto Sans SC,sans-serif;box-shadow:0 2px 12px rgba(0,0,0,0.08);">`
|
|
75
|
+
// 封面
|
|
76
|
+
+ coverHtml
|
|
77
|
+
// 内容区
|
|
78
|
+
+ `<div style="display:flex;flex-direction:column;padding:18px 20px 16px;">`
|
|
79
|
+
// 平台标签
|
|
80
|
+
+ `<div style="display:flex;align-items:center;margin-bottom:10px;">`
|
|
81
|
+
+ `<span style="display:flex;align-items:center;background:${theme.color};color:#ffffff;padding:3px 10px;border-radius:4px;font-size:11px;font-weight:700;letter-spacing:0.5px;">`
|
|
82
|
+
+ escapeHtml(theme.name)
|
|
83
|
+
+ `</span>`
|
|
84
|
+
+ `</div>`
|
|
85
|
+
// 标题
|
|
86
|
+
+ `<span style="font-size:18px;font-weight:700;color:#1a1a1a;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">`
|
|
87
|
+
+ escapeHtml(truncate(meta.title, 50))
|
|
88
|
+
+ `</span>`
|
|
89
|
+
// 描述
|
|
90
|
+
+ descHtml
|
|
91
|
+
// 作者
|
|
92
|
+
+ authorHtml
|
|
93
|
+
// 统计
|
|
94
|
+
+ statsHtml
|
|
95
|
+
// 底部链接
|
|
96
|
+
+ `<div style="display:flex;margin-top:14px;padding-top:12px;border-top:1px solid #f0f0f0;">`
|
|
97
|
+
+ `<span style="font-size:11px;color:#bbb;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">`
|
|
98
|
+
+ escapeHtml(displayUrl)
|
|
99
|
+
+ `</span>`
|
|
100
|
+
+ `</div>`
|
|
101
|
+
+ `</div>`
|
|
102
|
+
+ `</div>`
|
|
103
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** 链接解析结果 */
|
|
2
|
+
export interface LinkMeta {
|
|
3
|
+
platform: 'bilibili' | 'github' | 'douyin' | 'xiaohongshu'
|
|
4
|
+
title: string
|
|
5
|
+
description?: string
|
|
6
|
+
author?: string
|
|
7
|
+
authorAvatar?: string
|
|
8
|
+
/** 封面图(已转为 data URI) */
|
|
9
|
+
cover?: string
|
|
10
|
+
url: string
|
|
11
|
+
stats?: Record<string, string>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** 平台视觉主题 */
|
|
15
|
+
export interface PlatformTheme {
|
|
16
|
+
name: string
|
|
17
|
+
color: string
|
|
18
|
+
bg: string
|
|
19
|
+
}
|