koishi-plugin-dwrgbwiki 2.0.4 → 2.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/index.js +28 -3
  2. package/notice.js +291 -0
  3. package/package.json +2 -1
package/index.js CHANGED
@@ -8,6 +8,7 @@ const codex = require('./codex')
8
8
  const wiki = require('./wiki')
9
9
  const cangtou = require('./cangtou')
10
10
  const match = require('./match')
11
+ const notice = require('./notice')
11
12
 
12
13
  const HELP = lib.buildHelp()
13
14
 
@@ -1338,11 +1339,27 @@ async function sendNoticeMessage(ctx, config, content, logger) {
1338
1339
  }
1339
1340
  }
1340
1341
 
1342
+ // 公告渲染为图片推送;失败(无 puppeteer / 渲染出错)返回 false,由调用方回退文本
1343
+ // changed 为 diffNotice 结果(非空时仅渲染变化部分),为 null 时渲染完整公告
1344
+ async function sendNoticeImage(ctx, config, parsed, header, logger, sourceName, changed) {
1345
+ if (!ctx.puppeteer) return false
1346
+ try {
1347
+ const width = config.width || 1300
1348
+ const html = notice.buildNoticeHtml(parsed, { ...config, width, sourceName }, changed)
1349
+ const image = await capture(ctx, html, { ...config, width })
1350
+ return await sendNoticeMessage(ctx, config, [header, h.image(image, 'image/png')], logger)
1351
+ } catch (e) {
1352
+ logger.warn('公告图片渲染失败: ' + (e && e.message ? e.message : e))
1353
+ return false
1354
+ }
1355
+ }
1356
+
1341
1357
  function startNoticeMonitor(ctx, config) {
1342
1358
  const logger = ctx.logger('dwrgbwiki')
1343
1359
  const timeout = Math.max(config.timeout || 15000, 20000)
1344
- // 上次内容哈希:key -> hash
1360
+ // 上次内容哈希:key -> hash;上次解析结果:key -> parsed(用于增量 diff)
1345
1361
  let lastHash = {}
1362
+ let lastParsed = {}
1346
1363
  let running = false
1347
1364
 
1348
1365
  const check = async () => {
@@ -1355,12 +1372,20 @@ function startNoticeMonitor(ctx, config) {
1355
1372
  const hash = require('crypto').createHash('sha256').update(text).digest('hex')
1356
1373
  const prev = lastHash[item.key]
1357
1374
  lastHash[item.key] = hash
1375
+ const parsed = notice.parseNoticeCached(text)
1376
+ const prevParsed = lastParsed[item.key] || null
1377
+ lastParsed[item.key] = parsed
1358
1378
  // 首次运行只记录,不推送;之后变动才推送
1359
1379
  if (prev !== undefined && prev !== hash) {
1360
1380
  const { title, body } = parseNotice(text)
1361
1381
  const header = '【' + item.name + '公告更新】' + (title ? '「' + title + '」' : '')
1362
- const content = [header, '', body].join('\n')
1363
- await sendNoticeMessage(ctx, config, content, logger)
1382
+ // 优先渲染为图片推送(仅变化部分),失败时回退纯文本摘要
1383
+ const changed = prevParsed ? notice.diffNotice(prevParsed, parsed) : null
1384
+ const sent = await sendNoticeImage(ctx, config, parsed, header, logger, item.name, changed && changed.hasChange ? changed : null)
1385
+ if (!sent) {
1386
+ const content = [header, '', body].join('\n')
1387
+ await sendNoticeMessage(ctx, config, content, logger)
1388
+ }
1364
1389
  }
1365
1390
  } catch (e) {
1366
1391
  logger.warn('公告检查失败(' + item.key + '): ' + (e && e.message ? e.message : e))
package/notice.js ADDED
@@ -0,0 +1,291 @@
1
+ 'use strict'
2
+
3
+ // ===== 网易官方公告解析与图片渲染 =====
4
+ // 公告文本格式(网易 h55_notice_version2.txt):
5
+ // <t>标题</t> <c2>引言正文</c2>
6
+ // <t1>大节</t1> <t2>小节</t2> <t3>三级标题</t3>
7
+ // <c1><l2>/<l3>内容行</c1>(l2/l3 为单标签,标记行类型)
8
+ // <tt1>表格标题</tt1> <tt2>表头,列</tt2> <tc>行,列</tc>
9
+ // <te>结尾标题</te><c2>结尾正文</c2>
10
+ // #cRRGGBB 行内颜色(到 #n 结束) #n/#r 换行 【】高亮
11
+
12
+ const CACHE = new Map()
13
+ const CACHE_TTL = 6 * 60 * 60 * 1000
14
+
15
+ function esc(s) {
16
+ return String(s == null ? '' : s)
17
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
18
+ .replace(/"/g, '&quot;').replace(/'/g, '&#39;')
19
+ }
20
+
21
+ // 行内格式:#cRRGGBB 颜色、#n/#r 换行、【】高亮
22
+ function inlineHtml(raw) {
23
+ const parts = String(raw == null ? '' : raw).split(/(#c[0-9a-fA-F]{6}|#n|#r)/)
24
+ let out = ''
25
+ let color = ''
26
+ for (let i = 0; i < parts.length; i++) {
27
+ const p = parts[i]
28
+ if (/^#c[0-9a-fA-F]{6}$/.test(p)) { color = p.slice(2); continue }
29
+ if (p === '#n' || p === '#r') { out += '<br>'; color = ''; continue }
30
+ if (!p) continue
31
+ const seg = esc(p)
32
+ .split('\n').map(l => l.trim()).join('<br>')
33
+ .replace(/【([^】]+)】/g, '<span class="kw">$1</span>')
34
+ out += color ? '<span style="color:#' + color + '">' + seg + '</span>' : seg
35
+ }
36
+ return out
37
+ }
38
+
39
+ function splitCols(s) {
40
+ return String(s).split(',').map(x => x.trim()).filter(Boolean)
41
+ }
42
+
43
+ // 解析一个大节内部的内容(c1 内容行 / t2 / t3 / 表格)
44
+ function parseItems(body) {
45
+ const items = []
46
+ const re = /<tt1>([^<]*)<\/tt1>|<tt2>([^<]*)<\/tt2>|<tc>([^<]*)<\/tc>|<t2>([^<]*)<\/t2>|<t3>([^<]*)<\/t3>|<c1>([\s\S]*?)<\/c1>/g
47
+ let m
48
+ let table = null
49
+ while ((m = re.exec(body)) !== null) {
50
+ if (m[1] !== undefined) { // tt1 表格标题
51
+ if (table && table.headers.length) { items.push(table); table = null }
52
+ table = { type: 'table', title: m[1].trim(), headers: [], rows: [] }
53
+ } else if (m[2] !== undefined) { // tt2 表头
54
+ if (!table) table = { type: 'table', title: '', headers: [], rows: [] }
55
+ table.headers = splitCols(m[2])
56
+ } else if (m[3] !== undefined) { // tc 表格行
57
+ if (!table) table = { type: 'table', title: '', headers: [], rows: [] }
58
+ table.rows.push(splitCols(m[3]))
59
+ } else if (m[4] !== undefined) { // t2 小节标题
60
+ if (table && table.headers.length) { items.push(table); table = null }
61
+ items.push({ type: 'sub', text: m[4].trim() })
62
+ } else if (m[5] !== undefined) { // t3 三级标题
63
+ if (table && table.headers.length) { items.push(table); table = null }
64
+ items.push({ type: 'h3', text: m[5].trim() })
65
+ } else { // c1 内容块
66
+ if (table && table.headers.length) { items.push(table); table = null }
67
+ const inner = m[6]
68
+ const lvMatch = inner.match(/^<l([23])>/)
69
+ const txt = inner.replace(/^<l[23]>/, '').trim()
70
+ if (txt) items.push({ type: 'item', lv: lvMatch ? Number(lvMatch[1]) : 2, text: txt })
71
+ }
72
+ }
73
+ if (table && table.headers.length) items.push(table)
74
+ return items
75
+ }
76
+
77
+ // 完整解析公告文本 → 结构化对象
78
+ function parseNotice(text) {
79
+ const s = String(text || '')
80
+ const title = (s.match(/<t>([^<]*)<\/t>/) || [])[1] || ''
81
+
82
+ // 结尾公告(<te> 标题 + 之后的 <c2> 正文)
83
+ let endTitle = ''
84
+ let endBody = ''
85
+ let main = s
86
+ const teMatch = s.match(/<te>([^<]*)<\/te>([\s\S]*)$/)
87
+ if (teMatch) {
88
+ endTitle = teMatch[1].trim()
89
+ main = s.slice(0, teMatch.index)
90
+ const endBodyMatch = teMatch[2].match(/<c2>([\s\S]*?)<\/c2>/)
91
+ endBody = endBodyMatch ? endBodyMatch[1].trim() : teMatch[2].replace(/<[^>]+>/g, '').trim()
92
+ }
93
+
94
+ // 引言(首个 <c2>)
95
+ let intro = ''
96
+ const c2Match = main.match(/<c2>([\s\S]*?)<\/c2>/)
97
+ if (c2Match) {
98
+ intro = c2Match[1].trim()
99
+ main = main.replace(c2Match[0], '')
100
+ }
101
+
102
+ // 大节(按 <t1> 切分)
103
+ const sections = []
104
+ const parts = main.split(/(?=<t1>[^<]*<\/t1>)/)
105
+ for (const part of parts) {
106
+ const m = part.match(/^<t1>([^<]*)<\/t1>([\s\S]*)$/)
107
+ if (m && m[1].trim()) {
108
+ sections.push({ title: m[1].trim(), items: parseItems(m[2]) })
109
+ }
110
+ }
111
+ return { title, intro, sections, endTitle, endBody }
112
+ }
113
+
114
+ // 带缓存的解析(避免重复解析同一公告)
115
+ function parseNoticeCached(text) {
116
+ const key = require('crypto').createHash('sha256').update(String(text || '')).digest('hex')
117
+ const hit = CACHE.get(key)
118
+ if (hit && Date.now() - hit.ts < CACHE_TTL) return hit.data
119
+ const data = parseNotice(text)
120
+ CACHE.set(key, { data, ts: Date.now() })
121
+ return data
122
+ }
123
+
124
+ // ===== 增量 diff:对比新旧解析结果,找出变化的部分 =====
125
+
126
+ function itemKey(it) {
127
+ if (it.type === 'table') {
128
+ return 'table|' + it.title + '|' + it.headers.join(',') + '|' + it.rows.map(r => r.join(',')).join(';')
129
+ }
130
+ return it.type + '|' + String(it.text || '').replace(/\s+/g, ' ').trim()
131
+ }
132
+
133
+ function normalizeSpace(s) {
134
+ return String(s || '').replace(/\s+/g, ' ').trim()
135
+ }
136
+
137
+ // 用 LCS 标记当前 items 中相对 prevItems 有变化(新增/修改)的项
138
+ function diffItems(prevItems, currItems) {
139
+ const a = prevItems, b = currItems
140
+ const n = a.length, m = b.length
141
+ const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0))
142
+ for (let i = n - 1; i >= 0; i--) {
143
+ for (let j = m - 1; j >= 0; j--) {
144
+ dp[i][j] = itemKey(a[i]) === itemKey(b[j])
145
+ ? dp[i + 1][j + 1] + 1
146
+ : Math.max(dp[i + 1][j], dp[i][j + 1])
147
+ }
148
+ }
149
+ const inLcs = new Array(m).fill(false)
150
+ let i = 0, j = 0
151
+ while (i < n && j < m) {
152
+ if (itemKey(a[i]) === itemKey(b[j])) { inLcs[j] = true; i++; j++ }
153
+ else if (dp[i + 1][j] >= dp[i][j + 1]) i++
154
+ else j++
155
+ }
156
+ return b.map((it, idx) => ({ ...it, changed: !inLcs[idx] }))
157
+ }
158
+
159
+ // 对比新旧解析结果,返回变化摘要(仅含发生变化的部分)
160
+ function diffNotice(prev, curr) {
161
+ const prevByTitle = new Map()
162
+ prev.sections.forEach((s, i) => {
163
+ if (!prevByTitle.has(s.title)) prevByTitle.set(s.title, [])
164
+ prevByTitle.get(s.title).push(i)
165
+ })
166
+ const used = new Set()
167
+ const sections = []
168
+ curr.sections.forEach(sec => {
169
+ const idxs = prevByTitle.get(sec.title) || []
170
+ const pIdx = idxs.find(i => !used.has(i))
171
+ let items, isNew
172
+ if (pIdx === undefined) {
173
+ isNew = true
174
+ items = sec.items.map(it => ({ ...it, changed: true }))
175
+ } else {
176
+ used.add(pIdx)
177
+ isNew = false
178
+ items = diffItems(prev.sections[pIdx].items, sec.items)
179
+ }
180
+ const hasChanged = isNew || items.some(it => it.changed)
181
+ if (hasChanged) sections.push({ title: sec.title, items, isNew })
182
+ })
183
+ const intro = normalizeSpace(prev.intro) !== normalizeSpace(curr.intro)
184
+ const end = normalizeSpace(prev.endTitle + prev.endBody) !== normalizeSpace(curr.endTitle + curr.endBody)
185
+ return { intro, end, sections, hasChange: intro || end || sections.length > 0 }
186
+ }
187
+
188
+ // ===== 图片渲染 =====
189
+
190
+ function tableHtml(t) {
191
+ const th = t.headers.map(h => '<th>' + esc(h) + '</th>').join('')
192
+ const tr = t.rows.map(r => '<tr>' + r.map(c => '<td>' + esc(c) + '</td>').join('') + '</tr>').join('')
193
+ return '<div class="tbl' + (t.changed ? ' changed' : '') + '">' +
194
+ (t.title ? '<div class="tbl-title">' + inlineHtml(t.title) + '</div>' : '') +
195
+ '<table><thead><tr>' + th + '</tr></thead><tbody>' + tr + '</tbody></table></div>'
196
+ }
197
+
198
+ function sectionHtml(sec, idx) {
199
+ const itemsHtml = sec.items.map(it => {
200
+ if (it.type === 'sub') return '<div class="s-sub">' + inlineHtml(it.text) + '</div>'
201
+ if (it.type === 'h3') return '<div class="s-h3">' + inlineHtml(it.text) + '</div>'
202
+ if (it.type === 'table') return tableHtml(it)
203
+ return '<div class="row' + (it.changed ? ' changed' : '') + '"><span class="dot"></span><div class="row-text">' + inlineHtml(it.text) + '</div></div>'
204
+ }).join('')
205
+ return '<div class="section">' +
206
+ '<div class="s-head"><span class="s-num">' + String(idx + 1).padStart(2, '0') + '</span>' + inlineHtml(sec.title) +
207
+ (sec.isNew ? '<span class="tag-new">新增</span>' : '') +
208
+ '</div>' +
209
+ itemsHtml +
210
+ '</div>'
211
+ }
212
+
213
+ function buildNoticeHtml(parsed, config, changed) {
214
+ const width = config.width || 1300
215
+ const sourceName = config.sourceName || '官方'
216
+ const title = parsed.title || '更新公告'
217
+ const introLines = parsed.intro.split('\n').map(l => l.trim()).filter(Boolean)
218
+ const introHtml = introLines.length
219
+ ? '<div class="intro' + (changed ? ' update' : '') + '">' + introLines.map(l => inlineHtml(l)).join('<br>') + '</div>'
220
+ : ''
221
+ const sectionsHtml = changed
222
+ ? changed.sections.map(sectionHtml).join('')
223
+ : parsed.sections.map(sectionHtml).join('')
224
+ const endHtml = (changed ? changed.end && (parsed.endTitle || parsed.endBody) : parsed.endTitle || parsed.endBody)
225
+ ? '<div class="end-card"><div class="end-title">' + inlineHtml(parsed.endTitle || '公告') + '</div>' +
226
+ '<div class="end-body">' + inlineHtml(parsed.endBody) + '</div></div>'
227
+ : ''
228
+
229
+ return `<!DOCTYPE html>
230
+ <html lang="zh-CN">
231
+ <head>
232
+ <meta charset="UTF-8">
233
+ <style>
234
+ * { margin:0; padding:0; box-sizing:border-box; }
235
+ body { font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Microsoft YaHei',sans-serif; color:#e6edf3;
236
+ background:radial-gradient(1400px 700px at 50% -10%, #1b2a4a 0%, #10182b 45%, #0a0e17 100%); padding:26px 20px; }
237
+ .page { max-width:${width}px; margin:0 auto; }
238
+ .hero { text-align:center; padding:26px 24px 22px; border-radius:18px; margin-bottom:18px;
239
+ background:linear-gradient(135deg, #667eea 0%, #764ba2 55%, #a4508b 100%); box-shadow:0 8px 28px rgba(0,0,0,.4); position:relative; overflow:hidden; }
240
+ .hero::after { content:''; position:absolute; inset:0; background:radial-gradient(600px 200px at 80% -20%, rgba(255,255,255,.18), transparent 60%); }
241
+ .badge { display:inline-flex; align-items:center; gap:6px; font-size:14px; font-weight:700; color:#ffd76d;
242
+ background:rgba(0,0,0,.28); border:1px solid rgba(255,215,109,.4); border-radius:999px; padding:4px 14px; margin-bottom:12px; letter-spacing:1px; }
243
+ .title { font-size:34px; font-weight:800; color:#fff; letter-spacing:2px; text-shadow:0 2px 10px rgba(0,0,0,.35); }
244
+ .sub { font-size:14px; color:rgba(255,255,255,.8); margin-top:8px; letter-spacing:.5px; }
245
+ .intro { background:rgba(22,27,34,.82); border:1px solid rgba(255,255,255,.1); border-left:4px solid #ffd76d;
246
+ border-radius:14px; padding:16px 20px; margin-bottom:16px; line-height:1.9; font-size:16px; color:#c9d1d9; }
247
+ .section { background:rgba(22,27,34,.78); border:1px solid rgba(255,255,255,.09); border-radius:16px; padding:18px 20px; margin-bottom:16px; }
248
+ .s-head { display:flex; align-items:center; gap:12px; font-size:22px; font-weight:800; color:#ffd76d; letter-spacing:1px;
249
+ padding-bottom:12px; margin-bottom:12px; border-bottom:1px solid rgba(255,255,255,.08); }
250
+ .s-num { flex-shrink:0; width:34px; height:34px; border-radius:10px; display:flex; align-items:center; justify-content:center;
251
+ font-size:15px; font-weight:800; color:#fff; background:linear-gradient(135deg,#667eea,#764ba2); box-shadow:0 3px 10px rgba(102,126,234,.35); }
252
+ .s-sub { font-size:16px; font-weight:700; color:#79c0ff; margin:2px 0 10px; padding-left:10px; border-left:3px solid #58a6ff; line-height:1.6; }
253
+ .s-h3 { font-size:15.5px; font-weight:700; color:#56d4dd; margin:12px 0 8px; padding-left:10px; border-left:3px solid #56d4dd; line-height:1.6; }
254
+ .row { display:flex; gap:10px; margin-bottom:10px; line-height:1.85; font-size:15.5px; color:#c9d1d9; }
255
+ .row:last-child { margin-bottom:0; }
256
+ .dot { flex-shrink:0; width:7px; height:7px; border-radius:50%; margin-top:10px; background:linear-gradient(135deg,#ffd76d,#ff8c00); box-shadow:0 0 6px rgba(255,180,60,.6); }
257
+ .row-text { flex:1; }
258
+ .row.changed { background:rgba(255,215,109,.08); border:1px solid rgba(255,215,109,.28); border-radius:8px; padding:6px 10px; margin-left:-6px; margin-right:-6px; }
259
+ .row.changed .dot { background:#3fb950; box-shadow:0 0 6px rgba(63,185,80,.6); }
260
+ .kw { color:#ffd76d; font-weight:700; }
261
+ .tag-new { flex-shrink:0; font-size:12px; font-weight:700; color:#0d1117; background:linear-gradient(135deg,#3fb950,#2ea043); border-radius:999px; padding:2px 10px; letter-spacing:.5px; }
262
+ .tbl { margin:10px 0 14px; }
263
+ .tbl.changed { border:1px solid rgba(255,215,109,.35); border-radius:12px; padding:10px 12px; background:rgba(255,215,109,.05); }
264
+ .intro.update { border-left-color:#3fb950; }
265
+ .tbl-title { font-size:15px; font-weight:800; color:#fff; margin-bottom:8px; letter-spacing:.5px; }
266
+ .tbl table { width:100%; border-collapse:collapse; border-radius:10px; overflow:hidden; }
267
+ .tbl th { background:linear-gradient(135deg,#667eea,#764ba2); color:#fff; font-size:14.5px; font-weight:700; padding:8px 14px; text-align:center; }
268
+ .tbl td { padding:8px 14px; font-size:15px; text-align:center; color:#e6edf3; border-top:1px solid rgba(255,255,255,.07); background:rgba(255,255,255,.02); }
269
+ .end-card { background:rgba(248,81,73,.08); border:1px solid rgba(248,81,73,.35); border-radius:16px; padding:18px 20px; margin-top:4px; }
270
+ .end-title { font-size:18px; font-weight:800; color:#ff7b72; margin-bottom:10px; letter-spacing:.5px; }
271
+ .end-body { line-height:1.9; font-size:15px; color:#c9d1d9; }
272
+ .foot { text-align:center; color:#8b949e; font-size:13px; margin-top:16px; opacity:.75; letter-spacing:.5px; }
273
+ </style>
274
+ </head>
275
+ <body>
276
+ <div class="page">
277
+ <div class="hero">
278
+ <div class="badge">${changed ? '✏️ 更新内容 · ' : '📢 '}${esc(sourceName)}公告</div>
279
+ <div class="title">${esc(title)}</div>
280
+ <div class="sub">第五人格 · ${changed ? '本次更新内容' : esc(sourceName) + '公告推送'}</div>
281
+ </div>
282
+ ${introHtml}
283
+ ${sectionsHtml}
284
+ ${endHtml}
285
+ <div class="foot">数据来源:网易官方公告 · BWIKI 机器人推送</div>
286
+ </div>
287
+ </body>
288
+ </html>`
289
+ }
290
+
291
+ module.exports = { parseNotice, parseNoticeCached, diffNotice, buildNoticeHtml, inlineHtml }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koishi-plugin-dwrgbwiki",
3
- "version": "2.0.4",
3
+ "version": "2.0.6",
4
4
  "description": "第五人格BWIKI编辑组开发,所有代码由DeepSeek V4 Flash 0731编写。支持胜率/地图/模仿者/勋章/基础数据查询、图鉴查询、wiki页面截图、藏头诗、抽卡模拟、加页手记探索、赛事数据(选手/对决)、公告变动推送、反馈系统等功能。",
5
5
  "main": "index.js",
6
6
  "files": [
@@ -12,6 +12,7 @@
12
12
  "wiki.js",
13
13
  "cangtou.js",
14
14
  "match.js",
15
+ "notice.js",
15
16
  "readme.md"
16
17
  ],
17
18
  "keywords": [