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.
- package/index.js +147 -36
- package/package.json +1 -1
- 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
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
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
|
## 图片内容
|