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,2733 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Config = exports.name = void 0;
4
+ exports.apply = apply;
5
+ const fetch = require('node-fetch');
6
+ const cheerio = require('cheerio');
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const { createCanvas, loadImage, registerFont, GlobalFonts } = require('@napi-rs/canvas');
10
+ const { h, Schema } = require('koishi');
11
+ // Cookie 管理器
12
+ let cookieManager = null;
13
+ try {
14
+ cookieManager = require('../../cookie-manager');
15
+ }
16
+ catch (e) {
17
+ // cookie-manager 不存在时静默忽略
18
+ }
19
+ // ================= 状态管理和常量 =================
20
+ const searchStates = new Map();
21
+ const PAGE_SIZE = 10;
22
+ const TIMEOUT_MS = 60000;
23
+ const BASE_URL = 'https://mcmod.cn';
24
+ const CENTER_URL = 'https://center.mcmod.cn';
25
+ // 备用接口类型映射
26
+ const COMMON_SELECT_URL = 'https://www.mcmod.cn/object/CommonSelect/';
27
+ const FALLBACK_TYPE_MAP = {
28
+ mod: 'post_relation_mod',
29
+ pack: 'post_relation_modpack',
30
+ author: 'author'
31
+ };
32
+ // 全局字体变量
33
+ let GLOBAL_FONT_FAMILY = 'sans-serif';
34
+ // 全局 Cookie 变量
35
+ let globalCookie = '';
36
+ let cookieLastCheck = 0;
37
+ const COOKIE_CHECK_INTERVAL = 30 * 60 * 1000; // 30分钟检查一次
38
+ // ================= 辅助工具 =================
39
+ async function fetchWithTimeout(url, opts = {}, timeout = 15000) {
40
+ const controller = new AbortController();
41
+ const id = setTimeout(() => controller.abort(), timeout);
42
+ try {
43
+ const res = await fetch(url, { ...opts, signal: controller.signal });
44
+ clearTimeout(id);
45
+ return res;
46
+ }
47
+ catch (err) {
48
+ clearTimeout(id);
49
+ throw err;
50
+ }
51
+ }
52
+ function getHeaders(referer = 'https://mcmod.cn/') {
53
+ const headers = {
54
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
55
+ 'Referer': referer,
56
+ 'Accept-Language': 'zh-CN,zh;q=0.9',
57
+ 'X-Requested-With': 'XMLHttpRequest'
58
+ };
59
+ if (globalCookie) {
60
+ headers['Cookie'] = globalCookie;
61
+ }
62
+ return headers;
63
+ }
64
+ // 确保 Cookie 有效(自动刷新)
65
+ async function ensureValidCookie() {
66
+ const now = Date.now();
67
+ // 如果距离上次检查不到30分钟,跳过
68
+ if (globalCookie && (now - cookieLastCheck) < COOKIE_CHECK_INTERVAL) {
69
+ return;
70
+ }
71
+ // 如果有 cookieManager,尝试自动获取
72
+ if (cookieManager) {
73
+ try {
74
+ const cookie = await cookieManager.getCookie();
75
+ if (cookie) {
76
+ globalCookie = cookie;
77
+ cookieLastCheck = now;
78
+ }
79
+ }
80
+ catch (e) {
81
+ // 静默失败
82
+ }
83
+ }
84
+ }
85
+ function cleanText(text) {
86
+ if (!text)
87
+ return '';
88
+ return text.replace(/[\r\n\t]+/g, '').trim();
89
+ }
90
+ function fixUrl(url) {
91
+ if (!url)
92
+ return null;
93
+ if (url.startsWith('//'))
94
+ return 'https:' + url;
95
+ if (url.startsWith('/'))
96
+ return BASE_URL + url;
97
+ if (!url.startsWith('http'))
98
+ return BASE_URL + '/' + url;
99
+ return url;
100
+ }
101
+ function roundRect(ctx, x, y, w, h, r) {
102
+ if (w < 2 * r)
103
+ r = w / 2;
104
+ if (h < 2 * r)
105
+ r = h / 2;
106
+ ctx.beginPath();
107
+ ctx.moveTo(x + r, y);
108
+ ctx.arcTo(x + w, y, x + w, y + h, r);
109
+ ctx.arcTo(x + w, y + h, x, y + h, r);
110
+ ctx.arcTo(x, y + h, x, y, r);
111
+ ctx.arcTo(x, y, x + w, y, r);
112
+ ctx.closePath();
113
+ }
114
+ function wrapText(ctx, text, x, y, maxWidth, lineHeight, maxLines = 1000, draw = true) {
115
+ if (!text)
116
+ return y;
117
+ const words = text.split('');
118
+ let line = '';
119
+ let linesCount = 0;
120
+ let currentY = y;
121
+ for (let n = 0; n < words.length; n++) {
122
+ const testLine = line + words[n];
123
+ const metrics = ctx.measureText(testLine);
124
+ if (metrics.width > maxWidth && n > 0) {
125
+ if (draw)
126
+ ctx.fillText(line, x, currentY);
127
+ line = words[n];
128
+ currentY += lineHeight;
129
+ linesCount++;
130
+ if (linesCount >= maxLines)
131
+ return currentY;
132
+ }
133
+ else {
134
+ line = testLine;
135
+ }
136
+ }
137
+ if (draw)
138
+ ctx.fillText(line, x, currentY);
139
+ return currentY + lineHeight;
140
+ }
141
+ // ================= 字体注册 =================
142
+ function initFont(preferredPath, logger) {
143
+ const fontName = 'MCModFont';
144
+ const tryRegister = (filePath, source) => {
145
+ if (!fs.existsSync(filePath))
146
+ return false;
147
+ try {
148
+ if (GlobalFonts.registerFromPath(filePath, fontName)) {
149
+ GLOBAL_FONT_FAMILY = fontName;
150
+ logger.info(`[Font] 成功加载${source}: ${filePath}`);
151
+ return true;
152
+ }
153
+ }
154
+ catch (e) { }
155
+ return false;
156
+ };
157
+ if (preferredPath) {
158
+ let abs = path.isAbsolute(preferredPath) ? preferredPath : path.resolve(process.cwd(), preferredPath);
159
+ if (tryRegister(abs, '配置字体'))
160
+ return true;
161
+ }
162
+ const candidates = [
163
+ 'C:\\Windows\\Fonts\\msyh.ttc', 'C:\\Windows\\Fonts\\msyh.ttf', 'C:\\Windows\\Fonts\\simhei.ttf',
164
+ '/usr/share/fonts/truetype/noto/NotoSansSC-Regular.otf', '/usr/share/fonts/noto/NotoSansSC-Regular.otf',
165
+ '/usr/share/fonts/wqy-zenhei/wqy-zenhei.ttc', '/System/Library/Fonts/PingFang.ttc'
166
+ ];
167
+ for (const p of candidates) {
168
+ if (tryRegister(p, '系统字体'))
169
+ return true;
170
+ }
171
+ return false;
172
+ }
173
+ // ================= 搜索逻辑 =================
174
+ async function fetchSearch(query, typeKey) {
175
+ const filterMap = { mod: 1, pack: 2, data: 3, tutorial: 4, author: 5, user: 6 };
176
+ const filter = filterMap[typeKey] || 1;
177
+ const searchUrl = `https://search.mcmod.cn/s?key=${encodeURIComponent(query)}&filter=${filter}&mold=0`;
178
+ let results = [];
179
+ // --- 1. 尝试主站爬虫搜索 ---
180
+ try {
181
+ const res = await fetchWithTimeout(searchUrl, { headers: getHeaders('https://search.mcmod.cn/') });
182
+ const html = await res.text();
183
+ const $ = cheerio.load(html);
184
+ $('.result-item, .media, .search-list .item, .user-list .row, .list .row').each((i, el) => {
185
+ const $el = $(el);
186
+ let titleEl = $el.find('.head > a').first();
187
+ if (!titleEl.length)
188
+ titleEl = $el.find('.media-heading a').first();
189
+ if (!titleEl.length) {
190
+ $el.find('a').each((j, a) => {
191
+ if ($(a).text().trim().length > 0 && !titleEl.length)
192
+ titleEl = $(a);
193
+ });
194
+ }
195
+ let title = cleanText(titleEl.text());
196
+ let link = titleEl.attr('href');
197
+ let modName = cleanText($el.find('.meta span, .source').first().text()) || cleanText($el.find('.media-body .text-muted').first().text());
198
+ if (title && link) {
199
+ link = fixUrl(link);
200
+ if (link && !link.includes('target=') && !/^\d+$/.test(title)) {
201
+ let summary = cleanText($el.find('.body, .media-body').text());
202
+ summary = summary.replace(title, '').replace(modName, '').trim();
203
+ results.push({ title, link, modName: modName || '', summary });
204
+ }
205
+ }
206
+ });
207
+ }
208
+ catch (e) {
209
+ // 主站搜索失败忽略,继续走备用
210
+ }
211
+ // --- 2. 备用接口兜底逻辑 ---
212
+ if (results.length === 0) {
213
+ try {
214
+ const fallbackResults = await fetchSearchFallback(query, typeKey);
215
+ if (fallbackResults && fallbackResults.length > 0) {
216
+ return fallbackResults;
217
+ }
218
+ }
219
+ catch (e) {
220
+ // 备用接口失败则彻底无结果
221
+ }
222
+ }
223
+ return results;
224
+ }
225
+ // [修改后] 适配真实返回结构的备用接口
226
+ async function fetchSearchFallback(query, typeKey) {
227
+ const apiType = FALLBACK_TYPE_MAP[typeKey];
228
+ if (!apiType)
229
+ return [];
230
+ try {
231
+ const requestData = { key: query, type: apiType };
232
+ const params = new URLSearchParams();
233
+ params.append('data', JSON.stringify(requestData));
234
+ const headers = {
235
+ ...getHeaders('https://www.mcmod.cn'),
236
+ 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
237
+ };
238
+ const res = await fetchWithTimeout(COMMON_SELECT_URL, {
239
+ method: 'POST',
240
+ headers: headers,
241
+ body: params
242
+ });
243
+ const json = await res.json();
244
+ // 真实返回结构: { state: 0, html: "<table>...</table>" }
245
+ if (json.state === 0 && json.html) {
246
+ const $ = cheerio.load(json.html);
247
+ const results = [];
248
+ $('tr[data-id]').each((i, el) => {
249
+ const $el = $(el);
250
+ const id = $el.attr('data-id');
251
+ if (!id)
252
+ return;
253
+ let title = '';
254
+ let summary = '(来自快速索引)';
255
+ let link = '';
256
+ if (typeKey === 'author') {
257
+ // 作者结构: <td><b>酒石酸菌</b> - <i class="text-muted">TartaricAcid...</i></td>
258
+ title = cleanText($el.find('b').text()) || cleanText($el.text());
259
+ summary = cleanText($el.find('i').text());
260
+ link = `https://www.mcmod.cn/author/${id}.html`;
261
+ }
262
+ else {
263
+ // 模组/整合包结构: <td>ID:19638 [RWFJ] 彩虹扳手...</td>
264
+ const rawText = cleanText($el.text());
265
+ // 去掉开头的 "ID:12345 ",保留后面更有用的名称
266
+ title = rawText.replace(/^ID:\d+\s*/, '');
267
+ link = `https://www.mcmod.cn/class/${id}.html`;
268
+ summary = `ID: ${id}`; // 模组把 ID 放在摘要里
269
+ }
270
+ if (title && link) {
271
+ results.push({
272
+ title: title,
273
+ link: link,
274
+ modName: typeKey === 'pack' ? '整合包' : '',
275
+ summary: summary
276
+ });
277
+ }
278
+ });
279
+ return results;
280
+ }
281
+ }
282
+ catch (e) {
283
+ // console.error('备用接口解析失败:', e);
284
+ }
285
+ return [];
286
+ }
287
+ function formatListPage(items, pageIndex, type) {
288
+ const total = Math.max(1, Math.ceil(items.length / PAGE_SIZE));
289
+ const page = items.slice(pageIndex * PAGE_SIZE, (pageIndex + 1) * PAGE_SIZE);
290
+ const typeName = { mod: '模组', pack: '整合包', data: '资料', tutorial: '教程', author: '作者', user: '用户' }[type] || '结果';
291
+ let text = `[mcmod] 搜索到的${typeName} (第 ${pageIndex + 1}/${total} 页):\n`;
292
+ page.forEach((it, idx) => text += `${(pageIndex * PAGE_SIZE) + idx + 1}. ${it.title}${it.modName ? ` 《${it.modName.replace(/[《》]/g, '')}》` : ''}\n`);
293
+ text += '\n发送序号选择,p/n 翻页,q 退出。';
294
+ return text;
295
+ }
296
+ // ================= 渲染:模组/整合包卡片 (macOS 风格) =================
297
+ async function drawModCard(url) {
298
+ const res = await fetchWithTimeout(url, { headers: getHeaders() });
299
+ const html = await res.text();
300
+ const $ = cheerio.load(html);
301
+ // --- 1. 数据抓取 (保持原逻辑,确保稳定性) ---
302
+ const titleHtml = $('.class-title').html() || '';
303
+ const cleanTitleStr = titleHtml
304
+ .replace(/<div class="class-official-group"[\s\S]*?<\/div>/gi, '')
305
+ .replace(/<[^>]+>/g, '\n');
306
+ const titleLines = cleanTitleStr.split('\n').map(s => s.trim()).filter(s => s);
307
+ const title = titleLines[0] || cleanText($('.class-title').text().replace(/开源|活跃|稳定|闭源|停更|弃坑|半弃坑|Beta/g, '').trim());
308
+ const subTitle = titleLines.slice(1).join(' ');
309
+ let coverUrl = fixUrl($('.class-cover-image img').attr('src'));
310
+ let iconUrl = fixUrl($('.class-icon img').attr('src'));
311
+ // 如果没有封面,用图标代替;如果没有图标,尝试用封面代替
312
+ if (!coverUrl && iconUrl)
313
+ coverUrl = iconUrl;
314
+ if (!iconUrl && coverUrl)
315
+ iconUrl = coverUrl;
316
+ // 标签
317
+ const tags = [];
318
+ const officialTags = new Set();
319
+ $('.class-official-group div').each((i, el) => {
320
+ const txt = cleanText($(el).text());
321
+ if (!txt || txt.length > 20)
322
+ return;
323
+ officialTags.add(txt);
324
+ let color = '#999', bg = '#eee';
325
+ if (txt.includes('开源') || txt.includes('活跃') || txt.includes('稳定')) {
326
+ color = '#2ecc71';
327
+ bg = '#e8f5e9';
328
+ }
329
+ else if (txt.includes('半弃坑') || txt.includes('Beta')) {
330
+ color = '#f39c12';
331
+ bg = '#fef9e7';
332
+ }
333
+ else if (txt.includes('停更') || txt.includes('闭源') || txt.includes('弃坑')) {
334
+ color = '#e74c3c';
335
+ bg = '#fce4ec';
336
+ }
337
+ tags.push({ t: txt, bg, c: color });
338
+ });
339
+ $('.class-label-list a').each((i, el) => {
340
+ const labelText = cleanText($(el).text());
341
+ if (!labelText || officialTags.has(labelText))
342
+ return;
343
+ const cls = $(el).attr('class') || '';
344
+ let bg = '#e3f2fd', c = '#3498db';
345
+ if (cls.includes('c_1')) {
346
+ bg = '#e8f5e9';
347
+ c = '#2ecc71';
348
+ }
349
+ else if (cls.includes('c_3')) {
350
+ bg = '#fff3e0';
351
+ c = '#e67e22';
352
+ }
353
+ tags.push({ t: labelText, bg, c });
354
+ });
355
+ // 统计数据
356
+ let score = cleanText($('.class-score-num').text());
357
+ let scoreComment = '';
358
+ if (!score || score === '') {
359
+ score = cleanText($('.class-excount .star .up').text()) || '0.0';
360
+ scoreComment = cleanText($('.class-excount .star .down').text());
361
+ }
362
+ if (!scoreComment)
363
+ scoreComment = '暂无评价';
364
+ const yIndex = cleanText($('.class-excount .star .text').first().text().replace('昨日指数:', '').trim());
365
+ let viewNum = '0', fillRate = '--';
366
+ $('.class-excount .infos .span').each((i, el) => {
367
+ const t = $(el).find('.t').text();
368
+ const n = cleanText($(el).find('.n').text());
369
+ if (t.includes('浏览'))
370
+ viewNum = n;
371
+ if (t.includes('填充'))
372
+ fillRate = n;
373
+ });
374
+ function getSocialNum(className) {
375
+ let result = '0';
376
+ const selectors = [
377
+ `.common-fuc-group li.${className} div.nums`, `.common-fuc-group li.${className} .nums`,
378
+ `li.${className} div.nums`, `li.${className} .nums`
379
+ ];
380
+ for (const sel of selectors) {
381
+ const el = $(sel);
382
+ if (el.length > 0) {
383
+ const titleAttr = el.attr('title');
384
+ if (titleAttr && /^\d+$/.test(titleAttr.replace(/,/g, '').trim())) {
385
+ result = titleAttr.replace(/,/g, '').trim();
386
+ break;
387
+ }
388
+ const text = el.text().replace(/,/g, '').trim();
389
+ if (text && /^\d+$/.test(text)) {
390
+ result = text;
391
+ break;
392
+ }
393
+ }
394
+ }
395
+ return result;
396
+ }
397
+ const pushNum = getSocialNum('push');
398
+ const favNum = getSocialNum('like');
399
+ const subNum = getSocialNum('subscribe');
400
+ // 作者
401
+ const authors = [];
402
+ $('.author-list li, .author li').each((i, el) => {
403
+ const n = cleanText($(el).find('.name').text());
404
+ const r = cleanText($(el).find('.position').text());
405
+ const iurl = fixUrl($(el).find('img').attr('src'));
406
+ if (n)
407
+ authors.push({ n, r, i: iurl });
408
+ });
409
+ // 属性
410
+ const props = [];
411
+ $('.class-meta-list li').each((i, el) => {
412
+ const l = cleanText($(el).find('h4').text());
413
+ const v = cleanText($(el).find('.text').text());
414
+ if (l && v && !l.includes('编辑') && !l.includes('推荐') && !l.includes('收录') && !l.includes('最后')) {
415
+ props.push({ l, v });
416
+ }
417
+ });
418
+ // 版本
419
+ const versions = [];
420
+ const mcVerRoot = $('.mcver');
421
+ let verGroups = mcVerRoot.find('ul ul');
422
+ if (verGroups.length === 0)
423
+ verGroups = mcVerRoot.find('ul').first();
424
+ const allUls = mcVerRoot.find('ul');
425
+ allUls.each((i, ul) => {
426
+ if ($(ul).find('ul').length > 0)
427
+ return;
428
+ let loader = '';
429
+ const vers = [];
430
+ $(ul).find('li').each((j, li) => {
431
+ const txt = cleanText($(li).text());
432
+ if (txt.includes(':') || txt.includes(':'))
433
+ loader = txt.replace(/[::]/g, '').trim();
434
+ else
435
+ vers.push(txt);
436
+ });
437
+ if (loader && vers.length > 0)
438
+ versions.push({ l: loader, v: vers.join(', ') });
439
+ });
440
+ // 链接
441
+ const links = [];
442
+ $('.common-link-icon-frame a').each((i, el) => {
443
+ const name = $(el).attr('data-original-title') || 'Link';
444
+ let sn = name;
445
+ if (name.includes('GitHub'))
446
+ sn = 'GitHub';
447
+ else if (name.includes('CurseForge'))
448
+ sn = 'CurseForge';
449
+ else if (name.includes('Modrinth'))
450
+ sn = 'Modrinth';
451
+ else if (name.includes('百科'))
452
+ sn = 'Wiki';
453
+ links.push(sn);
454
+ });
455
+ // 简介解析
456
+ const descRoot = $('.common-text').first();
457
+ const descNodes = [];
458
+ function parseNode(node, depth = 0) {
459
+ if (depth > 10)
460
+ return;
461
+ if (node.type === 'text') {
462
+ const t = cleanText(node.data);
463
+ if (t && t.length > 1) {
464
+ const lastNode = descNodes[descNodes.length - 1];
465
+ if (!lastNode || lastNode.type !== 't' || lastNode.val !== t)
466
+ descNodes.push({ type: 't', val: t, tag: 'p' });
467
+ }
468
+ }
469
+ else if (node.type === 'tag') {
470
+ const tagName = node.name;
471
+ if (tagName === 'img') {
472
+ const src = node.attribs['data-src'] || node.attribs['src'];
473
+ if (src && !src.includes('icon') && !src.includes('smilies') && !src.includes('loading'))
474
+ descNodes.push({ type: 'i', src: fixUrl(src) });
475
+ }
476
+ else if (['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tagName)) {
477
+ const text = cleanText($(node).text());
478
+ if (text && text.length > 1)
479
+ descNodes.push({ type: 't', val: text, tag: 'h' });
480
+ }
481
+ else if (tagName === 'li') {
482
+ const text = cleanText($(node).text());
483
+ if (text && text.length > 1)
484
+ descNodes.push({ type: 't', val: '• ' + text, tag: 'li' });
485
+ }
486
+ else if (tagName === 'br') {
487
+ descNodes.push({ type: 'br' });
488
+ }
489
+ else if (['p', 'div', 'span', 'section', 'article', 'ul', 'ol', 'strong', 'b', 'em', 'i'].includes(tagName)) {
490
+ if (node.children)
491
+ node.children.forEach(child => parseNode(child, depth + 1));
492
+ }
493
+ }
494
+ }
495
+ if (descRoot.length)
496
+ descRoot[0].children.forEach(child => parseNode(child, 0));
497
+ if (descNodes.length === 0) {
498
+ const metaDesc = $('meta[name="description"]').attr('content');
499
+ if (metaDesc)
500
+ descNodes.push({ type: 't', val: metaDesc, tag: 'p' });
501
+ }
502
+ // --- 2. 布局计算 (macOS 风格) ---
503
+ const width = 800;
504
+ const font = GLOBAL_FONT_FAMILY;
505
+ const margin = 20; // 窗口外边距
506
+ const winPadding = 35; // 窗口内边距
507
+ const contentW = width - margin * 2 - winPadding * 2;
508
+ // 预计算高度
509
+ const dummyC = createCanvas(100, 100);
510
+ const dummy = dummyC.getContext('2d');
511
+ dummy.font = `bold 32px "${font}"`;
512
+ // 头部区域 (Header)
513
+ let headerH = 100; // Icon(80) + padding
514
+ const titleLinesNum = wrapText(dummy, title, 0, 0, contentW - 100, 40, 10, false) / 40;
515
+ headerH = Math.max(headerH, 10 + titleLinesNum * 40 + (subTitle ? 25 : 0) + (authors.length ? 40 : 0));
516
+ // 标签区域
517
+ let tagsH = 0;
518
+ if (tags.length)
519
+ tagsH = 40;
520
+ // 封面图 (Cover)
521
+ let coverH = 0;
522
+ if (coverUrl)
523
+ coverH = 300; // 固定封面显示高度
524
+ // 统计数据 (Stats Grid)
525
+ // 布局:每行4个数据
526
+ const statsItems = [
527
+ { l: '评分', v: score }, { l: '热度', v: viewNum },
528
+ { l: '推荐', v: pushNum }, { l: '收藏', v: favNum },
529
+ { l: '关注', v: subNum }
530
+ ];
531
+ if (fillRate !== '--')
532
+ statsItems.push({ l: '填充率', v: fillRate });
533
+ if (yIndex)
534
+ statsItems.push({ l: '昨日指数', v: yIndex });
535
+ let statsH = 0;
536
+ if (statsItems.length) {
537
+ const rows = Math.ceil(statsItems.length / 4);
538
+ statsH = rows * 70 + (rows - 1) * 15;
539
+ }
540
+ // 属性列表 (Props)
541
+ let propsH = 0;
542
+ if (props.length) {
543
+ const rows = Math.ceil(props.length / 2);
544
+ propsH = rows * 30 + 10;
545
+ }
546
+ // 版本和链接
547
+ let extraH = 0;
548
+ if (versions.length) {
549
+ extraH += 30; // Title
550
+ versions.forEach(v => {
551
+ dummy.font = `14px "${font}"`;
552
+ const lw = dummy.measureText(v.l).width + 10;
553
+ const lines = wrapText(dummy, v.v, 0, 0, contentW - lw, 20, 100, false) / 20;
554
+ extraH += lines * 20 + 10;
555
+ });
556
+ }
557
+ if (links.length)
558
+ extraH += 50;
559
+ // 简介 (Desc)
560
+ let descH = 0;
561
+ dummy.font = `16px "${font}"`;
562
+ for (const node of descNodes) {
563
+ if (node.type === 't') {
564
+ const isHeader = node.tag === 'h';
565
+ dummy.font = `${isHeader ? 'bold' : ''} ${isHeader ? 22 : 16}px "${font}"`;
566
+ const lh = isHeader ? 32 : 26;
567
+ const lines = wrapText(dummy, node.val, 0, 0, contentW, lh, 100, false) / lh;
568
+ descH += lines * lh + (isHeader ? 15 : 10);
569
+ }
570
+ else if (node.type === 'i') {
571
+ descH += 400; // 估算图片高度
572
+ }
573
+ else if (node.type === 'br') {
574
+ descH += 10;
575
+ }
576
+ }
577
+ if (descH > 0)
578
+ descH += 50; // Title + Padding
579
+ // 总高度
580
+ let cursorY = margin + 40; // Top traffic lights area
581
+ const components = [
582
+ { h: headerH, gap: 20 },
583
+ { h: tagsH, gap: 10 },
584
+ { h: coverH, gap: 25 },
585
+ { h: statsH, gap: 25 },
586
+ { h: propsH, gap: 25 },
587
+ { h: extraH, gap: 25 },
588
+ { h: descH, gap: 20 }
589
+ ];
590
+ components.forEach(c => { if (c.h > 0)
591
+ cursorY += c.h + c.gap; });
592
+ const windowH = cursorY;
593
+ const totalH = windowH + margin * 2;
594
+ // --- 3. 开始绘制 ---
595
+ const canvas = createCanvas(width, totalH);
596
+ const ctx = canvas.getContext('2d');
597
+ // 背景 (Bing 壁纸)
598
+ try {
599
+ const bgUrl = 'https://bing.biturl.top/?resolution=1920&format=image&index=0&mkt=zh-CN';
600
+ const bgImg = await loadImage(bgUrl);
601
+ const r = Math.max(width / bgImg.width, totalH / bgImg.height);
602
+ ctx.drawImage(bgImg, (width - bgImg.width * r) / 2, (totalH - bgImg.height * r) / 2, bgImg.width * r, bgImg.height * r);
603
+ ctx.fillStyle = 'rgba(0,0,0,0.15)'; // 遮罩
604
+ ctx.fillRect(0, 0, width, totalH);
605
+ }
606
+ catch (e) {
607
+ const grad = ctx.createLinearGradient(0, 0, 0, totalH);
608
+ grad.addColorStop(0, '#e0c3fc');
609
+ grad.addColorStop(1, '#8ec5fc');
610
+ ctx.fillStyle = grad;
611
+ ctx.fillRect(0, 0, width, totalH);
612
+ }
613
+ // 窗口 (Acrylic)
614
+ const winX = margin;
615
+ const winY = margin;
616
+ ctx.save();
617
+ ctx.shadowColor = 'rgba(0,0,0,0.2)';
618
+ ctx.shadowBlur = 40;
619
+ ctx.shadowOffsetY = 20;
620
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.85)';
621
+ roundRect(ctx, winX, winY, width - margin * 2, windowH, 16);
622
+ ctx.fill();
623
+ ctx.restore();
624
+ // 窗口边框
625
+ ctx.strokeStyle = 'rgba(255,255,255,0.5)';
626
+ ctx.lineWidth = 1;
627
+ roundRect(ctx, winX, winY, width - margin * 2, windowH, 16);
628
+ ctx.stroke();
629
+ // 交通灯
630
+ const trafficY = winY + 20;
631
+ ['#ff5f56', '#ffbd2e', '#27c93f'].forEach((c, i) => {
632
+ ctx.beginPath();
633
+ ctx.arc(winX + 20 + i * 25, trafficY, 6, 0, Math.PI * 2);
634
+ ctx.fillStyle = c;
635
+ ctx.fill();
636
+ });
637
+ // --- 内容绘制 ---
638
+ let dy = winY + 50;
639
+ const cx = winX + winPadding;
640
+ // 1. Header
641
+ // Icon
642
+ const iconSize = 80;
643
+ if (iconUrl) {
644
+ try {
645
+ const img = await loadImage(iconUrl);
646
+ ctx.save();
647
+ roundRect(ctx, cx, dy, iconSize, iconSize, 12);
648
+ ctx.clip();
649
+ ctx.drawImage(img, cx, dy, iconSize, iconSize);
650
+ ctx.restore();
651
+ }
652
+ catch (e) {
653
+ ctx.fillStyle = '#ddd';
654
+ roundRect(ctx, cx, dy, iconSize, iconSize, 12);
655
+ ctx.fill();
656
+ }
657
+ }
658
+ // Title
659
+ const titleX = cx + iconSize + 20;
660
+ ctx.fillStyle = '#333';
661
+ ctx.font = `bold 32px "${font}"`;
662
+ ctx.textBaseline = 'top';
663
+ const titleDrawnH = wrapText(ctx, title, titleX, dy - 5, contentW - iconSize - 20, 40, 3, true);
664
+ // SubTitle
665
+ let subY = titleDrawnH + 5;
666
+ if (subTitle) {
667
+ ctx.fillStyle = '#888';
668
+ ctx.font = `16px "${font}"`;
669
+ ctx.fillText(subTitle, titleX, subY);
670
+ subY += 25;
671
+ }
672
+ // Authors
673
+ if (authors.length) {
674
+ let ax = titleX;
675
+ for (const a of authors.slice(0, 3)) { // 最多显示3个作者
676
+ ctx.save();
677
+ ctx.beginPath();
678
+ ctx.arc(ax + 12, subY + 12, 12, 0, Math.PI * 2);
679
+ ctx.clip();
680
+ if (a.i) {
681
+ try {
682
+ const img = await loadImage(a.i);
683
+ ctx.drawImage(img, ax, subY, 24, 24);
684
+ }
685
+ catch (e) {
686
+ ctx.fillStyle = '#ccc';
687
+ ctx.fill();
688
+ }
689
+ }
690
+ else {
691
+ ctx.fillStyle = '#ccc';
692
+ ctx.fill();
693
+ }
694
+ ctx.restore();
695
+ ctx.fillStyle = '#666';
696
+ ctx.font = `14px "${font}"`;
697
+ ctx.fillText(a.n, ax + 30, subY + 5);
698
+ ax += ctx.measureText(a.n).width + 45;
699
+ }
700
+ }
701
+ dy += Math.max(headerH, 100) + 20;
702
+ // 2. Tags
703
+ if (tags.length) {
704
+ let tx = cx;
705
+ tags.forEach(t => {
706
+ ctx.font = `12px "${font}"`;
707
+ const tw = ctx.measureText(t.t).width + 20;
708
+ if (tx + tw < cx + contentW) {
709
+ ctx.fillStyle = t.bg;
710
+ roundRect(ctx, tx, dy, tw, 24, 6);
711
+ ctx.fill();
712
+ ctx.fillStyle = t.c;
713
+ ctx.fillText(t.t, tx + 10, dy + 6);
714
+ tx += tw + 10;
715
+ }
716
+ });
717
+ dy += 35;
718
+ }
719
+ // 3. Cover Image
720
+ if (coverUrl) {
721
+ try {
722
+ const img = await loadImage(coverUrl);
723
+ const coverW = contentW;
724
+ const coverH_Actual = 280;
725
+ // Crop fit
726
+ const r = Math.max(coverW / img.width, coverH_Actual / img.height);
727
+ ctx.save();
728
+ roundRect(ctx, cx, dy, coverW, coverH_Actual, 12);
729
+ ctx.clip();
730
+ ctx.drawImage(img, (coverW - img.width * r) / 2 + cx, (coverH_Actual - img.height * r) / 2 + dy, img.width * r, img.height * r);
731
+ ctx.restore();
732
+ dy += coverH_Actual + 25;
733
+ }
734
+ catch (e) { }
735
+ }
736
+ // 4. Stats Grid
737
+ if (statsItems.length) {
738
+ const cols = 4;
739
+ const gap = 15;
740
+ const itemW = (contentW - (cols - 1) * gap) / cols;
741
+ const itemH = 70;
742
+ statsItems.forEach((s, i) => {
743
+ const c = i % cols;
744
+ const r = Math.floor(i / cols);
745
+ const x = cx + c * (itemW + gap);
746
+ const y = dy + r * (itemH + gap);
747
+ ctx.fillStyle = 'rgba(255,255,255,0.6)';
748
+ roundRect(ctx, x, y, itemW, itemH, 10);
749
+ ctx.fill();
750
+ ctx.textAlign = 'center';
751
+ ctx.fillStyle = '#888';
752
+ ctx.font = `12px "${font}"`;
753
+ ctx.fillText(s.l, x + itemW / 2, y + 15);
754
+ ctx.fillStyle = '#333';
755
+ ctx.font = `bold 20px "${font}"`;
756
+ ctx.fillText(s.v, x + itemW / 2, y + 40);
757
+ });
758
+ ctx.textAlign = 'left';
759
+ dy += Math.ceil(statsItems.length / cols) * (itemH + gap) + 10;
760
+ }
761
+ // 5. Props List
762
+ if (props.length) {
763
+ const colW = contentW / 2;
764
+ props.forEach((p, i) => {
765
+ const c = i % 2;
766
+ const r = Math.floor(i / 2);
767
+ const x = cx + c * colW;
768
+ const y = dy + r * 30;
769
+ ctx.fillStyle = '#888';
770
+ ctx.font = `14px "${font}"`;
771
+ ctx.fillText(p.l + ':', x, y);
772
+ const lw = ctx.measureText(p.l + ':').width;
773
+ ctx.fillStyle = '#333';
774
+ // 截断过长文本
775
+ let val = p.v;
776
+ while (ctx.measureText(val).width > colW - lw - 20 && val.length > 5)
777
+ val = val.slice(0, -1);
778
+ if (val.length < p.v.length)
779
+ val += '...';
780
+ ctx.fillText(val, x + lw + 10, y);
781
+ });
782
+ dy += Math.ceil(props.length / 2) * 30 + 15;
783
+ }
784
+ // 6. Versions & Links
785
+ if (versions.length) {
786
+ ctx.fillStyle = '#333';
787
+ ctx.font = `bold 16px "${font}"`;
788
+ ctx.fillText('支持版本', cx, dy);
789
+ dy += 25;
790
+ versions.forEach(v => {
791
+ ctx.fillStyle = '#555';
792
+ ctx.font = `bold 14px "${font}"`;
793
+ ctx.fillText(v.l, cx, dy);
794
+ const lw = ctx.measureText(v.l).width + 10;
795
+ ctx.fillStyle = '#e74c3c';
796
+ ctx.font = `14px "${font}"`;
797
+ dy = wrapText(ctx, v.v, cx + lw, dy, contentW - lw, 20, 500, true) + 5;
798
+ });
799
+ dy += 15;
800
+ }
801
+ if (links.length) {
802
+ let lx = cx;
803
+ links.forEach(l => {
804
+ ctx.font = `bold 12px "${font}"`;
805
+ const w = ctx.measureText(l).width + 20;
806
+ if (lx + w < cx + contentW) {
807
+ ctx.fillStyle = '#333';
808
+ roundRect(ctx, lx, dy, w, 24, 12);
809
+ ctx.fill();
810
+ ctx.fillStyle = '#fff';
811
+ ctx.fillText(l, lx + 10, dy + 6);
812
+ lx += w + 10;
813
+ }
814
+ });
815
+ dy += 45;
816
+ }
817
+ // 7. Description
818
+ if (descNodes.length) {
819
+ ctx.fillStyle = '#333';
820
+ ctx.font = `bold 20px "${font}"`;
821
+ ctx.fillText('简介', cx, dy);
822
+ ctx.fillStyle = '#3498db';
823
+ ctx.fillRect(cx, dy + 25, 40, 4);
824
+ dy += 45;
825
+ for (const node of descNodes) {
826
+ if (node.type === 't') {
827
+ const isHeader = node.tag === 'h';
828
+ ctx.font = `${isHeader ? 'bold' : ''} ${isHeader ? 22 : 16}px "${font}"`;
829
+ ctx.fillStyle = isHeader ? '#2c3e50' : '#444';
830
+ const lh = isHeader ? 32 : 26;
831
+ dy = wrapText(ctx, node.val, cx, dy, contentW, lh, 5000, true) + (isHeader ? 15 : 10);
832
+ }
833
+ else if (node.type === 'i') {
834
+ try {
835
+ const img = await loadImage(node.src);
836
+ const maxH = 400;
837
+ const r = Math.min(contentW / img.width, maxH / img.height);
838
+ const dw = img.width * r;
839
+ const dh = img.height * r;
840
+ ctx.drawImage(img, cx + (contentW - dw) / 2, dy, dw, dh);
841
+ dy += dh + 20;
842
+ }
843
+ catch (e) { }
844
+ }
845
+ else if (node.type === 'br') {
846
+ dy += 10;
847
+ }
848
+ }
849
+ }
850
+ // Footer
851
+ ctx.fillStyle = '#999';
852
+ ctx.font = `12px "${font}"`;
853
+ ctx.textAlign = 'center';
854
+ ctx.fillText('mcmod.cn | Powered by Koishi', width / 2, totalH - 12);
855
+ return canvas.toBuffer('image/png');
856
+ }
857
+ // ================= 渲染:教程卡片 (macOS 风格) =================
858
+ async function drawTutorialCard(url) {
859
+ const res = await fetchWithTimeout(url, { headers: getHeaders() });
860
+ const html = await res.text();
861
+ const $ = cheerio.load(html);
862
+ // --- 1. 核心数据抓取 ---
863
+ // 标题
864
+ const title = cleanText($('h1, .post-title, .article-title, .postname h5').first().text()) || cleanText($('title').text().split('-')[0]);
865
+ // 作者
866
+ let author = cleanText($('.post-user-frame .post-user-name a').first().text());
867
+ if (!author)
868
+ author = cleanText($('.post-user-name a').first().text());
869
+ if (!author)
870
+ author = cleanText($('a[href*="/center/"]').first().text());
871
+ if (!author)
872
+ author = '未知作者';
873
+ // 头像
874
+ let authorAvatar = fixUrl($('.post-user-frame .post-user-avatar img').attr('src'));
875
+ if (!authorAvatar)
876
+ authorAvatar = fixUrl($('.post-user-avatar img').attr('src'));
877
+ // 浏览量/日期
878
+ let views = '0';
879
+ let date = '';
880
+ $('.common-rowlist-2 li').each((i, el) => {
881
+ const text = $(el).text();
882
+ if (text.includes('浏览量'))
883
+ views = text.replace(/[^0-9]/g, '') || '0';
884
+ if (text.includes('创建日期')) {
885
+ const fullDate = $(el).attr('data-original-title');
886
+ date = fullDate ? fullDate.split(' ')[0] : text.replace('创建日期:', '').trim();
887
+ }
888
+ });
889
+ // 互动数据
890
+ function getSocialNum(className) {
891
+ let result = '0';
892
+ const selectors = [
893
+ `.common-fuc-group[data-category="post"] li.${className} div.nums`,
894
+ `.common-fuc-group li.${className} div.nums`,
895
+ `.common-fuc-group li.${className} .nums`,
896
+ `li.${className} div.nums`,
897
+ ];
898
+ for (const sel of selectors) {
899
+ const el = $(sel);
900
+ if (el.length > 0) {
901
+ const titleAttr = el.attr('title');
902
+ if (titleAttr) {
903
+ const num = titleAttr.replace(/,/g, '').trim();
904
+ if (num && /^\d+$/.test(num))
905
+ return num;
906
+ }
907
+ const text = el.text().replace(/,/g, '').trim();
908
+ if (text && /^\d+$/.test(text))
909
+ return text;
910
+ }
911
+ }
912
+ return result;
913
+ }
914
+ const pushNum = getSocialNum('push');
915
+ const favNum = getSocialNum('like');
916
+ // 目录
917
+ const tocItems = [];
918
+ $('a[href^="javascript:void(0);"]').each((i, el) => {
919
+ const text = cleanText($(el).text());
920
+ if (text && text.length > 2 && text.length < 50 && !text.includes('百科') && !text.includes('登录')) {
921
+ tocItems.push(text);
922
+ }
923
+ });
924
+ // 正文提取
925
+ const contentNodes = [];
926
+ const contentRoot = $('.post-content, .article-content, .common-text, .news-text').first();
927
+ function parseContent(node) {
928
+ if (node.type === 'text') {
929
+ const t = cleanText(node.data);
930
+ if (t && t.length > 1)
931
+ contentNodes.push({ type: 't', val: t, tag: 'p' });
932
+ }
933
+ else if (node.type === 'tag') {
934
+ const tagName = node.name;
935
+ if (tagName === 'img') {
936
+ const src = node.attribs['data-src'] || node.attribs['src'];
937
+ if (src && !src.includes('loading') && !src.includes('smilies') && !src.includes('icon')) {
938
+ contentNodes.push({ type: 'i', src: fixUrl(src) });
939
+ }
940
+ }
941
+ else if (['h1', 'h2', 'h3', 'h4'].includes(tagName)) {
942
+ const text = cleanText($(node).text());
943
+ if (text)
944
+ contentNodes.push({ type: 't', val: text, tag: 'h' });
945
+ }
946
+ else if (tagName === 'li') {
947
+ const text = cleanText($(node).text());
948
+ if (text)
949
+ contentNodes.push({ type: 't', val: '• ' + text, tag: 'li' });
950
+ }
951
+ else if (['p', 'div', 'blockquote', 'span', 'strong', 'b', 'i', 'em'].includes(tagName)) {
952
+ if (node.children)
953
+ node.children.forEach(parseContent);
954
+ }
955
+ else {
956
+ if (node.children)
957
+ node.children.forEach(parseContent);
958
+ }
959
+ }
960
+ }
961
+ if (contentRoot.length) {
962
+ const textContainer = contentRoot.find('.text').first();
963
+ if (textContainer.length > 0)
964
+ textContainer[0].children.forEach(parseContent);
965
+ else
966
+ contentRoot[0].children.forEach(parseContent);
967
+ }
968
+ if (contentNodes.length === 0) {
969
+ const metaDesc = $('meta[name="description"]').attr('content');
970
+ if (metaDesc)
971
+ contentNodes.push({ type: 't', val: metaDesc, tag: 'p' });
972
+ }
973
+ // --- 2. 布局常量定义 ---
974
+ const width = 1000;
975
+ const font = GLOBAL_FONT_FAMILY;
976
+ const margin = 20;
977
+ const winPadding = 40;
978
+ const contentW = width - margin * 2 - winPadding * 2;
979
+ // --- 3. 关键步骤:预加载图片以获取真实高度 ---
980
+ // 并行加载所有图片,确保后续高度计算准确
981
+ await Promise.all(contentNodes.map(async (node) => {
982
+ if (node.type === 'i') {
983
+ try {
984
+ const img = await loadImage(node.src);
985
+ node.img = img; // 保存 Image 对象
986
+ // 计算自适应尺寸:宽度最大为 contentW,高度按比例缩放,不设上限
987
+ const scale = Math.min(contentW / img.width, 1);
988
+ node.dw = img.width * scale;
989
+ node.dh = img.height * scale;
990
+ }
991
+ catch (e) {
992
+ node.error = true;
993
+ }
994
+ }
995
+ }));
996
+ // --- 4. 精确计算总高度 ---
997
+ const dummyC = createCanvas(100, 100);
998
+ const dummy = dummyC.getContext('2d');
999
+ let totalH = 0;
1000
+ // Header 高度
1001
+ dummy.font = `bold 32px "${font}"`;
1002
+ const titleLines = wrapText(dummy, title, 0, 0, contentW, 45, 5, false) / 45;
1003
+ const headerH = 60 + titleLines * 45 + 50 + 20;
1004
+ totalH += headerH;
1005
+ // TOC 高度
1006
+ let tocH = 0;
1007
+ if (tocItems.length > 0) {
1008
+ tocH = 50 + Math.ceil(tocItems.length / 2) * 35 + 20;
1009
+ totalH += tocH;
1010
+ }
1011
+ // 正文高度 (使用真实图片高度)
1012
+ let contentH = 0;
1013
+ dummy.font = `16px "${font}"`;
1014
+ for (const node of contentNodes) {
1015
+ if (node.type === 't') {
1016
+ const isHeader = node.tag === 'h';
1017
+ const fontSize = isHeader ? 22 : 16;
1018
+ dummy.font = `${isHeader ? 'bold' : ''} ${fontSize}px "${font}"`;
1019
+ const lineHeight = Math.floor(fontSize * 1.6);
1020
+ // 这里不再限制行数 (limit = 10000),显示全部文本
1021
+ const lines = wrapText(dummy, node.val, 0, 0, contentW, lineHeight, 10000, false) / lineHeight;
1022
+ contentH += lines * lineHeight + (isHeader ? 25 : 15);
1023
+ }
1024
+ else if (node.type === 'i' && !node.error && node.img) {
1025
+ // 使用预加载时计算出的真实高度
1026
+ contentH += node.dh + 25;
1027
+ }
1028
+ }
1029
+ if (contentH === 0)
1030
+ contentH = 100;
1031
+ totalH += contentH + 50; // Padding
1032
+ const windowH = totalH + 100;
1033
+ const canvasH = windowH + margin * 2;
1034
+ // --- 5. 绘制 ---
1035
+ const canvas = createCanvas(width, canvasH);
1036
+ const ctx = canvas.getContext('2d');
1037
+ // 背景 (Bing)
1038
+ try {
1039
+ const bgUrl = 'https://bing.biturl.top/?resolution=1920&format=image&index=0&mkt=zh-CN';
1040
+ const bgImg = await loadImage(bgUrl);
1041
+ const r = Math.max(width / bgImg.width, canvasH / bgImg.height);
1042
+ ctx.drawImage(bgImg, (width - bgImg.width * r) / 2, (canvasH - bgImg.height * r) / 2, bgImg.width * r, bgImg.height * r);
1043
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.4)';
1044
+ ctx.fillRect(0, 0, width, canvasH);
1045
+ }
1046
+ catch (e) {
1047
+ const grad = ctx.createLinearGradient(0, 0, 0, canvasH);
1048
+ grad.addColorStop(0, '#a18cd1');
1049
+ grad.addColorStop(1, '#fbc2eb');
1050
+ ctx.fillStyle = grad;
1051
+ ctx.fillRect(0, 0, width, canvasH);
1052
+ }
1053
+ // 窗口主体
1054
+ const winX = margin, winY = margin;
1055
+ ctx.save();
1056
+ ctx.shadowColor = 'rgba(0,0,0,0.2)';
1057
+ ctx.shadowBlur = 50;
1058
+ ctx.shadowOffsetY = 20;
1059
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.85)';
1060
+ roundRect(ctx, winX, winY, width - margin * 2, windowH, 16);
1061
+ ctx.fill();
1062
+ ctx.restore();
1063
+ ctx.strokeStyle = 'rgba(255,255,255,0.5)';
1064
+ ctx.lineWidth = 1;
1065
+ roundRect(ctx, winX, winY, width - margin * 2, windowH, 16);
1066
+ ctx.stroke();
1067
+ // 交通灯
1068
+ ['#ff5f56', '#ffbd2e', '#27c93f'].forEach((c, i) => {
1069
+ ctx.beginPath();
1070
+ ctx.arc(winX + 25 + i * 25, winY + 25, 6, 0, Math.PI * 2);
1071
+ ctx.fillStyle = c;
1072
+ ctx.fill();
1073
+ });
1074
+ // --- 内容绘制 ---
1075
+ let dy = winY + 60;
1076
+ const cx = winX + winPadding;
1077
+ // 1. Header
1078
+ ctx.fillStyle = '#333';
1079
+ ctx.font = `bold 32px "${font}"`;
1080
+ ctx.textBaseline = 'top';
1081
+ const drawnTitleH = wrapText(ctx, title, cx, dy, contentW, 45, 5, true);
1082
+ dy += drawnTitleH + 20;
1083
+ // Meta Info
1084
+ const avSize = 40;
1085
+ if (authorAvatar) {
1086
+ try {
1087
+ const img = await loadImage(authorAvatar);
1088
+ ctx.save();
1089
+ ctx.beginPath();
1090
+ ctx.arc(cx + avSize / 2, dy + avSize / 2, avSize / 2, 0, Math.PI * 2);
1091
+ ctx.clip();
1092
+ ctx.drawImage(img, cx, dy, avSize, avSize);
1093
+ ctx.restore();
1094
+ }
1095
+ catch (e) {
1096
+ ctx.fillStyle = '#ccc';
1097
+ ctx.beginPath();
1098
+ ctx.arc(cx + avSize / 2, dy + avSize / 2, avSize / 2, 0, Math.PI * 2);
1099
+ ctx.fill();
1100
+ }
1101
+ }
1102
+ else {
1103
+ ctx.fillStyle = '#ccc';
1104
+ ctx.beginPath();
1105
+ ctx.arc(cx + avSize / 2, dy + avSize / 2, avSize / 2, 0, Math.PI * 2);
1106
+ ctx.fill();
1107
+ }
1108
+ ctx.fillStyle = '#333';
1109
+ ctx.font = `bold 16px "${font}"`;
1110
+ ctx.fillText(author, cx + avSize + 15, dy + 5);
1111
+ ctx.fillStyle = '#888';
1112
+ ctx.font = `12px "${font}"`;
1113
+ ctx.fillText(date || '未知日期', cx + avSize + 15, dy + 25);
1114
+ // Stats
1115
+ const statsY = dy + 10;
1116
+ let sx = cx + contentW;
1117
+ const drawStat = (icon, val, color) => {
1118
+ ctx.textAlign = 'right';
1119
+ ctx.fillStyle = color;
1120
+ ctx.font = `bold 16px "${font}"`;
1121
+ const vw = ctx.measureText(val).width;
1122
+ ctx.fillText(val, sx, statsY);
1123
+ ctx.fillStyle = '#999';
1124
+ ctx.font = `12px "${font}"`;
1125
+ ctx.fillText(icon, sx - vw - 5, statsY);
1126
+ sx -= (vw + 5 + ctx.measureText(icon).width + 20);
1127
+ ctx.textAlign = 'left';
1128
+ };
1129
+ drawStat('收藏', favNum, '#f1c40f');
1130
+ drawStat('推荐', pushNum, '#e74c3c');
1131
+ drawStat('浏览', views, '#3498db');
1132
+ dy += avSize + 30;
1133
+ // Divider
1134
+ ctx.fillStyle = 'rgba(0,0,0,0.05)';
1135
+ ctx.fillRect(cx, dy, contentW, 1);
1136
+ dy += 25;
1137
+ // 2. TOC
1138
+ if (tocItems.length > 0) {
1139
+ ctx.fillStyle = 'rgba(0,0,0,0.03)';
1140
+ roundRect(ctx, cx, dy, contentW, tocH - 20, 10);
1141
+ ctx.fill();
1142
+ ctx.fillStyle = '#555';
1143
+ ctx.font = `bold 16px "${font}"`;
1144
+ ctx.fillText('目录', cx + 20, dy + 30);
1145
+ let tx = cx + 20;
1146
+ let ty = dy + 60;
1147
+ const colW = (contentW - 40) / 2;
1148
+ ctx.fillStyle = '#666';
1149
+ ctx.font = `14px "${font}"`;
1150
+ tocItems.forEach((item, i) => {
1151
+ const col = i % 2;
1152
+ if (col === 0 && i > 0)
1153
+ ty += 30;
1154
+ const x = tx + col * colW;
1155
+ let displayTitle = item;
1156
+ if (ctx.measureText(displayTitle).width > colW - 20) {
1157
+ while (ctx.measureText(displayTitle + '...').width > colW - 20 && displayTitle.length > 0)
1158
+ displayTitle = displayTitle.slice(0, -1);
1159
+ displayTitle += '...';
1160
+ }
1161
+ ctx.fillText(`${i + 1}. ${displayTitle}`, x, ty);
1162
+ });
1163
+ dy += tocH + 10;
1164
+ }
1165
+ // 3. Content (Drawing loop)
1166
+ for (const node of contentNodes) {
1167
+ if (node.type === 't') {
1168
+ const isHeader = node.tag === 'h';
1169
+ const fontSize = isHeader ? 22 : 16;
1170
+ ctx.font = `${isHeader ? 'bold' : ''} ${fontSize}px "${font}"`;
1171
+ ctx.fillStyle = isHeader ? '#2c3e50' : '#444';
1172
+ if (isHeader) {
1173
+ ctx.fillStyle = '#3498db';
1174
+ ctx.fillRect(cx - 15, dy + 5, 4, fontSize);
1175
+ ctx.fillStyle = '#2c3e50';
1176
+ }
1177
+ const lineHeight = Math.floor(fontSize * 1.6);
1178
+ dy = wrapText(ctx, node.val, cx, dy, contentW, lineHeight, 10000, true) + (isHeader ? 20 : 15);
1179
+ }
1180
+ else if (node.type === 'i' && !node.error && node.img) {
1181
+ // 绘制预加载的图片
1182
+ // 居中显示
1183
+ const dx = cx + (contentW - node.dw) / 2;
1184
+ ctx.save();
1185
+ ctx.shadowColor = 'rgba(0,0,0,0.1)';
1186
+ ctx.shadowBlur = 15;
1187
+ ctx.shadowOffsetY = 5;
1188
+ // 绘制图片 (圆角效果)
1189
+ roundRect(ctx, dx, dy, node.dw, node.dh, 8);
1190
+ ctx.shadowColor = 'transparent'; // clip 前清除阴影以免影响性能
1191
+ ctx.clip();
1192
+ ctx.drawImage(node.img, dx, dy, node.dw, node.dh);
1193
+ ctx.restore();
1194
+ dy += node.dh + 25;
1195
+ }
1196
+ }
1197
+ // Footer
1198
+ dy += 30;
1199
+ ctx.fillStyle = '#aaa';
1200
+ ctx.font = `12px "${font}"`;
1201
+ ctx.textAlign = 'center';
1202
+ ctx.fillText('mcmod.cn | Powered by Koishi', width / 2, canvasH - 15);
1203
+ return canvas.toBuffer('image/png');
1204
+ }
1205
+ // ================= 渲染:作者卡片 (macOS 风格) =================
1206
+ // ================= 渲染:作者卡片 (macOS 风格) =================
1207
+ async function drawAuthorCard(url) {
1208
+ var _a;
1209
+ const uid = ((_a = url.match(/author\/(\d+)/)) === null || _a === void 0 ? void 0 : _a[1]) || 'Unknown';
1210
+ // 1. 获取数据
1211
+ const res = await fetchWithTimeout(url, { headers: getHeaders() });
1212
+ const html = await res.text();
1213
+ const $ = cheerio.load(html);
1214
+ const username = cleanText($('.author-name h5').text()) || $('title').text().split('-')[0].trim();
1215
+ const subname = $('.author-name .subname p').map((i, el) => $(el).text().trim()).get().join(' / ');
1216
+ const avatarUrl = fixUrl($('.author-user-avatar img').attr('src'));
1217
+ const bio = cleanText($('.author-content .text').text()) || '(暂无简介)';
1218
+ // 统计数据
1219
+ const pageInfo = {};
1220
+ const fullText = $('body').text().replace(/\s+/g, ' ');
1221
+ function extractStat(regex) {
1222
+ const m = fullText.match(regex);
1223
+ if (m && m[1] && m[1].length < 20)
1224
+ return m[1].trim();
1225
+ return null;
1226
+ }
1227
+ pageInfo.views = extractStat(/浏览量[::]\s*([\d,]+)/);
1228
+ pageInfo.createDate = extractStat(/创建日期[::]\s*(\d{4}-\d{2}-\d{2}|\d+年前|\d+个月前|\d+天前)/);
1229
+ pageInfo.lastEdit = extractStat(/最后编辑[::]\s*(\d{4}-\d{2}-\d{2}|\d+年前|\d+个月前|\d+天前)/);
1230
+ pageInfo.editCount = extractStat(/编辑次数[::]\s*(\d+)/);
1231
+ let favCount = '0';
1232
+ const favEl = $('.author-fav .nums, .common-fuc-group li.like .nums, .fav-count');
1233
+ if (favEl.length) {
1234
+ favCount = favEl.attr('title') || favEl.text().trim() || '0';
1235
+ }
1236
+ if (favCount === '0') {
1237
+ const favMatch = fullText.match(/收藏\s*(\d+)/);
1238
+ if (favMatch)
1239
+ favCount = favMatch[1];
1240
+ }
1241
+ const stats = [];
1242
+ if (pageInfo.views)
1243
+ stats.push({ l: '浏览量', v: pageInfo.views });
1244
+ if (pageInfo.createDate)
1245
+ stats.push({ l: '创建日期', v: pageInfo.createDate });
1246
+ if (pageInfo.lastEdit)
1247
+ stats.push({ l: '最后编辑', v: pageInfo.lastEdit });
1248
+ if (pageInfo.editCount)
1249
+ stats.push({ l: '编辑次数', v: pageInfo.editCount });
1250
+ if (favCount)
1251
+ stats.push({ l: '收藏', v: favCount });
1252
+ const links = [];
1253
+ $('.author-link .common-link-icon-list a, .common-link-icon-frame a').each((i, el) => {
1254
+ const h = $(el).attr('href');
1255
+ let n = $(el).attr('data-original-title') || $(el).text().trim();
1256
+ if (!n && h) {
1257
+ if (h.includes('github'))
1258
+ n = 'GitHub';
1259
+ else if (h.includes('bilibili'))
1260
+ n = 'Bilibili';
1261
+ else if (h.includes('curseforge'))
1262
+ n = 'CurseForge';
1263
+ else if (h.includes('modrinth'))
1264
+ n = 'Modrinth';
1265
+ else if (h.includes('mcbbs'))
1266
+ n = 'MCBBS';
1267
+ else
1268
+ n = 'Link';
1269
+ }
1270
+ if (n && h && !links.some(l => l.n === n))
1271
+ links.push({ n, h });
1272
+ });
1273
+ // 列表抓取 - 优先使用特定类名,因为它们更稳定
1274
+ const teams = [];
1275
+ const projects = [];
1276
+ const partners = [];
1277
+ // 辅助函数:从容器中提取列表项
1278
+ function extractListItems(container, targetList, isProject = false) {
1279
+ // 增加 .block 选择器以匹配 div.block (用于参与项目)
1280
+ container.find('li.block, .block, .row > div').each((i, el) => {
1281
+ const n = cleanText($(el).find('.name a, .name, h4').first().text());
1282
+ if (!n)
1283
+ return;
1284
+ const m = fixUrl($(el).find('img').attr('src'));
1285
+ // 增加 .count 选择器 (用于相关作者的合作次数)
1286
+ const r = cleanText($(el).find('.position, .meta, .count').text());
1287
+ // 获取类型标签 (模组/整合包等)
1288
+ let t = '';
1289
+ if (isProject) {
1290
+ const badge = $(el).find('.badge, .badge-mod, .badge-modpack').first().text().trim();
1291
+ if (badge)
1292
+ t = badge;
1293
+ }
1294
+ if (!targetList.some(x => x.n === n)) {
1295
+ targetList.push({ n, m, r, t });
1296
+ }
1297
+ });
1298
+ }
1299
+ // 1. 尝试特定类名 (根据用户提供的 HTML 结构修正)
1300
+ extractListItems($('.author-member .list, .author-team .list'), teams, false);
1301
+ extractListItems($('.author-mods .list'), projects, true);
1302
+ extractListItems($('.author-partner .list, .author-users .list'), partners, false);
1303
+ // 2. 如果没抓到,尝试通用抓取 (遍历所有 block/panel)
1304
+ if (teams.length === 0 || projects.length === 0 || partners.length === 0) {
1305
+ $('.common-card-layout, .panel, .block').each((i, el) => {
1306
+ const title = $(el).find('.head, .panel-heading, h3, h4').text().trim();
1307
+ if (teams.length === 0 && title.includes('参与团队'))
1308
+ extractListItems($(el), teams);
1309
+ if (projects.length === 0 && (title.includes('参与项目') || title.includes('发布的模组')))
1310
+ extractListItems($(el), projects);
1311
+ if (partners.length === 0 && (title.includes('相关作者') || title.includes('合作者')))
1312
+ extractListItems($(el), partners);
1313
+ });
1314
+ }
1315
+ // 2. 布局计算
1316
+ const width = 800;
1317
+ const font = GLOBAL_FONT_FAMILY;
1318
+ const padding = 40;
1319
+ const windowMargin = 20;
1320
+ const contentW = width - windowMargin * 2 - padding * 2; // 实际内容宽度
1321
+ // 严格计算高度
1322
+ let cursorY = 60; // Initial padding inside window
1323
+ // Avatar area
1324
+ cursorY += 100 + 40; // Avatar(100) + gap(40)
1325
+ // Stats Grid
1326
+ if (stats.length > 0) {
1327
+ cursorY += 80 + 30; // StatH(80) + gap(30)
1328
+ }
1329
+ // Links
1330
+ if (links.length > 0) {
1331
+ // Simulate link wrapping
1332
+ const tempC = createCanvas(100, 100);
1333
+ const tempCtx = tempC.getContext('2d');
1334
+ tempCtx.font = `bold 14px "${font}"`;
1335
+ let lx = 0;
1336
+ let ly = 0;
1337
+ let rowH = 34;
1338
+ links.forEach(l => {
1339
+ const lw = tempCtx.measureText(l.n).width + 30;
1340
+ if (lx + lw > contentW) {
1341
+ lx = 0;
1342
+ ly += 45; // Line gap
1343
+ }
1344
+ lx += lw + 10;
1345
+ });
1346
+ cursorY += ly + rowH + 60; // + gap
1347
+ }
1348
+ // Lists Calculation Helper
1349
+ function calcSectionHeight(items, itemH, cols) {
1350
+ if (!items.length)
1351
+ return 0;
1352
+ const rows = Math.ceil(items.length / cols);
1353
+ // Title(35) + Rows * (ItemH + 15) + BottomGap(30)
1354
+ return 35 + rows * (itemH + 15) + 30;
1355
+ }
1356
+ cursorY += calcSectionHeight(teams, 70, 3);
1357
+ cursorY += calcSectionHeight(projects, 90, 2);
1358
+ cursorY += calcSectionHeight(partners, 100, 5);
1359
+ // Bio
1360
+ let bioH = 0;
1361
+ if (bio && bio !== '(暂无简介)') {
1362
+ const tempC = createCanvas(100, 100);
1363
+ const tempCtx = tempC.getContext('2d');
1364
+ tempCtx.font = `16px "${font}"`;
1365
+ // Title(35)
1366
+ cursorY += 35;
1367
+ // Content
1368
+ bioH = wrapText(tempCtx, bio, 0, 0, contentW - 40, 26, 1000, false);
1369
+ cursorY += bioH + 40 + 60; // Padding inside rect(40) + BottomGap(60)
1370
+ }
1371
+ // Footer
1372
+ cursorY += 30;
1373
+ const windowH = cursorY;
1374
+ const totalH = windowH + windowMargin * 2;
1375
+ const canvas = createCanvas(width, totalH);
1376
+ const ctx = canvas.getContext('2d');
1377
+ // 3. 绘制背景 (使用微软 Bing 每日图片/自然风格)
1378
+ try {
1379
+ // 使用 Bing 每日图片 API (1920x1080)
1380
+ const bgUrl = 'https://bing.biturl.top/?resolution=1920&format=image&index=0&mkt=zh-CN';
1381
+ const bgImg = await loadImage(bgUrl);
1382
+ // 保持比例填充
1383
+ const r = Math.max(width / bgImg.width, totalH / bgImg.height);
1384
+ const dw = bgImg.width * r;
1385
+ const dh = bgImg.height * r;
1386
+ const dx = (width - dw) / 2;
1387
+ const dy = (totalH - dh) / 2;
1388
+ ctx.drawImage(bgImg, dx, dy, dw, dh);
1389
+ // 叠加一层模糊遮罩或颜色,保证文字可读性 (虽然有亚克力板,但背景太花也不好)
1390
+ // 这里不模糊背景本身(Canvas模糊开销大),而是加一层半透明遮罩
1391
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.1)';
1392
+ ctx.fillRect(0, 0, width, totalH);
1393
+ }
1394
+ catch (e) {
1395
+ // 失败回退到渐变
1396
+ const grad = ctx.createLinearGradient(0, 0, width, totalH);
1397
+ grad.addColorStop(0, '#a18cd1');
1398
+ grad.addColorStop(1, '#fbc2eb');
1399
+ ctx.fillStyle = grad;
1400
+ ctx.fillRect(0, 0, width, totalH);
1401
+ }
1402
+ // 4. 绘制 Acrylic 窗口
1403
+ const windowW = width - windowMargin * 2;
1404
+ ctx.save();
1405
+ // 窗口阴影
1406
+ ctx.shadowColor = 'rgba(0,0,0,0.3)';
1407
+ ctx.shadowBlur = 40;
1408
+ ctx.shadowOffsetY = 20;
1409
+ // 窗口背景 (40% Acrylic - 模拟)
1410
+ // 使用白色半透明 + 背景模糊效果 (Canvas 无法直接 backdrop-filter,只能通过叠加半透明白)
1411
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.75)'; // 提高不透明度以遮盖背景杂乱
1412
+ roundRect(ctx, windowMargin, windowMargin, windowW, windowH, 20);
1413
+ ctx.fill();
1414
+ ctx.restore();
1415
+ // 窗口边框
1416
+ ctx.strokeStyle = 'rgba(255,255,255,0.6)';
1417
+ ctx.lineWidth = 1.5;
1418
+ roundRect(ctx, windowMargin, windowMargin, windowW, windowH, 20);
1419
+ ctx.stroke();
1420
+ // 5. 窗口控件 (Traffic Lights)
1421
+ const controlY = windowMargin + 20;
1422
+ const controlX = windowMargin + 20;
1423
+ const controlR = 6;
1424
+ const controlGap = 20;
1425
+ ctx.fillStyle = '#ff5f56'; // Red
1426
+ ctx.beginPath();
1427
+ ctx.arc(controlX, controlY, controlR, 0, Math.PI * 2);
1428
+ ctx.fill();
1429
+ ctx.fillStyle = '#ffbd2e'; // Yellow
1430
+ ctx.beginPath();
1431
+ ctx.arc(controlX + controlGap, controlY, controlR, 0, Math.PI * 2);
1432
+ ctx.fill();
1433
+ ctx.fillStyle = '#27c93f'; // Green
1434
+ ctx.beginPath();
1435
+ ctx.arc(controlX + controlGap * 2, controlY, controlR, 0, Math.PI * 2);
1436
+ ctx.fill();
1437
+ // 6. 内容绘制
1438
+ // 重置 cursorY 到窗口内部起始位置
1439
+ cursorY = windowMargin + 60;
1440
+ const contentX = windowMargin + padding;
1441
+ // Header: Avatar & Name
1442
+ const avatarSize = 100;
1443
+ // Avatar
1444
+ ctx.save();
1445
+ ctx.shadowColor = 'rgba(0,0,0,0.1)';
1446
+ ctx.shadowBlur = 10;
1447
+ ctx.beginPath();
1448
+ ctx.arc(contentX + avatarSize / 2, cursorY + avatarSize / 2, avatarSize / 2, 0, Math.PI * 2);
1449
+ ctx.fillStyle = '#fff';
1450
+ ctx.fill();
1451
+ ctx.shadowBlur = 0;
1452
+ ctx.clip();
1453
+ if (avatarUrl) {
1454
+ try {
1455
+ const img = await loadImage(avatarUrl);
1456
+ ctx.drawImage(img, contentX, cursorY, avatarSize, avatarSize);
1457
+ }
1458
+ catch (e) {
1459
+ ctx.fillStyle = '#ddd';
1460
+ ctx.fill();
1461
+ }
1462
+ }
1463
+ else {
1464
+ ctx.fillStyle = '#ddd';
1465
+ ctx.fill();
1466
+ }
1467
+ ctx.restore();
1468
+ // Name & UID
1469
+ const textX = contentX + avatarSize + 30;
1470
+ ctx.fillStyle = '#333';
1471
+ ctx.font = `bold 40px "${font}"`;
1472
+ ctx.textBaseline = 'top';
1473
+ ctx.fillText(username, textX, cursorY + 10);
1474
+ // UID Chip
1475
+ const uidText = `UID: ${uid}`;
1476
+ ctx.font = `bold 14px "${font}"`;
1477
+ const uidW = ctx.measureText(uidText).width + 20;
1478
+ ctx.fillStyle = 'rgba(0,0,0,0.05)';
1479
+ roundRect(ctx, textX, cursorY + 60, uidW, 24, 12);
1480
+ ctx.fill();
1481
+ ctx.fillStyle = '#666';
1482
+ ctx.fillText(uidText, textX + 10, cursorY + 64);
1483
+ // Subname (Alias)
1484
+ if (subname) {
1485
+ ctx.fillStyle = '#999';
1486
+ ctx.font = `14px "${font}"`;
1487
+ // 绘制在 UID 下方,稍微留点间距
1488
+ ctx.fillText(subname, textX, cursorY + 95);
1489
+ }
1490
+ cursorY += avatarSize + 40;
1491
+ // Stats Grid
1492
+ if (stats.length > 0) {
1493
+ const statW = (contentW - (stats.length - 1) * 15) / stats.length;
1494
+ const statH = 80;
1495
+ stats.forEach((s, i) => {
1496
+ const sx = contentX + i * (statW + 15);
1497
+ // Card bg
1498
+ ctx.fillStyle = 'rgba(255,255,255,0.6)';
1499
+ roundRect(ctx, sx, cursorY, statW, statH, 12);
1500
+ ctx.fill();
1501
+ // Label
1502
+ ctx.textAlign = 'center';
1503
+ ctx.fillStyle = '#666';
1504
+ ctx.font = `14px "${font}"`;
1505
+ ctx.fillText(s.l, sx + statW / 2, cursorY + 15);
1506
+ // Value
1507
+ ctx.fillStyle = '#333';
1508
+ ctx.font = `bold 20px "${font}"`;
1509
+ // Auto scale font if too long
1510
+ let fontSize = 20;
1511
+ while (ctx.measureText(s.v).width > statW - 10 && fontSize > 10) {
1512
+ fontSize--;
1513
+ ctx.font = `bold ${fontSize}px "${font}"`;
1514
+ }
1515
+ ctx.fillText(s.v, sx + statW / 2, cursorY + 45);
1516
+ });
1517
+ ctx.textAlign = 'left';
1518
+ cursorY += statH + 30;
1519
+ }
1520
+ // Links
1521
+ if (links.length > 0) {
1522
+ let lx = contentX;
1523
+ let ly = cursorY;
1524
+ links.forEach(l => {
1525
+ ctx.font = `bold 14px "${font}"`;
1526
+ const lw = ctx.measureText(l.n).width + 30;
1527
+ if (lx + lw > contentX + contentW) {
1528
+ lx = contentX;
1529
+ ly += 45;
1530
+ }
1531
+ ctx.fillStyle = '#fff';
1532
+ ctx.shadowColor = 'rgba(0,0,0,0.05)';
1533
+ ctx.shadowBlur = 5;
1534
+ roundRect(ctx, lx, ly, lw, 34, 17);
1535
+ ctx.fill();
1536
+ ctx.shadowBlur = 0;
1537
+ ctx.fillStyle = '#333';
1538
+ ctx.fillText(l.n, lx + 15, ly + 8);
1539
+ lx += lw + 10;
1540
+ });
1541
+ cursorY = ly + 60;
1542
+ }
1543
+ // Helper for Lists
1544
+ async function drawSection(title, items, itemH, cols, renderItem) {
1545
+ if (!items.length)
1546
+ return;
1547
+ ctx.fillStyle = '#333';
1548
+ ctx.font = `bold 22px "${font}"`;
1549
+ ctx.fillText(title, contentX, cursorY);
1550
+ cursorY += 35;
1551
+ const itemW = (contentW - (cols - 1) * 15) / cols;
1552
+ for (let i = 0; i < items.length; i++) {
1553
+ const col = i % cols;
1554
+ const row = Math.floor(i / cols);
1555
+ const ix = contentX + col * (itemW + 15);
1556
+ const iy = cursorY + row * (itemH + 15);
1557
+ // Item Card
1558
+ ctx.fillStyle = 'rgba(255,255,255,0.7)';
1559
+ roundRect(ctx, ix, iy, itemW, itemH, 12);
1560
+ ctx.fill();
1561
+ await renderItem(items[i], ix, iy, itemW, itemH);
1562
+ }
1563
+ cursorY += Math.ceil(items.length / cols) * (itemH + 15) + 30;
1564
+ }
1565
+ // Draw Lists
1566
+ await drawSection('参与团队', teams, 70, 3, async (item, x, y, w, h) => {
1567
+ if (item.m) {
1568
+ try {
1569
+ const img = await loadImage(item.m);
1570
+ ctx.drawImage(img, x + 10, y + 15, 40, 40);
1571
+ }
1572
+ catch (e) { }
1573
+ }
1574
+ ctx.fillStyle = '#333';
1575
+ ctx.font = `bold 16px "${font}"`;
1576
+ ctx.fillText(item.n, x + 60, y + 15);
1577
+ if (item.r) {
1578
+ ctx.fillStyle = '#666';
1579
+ ctx.font = `12px "${font}"`;
1580
+ ctx.fillText(item.r, x + 60, y + 40);
1581
+ }
1582
+ });
1583
+ await drawSection('参与项目', projects, 90, 2, async (item, x, y, w, h) => {
1584
+ if (item.m) {
1585
+ try {
1586
+ const img = await loadImage(item.m);
1587
+ ctx.drawImage(img, x + 10, y + 15, 100, 60);
1588
+ }
1589
+ catch (e) { }
1590
+ }
1591
+ // 绘制类型标签 (模组/整合包)
1592
+ let nameOffsetX = 120;
1593
+ if (item.t) {
1594
+ ctx.font = `bold 12px "${font}"`;
1595
+ const tagText = item.t;
1596
+ const tagW = ctx.measureText(tagText).width + 12;
1597
+ const tagH = 20;
1598
+ const tagX = x + 120;
1599
+ const tagY = y + 12;
1600
+ // 根据类型设置颜色:模组=绿色,整合包=橙色,其他=灰色
1601
+ let tagBg = '#999';
1602
+ if (tagText.includes('模组'))
1603
+ tagBg = '#2ecc71';
1604
+ else if (tagText.includes('整合包'))
1605
+ tagBg = '#e67e22';
1606
+ else if (tagText.includes('资料'))
1607
+ tagBg = '#3498db';
1608
+ ctx.fillStyle = tagBg;
1609
+ roundRect(ctx, tagX, tagY, tagW, tagH, 4);
1610
+ ctx.fill();
1611
+ ctx.fillStyle = '#fff';
1612
+ ctx.fillText(tagText, tagX + 6, tagY + 4);
1613
+ nameOffsetX = 120 + tagW + 8;
1614
+ }
1615
+ // 去掉名称中的类型前缀(避免与标签重复)
1616
+ let displayName = item.n;
1617
+ if (item.t) {
1618
+ // 移除开头的 "模组"、"整合包" 等前缀
1619
+ displayName = displayName.replace(/^(模组|整合包|资料)\s*/g, '').trim();
1620
+ }
1621
+ ctx.fillStyle = '#333';
1622
+ ctx.font = `bold 16px "${font}"`;
1623
+ wrapText(ctx, displayName, x + nameOffsetX, y + 15, w - nameOffsetX - 10, 20, 2, true);
1624
+ if (item.r) {
1625
+ ctx.fillStyle = '#666';
1626
+ ctx.font = `12px "${font}"`;
1627
+ ctx.fillText(item.r, x + 120, y + 60);
1628
+ }
1629
+ });
1630
+ await drawSection('相关作者', partners, 100, 5, async (item, x, y, w, h) => {
1631
+ const iconSize = 50;
1632
+ if (item.m) {
1633
+ try {
1634
+ const img = await loadImage(item.m);
1635
+ ctx.save();
1636
+ ctx.beginPath();
1637
+ ctx.arc(x + w / 2, y + 25, iconSize / 2, 0, Math.PI * 2);
1638
+ ctx.clip();
1639
+ ctx.drawImage(img, x + w / 2 - iconSize / 2, y, iconSize, iconSize);
1640
+ ctx.restore();
1641
+ }
1642
+ catch (e) { }
1643
+ }
1644
+ ctx.textAlign = 'center';
1645
+ ctx.fillStyle = '#333';
1646
+ ctx.font = `14px "${font}"`;
1647
+ wrapText(ctx, item.n, x + w / 2, y + 60, w - 10, 18, 2, true);
1648
+ ctx.textAlign = 'left';
1649
+ });
1650
+ // Bio
1651
+ if (bio && bio !== '(暂无简介)') {
1652
+ ctx.fillStyle = '#333';
1653
+ ctx.font = `bold 22px "${font}"`;
1654
+ ctx.fillText('简介', contentX, cursorY);
1655
+ cursorY += 35;
1656
+ ctx.fillStyle = 'rgba(255,255,255,0.5)';
1657
+ roundRect(ctx, contentX, cursorY, contentW, bioH + 40, 12);
1658
+ ctx.fill();
1659
+ ctx.fillStyle = '#444';
1660
+ ctx.font = `16px "${font}"`;
1661
+ wrapText(ctx, bio, contentX + 20, cursorY + 20, contentW - 40, 26, 1000, true);
1662
+ cursorY += bioH + 60;
1663
+ }
1664
+ // Footer
1665
+ ctx.fillStyle = 'rgba(0,0,0,0.5)';
1666
+ ctx.font = `12px "${font}"`;
1667
+ ctx.textAlign = 'center';
1668
+ ctx.fillText('mcmod.cn | Powered by Koishi', width / 2, totalH - 15);
1669
+ return canvas.toBuffer('image/png');
1670
+ }
1671
+ // ================= 普通用户卡片 (Center Card) =================
1672
+ async function drawCenterCard(uid, logger) { return drawCenterCardImpl(uid, logger); }
1673
+ async function drawCenterCardImpl(uid, logger) {
1674
+ var _a, _b, _c, _d;
1675
+ const centerUrl = `${CENTER_URL}/${uid}/`;
1676
+ const bbsUrl = `https://bbs.mcmod.cn/center/${uid}/`;
1677
+ const homeApiUrl = `${CENTER_URL}/frame/CenterHome/`;
1678
+ const commentApiUrl = `${CENTER_URL}/frame/CenterComment/`;
1679
+ const chartApiUrl = `${CENTER_URL}/object/UserHistoryChartData/`;
1680
+ const params = new URLSearchParams();
1681
+ params.append('uid', uid);
1682
+ const currentYear = new Date().getFullYear();
1683
+ const chartParams = new URLSearchParams();
1684
+ chartParams.append('data', JSON.stringify({ uid: parseInt(uid), year: currentYear }));
1685
+ const apiHeaders = { ...getHeaders(centerUrl), 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' };
1686
+ let mainHtml = '', homeJson = null, commentJson = null, chartJson = null, bbsHtml = '';
1687
+ // 1. 并行获取所有数据
1688
+ try {
1689
+ const results = await Promise.allSettled([
1690
+ fetchWithTimeout(centerUrl, { headers: getHeaders() }),
1691
+ fetchWithTimeout(homeApiUrl, { method: 'POST', headers: apiHeaders, body: params }),
1692
+ fetchWithTimeout(commentApiUrl, { method: 'POST', headers: apiHeaders, body: params }),
1693
+ fetchWithTimeout(chartApiUrl, { method: 'POST', headers: apiHeaders, body: chartParams }),
1694
+ fetchWithTimeout(bbsUrl, { headers: getHeaders() })
1695
+ ]);
1696
+ if (results[0].status === 'fulfilled')
1697
+ mainHtml = await results[0].value.text();
1698
+ if (results[1].status === 'fulfilled' && results[1].value.ok)
1699
+ try {
1700
+ homeJson = await results[1].value.json();
1701
+ }
1702
+ catch (e) { }
1703
+ if (results[2].status === 'fulfilled' && results[2].value.ok)
1704
+ try {
1705
+ commentJson = await results[2].value.json();
1706
+ }
1707
+ catch (e) { }
1708
+ if (results[3].status === 'fulfilled' && results[3].value.ok)
1709
+ try {
1710
+ chartJson = await results[3].value.json();
1711
+ }
1712
+ catch (e) { }
1713
+ if (results[4].status === 'fulfilled' && results[4].value.ok)
1714
+ bbsHtml = await results[4].value.text();
1715
+ }
1716
+ catch (e) {
1717
+ logger.error(`[Card] 数据获取部分失败: ${e.message}`);
1718
+ }
1719
+ // 2. 解析 Center 主站数据
1720
+ const $main = cheerio.load(mainHtml || '');
1721
+ const header = $main('.center-header');
1722
+ const username = cleanText(header.find('.user-un').text()) || 'User';
1723
+ const levelText = cleanText(header.find('.user-lv').text()) || 'Lv.?';
1724
+ const signature = cleanText(header.find('.user-sign').text()) || '(无签名)';
1725
+ let avatarUrl = fixUrl(header.find('.user-icon-img img').attr('src'));
1726
+ let bannerUrl = null;
1727
+ $main('style').each((i, el) => {
1728
+ const styleText = $main(el).html() || '';
1729
+ const bodyBgMatch = styleText.match(/body\s*\{\s*background\s*:\s*url\(([^)]+)\)/i);
1730
+ if (bodyBgMatch && bodyBgMatch[1] && (!styleText.includes('.copyright') || styleText.includes('body{background'))) {
1731
+ bannerUrl = fixUrl(bodyBgMatch[1].replace(/['"]/g, ''));
1732
+ }
1733
+ });
1734
+ if (!bannerUrl)
1735
+ bannerUrl = fixUrl((_b = (_a = (header.attr('style') || '').match(/url\((.*?)\)/)) === null || _a === void 0 ? void 0 : _a[1]) === null || _b === void 0 ? void 0 : _b.replace(/['"]/g, ''));
1736
+ // 3. 解析 BBS 数据
1737
+ const bbsData = { medals: [], points: [], detailed: [], profile: [], times: [] };
1738
+ if (bbsHtml) {
1739
+ const $bbs = cheerio.load(bbsHtml);
1740
+ if (!avatarUrl)
1741
+ avatarUrl = fixUrl($bbs('.icn.avt img').attr('src'));
1742
+ // 勋章墙 (修复:$(el) -> $bbs(el))
1743
+ $bbs('.md_ctrl img').each((i, el) => {
1744
+ const src = fixUrl($bbs(el).attr('src'));
1745
+ const name = $bbs(el).attr('alt') || $bbs(el).attr('title') || '勋章';
1746
+ if (src)
1747
+ bbsData.medals.push({ src, name });
1748
+ });
1749
+ // 积分统计 (修复:$(el) -> $bbs(el))
1750
+ $bbs('#psts .pf_l li').each((i, el) => {
1751
+ const label = cleanText($bbs(el).find('em').text());
1752
+ const val = cleanText($bbs(el).text()).replace(label, '').trim();
1753
+ if (label && val)
1754
+ bbsData.points.push({ l: label, v: val });
1755
+ });
1756
+ // 详细贡献 (修复:$(el) -> $bbs(el))
1757
+ $bbs('.u_profile .bbda.pbm.mbm li p').each((i, el) => {
1758
+ const txt = $bbs(el).text();
1759
+ if (txt.includes(':') && ($bbs(el).find('.green').length > 0 || txt.includes('/'))) {
1760
+ const label = txt.split(':')[0].trim();
1761
+ const add = cleanText($bbs(el).find('.green').text()) || '0';
1762
+ const edit = cleanText($bbs(el).find('.blue').text()) || '0';
1763
+ if (label && !label.includes('以下数据')) {
1764
+ bbsData.detailed.push({ l: label, add, edit });
1765
+ }
1766
+ }
1767
+ });
1768
+ // 个人档案 (修复:$(el) -> $bbs(el))
1769
+ $bbs('.u_profile .pf_l.cl li').each((i, el) => {
1770
+ const label = cleanText($bbs(el).find('em').text());
1771
+ const val = cleanText($bbs(el).text()).replace(label, '').trim();
1772
+ if (label && val)
1773
+ bbsData.profile.push({ l: label, v: val });
1774
+ });
1775
+ // 完整时间统计 (修复:$(el) -> $bbs(el))
1776
+ $bbs('#pbbs li').each((i, el) => {
1777
+ const label = cleanText($bbs(el).find('em').text());
1778
+ const val = cleanText($bbs(el).text()).replace(label, '').trim();
1779
+ if (label && val)
1780
+ bbsData.times.push({ l: label, v: val });
1781
+ });
1782
+ }
1783
+ // 4. 解析原有 API 数据
1784
+ const statsMap = {};
1785
+ if (homeJson === null || homeJson === void 0 ? void 0 : homeJson.html) {
1786
+ const $h = cheerio.load(homeJson.html);
1787
+ $h('li').each((i, el) => {
1788
+ const t = cleanText($h(el).find('.title').text());
1789
+ const v = cleanText($h(el).find('.text').text());
1790
+ if (t && v) {
1791
+ if (t.includes('用户组'))
1792
+ statsMap.group = v;
1793
+ else if (t.includes('编辑次数'))
1794
+ statsMap.edits = v;
1795
+ else if (t.includes('编辑字数'))
1796
+ statsMap.words = v;
1797
+ else if (t.includes('短评'))
1798
+ statsMap.comments = v;
1799
+ else if (t.includes('教程'))
1800
+ statsMap.tutorials = v;
1801
+ else if (t.includes('注册'))
1802
+ statsMap.reg = v;
1803
+ }
1804
+ });
1805
+ }
1806
+ // 基础统计列表
1807
+ const basicStats = [
1808
+ { l: '用户组', v: statsMap.group || '未知' }, { l: '总编辑次数', v: statsMap.edits || '0' },
1809
+ { l: '总编辑字数', v: statsMap.words || '0' }, { l: '总短评数', v: statsMap.comments || '0' },
1810
+ { l: '个人教程', v: statsMap.tutorials || '0' }
1811
+ ];
1812
+ // 如果 BBS 数据里没有注册时间,则从 API 补充
1813
+ if (!bbsData.times.some(t => t.l.includes('注册')) && statsMap.reg) {
1814
+ bbsData.times.unshift({ l: '注册时间', v: statsMap.reg });
1815
+ }
1816
+ const reactions = [];
1817
+ if (commentJson === null || commentJson === void 0 ? void 0 : commentJson.html) {
1818
+ const $c = cheerio.load(commentJson.html);
1819
+ $c('li').each((i, el) => {
1820
+ const t = cleanText($c(el).text());
1821
+ const m = t.match(/被评[“"'](.+?)[”"']\s*[::]\s*([\d,]+)/);
1822
+ if (m)
1823
+ reactions.push({ l: m[1], c: m[2] });
1824
+ });
1825
+ }
1826
+ const activityMap = {};
1827
+ if ((_c = chartJson === null || chartJson === void 0 ? void 0 : chartJson.chartdata) === null || _c === void 0 ? void 0 : _c.total) {
1828
+ chartJson.chartdata.total.forEach(item => {
1829
+ if (Array.isArray(item) && typeof item[1] === 'number')
1830
+ activityMap[item[0]] = item[1];
1831
+ });
1832
+ }
1833
+ // ================= 绘图逻辑 =================
1834
+ const width = 800;
1835
+ const font = GLOBAL_FONT_FAMILY;
1836
+ const bannerH = 160;
1837
+ const headerH = 140;
1838
+ const cardOverlap = 40;
1839
+ const padding = 20;
1840
+ const gap = 15;
1841
+ let currentY = bannerH - cardOverlap + headerH + padding;
1842
+ // BBS 勋章墙
1843
+ let medalsH = 0;
1844
+ if (bbsData.medals.length > 0) {
1845
+ const rows = Math.ceil(bbsData.medals.length / 12);
1846
+ medalsH = 50 + rows * 40 + 20;
1847
+ currentY += medalsH + gap;
1848
+ }
1849
+ // BBS 积分
1850
+ let pointsH = 0;
1851
+ if (bbsData.points.length > 0) {
1852
+ const rows = Math.ceil(bbsData.points.length / 4);
1853
+ pointsH = 50 + rows * 60 + 20;
1854
+ currentY += pointsH + gap;
1855
+ }
1856
+ // BBS 详细贡献
1857
+ let detailedH = 0;
1858
+ if (bbsData.detailed.length > 0) {
1859
+ const rows = Math.ceil(bbsData.detailed.length / 2);
1860
+ detailedH = 50 + rows * 50 + 20;
1861
+ currentY += detailedH + gap;
1862
+ }
1863
+ // 基础统计
1864
+ const statsH = 180;
1865
+ currentY += statsH + gap;
1866
+ // 表态
1867
+ let reactionSectionH = 80;
1868
+ if (reactions.length > 0) {
1869
+ const tempC = createCanvas(100, 100);
1870
+ const tempCtx = tempC.getContext('2d');
1871
+ tempCtx.font = `14px "${font}"`;
1872
+ let rx = 50, lines = 1;
1873
+ reactions.forEach(item => {
1874
+ const t = `${item.l}: ${item.c}`;
1875
+ const w = tempCtx.measureText(t).width + 30;
1876
+ if (rx + w > width - 50) {
1877
+ rx = 50;
1878
+ lines++;
1879
+ }
1880
+ rx += w + 10;
1881
+ });
1882
+ reactionSectionH = 50 + (lines * 35) + 20;
1883
+ }
1884
+ currentY += reactionSectionH + gap;
1885
+ // 热力图
1886
+ const mapH = 200;
1887
+ currentY += mapH + gap;
1888
+ // 时间信息区域高度
1889
+ let timesH = 0;
1890
+ if (bbsData.times.length > 0) {
1891
+ timesH = 80;
1892
+ currentY += timesH;
1893
+ }
1894
+ const totalHeight = currentY + 30; // 底部版权留白
1895
+ const canvas = createCanvas(width, totalHeight);
1896
+ const ctx = canvas.getContext('2d');
1897
+ // 背景
1898
+ ctx.fillStyle = '#f0f2f5';
1899
+ ctx.fillRect(0, 0, width, totalHeight);
1900
+ try {
1901
+ if (bannerUrl) {
1902
+ const img = await loadImage(bannerUrl);
1903
+ const r = Math.max(width / img.width, bannerH / img.height);
1904
+ ctx.drawImage(img, 0, 0, img.width, img.height, (width - img.width * r) / 2, (bannerH - img.height * r) / 2, img.width * r, img.height * r);
1905
+ }
1906
+ else {
1907
+ ctx.fillStyle = '#3498db';
1908
+ ctx.fillRect(0, 0, width, bannerH);
1909
+ }
1910
+ }
1911
+ catch (e) {
1912
+ ctx.fillStyle = '#3498db';
1913
+ ctx.fillRect(0, 0, width, bannerH);
1914
+ }
1915
+ const overlay = ctx.createLinearGradient(0, 80, 0, bannerH);
1916
+ overlay.addColorStop(0, 'rgba(0,0,0,0)');
1917
+ overlay.addColorStop(1, 'rgba(0,0,0,0.5)');
1918
+ ctx.fillStyle = overlay;
1919
+ ctx.fillRect(0, 0, width, bannerH);
1920
+ // Header
1921
+ const cardTop = bannerH - cardOverlap;
1922
+ ctx.shadowColor = 'rgba(0,0,0,0.1)';
1923
+ ctx.shadowBlur = 10;
1924
+ ctx.fillStyle = '#fff';
1925
+ roundRect(ctx, 20, cardTop, width - 40, headerH, 10);
1926
+ ctx.fill();
1927
+ ctx.shadowBlur = 0;
1928
+ const avX = 50, avY = cardTop - 30;
1929
+ ctx.beginPath();
1930
+ ctx.arc(avX + 50, avY + 50, 54, 0, Math.PI * 2);
1931
+ ctx.fillStyle = '#fff';
1932
+ ctx.fill();
1933
+ if (avatarUrl) {
1934
+ try {
1935
+ const img = await loadImage(avatarUrl);
1936
+ ctx.save();
1937
+ ctx.beginPath();
1938
+ ctx.arc(avX + 50, avY + 50, 50, 0, Math.PI * 2);
1939
+ ctx.clip();
1940
+ ctx.drawImage(img, avX, avY, 100, 100);
1941
+ ctx.restore();
1942
+ }
1943
+ catch (e) { }
1944
+ }
1945
+ const nameX = 180, nameY = cardTop + 20;
1946
+ ctx.textBaseline = 'top';
1947
+ ctx.fillStyle = '#333';
1948
+ ctx.font = `bold 32px "${font}"`;
1949
+ ctx.fillText(username, nameX, nameY);
1950
+ const nameW = ctx.measureText(username).width;
1951
+ ctx.fillStyle = '#f39c12';
1952
+ roundRect(ctx, nameX + nameW + 15, nameY + 5, 50, 24, 4);
1953
+ ctx.fill();
1954
+ ctx.fillStyle = '#fff';
1955
+ ctx.font = `bold 16px "${font}"`;
1956
+ ctx.fillText(levelText, nameX + nameW + 22, nameY + 8);
1957
+ ctx.textAlign = 'right';
1958
+ ctx.fillStyle = '#999';
1959
+ ctx.font = `bold 20px "${font}"`;
1960
+ ctx.fillText(`UID: ${uid}`, width - 50, nameY + 10);
1961
+ ctx.textAlign = 'left';
1962
+ const mcid = (_d = bbsData.profile.find(p => p.l === 'MCID')) === null || _d === void 0 ? void 0 : _d.v;
1963
+ const subText = mcid ? `MCID: ${mcid} | ${signature}` : signature;
1964
+ ctx.fillStyle = '#666';
1965
+ ctx.font = `16px "${font}"`;
1966
+ wrapText(ctx, subText, nameX, nameY + 50, width - 250, 24, 2);
1967
+ let dy = cardTop + headerH + padding;
1968
+ // 绘制 BBS 勋章
1969
+ if (bbsData.medals.length > 0) {
1970
+ ctx.fillStyle = '#fff';
1971
+ roundRect(ctx, 20, dy, width - 40, medalsH, 10);
1972
+ ctx.fill();
1973
+ ctx.fillStyle = '#333';
1974
+ ctx.font = `bold 18px "${font}"`;
1975
+ ctx.fillText('勋章墙', 40, dy + 25);
1976
+ ctx.strokeStyle = '#eee';
1977
+ ctx.beginPath();
1978
+ ctx.moveTo(40, dy + 50);
1979
+ ctx.lineTo(width - 40, dy + 50);
1980
+ ctx.stroke();
1981
+ let mx = 40, my = dy + 60;
1982
+ const iconSize = 32;
1983
+ for (const m of bbsData.medals) {
1984
+ try {
1985
+ const img = await loadImage(m.src);
1986
+ ctx.drawImage(img, mx, my, iconSize, iconSize);
1987
+ }
1988
+ catch (e) { }
1989
+ mx += iconSize + 15;
1990
+ if (mx > width - 80) {
1991
+ mx = 40;
1992
+ my += iconSize + 10;
1993
+ }
1994
+ }
1995
+ dy += medalsH + gap;
1996
+ }
1997
+ // 绘制 BBS 积分
1998
+ if (bbsData.points.length > 0) {
1999
+ ctx.fillStyle = '#fff';
2000
+ roundRect(ctx, 20, dy, width - 40, pointsH, 10);
2001
+ ctx.fill();
2002
+ ctx.fillStyle = '#333';
2003
+ ctx.font = `bold 18px "${font}"`;
2004
+ ctx.fillText('积分统计', 40, dy + 25);
2005
+ ctx.beginPath();
2006
+ ctx.moveTo(40, dy + 50);
2007
+ ctx.lineTo(width - 40, dy + 50);
2008
+ ctx.stroke();
2009
+ const colW = (width - 80) / 4;
2010
+ bbsData.points.forEach((p, i) => {
2011
+ const col = i % 4;
2012
+ const row = Math.floor(i / 4);
2013
+ const px = 40 + col * colW;
2014
+ const py = dy + 70 + row * 60;
2015
+ ctx.fillStyle = '#999';
2016
+ ctx.font = `12px "${font}"`;
2017
+ ctx.fillText(p.l, px, py);
2018
+ ctx.fillStyle = '#333';
2019
+ ctx.font = `bold 20px "${font}"`;
2020
+ ctx.fillText(p.v, px, py + 20);
2021
+ });
2022
+ dy += pointsH + gap;
2023
+ }
2024
+ // 绘制 BBS 详细贡献
2025
+ if (bbsData.detailed.length > 0) {
2026
+ ctx.fillStyle = '#fff';
2027
+ roundRect(ctx, 20, dy, width - 40, detailedH, 10);
2028
+ ctx.fill();
2029
+ ctx.fillStyle = '#333';
2030
+ ctx.font = `bold 18px "${font}"`;
2031
+ ctx.fillText('详细贡献', 40, dy + 25);
2032
+ ctx.beginPath();
2033
+ ctx.moveTo(40, dy + 50);
2034
+ ctx.lineTo(width - 40, dy + 50);
2035
+ ctx.stroke();
2036
+ const colW = (width - 80) / 2;
2037
+ bbsData.detailed.forEach((d, i) => {
2038
+ const col = i % 2;
2039
+ const row = Math.floor(i / 2);
2040
+ const dx = 40 + col * colW;
2041
+ const dyLoc = dy + 70 + row * 50;
2042
+ ctx.fillStyle = '#555';
2043
+ ctx.font = `16px "${font}"`;
2044
+ ctx.fillText(d.l, dx, dyLoc);
2045
+ ctx.fillStyle = '#2ecc71';
2046
+ ctx.font = `bold 16px "${font}"`;
2047
+ const addTxt = `+${d.add}`;
2048
+ const addW = ctx.measureText(addTxt).width;
2049
+ ctx.fillText(addTxt, dx + 120, dyLoc);
2050
+ ctx.fillStyle = '#3498db';
2051
+ const editTxt = `~${d.edit}`;
2052
+ ctx.fillText(editTxt, dx + 120 + addW + 15, dyLoc);
2053
+ });
2054
+ dy += detailedH + gap;
2055
+ }
2056
+ // 绘制 基础统计
2057
+ ctx.fillStyle = '#fff';
2058
+ roundRect(ctx, 20, dy, width - 40, statsH, 10);
2059
+ ctx.fill();
2060
+ ctx.fillStyle = '#333';
2061
+ ctx.font = `bold 18px "${font}"`;
2062
+ ctx.fillText('基础统计', 40, dy + 25);
2063
+ ctx.beginPath();
2064
+ ctx.moveTo(40, dy + 50);
2065
+ ctx.lineTo(width - 40, dy + 50);
2066
+ ctx.stroke();
2067
+ const colW = (width - 40) / 3;
2068
+ basicStats.forEach((s, i) => {
2069
+ const col = i % 3, row = Math.floor(i / 3);
2070
+ const cx = 20 + col * colW;
2071
+ const cy = dy + 70 + row * 50;
2072
+ ctx.fillStyle = '#999';
2073
+ ctx.font = `14px "${font}"`;
2074
+ ctx.fillText(s.l, cx + 30, cy);
2075
+ ctx.fillStyle = '#333';
2076
+ ctx.font = `bold 16px "${font}"`;
2077
+ ctx.fillText(s.v, cx + 30, cy + 25);
2078
+ });
2079
+ dy += statsH + gap;
2080
+ // 绘制 表态
2081
+ ctx.fillStyle = '#fff';
2082
+ roundRect(ctx, 20, dy, width - 40, reactionSectionH, 10);
2083
+ ctx.fill();
2084
+ ctx.fillStyle = '#333';
2085
+ ctx.font = `bold 18px "${font}"`;
2086
+ ctx.fillText('表态统计', 40, dy + 25);
2087
+ ctx.beginPath();
2088
+ ctx.moveTo(40, dy + 50);
2089
+ ctx.lineTo(width - 40, dy + 50);
2090
+ ctx.stroke();
2091
+ if (reactions.length) {
2092
+ let rx = 50, ry = dy + 75;
2093
+ ctx.font = `14px "${font}"`;
2094
+ reactions.forEach(r => {
2095
+ const t = `${r.l}: ${r.c}`;
2096
+ const w = ctx.measureText(t).width + 30;
2097
+ if (rx + w > width - 50) {
2098
+ rx = 50;
2099
+ ry += 35;
2100
+ }
2101
+ ctx.fillStyle = '#f0f2f5';
2102
+ roundRect(ctx, rx, ry - 18, w, 28, 14);
2103
+ ctx.fill();
2104
+ ctx.fillStyle = '#e74c3c';
2105
+ ctx.beginPath();
2106
+ ctx.arc(rx + 10, ry - 4, 3, 0, Math.PI * 2);
2107
+ ctx.fill();
2108
+ ctx.fillStyle = '#555';
2109
+ ctx.fillText(t, rx + 20, ry - 10);
2110
+ rx += w + 10;
2111
+ });
2112
+ }
2113
+ else {
2114
+ ctx.fillStyle = '#ccc';
2115
+ ctx.font = `14px "${font}"`;
2116
+ ctx.fillText('暂无表态', 50, dy + 75);
2117
+ }
2118
+ dy += reactionSectionH + gap;
2119
+ // 绘制 热力图
2120
+ ctx.fillStyle = '#fff';
2121
+ roundRect(ctx, 20, dy, width - 40, mapH, 10);
2122
+ ctx.fill();
2123
+ ctx.fillStyle = '#333';
2124
+ ctx.font = `bold 18px "${font}"`;
2125
+ ctx.fillText(`活跃度 (${currentYear})`, 40, dy + 25);
2126
+ ctx.beginPath();
2127
+ ctx.moveTo(40, dy + 50);
2128
+ ctx.lineTo(width - 40, dy + 50);
2129
+ ctx.stroke();
2130
+ const box = 11, g = 3, sx = 50, sy = dy + 70;
2131
+ const start = new Date(currentYear, 0, 1);
2132
+ let curr = new Date(currentYear, 0, 1);
2133
+ const end = new Date(currentYear, 11, 31);
2134
+ while (curr <= end) {
2135
+ const doy = Math.floor((curr.getTime() - start.getTime()) / 86400000);
2136
+ const c = Math.floor((doy + start.getDay() + 6) / 7);
2137
+ const r = (curr.getDay() + 6) % 7;
2138
+ if (c < 53) {
2139
+ const count = activityMap[curr.toISOString().split('T')[0]] || 0;
2140
+ ctx.fillStyle = count === 0 ? '#ebedf0' : count <= 2 ? '#9be9a8' : count <= 5 ? '#40c463' : '#216e39';
2141
+ roundRect(ctx, sx + c * (box + g), sy + r * (box + g), box, box, 2);
2142
+ ctx.fill();
2143
+ }
2144
+ curr.setDate(curr.getDate() + 1);
2145
+ }
2146
+ dy += mapH + gap;
2147
+ // 绘制详细时间列表
2148
+ if (bbsData.times.length > 0) {
2149
+ ctx.fillStyle = '#666';
2150
+ ctx.font = `12px "${font}"`;
2151
+ let tx = 40, ty = dy;
2152
+ bbsData.times.forEach(t => {
2153
+ const str = `${t.l}: ${t.v}`;
2154
+ const w = ctx.measureText(str).width;
2155
+ if (tx + w > width - 40) {
2156
+ tx = 40; // 换行
2157
+ ty += 20;
2158
+ }
2159
+ ctx.fillText(str, tx, ty);
2160
+ tx += w + 30; // 字段间距
2161
+ });
2162
+ dy = ty + 30; // 更新总高度游标
2163
+ }
2164
+ // Footer
2165
+ ctx.fillStyle = '#999';
2166
+ ctx.font = `12px "${font}"`;
2167
+ ctx.textAlign = 'center';
2168
+ ctx.fillText('mcmod.cn & bbs.mcmod.cn | Powered by Koishi | Bot By Mai_xiyu', width / 2, totalHeight - 15);
2169
+ return canvas.toBuffer('image/png');
2170
+ }
2171
+ // ================= 详情页卡片 =================
2172
+ // ================= 详情页卡片 (资料/物品/通用) =================
2173
+ // ================= 详情页卡片 (资料/物品/通用) - 深度解析版 =================
2174
+ async function createInfoCard(url, type) {
2175
+ // 1. 获取并解析页面
2176
+ const res = await fetchWithTimeout(url, { headers: getHeaders('https://search.mcmod.cn/') });
2177
+ const html = await res.text();
2178
+ const $ = cheerio.load(html);
2179
+ // --- 基础信息 ---
2180
+ // 标题:尝试从 .itemname 或 h3 获取
2181
+ let title = cleanText($('.itemname .name h5, .itemname .name').first().text());
2182
+ if (!title)
2183
+ title = cleanText($('title').text().split('-')[0].trim());
2184
+ // 来源/模组:面包屑导航倒数第三个通常是模组名
2185
+ let source = cleanText($('.common-nav .item').eq(1).text());
2186
+ // 或者尝试从 nav 链接判断
2187
+ if (!source)
2188
+ source = cleanText($('.common-nav li a[href*="/class/"]').last().text());
2189
+ // 图标:优先获取高清大图 (128x128),其次普通图标
2190
+ let imgUrl = fixUrl($('.item-info-table img[width="128"]').attr('src'));
2191
+ if (!imgUrl)
2192
+ imgUrl = fixUrl($('.item-info-table img').first().attr('src'));
2193
+ if (!imgUrl)
2194
+ imgUrl = fixUrl($('.common-icon-text-frame img').attr('src'));
2195
+ // --- 属性列表 ---
2196
+ const props = [];
2197
+ // 1. 抓取右侧/下方的表格数据 (.item-data table, .item-info-table table)
2198
+ // 排除包含图片的行,只抓取文字属性
2199
+ $('table.table-bordered tr').each((i, tr) => {
2200
+ const tds = $(tr).find('td');
2201
+ if (tds.length >= 2) {
2202
+ // 可能是 <th>key</th><td>value</td> 或者 <td>key</td><td>value</td>
2203
+ let key = cleanText($(tds[0]).text()).replace(/[::]/g, '');
2204
+ let val = cleanText($(tds[1]).text());
2205
+ // 过滤无效行 (如图标行)
2206
+ if (key && val && val.length > 0 && !$(tds[1]).find('img').length) {
2207
+ // 排除重复
2208
+ if (!props.some(p => p.l === key)) {
2209
+ props.push({ l: key, v: val });
2210
+ }
2211
+ }
2212
+ }
2213
+ });
2214
+ // --- 简介 ---
2215
+ // 优先 .item-content,其次 meta description
2216
+ let desc = '';
2217
+ const contentDiv = $('.item-content.common-text').first();
2218
+ if (contentDiv.length) {
2219
+ desc = cleanText(contentDiv.text());
2220
+ }
2221
+ else {
2222
+ desc = $('meta[name="description"]').attr('content') || '暂无简介';
2223
+ }
2224
+ // 清理 "MCmod does not have a description..." 等默认文本
2225
+ if (desc.includes('MCmod does not have a description'))
2226
+ desc = '暂无简介';
2227
+ // --- 相关物品 (新增) ---
2228
+ const relations = [];
2229
+ $('.common-imglist-block .common-imglist li').each((i, el) => {
2230
+ if (i >= 7)
2231
+ return; // 最多显示7个
2232
+ const name = $(el).attr('data-original-title') || cleanText($(el).find('.text').text());
2233
+ const icon = fixUrl($(el).find('img').attr('src'));
2234
+ if (name && icon)
2235
+ relations.push({ n: name, i: icon });
2236
+ });
2237
+ // ================= 绘图逻辑 =================
2238
+ const width = 800;
2239
+ const font = GLOBAL_FONT_FAMILY;
2240
+ const margin = 20;
2241
+ const winPadding = 30;
2242
+ const contentW = width - margin * 2 - winPadding * 2;
2243
+ const dummyC = createCanvas(100, 100);
2244
+ const dummy = dummyC.getContext('2d');
2245
+ dummy.font = `bold 32px "${font}"`;
2246
+ // 1. 高度计算
2247
+ // Header (Title + Source)
2248
+ let headerH = 60;
2249
+ if (source)
2250
+ headerH += 30;
2251
+ // Content Layout: Left (Icon + Props) | Right (Desc)
2252
+ const iconSize = 100;
2253
+ const leftColW = 240; // 左侧宽度
2254
+ const rightColW = contentW - leftColW - 20; // 右侧宽度
2255
+ // Props Height
2256
+ let propsH = 0;
2257
+ if (props.length) {
2258
+ propsH = props.length * 28 + 20;
2259
+ }
2260
+ const leftH = iconSize + 20 + propsH;
2261
+ // Desc Height
2262
+ dummy.font = `16px "${font}"`;
2263
+ const descLines = wrapText(dummy, desc, 0, 0, rightColW, 26, 30, false) / 26;
2264
+ const descH = 40 + descLines * 26; // Title + Text
2265
+ // Relations Height
2266
+ let relH = 0;
2267
+ if (relations.length) {
2268
+ relH = 90; // Title + Icons
2269
+ }
2270
+ // Main Content Height (取左右最大值)
2271
+ let mainH = Math.max(leftH, descH);
2272
+ // Total Layout
2273
+ let cursorY = margin + 50; // Top traffic lights
2274
+ const gap = 20;
2275
+ cursorY += headerH + gap;
2276
+ cursorY += mainH + gap;
2277
+ if (relH)
2278
+ cursorY += relH + gap;
2279
+ const windowH = cursorY;
2280
+ const totalH = windowH + margin * 2;
2281
+ // 2. 绘制背景与窗口
2282
+ const canvas = createCanvas(width, totalH);
2283
+ const ctx = canvas.getContext('2d');
2284
+ // 背景 (Bing)
2285
+ try {
2286
+ const bgUrl = 'https://bing.biturl.top/?resolution=1920&format=image&index=0&mkt=zh-CN';
2287
+ const bgImg = await loadImage(bgUrl);
2288
+ const r = Math.max(width / bgImg.width, totalH / bgImg.height);
2289
+ ctx.drawImage(bgImg, (width - bgImg.width * r) / 2, (totalH - bgImg.height * r) / 2, bgImg.width * r, bgImg.height * r);
2290
+ ctx.fillStyle = 'rgba(0,0,0,0.1)';
2291
+ ctx.fillRect(0, 0, width, totalH);
2292
+ }
2293
+ catch (e) {
2294
+ const grad = ctx.createLinearGradient(0, 0, 0, totalH);
2295
+ grad.addColorStop(0, '#e6dee9');
2296
+ grad.addColorStop(1, '#dad4ec'); // 柔和紫灰
2297
+ ctx.fillStyle = grad;
2298
+ ctx.fillRect(0, 0, width, totalH);
2299
+ }
2300
+ // 窗口 (Acrylic)
2301
+ const winX = margin, winY = margin;
2302
+ ctx.save();
2303
+ ctx.shadowColor = 'rgba(0,0,0,0.2)';
2304
+ ctx.shadowBlur = 40;
2305
+ ctx.shadowOffsetY = 20;
2306
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.9)';
2307
+ roundRect(ctx, winX, winY, width - margin * 2, windowH, 16);
2308
+ ctx.fill();
2309
+ ctx.restore();
2310
+ ctx.strokeStyle = 'rgba(255,255,255,0.6)';
2311
+ ctx.lineWidth = 1;
2312
+ roundRect(ctx, winX, winY, width - margin * 2, windowH, 16);
2313
+ ctx.stroke();
2314
+ // 交通灯
2315
+ ['#ff5f56', '#ffbd2e', '#27c93f'].forEach((c, i) => {
2316
+ ctx.beginPath();
2317
+ ctx.arc(winX + 20 + i * 25, winY + 20, 6, 0, Math.PI * 2);
2318
+ ctx.fillStyle = c;
2319
+ ctx.fill();
2320
+ });
2321
+ // --- 内容绘制 ---
2322
+ let dy = winY + 50;
2323
+ const cx = winX + winPadding;
2324
+ // 1. Header
2325
+ ctx.fillStyle = '#333';
2326
+ ctx.font = `bold 32px "${font}"`;
2327
+ ctx.textBaseline = 'top';
2328
+ ctx.fillText(title, cx, dy);
2329
+ if (source) {
2330
+ ctx.fillStyle = '#888';
2331
+ ctx.font = `bold 16px "${font}"`;
2332
+ // 绘制所属模组标签
2333
+ const tagW = ctx.measureText(source).width + 16;
2334
+ ctx.fillStyle = '#f0f0f0';
2335
+ roundRect(ctx, cx, dy + 45, tagW, 26, 6);
2336
+ ctx.fill();
2337
+ ctx.fillStyle = '#666';
2338
+ ctx.fillText(source, cx + 8, dy + 49);
2339
+ }
2340
+ dy += headerH + gap;
2341
+ // 2. Left Column (Icon + Props)
2342
+ const leftX = cx;
2343
+ let leftY = dy;
2344
+ // Icon
2345
+ if (imgUrl) {
2346
+ try {
2347
+ const img = await loadImage(imgUrl);
2348
+ // 保持比例绘制在 100x100 区域居中
2349
+ const r = Math.min(iconSize / img.width, iconSize / img.height);
2350
+ const dw = img.width * r, dh = img.height * r;
2351
+ ctx.drawImage(img, leftX + (iconSize - dw) / 2, leftY + (iconSize - dh) / 2, dw, dh);
2352
+ }
2353
+ catch (e) {
2354
+ ctx.fillStyle = '#eee';
2355
+ roundRect(ctx, leftX, leftY, iconSize, iconSize, 12);
2356
+ ctx.fill();
2357
+ }
2358
+ }
2359
+ leftY += iconSize + 20;
2360
+ // Props
2361
+ if (props.length) {
2362
+ props.forEach(p => {
2363
+ ctx.fillStyle = '#999';
2364
+ ctx.font = `12px "${font}"`;
2365
+ ctx.fillText(p.l, leftX, leftY);
2366
+ ctx.fillStyle = '#333';
2367
+ ctx.font = `bold 14px "${font}"`;
2368
+ let v = p.v;
2369
+ if (v.length > 20)
2370
+ v = v.substring(0, 18) + '...';
2371
+ ctx.fillText(v, leftX, leftY + 16);
2372
+ leftY += 38;
2373
+ });
2374
+ }
2375
+ // 3. Right Column (Description)
2376
+ const rightX = cx + leftColW + 20;
2377
+ let rightY = dy;
2378
+ ctx.fillStyle = '#333';
2379
+ ctx.font = `bold 20px "${font}"`;
2380
+ ctx.fillText('简介', rightX, rightY);
2381
+ ctx.fillStyle = '#3498db';
2382
+ ctx.fillRect(rightX, rightY + 25, 30, 4);
2383
+ rightY += 40;
2384
+ ctx.fillStyle = '#555';
2385
+ ctx.font = `16px "${font}"`;
2386
+ wrapText(ctx, desc, rightX, rightY, rightColW, 26, 30, true);
2387
+ // 更新 dy 到主内容下方
2388
+ dy += mainH + gap;
2389
+ // 4. Relations (Bottom)
2390
+ if (relations.length) {
2391
+ // 分割线
2392
+ ctx.strokeStyle = '#eee';
2393
+ ctx.lineWidth = 1;
2394
+ ctx.beginPath();
2395
+ ctx.moveTo(cx, dy);
2396
+ ctx.lineTo(cx + contentW, dy);
2397
+ ctx.stroke();
2398
+ dy += 20;
2399
+ ctx.fillStyle = '#333';
2400
+ ctx.font = `bold 18px "${font}"`;
2401
+ ctx.fillText('相关物品', cx, dy);
2402
+ let rx = cx + 90;
2403
+ const rIconSize = 32;
2404
+ for (const r of relations) {
2405
+ try {
2406
+ const img = await loadImage(r.i);
2407
+ ctx.drawImage(img, rx, dy - 5, rIconSize, rIconSize);
2408
+ }
2409
+ catch (e) {
2410
+ ctx.fillStyle = '#eee';
2411
+ ctx.fillRect(rx, dy - 5, rIconSize, rIconSize);
2412
+ }
2413
+ // 简单显示名字 tooltip 效果不太好做,这里只画图标,或者简单的名字
2414
+ // 为了美观,这里只画图标,名字太长会乱
2415
+ // ctx.fillStyle = '#666'; ctx.font = `10px "${font}"`;
2416
+ // ctx.fillText(r.n.substring(0, 5), rx, dy + 40);
2417
+ rx += rIconSize + 15;
2418
+ }
2419
+ }
2420
+ // Footer
2421
+ ctx.fillStyle = '#aaa';
2422
+ ctx.font = `12px "${font}"`;
2423
+ ctx.textAlign = 'center';
2424
+ ctx.fillText('mcmod.cn | Powered by Koishi', width / 2, totalH - 15);
2425
+ return canvas.toBuffer('image/png');
2426
+ }
2427
+ // ================= Koishi =================
2428
+ exports.name = 'mcmod-search';
2429
+ exports.Config = Schema.object({
2430
+ sendLink: Schema.boolean().default(true).description('发送卡片后是否附带链接'),
2431
+ cookie: Schema.string().description('【可选】手动填写 mcmod.cn 的 Cookie'),
2432
+ });
2433
+ function apply(ctx, config) {
2434
+ var _a;
2435
+ const logger = ctx.logger('mcmod');
2436
+ if (!initFont(config.fontPath, logger)) { }
2437
+ // 初始化 Cookie
2438
+ if (config.cookie) {
2439
+ globalCookie = config.cookie;
2440
+ logger.info('使用手动配置的 Cookie');
2441
+ }
2442
+ else if (config.autoCookie && cookieManager) {
2443
+ cookieManager.getCookie().then(cookie => {
2444
+ if (cookie) {
2445
+ globalCookie = cookie;
2446
+ cookieLastCheck = Date.now();
2447
+ logger.info('已自动获取 mcmod.cn Cookie');
2448
+ }
2449
+ }).catch(e => {
2450
+ logger.warn('自动获取 Cookie 失败:', e.message);
2451
+ });
2452
+ }
2453
+ // --- 状态管理 (严格隔离) ---
2454
+ function clearState(cid) {
2455
+ const state = searchStates.get(cid);
2456
+ if (state && state.timer)
2457
+ clearTimeout(state.timer);
2458
+ searchStates.delete(cid);
2459
+ }
2460
+ // --- 排队系统 ---
2461
+ const queue = [];
2462
+ let isProcessing = false;
2463
+ async function processQueue() {
2464
+ if (isProcessing || queue.length === 0)
2465
+ return;
2466
+ isProcessing = true;
2467
+ const { session, task } = queue.shift();
2468
+ try {
2469
+ await task();
2470
+ }
2471
+ catch (e) {
2472
+ logger.error('任务执行出错:', e);
2473
+ await session.send(`执行出错: ${e.message}`);
2474
+ }
2475
+ finally {
2476
+ isProcessing = false;
2477
+ // 稍微延迟一下,给系统喘息时间
2478
+ setTimeout(processQueue, 500);
2479
+ }
2480
+ }
2481
+ // 入队函数
2482
+ function enqueue(session, taskName, taskFunc) {
2483
+ return new Promise((resolve, reject) => {
2484
+ queue.push({
2485
+ session,
2486
+ task: async () => {
2487
+ try {
2488
+ // 如果队列较长,提示用户
2489
+ if (queue.length > 1) {
2490
+ // 可选:发送排队提示
2491
+ // await session.send(`正在处理您的请求... (排队中)`);
2492
+ }
2493
+ await taskFunc();
2494
+ resolve();
2495
+ }
2496
+ catch (e) {
2497
+ reject(e);
2498
+ }
2499
+ }
2500
+ });
2501
+ processQueue();
2502
+ });
2503
+ }
2504
+ // 辅助:尝试撤回消息
2505
+ async function tryWithdraw(session, messageIds) {
2506
+ if (!messageIds || !messageIds.length)
2507
+ return;
2508
+ try {
2509
+ for (const id of messageIds) {
2510
+ await session.bot.deleteMessage(session.channelId, id);
2511
+ }
2512
+ }
2513
+ catch (e) { }
2514
+ }
2515
+ // --- 注册指令 ---
2516
+ const prefix = ((_a = config === null || config === void 0 ? void 0 : config.prefixes) === null || _a === void 0 ? void 0 : _a.cnmc) || 'cnmc';
2517
+ const commandTypes = ['mod', 'data', 'pack', 'tutorial', 'author', 'user'];
2518
+ ctx.command(`${prefix}.help`).action(() => [
2519
+ `${prefix} <关键词> | 默认搜索 Mod`,
2520
+ `${prefix}.mod/.data/.pack/.tutorial/.author/.user <关键词>`,
2521
+ '列表交互:输入序号查看,n 下一页,p 上一页,q 退出',
2522
+ ].join('\n'));
2523
+ commandTypes.forEach(type => {
2524
+ ctx.command(`${prefix}.${type} <keyword:text>`)
2525
+ .action(async ({ session }, keyword) => {
2526
+ if (!keyword)
2527
+ return '请输入关键词。';
2528
+ // 将搜索任务加入队列
2529
+ enqueue(session, `search-${type}`, async () => {
2530
+ var _a;
2531
+ try {
2532
+ if (config.debug)
2533
+ logger.debug(`[${session.userId}] 正在搜索 ${keyword} ...`);
2534
+ // 1. 尝试主搜索
2535
+ let results = await fetchSearch(keyword, type);
2536
+ // 2. [修改] 如果主搜索为空,且类型支持,尝试备用接口
2537
+ if (!results.length && FALLBACK_TYPE_MAP[type]) {
2538
+ if (config.debug)
2539
+ logger.debug(`主搜索为空,尝试备用接口: ${type}`);
2540
+ const fallbackResults = await fetchSearchFallback(keyword, type);
2541
+ if (fallbackResults.length > 0) {
2542
+ results = fallbackResults;
2543
+ }
2544
+ }
2545
+ if (!results.length) {
2546
+ await session.send('未找到相关结果。(备用也没用,我劝你换个关键词试试)');
2547
+ return;
2548
+ }
2549
+ // 单结果直接处理
2550
+ if (results.length === 1) {
2551
+ const item = results[0];
2552
+ await ensureValidCookie();
2553
+ let img;
2554
+ if (type === 'author')
2555
+ img = await drawAuthorCard(item.link);
2556
+ else if (type === 'user') {
2557
+ const uid = ((_a = item.link.match(/\/(\d+)(?:\.html|\/)?$/)) === null || _a === void 0 ? void 0 : _a[1]) || '0';
2558
+ img = await drawCenterCardImpl(uid, logger);
2559
+ }
2560
+ else if (type === 'mod' || type === 'pack')
2561
+ img = await drawModCard(item.link);
2562
+ else if (type === 'tutorial')
2563
+ img = await drawTutorialCard(item.link);
2564
+ else
2565
+ img = await createInfoCard(item.link, type);
2566
+ await session.send(h.image(img, 'image/png'));
2567
+ if (config.sendLink)
2568
+ await session.send(`链接: ${item.link}`);
2569
+ return;
2570
+ }
2571
+ // 多结果:初始化状态(隔离在 session.cid)
2572
+ clearState(session.cid);
2573
+ const listText = formatListPage(results, 0, type);
2574
+ const sentMessageIds = await session.send(listText);
2575
+ searchStates.set(session.cid, {
2576
+ type,
2577
+ results,
2578
+ pageIndex: 0,
2579
+ messageIds: sentMessageIds,
2580
+ timer: setTimeout(() => searchStates.delete(session.cid), TIMEOUT_MS)
2581
+ });
2582
+ }
2583
+ catch (e) {
2584
+ logger.error(e);
2585
+ await session.send(`处理失败: ${e.message}`);
2586
+ }
2587
+ });
2588
+ });
2589
+ });
2590
+ ctx.command(`${prefix} <keyword:text>`)
2591
+ .action(async ({ session }, keyword) => {
2592
+ if (!keyword)
2593
+ return '请输入关键词。';
2594
+ enqueue(session, 'search-mod', async () => {
2595
+ try {
2596
+ if (config.debug)
2597
+ logger.debug(`[${session.userId}] 正在搜索 ${keyword} ...`);
2598
+ let results = await fetchSearch(keyword, 'mod');
2599
+ if (!results.length && FALLBACK_TYPE_MAP.mod) {
2600
+ if (config.debug)
2601
+ logger.debug('主搜索为空,尝试备用接口: mod');
2602
+ const fallbackResults = await fetchSearchFallback(keyword, 'mod');
2603
+ if (fallbackResults.length > 0) {
2604
+ results = fallbackResults;
2605
+ }
2606
+ }
2607
+ if (!results.length) {
2608
+ await session.send('未找到相关结果。(备用也没用,我劝你换个关键词试试)');
2609
+ return;
2610
+ }
2611
+ if (results.length === 1) {
2612
+ const item = results[0];
2613
+ await ensureValidCookie();
2614
+ const img = await drawModCard(item.link);
2615
+ await session.send(h.image(img, 'image/png'));
2616
+ if (config.sendLink)
2617
+ await session.send(`链接: ${item.link}`);
2618
+ return;
2619
+ }
2620
+ clearState(session.cid);
2621
+ const listText = formatListPage(results, 0, 'mod');
2622
+ const sentMessageIds = await session.send(listText);
2623
+ searchStates.set(session.cid, {
2624
+ results,
2625
+ page: 0,
2626
+ type: 'mod',
2627
+ messageIds: Array.isArray(sentMessageIds) ? sentMessageIds : [sentMessageIds],
2628
+ timer: setTimeout(() => {
2629
+ tryWithdraw(session, Array.isArray(sentMessageIds) ? sentMessageIds : [sentMessageIds]);
2630
+ clearState(session.cid);
2631
+ }, config.timeouts || 60000),
2632
+ });
2633
+ }
2634
+ catch (e) {
2635
+ logger.error('执行出错:', e);
2636
+ await session.send(`执行出错: ${e.message}`);
2637
+ }
2638
+ });
2639
+ });
2640
+ // --- 中间件 (处理序号选择) ---
2641
+ ctx.middleware(async (session, next) => {
2642
+ // 1. 专一性检查:只处理当前有搜索状态的用户
2643
+ const state = searchStates.get(session.cid);
2644
+ if (!state)
2645
+ return next();
2646
+ const input = session.content.trim().toLowerCase();
2647
+ // 退出
2648
+ if (input === 'q' || input === '退出') {
2649
+ clearState(session.cid);
2650
+ await tryWithdraw(session, state.messageIds); // 退出时也可以顺手撤回列表
2651
+ await session.send('已退出搜索。');
2652
+ return;
2653
+ }
2654
+ // 翻页
2655
+ if (input === 'p' || input === 'n') {
2656
+ // 加入队列处理翻页,防止并发
2657
+ enqueue(session, 'page-turn', async () => {
2658
+ // 重新获取状态,防止排队期间状态丢失
2659
+ const currentState = searchStates.get(session.cid);
2660
+ if (!currentState)
2661
+ return;
2662
+ clearTimeout(currentState.timer);
2663
+ currentState.timer = setTimeout(() => searchStates.delete(session.cid), TIMEOUT_MS);
2664
+ const total = Math.ceil(currentState.results.length / PAGE_SIZE);
2665
+ let newIndex = currentState.pageIndex;
2666
+ if (input === 'n' && currentState.pageIndex < total - 1)
2667
+ newIndex++;
2668
+ else if (input === 'p' && currentState.pageIndex > 0)
2669
+ newIndex--;
2670
+ else {
2671
+ await session.send('没有更多页面了。');
2672
+ return;
2673
+ }
2674
+ // 撤回旧列表(可选,为了整洁)
2675
+ await tryWithdraw(session, currentState.messageIds);
2676
+ currentState.pageIndex = newIndex;
2677
+ const newMsgIds = await session.send(formatListPage(currentState.results, currentState.pageIndex, currentState.type));
2678
+ currentState.messageIds = newMsgIds;
2679
+ });
2680
+ return;
2681
+ }
2682
+ // 选择序号
2683
+ const choice = parseInt(input);
2684
+ if (!isNaN(choice) && choice >= 1) {
2685
+ // 加入队列处理生成卡片
2686
+ enqueue(session, 'select-item', async () => {
2687
+ var _a;
2688
+ const currentState = searchStates.get(session.cid);
2689
+ if (!currentState)
2690
+ return; // 状态可能已过期
2691
+ const idx = choice - 1;
2692
+ const pageStart = currentState.pageIndex * PAGE_SIZE;
2693
+ const pageEnd = Math.min(pageStart + PAGE_SIZE, currentState.results.length);
2694
+ if (choice < pageStart + 1 || choice > pageEnd) {
2695
+ // 如果序号不在当前页,忽略或提示
2696
+ // await session.send(`请输入当前页显示的序号 (${pageStart + 1}-${pageEnd})。`);
2697
+ return;
2698
+ }
2699
+ if (idx >= 0 && idx < currentState.results.length) {
2700
+ const item = currentState.results[idx];
2701
+ // 撤回列表消息
2702
+ await tryWithdraw(session, currentState.messageIds);
2703
+ clearState(session.cid); // 完成交互,清除状态
2704
+ try {
2705
+ await ensureValidCookie();
2706
+ let img;
2707
+ if (currentState.type === 'author')
2708
+ img = await drawAuthorCard(item.link);
2709
+ else if (currentState.type === 'user') {
2710
+ const uid = ((_a = item.link.match(/\/(\d+)(?:\.html|\/)?$/)) === null || _a === void 0 ? void 0 : _a[1]) || '0';
2711
+ img = await drawCenterCardImpl(uid, logger);
2712
+ }
2713
+ else if (currentState.type === 'mod' || currentState.type === 'pack')
2714
+ img = await drawModCard(item.link);
2715
+ else if (currentState.type === 'tutorial')
2716
+ img = await drawTutorialCard(item.link);
2717
+ else
2718
+ img = await createInfoCard(item.link, currentState.type);
2719
+ await session.send(h.image(img, 'image/png'));
2720
+ if (config.sendLink)
2721
+ await session.send(`链接: ${item.link}`);
2722
+ }
2723
+ catch (e) {
2724
+ logger.error(e);
2725
+ await session.send(`生成失败: ${e.message}`);
2726
+ }
2727
+ }
2728
+ });
2729
+ return;
2730
+ }
2731
+ return next();
2732
+ });
2733
+ }