koishi-plugin-dwrgbwiki 2.2.2 → 2.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/index.js +147 -36
  2. package/package.json +1 -1
  3. package/readme.md +4 -0
package/index.js CHANGED
@@ -52,6 +52,8 @@ const MAIN_HELP = [
52
52
  ' 查询自己的抽卡资源量',
53
53
  ' 反馈 <内容>',
54
54
  ' 反馈问题或建议(私聊推送给管理员)',
55
+ ' 公告 [源]',
56
+ ' 查看最新完整公告图片,源可选 正式/共研/全部,例:公告',
55
57
  '',
56
58
  '发送“胜率”“抽卡”等单独指令可查看对应完整帮助',
57
59
  '数据均根据第五人格官方小程序数据计算',
@@ -299,6 +301,43 @@ module.exports = {
299
301
  startNoticeMonitor(ctx, config)
300
302
  }
301
303
 
304
+ ctx.command('公告 [源:text]', '查看最新完整公告(渲染为图片发送)')
305
+ .alias('最新公告')
306
+ .example('公告')
307
+ .example('公告 共研')
308
+ .example('公告 全部')
309
+ .action(async ({ session }, source) => {
310
+ logCmd(ctx, session, '公告', { source })
311
+ const q = String(source || '').trim()
312
+ const wantAll = /^(全部|所有|both|all)$/i.test(q)
313
+ const wantGongyan = /^(共研|共研服|gongyan)$/i.test(q)
314
+ // 不指定时默认正式服;共研/全部 可切换
315
+ const items = wantAll ? NOTICE_URLS : (wantGongyan ? [NOTICE_URLS[1]] : [NOTICE_URLS[0]])
316
+ const logger = ctx.logger('dwrgbwiki')
317
+ const parts = []
318
+ const errors = []
319
+ for (const item of items) {
320
+ try {
321
+ const text = await fetchNotice(item.url, Math.max(config.timeout || 15000, 20000))
322
+ const parsed = notice.parseNoticeCached(text)
323
+ const title = parsed.title || '公告'
324
+ const image = await renderNoticeFull(ctx, config, parsed, item.name, logger)
325
+ if (image) {
326
+ parts.push('【' + item.name + ' · ' + title + '】')
327
+ parts.push(h.image(image, 'image/png'))
328
+ } else {
329
+ // 无 puppeteer 时回退完整文本
330
+ parts.push('【' + item.name + ' · ' + title + '】\n' + noticeToText(parsed))
331
+ }
332
+ } catch (e) {
333
+ errors.push('「' + item.name + '」获取失败:' + (e && e.message ? e.message : e))
334
+ }
335
+ }
336
+ if (!parts.length) return errors.join('\n') || '公告获取失败,请稍后再试'
337
+ if (errors.length) parts.push('', errors.join('\n'))
338
+ return parts
339
+ })
340
+
302
341
  ctx.command('BWIKI机器人', '显示当前支持的指令列表')
303
342
  .action(async ({ session }) => {
304
343
  logCmd(ctx, session, 'BWIKI机器人')
@@ -637,7 +676,7 @@ module.exports = {
637
676
  }
638
677
  const ok = await feedbackStore.addBlacklist(target.platform, target.userId, reason, session.userId)
639
678
  if (!ok) return '该用户已在反馈黑名单中'
640
- return '已将 ' + target.platform + '/' + target.userId + ' 加入反馈黑名单' + (reason ? '(原因:' + reason + ')' : '')
679
+ return '已将 ' + target.platform + '/' + target.userId + ' 加入反馈黑名单' + (reason ? '(原因:' + reason + ')。反馈 解封 [id]可解封' : '')
641
680
  }
642
681
  if (adminOp === '解封') {
643
682
  const id = clean0.replace(/^解封\s*/, '').trim()
@@ -1480,16 +1519,34 @@ function parseNotice(text) {
1480
1519
  return { title, body }
1481
1520
  }
1482
1521
 
1522
+ // 获取公告文本:带浏览器 UA 与退避重试,抵御偶发网络抖动 / CDN 限流导致的单次失败
1483
1523
  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)
1524
+ const headers = {
1525
+ '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',
1526
+ 'Accept': 'text/plain,text/*;q=0.9,*/*;q=0.8',
1527
+ }
1528
+ const MAX_ATTEMPTS = 3
1529
+ const DELAYS = [1000, 3000] // 重试间隔:1s、3s(远小于公告检查间隔,不影响整体节奏)
1530
+ let lastErr = null
1531
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
1532
+ const ctrl = new AbortController()
1533
+ const timer = setTimeout(() => ctrl.abort(), timeout)
1534
+ try {
1535
+ const res = await fetch(url, { signal: ctrl.signal, headers, redirect: 'follow' })
1536
+ if (!res.ok) throw new Error('HTTP ' + res.status)
1537
+ return await res.text()
1538
+ } catch (e) {
1539
+ lastErr = e
1540
+ if (attempt < MAX_ATTEMPTS) await new Promise(r => setTimeout(r, DELAYS[attempt - 1]))
1541
+ } finally {
1542
+ clearTimeout(timer)
1543
+ }
1492
1544
  }
1545
+ // 解出最里层原因(Node undici 的 fetch 失败原因在 e.cause,如 ECONNRESET / ETIMEDOUT / TLS 握手失败等)
1546
+ let cause = lastErr && lastErr.cause
1547
+ while (cause && cause.cause) cause = cause.cause
1548
+ const detail = cause ? String(cause.message || cause) : (lastErr && lastErr.message) || String(lastErr)
1549
+ throw new Error('fetch failed after ' + MAX_ATTEMPTS + ' attempts: ' + detail)
1493
1550
  }
1494
1551
 
1495
1552
  // 汇总公告推送目标列表(noticeTargets:多目标,群/频道与私聊可混用)
@@ -1546,43 +1603,97 @@ async function sendNoticeImage(ctx, config, parsed, header, logger, sourceName,
1546
1603
  }
1547
1604
  }
1548
1605
 
1606
+ // 将解析后的公告渲染为完整图片(changed 传 null 即完整渲染,供「公告」指令使用)
1607
+ // 渲染失败或没有 puppeteer 时返回 null,由调用方回退文本
1608
+ async function renderNoticeFull(ctx, config, parsed, sourceName, logger) {
1609
+ if (!ctx.puppeteer) return null
1610
+ try {
1611
+ const width = config.width || 1300
1612
+ const html = notice.buildNoticeHtml(parsed, { ...config, width, sourceName }, null)
1613
+ return await capture(ctx, html, { ...config, width })
1614
+ } catch (e) {
1615
+ logger.warn('公告图片渲染失败: ' + (e && e.message ? e.message : e))
1616
+ return null
1617
+ }
1618
+ }
1619
+
1620
+ // 公告结构化对象 → 完整纯文本(无 puppeteer 时的回退,去色码并限制长度防刷屏)
1621
+ function noticeToText(parsed) {
1622
+ const clean = s => String(s == null ? '' : s)
1623
+ .replace(/#c[0-9a-fA-F]{6}/g, '')
1624
+ .replace(/#n|#r/g, ' ')
1625
+ .replace(/\s+/g, ' ').trim()
1626
+ const lines = []
1627
+ if (parsed.title) lines.push('『' + clean(parsed.title) + '』')
1628
+ if (parsed.intro) lines.push('', clean(parsed.intro))
1629
+ parsed.sections.forEach((sec, i) => {
1630
+ lines.push('', '【' + (i + 1) + '. ' + clean(sec.title) + '】')
1631
+ sec.items.forEach(it => {
1632
+ if (it.type === 'sub') lines.push('▪ ' + clean(it.text))
1633
+ else if (it.type === 'h3') lines.push('▪ ' + clean(it.text))
1634
+ else if (it.type === 'table') {
1635
+ if (it.title) lines.push('▪ ' + clean(it.title))
1636
+ if (it.headers && it.headers.length) lines.push(' ' + it.headers.join(' | '))
1637
+ it.rows.forEach(r => lines.push(' ' + r.join(' | ')))
1638
+ } else {
1639
+ lines.push('▪ ' + clean(it.text))
1640
+ }
1641
+ })
1642
+ })
1643
+ if (parsed.endTitle) lines.push('', '『' + clean(parsed.endTitle) + '』')
1644
+ if (parsed.endBody) lines.push(clean(parsed.endBody))
1645
+ let text = lines.filter(l => l !== '').join('\n')
1646
+ if (text.length > 3000) text = text.slice(0, 3000) + '\n…(内容过长已截断)'
1647
+ return text
1648
+ }
1649
+
1650
+ // 检查单个公告源(自动轮询共用):更新哈希/解析缓存,有变动则推送
1651
+ // 返回 { ok, changed, first, title, error, name, key }
1652
+ async function checkNoticeItem(ctx, config, item, state, logger) {
1653
+ const timeout = Math.max(config.timeout || 15000, 20000)
1654
+ try {
1655
+ const text = await fetchNotice(item.url, timeout)
1656
+ const hash = require('crypto').createHash('sha256').update(text).digest('hex')
1657
+ const prev = state.lastHash[item.key]
1658
+ state.lastHash[item.key] = hash
1659
+ const parsed = notice.parseNoticeCached(text)
1660
+ const prevParsed = state.lastParsed[item.key] || null
1661
+ state.lastParsed[item.key] = parsed
1662
+ const { title, body } = parseNotice(text)
1663
+ // 首次运行只记录,不推送;之后变动才推送
1664
+ if (prev !== undefined && prev !== hash) {
1665
+ const header = '【' + item.name + '公告更新】' + (title ? '「' + title + '」' : '')
1666
+ // 优先渲染为图片推送(仅变化部分),失败时回退纯文本摘要
1667
+ const changed = prevParsed ? notice.diffNotice(prevParsed, parsed) : null
1668
+ const sent = await sendNoticeImage(ctx, config, parsed, header, logger, item.name, changed && changed.hasChange ? changed : null)
1669
+ if (!sent) {
1670
+ const content = [header, '', body].join('\n')
1671
+ await sendNoticeMessage(ctx, config, content, logger)
1672
+ }
1673
+ return { ok: true, changed: true, title, name: item.name, key: item.key }
1674
+ }
1675
+ return { ok: true, changed: false, first: prev === undefined, title, name: item.name, key: item.key }
1676
+ } catch (e) {
1677
+ logger.warn('公告检查失败(' + item.key + '): ' + (e && e.message ? e.message : e) + ' | ' + item.url)
1678
+ return { ok: false, error: (e && e.message ? e.message : e), name: item.name, key: item.key }
1679
+ }
1680
+ }
1681
+
1549
1682
  function startNoticeMonitor(ctx, config) {
1550
1683
  const logger = ctx.logger('dwrgbwiki')
1551
- const timeout = Math.max(config.timeout || 15000, 20000)
1552
1684
  // 上次内容哈希:key -> hash;上次解析结果:key -> parsed(用于增量 diff)
1553
- let lastHash = {}
1554
- let lastParsed = {}
1685
+ const state = { lastHash: {}, lastParsed: {} }
1555
1686
  let running = false
1556
1687
 
1557
1688
  const check = async () => {
1558
- if (running) return
1689
+ if (running) return []
1559
1690
  running = true
1560
1691
  try {
1692
+ const results = []
1561
1693
  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
- }
1694
+ results.push(await checkNoticeItem(ctx, config, item, state, logger))
1585
1695
  }
1696
+ return results
1586
1697
  } finally {
1587
1698
  running = false
1588
1699
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koishi-plugin-dwrgbwiki",
3
- "version": "2.2.2",
3
+ "version": "2.2.3",
4
4
  "description": "第五人格BWIKI编辑组开发,所有代码由DeepSeek V4 Flash 0731编写。支持胜率/地图/模仿者/勋章/基础数据查询、图鉴查询、wiki页面截图、藏头诗、抽卡模拟、加页手记探索、赛事数据(选手/对决)、公告变动推送、反馈系统等功能。",
5
5
  "main": "index.js",
6
6
  "files": [
package/readme.md CHANGED
@@ -322,6 +322,10 @@ WIKI页面 <页面名>
322
322
 
323
323
  每次内容变动(以内容哈希判断)时,通过 `noticePlatform` 平台的机器人推送到 `noticeTargets` 列表中的全部目标(每条目标可单独指定 `channel` 群/频道或 `private` 私聊,可同时推送)。推送内容为「公告标题」+ 正文摘要(去除 HTML 标签与颜色代码,截断 500 字)。首次启动只记录基线,不推送。
324
324
 
325
+ 检查请求带浏览器 User-Agent,并在网络抖动时自动重试 3 次(间隔 1s、3s),连续失败才会记录「公告检查失败」日志(日志会附上公告地址与真实原因)。
326
+
327
+ 任何成员可发送 `公告`(或 `最新公告`)查看最新完整公告:默认渲染并发送正式服完整公告图片,可加参数切换来源——`公告 共研`(共研服)、`公告 全部`(两个都发);无 puppeteer 环境时自动回退为完整文本。
328
+
325
329
  变更推送优先渲染为图片(仅包含发生变化的部分):当「致诸位尊敬的访客」等引言段落没有变动时,不再将其渲染进图中,避免每次推送都带上固定引言;若引言本身有变动则会正常展示。图片渲染失败时自动回退为纯文本摘要推送。
326
330
 
327
331
  ## 图片内容