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