@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 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
@@ -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,7 @@
1
+ import type { LinkMeta } from './types.js';
2
+ /**
3
+ * 从消息文本中检测第一个匹配的平台链接并解析元数据。
4
+ * 仅返回第一个命中结果,避免单条消息产生过多海报。
5
+ */
6
+ export declare function detectAndParse(text: string): Promise<LinkMeta | null>;
7
+ //# sourceMappingURL=platforms.d.ts.map
@@ -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"}
@@ -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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"}
@@ -0,0 +1,6 @@
1
+ import type { LinkMeta } from './types.js';
2
+ /**
3
+ * 生成 satori 兼容的海报 HTML
4
+ */
5
+ export declare function renderPoster(meta: LinkMeta): string;
6
+ //# sourceMappingURL=render.d.ts.map
@@ -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, '&amp;')
10
+ .replace(/</g, '&lt;')
11
+ .replace(/>/g, '&gt;')
12
+ .replace(/"/g, '&quot;');
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":"AAEA,MAAM,MAAM,GAAgD;IAC1D,QAAQ,EAAE,EAAE,IAAI,EAAE,UAAU,EAAE,KAAK,EAAE,SAAS,EAAE,EAAE,EAAE,SAAS,EAAE;IAC/D,MAAM,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,KAAK,EAAE,SAAS,EAAE,EAAE,EAAE,SAAS,EAAE;IAC3D,MAAM,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,SAAS,EAAE,EAAE,EAAE,SAAS,EAAE;IACvD,WAAW,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,EAAE,SAAS,EAAE,EAAE,EAAE,SAAS,EAAE;CAC9D,CAAA;AAED,SAAS,UAAU,CAAC,CAAS;IAC3B,OAAO,CAAC;SACL,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC;SACtB,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC;SACrB,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC;SACrB,OAAO,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAA;AAC5B,CAAC;AAED,SAAS,QAAQ,CAAC,CAAqB,EAAE,GAAW;IAClD,IAAI,CAAC,CAAC;QAAE,OAAO,EAAE,CAAA;IACjB,OAAO,CAAC,CAAC,MAAM,GAAG,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,CAAC,CAAA;AACnD,CAAC;AAED,SAAS,WAAW,CAAC,KAAyC,EAAE,KAAa;IAC3E,IAAI,CAAC,KAAK;QAAE,OAAO,EAAE,CAAA;IACrB,MAAM,KAAK,GAAG,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC;SAChC,GAAG,CACF,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,EAAE,CACT,6FAA6F;UAC3F,sBAAsB,KAAK,uCAAuC,UAAU,CAAC,CAAC,CAAC,SAAS;UACxF,SAAS,UAAU,CAAC,CAAC,CAAC,SAAS;UAC/B,SAAS,CACd;SACA,IAAI,CAAC,EAAE,CAAC,CAAA;IACX,OAAO,6DAA6D,KAAK,QAAQ,CAAA;AACnF,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,YAAY,CAAC,IAAc;IACzC,MAAM,KAAK,GAAG,MAAM,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAA;IAEnC,aAAa;IACb,MAAM,SAAS,GAAG,IAAI,CAAC,KAAK;QAC1B,CAAC,CAAC,aAAa,IAAI,CAAC,KAAK,yDAAyD;QAClF,CAAC,CAAC,yGAAyG,KAAK,CAAC,EAAE,KAAK;cACpH,qDAAqD,KAAK,CAAC,KAAK,MAAM,UAAU,CAAC,KAAK,CAAC,IAAI,CAAC,SAAS;cACrG,QAAQ,CAAA;IAEd,gBAAgB;IAChB,MAAM,UAAU,GAAG,IAAI,CAAC,YAAY;QAClC,CAAC,CAAC,aAAa,IAAI,CAAC,YAAY,0EAA0E;QAC1G,CAAC,CAAC,EAAE,CAAA;IACN,MAAM,UAAU,GAAG,IAAI,CAAC,MAAM;QAC5B,CAAC,CAAC,gEAAgE;cAC9D,UAAU;cACV,4CAA4C,UAAU,CAAC,IAAI,CAAC,MAAM,CAAC,SAAS;cAC5E,QAAQ;QACZ,CAAC,CAAC,EAAE,CAAA;IAEN,WAAW;IACX,MAAM,QAAQ,GAAG,IAAI,CAAC,WAAW;QAC/B,CAAC,CAAC,oHAAoH;cAClH,GAAG,UAAU,CAAC,QAAQ,CAAC,IAAI,CAAC,WAAW,EAAE,EAAE,CAAC,CAAC,EAAE;cAC/C,SAAS;QACb,CAAC,CAAC,EAAE,CAAA;IAEN,WAAW;IACX,MAAM,SAAS,GAAG,WAAW,CAAC,IAAI,CAAC,KAAK,EAAE,KAAK,CAAC,KAAK,CAAC,CAAA;IAEtD,WAAW;IACX,MAAM,UAAU,GAAG,QAAQ,CAAC,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC,cAAc,EAAE,EAAE,CAAC,EAAE,EAAE,CAAC,CAAA;IAErE,OAAO,gMAAgM;QACrM,KAAK;UACH,SAAS;QACX,MAAM;UACJ,0EAA0E;QAC1E,OAAO;UACL,mEAAmE;UACjE,2DAA2D,KAAK,CAAC,KAAK,0GAA0G;UAC9K,UAAU,CAAC,KAAK,CAAC,IAAI,CAAC;UACxB,SAAS;UACX,QAAQ;QACV,KAAK;UACH,wHAAwH;UACtH,UAAU,CAAC,QAAQ,CAAC,IAAI,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;UACtC,SAAS;QACX,KAAK;UACH,QAAQ;QACV,KAAK;UACH,UAAU;QACZ,KAAK;UACH,SAAS;QACX,OAAO;UACL,2FAA2F;UACzF,qGAAqG;UACnG,UAAU,CAAC,UAAU,CAAC;UACxB,SAAS;UACX,QAAQ;UACV,QAAQ;UACV,QAAQ,CAAA;AACZ,CAAC"}
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
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -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
+ })
@@ -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, '&amp;')
13
+ .replace(/</g, '&lt;')
14
+ .replace(/>/g, '&gt;')
15
+ .replace(/"/g, '&quot;')
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
+ }