koishi-plugin-dwrgbwiki 2.3.0 → 2.4.1

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/index.js CHANGED
@@ -235,20 +235,11 @@ module.exports = {
235
235
  .description('BWIKI 更新检查间隔(分钟)')
236
236
  .default(10),
237
237
  wikiWatchLimit: Schema.number().min(1).max(100)
238
- .description('每次拉取的最近变更条数(用于发现新变更)')
238
+ .description('每次拉取的最近变更条数(同时决定每次推送最多列出的页面变更数)')
239
239
  .default(50),
240
- wikiWatchDetailLimit: Schema.number().min(0).max(20)
241
- .description('每次推送中获取详细更新内容(diff/新页面内容)的最大页面数,其余只推送标题与编辑摘要')
242
- .default(5),
243
- wikiWatchMaxItems: Schema.number().min(1).max(30)
244
- .description('每次推送最多列出的页面变更数(超出部分仅提示数量)')
245
- .default(15),
246
240
  wikiWatchMaxChars: Schema.number().min(100).max(3000)
247
241
  .description('单页更新内容摘要最大字符数')
248
242
  .default(500),
249
- wikiWatchSkipBots: Schema.boolean()
250
- .description('过滤机器人编辑(rcshow=!bot)')
251
- .default(true),
252
243
  wikiWatchNamespaces: Schema.array(Schema.number())
253
244
  .description('监听的命名空间 id 列表(主命名空间=0;留空仅监听主命名空间)')
254
245
  .default([0]),
@@ -407,7 +398,9 @@ module.exports = {
407
398
  if (r.error) return '检查失败:' + r.error
408
399
  if (r.first) return '首次检查:已记录当前更新位置(本次不推送),之后的页面更新会自动推送到配置的目标'
409
400
  if (r.pushed) {
410
- return '检查完成:检测到 ' + r.pushed + ' 个页面变更' + (r.sent === false ? '(但未推送:未配置目标或对应平台机器人不可用)' : ',已推送')
401
+ const msg = Array.isArray(r.message) ? r.message.slice() : [String(r.message || '检查完成')]
402
+ if (r.sent === false) msg.push('\n(未推送:未配置目标或对应平台机器人不可用,以下仅展示)')
403
+ return msg
411
404
  }
412
405
  return '检查完成:没有新的页面变更'
413
406
  }
@@ -532,10 +525,11 @@ module.exports = {
532
525
  if (subFn === '选手') {
533
526
  if (!p1 || !String(p1).trim()) return '请提供选手 id,格式:赛事 选手 <选手id>'
534
527
  const target = String(p1).trim()
528
+ const notFoundText = '当前赛事数据系统未收录【' + target + '】相关数据,请检查选手id是否输入正确。目前仅支持使用选手英文id进行查询。'
535
529
  try {
536
530
  const result = await match.loadPlayerData(config, target, mode)
537
531
  if (!result.data || (!result.data.hunter && !result.data.survivor)) {
538
- return errorImage(ctx, config, '未找到选手「' + target + '」的数据,请检查 id 是否正确', '赛事')
532
+ return notFoundText
539
533
  }
540
534
  const width = cmdWidth(config, 'matchWidth')
541
535
  const updateTime = await match.loadAnnounceTime(config)
@@ -544,6 +538,7 @@ module.exports = {
544
538
  return updateTime ? [h.image(image, 'image/png'), '本页面数据上次更新时间:' + updateTime] : [h.image(image, 'image/png')]
545
539
  } catch (e) {
546
540
  ctx.logger('dwrgbwiki').warn('赛事查询失败: ' + (e && e.message ? e.message : e))
541
+ if (e && e.code === 'PLAYER_NOT_FOUND') return notFoundText
547
542
  const msg = (e && e.message || '').includes('404')
548
543
  ? '未找到选手「' + target + '」的数据,请检查 id 是否正确(大小写、别名)'
549
544
  : '赛事查询失败:' + (e && e.message ? e.message : e)
package/match.js CHANGED
@@ -76,6 +76,13 @@ async function loadPlayerData(config, playerId, mergeMode) {
76
76
  if (key.toLowerCase() === lower) { mainId = key; break }
77
77
  }
78
78
  }
79
+ // 选手未收录:index 中不存在该 id(大小写不敏感)时直接抛专用错误,
80
+ // 避免对不存在的文件发起请求而报出「Unexpected token '<'」之类的晦涩 JSON 解析错误
81
+ if (!index[mainId]) {
82
+ const err = new Error('PLAYER_NOT_FOUND:' + String(playerId))
83
+ err.code = 'PLAYER_NOT_FOUND'
84
+ throw err
85
+ }
79
86
  // 收集候选文件:精确匹配 + 所有大小写变体
80
87
  const candidates = new Set()
81
88
  if (index[mainId]) candidates.add(index[mainId].file)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koishi-plugin-dwrgbwiki",
3
- "version": "2.3.0",
3
+ "version": "2.4.1",
4
4
  "description": "第五人格BWIKI编辑组开发,所有代码由DeepSeek V4 Flash 0731编写。支持胜率/地图/模仿者/勋章/基础数据查询、图鉴查询、wiki页面截图、藏头诗、抽卡模拟、加页手记探索、赛事数据(选手/对决)、公告变动推送、BWIKI页面更新推送、反馈系统等功能。",
5
5
  "main": "index.js",
6
6
  "files": [
package/readme.md CHANGED
@@ -309,11 +309,8 @@ BWIKI更新监控 [检查]
309
309
  | `wikiWatchPlatform` | BWIKI 更新推送的平台(如 `qq`),留空禁用推送 | `qq` |
310
310
  | `wikiWatchTargets` | BWIKI 更新推送目标列表(与 `noticeTargets` 同构:每行一个目标,类型 channel=群/频道 或 private=私聊、目标 id),可添加多个 | 空 |
311
311
  | `wikiWatchInterval` | BWIKI 更新检查间隔(分钟) | `10` |
312
- | `wikiWatchLimit` | 每次拉取的最近变更条数(用于发现新变更) | `50` |
313
- | `wikiWatchDetailLimit` | 每次推送中获取详细更新内容(diff/新页面内容)的最大页面数,其余只推送标题与编辑摘要 | `5` |
314
- | `wikiWatchMaxItems` | 每次推送最多列出的页面变更数(超出部分仅提示数量) | `15` |
312
+ | `wikiWatchLimit` | 每次拉取的最近变更条数(同时决定每次推送最多列出的页面变更数) | `50` |
315
313
  | `wikiWatchMaxChars` | 单页更新内容摘要最大字符数 | `500` |
316
- | `wikiWatchSkipBots` | 过滤机器人编辑(`rcshow=!bot`) | `true` |
317
314
  | `wikiWatchNamespaces` | 监听的命名空间 id 列表(主命名空间=0;留空仅监听主命名空间) | `[0]` |
318
315
 
319
316
  数据源两个镜像:`https://website.homeworkkun.top` 与 `https://web.homeworkkun.top`,可直接在配置中切换。
@@ -343,15 +340,16 @@ BWIKI更新监控 [检查]
343
340
 
344
341
  启用 `wikiWatchEnable` 后,插件会定时(`wikiWatchInterval` 分钟)调用第五人格 BWIKI 的 MediaWiki API(`https://wiki.biligame.com/dwrg/api.php`):
345
342
 
346
- 1. `action=query&list=recentchanges` 获取最近更新的页面(默认只监听主命名空间,可配置 `wikiWatchNamespaces`;`wikiWatchSkipBots` 可过滤机器人编辑)
343
+ 1. `action=query&list=recentchanges` 获取最近更新的页面(默认只监听主命名空间,可配置 `wikiWatchNamespaces`;**不过滤机器人编辑**)
347
344
  2. 对每个新变更调用 `action=compare` 拉取该次编辑的差异(纯文本 unified diff)作为「最后更新内容」;新创建的页面则拉取最新内容前段
345
+ 3. 编辑者的 bilibili 数字 uid 通过 bilibili 空间接口解析为真实用户名(如 `2086555` → 摸鱼达人作业君),解析失败时回退显示 uid
348
346
 
349
- 汇总为一条文字消息,通过 `wikiWatchPlatform` 平台的机器人推送到 `wikiWatchTargets` 列表中的全部目标(每行一个目标,可混用 `channel` 群/频道与 `private` 私聊),格式为:页面标题(新页面标 🆕)、编辑者、时间、编辑摘要、大小变化、更新内容摘要(`wikiWatchMaxChars` 截断)、页面链接。
347
+ 汇总后渲染为一张图片(每轮最多 `wikiWatchLimit` 个页面,全部附更新内容),通过 `wikiWatchPlatform` 平台的机器人推送到 `wikiWatchTargets` 列表中的全部目标(每行一个目标,可混用 `channel` 群/频道与 `private` 私聊)。图片内容为:页面标题(新页面标 🆕)、编辑者昵称、时间、编辑摘要、大小变化、更新内容(diff 的 `+` 行绿、`-` 行红);不包含页面链接。
350
348
 
351
349
  - 首次启动只记录基线(当前最新变更位置),不推送;之后检测到新变更才推送
352
350
  - 已处理的最新变更 id 持久化到数据库(`wiki_data` 表),机器人重启后从上次位置继续,不会重复推送
353
- - 每次推送最多列出 `wikiWatchMaxItems` 个变更,其中最新的 `wikiWatchDetailLimit` 个会附详细更新内容,其余仅标题与摘要
354
- - 任何成员可发送 `BWIKI更新监控` 查看监控状态,发送 `BWIKI更新监控 检查` 立即执行一次检查(首次检查只记录基线)
351
+ - 渲染失败或没有 puppeteer 时自动回退为纯文本推送
352
+ - 任何成员可发送 `BWIKI更新监控` 查看监控状态,发送 `BWIKI更新监控 检查` 立即执行一次检查(返回图片,首次检查只记录基线)
355
353
  - 请求带浏览器 User-Agent,遇到 BWIKI 限流(HTTP 567/429/503)自动重试,检查失败只记录日志,不影响下次轮询
356
354
 
357
355
  ## 图片内容
package/wikiwatch.js CHANGED
@@ -2,14 +2,16 @@
2
2
 
3
3
  // ===== 第五人格 BWIKI 页面更新监听 =====
4
4
  // 定时调用 MediaWiki API(wiki.biligame.com/dwrg/api.php):
5
- // 1. list=recentchanges 获取最近更新的页面(默认主命名空间,可过滤机器人编辑)
5
+ // 1. list=recentchanges 获取最近更新的页面(默认主命名空间,不过滤机器人编辑)
6
6
  // 2. 对每个新变更 action=compare 拉取该次编辑的差异(difftype=unified 纯文本 diff);
7
7
  // 新页面则拉取最新版本内容的前段
8
- // 汇总成文字消息,通过配置的机器人平台(wikiWatchPlatform)推送到目标群聊/私聊列表
9
- // (wikiWatchTargets,与公告推送同构,每行一个目标:类型 + id)。
8
+ // 3. 编辑者的 bilibili 数字 uid 通过 bilibili 空间接口解析为真实用户名
9
+ // 汇总后渲染为一张图片推送到配置的目标群聊/私聊列表(wikiWatchTargets)。
10
10
  // 首次启动只记录基线不推送;已处理的最新 rcid 持久化到数据库(wiki_data 表),
11
11
  // 重启后从上次位置继续,不会重复推送。
12
12
 
13
+ const { h } = require('koishi')
14
+ const crypto = require('crypto')
13
15
  const API_BASE = 'https://wiki.biligame.com/dwrg'
14
16
  const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36'
15
17
  const RETRY_WAIT = [3000, 8000] // BWIKI 限流/安全策略(567/429/503)时的重试间隔
@@ -55,7 +57,7 @@ function buildNamespaceParam(config) {
55
57
  return encodeURIComponent(list.join('|'))
56
58
  }
57
59
 
58
- // 拉取最近变更(返回 recentchanges 记录数组,按时间倒序)
60
+ // 拉取最近变更(返回 recentchanges 记录数组,按时间倒序;不过滤机器人编辑)
59
61
  async function fetchRecentChanges(config) {
60
62
  const timeout = Math.max(config.timeout || 15000, 20000)
61
63
  const limit = clamp(config.wikiWatchLimit, 1, 100, 50)
@@ -64,7 +66,6 @@ async function fetchRecentChanges(config) {
64
66
  '&rclimit=' + limit +
65
67
  '&rcprop=' + encodeURIComponent('title|ids|timestamp|user|comment|flags|sizes') +
66
68
  '&rctype=' + encodeURIComponent('edit|new') +
67
- (config.wikiWatchSkipBots === false ? '' : '&rcshow=!bot') +
68
69
  '&format=json&formatversion=2'
69
70
  const data = await fetchJson(url, timeout)
70
71
  return (data.query && data.query.recentchanges) || []
@@ -136,40 +137,296 @@ function fmtTime(ts) {
136
137
  }
137
138
  }
138
139
 
139
- function indentText(text, prefix) {
140
- return String(text).split('\n').map(l => prefix + l).join('\n')
140
+ // ===== bilibili uid → 用户名解析 =====
141
+ // BWIKI 账号绑定 bilibili,MediaWiki 返回的 user 是数字 uid。通过 bilibili 多个来源
142
+ // 解析真实用户名:
143
+ // 1. WBI 签名后的 x/space/wbi/acc/info(官方推荐,需动态取 wbi 密钥)
144
+ // 2. 未签名 x/space/acc/info、x/web-interface/card(部分环境仍可用)
145
+ // 3. 空间页 <title>(服务端渲染,含用户名)
146
+ // 全部失败时回退显示 uid。
147
+ // 成功结果永久缓存;失败结果缓存 10 分钟后自动重试(避免首轮风控导致一直显示 uid)。
148
+ // 每个 uid 首次失败会打一条 warn 日志,便于排查具体原因。
149
+
150
+ const NAME_CACHE = new Map()
151
+
152
+ // wbi 签名混盐索引表(bilibili 官方 hash 算法,公开常量)
153
+ const MIXIN_KEY_ENC_TAB = [46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49, 33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40, 61, 26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11, 36, 20, 34, 44, 52]
154
+
155
+ const NEG_CACHE_TTL = 10 * 60 * 1000 // 失败缓存有效期 10 分钟
156
+ const BILI_TIMEOUT = 6000
157
+
158
+ let biliMixinKey = null
159
+ let biliMixinTs = 0
160
+
161
+ async function fetchBiliJson(url, timeout) {
162
+ const ctrl = new AbortController()
163
+ const timer = setTimeout(() => ctrl.abort(), timeout)
164
+ try {
165
+ const headers = {
166
+ 'User-Agent': UA,
167
+ 'Accept-Language': 'zh-CN,zh;q=0.9',
168
+ 'Accept': 'application/json, text/plain, */*',
169
+ 'Referer': 'https://space.bilibili.com/',
170
+ }
171
+ const res = await fetch(url, { signal: ctrl.signal, headers })
172
+ if (!res.ok) return { __status: res.status }
173
+ return await res.json()
174
+ } catch (e) {
175
+ return { __error: e && e.message ? e.message : String(e) }
176
+ } finally {
177
+ clearTimeout(timer)
178
+ }
141
179
  }
142
180
 
143
- // 汇总推送消息(单页内容摘要按 wikiWatchMaxChars 截断,总长度 4000 字符防刷屏)
144
- function buildWatchMessage(items, maxChars) {
145
- const cap = clamp(maxChars, 100, 3000, 500)
146
- const lines = []
147
- lines.push('【第五人格 BWIKI 更新】检测到 ' + items.length + ' 个页面变更')
148
- for (const it of items) {
149
- lines.push('')
150
- lines.push((it.isNew ? '🆕 ' : '📄 ') + it.title)
181
+ // 动态获取 wbi 混盐密钥(48 位原始 key → 32 位 mixin key),缓存 4 小时
182
+ async function ensureBiliMixinKey() {
183
+ if (biliMixinKey && Date.now() - biliMixinTs < 4 * 60 * 60 * 1000) return biliMixinKey
184
+ const data = await fetchBiliJson('https://api.bilibili.com/x/web-interface/nav', BILI_TIMEOUT)
185
+ const img = data && data.data && data.data.wbi_img && data.data.wbi_img.img_url
186
+ const sub = data && data.data && data.data.wbi_img && data.data.wbi_img.sub_url
187
+ if (!img || !sub) return null
188
+ const raw = (String(img).split('/').pop() + String(sub).split('/').pop()).replace(/\.\w+$/, '')
189
+ if (raw.length < 64) return null
190
+ let mixin = ''
191
+ for (const i of MIXIN_KEY_ENC_TAB) mixin += raw[i]
192
+ biliMixinKey = mixin.slice(0, 32)
193
+ biliMixinTs = Date.now()
194
+ return biliMixinKey
195
+ }
196
+
197
+ // 生成 wbi 签名后的完整接口 URL
198
+ function wbiSignUrl(endpoint, params, mixinKey) {
199
+ const p = Object.assign({}, params)
200
+ p.wts = Math.round(Date.now() / 1000)
201
+ const query = Object.keys(p).sort().map(k => k + '=' + encodeURIComponent(p[k])).join('&')
202
+ const wRid = crypto.createHash('md5').update(query + mixinKey).digest('hex')
203
+ return endpoint + '?' + query + '&w_rid=' + wRid
204
+ }
205
+
206
+ // 从 bilibili 返回体提取用户名:acc/info 是 data.name,card 是 data.card.name
207
+ function extractBiliName(d) {
208
+ if (!d || d.code !== 0 || !d.data) return null
209
+ if (d.data.name && String(d.data.name).trim()) return String(d.data.name).trim()
210
+ if (d.data.card && d.data.card.name && String(d.data.card.name).trim()) return String(d.data.card.name).trim()
211
+ return null
212
+ }
213
+
214
+ // 尝试解析 uid 对应的用户名;失败返回 null
215
+ async function tryResolveName(uid) {
216
+ // 1) WBI 签名接口(最可靠)
217
+ const mixinKey = await ensureBiliMixinKey()
218
+ if (mixinKey) {
219
+ const url = wbiSignUrl('https://api.bilibili.com/x/space/wbi/acc/info', { mid: uid }, mixinKey)
220
+ const d = await fetchBiliJson(url, BILI_TIMEOUT)
221
+ const n = extractBiliName(d)
222
+ if (n) return n
223
+ }
224
+ // 2) 未签名接口(部分环境仍可用,card 结构为 data.card.name)
225
+ for (const ep of [
226
+ 'https://api.bilibili.com/x/space/acc/info?mid=' + uid,
227
+ 'https://api.bilibili.com/x/web-interface/card?mid=' + uid,
228
+ ]) {
229
+ const d = await fetchBiliJson(ep, BILI_TIMEOUT)
230
+ const n = extractBiliName(d)
231
+ if (n) return n
232
+ }
233
+ // 3) 空间页 <title>(服务端渲染,含用户名)
234
+ const ctrl = new AbortController()
235
+ const timer = setTimeout(() => ctrl.abort(), BILI_TIMEOUT)
236
+ try {
237
+ const res = await fetch('https://space.bilibili.com/' + uid, {
238
+ signal: ctrl.signal,
239
+ headers: { 'User-Agent': UA, 'Accept-Language': 'zh-CN,zh;q=0.9' },
240
+ })
241
+ if (res.ok) {
242
+ const t = await res.text()
243
+ const m = t.match(/<title>([^<]+)<\/title>/)
244
+ if (m && m[1]) {
245
+ let s = String(m[1]).replace(/[_\- ]\s*哔哩哔哩.*$/i, '').replace(/\s*[-_]\s*bilibili.*$/i, '').trim()
246
+ // 过滤占位/错误标题,避免把无意义文本当用户名
247
+ if (s && !/^[\d\s]+$/.test(s) && !/^(加载中|请稍候|没有找到|错误|验证|访问|403|404|412|哔哩哔哩)/.test(s)) return s
248
+ }
249
+ }
250
+ } catch (e) { /* 忽略 */ }
251
+ finally { clearTimeout(timer) }
252
+ return null
253
+ }
254
+
255
+ async function resolveUserName(logger, uid) {
256
+ const key = String(uid == null ? '' : uid).trim()
257
+ if (!/^\d+$/.test(key)) return key || '' // 非数字 uid(普通用户名)原样返回
258
+ const cached = NAME_CACHE.get(key)
259
+ if (cached) {
260
+ if (cached.name) return cached.name // 已解析成功
261
+ if (Date.now() - cached.ts < NEG_CACHE_TTL) return key // 失败缓存期内
262
+ }
263
+ const found = await tryResolveName(key)
264
+ if (found) {
265
+ NAME_CACHE.set(key, { name: found, ts: Date.now() })
266
+ return found
267
+ }
268
+ // 解析失败:短时缓存(10 分钟)以便自动重试;每个 uid 每进程只打一次日志
269
+ NAME_CACHE.set(key, { name: null, ts: Date.now(), logged: true })
270
+ if (!cached || !cached.logged) {
271
+ logger.warn('BWIKI更新昵称解析失败(显示 uid),请检查 bilibili 接口可达性: ' + key)
272
+ }
273
+ return key
274
+ }
275
+
276
+ // ===== 文本渲染为图片 =====
277
+
278
+ function escHtml(s) {
279
+ return String(s == null ? '' : s)
280
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
281
+ .replace(/"/g, '&quot;').replace(/'/g, '&#39;')
282
+ }
283
+
284
+ // 更新内容块:diff 的 + 行标绿、- 行标红;普通行中性色
285
+ function renderContentHtml(text, maxChars) {
286
+ let t = String(text || '').replace(/\n{2,}/g, '\n').trim()
287
+ if (t.length > maxChars) t = t.slice(0, maxChars) + '…'
288
+ const lines = t.split('\n').map(l => {
289
+ const esc = escHtml(l)
290
+ if (l.charAt(0) === '+') return '<div class="ln add">' + esc + '</div>'
291
+ if (l.charAt(0) === '-') return '<div class="ln del">' + esc + '</div>'
292
+ return '<div class="ln">' + esc + '</div>'
293
+ }).join('')
294
+ return '<div class="content">' + lines + '</div>'
295
+ }
296
+
297
+ function buildWatchHtml(items, config) {
298
+ const width = config.width || 1300
299
+ const title = escHtml(config.summaryTitle || ('检测到 ' + items.length + ' 个页面变更'))
300
+ const cards = items.map(it => {
301
+ const tag = it.isNew ? '<span class="tag-new">🆕 新页面</span>' : '<span class="tag-edit">✏️ 编辑</span>'
151
302
  const meta = []
152
- if (it.user) meta.push(it.user)
153
- meta.push(fmtTime(it.time))
303
+ if (it.user) meta.push('<span class="m-user">👤 ' + escHtml(it.user) + '</span>')
304
+ meta.push('<span>🕒 ' + escHtml(fmtTime(it.time)) + '</span>')
305
+ if (it.comment) meta.push('<span class="m-comment">💬 “' + escHtml(it.comment) + '”</span>')
306
+ if (it.minor) meta.push('<span class="m-minor">(小编辑)</span>')
307
+ const size = it.sizeDelta != null
308
+ ? '<div class="size">大小:' + (it.sizeDelta >= 0 ? '+' : '') + it.sizeDelta + ' 字节</div>'
309
+ : ''
310
+ const content = it.detail && it.detail.text
311
+ ? renderContentHtml(it.detail.text, clamp(config.wikiWatchMaxChars, 100, 3000, 500))
312
+ : ''
313
+ return '<div class="card">' +
314
+ '<div class="c-head">' + tag + '<span class="c-title">' + escHtml(it.title) + '</span></div>' +
315
+ '<div class="c-meta">' + meta.join('<span class="sep">·</span>') + '</div>' +
316
+ size +
317
+ content +
318
+ '</div>'
319
+ }).join('')
320
+
321
+ return `<!DOCTYPE html>
322
+ <html lang="zh-CN">
323
+ <head>
324
+ <meta charset="UTF-8">
325
+ <style>
326
+ * { margin:0; padding:0; box-sizing:border-box; }
327
+ body { font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Microsoft YaHei',sans-serif; color:#e6edf3;
328
+ background:radial-gradient(1400px 700px at 50% -10%, #1b2a4a 0%, #10182b 45%, #0a0e17 100%); padding:24px 20px; }
329
+ .page { max-width:${width}px; margin:0 auto; }
330
+ .hero { text-align:center; padding:24px 22px 20px; border-radius:18px; margin-bottom:18px;
331
+ background:linear-gradient(135deg, #667eea 0%, #764ba2 55%, #a4508b 100%); box-shadow:0 8px 28px rgba(0,0,0,.4); position:relative; overflow:hidden; }
332
+ .hero::after { content:''; position:absolute; inset:0; background:radial-gradient(600px 200px at 80% -20%, rgba(255,255,255,.18), transparent 60%); }
333
+ .badge { display:inline-flex; font-size:22px; font-weight:700; color:#ffd76d; background:rgba(0,0,0,.28);
334
+ border:1px solid rgba(255,215,109,.4); border-radius:999px; padding:4px 14px; margin-bottom:10px; letter-spacing:1px; }
335
+ .title { font-size:32px; font-weight:800; color:#fff; letter-spacing:2px; text-shadow:0 2px 10px rgba(0,0,0,.35); }
336
+ .sub { font-size:22px; color:rgba(255,255,255,.8); margin-top:8px; letter-spacing:.5px; }
337
+ .card { background:rgba(22,27,34,.8); border:1px solid rgba(255,255,255,.09); border-radius:16px; padding:16px 18px; margin-bottom:14px; }
338
+ .c-head { display:flex; align-items:center; gap:10px; font-size:24px; margin-bottom:8px; }
339
+ .tag-new { flex-shrink:0; font-size:20px; font-weight:700; color:#0d1117; background:linear-gradient(135deg,#3fb950,#2ea043); border-radius:999px; padding:2px 10px; }
340
+ .tag-edit { flex-shrink:0; font-size:20px; font-weight:700; color:#0d1117; background:linear-gradient(135deg,#58a6ff,#4c8dff); border-radius:999px; padding:2px 10px; }
341
+ .c-title { font-weight:800; color:#fff; letter-spacing:.5px; word-break:break-all; }
342
+ .c-meta { display:flex; align-items:center; flex-wrap:wrap; gap:10px; font-size:21px; color:#79c0ff; margin-bottom:6px; }
343
+ .c-meta .sep { color:rgba(255,255,255,.3); }
344
+ .m-user { color:#ffd76d; font-weight:700; }
345
+ .m-comment { color:#c9d1d9; }
346
+ .m-minor { color:#8b949e; font-size:19px; }
347
+ .size { font-size:20px; color:#8b949e; margin-bottom:6px; }
348
+ .content { background:rgba(0,0,0,.25); border:1px solid rgba(255,255,255,.07); border-radius:10px; padding:10px 12px; }
349
+ .ln { font-size:21px; line-height:1.7; color:#c9d1d9; white-space:pre-wrap; word-break:break-all; font-family:'SFMono-Regular',Consolas,'Liberation Mono',Menlo,monospace; }
350
+ .ln.add { color:#3fb950; }
351
+ .ln.del { color:#f85149; }
352
+ .foot { text-align:center; color:#8b949e; font-size:20px; margin-top:12px; opacity:.75; letter-spacing:.5px; }
353
+ </style>
354
+ </head>
355
+ <body>
356
+ <div class="page">
357
+ <div class="hero">
358
+ <div class="badge">🛰️ 第五人格 BWIKI 更新</div>
359
+ <div class="title">${title}</div>
360
+ <div class="sub">数据来源:第五人格 BWIKI · 最近更改</div>
361
+ </div>
362
+ ${cards}
363
+ <div class="foot">数据来源:第五人格 BWIKI</div>
364
+ </div>
365
+ </body>
366
+ </html>`
367
+ }
368
+
369
+ // 通过 puppeteer 把 HTML 渲染为 PNG;无 puppeteer / 渲染失败返回 null(调用方回退文本)
370
+ async function renderWatchHtml(ctx, html, config) {
371
+ if (!ctx.puppeteer) return null
372
+ try {
373
+ const width = config.width || 1300
374
+ const page = await ctx.puppeteer.page()
375
+ try {
376
+ await page.setViewport({ width, height: 400, deviceScaleFactor: config.scale || 2 })
377
+ await page.setContent(html, { waitUntil: 'load' })
378
+ await page.waitForFunction(() => Array.from(document.images).every(i => i.complete), {
379
+ timeout: 15000, polling: 200,
380
+ }).catch(() => {})
381
+ return await page.screenshot({ type: 'png', fullPage: true })
382
+ } finally {
383
+ await page.close().catch(() => {})
384
+ }
385
+ } catch (e) {
386
+ return null
387
+ }
388
+ }
389
+
390
+ // 纯文本回退(无 puppeteer / 渲染失败时使用;不含标题行,标题由 buildWatchOutput 统一给出)
391
+ function buildWatchText(items, maxChars) {
392
+ const blocks = []
393
+ for (const it of items) {
394
+ const b = []
395
+ b.push((it.isNew ? '🆕 ' : '📄 ') + it.title)
396
+ const meta = [it.user, fmtTime(it.time)].filter(Boolean)
154
397
  if (it.comment) meta.push('“' + it.comment + '”')
155
398
  if (it.minor) meta.push('(小编辑)')
156
- lines.push(' ' + meta.join(' · '))
157
- if (it.sizeDelta != null) {
158
- lines.push(' 大小:' + (it.sizeDelta >= 0 ? '+' : '') + it.sizeDelta + ' 字节')
159
- }
399
+ b.push(' ' + meta.join(' · '))
400
+ if (it.sizeDelta != null) b.push(' 大小:' + (it.sizeDelta >= 0 ? '+' : '') + it.sizeDelta + ' 字节')
160
401
  if (it.detail && it.detail.text) {
161
- let text = String(it.detail.text).replace(/\n{2,}/g, '\n').trim()
162
- if (text.length > cap) text = text.slice(0, cap) + '…'
163
- if (text) lines.push(' 更新内容:\n' + indentText(text, ' '))
402
+ let t = String(it.detail.text).replace(/\n{2,}/g, '\n').trim()
403
+ if (t.length > maxChars) t = t.slice(0, maxChars) + '…'
404
+ if (t) b.push(' 更新内容:\n' + indentText(t, ' '))
164
405
  }
165
- lines.push(' 🔗 https://wiki.biligame.com/dwrg/' + encodeURIComponent(it.title))
406
+ blocks.push(b.join('\n'))
166
407
  }
167
- let msg = lines.join('\n')
168
- const MAX_TOTAL = 4000
169
- if (msg.length > MAX_TOTAL) msg = msg.slice(0, MAX_TOTAL) + '\n…(内容过长已截断)'
408
+ let msg = blocks.join('\n\n')
409
+ if (msg.length > 4000) msg = msg.slice(0, 4000) + '\n…(内容过长已截断)'
170
410
  return msg
171
411
  }
172
412
 
413
+ function indentText(text, prefix) {
414
+ return String(text).split('\n').map(l => prefix + l).join('\n')
415
+ }
416
+
417
+ // 汇总输出:优先渲染为图片,失败回退纯文本。返回 koishi 元素数组
418
+ async function buildWatchOutput(ctx, config, items, logger) {
419
+ const summary = '检测到 ' + items.length + ' 个页面变更'
420
+ try {
421
+ const html = buildWatchHtml(items, { ...config, summaryTitle: summary })
422
+ const img = await renderWatchHtml(ctx, html, config)
423
+ if (img) return [h.image(img, 'image/png')]
424
+ } catch (e) {
425
+ logger.warn('BWIKI更新图片渲染失败,回退纯文本: ' + (e && e.message ? e.message : e))
426
+ }
427
+ return ['【第五人格 BWIKI 更新】' + summary, '\n' + buildWatchText(items, clamp(config.wikiWatchMaxChars, 100, 3000, 500))]
428
+ }
429
+
173
430
  // 汇总推送目标列表(wikiWatchTargets:多目标,群/频道与私聊可混用;与公告推送一致)
174
431
  function buildWatchTargets(config) {
175
432
  const list = Array.isArray(config.wikiWatchTargets) ? config.wikiWatchTargets : []
@@ -187,7 +444,7 @@ function buildWatchTargets(config) {
187
444
  return targets
188
445
  }
189
446
 
190
- // 推送到全部目标,任一目标成功即视为成功(其余目标失败仅记录日志)
447
+ // 推送到全部目标,任一目标成功即视为成功(其余目标失败仅记录日志)。content 为元素数组或字符串。
191
448
  async function sendWatchMessage(ctx, config, content, logger) {
192
449
  const targets = buildWatchTargets(config)
193
450
  if (!config.wikiWatchPlatform || !targets.length) return false
@@ -243,7 +500,7 @@ function startWikiWatchMonitor(ctx, config) {
243
500
  } catch (e) { /* 忽略 */ }
244
501
  }
245
502
 
246
- // 执行一次检查。返回 { pushed, first, sent } 或 { error }
503
+ // 执行一次检查。返回 { pushed, first, sent, message } 或 { error }
247
504
  const check = async () => {
248
505
  if (state.running) return { error: '正在检查中,请稍后再试' }
249
506
  state.running = true
@@ -252,7 +509,7 @@ function startWikiWatchMonitor(ctx, config) {
252
509
  state.lastCheck = fmtTime(new Date().toISOString())
253
510
  state.lastError = null
254
511
  const changes = await fetchRecentChanges(config)
255
- if (!changes.length) return { pushed: 0, first: state.lastRcid == null }
512
+ if (!changes.length) return { pushed: 0, first: state.lastRcid == null, message: null }
256
513
 
257
514
  const firstRun = state.lastRcid == null
258
515
  const fresh = [] // 相对上次位置新增的变更(倒序)
@@ -269,12 +526,10 @@ function startWikiWatchMonitor(ctx, config) {
269
526
  state.lastRcid = newest
270
527
  await saveState()
271
528
  }
272
- if (firstRun || !fresh.length) return { pushed: 0, first: firstRun }
529
+ if (firstRun || !fresh.length) return { pushed: 0, first: firstRun, message: null }
273
530
 
274
531
  fresh.reverse() // 转为时间正序(旧 → 新)
275
532
 
276
- const maxItems = clamp(config.wikiWatchMaxItems, 1, 30, 15)
277
- const detailLimit = clamp(config.wikiWatchDetailLimit, 0, 20, 5)
278
533
  const items = []
279
534
  for (let i = 0; i < fresh.length; i++) {
280
535
  const c = fresh[i]
@@ -289,23 +544,22 @@ function startWikiWatchMonitor(ctx, config) {
289
544
  isNew: c.type === 'new' || !!c.new,
290
545
  sizeDelta: !isNaN(nl) && !isNaN(ol) && nl !== undefined && ol !== undefined ? nl - ol : null,
291
546
  }
292
- // 只对最新的 detailLimit 条拉取详细内容(其余仅摘要),控制 API 请求量
293
- if (i >= fresh.length - detailLimit) {
294
- try {
295
- item.detail = await fetchDiffDetail(config, c)
296
- } catch (e) {
297
- logger.warn('BWIKI更新详情获取失败(' + item.title + '): ' + (e && e.message ? e.message : e))
298
- }
547
+ try {
548
+ item.detail = await fetchDiffDetail(config, c)
549
+ } catch (e) {
550
+ logger.warn('BWIKI更新详情获取失败(' + item.title + '): ' + (e && e.message ? e.message : e))
299
551
  }
300
552
  items.push(item)
301
553
  }
302
554
 
303
- const shown = items.slice(-maxItems) // 保留最新的 maxItems 条
304
- const omitted = items.length - shown.length
305
- let msg = buildWatchMessage(shown, config.wikiWatchMaxChars)
306
- if (omitted > 0) msg += '\n…另有 ' + omitted + ' 个变更未列出(可调大 wikiWatchMaxItems)'
307
- const sent = await sendWatchMessage(ctx, config, msg, logger)
308
- return { pushed: shown.length, first: false, sent }
555
+ // 解析编辑者昵称:bilibili 数字 uid → 真实用户名
556
+ for (const it of items) {
557
+ if (it.user) it.user = await resolveUserName(logger, it.user)
558
+ }
559
+
560
+ const message = await buildWatchOutput(ctx, config, items, logger)
561
+ const sent = await sendWatchMessage(ctx, config, message, logger)
562
+ return { pushed: items.length, first: false, sent, message }
309
563
  } catch (e) {
310
564
  state.lastError = (e && e.message ? e.message : String(e))
311
565
  logger.warn('BWIKI更新检查失败: ' + state.lastError)
@@ -333,5 +587,7 @@ module.exports = {
333
587
  buildWatchTargets,
334
588
  fetchRecentChanges,
335
589
  fetchDiffDetail,
336
- buildWatchMessage,
590
+ buildWatchHtml,
591
+ buildWatchText,
592
+ resolveUserName,
337
593
  }