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