koishi-plugin-cfmrmod 1.0.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.
@@ -0,0 +1,1660 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Config = exports.name = void 0;
4
+ exports.apply = apply;
5
+ const { Schema, h } = require('koishi');
6
+ // 【修复】这里添加了 Path2D 的引入
7
+ const { createCanvas, loadImage, Path2D, GlobalFonts } = require('@napi-rs/canvas');
8
+ const fetch = require('node-fetch');
9
+ const cheerio = require('cheerio');
10
+ const { marked } = require('marked');
11
+ const CF_LOADER_MAP = {
12
+ 1: 'Forge',
13
+ 2: 'Cauldron',
14
+ 3: 'LiteLoader',
15
+ 4: 'Fabric',
16
+ 5: 'Quilt',
17
+ 6: 'NeoForge'
18
+ };
19
+ const CF_LOGO_SVG = `
20
+ <svg viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
21
+ <path d="M7.766 6.844l-.953.375c-.328.14-.547.453-.547.812v6.922c0 2.25 1.125 4.313 3 5.516l.266.172v5.03c0 .36-.203.688-.532.86l-2.67 1.484c-.36.203-.814.156-1.126-.11l-3.344-2.812c-.22-.188-.344-.453-.344-.734V12.78c0-1.89 1.063-3.625 2.766-4.5l3.484-1.437z" fill="#f16436"/>
22
+ <path d="M29.11 9.36l-3.328 2.812c-.313.265-.766.312-1.125.11l-2.672-1.485c-.328-.172-.53-.5-.53-.86v-5.03c1.875-1.203 3-3.266 3-5.516V.812c0-.36-.22-.672-.548-.813L20.423-.375c-1.687-.672-3.61.125-4.28 1.78l-1.048 2.548 4.797 2.656 2.156-1.078 2.734 1.516v6.203l4.625 2.578v10.53c0 .282-.125.548-.344.735z" fill="#f16436" transform="rotate(180 22.25 11.234)"/>
23
+ <path d="M28.016 26.61l-10.75-5.97-1.39 1.11c-.516.406-1.235.406-1.75 0l-1.39-1.11-10.75 5.97c-.61.328-1.094.86-1.344 1.5l-.64 1.703c-.235.625.046 1.328.625 1.563l.625.265c.344.14.734.094 1.047-.14l11.5-8.626c.72-.547 1.703-.547 2.422 0l11.5 8.625c.313.234.703.28 1.047.14l.625-.265c.58-.235.86-.938.625-1.563l-.64-1.703c-.25-.64-.735-1.172-1.345-1.5z" fill="#f16436"/>
24
+ </svg>`;
25
+ const MR_LOGO_SVG = `
26
+ <svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" clip-rule="evenodd" viewBox="0 0 3307 593">
27
+ <path fill="#2d3436" fill-rule="nonzero" d="M1053.02 205.51c35.59 0 64.27 10.1 84.98 30.81 20.72 21.25 31.34 52.05 31.34 93.48v162.53h-66.4V338.3c0-24.96-5.3-43.55-16.46-56.3-11.15-12.22-26.55-18.6-47.27-18.6-22.3 0-40.37 7.45-53.65 21.79-13.27 14.87-20.18 36.11-20.18 63.2v143.94h-66.4V338.3c0-24.96-5.3-43.55-16.46-56.3-11.15-12.22-26.56-18.6-47.27-18.6-22.84 0-40.37 7.45-53.65 21.79-13.27 14.34-20.18 35.58-20.18 63.2v143.94h-66.4V208.7h63.21v36.12c10.63-12.75 23.9-22.3 39.84-29.21 15.93-6.9 33.46-10.1 53.11-10.1 21.25 0 40.37 3.72 56.84 11.69 16.46 8.5 29.21 20.18 38.77 35.59 11.69-14.88 26.56-26.56 45.15-35.06 18.59-7.97 38.77-12.22 61.08-12.22Zm329.84 290.54c-28.68 0-54.7-6.37-77.54-18.59a133.19 133.19 0 0 1-53.65-52.05c-13.28-21.78-19.65-46.74-19.65-74.9 0-28.14 6.37-53.1 19.65-74.88a135.4 135.4 0 0 1 53.65-51.53c22.84-12.21 48.86-18.59 77.54-18.59 29.22 0 55.24 6.38 78.08 18.6 22.84 12.21 40.9 29.74 54.18 51.52 12.75 21.77 19.12 46.74 19.12 74.89s-6.37 53.11-19.12 74.89c-13.28 22.3-31.34 39.83-54.18 52.05-22.84 12.22-48.86 18.6-78.08 18.6Zm0-56.83c24.44 0 44.62-7.97 60.55-24.43 15.94-16.47 23.9-37.72 23.9-64.27 0-26.56-7.96-47.8-23.9-64.27-15.93-16.47-36.11-24.43-60.55-24.43-24.43 0-44.61 7.96-60.02 24.43-15.93 16.46-23.9 37.71-23.9 64.27 0 26.55 7.97 47.8 23.9 64.27 15.4 16.46 35.6 24.43 60.02 24.43Zm491.32-341v394.11h-63.74v-36.65a108.02 108.02 0 0 1-40.37 30.28c-16.46 6.9-34 10.1-53.65 10.1-27.08 0-51.52-5.85-73.3-18.07-21.77-12.21-39.3-29.21-51.52-51.52-12.21-21.78-18.59-47.27-18.59-75.95s6.38-54.18 18.6-75.96c12.21-21.77 29.74-38.77 51.52-50.99 21.77-12.21 46.2-18.06 73.3-18.06 18.59 0 36.11 3.2 51.52 9.56a106.35 106.35 0 0 1 39.83 28.69V98.22h66.4Zm-149.79 341c15.94 0 30.28-3.72 43.03-11.16 12.74-6.9 22.83-17.52 30.27-30.8 7.44-13.28 11.15-29.21 11.15-46.74s-3.71-33.46-11.15-46.74c-7.44-13.28-17.53-23.9-30.27-31.34-12.75-6.9-27.1-10.62-43.03-10.62s-30.27 3.71-43.02 10.62c-12.75 7.43-22.84 18.06-30.28 31.34-7.43 13.28-11.15 29.2-11.15 46.74 0 17.53 3.72 33.46 11.15 46.74 7.44 13.28 17.53 23.9 30.28 30.8 12.75 7.44 27.09 11.16 43.02 11.16Zm298.51-189.09c19.12-29.74 52.58-44.62 100.92-44.62v63.21a84.29 84.29 0 0 0-15.4-1.6c-26.03 0-46.22 7.44-60.56 22.32-14.34 15.4-21.78 37.18-21.78 65.33v137.56h-66.39V208.7h63.2v41.43Zm155.63-41.43h66.39v283.63h-66.4V208.7Zm33.46-46.74c-12.22 0-22.31-3.72-30.28-11.68a37.36 37.36 0 0 1-12.21-28.16c0-11.15 4.25-20.71 12.21-28.68 7.97-7.43 18.06-11.15 30.28-11.15 12.21 0 22.3 3.72 30.27 10.62 7.97 7.44 12.22 16.47 12.22 27.62 0 11.69-3.72 21.25-11.69 29.21-7.96 7.97-18.59 12.22-30.8 12.22Zm279.38 43.55c35.59 0 64.27 10.63 86.05 31.34 21.78 20.72 32.4 52.05 32.4 92.95v162.53h-66.4V338.3c0-24.96-5.84-43.55-17.52-56.3-11.69-12.22-28.15-18.6-49.93-18.6-24.43 0-43.55 7.45-57.9 21.79-14.34 14.87-21.24 36.11-21.24 63.73v143.41h-66.4V208.7h63.21v36.65c11.16-13.28 24.97-22.84 41.43-29.74 16.47-6.9 35.59-10.1 56.3-10.1Zm371.81 271.42a78.34 78.34 0 0 1-28.15 14.34 130.83 130.83 0 0 1-35.6 4.78c-31.33 0-55.23-7.97-72.23-24.43-17-16.47-25.5-39.84-25.5-71.17V263.94h-46.73v-53.11h46.74v-64.8h66.4v64.8h75.95v53.11h-75.96v134.91c0 13.81 3.19 24.43 10.1 31.34 6.9 7.44 16.46 11.15 29.2 11.15 14.88 0 27.1-3.71 37.19-11.68l18.59 47.27Zm214.05-271.42c35.59 0 64.27 10.63 86.05 31.34 21.77 20.72 32.4 52.05 32.4 92.95v162.53h-66.4V338.3c0-24.96-5.84-43.55-17.53-56.3-11.68-12.22-28.15-18.6-49.92-18.6-24.44 0-43.56 7.45-57.9 21.79-14.34 14.87-21.24 36.11-21.24 63.73v143.41h-66.4V98.23h66.4v143.4c11.15-11.68 24.43-20.71 40.9-27.09 15.93-5.84 33.99-9.03 53.64-9.03Z"></path>
28
+ <g fill="#1bd96a"><path d="m29 424.4 188.2-112.95-17.15-45.48 53.75-55.21 67.93-14.64 19.67 24.21-31.32 31.72-27.3 8.6-19.52 20.05 9.56 26.6 19.4 20.6 27.36-7.28 19.47-21.38 42.51-13.47 12.67 28.5-43.87 53.78-73.5 23.27-32.97-36.7L55.06 467.94C46.1 456.41 35.67 440.08 29 424.4Zm543.03-230.25-149.5 40.32c8.24 21.92 10.95 34.8 13.23 49l149.23-40.26c-2.38-15.94-6.65-32.17-12.96-49.06Z"></path>
29
+ <path d="M51.28 316.13c10.59 125 115.54 223.3 243.27 223.3 96.51 0 180.02-56.12 219.63-137.46l48.61 16.83c-46.78 101.34-149.35 171.75-268.24 171.75C138.6 590.55 10.71 469.38 0 316.13h51.28ZM.78 265.24C15.86 116.36 141.73 0 294.56 0c162.97 0 295.28 132.31 295.28 295.28 0 26.14-3.4 51.49-9.8 75.63l-48.48-16.78a244.28 244.28 0 0 0 7.15-58.85c0-134.75-109.4-244.15-244.15-244.15-124.58 0-227.49 93.5-242.32 214.11H.8Z" class="ring--large ring"></path>
30
+ <path d="M293.77 153.17c-78.49.07-142.2 63.83-142.2 142.34 0 78.56 63.79 142.34 142.35 142.34 3.98 0 7.93-.16 11.83-.49l14.22 49.76a194.65 194.65 0 0 1-26.05 1.74c-106.72 0-193.36-86.64-193.36-193.35 0-106.72 86.64-193.35 193.36-193.35 2.64 0 5.28.05 7.9.16l-8.05 50.85Zm58.2-42.13c78.39 24.67 135.3 97.98 135.3 184.47 0 80.07-48.77 148.83-118.2 178.18l-14.17-49.55c48.08-22.85 81.36-71.89 81.36-128.63 0-60.99-38.44-113.07-92.39-133.32l8.1-51.15Z" class="ring--small ring"></path></g></svg>`;
31
+ exports.name = 'minecraft-project-search';
32
+ // ================= 配置定义 =================
33
+ exports.Config = Schema.object({
34
+ pageSize: Schema.number().default(10).description('每页显示数量'),
35
+ cacheTtl: Schema.number().default(5 * 60 * 1000).description('缓存有效期(ms)'),
36
+ requestTimeout: Schema.number().default(15000).description('请求超时(ms)'),
37
+ sendLink: Schema.boolean().default(true).description('发送卡片后是否附带链接'),
38
+ });
39
+ // ================= 常量定义 =================
40
+ const MR_BASE = 'https://api.modrinth.com/v2';
41
+ const CF_BASE = 'https://api.curseforge.com/v1';
42
+ const CF_MIRROR_BASE = 'https://api.curse.tools/v1/cf';
43
+ const CF_CLASS_MAP = { mod: 6, pack: 4471, resource: 12, world: 17, plugin: 5, shader: 6552, datapack: 6945 };
44
+ const MR_FACET_MAP = {
45
+ mod: 'project_type:mod', pack: 'project_type:modpack', resource: 'project_type:resourcepack',
46
+ shader: 'categories:shader', plugin: 'categories:bukkit', datapack: 'categories:datapack'
47
+ };
48
+ const TYPE_LABELS = {
49
+ mod: 'Mod', pack: 'Modpack', resource: 'Resource Pack', shader: 'Shader',
50
+ plugin: 'Plugin', datapack: 'Datapack', world: 'World', author: 'Author'
51
+ };
52
+ // ================= 辅助工具 (Canvas & Utils) =================
53
+ let GLOBAL_FONT_FAMILY = 'sans-serif';
54
+ // 颜色定义 (Modrinth Light Theme)
55
+ const COLORS = {
56
+ bg: '#ffffff',
57
+ textMain: '#131c20', // text-contrast
58
+ textSec: '#6e6e6e', // text-secondary
59
+ divider: '#e2e2e2',
60
+ badgeBg: '#e8e8e8', // button-bg
61
+ badgeText: '#131c20',
62
+ link: '#1bd96a', // primary (Modrinth Green)
63
+ cardBg: '#ffffff',
64
+ accent: '#1bd96a'
65
+ };
66
+ function roundRect(ctx, x, y, w, h, r) {
67
+ if (w < 2 * r)
68
+ r = w / 2;
69
+ if (h < 2 * r)
70
+ r = h / 2;
71
+ ctx.beginPath();
72
+ ctx.moveTo(x + r, y);
73
+ ctx.arcTo(x + w, y, x + w, y + h, r);
74
+ ctx.arcTo(x + w, y + h, x, y + h, r);
75
+ ctx.arcTo(x, y + h, x, y, r);
76
+ ctx.arcTo(x, y, x + w, y, r);
77
+ ctx.closePath();
78
+ }
79
+ // 文本换行计算
80
+ function wrapText(ctx, text, x, y, maxWidth, lineHeight, maxLines = 1000, draw = true) {
81
+ if (!text)
82
+ return y;
83
+ const words = text.split('');
84
+ let line = '';
85
+ let linesCount = 0;
86
+ let currentY = y;
87
+ for (let n = 0; n < words.length; n++) {
88
+ const testLine = line + words[n];
89
+ const metrics = ctx.measureText(testLine);
90
+ if (metrics.width > maxWidth && n > 0) {
91
+ if (draw)
92
+ ctx.fillText(line, x, currentY);
93
+ line = words[n];
94
+ currentY += lineHeight;
95
+ linesCount++;
96
+ if (linesCount >= maxLines) {
97
+ if (draw)
98
+ ctx.fillText(line + '...', x, currentY);
99
+ return currentY + lineHeight;
100
+ }
101
+ }
102
+ else {
103
+ line = testLine;
104
+ }
105
+ }
106
+ if (draw)
107
+ ctx.fillText(line, x, currentY);
108
+ return currentY + lineHeight;
109
+ }
110
+ function formatNumber(num) {
111
+ if (num === null || num === undefined)
112
+ return '0';
113
+ const n = Number(num) || 0;
114
+ if (n >= 1e6)
115
+ return `${(n / 1e6).toFixed(2)}M`;
116
+ if (n >= 1e3)
117
+ return `${(n / 1e3).toFixed(1).replace('.0', '')}k`;
118
+ return String(n);
119
+ }
120
+ function parseCompactNumber(text) {
121
+ if (!text)
122
+ return null;
123
+ const raw = String(text).replace(/[,\s]/g, '').trim();
124
+ const match = raw.match(/(\d+(?:\.\d+)?)([kKmM]?)/);
125
+ if (!match)
126
+ return null;
127
+ const value = Number(match[1]);
128
+ const unit = match[2].toLowerCase();
129
+ if (unit === 'm')
130
+ return Math.round(value * 1e6);
131
+ if (unit === 'k')
132
+ return Math.round(value * 1e3);
133
+ return Math.round(value);
134
+ }
135
+ function fixUrl(url, base = '') {
136
+ if (!url)
137
+ return null;
138
+ if (url.startsWith('//'))
139
+ return `https:${url}`;
140
+ if (url.startsWith('/'))
141
+ return base ? `${base}${url}` : url;
142
+ return url;
143
+ }
144
+ async function loadImageSafe(url, timeout = 15000) {
145
+ if (!url)
146
+ return null;
147
+ const tryUrls = [url];
148
+ if (url.includes('.webp')) {
149
+ tryUrls.push(url.replace('.webp', '.png'));
150
+ if (!url.includes('format='))
151
+ tryUrls.push(`${url}${url.includes('?') ? '&' : '?'}format=png`);
152
+ }
153
+ let lastErr;
154
+ for (const u of tryUrls) {
155
+ try {
156
+ return await loadImage(u);
157
+ }
158
+ catch (e) {
159
+ lastErr = e;
160
+ }
161
+ }
162
+ try {
163
+ const res = await fetchWithTimeout(tryUrls[0], {}, timeout);
164
+ const buf = await res.buffer();
165
+ return await loadImage(buf);
166
+ }
167
+ catch (e) {
168
+ lastErr = e;
169
+ }
170
+ throw lastErr;
171
+ }
172
+ // 简单的 Markdown 转 HTML 配置
173
+ marked.setOptions({ breaks: true, gfm: true });
174
+ // ================= 网络请求工具 =================
175
+ async function fetchWithTimeout(url, options = {}, timeout = 15000) {
176
+ const controller = new AbortController();
177
+ const id = setTimeout(() => controller.abort(), timeout);
178
+ try {
179
+ return await fetch(url, { ...options, signal: controller.signal });
180
+ }
181
+ finally {
182
+ clearTimeout(id);
183
+ }
184
+ }
185
+ async function fetchJson(url, options = {}, timeout = 15000) {
186
+ const res = await fetchWithTimeout(url, options, timeout);
187
+ if (!res.ok)
188
+ throw new Error(`${res.status} ${res.statusText}`);
189
+ return res.json();
190
+ }
191
+ async function fetchCurseForgeHtml(url, timeout = 15000) {
192
+ const res = await fetchWithTimeout(url, {
193
+ headers: {
194
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36',
195
+ 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
196
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
197
+ }
198
+ }, timeout);
199
+ if (!res.ok)
200
+ throw new Error(`${res.status} ${res.statusText}`);
201
+ return res.text();
202
+ }
203
+ function getCurseForgeHeaders(apiKey) {
204
+ if (!apiKey || !String(apiKey).trim()) {
205
+ throw new Error('CurseForge API Key 不能为空,请在插件配置中填写 curseforgeApiKey');
206
+ }
207
+ return {
208
+ 'Accept': 'application/json',
209
+ 'x-api-key': String(apiKey).trim(),
210
+ };
211
+ }
212
+ function extractFirstMarkdownImage(md = '') {
213
+ const match = md.match(/!\[[^\]]*\]\(([^)]+)\)/);
214
+ if (!match)
215
+ return null;
216
+ return match[1];
217
+ }
218
+ async function fetchModrinthPage(slug, timeout) {
219
+ const url = `https://modrinth.com/mod/${slug}`;
220
+ const res = await fetchWithTimeout(url, {}, timeout);
221
+ const html = await res.text();
222
+ const $ = cheerio.load(html);
223
+ const icon = fixUrl($('img[class*="avatar"]').first().attr('src') ||
224
+ $('meta[property="og:image"]').attr('content'), 'https://modrinth.com');
225
+ const overviewHtml = $('.markdown, article, .prose').first().html();
226
+ return { icon, overviewHtml };
227
+ }
228
+ async function fetchCurseForgePage(url, timeout) {
229
+ if (!url)
230
+ return { icon: null, overviewHtml: null, baseUrl: null };
231
+ const html = await fetchCurseForgeHtml(url, timeout);
232
+ const $ = cheerio.load(html);
233
+ const icon = fixUrl($('img[class*="project-avatar"], img[class*="avatar"], img[alt][src*="thumbnail"]').first().attr('src') ||
234
+ $('meta[property="og:image"]').attr('content'), 'https://www.curseforge.com');
235
+ const overviewHtml = ($('.tab-content .description').first().html() ||
236
+ $('.project-description').first().html() ||
237
+ $('.description-content').first().html() ||
238
+ $('.markdown').first().html());
239
+ return { icon, overviewHtml, baseUrl: 'https://www.curseforge.com' };
240
+ }
241
+ // ================= HTML 解析逻辑 =================
242
+ async function parseContentToNodes(htmlContent, maxWidth, baseUrl = '') {
243
+ if (!htmlContent)
244
+ return [];
245
+ const $ = cheerio.load(htmlContent);
246
+ const nodes = [];
247
+ async function traverse(elem) {
248
+ if (nodes.length > 120)
249
+ return; // 限制长度
250
+ const type = elem.type;
251
+ const tagName = elem.tagName || elem.name;
252
+ if (type === 'text') {
253
+ const text = elem.data.replace(/[\r\n\t]+/g, ' ').trim();
254
+ if (text)
255
+ nodes.push({ type: 'text', val: text, tag: 'p' });
256
+ }
257
+ else if (type === 'tag') {
258
+ if (tagName === 'img') {
259
+ const src = fixUrl($(elem).attr('src') || $(elem).attr('data-src'), baseUrl);
260
+ if (src)
261
+ nodes.push({ type: 'img', src: src });
262
+ }
263
+ else if (['h1', 'h2', 'h3', 'h4'].includes(tagName)) {
264
+ const text = $(elem).text().trim();
265
+ if (text)
266
+ nodes.push({ type: 'text', val: text, tag: 'h' });
267
+ }
268
+ else if (tagName === 'li') {
269
+ const text = $(elem).text().trim();
270
+ if (text)
271
+ nodes.push({ type: 'text', val: '• ' + text, tag: 'li' });
272
+ }
273
+ else if (elem.children) {
274
+ for (const child of elem.children)
275
+ await traverse(child);
276
+ }
277
+ }
278
+ }
279
+ const body = $('body').length ? $('body')[0] : $.root()[0];
280
+ if (body.children) {
281
+ for (const child of body.children)
282
+ await traverse(child);
283
+ }
284
+ await Promise.all(nodes.map(async (node) => {
285
+ if (node.type === 'img') {
286
+ try {
287
+ const img = await loadImageSafe(node.src);
288
+ node.imgObj = img;
289
+ const scale = Math.min(maxWidth / img.width, 1);
290
+ node.dw = img.width * scale;
291
+ node.dh = img.height * scale;
292
+ }
293
+ catch (e) {
294
+ node.error = true;
295
+ }
296
+ }
297
+ }));
298
+ return nodes;
299
+ }
300
+ // ================= 绘图核心 (Layout Engine) =================
301
+ async function drawProjectCard(data) {
302
+ const margin = 24;
303
+ const gap = 32;
304
+ const font = GLOBAL_FONT_FAMILY;
305
+ const clamp = (n, min, max) => Math.max(min, Math.min(max, n));
306
+ const maxCanvasHeight = data.maxCanvasHeight || 8000;
307
+ const contentOnly = !!data._contentOnly;
308
+ // 1. 预处理正文
309
+ let rawBody = data.body;
310
+ if (!rawBody && data.summary)
311
+ rawBody = `<p>${data.summary}</p>`;
312
+ if (!data.bodyIsHtml && data.source === 'Modrinth' && rawBody)
313
+ rawBody = marked.parse(rawBody);
314
+ // 2. 预计算高度
315
+ const dummyC = createCanvas(200, 200);
316
+ const dummy = dummyC.getContext('2d');
317
+ // Sidebar 内容
318
+ const sections = contentOnly ? [] : [
319
+ { t: 'Compatibility', d: (data.gameVersions || []).slice(0, 15), type: 'chips' },
320
+ { t: 'Platforms', d: data.loaders || [], type: 'chips' },
321
+ { t: 'Supported environments', d: [data.clientSide ? 'Client' : null, data.serverSide ? 'Server' : null].filter(Boolean), type: 'chips' },
322
+ { t: 'Links', d: data.links || [], type: 'links' },
323
+ { t: 'Creators', d: [data.author], type: 'text' }
324
+ ];
325
+ const measureChipsHeight = (items, maxWidth, ctx, fontSize = 13, padX = 16, rowH = 24, rowGap = 8) => {
326
+ if (!items || !items.length)
327
+ return 0;
328
+ ctx.font = `600 ${fontSize}px "${font}"`;
329
+ let x = 0;
330
+ let rows = 1;
331
+ items.forEach(item => {
332
+ if (!item)
333
+ return;
334
+ const tw = ctx.measureText(item).width + padX;
335
+ if (x + tw > maxWidth) {
336
+ rows += 1;
337
+ x = 0;
338
+ }
339
+ x += tw + 6;
340
+ });
341
+ return rows * rowH + (rows - 1) * rowGap;
342
+ };
343
+ const measureTextBlockHeight = (text, width, fontSize, isHeader) => {
344
+ const lineHeight = Math.floor(fontSize * 1.6);
345
+ dummy.font = `${isHeader ? '800' : 'normal'} ${fontSize}px "${font}"`;
346
+ return wrapText(dummy, text || '', 0, 0, width, lineHeight, 10000, false);
347
+ };
348
+ // 自适应宽度计算
349
+ const headerIconSize = 96;
350
+ dummy.font = `800 28px "${font}"`;
351
+ const titleWidth = Math.min(dummy.measureText(data.name || '').width + headerIconSize + 60, 900);
352
+ let mainW = 620;
353
+ // 首次解析,获取图片原始尺寸
354
+ let contentNodes = data._contentNodes || await parseContentToNodes(rawBody, mainW, data.baseUrl || '');
355
+ let maxImgW = 0;
356
+ contentNodes.forEach(node => {
357
+ if (node.type === 'img' && node.dw)
358
+ maxImgW = Math.max(maxImgW, node.dw);
359
+ });
360
+ const computedMainW = clamp(Math.max(mainW, maxImgW, titleWidth), 520, 900);
361
+ if (Math.abs(computedMainW - mainW) > 20 && !data._contentNodes) {
362
+ mainW = computedMainW;
363
+ // 宽度变化大,重新解析以适应图片缩放
364
+ contentNodes = await parseContentToNodes(rawBody, mainW, data.baseUrl || '');
365
+ }
366
+ else {
367
+ mainW = computedMainW;
368
+ }
369
+ // Sidebar 宽度估算
370
+ let sidebarTextW = 0;
371
+ dummy.font = `600 14px "${font}"`;
372
+ sections.forEach(sec => {
373
+ if (!sec.d || !sec.d.length)
374
+ return;
375
+ sidebarTextW = Math.max(sidebarTextW, dummy.measureText(sec.t).width);
376
+ sec.d.forEach(item => {
377
+ const text = typeof item === 'string' ? item : ((item === null || item === void 0 ? void 0 : item.name) || '');
378
+ if (!text)
379
+ return;
380
+ sidebarTextW = Math.max(sidebarTextW, dummy.measureText(text).width);
381
+ });
382
+ });
383
+ const infoLines = [
384
+ data.license ? `License: ${data.license}` : null,
385
+ data.updated ? `Updated: ${data.updated}` : null,
386
+ data.created ? `Created: ${data.created}` : null
387
+ ].filter(Boolean);
388
+ infoLines.forEach(line => {
389
+ sidebarTextW = Math.max(sidebarTextW, dummy.measureText(line).width);
390
+ });
391
+ const sidebarW = contentOnly ? 0 : clamp(sidebarTextW + 60, 220, 360);
392
+ const width = margin * 2 + mainW + (contentOnly ? 0 : gap + sidebarW);
393
+ // 计算 Header 高度
394
+ const headerTextW = contentOnly ? mainW : (width - margin * 2 - headerIconSize - 24);
395
+ let headerContentH = 0;
396
+ dummy.font = `800 28px "${font}"`; // Title
397
+ const titleH = wrapText(dummy, data.name || '', 0, 0, headerTextW, 32, 3, false);
398
+ headerContentH += titleH + 6;
399
+ dummy.font = `16px "${font}"`; // Desc
400
+ const descH = wrapText(dummy, (data.summary || '').substring(0, 150), 0, 0, headerTextW, 24, 2, false);
401
+ headerContentH += descH + 10;
402
+ // Stats & Tags 行高度(按实际宽度计算是否换行)
403
+ dummy.font = `600 15px "${font}"`;
404
+ const dlText = formatNumber(data.downloads);
405
+ const flText = formatNumber(data.follows);
406
+ const statsWidth = 24 + dummy.measureText(dlText).width + 16 + 24 + dummy.measureText(flText).width + 24 + 24;
407
+ const tags = (data.tags || []).slice(0, 3);
408
+ const tagsRowH = measureChipsHeight(tags, Math.max(120, headerTextW - statsWidth), dummy, 13, 20, 26, 6);
409
+ headerContentH += Math.max(26, tagsRowH); // Stats & Tags
410
+ const headerH = contentOnly ? 0 : (Math.max(headerIconSize, headerContentH) + 20);
411
+ // 计算 Sidebar 高度
412
+ let sidebarH = 0;
413
+ // 更准确的 Sidebar 估算
414
+ sections.forEach(sec => {
415
+ if (!sec.d || !sec.d.length)
416
+ return;
417
+ sidebarH += 30; // Title
418
+ if (sec.type === 'chips') {
419
+ const chipsH = measureChipsHeight(sec.d, sidebarW, dummy, 13, 16, 24, 8);
420
+ sidebarH += chipsH + 10;
421
+ }
422
+ else {
423
+ sidebarH += sec.d.length * 26 + 10;
424
+ }
425
+ sidebarH += 20; // Gap
426
+ });
427
+ const infoCount = contentOnly ? 0 : [data.license, data.updated, data.created].filter(Boolean).length;
428
+ if (infoCount) {
429
+ sidebarH += 30 + infoCount * 26 + 20;
430
+ }
431
+ // 计算 Main Content 高度(与绘制逻辑严格一致)
432
+ let contentH = 0;
433
+ for (const node of contentNodes) {
434
+ if (node.type === 'text') {
435
+ const isHeader = node.tag === 'h';
436
+ const fontSize = isHeader ? 22 : 15;
437
+ if (isHeader)
438
+ contentH += 10;
439
+ const h = measureTextBlockHeight(node.val, mainW, fontSize, isHeader);
440
+ contentH += h + (isHeader ? 15 : 10);
441
+ if (isHeader)
442
+ contentH += 10; // 分割线间距
443
+ }
444
+ else if (node.type === 'img' && !node.error && node.imgObj) {
445
+ let drawH = node.dh;
446
+ if (drawH > 400)
447
+ drawH = 400;
448
+ contentH += drawH + 20;
449
+ }
450
+ }
451
+ if (contentH < 200)
452
+ contentH = 200;
453
+ // 重新计算 TotalH,严格对齐绘制坐标
454
+ // 绘制逻辑: margin -> headerH -> gap(10) -> divider -> gap(30) -> content -> gap(40) -> footer -> bottom
455
+ const contentStartY = contentOnly ? margin : (margin + headerH + 10 + 30);
456
+ const footerStartGap = contentOnly ? 20 : 40;
457
+ // Footer 高度预留 (Logo + Text)
458
+ const footerH = data.source === 'Modrinth' ? 80 : 40;
459
+ const safetyPad = 20;
460
+ const totalH = contentStartY + Math.max(sidebarH, contentH) + footerStartGap + footerH + margin + safetyPad;
461
+ // 若超出最大高度,分页渲染
462
+ if (!data._noPaginate && !contentOnly && totalH > maxCanvasHeight) {
463
+ const nodeHeights = [];
464
+ for (const node of contentNodes) {
465
+ if (node.type === 'text') {
466
+ const isHeader = node.tag === 'h';
467
+ const fontSize = isHeader ? 22 : 15;
468
+ let h = 0;
469
+ if (isHeader)
470
+ h += 10;
471
+ h += measureTextBlockHeight(node.val, mainW, fontSize, isHeader);
472
+ h += (isHeader ? 15 : 10);
473
+ if (isHeader)
474
+ h += 10;
475
+ nodeHeights.push(h);
476
+ }
477
+ else if (node.type === 'img' && !node.error && node.imgObj) {
478
+ let drawH = node.dh;
479
+ if (drawH > 400)
480
+ drawH = 400;
481
+ nodeHeights.push(drawH + 20);
482
+ }
483
+ else {
484
+ nodeHeights.push(0);
485
+ }
486
+ }
487
+ const availableFirst = maxCanvasHeight - (contentStartY + footerStartGap + footerH + margin + safetyPad);
488
+ const availableNext = maxCanvasHeight - (margin + footerStartGap + footerH + margin + safetyPad);
489
+ const pages = [];
490
+ let bucket = [];
491
+ let acc = 0;
492
+ let limit = availableFirst;
493
+ for (let i = 0; i < contentNodes.length; i++) {
494
+ const h = nodeHeights[i];
495
+ if (acc + h > limit && bucket.length) {
496
+ pages.push(bucket);
497
+ bucket = [];
498
+ acc = 0;
499
+ limit = availableNext;
500
+ }
501
+ bucket.push(contentNodes[i]);
502
+ acc += h;
503
+ }
504
+ if (bucket.length)
505
+ pages.push(bucket);
506
+ const buffers = [];
507
+ for (let i = 0; i < pages.length; i++) {
508
+ const bufList = await drawProjectCard({
509
+ ...data,
510
+ _contentNodes: pages[i],
511
+ _contentOnly: i > 0,
512
+ _noPaginate: true,
513
+ maxCanvasHeight
514
+ });
515
+ buffers.push(...bufList);
516
+ }
517
+ return buffers;
518
+ }
519
+ // 3. 开始绘制
520
+ const canvas = createCanvas(width, totalH);
521
+ const ctx = canvas.getContext('2d');
522
+ // Background
523
+ ctx.fillStyle = COLORS.bg;
524
+ ctx.fillRect(0, 0, width, totalH);
525
+ // ================= Header Draw =================
526
+ let cy = margin;
527
+ const hx = margin;
528
+ // Icon
529
+ if (!contentOnly && data.icon) {
530
+ try {
531
+ const img = await loadImageSafe(data.icon);
532
+ ctx.save();
533
+ roundRect(ctx, hx, cy, headerIconSize, headerIconSize, 16);
534
+ ctx.clip();
535
+ ctx.drawImage(img, hx, cy, headerIconSize, headerIconSize);
536
+ ctx.restore();
537
+ }
538
+ catch (e) {
539
+ ctx.fillStyle = '#eee';
540
+ roundRect(ctx, hx, cy, headerIconSize, headerIconSize, 16);
541
+ ctx.fill();
542
+ }
543
+ }
544
+ // Header Info
545
+ const hTx = hx + headerIconSize + 24;
546
+ let hTy = cy;
547
+ // Title
548
+ if (!contentOnly) {
549
+ ctx.fillStyle = COLORS.textMain;
550
+ ctx.font = `800 28px "${font}"`;
551
+ ctx.textBaseline = 'top';
552
+ hTy = wrapText(ctx, data.name || '', hTx, hTy, headerTextW, 32, 3, true) + 4;
553
+ }
554
+ // Desc
555
+ if (!contentOnly) {
556
+ ctx.fillStyle = COLORS.textSec;
557
+ ctx.font = `16px "${font}"`;
558
+ hTy = wrapText(ctx, (data.summary || '').substring(0, 150), hTx, hTy, headerTextW, 24, 2, true) + 12;
559
+ }
560
+ // Stats & Tags Row
561
+ // Downloads Icon
562
+ const drawIcon = (path, x, y) => {
563
+ ctx.save();
564
+ ctx.translate(x, y);
565
+ ctx.scale(0.8, 0.8);
566
+ ctx.strokeStyle = COLORS.textSec;
567
+ ctx.lineWidth = 2;
568
+ ctx.lineCap = 'round';
569
+ ctx.lineJoin = 'round';
570
+ // Path2D 需要引入
571
+ const p = new Path2D(path);
572
+ ctx.stroke(p);
573
+ ctx.restore();
574
+ };
575
+ let sx = hTx;
576
+ let statY = hTy + 4;
577
+ // Download
578
+ if (!contentOnly) {
579
+ drawIcon('M4 16v1a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3v-1m-4-4-4 4m0 0-4-4m4 4V4', sx, statY);
580
+ ctx.fillStyle = COLORS.textMain;
581
+ ctx.font = `600 15px "${font}"`;
582
+ const dlText = formatNumber(data.downloads);
583
+ ctx.fillText(dlText, sx + 24, statY + 2);
584
+ sx += 24 + ctx.measureText(dlText).width + 16;
585
+ }
586
+ // Follows
587
+ if (!contentOnly) {
588
+ drawIcon('M4.3 6.3a4.5 4.5 0 0 0 0 6.4L12 20.4l7.7-7.7a4.5 4.5 0 0 0-6.4-6.4L12 7.6l-1.3-1.3a4.5 4.5 0 0 0-6.4 0', sx, statY);
589
+ const flText = formatNumber(data.follows);
590
+ ctx.fillText(flText, sx + 24, statY + 2);
591
+ sx += 24 + ctx.measureText(flText).width + 24;
592
+ }
593
+ // Vertical Divider
594
+ if (!contentOnly) {
595
+ ctx.beginPath();
596
+ ctx.moveTo(sx, statY - 2);
597
+ ctx.lineTo(sx, statY + 20);
598
+ ctx.strokeStyle = COLORS.divider;
599
+ ctx.lineWidth = 1;
600
+ ctx.stroke();
601
+ sx += 24;
602
+ }
603
+ // Tags (Pills)
604
+ if (!contentOnly) {
605
+ const tags = (data.tags || []).slice(0, 3);
606
+ tags.forEach(tag => {
607
+ ctx.font = `600 13px "${font}"`;
608
+ const tw = ctx.measureText(tag).width + 20;
609
+ if (sx + tw > hTx + headerTextW) {
610
+ sx = hTx;
611
+ statY += 32;
612
+ }
613
+ ctx.fillStyle = COLORS.badgeBg;
614
+ roundRect(ctx, sx, statY - 4, tw, 26, 13);
615
+ ctx.fill();
616
+ ctx.fillStyle = COLORS.textSec;
617
+ ctx.fillText(tag, sx + 10, statY + 3);
618
+ sx += tw + 8;
619
+ });
620
+ }
621
+ // Divider Line under Header
622
+ if (!contentOnly) {
623
+ cy += headerH + 10;
624
+ ctx.beginPath();
625
+ ctx.moveTo(margin, cy);
626
+ ctx.lineTo(width - margin, cy);
627
+ ctx.strokeStyle = COLORS.divider;
628
+ ctx.lineWidth = 1;
629
+ ctx.stroke();
630
+ }
631
+ // ================= Columns =================
632
+ const colTopY = contentOnly ? margin : (cy + 30);
633
+ // --- Right Sidebar ---
634
+ const rx = margin + mainW + gap;
635
+ let ry = colTopY;
636
+ const drawSidebarSection = (title, items, type) => {
637
+ if (!items || !items.length)
638
+ return;
639
+ ctx.fillStyle = COLORS.textMain;
640
+ ctx.font = `700 18px "${font}"`;
641
+ ctx.fillText(title, rx, ry);
642
+ ry += 30;
643
+ if (type === 'chips') {
644
+ let cx = rx;
645
+ items.forEach(item => {
646
+ if (!item)
647
+ return;
648
+ ctx.font = `600 13px "${font}"`;
649
+ const tw = ctx.measureText(item).width + 16;
650
+ if (cx + tw > rx + sidebarW) {
651
+ cx = rx;
652
+ ry += 32;
653
+ }
654
+ ctx.fillStyle = COLORS.badgeBg;
655
+ roundRect(ctx, cx, ry, tw, 24, 6);
656
+ ctx.fill();
657
+ ctx.fillStyle = COLORS.textMain;
658
+ ctx.fillText(item, cx + 8, ry + 6);
659
+ cx += tw + 6;
660
+ });
661
+ ry += 40;
662
+ }
663
+ else if (type === 'links') {
664
+ items.forEach(l => {
665
+ ctx.fillStyle = COLORS.link;
666
+ ctx.font = `600 14px "${font}"`;
667
+ ctx.fillText(l.name, rx, ry);
668
+ ry += 24;
669
+ });
670
+ ry += 20;
671
+ }
672
+ else if (type === 'text') {
673
+ items.forEach(t => {
674
+ ctx.fillStyle = COLORS.textMain;
675
+ ctx.font = `15px "${font}"`;
676
+ ctx.fillText(t, rx, ry);
677
+ ry += 24;
678
+ });
679
+ ry += 20;
680
+ }
681
+ };
682
+ if (!contentOnly) {
683
+ drawSidebarSection('Compatibility', (data.gameVersions || []).slice(0, 15), 'chips');
684
+ drawSidebarSection('Platforms', data.loaders, 'chips');
685
+ drawSidebarSection('Supported environments', [
686
+ data.clientSide ? (data.clientSide === 'required' ? 'Client (Required)' : 'Client') : null,
687
+ data.serverSide ? (data.serverSide === 'required' ? 'Server (Required)' : 'Server') : null
688
+ ].filter(Boolean), 'chips');
689
+ drawSidebarSection('Links', data.links, 'links');
690
+ // Info Section Manually
691
+ ctx.fillStyle = COLORS.textMain;
692
+ ctx.font = `700 18px "${font}"`;
693
+ ctx.fillText('Info', rx, ry);
694
+ ry += 30;
695
+ const drawInfoItem = (icon, label) => {
696
+ ctx.fillStyle = COLORS.textSec;
697
+ ctx.font = `14px "${font}"`;
698
+ ctx.fillText(label, rx + 20, ry);
699
+ // Draw dot/icon placeholder
700
+ ctx.beginPath();
701
+ ctx.arc(rx + 6, ry - 5, 3, 0, Math.PI * 2);
702
+ ctx.fill();
703
+ ry += 24;
704
+ };
705
+ if (data.license)
706
+ drawInfoItem('', `License: ${data.license}`);
707
+ drawInfoItem('', `Updated: ${data.updated}`);
708
+ drawInfoItem('', `Created: ${data.created || '--'}`);
709
+ ry += 20;
710
+ drawSidebarSection('Creators', [data.author], 'text');
711
+ }
712
+ // --- Left Content ---
713
+ let lx = margin;
714
+ let ly = colTopY;
715
+ // Render Nodes
716
+ for (const node of contentNodes) {
717
+ if (node.type === 'text') {
718
+ const isHeader = node.tag === 'h';
719
+ const fontSize = isHeader ? 22 : 15;
720
+ ctx.font = `${isHeader ? '800' : 'normal'} ${fontSize}px "${font}"`;
721
+ ctx.fillStyle = isHeader ? COLORS.textMain : '#333';
722
+ // Header Decoration
723
+ if (isHeader) {
724
+ ly += 10;
725
+ }
726
+ // 使用 10000 作为 maxLines,确保绘制完整内容
727
+ ly = wrapText(ctx, node.val, lx, ly, mainW, Math.floor(fontSize * 1.6), 10000, true) + (isHeader ? 15 : 10);
728
+ if (isHeader) {
729
+ ctx.fillStyle = COLORS.divider;
730
+ ctx.fillRect(lx, ly - 5, mainW, 1);
731
+ ly += 10;
732
+ }
733
+ }
734
+ else if (node.type === 'img' && !node.error && node.imgObj) {
735
+ let drawH = node.dh;
736
+ let drawW = node.dw;
737
+ if (drawH > 400) {
738
+ const r = 400 / drawH;
739
+ drawH = 400;
740
+ drawW = drawW * r;
741
+ }
742
+ // Center Image
743
+ const dx = lx + (mainW - drawW) / 2;
744
+ ctx.save();
745
+ roundRect(ctx, dx, ly, drawW, drawH, 8);
746
+ ctx.clip();
747
+ ctx.drawImage(node.imgObj, dx, ly, drawW, drawH);
748
+ ctx.restore();
749
+ ly += drawH + 20;
750
+ }
751
+ }
752
+ // Footer Drawing (Modrinth Logo & Author Text)
753
+ let footerY = Math.max(ly, ry) + 40;
754
+ if (footerY > totalH - margin - 10) {
755
+ footerY = totalH - margin - 10;
756
+ }
757
+ // 1. 如果是 Modrinth,绘制 Logo
758
+ if (data.source === 'Modrinth') {
759
+ try {
760
+ // 将 SVG 转为 Base64 Data URI 以加载
761
+ const base64Svg = Buffer.from(MR_LOGO_SVG).toString('base64');
762
+ const logoImg = await loadImage(`data:image/svg+xml;base64,${base64Svg}`);
763
+ const logoH = 40;
764
+ const logoW = logoImg.width * (logoH / logoImg.height);
765
+ // 居中绘制 Logo
766
+ ctx.drawImage(logoImg, (width - logoW) / 2, footerY, logoW, logoH);
767
+ footerY += logoH + 15;
768
+ }
769
+ catch (e) {
770
+ // console.error('Logo draw failed', e);
771
+ }
772
+ }
773
+ // 2. 绘制原有 Footer 文本
774
+ ctx.fillStyle = COLORS.textSec;
775
+ ctx.font = `12px "${font}"`;
776
+ ctx.textAlign = 'center';
777
+ ctx.fillText('Generated by Koishi | Powered by Modrinth & CurseForge', width / 2, footerY);
778
+ footerY += 18;
779
+ // 3. 绘制要求的作者署名
780
+ ctx.fillText('插件作者 Mai_xiyu(机器人作者 Mai_xiyu)', width / 2, footerY);
781
+ return [canvas.toBuffer('image/png')];
782
+ }
783
+ // ================= CurseForge 专用构图 =================
784
+ async function drawProjectCardCF(data) {
785
+ const width = 1000;
786
+ const margin = 24;
787
+ const gap = 20;
788
+ const font = GLOBAL_FONT_FAMILY;
789
+ const maxCanvasHeight = data.maxCanvasHeight || 8000;
790
+ const contentOnly = !!data._contentOnly;
791
+ // CF Colors
792
+ const C_BG = '#1b1b1b';
793
+ const C_PANEL = '#2d2d2d';
794
+ const C_TEXT_MAIN = '#e4e4e4';
795
+ const C_TEXT_SEC = '#b0b0b0';
796
+ const C_ACCENT = '#f16436';
797
+ const C_DIVIDER = '#2c2c2c';
798
+ const C_BUTTON = '#f16436';
799
+ // 1. 预处理正文
800
+ let rawBody = data.body;
801
+ if (!rawBody && data.summary)
802
+ rawBody = `<p>${data.summary}</p>`;
803
+ if (!data.bodyIsHtml && rawBody)
804
+ rawBody = marked.parse(rawBody);
805
+ // 2. 预计算 & 布局
806
+ const dummyC = createCanvas(100, 100);
807
+ const dummy = dummyC.getContext('2d');
808
+ const sidebarW = 300;
809
+ const mainW = width - margin * 2 - sidebarW - gap;
810
+ // 解析正文节点 (包括图片)
811
+ let contentNodes = data._contentNodes || await parseContentToNodes(rawBody, mainW, data.baseUrl || '');
812
+ const measureTextBlockHeight = (text, width, fontSize, isHeader) => {
813
+ const lineHeight = Math.floor(fontSize * 1.5);
814
+ dummy.font = `${isHeader ? 'bold' : 'normal'} ${fontSize}px "${font}"`;
815
+ return wrapText(dummy, text || '', 0, 0, width, lineHeight, 10000, false);
816
+ };
817
+ const measureChipsHeight = (items, maxWidth, ctx, fontSize = 12) => {
818
+ if (!items || !items.length)
819
+ return 0;
820
+ ctx.font = `normal ${fontSize}px "${font}"`;
821
+ let x = 0;
822
+ let rows = 1;
823
+ const padX = 16, rowH = 28, rowGap = 8;
824
+ items.forEach(item => {
825
+ const tw = ctx.measureText(item).width + padX;
826
+ if (x + tw > maxWidth) {
827
+ rows++;
828
+ x = 0;
829
+ }
830
+ x += tw + 8;
831
+ });
832
+ return rows * rowH + (rows - 1) * rowGap;
833
+ };
834
+ // --- Header Layout ---
835
+ const headerIconSize = 80;
836
+ let headerH = 0;
837
+ if (!contentOnly) {
838
+ headerH = 140;
839
+ }
840
+ // --- Sidebar Layout Construction ---
841
+ let sidebarH = 0;
842
+ const sidebarItems = [];
843
+ if (!contentOnly) {
844
+ // 1. Action Box
845
+ sidebarItems.push({ type: 'actionBox', h: 50 });
846
+ sidebarH += 50 + 20;
847
+ // 2. Details
848
+ const details = [
849
+ { l: 'Downloads', v: formatNumber(data.downloads) },
850
+ { l: 'Created', v: data.created || '--' },
851
+ { l: 'Updated', v: data.updated || '--' },
852
+ { l: 'License', v: data.license || 'Custom' }
853
+ ];
854
+ if (data.follows)
855
+ details.splice(1, 0, { l: 'Follows', v: formatNumber(data.follows) });
856
+ const detailH = 40 + details.length * 24;
857
+ sidebarItems.push({ type: 'listKV', title: 'Details', data: details, h: detailH });
858
+ sidebarH += detailH + 20;
859
+ // 3. Game Versions
860
+ if (data.gameVersions && data.gameVersions.length) {
861
+ const h = measureChipsHeight(data.gameVersions, sidebarW, dummy) + 45;
862
+ sidebarItems.push({ type: 'chips', title: 'Game Versions', data: data.gameVersions, h });
863
+ sidebarH += h + 20;
864
+ }
865
+ // 4. Mod Loaders
866
+ if (data.loaders && data.loaders.length) {
867
+ const h = measureChipsHeight(data.loaders, sidebarW, dummy) + 45;
868
+ sidebarItems.push({ type: 'chips', title: 'Mod Loaders', data: data.loaders, h });
869
+ sidebarH += h + 20;
870
+ }
871
+ // 5. Categories
872
+ if (data.tags && data.tags.length) {
873
+ const h = measureChipsHeight(data.tags, sidebarW - 30, dummy) + 45;
874
+ sidebarItems.push({ type: 'chips', title: 'Categories', data: data.tags, h });
875
+ sidebarH += h + 20;
876
+ }
877
+ // 6. Links
878
+ if (data.links && data.links.length) {
879
+ const h = 40 + data.links.length * 24;
880
+ sidebarItems.push({ type: 'links', title: 'Links', data: data.links, h });
881
+ sidebarH += h + 20;
882
+ }
883
+ // 7. Members
884
+ const membersH = 40 + 50;
885
+ sidebarItems.push({ type: 'members', title: 'Members', data: [{ name: data.author, icon: data.authorIcon }], h: membersH });
886
+ sidebarH += membersH + 20;
887
+ // 8. Footer (Logo & Credits)
888
+ const sideFooterH = 100;
889
+ sidebarItems.push({ type: 'sideFooter', h: sideFooterH });
890
+ sidebarH += sideFooterH;
891
+ }
892
+ // --- Main Content Height ---
893
+ let contentH = 0;
894
+ if (!contentOnly) {
895
+ contentH += 50;
896
+ }
897
+ for (const node of contentNodes) {
898
+ if (node.type === 'text') {
899
+ const isHeader = node.tag === 'h';
900
+ const fontSize = isHeader ? 22 : 15;
901
+ const h = measureTextBlockHeight(node.val, mainW, fontSize, isHeader);
902
+ contentH += h + (isHeader ? 15 : 10);
903
+ if (isHeader)
904
+ contentH += 8;
905
+ }
906
+ else if (node.type === 'img' && !node.error && node.imgObj) {
907
+ let drawH = node.dh;
908
+ if (drawH > 600) {
909
+ const ratio = 600 / drawH;
910
+ drawH = 600;
911
+ node.dw = node.dw * ratio;
912
+ }
913
+ node._drawH = drawH;
914
+ contentH += drawH + 20;
915
+ }
916
+ }
917
+ if (contentH < 200)
918
+ contentH = 200;
919
+ const contentStartY = contentOnly ? margin : (margin + headerH + 20);
920
+ const totalH = contentStartY + Math.max(sidebarH, contentH) + margin;
921
+ // 分页逻辑 (省略)
922
+ // 3. 开始绘制
923
+ const canvas = createCanvas(width, totalH);
924
+ const ctx = canvas.getContext('2d');
925
+ // Background
926
+ ctx.fillStyle = C_BG;
927
+ ctx.fillRect(0, 0, width, totalH);
928
+ // ================= Header Draw =================
929
+ let cy = margin;
930
+ if (!contentOnly) {
931
+ // Icon
932
+ const iconSize = 80;
933
+ if (data.icon) {
934
+ try {
935
+ const img = await loadImageSafe(data.icon);
936
+ ctx.save();
937
+ roundRect(ctx, margin, cy, iconSize, iconSize, 8);
938
+ ctx.clip();
939
+ ctx.drawImage(img, margin, cy, iconSize, iconSize);
940
+ ctx.restore();
941
+ }
942
+ catch (e) {
943
+ ctx.fillStyle = '#333';
944
+ roundRect(ctx, margin, cy, iconSize, iconSize, 8);
945
+ ctx.fill();
946
+ }
947
+ }
948
+ const tx = margin + iconSize + 20;
949
+ let ty = cy + 10;
950
+ ctx.fillStyle = C_TEXT_MAIN;
951
+ ctx.font = `bold 32px "${font}"`;
952
+ ctx.textBaseline = 'top';
953
+ ctx.fillText(data.name || 'Unknown', tx, ty);
954
+ ty += 42;
955
+ if (data.summary) {
956
+ ctx.fillStyle = C_TEXT_SEC;
957
+ ctx.font = `14px "${font}"`;
958
+ ty = wrapText(ctx, data.summary, tx, ty, width - tx - margin, 20, 3, true) + 6;
959
+ }
960
+ const avatarSize = 28;
961
+ if (data.authorIcon) {
962
+ try {
963
+ const aimg = await loadImageSafe(data.authorIcon);
964
+ ctx.save();
965
+ ctx.beginPath();
966
+ ctx.arc(tx + avatarSize / 2, ty + avatarSize / 2, avatarSize / 2, 0, Math.PI * 2);
967
+ ctx.clip();
968
+ ctx.drawImage(aimg, tx, ty, avatarSize, avatarSize);
969
+ ctx.restore();
970
+ }
971
+ catch (e) { }
972
+ }
973
+ else {
974
+ ctx.fillStyle = '#333';
975
+ ctx.beginPath();
976
+ ctx.arc(tx + avatarSize / 2, ty + avatarSize / 2, avatarSize / 2, 0, Math.PI * 2);
977
+ ctx.fill();
978
+ }
979
+ ctx.fillStyle = C_TEXT_SEC;
980
+ ctx.font = `14px "${font}"`;
981
+ ctx.fillText('By', tx + avatarSize + 10, ty + 6);
982
+ const byW = ctx.measureText('By').width;
983
+ ctx.fillStyle = C_ACCENT;
984
+ ctx.font = `bold 14px "${font}"`;
985
+ ctx.fillText(data.author || 'Unknown', tx + avatarSize + 10 + byW + 6, ty + 6);
986
+ const tabY = cy + iconSize + 30;
987
+ ctx.fillStyle = C_DIVIDER;
988
+ ctx.fillRect(margin, tabY + 30, width - margin * 2, 2);
989
+ ctx.fillStyle = C_TEXT_MAIN;
990
+ ctx.font = `bold 16px "${font}"`;
991
+ ctx.fillText('Description', margin + 10, tabY);
992
+ ctx.fillStyle = C_ACCENT;
993
+ ctx.fillRect(margin, tabY + 28, 100, 4);
994
+ cy = tabY + 50;
995
+ }
996
+ else {
997
+ cy = margin;
998
+ }
999
+ // ================= Columns Draw =================
1000
+ const leftX = margin;
1001
+ const rightX = margin + mainW + gap;
1002
+ let ly = cy;
1003
+ let ry = cy;
1004
+ // --- Right Sidebar ---
1005
+ if (!contentOnly) {
1006
+ const drawSidePanel = (title) => {
1007
+ ctx.fillStyle = C_TEXT_MAIN;
1008
+ ctx.font = `bold 16px "${font}"`;
1009
+ ctx.textBaseline = 'top';
1010
+ ctx.fillText(title, rightX, ry);
1011
+ ctx.fillStyle = C_DIVIDER;
1012
+ // 分割线画在标题下方 25px
1013
+ ctx.fillRect(rightX, ry + 25, sidebarW, 1);
1014
+ return ry + 40; // 内容起始 Y
1015
+ };
1016
+ for (const item of sidebarItems) {
1017
+ if (item.type === 'actionBox') {
1018
+ ctx.fillStyle = C_BUTTON;
1019
+ roundRect(ctx, rightX, ry, sidebarW, 45, 4);
1020
+ ctx.fill();
1021
+ ctx.fillStyle = 'rgba(0,0,0,0.2)';
1022
+ ctx.fillRect(rightX + sidebarW - 50, ry, 1, 45);
1023
+ ctx.fillStyle = '#fff';
1024
+ ctx.font = `bold 16px "${font}"`;
1025
+ ctx.textBaseline = 'middle';
1026
+ ctx.textAlign = 'center';
1027
+ ctx.fillText('Download', rightX + (sidebarW - 50) / 2, ry + 22);
1028
+ ctx.textAlign = 'left';
1029
+ ctx.textBaseline = 'top';
1030
+ ry += item.h;
1031
+ }
1032
+ else if (item.type === 'sideFooter') {
1033
+ ctx.fillStyle = '#333';
1034
+ ctx.fillRect(rightX, ry + 20, sidebarW, 1);
1035
+ let fy = ry + 40;
1036
+ try {
1037
+ const base64Svg = Buffer.from(CF_LOGO_SVG).toString('base64');
1038
+ const logoImg = await loadImage(`data:image/svg+xml;base64,${base64Svg}`);
1039
+ const logoSize = 32;
1040
+ const cx = rightX + sidebarW / 2;
1041
+ ctx.drawImage(logoImg, cx - logoSize - 50, fy, logoSize, logoSize);
1042
+ ctx.fillStyle = '#fff';
1043
+ ctx.font = `bold 24px "${font}"`;
1044
+ ctx.textBaseline = 'middle';
1045
+ ctx.fillText('CurseForge', cx - 10, fy + logoSize / 2 + 2);
1046
+ }
1047
+ catch (e) { }
1048
+ fy += 50;
1049
+ ctx.fillStyle = '#c25c09';
1050
+ ctx.font = `12px "${font}"`;
1051
+ ctx.textAlign = 'center';
1052
+ ctx.textBaseline = 'top';
1053
+ ctx.fillText('插件作者 Mai_xiyu(机器人作者 Mai_xiyu)', rightX + sidebarW / 2, fy);
1054
+ ctx.textAlign = 'left';
1055
+ ry += item.h;
1056
+ }
1057
+ else if (item.type === 'listKV') {
1058
+ let currY = drawSidePanel(item.title);
1059
+ item.data.forEach(d => {
1060
+ ctx.fillStyle = C_TEXT_SEC;
1061
+ ctx.font = `14px "${font}"`;
1062
+ ctx.fillText(d.l, rightX, currY);
1063
+ ctx.textAlign = 'right';
1064
+ ctx.fillStyle = C_TEXT_MAIN;
1065
+ ctx.fillText(d.v, rightX + sidebarW, currY);
1066
+ ctx.textAlign = 'left';
1067
+ currY += 24;
1068
+ });
1069
+ ry = currY + 20;
1070
+ }
1071
+ else if (item.type === 'chips') {
1072
+ let currY = drawSidePanel(item.title);
1073
+ let cx = rightX;
1074
+ ctx.font = `12px "${font}"`;
1075
+ item.data.forEach(tag => {
1076
+ const tw = ctx.measureText(tag).width + 24;
1077
+ if (cx + tw > rightX + sidebarW) {
1078
+ cx = rightX;
1079
+ currY += 32;
1080
+ }
1081
+ ctx.fillStyle = C_PANEL;
1082
+ roundRect(ctx, cx, currY, tw, 24, 4);
1083
+ ctx.fill();
1084
+ ctx.fillStyle = C_TEXT_SEC;
1085
+ ctx.fillText(tag, cx + 12, currY + 6);
1086
+ cx += tw + 8;
1087
+ });
1088
+ ry = currY + 24 + 20;
1089
+ }
1090
+ else if (item.type === 'links') {
1091
+ let currY = drawSidePanel(item.title);
1092
+ item.data.forEach(l => {
1093
+ ctx.fillStyle = C_TEXT_MAIN;
1094
+ ctx.font = `14px "${font}"`;
1095
+ ctx.fillText(`🔗 ${l.name}`, rightX, currY);
1096
+ currY += 24;
1097
+ });
1098
+ ry = currY + 20;
1099
+ }
1100
+ else if (item.type === 'members') {
1101
+ let currY = drawSidePanel(item.title);
1102
+ const authorData = item.data[0];
1103
+ // Avatar
1104
+ ctx.save();
1105
+ ctx.beginPath();
1106
+ ctx.arc(rightX + 16, currY + 16, 16, 0, Math.PI * 2);
1107
+ ctx.clip();
1108
+ if (authorData.icon) {
1109
+ try {
1110
+ const img = await loadImageSafe(authorData.icon);
1111
+ ctx.drawImage(img, rightX, currY, 32, 32);
1112
+ }
1113
+ catch (e) {
1114
+ ctx.fillStyle = '#333';
1115
+ ctx.fill();
1116
+ }
1117
+ }
1118
+ else {
1119
+ ctx.fillStyle = '#333';
1120
+ ctx.fill();
1121
+ }
1122
+ ctx.restore();
1123
+ ctx.fillStyle = C_TEXT_MAIN;
1124
+ ctx.font = `bold 14px "${font}"`;
1125
+ ctx.fillText(authorData.name || 'User', rightX + 40, currY + 6);
1126
+ ctx.fillStyle = C_TEXT_SEC;
1127
+ ctx.font = `12px "${font}"`;
1128
+ ctx.fillText('Owner', rightX + 40, currY + 22);
1129
+ ry = currY + 50;
1130
+ }
1131
+ }
1132
+ }
1133
+ // --- Left Content ---
1134
+ if (!contentOnly) {
1135
+ ctx.fillStyle = C_TEXT_MAIN;
1136
+ ctx.font = `bold 24px "${font}"`;
1137
+ ctx.textBaseline = 'top';
1138
+ ctx.fillText('Description', leftX, ly);
1139
+ ly += 40;
1140
+ }
1141
+ for (const node of contentNodes) {
1142
+ if (node.type === 'text') {
1143
+ const isHeader = node.tag === 'h';
1144
+ const fontSize = isHeader ? 22 : 15;
1145
+ ctx.font = `${isHeader ? 'bold' : 'normal'} ${fontSize}px "${font}"`;
1146
+ ctx.fillStyle = isHeader ? '#ffffff' : '#d0d0d0';
1147
+ ctx.textBaseline = 'top';
1148
+ const lineHeight = Math.floor(fontSize * 1.5);
1149
+ ly = wrapText(ctx, node.val, leftX, ly, mainW, lineHeight, 10000, true) + (isHeader ? 15 : 10);
1150
+ if (isHeader)
1151
+ ly += 8;
1152
+ }
1153
+ else if (node.type === 'img' && !node.error && node.imgObj) {
1154
+ let drawH = node._drawH || node.dh;
1155
+ let drawW = node.dw;
1156
+ if (!node._drawH && drawH > 600) {
1157
+ const r = 600 / drawH;
1158
+ drawH = 600;
1159
+ drawW = drawW * r;
1160
+ }
1161
+ const dx = leftX + (mainW - drawW) / 2;
1162
+ try {
1163
+ ctx.drawImage(node.imgObj, dx, ly, drawW, drawH);
1164
+ }
1165
+ catch (e) { }
1166
+ ly += drawH + 20;
1167
+ }
1168
+ }
1169
+ return [canvas.toBuffer('image/png')];
1170
+ }
1171
+ // ================= API 交互 =================
1172
+ async function fetchModrinthDetail(id, timeout) {
1173
+ var _a, _b, _c, _d, _e, _f;
1174
+ const project = await fetchJson(`${MR_BASE}/project/${id}`, {}, timeout);
1175
+ let versions = [];
1176
+ try {
1177
+ versions = await fetchJson(`${MR_BASE}/project/${id}/version`, {}, timeout);
1178
+ }
1179
+ catch (e) { }
1180
+ let author = 'Unknown';
1181
+ try {
1182
+ const members = await fetchJson(`${MR_BASE}/project/${id}/members`, {}, timeout);
1183
+ author = ((_b = (_a = members.find(m => m.role === 'Owner')) === null || _a === void 0 ? void 0 : _a.user) === null || _b === void 0 ? void 0 : _b.username) || ((_d = (_c = members[0]) === null || _c === void 0 ? void 0 : _c.user) === null || _d === void 0 ? void 0 : _d.username) || author;
1184
+ }
1185
+ catch (e) { }
1186
+ let pageInfo = null;
1187
+ try {
1188
+ pageInfo = await fetchModrinthPage(project.slug, timeout);
1189
+ }
1190
+ catch (e) { }
1191
+ const gameVersions = new Set();
1192
+ const loaders = new Set();
1193
+ versions.forEach(v => {
1194
+ v.game_versions.forEach(gv => gameVersions.add(String(gv)));
1195
+ v.loaders.forEach(l => loaders.add(String(l)));
1196
+ });
1197
+ const links = [];
1198
+ if (project.source_url)
1199
+ links.push({ name: 'Source', url: project.source_url });
1200
+ if (project.issues_url)
1201
+ links.push({ name: 'Issues', url: project.issues_url });
1202
+ if (project.wiki_url)
1203
+ links.push({ name: 'Wiki', url: project.wiki_url });
1204
+ if (project.discord_url)
1205
+ links.push({ name: 'Discord', url: project.discord_url });
1206
+ // 排序版本号 (简单按长度和数值降序)
1207
+ const sortedVersions = Array.from(gameVersions).map(String).sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));
1208
+ let body = project.body;
1209
+ let bodyIsHtml = false;
1210
+ if (pageInfo === null || pageInfo === void 0 ? void 0 : pageInfo.overviewHtml) {
1211
+ body = pageInfo.overviewHtml;
1212
+ bodyIsHtml = true;
1213
+ }
1214
+ const firstMdImage = extractFirstMarkdownImage(project.body || '');
1215
+ if (firstMdImage && !bodyIsHtml) {
1216
+ body = `![](${firstMdImage})\n\n${body || ''}`;
1217
+ }
1218
+ let cover = null;
1219
+ try {
1220
+ const gallery = await fetchJson(`${MR_BASE}/project/${id}/gallery`, {}, timeout);
1221
+ if (Array.isArray(gallery) && gallery.length)
1222
+ cover = (_e = gallery[0]) === null || _e === void 0 ? void 0 : _e.url;
1223
+ }
1224
+ catch (e) { }
1225
+ return {
1226
+ source: 'Modrinth',
1227
+ id: project.id,
1228
+ name: project.title,
1229
+ author,
1230
+ icon: (pageInfo === null || pageInfo === void 0 ? void 0 : pageInfo.icon) || project.icon_url,
1231
+ summary: project.description,
1232
+ body,
1233
+ bodyIsHtml,
1234
+ downloads: project.downloads,
1235
+ follows: project.followers,
1236
+ updated: new Date(project.updated).toLocaleDateString(),
1237
+ created: new Date(project.published).toLocaleDateString(),
1238
+ license: (_f = project.license) === null || _f === void 0 ? void 0 : _f.id,
1239
+ tags: project.categories,
1240
+ gameVersions: sortedVersions,
1241
+ loaders: Array.from(loaders),
1242
+ clientSide: project.client_side,
1243
+ serverSide: project.server_side,
1244
+ links,
1245
+ cover,
1246
+ baseUrl: 'https://modrinth.com',
1247
+ url: `https://modrinth.com/${project.project_type === 'modpack' ? 'modpack' : 'mod'}/${project.slug}`
1248
+ };
1249
+ }
1250
+ async function fetchCurseForgeDetail(id, apiKey, timeout, cfUrl = null) {
1251
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r;
1252
+ const url = cfUrl || (id ? `https://www.curseforge.com/minecraft/mc-mods/${id}` : null);
1253
+ if (!url)
1254
+ throw new Error('CurseForge 页面地址为空');
1255
+ try {
1256
+ const html = await fetchCurseForgeHtml(url, timeout);
1257
+ const $ = cheerio.load(html);
1258
+ const icon = fixUrl($('img[class*="project-avatar"], img[class*="avatar"], img[alt][src*="thumbnail"]').first().attr('src') ||
1259
+ $('meta[property="og:image"]').attr('content'), 'https://www.curseforge.com');
1260
+ // 抓取作者头像
1261
+ const authorIcon = fixUrl($('.project-members .member-avatar img, .members .avatar img, .member-list img, img.avatar, img[alt*="avatar"]').first().attr('src'), 'https://www.curseforge.com');
1262
+ const overviewHtml = ($('.tab-content .description').first().html() ||
1263
+ $('.project-description').first().html() ||
1264
+ $('.description-content').first().html() ||
1265
+ $('.markdown').first().html());
1266
+ const name = $('h1').first().text().trim() || $('meta[property="og:title"]').attr('content') || 'Unknown';
1267
+ const summary = $('meta[name="description"]').attr('content') || $('meta[property="og:description"]').attr('content') || '';
1268
+ const author = $('a[href*="/members/"]').first().text().trim() || 'Unknown';
1269
+ const tags = new Set();
1270
+ $('.categories a, .tag-list a, a.tag, a.category').each((_, el) => {
1271
+ const t = $(el).text().trim();
1272
+ if (t)
1273
+ tags.add(t);
1274
+ });
1275
+ let downloads = null;
1276
+ $('.project-details__item, .detail-list-item, li, .project-description').each((_, el) => {
1277
+ const t = $(el).text();
1278
+ if (/Downloads?/i.test(t)) {
1279
+ const m = t.match(/([\d,.]+\s*[kKmM]?)/);
1280
+ if (m)
1281
+ downloads = parseCompactNumber(m[1]);
1282
+ }
1283
+ });
1284
+ let updated = null;
1285
+ let created = null;
1286
+ $('time, .project-details__item, .detail-list-item, li').each((_, el) => {
1287
+ const t = $(el).text();
1288
+ if (!updated && /Updated/i.test(t)) {
1289
+ const m = t.match(/Updated\s*:?\s*([^\n]+)/i);
1290
+ if (m)
1291
+ updated = m[1].trim();
1292
+ }
1293
+ if (!created && /Created/i.test(t)) {
1294
+ const m = t.match(/Created\s*:?\s*([^\n]+)/i);
1295
+ if (m)
1296
+ created = m[1].trim();
1297
+ }
1298
+ });
1299
+ const links = [];
1300
+ const seen = new Set();
1301
+ $('a[href^="http"]').each((_, el) => {
1302
+ const href = $(el).attr('href');
1303
+ if (!href)
1304
+ return;
1305
+ if (/curseforge\.com/i.test(href))
1306
+ return;
1307
+ if (seen.has(href))
1308
+ return;
1309
+ const text = $(el).text().trim();
1310
+ if (!text)
1311
+ return;
1312
+ seen.add(href);
1313
+ if (links.length < 6)
1314
+ links.push({ name: text, url: href });
1315
+ });
1316
+ const slug = url.split('/').filter(Boolean).pop();
1317
+ const body = overviewHtml || (summary ? `<p>${summary}</p>` : '');
1318
+ return {
1319
+ source: 'CurseForge',
1320
+ id: slug || id,
1321
+ name,
1322
+ author,
1323
+ authorIcon, // 新增
1324
+ icon,
1325
+ summary,
1326
+ body,
1327
+ bodyIsHtml: true,
1328
+ downloads: downloads || 0,
1329
+ follows: 0,
1330
+ updated: updated || '--',
1331
+ created: created || '--',
1332
+ license: 'Custom',
1333
+ tags: Array.from(tags),
1334
+ gameVersions: [],
1335
+ loaders: [],
1336
+ links,
1337
+ cover: icon,
1338
+ baseUrl: 'https://www.curseforge.com',
1339
+ url
1340
+ };
1341
+ }
1342
+ catch (e) {
1343
+ // Cloudflare 403 回退
1344
+ }
1345
+ const res = await fetchJson(`${CF_MIRROR_BASE}/mods/${id}`, {}, timeout);
1346
+ const mod = res.data;
1347
+ let desc = '';
1348
+ try {
1349
+ const descRes = await fetchJson(`${CF_MIRROR_BASE}/mods/${id}/description`, {}, timeout);
1350
+ desc = descRes.data;
1351
+ }
1352
+ catch (e) { }
1353
+ const gv = new Set();
1354
+ const ld = new Set();
1355
+ (mod.latestFilesIndexes || []).forEach(f => {
1356
+ if (f.gameVersion)
1357
+ gv.add(f.gameVersion);
1358
+ if (f.modLoader) {
1359
+ // 映射加载器 ID 到名称
1360
+ const name = CF_LOADER_MAP[f.modLoader];
1361
+ if (name)
1362
+ ld.add(name);
1363
+ }
1364
+ });
1365
+ if (gv.size === 0)
1366
+ (mod.latestFiles || []).forEach(f => (f.gameVersions || []).forEach(v => gv.add(v)));
1367
+ const links = [];
1368
+ if ((_a = mod.links) === null || _a === void 0 ? void 0 : _a.websiteUrl)
1369
+ links.push({ name: 'Website', url: mod.links.websiteUrl });
1370
+ if ((_b = mod.links) === null || _b === void 0 ? void 0 : _b.sourceUrl)
1371
+ links.push({ name: 'Source', url: mod.links.sourceUrl });
1372
+ if ((_c = mod.links) === null || _c === void 0 ? void 0 : _c.wikiUrl)
1373
+ links.push({ name: 'Wiki', url: mod.links.wikiUrl });
1374
+ const cover = ((_e = (_d = mod.screenshots) === null || _d === void 0 ? void 0 : _d.find(s => s.title)) === null || _e === void 0 ? void 0 : _e.thumbnailUrl) || ((_f = mod.logo) === null || _f === void 0 ? void 0 : _f.url) || ((_g = mod.logo) === null || _g === void 0 ? void 0 : _g.thumbnailUrl);
1375
+ const body = desc ? desc : (mod.summary ? `<p>${mod.summary}</p>` : '');
1376
+ return {
1377
+ source: 'CurseForge',
1378
+ id: mod.id,
1379
+ name: mod.name,
1380
+ author: ((_j = (_h = mod.authors) === null || _h === void 0 ? void 0 : _h[0]) === null || _j === void 0 ? void 0 : _j.name) || 'Unknown',
1381
+ authorIcon: ((_l = (_k = mod.authors) === null || _k === void 0 ? void 0 : _k[0]) === null || _l === void 0 ? void 0 : _l.avatarUrl) || ((_o = (_m = mod.authors) === null || _m === void 0 ? void 0 : _m[0]) === null || _o === void 0 ? void 0 : _o.avatar) || null,
1382
+ icon: ((_p = mod.logo) === null || _p === void 0 ? void 0 : _p.thumbnailUrl) || ((_q = mod.logo) === null || _q === void 0 ? void 0 : _q.url),
1383
+ summary: mod.summary,
1384
+ body,
1385
+ bodyIsHtml: true,
1386
+ downloads: mod.downloadCount,
1387
+ follows: mod.thumbsUpCount,
1388
+ updated: new Date(mod.dateModified).toLocaleDateString(),
1389
+ created: new Date(mod.dateCreated).toLocaleDateString(),
1390
+ license: 'Custom',
1391
+ tags: (mod.categories || []).map(c => c.name),
1392
+ gameVersions: Array.from(gv)
1393
+ .map(String)
1394
+ .filter(v => /\d/.test(v))
1395
+ .sort((a, b) => b.localeCompare(a, undefined, { numeric: true })),
1396
+ loaders: Array.from(ld).map(String).length ? Array.from(ld).map(String) : ['Forge', 'Fabric'],
1397
+ links,
1398
+ cover,
1399
+ baseUrl: 'https://www.curseforge.com',
1400
+ url: ((_r = mod.links) === null || _r === void 0 ? void 0 : _r.websiteUrl) || url
1401
+ };
1402
+ }
1403
+ // 搜索入口 (MR)
1404
+ async function searchModrinth(query, type, timeout) {
1405
+ const facet = MR_FACET_MAP[type];
1406
+ const url = `${MR_BASE}/search?query=${encodeURIComponent(query)}&facets=[["${facet}"]]&limit=20`;
1407
+ const json = await fetchJson(url, {}, timeout);
1408
+ return json.hits.map(hit => ({
1409
+ platform: 'Modrinth', id: hit.slug, name: hit.title, author: hit.author,
1410
+ summary: hit.description, type, icon: hit.icon_url,
1411
+ downloads: hit.downloads, updated: new Date(hit.date_modified).toLocaleDateString()
1412
+ }));
1413
+ }
1414
+ // 搜索入口 (CF)
1415
+ async function searchCurseForge(query, type, apiKey, timeout, gameId = 432) {
1416
+ const typeMap = {
1417
+ mod: 'mc-mods',
1418
+ pack: 'modpacks',
1419
+ resource: 'texture-packs',
1420
+ shader: 'shaders',
1421
+ plugin: 'bukkit-plugins',
1422
+ datapack: 'data-packs',
1423
+ world: 'worlds'
1424
+ };
1425
+ const slug = typeMap[type] || 'mc-mods';
1426
+ const searchUrl = `https://www.curseforge.com/minecraft/${slug}/search?search=${encodeURIComponent(query)}`;
1427
+ try {
1428
+ const html = await fetchCurseForgeHtml(searchUrl, timeout);
1429
+ const $ = cheerio.load(html);
1430
+ const results = [];
1431
+ const seen = new Set();
1432
+ const pickText = (el, sel) => $(el).find(sel).first().text().trim();
1433
+ const pickHref = (el) => $(el).find('a[href*="/minecraft/"]').first().attr('href');
1434
+ $('.project-listing-row, .project-card, article.project-card').each((_, el) => {
1435
+ const href = pickHref(el);
1436
+ if (!href)
1437
+ return;
1438
+ const url = fixUrl(href, 'https://www.curseforge.com');
1439
+ if (seen.has(url))
1440
+ return;
1441
+ seen.add(url);
1442
+ const name = pickText(el, 'a.project-card__name, a.name, .name, h3, h2') || $(el).find('a[href*="/minecraft/"]').first().text().trim();
1443
+ const summary = pickText(el, '.description, .summary, .project-card__summary, p');
1444
+ const author = pickText(el, '.author, .author-name, .project-author, a[href*="/members/"]');
1445
+ const icon = fixUrl($(el).find('img').first().attr('src'), 'https://www.curseforge.com');
1446
+ const dlText = pickText(el, '.download-count, .downloads, .project-downloads');
1447
+ const downloads = parseCompactNumber(dlText) || 0;
1448
+ const slugId = url.split('/').filter(Boolean).pop();
1449
+ results.push({
1450
+ platform: 'CurseForge',
1451
+ id: slugId,
1452
+ name,
1453
+ author,
1454
+ summary,
1455
+ type,
1456
+ icon,
1457
+ downloads,
1458
+ updated: '--',
1459
+ _cfUrl: url
1460
+ });
1461
+ });
1462
+ if (results.length)
1463
+ return results;
1464
+ }
1465
+ catch (e) {
1466
+ // Cloudflare 403 时回退到镜像 API
1467
+ }
1468
+ const classId = CF_CLASS_MAP[type];
1469
+ const mirrorUrl = `${CF_MIRROR_BASE}/mods/search?gameId=${encodeURIComponent(gameId)}&classId=${classId}&searchFilter=${encodeURIComponent(query)}&sortField=2&sortOrder=desc&pageSize=20`;
1470
+ const json = await fetchJson(mirrorUrl, {}, timeout);
1471
+ return (json.data || []).map(mod => {
1472
+ var _a, _b, _c, _d, _e;
1473
+ return ({
1474
+ platform: 'CurseForge',
1475
+ id: mod.id,
1476
+ name: mod.name,
1477
+ author: ((_b = (_a = mod.authors) === null || _a === void 0 ? void 0 : _a[0]) === null || _b === void 0 ? void 0 : _b.name) || 'Unknown',
1478
+ summary: mod.summary,
1479
+ type,
1480
+ icon: ((_c = mod.logo) === null || _c === void 0 ? void 0 : _c.thumbnailUrl) || ((_d = mod.logo) === null || _d === void 0 ? void 0 : _d.url),
1481
+ downloads: mod.downloadCount,
1482
+ updated: new Date(mod.dateModified).toLocaleDateString(),
1483
+ _cfUrl: ((_e = mod.links) === null || _e === void 0 ? void 0 : _e.websiteUrl) || (mod.slug ? `https://www.curseforge.com/minecraft/${slug}/${mod.slug}` : null)
1484
+ });
1485
+ });
1486
+ }
1487
+ // ================= Apply =================
1488
+ function apply(ctx, config) {
1489
+ var _a, _b;
1490
+ const logger = ctx.logger('mc-search');
1491
+ if (config.fontPath) {
1492
+ try {
1493
+ const ok = GlobalFonts.registerFromPath(config.fontPath, 'KoishiFont');
1494
+ if (ok)
1495
+ GLOBAL_FONT_FAMILY = 'KoishiFont';
1496
+ else
1497
+ logger.warn('字体加载失败: registerFromPath 返回 false');
1498
+ }
1499
+ catch (e) {
1500
+ logger.warn('字体加载失败: ' + e.message);
1501
+ }
1502
+ }
1503
+ const states = new Map();
1504
+ const normalizeMessageIds = (res) => {
1505
+ if (!res)
1506
+ return [];
1507
+ if (Array.isArray(res))
1508
+ return res;
1509
+ if (typeof res === 'string')
1510
+ return [res];
1511
+ if (res.messageId)
1512
+ return [res.messageId];
1513
+ return [];
1514
+ };
1515
+ const tryWithdraw = async (session, messageIds) => {
1516
+ const ids = normalizeMessageIds(messageIds);
1517
+ if (!ids.length)
1518
+ return;
1519
+ for (const id of ids) {
1520
+ try {
1521
+ await session.bot.deleteMessage(session.channelId, id);
1522
+ }
1523
+ catch (e) { }
1524
+ }
1525
+ };
1526
+ const formatList = (results, page, size) => {
1527
+ const total = Math.ceil(results.length / size);
1528
+ const list = results.slice(page * size, (page + 1) * size);
1529
+ return `搜索结果 (${page + 1}/${total}):\n` +
1530
+ list.map((item, i) => `${i + 1 + page * size}. [${item.platform}] ${item.name} - ${item.author}`).join('\n') +
1531
+ '\n请输入序号查看详情 (p/n 翻页)';
1532
+ };
1533
+ const handleSearch = async (session, platform, type, keyword) => {
1534
+ if (!keyword)
1535
+ return session.send('请输入关键词');
1536
+ let results = [];
1537
+ try {
1538
+ if (platform === 'mr')
1539
+ results = await searchModrinth(keyword, type, config.requestTimeout);
1540
+ else
1541
+ results = await searchCurseForge(keyword, type, config.curseforgeApiKey, config.requestTimeout, config.curseforgeGameId);
1542
+ }
1543
+ catch (e) {
1544
+ return session.send(`搜索出错: ${e.message}`);
1545
+ }
1546
+ if (!results.length)
1547
+ return session.send('未找到结果');
1548
+ if (results.length === 1) {
1549
+ const item = results[0];
1550
+ try {
1551
+ let detailData;
1552
+ if (item.platform === 'Modrinth')
1553
+ detailData = await fetchModrinthDetail(item.id, config.requestTimeout);
1554
+ else
1555
+ detailData = await fetchCurseForgeDetail(item.id, config.curseforgeApiKey, config.requestTimeout, item._cfUrl);
1556
+ detailData.type = item.type;
1557
+ const imgBufs = detailData.source === 'CurseForge'
1558
+ ? await drawProjectCardCF({
1559
+ ...detailData,
1560
+ maxCanvasHeight: config.maxCanvasHeight || 8000
1561
+ })
1562
+ : await drawProjectCard({
1563
+ ...detailData,
1564
+ maxCanvasHeight: config.maxCanvasHeight || 8000
1565
+ });
1566
+ for (const buf of imgBufs) {
1567
+ await session.send(h.image(buf, 'image/png'));
1568
+ }
1569
+ if (config.sendLink)
1570
+ await session.send(`链接: ${detailData.url}`);
1571
+ }
1572
+ catch (e) {
1573
+ logger.error(e);
1574
+ return session.send(`生成失败: ${e.message}`);
1575
+ }
1576
+ return;
1577
+ }
1578
+ states.set(session.cid, { results, page: 0, platform, type, listMessageIds: [] });
1579
+ const msgId = await session.send(formatList(results, 0, config.pageSize));
1580
+ states.get(session.cid).listMessageIds = normalizeMessageIds(msgId);
1581
+ };
1582
+ ctx.middleware(async (session, next) => {
1583
+ const state = states.get(session.cid);
1584
+ if (!state)
1585
+ return next();
1586
+ const text = session.content.trim();
1587
+ if (text === 'q') {
1588
+ states.delete(session.cid);
1589
+ return session.send('已退出');
1590
+ }
1591
+ if (text === 'n') {
1592
+ await tryWithdraw(session, state.listMessageIds);
1593
+ state.page++;
1594
+ const msgId = await session.send(formatList(state.results, state.page, config.pageSize));
1595
+ state.listMessageIds = normalizeMessageIds(msgId);
1596
+ return;
1597
+ }
1598
+ if (text === 'p') {
1599
+ await tryWithdraw(session, state.listMessageIds);
1600
+ state.page = Math.max(0, state.page - 1);
1601
+ const msgId = await session.send(formatList(state.results, state.page, config.pageSize));
1602
+ state.listMessageIds = normalizeMessageIds(msgId);
1603
+ return;
1604
+ }
1605
+ const idx = parseInt(text);
1606
+ if (!isNaN(idx) && idx > 0) {
1607
+ const item = state.results[idx - 1];
1608
+ if (item) {
1609
+ await tryWithdraw(session, state.listMessageIds);
1610
+ states.delete(session.cid);
1611
+ try {
1612
+ let detailData;
1613
+ if (item.platform === 'Modrinth')
1614
+ detailData = await fetchModrinthDetail(item.id, config.requestTimeout);
1615
+ else
1616
+ detailData = await fetchCurseForgeDetail(item.id, config.curseforgeApiKey, config.requestTimeout, item._cfUrl);
1617
+ detailData.type = item.type;
1618
+ const imgBufs = detailData.source === 'CurseForge'
1619
+ ? await drawProjectCardCF({
1620
+ ...detailData,
1621
+ maxCanvasHeight: config.maxCanvasHeight || 8000
1622
+ })
1623
+ : await drawProjectCard({
1624
+ ...detailData,
1625
+ maxCanvasHeight: config.maxCanvasHeight || 8000
1626
+ });
1627
+ for (const buf of imgBufs) {
1628
+ await session.send(h.image(buf, 'image/png'));
1629
+ }
1630
+ if (config.sendLink)
1631
+ await session.send(`链接: ${detailData.url}`);
1632
+ }
1633
+ catch (e) {
1634
+ logger.error(e);
1635
+ return session.send(`生成失败: ${e.message}`);
1636
+ }
1637
+ return;
1638
+ }
1639
+ }
1640
+ return next();
1641
+ });
1642
+ const cfPrefix = ((_a = config === null || config === void 0 ? void 0 : config.prefixes) === null || _a === void 0 ? void 0 : _a.cf) || 'cf';
1643
+ const mrPrefix = ((_b = config === null || config === void 0 ? void 0 : config.prefixes) === null || _b === void 0 ? void 0 : _b.mr) || 'mr';
1644
+ ctx.command(`${mrPrefix}.help`).action(() => [
1645
+ `${mrPrefix} <关键词> | 默认搜索 Modrinth Mod`,
1646
+ `${mrPrefix}.mod/.pack/.resource/.shader/.plugin <关键词>`,
1647
+ '列表交互:输入序号查看,n 下一页,p 上一页,q 退出',
1648
+ ].join('\n'));
1649
+ ctx.command(`${cfPrefix}.help`).action(() => [
1650
+ `${cfPrefix} <关键词> | 默认搜索 CurseForge Mod`,
1651
+ `${cfPrefix}.mod/.pack/.resource/.shader/.plugin <关键词>`,
1652
+ '列表交互:输入序号查看,n 下一页,p 上一页,q 退出',
1653
+ ].join('\n'));
1654
+ ['mod', 'pack', 'resource', 'shader', 'plugin'].forEach(t => {
1655
+ ctx.command(`${mrPrefix}.${t} <keyword:text>`).action(({ session }, kw) => handleSearch(session, 'mr', t, kw));
1656
+ ctx.command(`${cfPrefix}.${t} <keyword:text>`).action(({ session }, kw) => handleSearch(session, 'cf', t, kw));
1657
+ });
1658
+ ctx.command(`${mrPrefix} <keyword:text>`).action(({ session }, kw) => handleSearch(session, 'mr', 'mod', kw));
1659
+ ctx.command(`${cfPrefix} <keyword:text>`).action(({ session }, kw) => handleSearch(session, 'cf', 'mod', kw));
1660
+ }