koishi-plugin-dwrgbwiki 2.2.3 → 2.4.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 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,34 @@ 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
+ wikiWatchMaxChars: Schema.number().min(100).max(3000)
241
+ .description('单页更新内容摘要最大字符数')
242
+ .default(500),
243
+ wikiWatchNamespaces: Schema.array(Schema.number())
244
+ .description('监听的命名空间 id 列表(主命名空间=0;留空仅监听主命名空间)')
245
+ .default([0]),
215
246
  进阶设置: Schema.object({
216
247
  codexCommands: Schema.array(Schema.object({
217
248
  name: Schema.string().required()
@@ -300,6 +331,10 @@ module.exports = {
300
331
  if (config.noticeEnable) {
301
332
  startNoticeMonitor(ctx, config)
302
333
  }
334
+ let watchApi = null
335
+ if (config.wikiWatchEnable) {
336
+ watchApi = wikiwatch.startWikiWatchMonitor(ctx, config)
337
+ }
303
338
 
304
339
  ctx.command('公告 [源:text]', '查看最新完整公告(渲染为图片发送)')
305
340
  .alias('最新公告')
@@ -344,6 +379,46 @@ module.exports = {
344
379
  return helpImage(ctx, config, MAIN_HELP)
345
380
  })
346
381
 
382
+ ctx.command('BWIKI更新监控 [操作:text]', '查看 BWIKI 页面更新监控状态,或立即检查一次')
383
+ .usage('BWIKI更新监控 查看监控状态(启用/间隔/推送目标/上次检查)')
384
+ .usage('BWIKI更新监控 检查 立即执行一次检查')
385
+ .example('BWIKI更新监控')
386
+ .example('BWIKI更新监控 检查')
387
+ .action(async ({ session }, op) => {
388
+ logCmd(ctx, session, 'BWIKI更新监控', { op })
389
+ const opTxt = String(op || '').trim().toLowerCase()
390
+ if (opTxt && opTxt !== '检查' && opTxt !== '测试' && opTxt !== 'check' && opTxt !== 'test') {
391
+ return '未知操作,可用:BWIKI更新监控(查看状态)、BWIKI更新监控 检查(立即检查一次)'
392
+ }
393
+ if (!config.wikiWatchEnable) {
394
+ return 'BWIKI 更新监控未启用:请在插件配置中开启「wikiWatchEnable」,并在「wikiWatchTargets」中添加推送目标(群/频道或私聊)'
395
+ }
396
+ if (opTxt) {
397
+ const r = await watchApi.check()
398
+ if (r.error) return '检查失败:' + r.error
399
+ if (r.first) return '首次检查:已记录当前更新位置(本次不推送),之后的页面更新会自动推送到配置的目标'
400
+ if (r.pushed) {
401
+ const msg = Array.isArray(r.message) ? r.message.slice() : [String(r.message || '检查完成')]
402
+ if (r.sent === false) msg.push('\n(未推送:未配置目标或对应平台机器人不可用,以下仅展示)')
403
+ return msg
404
+ }
405
+ return '检查完成:没有新的页面变更'
406
+ }
407
+ const targets = wikiwatch.buildWatchTargets(config)
408
+ const s = watchApi.state
409
+ return [
410
+ 'BWIKI 页面更新监控状态:',
411
+ ' 启用:是',
412
+ ' 检查间隔:' + (config.wikiWatchInterval || 10) + ' 分钟',
413
+ ' 推送平台:' + (config.wikiWatchPlatform || '未设置'),
414
+ ' 推送目标:' + (targets.length ? targets.map(t => t.type + ':' + t.id).join('、') : '未设置(仅记录位置,不推送)'),
415
+ ' 已记录最新变更 id:' + (s && s.lastRcid != null ? s.lastRcid : '无'),
416
+ ' 上次检查:' + (s && s.lastCheck ? s.lastCheck : '尚未检查'),
417
+ (s && s.lastError ? ' 上次错误:' + s.lastError : ''),
418
+ '发送「BWIKI更新监控 检查」可立即执行一次检查',
419
+ ].filter(Boolean).join('\n')
420
+ })
421
+
347
422
  ctx.command('胜率 <角色名> [条件:text]', '查询角色胜率并生成图片')
348
423
  .usage(HELP)
349
424
  .example('胜率 小女孩')
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-dwrgbwiki",
3
- "version": "2.2.3",
4
- "description": "第五人格BWIKI编辑组开发,所有代码由DeepSeek V4 Flash 0731编写。支持胜率/地图/模仿者/勋章/基础数据查询、图鉴查询、wiki页面截图、藏头诗、抽卡模拟、加页手记探索、赛事数据(选手/对决)、公告变动推送、反馈系统等功能。",
3
+ "version": "2.4.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,13 @@ 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
+ | `wikiWatchMaxChars` | 单页更新内容摘要最大字符数 | `500` |
314
+ | `wikiWatchNamespaces` | 监听的命名空间 id 列表(主命名空间=0;留空仅监听主命名空间) | `[0]` |
307
315
 
308
316
  数据源两个镜像:`https://website.homeworkkun.top` 与 `https://web.homeworkkun.top`,可直接在配置中切换。
309
317
 
@@ -328,6 +336,22 @@ WIKI页面 <页面名>
328
336
 
329
337
  变更推送优先渲染为图片(仅包含发生变化的部分):当「致诸位尊敬的访客」等引言段落没有变动时,不再将其渲染进图中,避免每次推送都带上固定引言;若引言本身有变动则会正常展示。图片渲染失败时自动回退为纯文本摘要推送。
330
338
 
339
+ ## BWIKI 页面更新推送
340
+
341
+ 启用 `wikiWatchEnable` 后,插件会定时(`wikiWatchInterval` 分钟)调用第五人格 BWIKI 的 MediaWiki API(`https://wiki.biligame.com/dwrg/api.php`):
342
+
343
+ 1. `action=query&list=recentchanges` 获取最近更新的页面(默认只监听主命名空间,可配置 `wikiWatchNamespaces`;**不过滤机器人编辑**)
344
+ 2. 对每个新变更调用 `action=compare` 拉取该次编辑的差异(纯文本 unified diff)作为「最后更新内容」;新创建的页面则拉取最新内容前段
345
+ 3. 编辑者的 bilibili 数字 uid 通过 bilibili 空间接口解析为真实用户名(如 `2086555` → 摸鱼达人作业君),解析失败时回退显示 uid
346
+
347
+ 汇总后渲染为一张图片(每轮最多 `wikiWatchLimit` 个页面,全部附更新内容),通过 `wikiWatchPlatform` 平台的机器人推送到 `wikiWatchTargets` 列表中的全部目标(每行一个目标,可混用 `channel` 群/频道与 `private` 私聊)。图片内容为:页面标题(新页面标 🆕)、编辑者昵称、时间、编辑摘要、大小变化、更新内容(diff 的 `+` 行绿、`-` 行红);不包含页面链接。
348
+
349
+ - 首次启动只记录基线(当前最新变更位置),不推送;之后检测到新变更才推送
350
+ - 已处理的最新变更 id 持久化到数据库(`wiki_data` 表),机器人重启后从上次位置继续,不会重复推送
351
+ - 渲染失败或没有 puppeteer 时自动回退为纯文本推送
352
+ - 任何成员可发送 `BWIKI更新监控` 查看监控状态,发送 `BWIKI更新监控 检查` 立即执行一次检查(返回图片,首次检查只记录基线)
353
+ - 请求带浏览器 User-Agent,遇到 BWIKI 限流(HTTP 567/429/503)自动重试,检查失败只记录日志,不影响下次轮询
354
+
331
355
  ## 图片内容
332
356
 
333
357
  ### 胜率
package/wikiwatch.js ADDED
@@ -0,0 +1,514 @@
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
+ // 3. 编辑者的 bilibili 数字 uid 通过 bilibili 空间接口解析为真实用户名
9
+ // 汇总后渲染为一张图片推送到配置的目标群聊/私聊列表(wikiWatchTargets)。
10
+ // 首次启动只记录基线不推送;已处理的最新 rcid 持久化到数据库(wiki_data 表),
11
+ // 重启后从上次位置继续,不会重复推送。
12
+
13
+ const { h } = require('koishi')
14
+ const API_BASE = 'https://wiki.biligame.com/dwrg'
15
+ 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'
16
+ const RETRY_WAIT = [3000, 8000] // BWIKI 限流/安全策略(567/429/503)时的重试间隔
17
+
18
+ // 读取 recentchanges 记录字段:兼容 formatversion=2(rcid/revid/old_revid…)与
19
+ // formatversion=1(rc_rcid/rc_this_oldid/rc_last_oldid…)两种命名
20
+ function rcField(c, v2, v1) {
21
+ return c[v2] !== undefined ? c[v2] : c[v1]
22
+ }
23
+
24
+ function clamp(v, min, max, def) {
25
+ const n = Number(v)
26
+ return isNaN(n) ? def : Math.min(max, Math.max(min, n))
27
+ }
28
+
29
+ // 带 UA / 限流退避的 JSON 请求(与 codex.js 同一策略)
30
+ async function fetchJson(url, timeout) {
31
+ const ctrl = new AbortController()
32
+ const timer = setTimeout(() => ctrl.abort(), timeout)
33
+ const doFetch = () => fetch(url, {
34
+ signal: ctrl.signal,
35
+ headers: { 'User-Agent': UA, 'Accept-Language': 'zh-CN,zh;q=0.9' },
36
+ })
37
+ try {
38
+ let res = await doFetch()
39
+ for (const wait of RETRY_WAIT) {
40
+ if (res.status !== 567 && res.status !== 429 && res.status !== 503) break
41
+ await new Promise(r => setTimeout(r, wait))
42
+ res = await doFetch()
43
+ }
44
+ if (!res.ok) throw new Error('HTTP ' + res.status)
45
+ return await res.json()
46
+ } finally {
47
+ clearTimeout(timer)
48
+ }
49
+ }
50
+
51
+ // 命名空间列表参数:留空默认只监听主命名空间(0)
52
+ function buildNamespaceParam(config) {
53
+ const list = Array.isArray(config.wikiWatchNamespaces) && config.wikiWatchNamespaces.length
54
+ ? config.wikiWatchNamespaces.map(Number).filter(n => !isNaN(n))
55
+ : [0]
56
+ return encodeURIComponent(list.join('|'))
57
+ }
58
+
59
+ // 拉取最近变更(返回 recentchanges 记录数组,按时间倒序;不过滤机器人编辑)
60
+ async function fetchRecentChanges(config) {
61
+ const timeout = Math.max(config.timeout || 15000, 20000)
62
+ const limit = clamp(config.wikiWatchLimit, 1, 100, 50)
63
+ const ns = buildNamespaceParam(config)
64
+ const url = API_BASE + '/api.php?action=query&list=recentchanges&rcnamespace=' + ns +
65
+ '&rclimit=' + limit +
66
+ '&rcprop=' + encodeURIComponent('title|ids|timestamp|user|comment|flags|sizes') +
67
+ '&rctype=' + encodeURIComponent('edit|new') +
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
+ // ===== bilibili uid → 用户名解析 =====
140
+ // BWIKI 账号绑定 bilibili,MediaWiki 返回的 user 是数字 uid。通过 bilibili 空间接口
141
+ // 解析真实用户名;失败时回退显示 uid。结果在进程内缓存,避免频繁请求。
142
+ const NAME_CACHE = new Map()
143
+
144
+ async function resolveUserName(logger, uid) {
145
+ const key = String(uid == null ? '' : uid).trim()
146
+ if (!/^\d+$/.test(key)) return key || '' // 非数字 uid 原样返回
147
+ if (NAME_CACHE.has(key)) return NAME_CACHE.get(key)
148
+ const timeout = 10000
149
+ let name = null
150
+ // 依次尝试两个公开接口
151
+ const apis = [
152
+ 'https://api.bilibili.com/x/space/acc/info?mid=' + key,
153
+ 'https://api.bilibili.com/x/web-interface/card?mid=' + key,
154
+ ]
155
+ for (const url of apis) {
156
+ if (name) break
157
+ const ctrl = new AbortController()
158
+ const timer = setTimeout(() => ctrl.abort(), timeout)
159
+ try {
160
+ const res = await fetch(url, {
161
+ signal: ctrl.signal,
162
+ headers: {
163
+ 'User-Agent': UA,
164
+ 'Accept-Language': 'zh-CN,zh;q=0.9',
165
+ 'Referer': 'https://space.bilibili.com/' + key,
166
+ },
167
+ })
168
+ if (res.ok) {
169
+ const data = await res.json()
170
+ if (data && data.code === 0 && data.data && data.data.name) name = String(data.data.name).trim()
171
+ }
172
+ } catch (e) { /* 尝试下一个 */ }
173
+ finally { clearTimeout(timer) }
174
+ }
175
+ // 接口失败则解析空间页标题
176
+ if (!name) {
177
+ const ctrl = new AbortController()
178
+ const timer = setTimeout(() => ctrl.abort(), timeout)
179
+ try {
180
+ const res = await fetch('https://space.bilibili.com/' + key, {
181
+ signal: ctrl.signal,
182
+ headers: { 'User-Agent': UA, 'Accept-Language': 'zh-CN,zh;q=0.9' },
183
+ })
184
+ if (res.ok) {
185
+ const t = await res.text()
186
+ const m = t.match(/<title>([^<]+)<\/title>/)
187
+ if (m && m[1]) name = String(m[1]).replace(/[_\-]\s*哔哩哔哩.*$/i, '').trim()
188
+ }
189
+ } catch (e) { /* 忽略 */ }
190
+ finally { clearTimeout(timer) }
191
+ }
192
+ const result = name || key
193
+ NAME_CACHE.set(key, result)
194
+ return result
195
+ }
196
+
197
+ // ===== 文本渲染为图片 =====
198
+
199
+ function escHtml(s) {
200
+ return String(s == null ? '' : s)
201
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
202
+ .replace(/"/g, '&quot;').replace(/'/g, '&#39;')
203
+ }
204
+
205
+ // 更新内容块:diff 的 + 行标绿、- 行标红;普通行中性色
206
+ function renderContentHtml(text, maxChars) {
207
+ let t = String(text || '').replace(/\n{2,}/g, '\n').trim()
208
+ if (t.length > maxChars) t = t.slice(0, maxChars) + '…'
209
+ const lines = t.split('\n').map(l => {
210
+ const esc = escHtml(l)
211
+ if (l.charAt(0) === '+') return '<div class="ln add">' + esc + '</div>'
212
+ if (l.charAt(0) === '-') return '<div class="ln del">' + esc + '</div>'
213
+ return '<div class="ln">' + esc + '</div>'
214
+ }).join('')
215
+ return '<div class="content">' + lines + '</div>'
216
+ }
217
+
218
+ function buildWatchHtml(items, config) {
219
+ const width = config.width || 1300
220
+ const title = escHtml(config.summaryTitle || ('检测到 ' + items.length + ' 个页面变更'))
221
+ const cards = items.map(it => {
222
+ const tag = it.isNew ? '<span class="tag-new">🆕 新页面</span>' : '<span class="tag-edit">✏️ 编辑</span>'
223
+ const meta = []
224
+ if (it.user) meta.push('<span class="m-user">👤 ' + escHtml(it.user) + '</span>')
225
+ meta.push('<span>🕒 ' + escHtml(fmtTime(it.time)) + '</span>')
226
+ if (it.comment) meta.push('<span class="m-comment">💬 “' + escHtml(it.comment) + '”</span>')
227
+ if (it.minor) meta.push('<span class="m-minor">(小编辑)</span>')
228
+ const size = it.sizeDelta != null
229
+ ? '<div class="size">大小:' + (it.sizeDelta >= 0 ? '+' : '') + it.sizeDelta + ' 字节</div>'
230
+ : ''
231
+ const content = it.detail && it.detail.text
232
+ ? renderContentHtml(it.detail.text, clamp(config.wikiWatchMaxChars, 100, 3000, 500))
233
+ : ''
234
+ return '<div class="card">' +
235
+ '<div class="c-head">' + tag + '<span class="c-title">' + escHtml(it.title) + '</span></div>' +
236
+ '<div class="c-meta">' + meta.join('<span class="sep">·</span>') + '</div>' +
237
+ size +
238
+ content +
239
+ '</div>'
240
+ }).join('')
241
+
242
+ return `<!DOCTYPE html>
243
+ <html lang="zh-CN">
244
+ <head>
245
+ <meta charset="UTF-8">
246
+ <style>
247
+ * { margin:0; padding:0; box-sizing:border-box; }
248
+ body { font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Microsoft YaHei',sans-serif; color:#e6edf3;
249
+ background:radial-gradient(1400px 700px at 50% -10%, #1b2a4a 0%, #10182b 45%, #0a0e17 100%); padding:24px 20px; }
250
+ .page { max-width:${width}px; margin:0 auto; }
251
+ .hero { text-align:center; padding:24px 22px 20px; border-radius:18px; margin-bottom:18px;
252
+ background:linear-gradient(135deg, #667eea 0%, #764ba2 55%, #a4508b 100%); box-shadow:0 8px 28px rgba(0,0,0,.4); position:relative; overflow:hidden; }
253
+ .hero::after { content:''; position:absolute; inset:0; background:radial-gradient(600px 200px at 80% -20%, rgba(255,255,255,.18), transparent 60%); }
254
+ .badge { display:inline-flex; font-size:22px; font-weight:700; color:#ffd76d; background:rgba(0,0,0,.28);
255
+ border:1px solid rgba(255,215,109,.4); border-radius:999px; padding:4px 14px; margin-bottom:10px; letter-spacing:1px; }
256
+ .title { font-size:32px; font-weight:800; color:#fff; letter-spacing:2px; text-shadow:0 2px 10px rgba(0,0,0,.35); }
257
+ .sub { font-size:22px; color:rgba(255,255,255,.8); margin-top:8px; letter-spacing:.5px; }
258
+ .card { background:rgba(22,27,34,.8); border:1px solid rgba(255,255,255,.09); border-radius:16px; padding:16px 18px; margin-bottom:14px; }
259
+ .c-head { display:flex; align-items:center; gap:10px; font-size:24px; margin-bottom:8px; }
260
+ .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; }
261
+ .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; }
262
+ .c-title { font-weight:800; color:#fff; letter-spacing:.5px; word-break:break-all; }
263
+ .c-meta { display:flex; align-items:center; flex-wrap:wrap; gap:10px; font-size:21px; color:#79c0ff; margin-bottom:6px; }
264
+ .c-meta .sep { color:rgba(255,255,255,.3); }
265
+ .m-user { color:#ffd76d; font-weight:700; }
266
+ .m-comment { color:#c9d1d9; }
267
+ .m-minor { color:#8b949e; font-size:19px; }
268
+ .size { font-size:20px; color:#8b949e; margin-bottom:6px; }
269
+ .content { background:rgba(0,0,0,.25); border:1px solid rgba(255,255,255,.07); border-radius:10px; padding:10px 12px; }
270
+ .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; }
271
+ .ln.add { color:#3fb950; }
272
+ .ln.del { color:#f85149; }
273
+ .foot { text-align:center; color:#8b949e; font-size:20px; margin-top:12px; opacity:.75; letter-spacing:.5px; }
274
+ </style>
275
+ </head>
276
+ <body>
277
+ <div class="page">
278
+ <div class="hero">
279
+ <div class="badge">🛰️ 第五人格 BWIKI 更新</div>
280
+ <div class="title">${title}</div>
281
+ <div class="sub">数据来源:第五人格 BWIKI · 最近更改</div>
282
+ </div>
283
+ ${cards}
284
+ <div class="foot">数据来源:第五人格 BWIKI</div>
285
+ </div>
286
+ </body>
287
+ </html>`
288
+ }
289
+
290
+ // 通过 puppeteer 把 HTML 渲染为 PNG;无 puppeteer / 渲染失败返回 null(调用方回退文本)
291
+ async function renderWatchHtml(ctx, html, config) {
292
+ if (!ctx.puppeteer) return null
293
+ try {
294
+ const width = config.width || 1300
295
+ const page = await ctx.puppeteer.page()
296
+ try {
297
+ await page.setViewport({ width, height: 400, deviceScaleFactor: config.scale || 2 })
298
+ await page.setContent(html, { waitUntil: 'load' })
299
+ await page.waitForFunction(() => Array.from(document.images).every(i => i.complete), {
300
+ timeout: 15000, polling: 200,
301
+ }).catch(() => {})
302
+ return await page.screenshot({ type: 'png', fullPage: true })
303
+ } finally {
304
+ await page.close().catch(() => {})
305
+ }
306
+ } catch (e) {
307
+ return null
308
+ }
309
+ }
310
+
311
+ // 纯文本回退(无 puppeteer / 渲染失败时使用;不含标题行,标题由 buildWatchOutput 统一给出)
312
+ function buildWatchText(items, maxChars) {
313
+ const blocks = []
314
+ for (const it of items) {
315
+ const b = []
316
+ b.push((it.isNew ? '🆕 ' : '📄 ') + it.title)
317
+ const meta = [it.user, fmtTime(it.time)].filter(Boolean)
318
+ if (it.comment) meta.push('“' + it.comment + '”')
319
+ if (it.minor) meta.push('(小编辑)')
320
+ b.push(' ' + meta.join(' · '))
321
+ if (it.sizeDelta != null) b.push(' 大小:' + (it.sizeDelta >= 0 ? '+' : '') + it.sizeDelta + ' 字节')
322
+ if (it.detail && it.detail.text) {
323
+ let t = String(it.detail.text).replace(/\n{2,}/g, '\n').trim()
324
+ if (t.length > maxChars) t = t.slice(0, maxChars) + '…'
325
+ if (t) b.push(' 更新内容:\n' + indentText(t, ' '))
326
+ }
327
+ blocks.push(b.join('\n'))
328
+ }
329
+ let msg = blocks.join('\n\n')
330
+ if (msg.length > 4000) msg = msg.slice(0, 4000) + '\n…(内容过长已截断)'
331
+ return msg
332
+ }
333
+
334
+ function indentText(text, prefix) {
335
+ return String(text).split('\n').map(l => prefix + l).join('\n')
336
+ }
337
+
338
+ // 汇总输出:优先渲染为图片,失败回退纯文本。返回 koishi 元素数组
339
+ async function buildWatchOutput(ctx, config, items, logger) {
340
+ const summary = '检测到 ' + items.length + ' 个页面变更'
341
+ try {
342
+ const html = buildWatchHtml(items, { ...config, summaryTitle: summary })
343
+ const img = await renderWatchHtml(ctx, html, config)
344
+ if (img) return [h.image(img, 'image/png')]
345
+ } catch (e) {
346
+ logger.warn('BWIKI更新图片渲染失败,回退纯文本: ' + (e && e.message ? e.message : e))
347
+ }
348
+ return ['【第五人格 BWIKI 更新】' + summary, '\n' + buildWatchText(items, clamp(config.wikiWatchMaxChars, 100, 3000, 500))]
349
+ }
350
+
351
+ // 汇总推送目标列表(wikiWatchTargets:多目标,群/频道与私聊可混用;与公告推送一致)
352
+ function buildWatchTargets(config) {
353
+ const list = Array.isArray(config.wikiWatchTargets) ? config.wikiWatchTargets : []
354
+ const targets = []
355
+ const seen = new Set()
356
+ for (const t of list) {
357
+ const id = String(t && t.id ? t.id : '').trim()
358
+ if (!id) continue
359
+ const type = t.type === 'private' ? 'private' : 'channel'
360
+ const key = type + ':' + id
361
+ if (seen.has(key)) continue
362
+ seen.add(key)
363
+ targets.push({ type, id })
364
+ }
365
+ return targets
366
+ }
367
+
368
+ // 推送到全部目标,任一目标成功即视为成功(其余目标失败仅记录日志)。content 为元素数组或字符串。
369
+ async function sendWatchMessage(ctx, config, content, logger) {
370
+ const targets = buildWatchTargets(config)
371
+ if (!config.wikiWatchPlatform || !targets.length) return false
372
+ const bot = ctx.bots && ctx.bots.find(b => b.platform === config.wikiWatchPlatform && b.status === 1)
373
+ if (!bot) return false
374
+ let ok = false
375
+ for (const t of targets) {
376
+ try {
377
+ if (t.type === 'private') {
378
+ await bot.sendPrivateMessage(t.id, content)
379
+ } else {
380
+ await bot.sendMessage(t.id, content)
381
+ }
382
+ ok = true
383
+ } catch (e) {
384
+ logger.warn('BWIKI更新推送失败(' + t.type + ':' + t.id + '): ' + (e && e.message ? e.message : e))
385
+ }
386
+ }
387
+ return ok
388
+ }
389
+
390
+ // 启动定时监听。返回 { state, check },供「BWIKI更新监控」指令查看状态/手动检查。
391
+ function startWikiWatchMonitor(ctx, config) {
392
+ const logger = ctx.logger('dwrgbwiki')
393
+ const state = {
394
+ lastRcid: null, // 已处理的最新变更 id(内存 + 数据库持久化)
395
+ loaded: false,
396
+ running: false,
397
+ lastCheck: null, // 上次检查时间
398
+ lastError: null, // 上次错误信息
399
+ }
400
+
401
+ const loadState = async () => {
402
+ if (state.loaded) return
403
+ state.loaded = true
404
+ if (!ctx.database) return
405
+ try {
406
+ const rows = await ctx.database.get('wiki_data', { id: '_watch_state' })
407
+ if (rows && rows.length && rows[0].data) {
408
+ const d = JSON.parse(rows[0].data)
409
+ if (d && typeof d.rcid === 'number') state.lastRcid = d.rcid
410
+ }
411
+ } catch (e) { /* 数据库不可用时退化为内存状态 */ }
412
+ }
413
+
414
+ const saveState = async () => {
415
+ if (!ctx.database) return
416
+ try {
417
+ await ctx.database.upsert('wiki_data', [{
418
+ id: '_watch_state',
419
+ data: JSON.stringify({ rcid: state.lastRcid, time: new Date().toISOString() }),
420
+ }])
421
+ } catch (e) { /* 忽略 */ }
422
+ }
423
+
424
+ // 执行一次检查。返回 { pushed, first, sent, message } 或 { error }
425
+ const check = async () => {
426
+ if (state.running) return { error: '正在检查中,请稍后再试' }
427
+ state.running = true
428
+ try {
429
+ await loadState()
430
+ state.lastCheck = fmtTime(new Date().toISOString())
431
+ state.lastError = null
432
+ const changes = await fetchRecentChanges(config)
433
+ if (!changes.length) return { pushed: 0, first: state.lastRcid == null, message: null }
434
+
435
+ const firstRun = state.lastRcid == null
436
+ const fresh = [] // 相对上次位置新增的变更(倒序)
437
+ let newest = null
438
+ for (const c of changes) {
439
+ const rcid = Number(rcField(c, 'rcid', 'rc_rcid')) || 0
440
+ if (!rcid) continue
441
+ if (newest === null || rcid > newest) newest = rcid
442
+ if (firstRun) break // 首次只记录最新位置,不推送
443
+ if (rcid <= state.lastRcid) break // 记录按时间倒序,遇到已处理的即停止
444
+ fresh.push(c)
445
+ }
446
+ if (newest !== null && (state.lastRcid === null || newest > state.lastRcid)) {
447
+ state.lastRcid = newest
448
+ await saveState()
449
+ }
450
+ if (firstRun || !fresh.length) return { pushed: 0, first: firstRun, message: null }
451
+
452
+ fresh.reverse() // 转为时间正序(旧 → 新)
453
+
454
+ const items = []
455
+ for (let i = 0; i < fresh.length; i++) {
456
+ const c = fresh[i]
457
+ const nl = Number(rcField(c, 'newlen', 'rc_new_len'))
458
+ const ol = Number(rcField(c, 'oldlen', 'rc_old_len'))
459
+ const item = {
460
+ title: c.title || '',
461
+ user: rcField(c, 'user', 'rc_user') || '',
462
+ time: rcField(c, 'timestamp', 'rc_timestamp') || '',
463
+ comment: rcField(c, 'comment', 'rc_comment') || '',
464
+ minor: !!(rcField(c, 'minor', 'rc_minor')),
465
+ isNew: c.type === 'new' || !!c.new,
466
+ sizeDelta: !isNaN(nl) && !isNaN(ol) && nl !== undefined && ol !== undefined ? nl - ol : null,
467
+ }
468
+ try {
469
+ item.detail = await fetchDiffDetail(config, c)
470
+ } catch (e) {
471
+ logger.warn('BWIKI更新详情获取失败(' + item.title + '): ' + (e && e.message ? e.message : e))
472
+ }
473
+ items.push(item)
474
+ }
475
+
476
+ // 解析编辑者昵称:bilibili 数字 uid → 真实用户名
477
+ for (const it of items) {
478
+ if (it.user) it.user = await resolveUserName(logger, it.user)
479
+ }
480
+
481
+ const message = await buildWatchOutput(ctx, config, items, logger)
482
+ const sent = await sendWatchMessage(ctx, config, message, logger)
483
+ return { pushed: items.length, first: false, sent, message }
484
+ } catch (e) {
485
+ state.lastError = (e && e.message ? e.message : String(e))
486
+ logger.warn('BWIKI更新检查失败: ' + state.lastError)
487
+ return { error: state.lastError }
488
+ } finally {
489
+ state.running = false
490
+ }
491
+ }
492
+
493
+ // 启动延迟 5 秒(等数据库就绪),之后按 wikiWatchInterval 周期轮询
494
+ setTimeout(() => { check().catch(() => {}) }, 5000)
495
+ const interval = Math.max(1, config.wikiWatchInterval || 10) * 60 * 1000
496
+ const timer = setInterval(() => { check().catch(() => {}) }, interval)
497
+ ctx.on('dispose', () => clearInterval(timer))
498
+
499
+ if (!buildWatchTargets(config).length) {
500
+ logger.info('BWIKI更新监控已启用,但未配置推送目标(wikiWatchTargets),将仅记录更新位置不推送')
501
+ }
502
+
503
+ return { state, check }
504
+ }
505
+
506
+ module.exports = {
507
+ startWikiWatchMonitor,
508
+ buildWatchTargets,
509
+ fetchRecentChanges,
510
+ fetchDiffDetail,
511
+ buildWatchHtml,
512
+ buildWatchText,
513
+ resolveUserName,
514
+ }