koishi-plugin-dwrgbwiki 2.2.2 → 2.3.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 +229 -36
  2. package/package.json +4 -3
  3. package/readme.md +30 -0
  4. package/wikiwatch.js +337 -0
package/index.js CHANGED
@@ -9,6 +9,7 @@ const wiki = require('./wiki')
9
9
  const cangtou = require('./cangtou')
10
10
  const match = require('./match')
11
11
  const notice = require('./notice')
12
+ const wikiwatch = require('./wikiwatch')
12
13
 
13
14
  const HELP = lib.buildHelp()
14
15
 
@@ -52,6 +53,10 @@ const MAIN_HELP = [
52
53
  ' 查询自己的抽卡资源量',
53
54
  ' 反馈 <内容>',
54
55
  ' 反馈问题或建议(私聊推送给管理员)',
56
+ ' 公告 [源]',
57
+ ' 查看最新完整公告图片,源可选 正式/共研/全部,例:公告',
58
+ ' BWIKI更新监控 [检查]',
59
+ ' 查看/触发 BWIKI 页面更新监控,例:BWIKI更新监控 检查',
55
60
  '',
56
61
  '发送“胜率”“抽卡”等单独指令可查看对应完整帮助',
57
62
  '数据均根据第五人格官方小程序数据计算',
@@ -210,6 +215,43 @@ module.exports = {
210
215
  noticeInterval: Schema.number().min(1).max(1440)
211
216
  .description('公告检查间隔(分钟)')
212
217
  .default(10),
218
+ wikiWatchEnable: Schema.boolean()
219
+ .description('启用 BWIKI 页面更新监听(定期检查页面更新并推送变更内容到目标群聊)')
220
+ .default(false),
221
+ wikiWatchPlatform: Schema.string()
222
+ .description('BWIKI 更新推送的平台(如 qq),留空禁用推送')
223
+ .default('qq'),
224
+ wikiWatchTargets: Schema.array(Schema.object({
225
+ type: Schema.union(['channel', 'private'])
226
+ .description('目标类型:channel=群/频道,private=私聊')
227
+ .default('channel'),
228
+ id: Schema.string().required()
229
+ .description('群/频道 id(channel 类型)或用户 id(private 类型)'),
230
+ }))
231
+ .role('table')
232
+ .description('BWIKI 更新推送目标列表,以表格形式通过「添加项目」新增推送目标(每行一个目标),可添加多个,群/频道与私聊可同时推送')
233
+ .default([]),
234
+ wikiWatchInterval: Schema.number().min(1).max(1440)
235
+ .description('BWIKI 更新检查间隔(分钟)')
236
+ .default(10),
237
+ wikiWatchLimit: Schema.number().min(1).max(100)
238
+ .description('每次拉取的最近变更条数(用于发现新变更)')
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
+ wikiWatchMaxChars: Schema.number().min(100).max(3000)
247
+ .description('单页更新内容摘要最大字符数')
248
+ .default(500),
249
+ wikiWatchSkipBots: Schema.boolean()
250
+ .description('过滤机器人编辑(rcshow=!bot)')
251
+ .default(true),
252
+ wikiWatchNamespaces: Schema.array(Schema.number())
253
+ .description('监听的命名空间 id 列表(主命名空间=0;留空仅监听主命名空间)')
254
+ .default([0]),
213
255
  进阶设置: Schema.object({
214
256
  codexCommands: Schema.array(Schema.object({
215
257
  name: Schema.string().required()
@@ -298,6 +340,47 @@ module.exports = {
298
340
  if (config.noticeEnable) {
299
341
  startNoticeMonitor(ctx, config)
300
342
  }
343
+ let watchApi = null
344
+ if (config.wikiWatchEnable) {
345
+ watchApi = wikiwatch.startWikiWatchMonitor(ctx, config)
346
+ }
347
+
348
+ ctx.command('公告 [源:text]', '查看最新完整公告(渲染为图片发送)')
349
+ .alias('最新公告')
350
+ .example('公告')
351
+ .example('公告 共研')
352
+ .example('公告 全部')
353
+ .action(async ({ session }, source) => {
354
+ logCmd(ctx, session, '公告', { source })
355
+ const q = String(source || '').trim()
356
+ const wantAll = /^(全部|所有|both|all)$/i.test(q)
357
+ const wantGongyan = /^(共研|共研服|gongyan)$/i.test(q)
358
+ // 不指定时默认正式服;共研/全部 可切换
359
+ const items = wantAll ? NOTICE_URLS : (wantGongyan ? [NOTICE_URLS[1]] : [NOTICE_URLS[0]])
360
+ const logger = ctx.logger('dwrgbwiki')
361
+ const parts = []
362
+ const errors = []
363
+ for (const item of items) {
364
+ try {
365
+ const text = await fetchNotice(item.url, Math.max(config.timeout || 15000, 20000))
366
+ const parsed = notice.parseNoticeCached(text)
367
+ const title = parsed.title || '公告'
368
+ const image = await renderNoticeFull(ctx, config, parsed, item.name, logger)
369
+ if (image) {
370
+ parts.push('【' + item.name + ' · ' + title + '】')
371
+ parts.push(h.image(image, 'image/png'))
372
+ } else {
373
+ // 无 puppeteer 时回退完整文本
374
+ parts.push('【' + item.name + ' · ' + title + '】\n' + noticeToText(parsed))
375
+ }
376
+ } catch (e) {
377
+ errors.push('「' + item.name + '」获取失败:' + (e && e.message ? e.message : e))
378
+ }
379
+ }
380
+ if (!parts.length) return errors.join('\n') || '公告获取失败,请稍后再试'
381
+ if (errors.length) parts.push('', errors.join('\n'))
382
+ return parts
383
+ })
301
384
 
302
385
  ctx.command('BWIKI机器人', '显示当前支持的指令列表')
303
386
  .action(async ({ session }) => {
@@ -305,6 +388,44 @@ module.exports = {
305
388
  return helpImage(ctx, config, MAIN_HELP)
306
389
  })
307
390
 
391
+ ctx.command('BWIKI更新监控 [操作:text]', '查看 BWIKI 页面更新监控状态,或立即检查一次')
392
+ .usage('BWIKI更新监控 查看监控状态(启用/间隔/推送目标/上次检查)')
393
+ .usage('BWIKI更新监控 检查 立即执行一次检查')
394
+ .example('BWIKI更新监控')
395
+ .example('BWIKI更新监控 检查')
396
+ .action(async ({ session }, op) => {
397
+ logCmd(ctx, session, 'BWIKI更新监控', { op })
398
+ const opTxt = String(op || '').trim().toLowerCase()
399
+ if (opTxt && opTxt !== '检查' && opTxt !== '测试' && opTxt !== 'check' && opTxt !== 'test') {
400
+ return '未知操作,可用:BWIKI更新监控(查看状态)、BWIKI更新监控 检查(立即检查一次)'
401
+ }
402
+ if (!config.wikiWatchEnable) {
403
+ return 'BWIKI 更新监控未启用:请在插件配置中开启「wikiWatchEnable」,并在「wikiWatchTargets」中添加推送目标(群/频道或私聊)'
404
+ }
405
+ if (opTxt) {
406
+ const r = await watchApi.check()
407
+ if (r.error) return '检查失败:' + r.error
408
+ if (r.first) return '首次检查:已记录当前更新位置(本次不推送),之后的页面更新会自动推送到配置的目标'
409
+ if (r.pushed) {
410
+ return '检查完成:检测到 ' + r.pushed + ' 个页面变更' + (r.sent === false ? '(但未推送:未配置目标或对应平台机器人不可用)' : ',已推送')
411
+ }
412
+ return '检查完成:没有新的页面变更'
413
+ }
414
+ const targets = wikiwatch.buildWatchTargets(config)
415
+ const s = watchApi.state
416
+ return [
417
+ 'BWIKI 页面更新监控状态:',
418
+ ' 启用:是',
419
+ ' 检查间隔:' + (config.wikiWatchInterval || 10) + ' 分钟',
420
+ ' 推送平台:' + (config.wikiWatchPlatform || '未设置'),
421
+ ' 推送目标:' + (targets.length ? targets.map(t => t.type + ':' + t.id).join('、') : '未设置(仅记录位置,不推送)'),
422
+ ' 已记录最新变更 id:' + (s && s.lastRcid != null ? s.lastRcid : '无'),
423
+ ' 上次检查:' + (s && s.lastCheck ? s.lastCheck : '尚未检查'),
424
+ (s && s.lastError ? ' 上次错误:' + s.lastError : ''),
425
+ '发送「BWIKI更新监控 检查」可立即执行一次检查',
426
+ ].filter(Boolean).join('\n')
427
+ })
428
+
308
429
  ctx.command('胜率 <角色名> [条件:text]', '查询角色胜率并生成图片')
309
430
  .usage(HELP)
310
431
  .example('胜率 小女孩')
@@ -637,7 +758,7 @@ module.exports = {
637
758
  }
638
759
  const ok = await feedbackStore.addBlacklist(target.platform, target.userId, reason, session.userId)
639
760
  if (!ok) return '该用户已在反馈黑名单中'
640
- return '已将 ' + target.platform + '/' + target.userId + ' 加入反馈黑名单' + (reason ? '(原因:' + reason + ')' : '')
761
+ return '已将 ' + target.platform + '/' + target.userId + ' 加入反馈黑名单' + (reason ? '(原因:' + reason + ')。反馈 解封 [id]可解封' : '')
641
762
  }
642
763
  if (adminOp === '解封') {
643
764
  const id = clean0.replace(/^解封\s*/, '').trim()
@@ -1480,16 +1601,34 @@ function parseNotice(text) {
1480
1601
  return { title, body }
1481
1602
  }
1482
1603
 
1604
+ // 获取公告文本:带浏览器 UA 与退避重试,抵御偶发网络抖动 / CDN 限流导致的单次失败
1483
1605
  async function fetchNotice(url, timeout) {
1484
- const ctrl = new AbortController()
1485
- const timer = setTimeout(() => ctrl.abort(), timeout)
1486
- try {
1487
- const res = await fetch(url, { signal: ctrl.signal })
1488
- if (!res.ok) throw new Error('HTTP ' + res.status)
1489
- return await res.text()
1490
- } finally {
1491
- clearTimeout(timer)
1606
+ const headers = {
1607
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
1608
+ 'Accept': 'text/plain,text/*;q=0.9,*/*;q=0.8',
1492
1609
  }
1610
+ const MAX_ATTEMPTS = 3
1611
+ const DELAYS = [1000, 3000] // 重试间隔:1s、3s(远小于公告检查间隔,不影响整体节奏)
1612
+ let lastErr = null
1613
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
1614
+ const ctrl = new AbortController()
1615
+ const timer = setTimeout(() => ctrl.abort(), timeout)
1616
+ try {
1617
+ const res = await fetch(url, { signal: ctrl.signal, headers, redirect: 'follow' })
1618
+ if (!res.ok) throw new Error('HTTP ' + res.status)
1619
+ return await res.text()
1620
+ } catch (e) {
1621
+ lastErr = e
1622
+ if (attempt < MAX_ATTEMPTS) await new Promise(r => setTimeout(r, DELAYS[attempt - 1]))
1623
+ } finally {
1624
+ clearTimeout(timer)
1625
+ }
1626
+ }
1627
+ // 解出最里层原因(Node undici 的 fetch 失败原因在 e.cause,如 ECONNRESET / ETIMEDOUT / TLS 握手失败等)
1628
+ let cause = lastErr && lastErr.cause
1629
+ while (cause && cause.cause) cause = cause.cause
1630
+ const detail = cause ? String(cause.message || cause) : (lastErr && lastErr.message) || String(lastErr)
1631
+ throw new Error('fetch failed after ' + MAX_ATTEMPTS + ' attempts: ' + detail)
1493
1632
  }
1494
1633
 
1495
1634
  // 汇总公告推送目标列表(noticeTargets:多目标,群/频道与私聊可混用)
@@ -1546,43 +1685,97 @@ async function sendNoticeImage(ctx, config, parsed, header, logger, sourceName,
1546
1685
  }
1547
1686
  }
1548
1687
 
1688
+ // 将解析后的公告渲染为完整图片(changed 传 null 即完整渲染,供「公告」指令使用)
1689
+ // 渲染失败或没有 puppeteer 时返回 null,由调用方回退文本
1690
+ async function renderNoticeFull(ctx, config, parsed, sourceName, logger) {
1691
+ if (!ctx.puppeteer) return null
1692
+ try {
1693
+ const width = config.width || 1300
1694
+ const html = notice.buildNoticeHtml(parsed, { ...config, width, sourceName }, null)
1695
+ return await capture(ctx, html, { ...config, width })
1696
+ } catch (e) {
1697
+ logger.warn('公告图片渲染失败: ' + (e && e.message ? e.message : e))
1698
+ return null
1699
+ }
1700
+ }
1701
+
1702
+ // 公告结构化对象 → 完整纯文本(无 puppeteer 时的回退,去色码并限制长度防刷屏)
1703
+ function noticeToText(parsed) {
1704
+ const clean = s => String(s == null ? '' : s)
1705
+ .replace(/#c[0-9a-fA-F]{6}/g, '')
1706
+ .replace(/#n|#r/g, ' ')
1707
+ .replace(/\s+/g, ' ').trim()
1708
+ const lines = []
1709
+ if (parsed.title) lines.push('『' + clean(parsed.title) + '』')
1710
+ if (parsed.intro) lines.push('', clean(parsed.intro))
1711
+ parsed.sections.forEach((sec, i) => {
1712
+ lines.push('', '【' + (i + 1) + '. ' + clean(sec.title) + '】')
1713
+ sec.items.forEach(it => {
1714
+ if (it.type === 'sub') lines.push('▪ ' + clean(it.text))
1715
+ else if (it.type === 'h3') lines.push('▪ ' + clean(it.text))
1716
+ else if (it.type === 'table') {
1717
+ if (it.title) lines.push('▪ ' + clean(it.title))
1718
+ if (it.headers && it.headers.length) lines.push(' ' + it.headers.join(' | '))
1719
+ it.rows.forEach(r => lines.push(' ' + r.join(' | ')))
1720
+ } else {
1721
+ lines.push('▪ ' + clean(it.text))
1722
+ }
1723
+ })
1724
+ })
1725
+ if (parsed.endTitle) lines.push('', '『' + clean(parsed.endTitle) + '』')
1726
+ if (parsed.endBody) lines.push(clean(parsed.endBody))
1727
+ let text = lines.filter(l => l !== '').join('\n')
1728
+ if (text.length > 3000) text = text.slice(0, 3000) + '\n…(内容过长已截断)'
1729
+ return text
1730
+ }
1731
+
1732
+ // 检查单个公告源(自动轮询共用):更新哈希/解析缓存,有变动则推送
1733
+ // 返回 { ok, changed, first, title, error, name, key }
1734
+ async function checkNoticeItem(ctx, config, item, state, logger) {
1735
+ const timeout = Math.max(config.timeout || 15000, 20000)
1736
+ try {
1737
+ const text = await fetchNotice(item.url, timeout)
1738
+ const hash = require('crypto').createHash('sha256').update(text).digest('hex')
1739
+ const prev = state.lastHash[item.key]
1740
+ state.lastHash[item.key] = hash
1741
+ const parsed = notice.parseNoticeCached(text)
1742
+ const prevParsed = state.lastParsed[item.key] || null
1743
+ state.lastParsed[item.key] = parsed
1744
+ const { title, body } = parseNotice(text)
1745
+ // 首次运行只记录,不推送;之后变动才推送
1746
+ if (prev !== undefined && prev !== hash) {
1747
+ const header = '【' + item.name + '公告更新】' + (title ? '「' + title + '」' : '')
1748
+ // 优先渲染为图片推送(仅变化部分),失败时回退纯文本摘要
1749
+ const changed = prevParsed ? notice.diffNotice(prevParsed, parsed) : null
1750
+ const sent = await sendNoticeImage(ctx, config, parsed, header, logger, item.name, changed && changed.hasChange ? changed : null)
1751
+ if (!sent) {
1752
+ const content = [header, '', body].join('\n')
1753
+ await sendNoticeMessage(ctx, config, content, logger)
1754
+ }
1755
+ return { ok: true, changed: true, title, name: item.name, key: item.key }
1756
+ }
1757
+ return { ok: true, changed: false, first: prev === undefined, title, name: item.name, key: item.key }
1758
+ } catch (e) {
1759
+ logger.warn('公告检查失败(' + item.key + '): ' + (e && e.message ? e.message : e) + ' | ' + item.url)
1760
+ return { ok: false, error: (e && e.message ? e.message : e), name: item.name, key: item.key }
1761
+ }
1762
+ }
1763
+
1549
1764
  function startNoticeMonitor(ctx, config) {
1550
1765
  const logger = ctx.logger('dwrgbwiki')
1551
- const timeout = Math.max(config.timeout || 15000, 20000)
1552
1766
  // 上次内容哈希:key -> hash;上次解析结果:key -> parsed(用于增量 diff)
1553
- let lastHash = {}
1554
- let lastParsed = {}
1767
+ const state = { lastHash: {}, lastParsed: {} }
1555
1768
  let running = false
1556
1769
 
1557
1770
  const check = async () => {
1558
- if (running) return
1771
+ if (running) return []
1559
1772
  running = true
1560
1773
  try {
1774
+ const results = []
1561
1775
  for (const item of NOTICE_URLS) {
1562
- try {
1563
- const text = await fetchNotice(item.url, timeout)
1564
- const hash = require('crypto').createHash('sha256').update(text).digest('hex')
1565
- const prev = lastHash[item.key]
1566
- lastHash[item.key] = hash
1567
- const parsed = notice.parseNoticeCached(text)
1568
- const prevParsed = lastParsed[item.key] || null
1569
- lastParsed[item.key] = parsed
1570
- // 首次运行只记录,不推送;之后变动才推送
1571
- if (prev !== undefined && prev !== hash) {
1572
- const { title, body } = parseNotice(text)
1573
- const header = '【' + item.name + '公告更新】' + (title ? '「' + title + '」' : '')
1574
- // 优先渲染为图片推送(仅变化部分),失败时回退纯文本摘要
1575
- const changed = prevParsed ? notice.diffNotice(prevParsed, parsed) : null
1576
- const sent = await sendNoticeImage(ctx, config, parsed, header, logger, item.name, changed && changed.hasChange ? changed : null)
1577
- if (!sent) {
1578
- const content = [header, '', body].join('\n')
1579
- await sendNoticeMessage(ctx, config, content, logger)
1580
- }
1581
- }
1582
- } catch (e) {
1583
- logger.warn('公告检查失败(' + item.key + '): ' + (e && e.message ? e.message : e))
1584
- }
1776
+ results.push(await checkNoticeItem(ctx, config, item, state, logger))
1585
1777
  }
1778
+ return results
1586
1779
  } finally {
1587
1780
  running = false
1588
1781
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-dwrgbwiki",
3
- "version": "2.2.2",
4
- "description": "第五人格BWIKI编辑组开发,所有代码由DeepSeek V4 Flash 0731编写。支持胜率/地图/模仿者/勋章/基础数据查询、图鉴查询、wiki页面截图、藏头诗、抽卡模拟、加页手记探索、赛事数据(选手/对决)、公告变动推送、反馈系统等功能。",
3
+ "version": "2.3.0",
4
+ "description": "第五人格BWIKI编辑组开发,所有代码由DeepSeek V4 Flash 0731编写。支持胜率/地图/模仿者/勋章/基础数据查询、图鉴查询、wiki页面截图、藏头诗、抽卡模拟、加页手记探索、赛事数据(选手/对决)、公告变动推送、BWIKI页面更新推送、反馈系统等功能。",
5
5
  "main": "index.js",
6
6
  "files": [
7
7
  "index.js",
@@ -10,6 +10,7 @@
10
10
  "basicdata.js",
11
11
  "codex.js",
12
12
  "wiki.js",
13
+ "wikiwatch.js",
13
14
  "cangtou.js",
14
15
  "match.js",
15
16
  "notice.js",
@@ -28,7 +29,7 @@
28
29
  },
29
30
  "koishi": {
30
31
  "description": {
31
- "zh": "第五人格BWIKI编辑组开发,所有代码由DeepSeek V4 Flash 0731编写。支持胜率/地图/模仿者/勋章/基础数据查询、图鉴查询、wiki页面截图、藏头诗、抽卡模拟、加页手记探索、赛事数据(选手/对决)、公告变动推送、反馈系统等功能。"
32
+ "zh": "第五人格BWIKI编辑组开发,所有代码由DeepSeek V4 Flash 0731编写。支持胜率/地图/模仿者/勋章/基础数据查询、图鉴查询、wiki页面截图、藏头诗、抽卡模拟、加页手记探索、赛事数据(选手/对决)、公告变动推送、BWIKI页面更新推送、反馈系统等功能。"
32
33
  }
33
34
  }
34
35
  }
package/readme.md CHANGED
@@ -27,6 +27,7 @@ WIKI页面 <页面名>
27
27
  抽卡资源
28
28
  抽卡重置 <用户>(需权限 2 级)
29
29
  反馈 <内容> [-i 编号]
30
+ BWIKI更新监控 [检查]
30
31
  ```
31
32
 
32
33
  发送 `BWIKI机器人` 可查看当前支持的指令列表图片;发送 `胜率` 等单独指令可查看对应完整帮助。
@@ -304,6 +305,16 @@ WIKI页面 <页面名>
304
305
  | `noticePlatform` | 公告推送的平台(如 `qq`),留空禁用公告推送 | `qq` |
305
306
  | `noticeTargets` | 公告推送目标列表,以表格形式通过「添加项目」新增(每行一个目标:类型 channel=群/频道 或 private=私聊、目标 id),可添加多个,群/频道与私聊可同时推送 | 空 |
306
307
  | `noticeInterval` | 公告检查间隔(分钟) | `10` |
308
+ | `wikiWatchEnable` | 启用 BWIKI 页面更新监听(定期检查页面更新并推送变更内容到目标群聊) | `false` |
309
+ | `wikiWatchPlatform` | BWIKI 更新推送的平台(如 `qq`),留空禁用推送 | `qq` |
310
+ | `wikiWatchTargets` | BWIKI 更新推送目标列表(与 `noticeTargets` 同构:每行一个目标,类型 channel=群/频道 或 private=私聊、目标 id),可添加多个 | 空 |
311
+ | `wikiWatchInterval` | BWIKI 更新检查间隔(分钟) | `10` |
312
+ | `wikiWatchLimit` | 每次拉取的最近变更条数(用于发现新变更) | `50` |
313
+ | `wikiWatchDetailLimit` | 每次推送中获取详细更新内容(diff/新页面内容)的最大页面数,其余只推送标题与编辑摘要 | `5` |
314
+ | `wikiWatchMaxItems` | 每次推送最多列出的页面变更数(超出部分仅提示数量) | `15` |
315
+ | `wikiWatchMaxChars` | 单页更新内容摘要最大字符数 | `500` |
316
+ | `wikiWatchSkipBots` | 过滤机器人编辑(`rcshow=!bot`) | `true` |
317
+ | `wikiWatchNamespaces` | 监听的命名空间 id 列表(主命名空间=0;留空仅监听主命名空间) | `[0]` |
307
318
 
308
319
  数据源两个镜像:`https://website.homeworkkun.top` 与 `https://web.homeworkkun.top`,可直接在配置中切换。
309
320
 
@@ -322,8 +333,27 @@ WIKI页面 <页面名>
322
333
 
323
334
  每次内容变动(以内容哈希判断)时,通过 `noticePlatform` 平台的机器人推送到 `noticeTargets` 列表中的全部目标(每条目标可单独指定 `channel` 群/频道或 `private` 私聊,可同时推送)。推送内容为「公告标题」+ 正文摘要(去除 HTML 标签与颜色代码,截断 500 字)。首次启动只记录基线,不推送。
324
335
 
336
+ 检查请求带浏览器 User-Agent,并在网络抖动时自动重试 3 次(间隔 1s、3s),连续失败才会记录「公告检查失败」日志(日志会附上公告地址与真实原因)。
337
+
338
+ 任何成员可发送 `公告`(或 `最新公告`)查看最新完整公告:默认渲染并发送正式服完整公告图片,可加参数切换来源——`公告 共研`(共研服)、`公告 全部`(两个都发);无 puppeteer 环境时自动回退为完整文本。
339
+
325
340
  变更推送优先渲染为图片(仅包含发生变化的部分):当「致诸位尊敬的访客」等引言段落没有变动时,不再将其渲染进图中,避免每次推送都带上固定引言;若引言本身有变动则会正常展示。图片渲染失败时自动回退为纯文本摘要推送。
326
341
 
342
+ ## BWIKI 页面更新推送
343
+
344
+ 启用 `wikiWatchEnable` 后,插件会定时(`wikiWatchInterval` 分钟)调用第五人格 BWIKI 的 MediaWiki API(`https://wiki.biligame.com/dwrg/api.php`):
345
+
346
+ 1. `action=query&list=recentchanges` 获取最近更新的页面(默认只监听主命名空间,可配置 `wikiWatchNamespaces`;`wikiWatchSkipBots` 可过滤机器人编辑)
347
+ 2. 对每个新变更调用 `action=compare` 拉取该次编辑的差异(纯文本 unified diff)作为「最后更新内容」;新创建的页面则拉取最新内容前段
348
+
349
+ 汇总为一条文字消息,通过 `wikiWatchPlatform` 平台的机器人推送到 `wikiWatchTargets` 列表中的全部目标(每行一个目标,可混用 `channel` 群/频道与 `private` 私聊),格式为:页面标题(新页面标 🆕)、编辑者、时间、编辑摘要、大小变化、更新内容摘要(`wikiWatchMaxChars` 截断)、页面链接。
350
+
351
+ - 首次启动只记录基线(当前最新变更位置),不推送;之后检测到新变更才推送
352
+ - 已处理的最新变更 id 持久化到数据库(`wiki_data` 表),机器人重启后从上次位置继续,不会重复推送
353
+ - 每次推送最多列出 `wikiWatchMaxItems` 个变更,其中最新的 `wikiWatchDetailLimit` 个会附详细更新内容,其余仅标题与摘要
354
+ - 任何成员可发送 `BWIKI更新监控` 查看监控状态,发送 `BWIKI更新监控 检查` 立即执行一次检查(首次检查只记录基线)
355
+ - 请求带浏览器 User-Agent,遇到 BWIKI 限流(HTTP 567/429/503)自动重试,检查失败只记录日志,不影响下次轮询
356
+
327
357
  ## 图片内容
328
358
 
329
359
  ### 胜率
package/wikiwatch.js ADDED
@@ -0,0 +1,337 @@
1
+ 'use strict'
2
+
3
+ // ===== 第五人格 BWIKI 页面更新监听 =====
4
+ // 定时调用 MediaWiki API(wiki.biligame.com/dwrg/api.php):
5
+ // 1. list=recentchanges 获取最近更新的页面(默认主命名空间,可过滤机器人编辑)
6
+ // 2. 对每个新变更 action=compare 拉取该次编辑的差异(difftype=unified 纯文本 diff);
7
+ // 新页面则拉取最新版本内容的前段
8
+ // 汇总成文字消息,通过配置的机器人平台(wikiWatchPlatform)推送到目标群聊/私聊列表
9
+ // (wikiWatchTargets,与公告推送同构,每行一个目标:类型 + id)。
10
+ // 首次启动只记录基线不推送;已处理的最新 rcid 持久化到数据库(wiki_data 表),
11
+ // 重启后从上次位置继续,不会重复推送。
12
+
13
+ const API_BASE = 'https://wiki.biligame.com/dwrg'
14
+ 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
+ const RETRY_WAIT = [3000, 8000] // BWIKI 限流/安全策略(567/429/503)时的重试间隔
16
+
17
+ // 读取 recentchanges 记录字段:兼容 formatversion=2(rcid/revid/old_revid…)与
18
+ // formatversion=1(rc_rcid/rc_this_oldid/rc_last_oldid…)两种命名
19
+ function rcField(c, v2, v1) {
20
+ return c[v2] !== undefined ? c[v2] : c[v1]
21
+ }
22
+
23
+ function clamp(v, min, max, def) {
24
+ const n = Number(v)
25
+ return isNaN(n) ? def : Math.min(max, Math.max(min, n))
26
+ }
27
+
28
+ // 带 UA / 限流退避的 JSON 请求(与 codex.js 同一策略)
29
+ async function fetchJson(url, timeout) {
30
+ const ctrl = new AbortController()
31
+ const timer = setTimeout(() => ctrl.abort(), timeout)
32
+ const doFetch = () => fetch(url, {
33
+ signal: ctrl.signal,
34
+ headers: { 'User-Agent': UA, 'Accept-Language': 'zh-CN,zh;q=0.9' },
35
+ })
36
+ try {
37
+ let res = await doFetch()
38
+ for (const wait of RETRY_WAIT) {
39
+ if (res.status !== 567 && res.status !== 429 && res.status !== 503) break
40
+ await new Promise(r => setTimeout(r, wait))
41
+ res = await doFetch()
42
+ }
43
+ if (!res.ok) throw new Error('HTTP ' + res.status)
44
+ return await res.json()
45
+ } finally {
46
+ clearTimeout(timer)
47
+ }
48
+ }
49
+
50
+ // 命名空间列表参数:留空默认只监听主命名空间(0)
51
+ function buildNamespaceParam(config) {
52
+ const list = Array.isArray(config.wikiWatchNamespaces) && config.wikiWatchNamespaces.length
53
+ ? config.wikiWatchNamespaces.map(Number).filter(n => !isNaN(n))
54
+ : [0]
55
+ return encodeURIComponent(list.join('|'))
56
+ }
57
+
58
+ // 拉取最近变更(返回 recentchanges 记录数组,按时间倒序)
59
+ async function fetchRecentChanges(config) {
60
+ const timeout = Math.max(config.timeout || 15000, 20000)
61
+ const limit = clamp(config.wikiWatchLimit, 1, 100, 50)
62
+ const ns = buildNamespaceParam(config)
63
+ const url = API_BASE + '/api.php?action=query&list=recentchanges&rcnamespace=' + ns +
64
+ '&rclimit=' + limit +
65
+ '&rcprop=' + encodeURIComponent('title|ids|timestamp|user|comment|flags|sizes') +
66
+ '&rctype=' + encodeURIComponent('edit|new') +
67
+ (config.wikiWatchSkipBots === false ? '' : '&rcshow=!bot') +
68
+ '&format=json&formatversion=2'
69
+ const data = await fetchJson(url, timeout)
70
+ return (data.query && data.query.recentchanges) || []
71
+ }
72
+
73
+ // 清洗 wikitext:去掉注释/模板/表格/链接语法/HTML 标签,便于阅读
74
+ function stripWikitext(s) {
75
+ return String(s || '')
76
+ .replace(/<!--[\s\S]*?-->/g, '')
77
+ .replace(/\{\{[^{}]*\}\}/g, '')
78
+ .replace(/\{\{[\s\S]*?\}\}/g, '')
79
+ .replace(/\[\[(?:[^\]|]*\|)?([^\]]*)\]\]/g, '$1')
80
+ .replace(/\[(?:https?:)?[^\s\]]+\s+([^\]]*)\]/g, '$1')
81
+ .replace(/<[^>]+>/g, '')
82
+ .split('\n')
83
+ .map(l => l.trim())
84
+ .filter(l => l && !/^(\{\||\|\}|\|-|\||!)/.test(l))
85
+ .join('\n')
86
+ .replace(/\n{3,}/g, '\n\n')
87
+ .trim()
88
+ }
89
+
90
+ // unified diff → 只保留 + / - 变更行(去掉 @@ 上下文头与 ---/+++ 标题行)
91
+ function diffToExcerpt(body) {
92
+ const lines = String(body || '').split('\n')
93
+ const out = []
94
+ for (const line of lines) {
95
+ if (out.length >= 30) break
96
+ if (/^(---|\+\+\+)/.test(line)) continue
97
+ if (/^@@/.test(line)) continue
98
+ if (/^[+-]/.test(line) && line.length > 1) out.push(line.trimEnd())
99
+ }
100
+ return out.join('\n')
101
+ }
102
+
103
+ // 拉取单个变更的“最后更新内容”:老版本 → 新版本的差异;新页面拉取内容前段
104
+ // 失败返回 null,由调用方降级为仅摘要
105
+ async function fetchDiffDetail(config, change) {
106
+ const timeout = Math.max(config.timeout || 15000, 20000)
107
+ const newRev = Number(rcField(change, 'revid', 'rc_this_oldid')) || 0
108
+ const oldRev = Number(rcField(change, 'old_revid', 'rc_last_oldid')) || 0
109
+ const isNew = change.type === 'new' || !!change.new || !oldRev
110
+ if (isNew || !newRev) {
111
+ if (!newRev) return null
112
+ const url = API_BASE + '/api.php?action=query&prop=revisions&revids=' + newRev +
113
+ '&rvprop=content&rvslots=main&format=json&formatversion=2'
114
+ const data = await fetchJson(url, timeout)
115
+ const pages = (data.query && data.query.pages) || []
116
+ const rev = pages.length && pages[0].revisions ? pages[0].revisions[0] : null
117
+ let content = ''
118
+ if (rev && rev.slots && rev.slots.main) content = rev.slots.main.content || ''
119
+ else if (rev && rev.content !== undefined) content = rev.content || ''
120
+ return { kind: 'new', text: stripWikitext(content) }
121
+ }
122
+ const url = API_BASE + '/api.php?action=compare&fromrev=' + oldRev + '&torev=' + newRev +
123
+ '&prop=diff&difftype=unified&format=json&formatversion=2'
124
+ const data = await fetchJson(url, timeout)
125
+ const body = (data.compare && data.compare.body) || ''
126
+ return { kind: 'diff', text: diffToExcerpt(body) }
127
+ }
128
+
129
+ function fmtTime(ts) {
130
+ const d = new Date(ts)
131
+ if (isNaN(d.getTime())) return String(ts == null ? '' : ts)
132
+ try {
133
+ return d.toLocaleString('zh-CN', { hour12: false, timeZone: 'Asia/Shanghai' })
134
+ } catch (e) {
135
+ return d.toLocaleString('zh-CN', { hour12: false })
136
+ }
137
+ }
138
+
139
+ function indentText(text, prefix) {
140
+ return String(text).split('\n').map(l => prefix + l).join('\n')
141
+ }
142
+
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)
151
+ const meta = []
152
+ if (it.user) meta.push(it.user)
153
+ meta.push(fmtTime(it.time))
154
+ if (it.comment) meta.push('“' + it.comment + '”')
155
+ if (it.minor) meta.push('(小编辑)')
156
+ lines.push(' ' + meta.join(' · '))
157
+ if (it.sizeDelta != null) {
158
+ lines.push(' 大小:' + (it.sizeDelta >= 0 ? '+' : '') + it.sizeDelta + ' 字节')
159
+ }
160
+ 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, ' '))
164
+ }
165
+ lines.push(' 🔗 https://wiki.biligame.com/dwrg/' + encodeURIComponent(it.title))
166
+ }
167
+ let msg = lines.join('\n')
168
+ const MAX_TOTAL = 4000
169
+ if (msg.length > MAX_TOTAL) msg = msg.slice(0, MAX_TOTAL) + '\n…(内容过长已截断)'
170
+ return msg
171
+ }
172
+
173
+ // 汇总推送目标列表(wikiWatchTargets:多目标,群/频道与私聊可混用;与公告推送一致)
174
+ function buildWatchTargets(config) {
175
+ const list = Array.isArray(config.wikiWatchTargets) ? config.wikiWatchTargets : []
176
+ const targets = []
177
+ const seen = new Set()
178
+ for (const t of list) {
179
+ const id = String(t && t.id ? t.id : '').trim()
180
+ if (!id) continue
181
+ const type = t.type === 'private' ? 'private' : 'channel'
182
+ const key = type + ':' + id
183
+ if (seen.has(key)) continue
184
+ seen.add(key)
185
+ targets.push({ type, id })
186
+ }
187
+ return targets
188
+ }
189
+
190
+ // 推送到全部目标,任一目标成功即视为成功(其余目标失败仅记录日志)
191
+ async function sendWatchMessage(ctx, config, content, logger) {
192
+ const targets = buildWatchTargets(config)
193
+ if (!config.wikiWatchPlatform || !targets.length) return false
194
+ const bot = ctx.bots && ctx.bots.find(b => b.platform === config.wikiWatchPlatform && b.status === 1)
195
+ if (!bot) return false
196
+ let ok = false
197
+ for (const t of targets) {
198
+ try {
199
+ if (t.type === 'private') {
200
+ await bot.sendPrivateMessage(t.id, content)
201
+ } else {
202
+ await bot.sendMessage(t.id, content)
203
+ }
204
+ ok = true
205
+ } catch (e) {
206
+ logger.warn('BWIKI更新推送失败(' + t.type + ':' + t.id + '): ' + (e && e.message ? e.message : e))
207
+ }
208
+ }
209
+ return ok
210
+ }
211
+
212
+ // 启动定时监听。返回 { state, check },供「BWIKI更新监控」指令查看状态/手动检查。
213
+ function startWikiWatchMonitor(ctx, config) {
214
+ const logger = ctx.logger('dwrgbwiki')
215
+ const state = {
216
+ lastRcid: null, // 已处理的最新变更 id(内存 + 数据库持久化)
217
+ loaded: false,
218
+ running: false,
219
+ lastCheck: null, // 上次检查时间
220
+ lastError: null, // 上次错误信息
221
+ }
222
+
223
+ const loadState = async () => {
224
+ if (state.loaded) return
225
+ state.loaded = true
226
+ if (!ctx.database) return
227
+ try {
228
+ const rows = await ctx.database.get('wiki_data', { id: '_watch_state' })
229
+ if (rows && rows.length && rows[0].data) {
230
+ const d = JSON.parse(rows[0].data)
231
+ if (d && typeof d.rcid === 'number') state.lastRcid = d.rcid
232
+ }
233
+ } catch (e) { /* 数据库不可用时退化为内存状态 */ }
234
+ }
235
+
236
+ const saveState = async () => {
237
+ if (!ctx.database) return
238
+ try {
239
+ await ctx.database.upsert('wiki_data', [{
240
+ id: '_watch_state',
241
+ data: JSON.stringify({ rcid: state.lastRcid, time: new Date().toISOString() }),
242
+ }])
243
+ } catch (e) { /* 忽略 */ }
244
+ }
245
+
246
+ // 执行一次检查。返回 { pushed, first, sent } 或 { error }
247
+ const check = async () => {
248
+ if (state.running) return { error: '正在检查中,请稍后再试' }
249
+ state.running = true
250
+ try {
251
+ await loadState()
252
+ state.lastCheck = fmtTime(new Date().toISOString())
253
+ state.lastError = null
254
+ const changes = await fetchRecentChanges(config)
255
+ if (!changes.length) return { pushed: 0, first: state.lastRcid == null }
256
+
257
+ const firstRun = state.lastRcid == null
258
+ const fresh = [] // 相对上次位置新增的变更(倒序)
259
+ let newest = null
260
+ for (const c of changes) {
261
+ const rcid = Number(rcField(c, 'rcid', 'rc_rcid')) || 0
262
+ if (!rcid) continue
263
+ if (newest === null || rcid > newest) newest = rcid
264
+ if (firstRun) break // 首次只记录最新位置,不推送
265
+ if (rcid <= state.lastRcid) break // 记录按时间倒序,遇到已处理的即停止
266
+ fresh.push(c)
267
+ }
268
+ if (newest !== null && (state.lastRcid === null || newest > state.lastRcid)) {
269
+ state.lastRcid = newest
270
+ await saveState()
271
+ }
272
+ if (firstRun || !fresh.length) return { pushed: 0, first: firstRun }
273
+
274
+ fresh.reverse() // 转为时间正序(旧 → 新)
275
+
276
+ const maxItems = clamp(config.wikiWatchMaxItems, 1, 30, 15)
277
+ const detailLimit = clamp(config.wikiWatchDetailLimit, 0, 20, 5)
278
+ const items = []
279
+ for (let i = 0; i < fresh.length; i++) {
280
+ const c = fresh[i]
281
+ const nl = Number(rcField(c, 'newlen', 'rc_new_len'))
282
+ const ol = Number(rcField(c, 'oldlen', 'rc_old_len'))
283
+ const item = {
284
+ title: c.title || '',
285
+ user: rcField(c, 'user', 'rc_user') || '',
286
+ time: rcField(c, 'timestamp', 'rc_timestamp') || '',
287
+ comment: rcField(c, 'comment', 'rc_comment') || '',
288
+ minor: !!(rcField(c, 'minor', 'rc_minor')),
289
+ isNew: c.type === 'new' || !!c.new,
290
+ sizeDelta: !isNaN(nl) && !isNaN(ol) && nl !== undefined && ol !== undefined ? nl - ol : null,
291
+ }
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
+ }
299
+ }
300
+ items.push(item)
301
+ }
302
+
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 }
309
+ } catch (e) {
310
+ state.lastError = (e && e.message ? e.message : String(e))
311
+ logger.warn('BWIKI更新检查失败: ' + state.lastError)
312
+ return { error: state.lastError }
313
+ } finally {
314
+ state.running = false
315
+ }
316
+ }
317
+
318
+ // 启动延迟 5 秒(等数据库就绪),之后按 wikiWatchInterval 周期轮询
319
+ setTimeout(() => { check().catch(() => {}) }, 5000)
320
+ const interval = Math.max(1, config.wikiWatchInterval || 10) * 60 * 1000
321
+ const timer = setInterval(() => { check().catch(() => {}) }, interval)
322
+ ctx.on('dispose', () => clearInterval(timer))
323
+
324
+ if (!buildWatchTargets(config).length) {
325
+ logger.info('BWIKI更新监控已启用,但未配置推送目标(wikiWatchTargets),将仅记录更新位置不推送')
326
+ }
327
+
328
+ return { state, check }
329
+ }
330
+
331
+ module.exports = {
332
+ startWikiWatchMonitor,
333
+ buildWatchTargets,
334
+ fetchRecentChanges,
335
+ fetchDiffDetail,
336
+ buildWatchMessage,
337
+ }