koishi-plugin-dwrgbwiki 2.3.0 → 2.4.1
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 +7 -12
- package/match.js +7 -0
- package/package.json +1 -1
- package/readme.md +6 -8
- package/wikiwatch.js +304 -48
package/index.js
CHANGED
|
@@ -235,20 +235,11 @@ module.exports = {
|
|
|
235
235
|
.description('BWIKI 更新检查间隔(分钟)')
|
|
236
236
|
.default(10),
|
|
237
237
|
wikiWatchLimit: Schema.number().min(1).max(100)
|
|
238
|
-
.description('
|
|
238
|
+
.description('每次拉取的最近变更条数(同时决定每次推送最多列出的页面变更数)')
|
|
239
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
240
|
wikiWatchMaxChars: Schema.number().min(100).max(3000)
|
|
247
241
|
.description('单页更新内容摘要最大字符数')
|
|
248
242
|
.default(500),
|
|
249
|
-
wikiWatchSkipBots: Schema.boolean()
|
|
250
|
-
.description('过滤机器人编辑(rcshow=!bot)')
|
|
251
|
-
.default(true),
|
|
252
243
|
wikiWatchNamespaces: Schema.array(Schema.number())
|
|
253
244
|
.description('监听的命名空间 id 列表(主命名空间=0;留空仅监听主命名空间)')
|
|
254
245
|
.default([0]),
|
|
@@ -407,7 +398,9 @@ module.exports = {
|
|
|
407
398
|
if (r.error) return '检查失败:' + r.error
|
|
408
399
|
if (r.first) return '首次检查:已记录当前更新位置(本次不推送),之后的页面更新会自动推送到配置的目标'
|
|
409
400
|
if (r.pushed) {
|
|
410
|
-
|
|
401
|
+
const msg = Array.isArray(r.message) ? r.message.slice() : [String(r.message || '检查完成')]
|
|
402
|
+
if (r.sent === false) msg.push('\n(未推送:未配置目标或对应平台机器人不可用,以下仅展示)')
|
|
403
|
+
return msg
|
|
411
404
|
}
|
|
412
405
|
return '检查完成:没有新的页面变更'
|
|
413
406
|
}
|
|
@@ -532,10 +525,11 @@ module.exports = {
|
|
|
532
525
|
if (subFn === '选手') {
|
|
533
526
|
if (!p1 || !String(p1).trim()) return '请提供选手 id,格式:赛事 选手 <选手id>'
|
|
534
527
|
const target = String(p1).trim()
|
|
528
|
+
const notFoundText = '当前赛事数据系统未收录【' + target + '】相关数据,请检查选手id是否输入正确。目前仅支持使用选手英文id进行查询。'
|
|
535
529
|
try {
|
|
536
530
|
const result = await match.loadPlayerData(config, target, mode)
|
|
537
531
|
if (!result.data || (!result.data.hunter && !result.data.survivor)) {
|
|
538
|
-
return
|
|
532
|
+
return notFoundText
|
|
539
533
|
}
|
|
540
534
|
const width = cmdWidth(config, 'matchWidth')
|
|
541
535
|
const updateTime = await match.loadAnnounceTime(config)
|
|
@@ -544,6 +538,7 @@ module.exports = {
|
|
|
544
538
|
return updateTime ? [h.image(image, 'image/png'), '本页面数据上次更新时间:' + updateTime] : [h.image(image, 'image/png')]
|
|
545
539
|
} catch (e) {
|
|
546
540
|
ctx.logger('dwrgbwiki').warn('赛事查询失败: ' + (e && e.message ? e.message : e))
|
|
541
|
+
if (e && e.code === 'PLAYER_NOT_FOUND') return notFoundText
|
|
547
542
|
const msg = (e && e.message || '').includes('404')
|
|
548
543
|
? '未找到选手「' + target + '」的数据,请检查 id 是否正确(大小写、别名)'
|
|
549
544
|
: '赛事查询失败:' + (e && e.message ? e.message : e)
|
package/match.js
CHANGED
|
@@ -76,6 +76,13 @@ async function loadPlayerData(config, playerId, mergeMode) {
|
|
|
76
76
|
if (key.toLowerCase() === lower) { mainId = key; break }
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
|
+
// 选手未收录:index 中不存在该 id(大小写不敏感)时直接抛专用错误,
|
|
80
|
+
// 避免对不存在的文件发起请求而报出「Unexpected token '<'」之类的晦涩 JSON 解析错误
|
|
81
|
+
if (!index[mainId]) {
|
|
82
|
+
const err = new Error('PLAYER_NOT_FOUND:' + String(playerId))
|
|
83
|
+
err.code = 'PLAYER_NOT_FOUND'
|
|
84
|
+
throw err
|
|
85
|
+
}
|
|
79
86
|
// 收集候选文件:精确匹配 + 所有大小写变体
|
|
80
87
|
const candidates = new Set()
|
|
81
88
|
if (index[mainId]) candidates.add(index[mainId].file)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "koishi-plugin-dwrgbwiki",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.1",
|
|
4
4
|
"description": "第五人格BWIKI编辑组开发,所有代码由DeepSeek V4 Flash 0731编写。支持胜率/地图/模仿者/勋章/基础数据查询、图鉴查询、wiki页面截图、藏头诗、抽卡模拟、加页手记探索、赛事数据(选手/对决)、公告变动推送、BWIKI页面更新推送、反馈系统等功能。",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"files": [
|
package/readme.md
CHANGED
|
@@ -309,11 +309,8 @@ BWIKI更新监控 [检查]
|
|
|
309
309
|
| `wikiWatchPlatform` | BWIKI 更新推送的平台(如 `qq`),留空禁用推送 | `qq` |
|
|
310
310
|
| `wikiWatchTargets` | BWIKI 更新推送目标列表(与 `noticeTargets` 同构:每行一个目标,类型 channel=群/频道 或 private=私聊、目标 id),可添加多个 | 空 |
|
|
311
311
|
| `wikiWatchInterval` | BWIKI 更新检查间隔(分钟) | `10` |
|
|
312
|
-
| `wikiWatchLimit` |
|
|
313
|
-
| `wikiWatchDetailLimit` | 每次推送中获取详细更新内容(diff/新页面内容)的最大页面数,其余只推送标题与编辑摘要 | `5` |
|
|
314
|
-
| `wikiWatchMaxItems` | 每次推送最多列出的页面变更数(超出部分仅提示数量) | `15` |
|
|
312
|
+
| `wikiWatchLimit` | 每次拉取的最近变更条数(同时决定每次推送最多列出的页面变更数) | `50` |
|
|
315
313
|
| `wikiWatchMaxChars` | 单页更新内容摘要最大字符数 | `500` |
|
|
316
|
-
| `wikiWatchSkipBots` | 过滤机器人编辑(`rcshow=!bot`) | `true` |
|
|
317
314
|
| `wikiWatchNamespaces` | 监听的命名空间 id 列表(主命名空间=0;留空仅监听主命名空间) | `[0]` |
|
|
318
315
|
|
|
319
316
|
数据源两个镜像:`https://website.homeworkkun.top` 与 `https://web.homeworkkun.top`,可直接在配置中切换。
|
|
@@ -343,15 +340,16 @@ BWIKI更新监控 [检查]
|
|
|
343
340
|
|
|
344
341
|
启用 `wikiWatchEnable` 后,插件会定时(`wikiWatchInterval` 分钟)调用第五人格 BWIKI 的 MediaWiki API(`https://wiki.biligame.com/dwrg/api.php`):
|
|
345
342
|
|
|
346
|
-
1. `action=query&list=recentchanges` 获取最近更新的页面(默认只监听主命名空间,可配置 `wikiWatchNamespaces
|
|
343
|
+
1. `action=query&list=recentchanges` 获取最近更新的页面(默认只监听主命名空间,可配置 `wikiWatchNamespaces`;**不过滤机器人编辑**)
|
|
347
344
|
2. 对每个新变更调用 `action=compare` 拉取该次编辑的差异(纯文本 unified diff)作为「最后更新内容」;新创建的页面则拉取最新内容前段
|
|
345
|
+
3. 编辑者的 bilibili 数字 uid 通过 bilibili 空间接口解析为真实用户名(如 `2086555` → 摸鱼达人作业君),解析失败时回退显示 uid
|
|
348
346
|
|
|
349
|
-
|
|
347
|
+
汇总后渲染为一张图片(每轮最多 `wikiWatchLimit` 个页面,全部附更新内容),通过 `wikiWatchPlatform` 平台的机器人推送到 `wikiWatchTargets` 列表中的全部目标(每行一个目标,可混用 `channel` 群/频道与 `private` 私聊)。图片内容为:页面标题(新页面标 🆕)、编辑者昵称、时间、编辑摘要、大小变化、更新内容(diff 的 `+` 行绿、`-` 行红);不包含页面链接。
|
|
350
348
|
|
|
351
349
|
- 首次启动只记录基线(当前最新变更位置),不推送;之后检测到新变更才推送
|
|
352
350
|
- 已处理的最新变更 id 持久化到数据库(`wiki_data` 表),机器人重启后从上次位置继续,不会重复推送
|
|
353
|
-
-
|
|
354
|
-
- 任何成员可发送 `BWIKI更新监控` 查看监控状态,发送 `BWIKI更新监控 检查`
|
|
351
|
+
- 渲染失败或没有 puppeteer 时自动回退为纯文本推送
|
|
352
|
+
- 任何成员可发送 `BWIKI更新监控` 查看监控状态,发送 `BWIKI更新监控 检查` 立即执行一次检查(返回图片,首次检查只记录基线)
|
|
355
353
|
- 请求带浏览器 User-Agent,遇到 BWIKI 限流(HTTP 567/429/503)自动重试,检查失败只记录日志,不影响下次轮询
|
|
356
354
|
|
|
357
355
|
## 图片内容
|
package/wikiwatch.js
CHANGED
|
@@ -2,14 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
// ===== 第五人格 BWIKI 页面更新监听 =====
|
|
4
4
|
// 定时调用 MediaWiki API(wiki.biligame.com/dwrg/api.php):
|
|
5
|
-
// 1. list=recentchanges
|
|
5
|
+
// 1. list=recentchanges 获取最近更新的页面(默认主命名空间,不过滤机器人编辑)
|
|
6
6
|
// 2. 对每个新变更 action=compare 拉取该次编辑的差异(difftype=unified 纯文本 diff);
|
|
7
7
|
// 新页面则拉取最新版本内容的前段
|
|
8
|
-
//
|
|
9
|
-
//
|
|
8
|
+
// 3. 编辑者的 bilibili 数字 uid 通过 bilibili 空间接口解析为真实用户名
|
|
9
|
+
// 汇总后渲染为一张图片推送到配置的目标群聊/私聊列表(wikiWatchTargets)。
|
|
10
10
|
// 首次启动只记录基线不推送;已处理的最新 rcid 持久化到数据库(wiki_data 表),
|
|
11
11
|
// 重启后从上次位置继续,不会重复推送。
|
|
12
12
|
|
|
13
|
+
const { h } = require('koishi')
|
|
14
|
+
const crypto = require('crypto')
|
|
13
15
|
const API_BASE = 'https://wiki.biligame.com/dwrg'
|
|
14
16
|
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
17
|
const RETRY_WAIT = [3000, 8000] // BWIKI 限流/安全策略(567/429/503)时的重试间隔
|
|
@@ -55,7 +57,7 @@ function buildNamespaceParam(config) {
|
|
|
55
57
|
return encodeURIComponent(list.join('|'))
|
|
56
58
|
}
|
|
57
59
|
|
|
58
|
-
// 拉取最近变更(返回 recentchanges
|
|
60
|
+
// 拉取最近变更(返回 recentchanges 记录数组,按时间倒序;不过滤机器人编辑)
|
|
59
61
|
async function fetchRecentChanges(config) {
|
|
60
62
|
const timeout = Math.max(config.timeout || 15000, 20000)
|
|
61
63
|
const limit = clamp(config.wikiWatchLimit, 1, 100, 50)
|
|
@@ -64,7 +66,6 @@ async function fetchRecentChanges(config) {
|
|
|
64
66
|
'&rclimit=' + limit +
|
|
65
67
|
'&rcprop=' + encodeURIComponent('title|ids|timestamp|user|comment|flags|sizes') +
|
|
66
68
|
'&rctype=' + encodeURIComponent('edit|new') +
|
|
67
|
-
(config.wikiWatchSkipBots === false ? '' : '&rcshow=!bot') +
|
|
68
69
|
'&format=json&formatversion=2'
|
|
69
70
|
const data = await fetchJson(url, timeout)
|
|
70
71
|
return (data.query && data.query.recentchanges) || []
|
|
@@ -136,40 +137,296 @@ function fmtTime(ts) {
|
|
|
136
137
|
}
|
|
137
138
|
}
|
|
138
139
|
|
|
139
|
-
|
|
140
|
-
|
|
140
|
+
// ===== bilibili uid → 用户名解析 =====
|
|
141
|
+
// BWIKI 账号绑定 bilibili,MediaWiki 返回的 user 是数字 uid。通过 bilibili 多个来源
|
|
142
|
+
// 解析真实用户名:
|
|
143
|
+
// 1. WBI 签名后的 x/space/wbi/acc/info(官方推荐,需动态取 wbi 密钥)
|
|
144
|
+
// 2. 未签名 x/space/acc/info、x/web-interface/card(部分环境仍可用)
|
|
145
|
+
// 3. 空间页 <title>(服务端渲染,含用户名)
|
|
146
|
+
// 全部失败时回退显示 uid。
|
|
147
|
+
// 成功结果永久缓存;失败结果缓存 10 分钟后自动重试(避免首轮风控导致一直显示 uid)。
|
|
148
|
+
// 每个 uid 首次失败会打一条 warn 日志,便于排查具体原因。
|
|
149
|
+
|
|
150
|
+
const NAME_CACHE = new Map()
|
|
151
|
+
|
|
152
|
+
// wbi 签名混盐索引表(bilibili 官方 hash 算法,公开常量)
|
|
153
|
+
const MIXIN_KEY_ENC_TAB = [46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49, 33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40, 61, 26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11, 36, 20, 34, 44, 52]
|
|
154
|
+
|
|
155
|
+
const NEG_CACHE_TTL = 10 * 60 * 1000 // 失败缓存有效期 10 分钟
|
|
156
|
+
const BILI_TIMEOUT = 6000
|
|
157
|
+
|
|
158
|
+
let biliMixinKey = null
|
|
159
|
+
let biliMixinTs = 0
|
|
160
|
+
|
|
161
|
+
async function fetchBiliJson(url, timeout) {
|
|
162
|
+
const ctrl = new AbortController()
|
|
163
|
+
const timer = setTimeout(() => ctrl.abort(), timeout)
|
|
164
|
+
try {
|
|
165
|
+
const headers = {
|
|
166
|
+
'User-Agent': UA,
|
|
167
|
+
'Accept-Language': 'zh-CN,zh;q=0.9',
|
|
168
|
+
'Accept': 'application/json, text/plain, */*',
|
|
169
|
+
'Referer': 'https://space.bilibili.com/',
|
|
170
|
+
}
|
|
171
|
+
const res = await fetch(url, { signal: ctrl.signal, headers })
|
|
172
|
+
if (!res.ok) return { __status: res.status }
|
|
173
|
+
return await res.json()
|
|
174
|
+
} catch (e) {
|
|
175
|
+
return { __error: e && e.message ? e.message : String(e) }
|
|
176
|
+
} finally {
|
|
177
|
+
clearTimeout(timer)
|
|
178
|
+
}
|
|
141
179
|
}
|
|
142
180
|
|
|
143
|
-
//
|
|
144
|
-
function
|
|
145
|
-
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
181
|
+
// 动态获取 wbi 混盐密钥(48 位原始 key → 32 位 mixin key),缓存 4 小时
|
|
182
|
+
async function ensureBiliMixinKey() {
|
|
183
|
+
if (biliMixinKey && Date.now() - biliMixinTs < 4 * 60 * 60 * 1000) return biliMixinKey
|
|
184
|
+
const data = await fetchBiliJson('https://api.bilibili.com/x/web-interface/nav', BILI_TIMEOUT)
|
|
185
|
+
const img = data && data.data && data.data.wbi_img && data.data.wbi_img.img_url
|
|
186
|
+
const sub = data && data.data && data.data.wbi_img && data.data.wbi_img.sub_url
|
|
187
|
+
if (!img || !sub) return null
|
|
188
|
+
const raw = (String(img).split('/').pop() + String(sub).split('/').pop()).replace(/\.\w+$/, '')
|
|
189
|
+
if (raw.length < 64) return null
|
|
190
|
+
let mixin = ''
|
|
191
|
+
for (const i of MIXIN_KEY_ENC_TAB) mixin += raw[i]
|
|
192
|
+
biliMixinKey = mixin.slice(0, 32)
|
|
193
|
+
biliMixinTs = Date.now()
|
|
194
|
+
return biliMixinKey
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// 生成 wbi 签名后的完整接口 URL
|
|
198
|
+
function wbiSignUrl(endpoint, params, mixinKey) {
|
|
199
|
+
const p = Object.assign({}, params)
|
|
200
|
+
p.wts = Math.round(Date.now() / 1000)
|
|
201
|
+
const query = Object.keys(p).sort().map(k => k + '=' + encodeURIComponent(p[k])).join('&')
|
|
202
|
+
const wRid = crypto.createHash('md5').update(query + mixinKey).digest('hex')
|
|
203
|
+
return endpoint + '?' + query + '&w_rid=' + wRid
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// 从 bilibili 返回体提取用户名:acc/info 是 data.name,card 是 data.card.name
|
|
207
|
+
function extractBiliName(d) {
|
|
208
|
+
if (!d || d.code !== 0 || !d.data) return null
|
|
209
|
+
if (d.data.name && String(d.data.name).trim()) return String(d.data.name).trim()
|
|
210
|
+
if (d.data.card && d.data.card.name && String(d.data.card.name).trim()) return String(d.data.card.name).trim()
|
|
211
|
+
return null
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// 尝试解析 uid 对应的用户名;失败返回 null
|
|
215
|
+
async function tryResolveName(uid) {
|
|
216
|
+
// 1) WBI 签名接口(最可靠)
|
|
217
|
+
const mixinKey = await ensureBiliMixinKey()
|
|
218
|
+
if (mixinKey) {
|
|
219
|
+
const url = wbiSignUrl('https://api.bilibili.com/x/space/wbi/acc/info', { mid: uid }, mixinKey)
|
|
220
|
+
const d = await fetchBiliJson(url, BILI_TIMEOUT)
|
|
221
|
+
const n = extractBiliName(d)
|
|
222
|
+
if (n) return n
|
|
223
|
+
}
|
|
224
|
+
// 2) 未签名接口(部分环境仍可用,card 结构为 data.card.name)
|
|
225
|
+
for (const ep of [
|
|
226
|
+
'https://api.bilibili.com/x/space/acc/info?mid=' + uid,
|
|
227
|
+
'https://api.bilibili.com/x/web-interface/card?mid=' + uid,
|
|
228
|
+
]) {
|
|
229
|
+
const d = await fetchBiliJson(ep, BILI_TIMEOUT)
|
|
230
|
+
const n = extractBiliName(d)
|
|
231
|
+
if (n) return n
|
|
232
|
+
}
|
|
233
|
+
// 3) 空间页 <title>(服务端渲染,含用户名)
|
|
234
|
+
const ctrl = new AbortController()
|
|
235
|
+
const timer = setTimeout(() => ctrl.abort(), BILI_TIMEOUT)
|
|
236
|
+
try {
|
|
237
|
+
const res = await fetch('https://space.bilibili.com/' + uid, {
|
|
238
|
+
signal: ctrl.signal,
|
|
239
|
+
headers: { 'User-Agent': UA, 'Accept-Language': 'zh-CN,zh;q=0.9' },
|
|
240
|
+
})
|
|
241
|
+
if (res.ok) {
|
|
242
|
+
const t = await res.text()
|
|
243
|
+
const m = t.match(/<title>([^<]+)<\/title>/)
|
|
244
|
+
if (m && m[1]) {
|
|
245
|
+
let s = String(m[1]).replace(/[_\- ]\s*哔哩哔哩.*$/i, '').replace(/\s*[-_]\s*bilibili.*$/i, '').trim()
|
|
246
|
+
// 过滤占位/错误标题,避免把无意义文本当用户名
|
|
247
|
+
if (s && !/^[\d\s]+$/.test(s) && !/^(加载中|请稍候|没有找到|错误|验证|访问|403|404|412|哔哩哔哩)/.test(s)) return s
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
} catch (e) { /* 忽略 */ }
|
|
251
|
+
finally { clearTimeout(timer) }
|
|
252
|
+
return null
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function resolveUserName(logger, uid) {
|
|
256
|
+
const key = String(uid == null ? '' : uid).trim()
|
|
257
|
+
if (!/^\d+$/.test(key)) return key || '' // 非数字 uid(普通用户名)原样返回
|
|
258
|
+
const cached = NAME_CACHE.get(key)
|
|
259
|
+
if (cached) {
|
|
260
|
+
if (cached.name) return cached.name // 已解析成功
|
|
261
|
+
if (Date.now() - cached.ts < NEG_CACHE_TTL) return key // 失败缓存期内
|
|
262
|
+
}
|
|
263
|
+
const found = await tryResolveName(key)
|
|
264
|
+
if (found) {
|
|
265
|
+
NAME_CACHE.set(key, { name: found, ts: Date.now() })
|
|
266
|
+
return found
|
|
267
|
+
}
|
|
268
|
+
// 解析失败:短时缓存(10 分钟)以便自动重试;每个 uid 每进程只打一次日志
|
|
269
|
+
NAME_CACHE.set(key, { name: null, ts: Date.now(), logged: true })
|
|
270
|
+
if (!cached || !cached.logged) {
|
|
271
|
+
logger.warn('BWIKI更新昵称解析失败(显示 uid),请检查 bilibili 接口可达性: ' + key)
|
|
272
|
+
}
|
|
273
|
+
return key
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ===== 文本渲染为图片 =====
|
|
277
|
+
|
|
278
|
+
function escHtml(s) {
|
|
279
|
+
return String(s == null ? '' : s)
|
|
280
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
281
|
+
.replace(/"/g, '"').replace(/'/g, ''')
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// 更新内容块:diff 的 + 行标绿、- 行标红;普通行中性色
|
|
285
|
+
function renderContentHtml(text, maxChars) {
|
|
286
|
+
let t = String(text || '').replace(/\n{2,}/g, '\n').trim()
|
|
287
|
+
if (t.length > maxChars) t = t.slice(0, maxChars) + '…'
|
|
288
|
+
const lines = t.split('\n').map(l => {
|
|
289
|
+
const esc = escHtml(l)
|
|
290
|
+
if (l.charAt(0) === '+') return '<div class="ln add">' + esc + '</div>'
|
|
291
|
+
if (l.charAt(0) === '-') return '<div class="ln del">' + esc + '</div>'
|
|
292
|
+
return '<div class="ln">' + esc + '</div>'
|
|
293
|
+
}).join('')
|
|
294
|
+
return '<div class="content">' + lines + '</div>'
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function buildWatchHtml(items, config) {
|
|
298
|
+
const width = config.width || 1300
|
|
299
|
+
const title = escHtml(config.summaryTitle || ('检测到 ' + items.length + ' 个页面变更'))
|
|
300
|
+
const cards = items.map(it => {
|
|
301
|
+
const tag = it.isNew ? '<span class="tag-new">🆕 新页面</span>' : '<span class="tag-edit">✏️ 编辑</span>'
|
|
151
302
|
const meta = []
|
|
152
|
-
if (it.user) meta.push(it.user)
|
|
153
|
-
meta.push(fmtTime(it.time))
|
|
303
|
+
if (it.user) meta.push('<span class="m-user">👤 ' + escHtml(it.user) + '</span>')
|
|
304
|
+
meta.push('<span>🕒 ' + escHtml(fmtTime(it.time)) + '</span>')
|
|
305
|
+
if (it.comment) meta.push('<span class="m-comment">💬 “' + escHtml(it.comment) + '”</span>')
|
|
306
|
+
if (it.minor) meta.push('<span class="m-minor">(小编辑)</span>')
|
|
307
|
+
const size = it.sizeDelta != null
|
|
308
|
+
? '<div class="size">大小:' + (it.sizeDelta >= 0 ? '+' : '') + it.sizeDelta + ' 字节</div>'
|
|
309
|
+
: ''
|
|
310
|
+
const content = it.detail && it.detail.text
|
|
311
|
+
? renderContentHtml(it.detail.text, clamp(config.wikiWatchMaxChars, 100, 3000, 500))
|
|
312
|
+
: ''
|
|
313
|
+
return '<div class="card">' +
|
|
314
|
+
'<div class="c-head">' + tag + '<span class="c-title">' + escHtml(it.title) + '</span></div>' +
|
|
315
|
+
'<div class="c-meta">' + meta.join('<span class="sep">·</span>') + '</div>' +
|
|
316
|
+
size +
|
|
317
|
+
content +
|
|
318
|
+
'</div>'
|
|
319
|
+
}).join('')
|
|
320
|
+
|
|
321
|
+
return `<!DOCTYPE html>
|
|
322
|
+
<html lang="zh-CN">
|
|
323
|
+
<head>
|
|
324
|
+
<meta charset="UTF-8">
|
|
325
|
+
<style>
|
|
326
|
+
* { margin:0; padding:0; box-sizing:border-box; }
|
|
327
|
+
body { font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Microsoft YaHei',sans-serif; color:#e6edf3;
|
|
328
|
+
background:radial-gradient(1400px 700px at 50% -10%, #1b2a4a 0%, #10182b 45%, #0a0e17 100%); padding:24px 20px; }
|
|
329
|
+
.page { max-width:${width}px; margin:0 auto; }
|
|
330
|
+
.hero { text-align:center; padding:24px 22px 20px; border-radius:18px; margin-bottom:18px;
|
|
331
|
+
background:linear-gradient(135deg, #667eea 0%, #764ba2 55%, #a4508b 100%); box-shadow:0 8px 28px rgba(0,0,0,.4); position:relative; overflow:hidden; }
|
|
332
|
+
.hero::after { content:''; position:absolute; inset:0; background:radial-gradient(600px 200px at 80% -20%, rgba(255,255,255,.18), transparent 60%); }
|
|
333
|
+
.badge { display:inline-flex; font-size:22px; font-weight:700; color:#ffd76d; background:rgba(0,0,0,.28);
|
|
334
|
+
border:1px solid rgba(255,215,109,.4); border-radius:999px; padding:4px 14px; margin-bottom:10px; letter-spacing:1px; }
|
|
335
|
+
.title { font-size:32px; font-weight:800; color:#fff; letter-spacing:2px; text-shadow:0 2px 10px rgba(0,0,0,.35); }
|
|
336
|
+
.sub { font-size:22px; color:rgba(255,255,255,.8); margin-top:8px; letter-spacing:.5px; }
|
|
337
|
+
.card { background:rgba(22,27,34,.8); border:1px solid rgba(255,255,255,.09); border-radius:16px; padding:16px 18px; margin-bottom:14px; }
|
|
338
|
+
.c-head { display:flex; align-items:center; gap:10px; font-size:24px; margin-bottom:8px; }
|
|
339
|
+
.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; }
|
|
340
|
+
.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; }
|
|
341
|
+
.c-title { font-weight:800; color:#fff; letter-spacing:.5px; word-break:break-all; }
|
|
342
|
+
.c-meta { display:flex; align-items:center; flex-wrap:wrap; gap:10px; font-size:21px; color:#79c0ff; margin-bottom:6px; }
|
|
343
|
+
.c-meta .sep { color:rgba(255,255,255,.3); }
|
|
344
|
+
.m-user { color:#ffd76d; font-weight:700; }
|
|
345
|
+
.m-comment { color:#c9d1d9; }
|
|
346
|
+
.m-minor { color:#8b949e; font-size:19px; }
|
|
347
|
+
.size { font-size:20px; color:#8b949e; margin-bottom:6px; }
|
|
348
|
+
.content { background:rgba(0,0,0,.25); border:1px solid rgba(255,255,255,.07); border-radius:10px; padding:10px 12px; }
|
|
349
|
+
.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; }
|
|
350
|
+
.ln.add { color:#3fb950; }
|
|
351
|
+
.ln.del { color:#f85149; }
|
|
352
|
+
.foot { text-align:center; color:#8b949e; font-size:20px; margin-top:12px; opacity:.75; letter-spacing:.5px; }
|
|
353
|
+
</style>
|
|
354
|
+
</head>
|
|
355
|
+
<body>
|
|
356
|
+
<div class="page">
|
|
357
|
+
<div class="hero">
|
|
358
|
+
<div class="badge">🛰️ 第五人格 BWIKI 更新</div>
|
|
359
|
+
<div class="title">${title}</div>
|
|
360
|
+
<div class="sub">数据来源:第五人格 BWIKI · 最近更改</div>
|
|
361
|
+
</div>
|
|
362
|
+
${cards}
|
|
363
|
+
<div class="foot">数据来源:第五人格 BWIKI</div>
|
|
364
|
+
</div>
|
|
365
|
+
</body>
|
|
366
|
+
</html>`
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// 通过 puppeteer 把 HTML 渲染为 PNG;无 puppeteer / 渲染失败返回 null(调用方回退文本)
|
|
370
|
+
async function renderWatchHtml(ctx, html, config) {
|
|
371
|
+
if (!ctx.puppeteer) return null
|
|
372
|
+
try {
|
|
373
|
+
const width = config.width || 1300
|
|
374
|
+
const page = await ctx.puppeteer.page()
|
|
375
|
+
try {
|
|
376
|
+
await page.setViewport({ width, height: 400, deviceScaleFactor: config.scale || 2 })
|
|
377
|
+
await page.setContent(html, { waitUntil: 'load' })
|
|
378
|
+
await page.waitForFunction(() => Array.from(document.images).every(i => i.complete), {
|
|
379
|
+
timeout: 15000, polling: 200,
|
|
380
|
+
}).catch(() => {})
|
|
381
|
+
return await page.screenshot({ type: 'png', fullPage: true })
|
|
382
|
+
} finally {
|
|
383
|
+
await page.close().catch(() => {})
|
|
384
|
+
}
|
|
385
|
+
} catch (e) {
|
|
386
|
+
return null
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// 纯文本回退(无 puppeteer / 渲染失败时使用;不含标题行,标题由 buildWatchOutput 统一给出)
|
|
391
|
+
function buildWatchText(items, maxChars) {
|
|
392
|
+
const blocks = []
|
|
393
|
+
for (const it of items) {
|
|
394
|
+
const b = []
|
|
395
|
+
b.push((it.isNew ? '🆕 ' : '📄 ') + it.title)
|
|
396
|
+
const meta = [it.user, fmtTime(it.time)].filter(Boolean)
|
|
154
397
|
if (it.comment) meta.push('“' + it.comment + '”')
|
|
155
398
|
if (it.minor) meta.push('(小编辑)')
|
|
156
|
-
|
|
157
|
-
if (it.sizeDelta != null)
|
|
158
|
-
lines.push(' 大小:' + (it.sizeDelta >= 0 ? '+' : '') + it.sizeDelta + ' 字节')
|
|
159
|
-
}
|
|
399
|
+
b.push(' ' + meta.join(' · '))
|
|
400
|
+
if (it.sizeDelta != null) b.push(' 大小:' + (it.sizeDelta >= 0 ? '+' : '') + it.sizeDelta + ' 字节')
|
|
160
401
|
if (it.detail && it.detail.text) {
|
|
161
|
-
let
|
|
162
|
-
if (
|
|
163
|
-
if (
|
|
402
|
+
let t = String(it.detail.text).replace(/\n{2,}/g, '\n').trim()
|
|
403
|
+
if (t.length > maxChars) t = t.slice(0, maxChars) + '…'
|
|
404
|
+
if (t) b.push(' 更新内容:\n' + indentText(t, ' '))
|
|
164
405
|
}
|
|
165
|
-
|
|
406
|
+
blocks.push(b.join('\n'))
|
|
166
407
|
}
|
|
167
|
-
let msg =
|
|
168
|
-
|
|
169
|
-
if (msg.length > MAX_TOTAL) msg = msg.slice(0, MAX_TOTAL) + '\n…(内容过长已截断)'
|
|
408
|
+
let msg = blocks.join('\n\n')
|
|
409
|
+
if (msg.length > 4000) msg = msg.slice(0, 4000) + '\n…(内容过长已截断)'
|
|
170
410
|
return msg
|
|
171
411
|
}
|
|
172
412
|
|
|
413
|
+
function indentText(text, prefix) {
|
|
414
|
+
return String(text).split('\n').map(l => prefix + l).join('\n')
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// 汇总输出:优先渲染为图片,失败回退纯文本。返回 koishi 元素数组
|
|
418
|
+
async function buildWatchOutput(ctx, config, items, logger) {
|
|
419
|
+
const summary = '检测到 ' + items.length + ' 个页面变更'
|
|
420
|
+
try {
|
|
421
|
+
const html = buildWatchHtml(items, { ...config, summaryTitle: summary })
|
|
422
|
+
const img = await renderWatchHtml(ctx, html, config)
|
|
423
|
+
if (img) return [h.image(img, 'image/png')]
|
|
424
|
+
} catch (e) {
|
|
425
|
+
logger.warn('BWIKI更新图片渲染失败,回退纯文本: ' + (e && e.message ? e.message : e))
|
|
426
|
+
}
|
|
427
|
+
return ['【第五人格 BWIKI 更新】' + summary, '\n' + buildWatchText(items, clamp(config.wikiWatchMaxChars, 100, 3000, 500))]
|
|
428
|
+
}
|
|
429
|
+
|
|
173
430
|
// 汇总推送目标列表(wikiWatchTargets:多目标,群/频道与私聊可混用;与公告推送一致)
|
|
174
431
|
function buildWatchTargets(config) {
|
|
175
432
|
const list = Array.isArray(config.wikiWatchTargets) ? config.wikiWatchTargets : []
|
|
@@ -187,7 +444,7 @@ function buildWatchTargets(config) {
|
|
|
187
444
|
return targets
|
|
188
445
|
}
|
|
189
446
|
|
|
190
|
-
//
|
|
447
|
+
// 推送到全部目标,任一目标成功即视为成功(其余目标失败仅记录日志)。content 为元素数组或字符串。
|
|
191
448
|
async function sendWatchMessage(ctx, config, content, logger) {
|
|
192
449
|
const targets = buildWatchTargets(config)
|
|
193
450
|
if (!config.wikiWatchPlatform || !targets.length) return false
|
|
@@ -243,7 +500,7 @@ function startWikiWatchMonitor(ctx, config) {
|
|
|
243
500
|
} catch (e) { /* 忽略 */ }
|
|
244
501
|
}
|
|
245
502
|
|
|
246
|
-
// 执行一次检查。返回 { pushed, first, sent } 或 { error }
|
|
503
|
+
// 执行一次检查。返回 { pushed, first, sent, message } 或 { error }
|
|
247
504
|
const check = async () => {
|
|
248
505
|
if (state.running) return { error: '正在检查中,请稍后再试' }
|
|
249
506
|
state.running = true
|
|
@@ -252,7 +509,7 @@ function startWikiWatchMonitor(ctx, config) {
|
|
|
252
509
|
state.lastCheck = fmtTime(new Date().toISOString())
|
|
253
510
|
state.lastError = null
|
|
254
511
|
const changes = await fetchRecentChanges(config)
|
|
255
|
-
if (!changes.length) return { pushed: 0, first: state.lastRcid == null }
|
|
512
|
+
if (!changes.length) return { pushed: 0, first: state.lastRcid == null, message: null }
|
|
256
513
|
|
|
257
514
|
const firstRun = state.lastRcid == null
|
|
258
515
|
const fresh = [] // 相对上次位置新增的变更(倒序)
|
|
@@ -269,12 +526,10 @@ function startWikiWatchMonitor(ctx, config) {
|
|
|
269
526
|
state.lastRcid = newest
|
|
270
527
|
await saveState()
|
|
271
528
|
}
|
|
272
|
-
if (firstRun || !fresh.length) return { pushed: 0, first: firstRun }
|
|
529
|
+
if (firstRun || !fresh.length) return { pushed: 0, first: firstRun, message: null }
|
|
273
530
|
|
|
274
531
|
fresh.reverse() // 转为时间正序(旧 → 新)
|
|
275
532
|
|
|
276
|
-
const maxItems = clamp(config.wikiWatchMaxItems, 1, 30, 15)
|
|
277
|
-
const detailLimit = clamp(config.wikiWatchDetailLimit, 0, 20, 5)
|
|
278
533
|
const items = []
|
|
279
534
|
for (let i = 0; i < fresh.length; i++) {
|
|
280
535
|
const c = fresh[i]
|
|
@@ -289,23 +544,22 @@ function startWikiWatchMonitor(ctx, config) {
|
|
|
289
544
|
isNew: c.type === 'new' || !!c.new,
|
|
290
545
|
sizeDelta: !isNaN(nl) && !isNaN(ol) && nl !== undefined && ol !== undefined ? nl - ol : null,
|
|
291
546
|
}
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
} catch (e) {
|
|
297
|
-
logger.warn('BWIKI更新详情获取失败(' + item.title + '): ' + (e && e.message ? e.message : e))
|
|
298
|
-
}
|
|
547
|
+
try {
|
|
548
|
+
item.detail = await fetchDiffDetail(config, c)
|
|
549
|
+
} catch (e) {
|
|
550
|
+
logger.warn('BWIKI更新详情获取失败(' + item.title + '): ' + (e && e.message ? e.message : e))
|
|
299
551
|
}
|
|
300
552
|
items.push(item)
|
|
301
553
|
}
|
|
302
554
|
|
|
303
|
-
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
555
|
+
// 解析编辑者昵称:bilibili 数字 uid → 真实用户名
|
|
556
|
+
for (const it of items) {
|
|
557
|
+
if (it.user) it.user = await resolveUserName(logger, it.user)
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const message = await buildWatchOutput(ctx, config, items, logger)
|
|
561
|
+
const sent = await sendWatchMessage(ctx, config, message, logger)
|
|
562
|
+
return { pushed: items.length, first: false, sent, message }
|
|
309
563
|
} catch (e) {
|
|
310
564
|
state.lastError = (e && e.message ? e.message : String(e))
|
|
311
565
|
logger.warn('BWIKI更新检查失败: ' + state.lastError)
|
|
@@ -333,5 +587,7 @@ module.exports = {
|
|
|
333
587
|
buildWatchTargets,
|
|
334
588
|
fetchRecentChanges,
|
|
335
589
|
fetchDiffDetail,
|
|
336
|
-
|
|
590
|
+
buildWatchHtml,
|
|
591
|
+
buildWatchText,
|
|
592
|
+
resolveUserName,
|
|
337
593
|
}
|