koishi-plugin-dwrgbwiki 2.2.3 → 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.
- package/index.js +82 -0
- package/package.json +4 -3
- package/readme.md +26 -0
- 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
|
|
|
@@ -54,6 +55,8 @@ const MAIN_HELP = [
|
|
|
54
55
|
' 反馈问题或建议(私聊推送给管理员)',
|
|
55
56
|
' 公告 [源]',
|
|
56
57
|
' 查看最新完整公告图片,源可选 正式/共研/全部,例:公告',
|
|
58
|
+
' BWIKI更新监控 [检查]',
|
|
59
|
+
' 查看/触发 BWIKI 页面更新监控,例:BWIKI更新监控 检查',
|
|
57
60
|
'',
|
|
58
61
|
'发送“胜率”“抽卡”等单独指令可查看对应完整帮助',
|
|
59
62
|
'数据均根据第五人格官方小程序数据计算',
|
|
@@ -212,6 +215,43 @@ module.exports = {
|
|
|
212
215
|
noticeInterval: Schema.number().min(1).max(1440)
|
|
213
216
|
.description('公告检查间隔(分钟)')
|
|
214
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]),
|
|
215
255
|
进阶设置: Schema.object({
|
|
216
256
|
codexCommands: Schema.array(Schema.object({
|
|
217
257
|
name: Schema.string().required()
|
|
@@ -300,6 +340,10 @@ module.exports = {
|
|
|
300
340
|
if (config.noticeEnable) {
|
|
301
341
|
startNoticeMonitor(ctx, config)
|
|
302
342
|
}
|
|
343
|
+
let watchApi = null
|
|
344
|
+
if (config.wikiWatchEnable) {
|
|
345
|
+
watchApi = wikiwatch.startWikiWatchMonitor(ctx, config)
|
|
346
|
+
}
|
|
303
347
|
|
|
304
348
|
ctx.command('公告 [源:text]', '查看最新完整公告(渲染为图片发送)')
|
|
305
349
|
.alias('最新公告')
|
|
@@ -344,6 +388,44 @@ module.exports = {
|
|
|
344
388
|
return helpImage(ctx, config, MAIN_HELP)
|
|
345
389
|
})
|
|
346
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
|
+
|
|
347
429
|
ctx.command('胜率 <角色名> [条件:text]', '查询角色胜率并生成图片')
|
|
348
430
|
.usage(HELP)
|
|
349
431
|
.example('胜率 小女孩')
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "koishi-plugin-dwrgbwiki",
|
|
3
|
-
"version": "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
|
|
|
@@ -328,6 +339,21 @@ WIKI页面 <页面名>
|
|
|
328
339
|
|
|
329
340
|
变更推送优先渲染为图片(仅包含发生变化的部分):当「致诸位尊敬的访客」等引言段落没有变动时,不再将其渲染进图中,避免每次推送都带上固定引言;若引言本身有变动则会正常展示。图片渲染失败时自动回退为纯文本摘要推送。
|
|
330
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
|
+
|
|
331
357
|
## 图片内容
|
|
332
358
|
|
|
333
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
|
+
}
|