koishi-plugin-dwrgbwiki 2.1.1 → 2.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/codex.js +44 -56
- package/index.js +13 -1
- package/notice.js +293 -291
- package/package.json +1 -1
package/codex.js
CHANGED
|
@@ -36,26 +36,26 @@ async function fetchText(url, timeout) {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
async function fetchUrl(url, timeout) {
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
let res = await doFetch()
|
|
50
|
-
if (res.status === 567 || res.status === 429 || res.status === 503) {
|
|
51
|
-
await new Promise(r => setTimeout(r, 3000))
|
|
52
|
-
res = await doFetch()
|
|
53
|
-
}
|
|
54
|
-
if (!res.ok) throw new Error('HTTP ' + res.status)
|
|
55
|
-
return await res.text()
|
|
56
|
-
} finally {
|
|
57
|
-
clearTimeout(timer)
|
|
39
|
+
const headers = {
|
|
40
|
+
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36',
|
|
41
|
+
'Accept-Language': 'zh-CN,zh;q=0.9',
|
|
42
|
+
}
|
|
43
|
+
const doFetch = () => {
|
|
44
|
+
const ctrl = new AbortController()
|
|
45
|
+
const timer = setTimeout(() => ctrl.abort(), timeout)
|
|
46
|
+
const p = fetch(url, { signal: ctrl.signal, headers })
|
|
47
|
+
p.finally(() => clearTimeout(timer))
|
|
48
|
+
return p
|
|
58
49
|
}
|
|
50
|
+
let res = await doFetch()
|
|
51
|
+
const RETRY_WAIT = [3000, 8000]
|
|
52
|
+
for (const wait of RETRY_WAIT) {
|
|
53
|
+
if (res.status !== 567 && res.status !== 429 && res.status !== 503) break
|
|
54
|
+
await new Promise(r => setTimeout(r, wait))
|
|
55
|
+
res = await doFetch()
|
|
56
|
+
}
|
|
57
|
+
if (!res.ok) throw new Error('HTTP ' + res.status)
|
|
58
|
+
return await res.text()
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
async function fetchCategoryMembers(category, config) {
|
|
@@ -74,6 +74,14 @@ async function fetchCategoryMembers(category, config) {
|
|
|
74
74
|
return titles
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
async function searchPages(query, config) {
|
|
78
|
+
const q = String(query || '').trim().replace(/[“”"]/g, '')
|
|
79
|
+
if (!q) return []
|
|
80
|
+
const url = API_BASE + '/api.php?action=query&list=search&srsearch=' + encodeURIComponent(q) + '&srlimit=5&format=json&formatversion=2'
|
|
81
|
+
const data = await fetchRaw(url, config.timeout || 15000)
|
|
82
|
+
return (data.query && data.query.search) || []
|
|
83
|
+
}
|
|
84
|
+
|
|
77
85
|
function buildRoleHtml(category, titles, config) {
|
|
78
86
|
const width = config.width || 1300
|
|
79
87
|
const total = titles.length
|
|
@@ -116,49 +124,28 @@ body { background:#0d1117; color:#c9d1d9; font-family:-apple-system,BlinkMacSyst
|
|
|
116
124
|
async function resolveTitle(title, config) {
|
|
117
125
|
const q = String(title || '').trim()
|
|
118
126
|
if (!q) throw new Error('请输入页面名')
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
if (!triedQuotes && !/[“”"]/.test(q)) {
|
|
127
|
-
triedQuotes = true
|
|
128
|
-
current = '“' + q + '”'
|
|
129
|
-
continue
|
|
130
|
-
}
|
|
131
|
-
throw new Error('页面不存在:' + current)
|
|
132
|
-
}
|
|
127
|
+
const hasQuote = /[“”"]/.test(q)
|
|
128
|
+
const candidates = hasQuote ? [q] : [q, '“' + q + '”']
|
|
129
|
+
const url = API_BASE + '/api.php?action=query&redirects=1&prop=info&titles=' + encodeURIComponent(candidates.join('|')) + '&format=json&formatversion=2'
|
|
130
|
+
const data = await fetchRaw(url, config.timeout || 15000)
|
|
131
|
+
const pages = (data.query && data.query.pages) || []
|
|
132
|
+
const page = pages.find(p => !p.missing && p.title)
|
|
133
|
+
if (page) return page.title
|
|
133
134
|
throw new Error('页面不存在:' + q)
|
|
134
135
|
}
|
|
135
136
|
|
|
136
137
|
async function fetchWikitext(title, config) {
|
|
137
138
|
const q = String(title || '').trim()
|
|
138
139
|
if (!q) throw new Error('请输入要查询的名称')
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
if (
|
|
147
|
-
|
|
148
|
-
triedQuotes = true
|
|
149
|
-
current = '“' + q + '”'
|
|
150
|
-
continue
|
|
151
|
-
}
|
|
152
|
-
throw new Error('页面不存在:' + current)
|
|
153
|
-
}
|
|
154
|
-
if (!page.revisions || !page.revisions.length) throw new Error('页面「' + current + '」无内容')
|
|
155
|
-
const content = page.revisions[0].content
|
|
156
|
-
const rd = content.match(/^\s*#(?:重定向|REDIRECT)\s*\[\[([^\]]+)\]\]/)
|
|
157
|
-
if (rd && rd[1] && rd[1] !== current) {
|
|
158
|
-
current = rd[1].split('|')[0].trim()
|
|
159
|
-
continue
|
|
160
|
-
}
|
|
161
|
-
return { title: page.title, content }
|
|
140
|
+
const hasQuote = /[“”"]/.test(q)
|
|
141
|
+
const candidates = hasQuote ? [q] : [q, '“' + q + '”']
|
|
142
|
+
const url = API_BASE + '/api.php?action=query&prop=revisions&rvprop=content&redirects=1&format=json&formatversion=2&titles=' + encodeURIComponent(candidates.join('|'))
|
|
143
|
+
const data = await fetchRaw(url, config.timeout || 15000)
|
|
144
|
+
const pages = (data.query && data.query.pages) || []
|
|
145
|
+
const found = pages.find(p => !p.missing)
|
|
146
|
+
if (found) {
|
|
147
|
+
if (found.revisions && found.revisions.length) return { title: found.title, content: found.revisions[0].content }
|
|
148
|
+
throw new Error('页面「' + found.title + '」无内容')
|
|
162
149
|
}
|
|
163
150
|
throw new Error('页面不存在:' + q)
|
|
164
151
|
}
|
|
@@ -510,6 +497,7 @@ module.exports = {
|
|
|
510
497
|
fetchWikitext,
|
|
511
498
|
resolveTitle,
|
|
512
499
|
fetchCategoryMembers,
|
|
500
|
+
searchPages,
|
|
513
501
|
buildRoleHtml,
|
|
514
502
|
parseTemplates,
|
|
515
503
|
mergeParams,
|
package/index.js
CHANGED
|
@@ -879,7 +879,19 @@ async function codexImage(ctx, config, rest) {
|
|
|
879
879
|
return [h.image(image, 'image/png')]
|
|
880
880
|
} catch (e) {
|
|
881
881
|
ctx.logger('dwrgbwiki').warn(e)
|
|
882
|
-
|
|
882
|
+
const msg = e && e.message ? e.message : String(e)
|
|
883
|
+
if (String(msg).includes('页面不存在')) {
|
|
884
|
+
try {
|
|
885
|
+
const results = await codex.searchPages(q, config)
|
|
886
|
+
if (results.length) {
|
|
887
|
+
const list = results.slice(0, 5).map(r => ' ' + r.title).join('\n')
|
|
888
|
+
return '未找到「' + q + '」相关页面,为你找到:\n' + list + '\n可尝试:图鉴 ' + sub.name + ' <上面的名称>'
|
|
889
|
+
}
|
|
890
|
+
} catch (e2) {
|
|
891
|
+
ctx.logger('dwrgbwiki').warn(e2)
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
return errorImage(ctx, config, '图鉴查询失败:' + msg, '图鉴', codexWidth(sub, config))
|
|
883
895
|
}
|
|
884
896
|
}
|
|
885
897
|
|
package/notice.js
CHANGED
|
@@ -1,291 +1,293 @@
|
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
18
|
-
.replace(/"/g, '"').replace(/'/g, ''')
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
if (it.type === '
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
'
|
|
209
|
-
|
|
210
|
-
'</div>'
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
const
|
|
217
|
-
const
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
<
|
|
233
|
-
<
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
.hero
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
.
|
|
244
|
-
|
|
245
|
-
.
|
|
246
|
-
|
|
247
|
-
.
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
.s-
|
|
251
|
-
|
|
252
|
-
.s-
|
|
253
|
-
|
|
254
|
-
.
|
|
255
|
-
.
|
|
256
|
-
.
|
|
257
|
-
.row-
|
|
258
|
-
.
|
|
259
|
-
.row
|
|
260
|
-
.
|
|
261
|
-
.
|
|
262
|
-
.
|
|
263
|
-
.
|
|
264
|
-
.
|
|
265
|
-
.tbl
|
|
266
|
-
.
|
|
267
|
-
.tbl
|
|
268
|
-
.tbl
|
|
269
|
-
.
|
|
270
|
-
.
|
|
271
|
-
.end-
|
|
272
|
-
.
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
<div class="
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
${
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
</
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// ===== 网易官方公告解析与图片渲染 =====
|
|
4
|
+
// 公告文本格式(网易 h55_notice_version2.txt):
|
|
5
|
+
// <t>标题</t> <c2>引言正文</c2>
|
|
6
|
+
// <t0>顶部大节(如线上问题修复)</t0> <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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
18
|
+
.replace(/"/g, '"').replace(/'/g, ''')
|
|
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
|
+
// 大节(按 <t0>/<t1> 切分;<t0> 为“线上问题修复”等顶部栏目,同样作为大节处理)
|
|
103
|
+
// 先去掉公告标题 <t>...</t>,否则它会和其后的 <t0> 混在首段里被丢弃
|
|
104
|
+
main = main.replace(/^<t>[^<]*<\/t>\s*/, '')
|
|
105
|
+
const sections = []
|
|
106
|
+
const parts = main.split(/(?=<t[01]>[^<]*<\/t[01]>)/)
|
|
107
|
+
for (const part of parts) {
|
|
108
|
+
const m = part.match(/^<t([01])>([^<]*)<\/t\1>([\s\S]*)$/)
|
|
109
|
+
if (m && m[2].trim()) {
|
|
110
|
+
sections.push({ title: m[2].trim(), level: Number(m[1]), items: parseItems(m[3]) })
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return { title, intro, sections, endTitle, endBody }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 带缓存的解析(避免重复解析同一公告)
|
|
117
|
+
function parseNoticeCached(text) {
|
|
118
|
+
const key = require('crypto').createHash('sha256').update(String(text || '')).digest('hex')
|
|
119
|
+
const hit = CACHE.get(key)
|
|
120
|
+
if (hit && Date.now() - hit.ts < CACHE_TTL) return hit.data
|
|
121
|
+
const data = parseNotice(text)
|
|
122
|
+
CACHE.set(key, { data, ts: Date.now() })
|
|
123
|
+
return data
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ===== 增量 diff:对比新旧解析结果,找出变化的部分 =====
|
|
127
|
+
|
|
128
|
+
function itemKey(it) {
|
|
129
|
+
if (it.type === 'table') {
|
|
130
|
+
return 'table|' + it.title + '|' + it.headers.join(',') + '|' + it.rows.map(r => r.join(',')).join(';')
|
|
131
|
+
}
|
|
132
|
+
return it.type + '|' + String(it.text || '').replace(/\s+/g, ' ').trim()
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function normalizeSpace(s) {
|
|
136
|
+
return String(s || '').replace(/\s+/g, ' ').trim()
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 用 LCS 标记当前 items 中相对 prevItems 有变化(新增/修改)的项
|
|
140
|
+
function diffItems(prevItems, currItems) {
|
|
141
|
+
const a = prevItems, b = currItems
|
|
142
|
+
const n = a.length, m = b.length
|
|
143
|
+
const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0))
|
|
144
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
145
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
146
|
+
dp[i][j] = itemKey(a[i]) === itemKey(b[j])
|
|
147
|
+
? dp[i + 1][j + 1] + 1
|
|
148
|
+
: Math.max(dp[i + 1][j], dp[i][j + 1])
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const inLcs = new Array(m).fill(false)
|
|
152
|
+
let i = 0, j = 0
|
|
153
|
+
while (i < n && j < m) {
|
|
154
|
+
if (itemKey(a[i]) === itemKey(b[j])) { inLcs[j] = true; i++; j++ }
|
|
155
|
+
else if (dp[i + 1][j] >= dp[i][j + 1]) i++
|
|
156
|
+
else j++
|
|
157
|
+
}
|
|
158
|
+
return b.map((it, idx) => ({ ...it, changed: !inLcs[idx] }))
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// 对比新旧解析结果,返回变化摘要(仅含发生变化的部分)
|
|
162
|
+
function diffNotice(prev, curr) {
|
|
163
|
+
const prevByTitle = new Map()
|
|
164
|
+
prev.sections.forEach((s, i) => {
|
|
165
|
+
if (!prevByTitle.has(s.title)) prevByTitle.set(s.title, [])
|
|
166
|
+
prevByTitle.get(s.title).push(i)
|
|
167
|
+
})
|
|
168
|
+
const used = new Set()
|
|
169
|
+
const sections = []
|
|
170
|
+
curr.sections.forEach(sec => {
|
|
171
|
+
const idxs = prevByTitle.get(sec.title) || []
|
|
172
|
+
const pIdx = idxs.find(i => !used.has(i))
|
|
173
|
+
let items, isNew
|
|
174
|
+
if (pIdx === undefined) {
|
|
175
|
+
isNew = true
|
|
176
|
+
items = sec.items.map(it => ({ ...it, changed: true }))
|
|
177
|
+
} else {
|
|
178
|
+
used.add(pIdx)
|
|
179
|
+
isNew = false
|
|
180
|
+
items = diffItems(prev.sections[pIdx].items, sec.items)
|
|
181
|
+
}
|
|
182
|
+
const hasChanged = isNew || items.some(it => it.changed)
|
|
183
|
+
if (hasChanged) sections.push({ title: sec.title, items, isNew })
|
|
184
|
+
})
|
|
185
|
+
const intro = normalizeSpace(prev.intro) !== normalizeSpace(curr.intro)
|
|
186
|
+
const end = normalizeSpace(prev.endTitle + prev.endBody) !== normalizeSpace(curr.endTitle + curr.endBody)
|
|
187
|
+
return { intro, end, sections, hasChange: intro || end || sections.length > 0 }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ===== 图片渲染 =====
|
|
191
|
+
|
|
192
|
+
function tableHtml(t) {
|
|
193
|
+
const th = t.headers.map(h => '<th>' + esc(h) + '</th>').join('')
|
|
194
|
+
const tr = t.rows.map(r => '<tr>' + r.map(c => '<td>' + esc(c) + '</td>').join('') + '</tr>').join('')
|
|
195
|
+
return '<div class="tbl' + (t.changed ? ' changed' : '') + '">' +
|
|
196
|
+
(t.title ? '<div class="tbl-title">' + inlineHtml(t.title) + '</div>' : '') +
|
|
197
|
+
'<table><thead><tr>' + th + '</tr></thead><tbody>' + tr + '</tbody></table></div>'
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function sectionHtml(sec, idx) {
|
|
201
|
+
const itemsHtml = sec.items.map(it => {
|
|
202
|
+
if (it.type === 'sub') return '<div class="s-sub">' + inlineHtml(it.text) + '</div>'
|
|
203
|
+
if (it.type === 'h3') return '<div class="s-h3">' + inlineHtml(it.text) + '</div>'
|
|
204
|
+
if (it.type === 'table') return tableHtml(it)
|
|
205
|
+
return '<div class="row' + (it.changed ? ' changed' : '') + '"><span class="dot"></span><div class="row-text">' + inlineHtml(it.text) + '</div></div>'
|
|
206
|
+
}).join('')
|
|
207
|
+
return '<div class="section">' +
|
|
208
|
+
'<div class="s-head"><span class="s-num">' + String(idx + 1).padStart(2, '0') + '</span>' + inlineHtml(sec.title) +
|
|
209
|
+
(sec.isNew ? '<span class="tag-new">新增</span>' : '') +
|
|
210
|
+
'</div>' +
|
|
211
|
+
itemsHtml +
|
|
212
|
+
'</div>'
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function buildNoticeHtml(parsed, config, changed) {
|
|
216
|
+
const width = config.width || 1300
|
|
217
|
+
const sourceName = config.sourceName || '官方'
|
|
218
|
+
const title = parsed.title || '更新公告'
|
|
219
|
+
const introLines = parsed.intro.split('\n').map(l => l.trim()).filter(Boolean)
|
|
220
|
+
const introHtml = introLines.length
|
|
221
|
+
? '<div class="intro' + (changed ? ' update' : '') + '">' + introLines.map(l => inlineHtml(l)).join('<br>') + '</div>'
|
|
222
|
+
: ''
|
|
223
|
+
const sectionsHtml = changed
|
|
224
|
+
? changed.sections.map(sectionHtml).join('')
|
|
225
|
+
: parsed.sections.map(sectionHtml).join('')
|
|
226
|
+
const endHtml = (changed ? changed.end && (parsed.endTitle || parsed.endBody) : parsed.endTitle || parsed.endBody)
|
|
227
|
+
? '<div class="end-card"><div class="end-title">' + inlineHtml(parsed.endTitle || '公告') + '</div>' +
|
|
228
|
+
'<div class="end-body">' + inlineHtml(parsed.endBody) + '</div></div>'
|
|
229
|
+
: ''
|
|
230
|
+
|
|
231
|
+
return `<!DOCTYPE html>
|
|
232
|
+
<html lang="zh-CN">
|
|
233
|
+
<head>
|
|
234
|
+
<meta charset="UTF-8">
|
|
235
|
+
<style>
|
|
236
|
+
* { margin:0; padding:0; box-sizing:border-box; }
|
|
237
|
+
body { font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Microsoft YaHei',sans-serif; color:#e6edf3;
|
|
238
|
+
background:radial-gradient(1400px 700px at 50% -10%, #1b2a4a 0%, #10182b 45%, #0a0e17 100%); padding:26px 20px; }
|
|
239
|
+
.page { max-width:${width}px; margin:0 auto; }
|
|
240
|
+
.hero { text-align:center; padding:26px 24px 22px; border-radius:18px; margin-bottom:18px;
|
|
241
|
+
background:linear-gradient(135deg, #667eea 0%, #764ba2 55%, #a4508b 100%); box-shadow:0 8px 28px rgba(0,0,0,.4); position:relative; overflow:hidden; }
|
|
242
|
+
.hero::after { content:''; position:absolute; inset:0; background:radial-gradient(600px 200px at 80% -20%, rgba(255,255,255,.18), transparent 60%); }
|
|
243
|
+
.badge { display:inline-flex; align-items:center; gap:6px; font-size:25px; font-weight:700; color:#ffd76d;
|
|
244
|
+
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; }
|
|
245
|
+
.title { font-size:35px; font-weight:800; color:#fff; letter-spacing:2px; text-shadow:0 2px 10px rgba(0,0,0,.35); }
|
|
246
|
+
.sub { font-size:25px; color:rgba(255,255,255,.8); margin-top:8px; letter-spacing:.5px; }
|
|
247
|
+
.intro { background:rgba(22,27,34,.82); border:1px solid rgba(255,255,255,.1); border-left:4px solid #ffd76d;
|
|
248
|
+
border-radius:14px; padding:16px 20px; margin-bottom:16px; line-height:1.9; font-size:25px; color:#c9d1d9; }
|
|
249
|
+
.section { background:rgba(22,27,34,.78); border:1px solid rgba(255,255,255,.09); border-radius:16px; padding:18px 20px; margin-bottom:16px; }
|
|
250
|
+
.s-head { display:flex; align-items:center; gap:12px; font-size:25px; font-weight:800; color:#ffd76d; letter-spacing:1px;
|
|
251
|
+
padding-bottom:12px; margin-bottom:12px; border-bottom:1px solid rgba(255,255,255,.08); }
|
|
252
|
+
.s-num { flex-shrink:0; width:34px; height:34px; border-radius:10px; display:flex; align-items:center; justify-content:center;
|
|
253
|
+
font-size:25px; font-weight:800; color:#fff; background:linear-gradient(135deg,#667eea,#764ba2); box-shadow:0 3px 10px rgba(102,126,234,.35); }
|
|
254
|
+
.s-sub { font-size:25px; font-weight:700; color:#79c0ff; margin:2px 0 10px; padding-left:10px; border-left:3px solid #58a6ff; line-height:1.6; }
|
|
255
|
+
.s-h3 { font-size:25px; font-weight:700; color:#56d4dd; margin:12px 0 8px; padding-left:10px; border-left:3px solid #56d4dd; line-height:1.6; }
|
|
256
|
+
.row { display:flex; gap:10px; margin-bottom:10px; line-height:1.85; font-size:25px; color:#c9d1d9; }
|
|
257
|
+
.row:last-child { margin-bottom:0; }
|
|
258
|
+
.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); }
|
|
259
|
+
.row-text { flex:1; }
|
|
260
|
+
.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; }
|
|
261
|
+
.row.changed .dot { background:#3fb950; box-shadow:0 0 6px rgba(63,185,80,.6); }
|
|
262
|
+
.kw { color:#ffd76d; font-weight:700; }
|
|
263
|
+
.tag-new { flex-shrink:0; font-size:25px; font-weight:700; color:#0d1117; background:linear-gradient(135deg,#3fb950,#2ea043); border-radius:999px; padding:2px 10px; letter-spacing:.5px; }
|
|
264
|
+
.tbl { margin:10px 0 14px; }
|
|
265
|
+
.tbl.changed { border:1px solid rgba(255,215,109,.35); border-radius:12px; padding:10px 12px; background:rgba(255,215,109,.05); }
|
|
266
|
+
.intro.update { border-left-color:#3fb950; }
|
|
267
|
+
.tbl-title { font-size:25px; font-weight:800; color:#fff; margin-bottom:8px; letter-spacing:.5px; }
|
|
268
|
+
.tbl table { width:100%; border-collapse:collapse; border-radius:10px; overflow:hidden; }
|
|
269
|
+
.tbl th { background:linear-gradient(135deg,#667eea,#764ba2); color:#fff; font-size:25px; font-weight:700; padding:8px 14px; text-align:center; }
|
|
270
|
+
.tbl td { padding:8px 14px; font-size:25px; text-align:center; color:#e6edf3; border-top:1px solid rgba(255,255,255,.07); background:rgba(255,255,255,.02); }
|
|
271
|
+
.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; }
|
|
272
|
+
.end-title { font-size:25px; font-weight:800; color:#ff7b72; margin-bottom:10px; letter-spacing:.5px; }
|
|
273
|
+
.end-body { line-height:1.9; font-size:25px; color:#c9d1d9; }
|
|
274
|
+
.foot { text-align:center; color:#8b949e; font-size:25px; margin-top:16px; opacity:.75; letter-spacing:.5px; }
|
|
275
|
+
</style>
|
|
276
|
+
</head>
|
|
277
|
+
<body>
|
|
278
|
+
<div class="page">
|
|
279
|
+
<div class="hero">
|
|
280
|
+
<div class="badge">${changed ? '✏️ 更新内容 · ' : '📢 '}${esc(sourceName)}公告</div>
|
|
281
|
+
<div class="title">${esc(title)}</div>
|
|
282
|
+
<div class="sub">第五人格 · ${changed ? '本次更新内容' : esc(sourceName) + '公告推送'}</div>
|
|
283
|
+
</div>
|
|
284
|
+
${introHtml}
|
|
285
|
+
${sectionsHtml}
|
|
286
|
+
${endHtml}
|
|
287
|
+
<div class="foot">数据来源:网易官方公告 · BWIKI 机器人推送</div>
|
|
288
|
+
</div>
|
|
289
|
+
</body>
|
|
290
|
+
</html>`
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
module.exports = { parseNotice, parseNoticeCached, diffNotice, buildNoticeHtml, inlineHtml }
|
package/package.json
CHANGED