koishi-plugin-dwrgbwiki 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.
Files changed (4) hide show
  1. package/index.js +77 -0
  2. package/lib.js +729 -0
  3. package/package.json +27 -0
  4. package/readme.md +51 -0
package/index.js ADDED
@@ -0,0 +1,77 @@
1
+ 'use strict'
2
+
3
+ const { Schema, h } = require('koishi')
4
+ const lib = require('./lib')
5
+
6
+ const HELP = lib.buildHelp()
7
+
8
+ module.exports = {
9
+ name: 'dwrgbwiki',
10
+ description: '第五人格BWIKI编辑组测试用组件,纯AI生成喵',
11
+
12
+ Config: Schema.object({
13
+ dataSource: Schema.string()
14
+ .description('数据源地址,可在两个镜像站点之间切换:' + lib.KNOWN_SOURCES.join(' / '))
15
+ .default('https://website.homeworkkun.top'),
16
+ tryAlternate: Schema.boolean()
17
+ .description('主数据源失败时自动尝试备用数据源')
18
+ .default(true),
19
+ defaultPart: Schema.union(['8', '-127'])
20
+ .description('默认段位(8=巅七,-127=全段位)')
21
+ .default('8'),
22
+ showBadge: Schema.boolean()
23
+ .description('显示勋章分数据(获取失败时自动隐藏)')
24
+ .default(true),
25
+ badgeDays: Schema.number().min(1).max(365)
26
+ .description('勋章日增长率天数')
27
+ .default(7),
28
+ width: Schema.number().min(400).max(4000)
29
+ .description('图片宽度(px)')
30
+ .default(1000),
31
+ scale: Schema.number().min(1).max(4)
32
+ .description('截图倍率(越大越清晰)')
33
+ .default(2),
34
+ timeout: Schema.number().min(3000).max(60000)
35
+ .description('网络请求超时(毫秒)')
36
+ .default(15000),
37
+ }),
38
+
39
+ apply(ctx, config) {
40
+ ctx.command('胜率 <角色名> [条件:text]', '查询角色胜率并生成图片')
41
+ .usage(HELP)
42
+ .example('胜率 小女孩')
43
+ .example('胜率 小女孩 第43赛季 第3周 使用率')
44
+ .example('胜率 小女孩 43 3 胜率')
45
+ .action(async ({ session }, name, cond) => {
46
+ if (!name || !name.trim()) return HELP
47
+ if (!ctx.puppeteer) {
48
+ return '未检测到 puppeteer 服务,请先安装并启用 koishi-plugin-puppeteer'
49
+ }
50
+ try {
51
+ const result = await lib.collect(config, name.trim(), cond)
52
+ if (!result.ok) return result.error
53
+ const html = lib.buildHtml(result, { ...config, avatarBase: result.base })
54
+ const image = await capture(ctx, html, config)
55
+ return [h.image(image)]
56
+ } catch (e) {
57
+ ctx.logger('dwrgbwiki').warn(e)
58
+ return '查询失败:' + (e && e.message ? e.message : e)
59
+ }
60
+ })
61
+ },
62
+ }
63
+
64
+ async function capture(ctx, html, config) {
65
+ const page = await ctx.puppeteer.page()
66
+ try {
67
+ await page.setViewport({ width: config.width, height: 400, deviceScaleFactor: config.scale })
68
+ await page.setContent(html, { waitUntil: 'load' })
69
+ await page.waitForFunction(() => Array.from(document.images).every(i => i.complete), {
70
+ timeout: 15000,
71
+ polling: 200,
72
+ }).catch(() => {})
73
+ return await page.screenshot({ type: 'png', fullPage: true })
74
+ } finally {
75
+ await page.close().catch(() => {})
76
+ }
77
+ }
package/lib.js ADDED
@@ -0,0 +1,729 @@
1
+ 'use strict'
2
+
3
+ const KNOWN_SOURCES = [
4
+ 'https://website.homeworkkun.top',
5
+ 'https://web.homeworkkun.top',
6
+ ]
7
+
8
+ const METRICS = {
9
+ win_rate_without_draw: '去平胜率',
10
+ win_rate: '胜率',
11
+ ping_rate: '平率',
12
+ lose_rate: '负率',
13
+ use_rate: '使用率',
14
+ ban_rate: '禁用率',
15
+ }
16
+
17
+ const BADGE_TIERS = [
18
+ { key: '1', label: 'S1', cls: 'tier-s' },
19
+ { key: '10', label: 'S10', cls: 'tier-s' },
20
+ { key: '11', label: 'A11', cls: 'tier-a' },
21
+ { key: '100', label: 'A100', cls: 'tier-a' },
22
+ { key: '101', label: 'B101', cls: 'tier-b' },
23
+ { key: '300', label: 'B300', cls: 'tier-b' },
24
+ { key: '301', label: 'C301', cls: 'tier-c' },
25
+ { key: '500', label: 'C500', cls: 'tier-c' },
26
+ ]
27
+
28
+ const PARTS = { 1: '一阶', 2: '二阶', 3: '三阶', 4: '四阶', 5: '五阶', 6: '六阶', 7: '七阶', 8: '巅七', '-127': '全段位' }
29
+ const PART_ORDER = [8, 7, 6, 5, 4, 3, 2, 1]
30
+
31
+ const RATE_METRICS = ['win_rate_without_draw', 'win_rate', 'ping_rate', 'lose_rate', 'use_rate', 'ban_rate']
32
+
33
+ const METRIC_LABELS = {}
34
+ Object.keys(METRICS).forEach(k => { METRIC_LABELS[METRICS[k]] = k })
35
+
36
+ const CACHE_TTL = 15 * 60 * 1000
37
+ const cache = new Map()
38
+
39
+ // ===== 基础工具 =====
40
+
41
+ function esc(s) {
42
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
43
+ }
44
+
45
+ function url(base, path) {
46
+ return base.replace(/\/+$/, '') + encodeURI(path)
47
+ }
48
+
49
+ function loseRate(w, p) {
50
+ if (w == null || p == null || isNaN(w) || isNaN(p)) return null
51
+ return Math.max(0, 1 - w - p)
52
+ }
53
+
54
+ function winRateNoDraw(w, p) {
55
+ const l = loseRate(w, p)
56
+ if (l == null) return null
57
+ const t = w + l
58
+ return t > 0 ? w / t : 0
59
+ }
60
+
61
+ function getMetricVal(item, metric, badgeScores) {
62
+ if (metric.startsWith('badge_')) {
63
+ const tier = metric.slice(5)
64
+ const s = badgeScores && badgeScores[item.name] ? badgeScores[item.name][tier] : null
65
+ return s == null || isNaN(s) ? null : s
66
+ }
67
+ switch (metric) {
68
+ case 'win_rate_without_draw': return winRateNoDraw(item.win_rate, item.ping_rate)
69
+ case 'win_rate': return item.win_rate
70
+ case 'ping_rate': return item.ping_rate
71
+ case 'lose_rate': return loseRate(item.win_rate, item.ping_rate)
72
+ case 'use_rate': return item.use_rate
73
+ case 'ban_rate': return item.ban_rate
74
+ default: return null
75
+ }
76
+ }
77
+
78
+ function fmtPct(v) {
79
+ return v == null || isNaN(v) ? '--' : (v * 100).toFixed(2) + '%'
80
+ }
81
+
82
+ function fmtGrowth(v) {
83
+ if (v == null || isNaN(v)) return { text: '--', cls: '' }
84
+ const cls = v > 1 ? 'up' : (v < -1 ? 'down' : '')
85
+ return { text: (v > 0 ? '+' : '') + v.toFixed(1) + '%', cls }
86
+ }
87
+
88
+ function metricLabel(metric) {
89
+ if (METRICS[metric]) return METRICS[metric]
90
+ const m = metric.match(/^badge_(.+)$/)
91
+ if (m) {
92
+ const t = BADGE_TIERS.find(x => x.key === m[1])
93
+ return (t ? t.label : m[1]) + '勋章分'
94
+ }
95
+ return metric
96
+ }
97
+
98
+ function dateTo8(d) { return d < 20000000 ? d + 20000000 : d }
99
+
100
+ function dateAddDays(dateInt, days) {
101
+ const y = Math.floor(dateInt / 10000), m = Math.floor((dateInt % 10000) / 100), d = dateInt % 100
102
+ const dt = new Date(y, m - 1, d)
103
+ dt.setDate(dt.getDate() + days)
104
+ return parseInt(dt.getFullYear() + String(dt.getMonth() + 1).padStart(2, '0') + String(dt.getDate()).padStart(2, '0'))
105
+ }
106
+
107
+ function dateDiffDays(a, b) {
108
+ const y1 = Math.floor(a / 10000), m1 = Math.floor((a % 10000) / 100), d1 = a % 100
109
+ const y2 = Math.floor(b / 10000), m2 = Math.floor((b % 10000) / 100), d2 = b % 100
110
+ return Math.floor((new Date(y2, m2 - 1, d2) - new Date(y1, m1 - 1, d1)) / 86400000)
111
+ }
112
+
113
+ // 与 animationshow.html 相同的逻辑:当前日期在上赛季对应的同一天
114
+ function seasonComparisonDate(cur, seasonList) {
115
+ const seasons = []
116
+ Object.keys(seasonList).forEach(name => {
117
+ const m = seasonList[name].match(/(\d{6})/)
118
+ if (m) seasons.push({ name, end: dateTo8(parseInt(m[1])) })
119
+ })
120
+ seasons.sort((a, b) => a.end - b.end)
121
+ seasons.forEach((s, j) => { s.start = j === 0 ? dateAddDays(s.end, -60) : dateAddDays(seasons[j - 1].end, 1) })
122
+ let idx = -1
123
+ seasons.forEach((s, k) => { if (idx < 0 && cur >= s.start && cur <= s.end) idx = k })
124
+ if (idx <= 0) return dateAddDays(cur, -90)
125
+ const prev = seasons[idx - 1]
126
+ const curDays = dateDiffDays(seasons[idx].start, cur) + 1
127
+ const prevDays = dateDiffDays(prev.start, prev.end) + 1
128
+ return dateAddDays(prev.start, Math.min(curDays, prevDays) - 1)
129
+ }
130
+
131
+ // ===== 请求与缓存 =====
132
+
133
+ async function fetchText(fullUrl, timeout) {
134
+ const hit = cache.get(fullUrl)
135
+ if (hit && Date.now() - hit.ts < hit.ttl) return hit.text
136
+ const ctrl = new AbortController()
137
+ const timer = setTimeout(() => ctrl.abort(), timeout)
138
+ try {
139
+ const res = await fetch(fullUrl, { signal: ctrl.signal })
140
+ if (!res.ok) throw new Error('HTTP ' + res.status + ' ' + fullUrl)
141
+ const text = await res.text()
142
+ cache.set(fullUrl, { text, ts: Date.now(), ttl: CACHE_TTL })
143
+ return text
144
+ } finally {
145
+ clearTimeout(timer)
146
+ }
147
+ }
148
+
149
+ async function fetchJson(fullUrl, timeout) {
150
+ return JSON.parse(await fetchText(fullUrl, timeout))
151
+ }
152
+
153
+ // ===== 参数解析 =====
154
+
155
+ function parseArgs(rest) {
156
+ const tokens = String(rest || '').trim().split(/\s+/).filter(Boolean)
157
+ const r = { season: null, week: null, part: null, metric: null, seasonFull: false }
158
+ tokens.forEach(t => {
159
+ let m
160
+ if (t === '全赛季' || t === '全季') { r.seasonFull = true; return }
161
+ if (t === '全段位' || t === '全') { r.part = -127; return }
162
+ if ((m = t.match(/^第?\s*(\d{1,3})\s*赛季$/i))) { r.season = m[1]; return }
163
+ if ((m = t.match(/^s\s*(\d{1,3})\s*赛季$/i))) { r.season = m[1]; return }
164
+ if ((m = t.match(/^s\s*(\d{1,3})$/i))) { r.season = m[1]; return }
165
+ if ((m = t.match(/^第?\s*(\d{1,2})\s*周$/))) { r.week = m[1]; return }
166
+ if (METRIC_LABELS[t]) { r.metric = METRIC_LABELS[t]; return }
167
+ const pm = Object.keys(PARTS).find(k => PARTS[k] === t)
168
+ if (pm != null) { r.part = parseInt(pm); return }
169
+ if ((m = t.match(/^(S10|S1|A11|A100|B101|B300|C301|C500)\s*勋章分$/))) {
170
+ const tier = BADGE_TIERS.find(x => x.label === m[1])
171
+ r.metric = 'badge_' + tier.key
172
+ return
173
+ }
174
+ if ((m = t.match(/^(\d{1,3})$/))) {
175
+ const n = parseInt(m[1])
176
+ if (r.season == null && n >= 20) { r.season = String(n); return }
177
+ if (r.week == null) { r.week = String(n); return }
178
+ }
179
+ })
180
+ return r
181
+ }
182
+
183
+ function findSeasonKey(seasonList, q) {
184
+ const m = String(q).trim().match(/^s?\s*(\d{1,3})$/i)
185
+ if (!m) return null
186
+ const n = parseInt(m[1])
187
+ return Object.keys(seasonList).find(k => {
188
+ const km = k.match(/^第(\d+)赛季$/)
189
+ return km && parseInt(km[1]) === n
190
+ }) || null
191
+ }
192
+
193
+ function prevSeasonKey(sn) {
194
+ const m = String(sn).match(/第(\d+)赛季/)
195
+ return m ? '第' + (parseInt(m[1]) - 1) + '赛季' : null
196
+ }
197
+
198
+ // ===== 数据加载 =====
199
+
200
+ async function loadMeta(base, timeout) {
201
+ const text = await fetchText(url(base, '/胜率数据/js/season.js'), timeout)
202
+ const ex = new Function(text + '\n;return { seasonList, latestSeason, weekList }')()
203
+ if (!ex || typeof ex.seasonList !== 'object') throw new Error('赛季列表解析失败')
204
+ return ex
205
+ }
206
+
207
+ async function resolveSource(config) {
208
+ const primary = (config.dataSource || KNOWN_SOURCES[0]).replace(/\/+$/, '')
209
+ const candidates = []
210
+ if (!candidates.includes(primary)) candidates.push(primary)
211
+ if (config.tryAlternate) {
212
+ KNOWN_SOURCES.forEach(s => {
213
+ const x = s.replace(/\/+$/, '')
214
+ if (!candidates.includes(x)) candidates.push(x)
215
+ })
216
+ }
217
+ let lastErr = null
218
+ for (const base of candidates) {
219
+ try {
220
+ return { base, meta: await loadMeta(base, config.timeout) }
221
+ } catch (e) {
222
+ lastErr = e
223
+ }
224
+ }
225
+ throw lastErr || new Error('无法连接数据源')
226
+ }
227
+
228
+ async function loadSeasonData(base, file, timeout) {
229
+ const j = await fetchJson(url(base, '/胜率数据/json/' + file), timeout)
230
+ return j.data || []
231
+ }
232
+
233
+ async function loadBadgeFiles(base, timeout) {
234
+ const text = await fetchText(url(base, '/胜率数据/日数据/js/fileList.js'), timeout)
235
+ const m = text.match(/\[([\s\S]*)\]/)
236
+ if (!m) return []
237
+ let files = []
238
+ try { files = new Function('return [' + m[1] + ']')() } catch (e) { return [] }
239
+ return files.filter(f => typeof f === 'string' && /^Badge_\d{8}\.json$/.test(f)).sort()
240
+ }
241
+
242
+ async function loadBadgeMap(base, file, timeout) {
243
+ const j = await fetchJson(url(base, '/胜率数据/日数据/json/' + file), timeout)
244
+ const map = {}, camps = {}
245
+ ;(j.data || []).forEach(item => {
246
+ if (!map[item.hero_name]) {
247
+ map[item.hero_name] = {}
248
+ camps[item.hero_name] = String(item.camp_id)
249
+ }
250
+ map[item.hero_name][item.rn] = parseInt(String(item.score).replace(/,/g, '')) || 0
251
+ })
252
+ return { map, camps }
253
+ }
254
+
255
+ function pickBadgeFile(files, targetDate, maxDiff) {
256
+ let best = null, bestDiff = Infinity
257
+ files.forEach(f => {
258
+ const d = parseInt(f.replace('Badge_', '').replace('.json', ''))
259
+ const diff = Math.abs(d - targetDate)
260
+ if (diff < bestDiff) { bestDiff = diff; best = f }
261
+ })
262
+ if (!best || bestDiff > maxDiff) return null
263
+ return best
264
+ }
265
+
266
+ async function loadBadgeAll(base, config, meta, dataFile) {
267
+ const files = await loadBadgeFiles(base, config.timeout)
268
+ if (!files.length) return null
269
+ const fm = String(dataFile).match(/(\d{6})/)
270
+ const endDate = fm ? dateTo8(parseInt(fm[1])) : null
271
+ if (!endDate) return null
272
+ const curFile = pickBadgeFile(files, endDate, 90)
273
+ if (!curFile) return null
274
+ const cur = await loadBadgeMap(base, curFile, config.timeout)
275
+ const short = f => f.replace('Badge_', '').replace('.json', '')
276
+ let prev = null, prevDate = null
277
+ const prevFile = pickBadgeFile(files, seasonComparisonDate(parseInt(short(curFile)), meta.seasonList), 90)
278
+ if (prevFile) {
279
+ prev = await loadBadgeMap(base, prevFile, config.timeout)
280
+ prevDate = short(prevFile)
281
+ }
282
+ let daily = null, dailyDate = null
283
+ const dailyFile = pickBadgeFile(files, dateAddDays(parseInt(short(curFile)), -config.badgeDays), 90)
284
+ if (dailyFile) {
285
+ daily = await loadBadgeMap(base, dailyFile, config.timeout)
286
+ dailyDate = short(dailyFile)
287
+ }
288
+ return {
289
+ map: cur.map, camps: cur.camps, curDate: short(curFile),
290
+ prevMap: prev ? prev.map : null, prevDate,
291
+ dailyMap: daily ? daily.map : null, dailyDate,
292
+ }
293
+ }
294
+
295
+ // ===== 数据计算 =====
296
+
297
+ function uniqueBy(data, camp, part) {
298
+ const seen = new Set(), unq = []
299
+ data.forEach(d => {
300
+ if (d.camp_id === camp && d.part === part && !seen.has(d.name)) {
301
+ seen.add(d.name)
302
+ unq.push(d)
303
+ }
304
+ })
305
+ return unq
306
+ }
307
+
308
+ function sortDesc(unq, metric, badgeScores) {
309
+ return unq.slice().sort((a, b) => {
310
+ const va = getMetricVal(a, metric, badgeScores), vb = getMetricVal(b, metric, badgeScores)
311
+ return (vb == null ? -Infinity : vb) - (va == null ? -Infinity : va)
312
+ })
313
+ }
314
+
315
+ function rankMap(sorted) {
316
+ const map = {}
317
+ sorted.forEach((d, i) => { map[d.name] = i + 1 })
318
+ return map
319
+ }
320
+
321
+ function campAvg(data, camp, part) {
322
+ const fd = data.filter(d => d.camp_id === camp && d.part === part)
323
+ let tu = 0
324
+ fd.forEach(d => { tu += d.use_rate || 0 })
325
+ if (!tu) return null
326
+ let ww = 0, wp = 0
327
+ fd.forEach(d => {
328
+ const w = (d.use_rate || 0) / tu
329
+ ww += (d.win_rate || 0) * w
330
+ wp += (d.ping_rate || 0) * w
331
+ })
332
+ return { win_rate: ww, ping_rate: wp }
333
+ }
334
+
335
+ function computeCard(opts) {
336
+ const { allData, prevData, name, requestedPart, metric, badgeScores } = opts
337
+ const parts = [requestedPart, -127].concat(PART_ORDER.filter(p => p !== requestedPart && p !== -127))
338
+ let entry = null, part = null
339
+ for (const p of parts) {
340
+ const e = allData.find(d => d.name === name && d.part === p)
341
+ if (e) { entry = e; part = p; break }
342
+ }
343
+ if (!entry) return null
344
+ const camp = entry.camp_id
345
+
346
+ const sorted = sortDesc(uniqueBy(allData, camp, part), metric, badgeScores)
347
+ const rank = rankMap(sorted)
348
+ const total = sorted.length
349
+
350
+ const prevEntry = prevData.length
351
+ ? prevData.find(d => d.name === name && d.camp_id === camp && d.part === part)
352
+ : null
353
+
354
+ const six = RATE_METRICS.map(key => ({
355
+ key,
356
+ label: METRICS[key],
357
+ val: getMetricVal(entry, key, badgeScores),
358
+ prev: prevEntry ? getMetricVal(prevEntry, key, badgeScores) : null,
359
+ }))
360
+
361
+ const allRanks = {}
362
+ RATE_METRICS.forEach(key => {
363
+ const sorted2 = sortDesc(uniqueBy(allData, camp, part), key, badgeScores)
364
+ allRanks[key] = rankMap(sorted2)
365
+ })
366
+
367
+ let changeRanks = null
368
+ if (prevData.length) {
369
+ changeRanks = {}
370
+ RATE_METRICS.forEach(key => {
371
+ const changes = []
372
+ uniqueBy(allData, camp, part).forEach(d => {
373
+ const p = prevData.find(x => x.name === d.name && x.camp_id === camp && x.part === part)
374
+ const cv = getMetricVal(d, key, badgeScores)
375
+ const pv = p ? getMetricVal(p, key, badgeScores) : null
376
+ if (cv != null && pv != null) changes.push({ name: d.name, chg: cv - pv })
377
+ })
378
+ changes.sort((a, b) => (key === 'lose_rate' ? a.chg - b.chg : b.chg - a.chg))
379
+ const map = {}
380
+ changes.forEach((c, i) => { map[c.name] = i + 1 })
381
+ changeRanks[key] = map
382
+ })
383
+ }
384
+
385
+ const partsGrid = []
386
+ PART_ORDER.forEach(p => {
387
+ const e = allData.find(d => d.name === name && d.camp_id === camp && d.part === p)
388
+ const ca = campAvg(allData, camp, p)
389
+ if (!e || !ca) return
390
+ const wr = winRateNoDraw(e.win_rate, e.ping_rate)
391
+ const awr = winRateNoDraw(ca.win_rate, ca.ping_rate)
392
+ if (wr == null || awr == null) return
393
+ partsGrid.push({ label: PARTS[p], val: wr, diff: wr - awr })
394
+ })
395
+
396
+ return {
397
+ entry, camp, part,
398
+ rank: rank[entry.name] || 0, total,
399
+ six, allRanks, changeRanks, partsGrid, prevEntry,
400
+ }
401
+ }
402
+
403
+ function computeBadge(card, badge) {
404
+ const name = card.entry.name
405
+ const cur = badge.map[name] || {}
406
+ const campStr = String(card.camp)
407
+ const build = (srcMap, isPrev) => BADGE_TIERS.map(t => {
408
+ const cv = cur[t.key]
409
+ const pv = srcMap ? srcMap[name] ? srcMap[name][t.key] : null : null
410
+ return {
411
+ label: t.label,
412
+ delta: pv > 0 && cv != null ? (cv - pv) / pv * 100 : null,
413
+ }
414
+ })
415
+ const tiers = BADGE_TIERS.map(t => ({
416
+ label: t.label, cls: t.cls,
417
+ val: cur[t.key] != null ? cur[t.key] : null,
418
+ }))
419
+ const sums = {}, cnts = {}
420
+ BADGE_TIERS.forEach(t => { sums[t.key] = 0; cnts[t.key] = 0 })
421
+ Object.keys(badge.map).forEach(nm => {
422
+ if (badge.camps[nm] !== campStr) return
423
+ BADGE_TIERS.forEach(t => {
424
+ const v = badge.map[nm][t.key]
425
+ if (v) { sums[t.key] += v; cnts[t.key]++ }
426
+ })
427
+ })
428
+ const anomaly = BADGE_TIERS.map(t => {
429
+ const avg = cnts[t.key] ? sums[t.key] / cnts[t.key] : null
430
+ const cv = cur[t.key]
431
+ return { label: t.label, delta: avg > 0 && cv != null ? (cv - avg) / avg * 100 : null }
432
+ })
433
+ return {
434
+ tiers,
435
+ season: build(badge.prevMap),
436
+ daily: build(badge.dailyMap),
437
+ anomaly,
438
+ dates: { cur: badge.curDate, prev: badge.prevDate, daily: badge.dailyDate },
439
+ }
440
+ }
441
+
442
+ function findSimilar(data, name) {
443
+ const seen = new Set(), names = []
444
+ data.forEach(d => {
445
+ if (!seen.has(d.name)) { seen.add(d.name); names.push(d.name) }
446
+ })
447
+ return names.filter(n => n.includes(name) || name.includes(n)).slice(0, 8)
448
+ }
449
+
450
+ // ===== 主流程 =====
451
+
452
+ async function collect(config, name, cond) {
453
+ const parsed = parseArgs(cond)
454
+ const src = await resolveSource(config)
455
+ const { base, meta } = src
456
+ const sourceLabel = base.replace(/^https?:\/\//, '')
457
+
458
+ const seasonName = parsed.season ? (findSeasonKey(meta.seasonList, parsed.season) || meta.latestSeason) : meta.latestSeason
459
+ const weeks = meta.weekList[seasonName] || []
460
+ let weekIdx = null
461
+ if (!parsed.seasonFull) {
462
+ if (parsed.week != null) {
463
+ const w = parseInt(parsed.week)
464
+ if (!weeks.length) return { ok: false, error: seasonName + '暂无周数据' }
465
+ if (w < 1 || w > weeks.length) return { ok: false, error: seasonName + '仅收录 ' + weeks.length + ' 周数据' }
466
+ weekIdx = w - 1
467
+ } else if (weeks.length) {
468
+ weekIdx = weeks.length - 1
469
+ }
470
+ }
471
+
472
+ const file = weekIdx != null ? weeks[weekIdx].fileName : meta.seasonList[seasonName]
473
+ if (!file) return { ok: false, error: seasonName + '暂无可用数据' }
474
+
475
+ const allData = await loadSeasonData(base, file, config.timeout)
476
+ if (!allData.length) return { ok: false, error: '数据文件为空' }
477
+
478
+ const part = parsed.part != null ? parsed.part : parseInt(config.defaultPart)
479
+ const metric = parsed.metric || 'win_rate_without_draw'
480
+
481
+ let prevData = []
482
+ const prevKey = prevSeasonKey(seasonName)
483
+ if (prevKey && meta.seasonList[prevKey]) {
484
+ const prevFile = weekIdx != null && (meta.weekList[prevKey] || [])[weekIdx]
485
+ ? meta.weekList[prevKey][weekIdx].fileName
486
+ : meta.seasonList[prevKey]
487
+ if (prevFile) {
488
+ try { prevData = await loadSeasonData(base, prevFile, config.timeout) } catch (e) { prevData = [] }
489
+ }
490
+ }
491
+
492
+ let badge = null
493
+ if (config.showBadge) {
494
+ try { badge = await loadBadgeAll(base, config, meta, file) } catch (e) { badge = null }
495
+ }
496
+ if (metric.startsWith('badge_') && !badge) {
497
+ return { ok: false, error: '勋章分排序需要勋章数据,但勋章数据获取失败' }
498
+ }
499
+
500
+ const card = computeCard({
501
+ allData, prevData, name, requestedPart: part, metric,
502
+ badgeScores: badge ? badge.map : null,
503
+ })
504
+ if (!card) {
505
+ const similar = findSimilar(allData, name)
506
+ return {
507
+ ok: false,
508
+ error: '未找到角色「' + name + '」' + (similar.length ? '\n相近角色:' + similar.join('、') : ''),
509
+ }
510
+ }
511
+
512
+ return {
513
+ ok: true,
514
+ base, sourceLabel, file,
515
+ seasonName,
516
+ weekLabel: weekIdx != null ? '第' + (weekIdx + 1) + '周' : '全赛季',
517
+ partName: PARTS[part] || String(part),
518
+ metric, metricLabel: metricLabel(metric),
519
+ name, card,
520
+ badge: badge ? computeBadge(card, badge) : null,
521
+ badgeScores: badge ? badge.map : null,
522
+ badgeDays: config.badgeDays,
523
+ }
524
+ }
525
+
526
+ // ===== HTML 生成(样式参考 animationshow.html)=====
527
+
528
+ function buildHtml(data, config) {
529
+ const { name, seasonName, weekLabel, partName, metricLabel, metric, card, badge, sourceLabel, file, badgeDays } = data
530
+ const width = config.width || 1000
531
+
532
+ const hl = getMetricVal(card.entry, metric, data.badgeScores)
533
+ const hlCls = metric.startsWith('badge_') ? 'plain' : (hl == null ? '' : (hl >= 0.5 ? 'high' : (hl >= 0.3 ? 'mid' : 'low')))
534
+ const rankSup = '#' + card.rank + '/' + card.total
535
+
536
+ const sixRows = card.six.map(m => {
537
+ const rk = card.allRanks[m.key][card.entry.name]
538
+ return '<div class="s-row"><span class="s-label">' + m.label + '</span>' +
539
+ '<span class="s-val">' + fmtPct(m.val) + '</span>' +
540
+ '<span class="s-rank">' + (rk ? '#' + rk : '--') + '</span></div>'
541
+ }).join('')
542
+
543
+ let changeBlock = ''
544
+ if (card.changeRanks) {
545
+ const rows = card.six.map(m => {
546
+ const d = m.prev != null && m.val != null ? m.val - m.prev : null
547
+ const g = fmtGrowth(d != null ? d * 100 : null)
548
+ const cr = card.changeRanks[m.key][card.entry.name]
549
+ const cls = g.cls || (d != null && Math.abs(d) > 0.001 ? (d > 0 ? 'up' : 'down') : '')
550
+ const text = d == null ? (m.prev != null ? '--' : '<span class="stat-delta new">新增</span>') : '<span class="stat-delta ' + cls + '">' + (d > 0 ? '+' : '') + (d * 100).toFixed(2) + '%</span>'
551
+ return '<div class="s-row"><span class="s-label">' + m.label + '</span>' +
552
+ '<span class="s-val">' + text + '</span>' +
553
+ '<span class="s-rank">' + (cr ? '#' + cr : '--') + '</span></div>'
554
+ }).join('')
555
+ changeBlock = '<div class="zone-sep"></div>' +
556
+ '<div class="stat-block"><div class="comp-title">对比上赛季变化值 / 变化值在本阵营排名</div>' +
557
+ '<div class="s-row s-header-row"><span class="s-label"></span><span class="s-val">变化值</span><span class="s-rank">排名</span></div>' +
558
+ rows + '</div>'
559
+ }
560
+
561
+ const partsItems = card.partsGrid.map(p => {
562
+ const g = fmtGrowth(p.diff * 100)
563
+ return '<div class="allrank-item"><span class="allrank-label">' + p.label + '</span>' +
564
+ '<span class="allrank-val">' + fmtPct(p.val) + '</span>' +
565
+ '<span class="allrank-delta ' + g.cls + '">' + g.text + '</span></div>'
566
+ }).join('')
567
+
568
+ const zoneLeft = '<div class="stat-block">' + sixRows + '</div>' + changeBlock +
569
+ '<div class="zone-sep"></div>' +
570
+ '<div class="stat-block"><div class="comp-title" style="color:var(--accent);margin-bottom:4px">各段位去平胜率(距平均)</div>' +
571
+ '<div class="allrank-grid">' + partsItems + '</div></div>'
572
+
573
+ let zoneRight
574
+ if (badge) {
575
+ const tierItems = badge.tiers.map(t =>
576
+ '<div class="card-badge-item ' + t.cls + '"><span class="b-tier">' + t.label + '</span><span class="b-val">' + (t.val != null ? t.val : '--') + '</span></div>'
577
+ ).join('')
578
+ const short = d => d ? d.substring(4) : '--'
579
+ const growBlock = (title, items) =>
580
+ '<div class="stat-block badge-block"><div class="badge-block-title">' + title + '</div>' +
581
+ '<div class="badge-growth-grid">' + items + '</div></div>'
582
+ const growItems = list => list.map(x => {
583
+ const g = fmtGrowth(x.delta)
584
+ return '<div class="badge-growth-item"><span class="bg-tier">' + x.label + '</span><span class="bg-val ' + g.cls + '">' + g.text + '</span></div>'
585
+ }).join('')
586
+ zoneRight =
587
+ '<div class="stat-block badge-block"><div class="badge-block-title">勋章分数 (' + short(badge.dates.cur) + ')</div>' +
588
+ '<div class="card-badge-tiers">' + tierItems + '</div></div>' +
589
+ growBlock('赛季同比增长 (' + short(badge.dates.prev) + '→' + short(badge.dates.cur) + ')', growItems(badge.season)) +
590
+ growBlock(badgeDays + '日增长率 (' + short(badge.dates.daily) + '→' + short(badge.dates.cur) + ')', growItems(badge.daily)) +
591
+ growBlock('距阵营平均值', growItems(badge.anomaly))
592
+ } else {
593
+ zoneRight = '<div class="stat-block"><div class="comp-title" style="color:var(--text-secondary)">暂无勋章数据</div></div>'
594
+ }
595
+
596
+ const position = card.entry.position
597
+ const avatar = config.avatarBase || ''
598
+ const avatarUrl = avatar ? avatar.replace(/\/+$/, '') + '/statics/角色头像/' + encodeURIComponent(name) + '.png' : ''
599
+ const campName = card.camp === 1 ? '监管者' : '求生者'
600
+
601
+ const zoneCenter =
602
+ '<div class="card-avatar-wrap">' +
603
+ (avatarUrl
604
+ ? '<img class="card-avatar" src="' + avatarUrl + '" alt="' + esc(name) + '" onerror="this.style.display=\'none\';this.nextElementSibling.style.display=\'flex\';">' +
605
+ '<div class="card-avatar placeholder" style="display:none">' + esc(name.charAt(0)) + '</div>'
606
+ : '<div class="card-avatar placeholder">' + esc(name.charAt(0)) + '</div>') +
607
+ '</div>' +
608
+ '<div class="card-char-name">' + esc(name) + '</div>' +
609
+ (position ? '<div class="card-sub">' + esc(position) + ' · ' + campName + '</div>' : '') +
610
+ '<div class="card-highlight ' + hlCls + '">' + fmtHigh(hl, metric) + '<sup class="hl-rank">' + rankSup + '</sup></div>' +
611
+ '<div class="card-highlight-label">' + metricLabel + ' · ' + partName + '</div>'
612
+
613
+ const titleBar =
614
+ '<span>' + seasonName + '</span><span class="sep">·</span>' +
615
+ '<span>' + weekLabel + '</span><span class="sep">·</span>' +
616
+ '<span>' + partName + '</span><span class="sep">·</span>' +
617
+ '<span class="metric-tag">' + metricLabel + '↓</span>'
618
+
619
+ return `<!DOCTYPE html>
620
+ <html lang="zh-CN">
621
+ <head>
622
+ <meta charset="UTF-8">
623
+ <style>
624
+ :root { --bg:#0d1117; --surface:#161b22; --border:#30363d; --text:#c9d1d9; --text-secondary:#8b949e; --accent:#58a6ff; --green:#3fb950; --red:#f85149; --yellow:#d2991d; --card-bg:#1c2333; }
625
+ * { margin:0; padding:0; box-sizing:border-box; }
626
+ body { background:var(--bg); color:var(--text); font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Microsoft YaHei',sans-serif; padding:16px; }
627
+ .page { width:100%; margin:0 auto; }
628
+ .title-bar { display:flex; justify-content:center; align-items:center; gap:8px; padding:6px 18px; background:rgba(22,27,34,.9); border:1px solid var(--border); border-radius:20px; font-size:20px; margin-bottom:14px; white-space:nowrap; }
629
+ .title-bar .sep { color:var(--border); }
630
+ .title-bar .metric-tag { color:var(--accent); font-weight:600; }
631
+ .card { background:var(--card-bg); border:1px solid var(--border); border-radius:18px; padding:22px 26px; }
632
+ .card-grid { display:grid; grid-template-columns:330px 1fr 330px; gap:0 20px; align-items:start; }
633
+ .card-zone { width:100%; }
634
+ .zone-center { display:flex; flex-direction:column; align-items:center; }
635
+ .card-avatar-wrap { margin-bottom:8px; }
636
+ .card-avatar { width:150px; height:150px; border-radius:50%; object-fit:cover; border:3px solid rgba(255,255,255,.12); background:#1a1e26; box-shadow:0 0 30px rgba(0,0,0,.4); }
637
+ .card-avatar.placeholder { display:flex; align-items:center; justify-content:center; font-size:54px; color:var(--text-secondary); background:rgba(255,255,255,.04); }
638
+ .card-char-name { font-size:26px; font-weight:700; text-align:center; margin:6px 0 2px; }
639
+ .card-sub { font-size:13px; color:var(--text-secondary); margin-bottom:8px; }
640
+ .card-highlight { position:relative; font-size:64px; font-weight:800; line-height:1; text-align:center; margin-bottom:4px; }
641
+ .card-highlight.high { color:var(--green); }
642
+ .card-highlight.mid { color:var(--yellow); }
643
+ .card-highlight.low { color:var(--red); }
644
+ .card-highlight.plain { color:var(--accent); }
645
+ .card-highlight .hl-rank { position:absolute; top:-0.15em; font-size:.35em; font-weight:700; color:var(--accent); white-space:nowrap; }
646
+ .card-highlight-label { font-size:15px; color:var(--text-secondary); }
647
+ .stat-block { display:flex; flex-direction:column; gap:6px; }
648
+ .stat-block + .stat-block { margin-top:12px; }
649
+ .s-row { display:flex; align-items:center; justify-content:space-between; padding:8px 12px; background:rgba(255,255,255,.02); border:1px solid rgba(255,255,255,.04); border-radius:6px; gap:10px; }
650
+ .s-label { font-size:15px; color:var(--text-secondary); font-weight:600; width:76px; flex-shrink:0; }
651
+ .s-val { font-size:17px; font-weight:600; font-variant-numeric:tabular-nums; flex:1; text-align:center; }
652
+ .s-rank { font-size:16px; color:var(--accent); font-weight:700; min-width:46px; text-align:right; }
653
+ .stat-delta { font-size:15px; font-weight:600; font-variant-numeric:tabular-nums; }
654
+ .stat-delta.up { color:var(--green); }
655
+ .stat-delta.down { color:var(--red); }
656
+ .stat-delta.new { color:var(--yellow); }
657
+ .s-header-row { background:transparent; border:none; }
658
+ .s-header-row .s-val, .s-header-row .s-rank { font-size:12px; color:var(--text-secondary); font-weight:400; }
659
+ .zone-sep { margin:12px 0; border-top:1px solid rgba(255,255,255,.08); }
660
+ .comp-title { font-size:15px; color:var(--green); font-weight:700; text-align:center; margin-bottom:8px; letter-spacing:1px; }
661
+ .badge-block-title { font-size:15px; color:var(--yellow); font-weight:700; text-align:center; margin-bottom:8px; }
662
+ .card-badge-tiers, .badge-growth-grid, .allrank-grid { display:grid; grid-template-columns:repeat(4,1fr); gap:6px; }
663
+ .card-badge-item, .badge-growth-item, .allrank-item { display:flex; flex-direction:column; align-items:center; gap:2px; padding:6px 3px; background:rgba(255,255,255,.02); border:1px solid rgba(255,255,255,.04); border-radius:5px; }
664
+ .b-tier, .bg-tier, .allrank-label { font-size:12px; color:var(--text-secondary); font-weight:700; }
665
+ .b-val, .bg-val, .allrank-val { font-size:14px; font-weight:700; font-variant-numeric:tabular-nums; }
666
+ .allrank-delta { font-size:11px; font-weight:600; font-variant-numeric:tabular-nums; }
667
+ .allrank-delta.up { color:var(--green); }
668
+ .allrank-delta.down { color:var(--red); }
669
+ .card-badge-item.tier-s .b-tier { color:var(--yellow); }
670
+ .card-badge-item.tier-a .b-tier { color:var(--green); }
671
+ .card-badge-item.tier-b .b-tier { color:var(--accent); }
672
+ .card-badge-item.tier-c .b-tier { color:var(--text-secondary); }
673
+ .foot { text-align:center; color:var(--text-secondary); font-size:12px; margin-top:10px; opacity:.7; }
674
+ </style>
675
+ </head>
676
+ <body>
677
+ <div class="page">
678
+ <div class="title-bar">${titleBar}</div>
679
+ <div class="card">
680
+ <div class="card-grid">
681
+ <div class="card-zone zone-left">${zoneLeft}</div>
682
+ <div class="card-zone zone-center">${zoneCenter}</div>
683
+ <div class="card-zone zone-right">${zoneRight}</div>
684
+ </div>
685
+ </div>
686
+ <div class="foot">数据来源:${esc(sourceLabel)} · 数据文件:${esc(file)} · 数据均根据第五人格官方小程序数据计算</div>
687
+ </div>
688
+ </body>
689
+ </html>`
690
+ }
691
+
692
+ function fmtHigh(v, metric) {
693
+ if (v == null || isNaN(v)) return '--'
694
+ if (metric.startsWith('badge_')) return String(v)
695
+ return (v * 100).toFixed(2) + '%'
696
+ }
697
+
698
+ function buildHelp() {
699
+ return [
700
+ '查询角色胜率并生成图片',
701
+ '',
702
+ '用法:胜率 <角色名> [赛季] [周数] [排序要素] [段位]',
703
+ '',
704
+ '示例:',
705
+ ' 胜率 小女孩',
706
+ ' 胜率 小女孩 第43赛季 第3周 使用率',
707
+ ' 胜率 小女孩 43 3 胜率',
708
+ ' 胜率 小女孩 S44 全赛季 禁用率',
709
+ '',
710
+ '排序要素:' + Object.keys(METRICS).map(k => METRICS[k]).join('、') +
711
+ '、' + BADGE_TIERS.map(t => t.label + '勋章分').join('、'),
712
+ '段位:巅七(默认)、七阶、六阶、五阶、四阶、三阶、二阶、一阶、全段位',
713
+ '赛季、周数不填默认使用最新赛季最新周;填“全赛季”使用赛季累计数据',
714
+ ].join('\n')
715
+ }
716
+
717
+ module.exports = {
718
+ KNOWN_SOURCES,
719
+ METRICS,
720
+ BADGE_TIERS,
721
+ PARTS,
722
+ PART_ORDER,
723
+ parseArgs,
724
+ resolveSource,
725
+ collect,
726
+ buildHtml,
727
+ buildHelp,
728
+ findSimilar,
729
+ }
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "koishi-plugin-dwrgbwiki",
3
+ "version": "1.0.0",
4
+ "description": "第五人格BWIKI编辑组测试用组件,纯AI生成喵",
5
+ "main": "index.js",
6
+ "files": [
7
+ "index.js",
8
+ "lib.js",
9
+ "readme.md"
10
+ ],
11
+ "keywords": [
12
+ "koishi",
13
+ "plugin",
14
+ "winrate",
15
+ "第五人格",
16
+ "bwiki"
17
+ ],
18
+ "license": "MIT",
19
+ "peerDependencies": {
20
+ "koishi": "^4.0.0"
21
+ },
22
+ "koishi": {
23
+ "description": {
24
+ "zh": "第五人格BWIKI编辑组测试用组件,纯AI生成喵"
25
+ }
26
+ }
27
+ }
package/readme.md ADDED
@@ -0,0 +1,51 @@
1
+ # dwrgbwiki
2
+
3
+ 第五人格BWIKI编辑组测试用组件,纯AI生成喵。
4
+
5
+ 查询第五人格角色胜率,返回一张图片(样式参考 胜率数据站 animationshow.html 的角色卡片)。
6
+
7
+ 需要安装 [koishi-plugin-puppeteer](https://www.npmjs.com/package/koishi-plugin-puppeteer) 用于截图。
8
+
9
+ ## 用法
10
+
11
+ ```
12
+ 胜率 <角色名> [赛季] [周数] [排序要素] [段位]
13
+ ```
14
+
15
+ 示例:
16
+
17
+ | 指令 | 含义 |
18
+ | --- | --- |
19
+ | `胜率 小女孩` | 最新赛季最新周,按去平胜率 |
20
+ | `胜率 小女孩 第43赛季 第3周 使用率` | 指定赛季、周数与排序要素 |
21
+ | `胜率 小女孩 43 3 胜率` | 同上(数字写法) |
22
+ | `胜率 小女孩 S44 全赛季 禁用率` | 赛季累计数据 |
23
+ | `胜率 小女孩 巅七 B101勋章分` | 指定段位与勋章分排序 |
24
+
25
+ - 赛季、周数不填时默认使用最新赛季最新周;填写 `全赛季` 使用赛季累计数据
26
+ - 排序要素默认 `去平胜率`,可选:胜率、平率、负率、使用率、禁用率、S1/S10/A11/A100/B101/B300/C301/C500 勋章分
27
+ - 段位默认 `巅七`,可选:七阶、六阶、五阶、四阶、三阶、二阶、一阶、全段位
28
+
29
+ ## 配置
30
+
31
+ | 配置项 | 说明 | 默认值 |
32
+ | --- | --- | --- |
33
+ | `dataSource` | 数据源地址,可在两个镜像站点间切换 | `https://website.homeworkkun.top` |
34
+ | `tryAlternate` | 主数据源失败时自动尝试备用数据源 | `true` |
35
+ | `defaultPart` | 默认段位(8=巅七,-127=全段位) | `8` |
36
+ | `showBadge` | 显示勋章分数据 | `true` |
37
+ | `badgeDays` | 勋章日增长率天数 | `7` |
38
+ | `width` | 图片宽度(px) | `1000` |
39
+ | `scale` | 截图倍率 | `2` |
40
+ | `timeout` | 网络请求超时(毫秒) | `15000` |
41
+
42
+ 数据源两个镜像:`https://website.homeworkkun.top` 与 `https://web.homeworkkun.top`,可直接在配置中切换。
43
+
44
+ ## 图片内容
45
+
46
+ - 标题栏:赛季 · 周数 · 段位 · 排序要素
47
+ - 中央:角色头像、名称、定位/阵营、排序要素数值(含本阵营排名)、要素名
48
+ - 左侧:六项数据(去平胜率/胜率/平率/负率/使用率/禁用率)及阵营排名、对比上赛季变化值与变化排名、各段位去平胜率(距平均)
49
+ - 右侧:勋章分数(S1~C500)、赛季同比增长、日增长率、距阵营平均值
50
+
51
+ 数据均根据第五人格官方小程序数据计算。