koishi-plugin-cs2-update-log 2.2.3 → 2.3.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/README.md +4 -3
- package/lib/index.js +185 -43
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,15 +6,16 @@ Koishi QQ 机器人插件,用于轮询 CS2 官方 Steam 公告流,按 `gid`
|
|
|
6
6
|
|
|
7
7
|
## 功能
|
|
8
8
|
|
|
9
|
-
- 使用 Steam Store RSS
|
|
10
|
-
- 默认每
|
|
9
|
+
- 使用 Valve Fastly RSS、Steam Web API 与 Store RSS 拉取 AppID `730` 的官方公告流。
|
|
10
|
+
- 默认每 15 秒轮询一次。
|
|
11
11
|
- 首次启动默认只记录历史 `gid`,不推送历史内容。
|
|
12
12
|
- 自动分类:
|
|
13
13
|
- 标题包含 `Counter-Strike 2 Update` / `Release Notes`
|
|
14
14
|
- 或正文包含 `[MAPS]`、`[GAMEPLAY]`、`[MISC]`、`[AUDIO]`、`[ITEMS]`、`[WORKSHOP]`、`[PREMIER]` 等更新分区
|
|
15
15
|
- 支持纯文本推送或 Puppeteer 深色长图卡片推送。
|
|
16
16
|
- 支持 OpenAI-compatible Chat Completions API 翻译。
|
|
17
|
-
-
|
|
17
|
+
- 优先并发请求 Valve Fastly RSS 与 Steam Web API,并在两者都不可用时回退到 Store RSS;Fastly RSS 使用 30 秒缓存时间桶,兼顾新公告发现速度和源站压力。
|
|
18
|
+
- 内置短时运行时缓存:RSS 列表缓存 3 秒,新闻内容、AI 翻译结果、长图 PNG 和卡片静态资源每 5 分钟清理一次,减少连续测试/重复推送的开销。
|
|
18
19
|
- 提供 `cs2log.check`、`cs2log.push` 与 `cs2log.test` 命令。
|
|
19
20
|
|
|
20
21
|
## 配置要点
|
package/lib/index.js
CHANGED
|
@@ -17,9 +17,13 @@ exports.inject = {
|
|
|
17
17
|
optional: ['puppeteer'],
|
|
18
18
|
};
|
|
19
19
|
const APP_ID = 730;
|
|
20
|
-
const
|
|
20
|
+
const STEAM_FASTLY_RSS_URL = `https://store.fastly.steamstatic.com/feeds/news/app/${APP_ID}/`;
|
|
21
|
+
const STEAM_STORE_RSS_URL = `https://store.steampowered.com/feeds/news/app/${APP_ID}/`;
|
|
22
|
+
const STEAM_NEWS_API_URL = 'https://api.steampowered.com/ISteamNews/GetNewsForApp/v2/';
|
|
21
23
|
const STATE_LIMIT = 1000;
|
|
22
|
-
const RSS_LIST_CACHE_TTL_MS =
|
|
24
|
+
const RSS_LIST_CACHE_TTL_MS = 3 * 1000;
|
|
25
|
+
const RSS_CACHE_BUCKET_MS = 30 * 1000;
|
|
26
|
+
const STEAM_REQUEST_TIMEOUT_MS = 8 * 1000;
|
|
23
27
|
const RUNTIME_CACHE_CLEAR_INTERVAL_MS = 5 * 60 * 1000;
|
|
24
28
|
const TRANSLATE_TIMEOUT_MS = 90 * 1000;
|
|
25
29
|
const ASSET_DIR = node_path_1.default.resolve(__dirname, '..', 'assets');
|
|
@@ -27,7 +31,7 @@ const BRAND_LOGO_PATH = node_path_1.default.join(ASSET_DIR, 'brand-logo.png');
|
|
|
27
31
|
const GITHUB_QRCODE_PATH = node_path_1.default.join(ASSET_DIR, 'github-qrcode.png');
|
|
28
32
|
const loggerName = 'cs2-update-log';
|
|
29
33
|
exports.Config = koishi_1.Schema.object({
|
|
30
|
-
interval: koishi_1.Schema.number().min(5).step(1).default(
|
|
34
|
+
interval: koishi_1.Schema.number().min(5).step(1).default(15).description('轮询间隔,单位:秒。'),
|
|
31
35
|
count: koishi_1.Schema.number().min(1).max(100).step(1).default(20).description('每次从 Steam 拉取的新闻数量。'),
|
|
32
36
|
stateFile: koishi_1.Schema.string().default('.koishi-cs2-update-log.json').description('本地 gid 判重文件路径。相对路径会基于 Koishi 启动目录解析。'),
|
|
33
37
|
pushOnFirstRun: koishi_1.Schema.boolean().default(false).description('首次启动时是否推送历史内容。默认关闭,避免刷屏。'),
|
|
@@ -38,7 +42,7 @@ exports.Config = koishi_1.Schema.object({
|
|
|
38
42
|
guildId: koishi_1.Schema.string().description('可选群组 ID。标准 bot.sendMessage 只使用 channelId,此字段用于记录配置语义。'),
|
|
39
43
|
})).default([]).description('推送目标列表。'),
|
|
40
44
|
brandName: koishi_1.Schema.string().default('CS2 update').description('图片顶部品牌名。'),
|
|
41
|
-
siteName: koishi_1.Schema.string().default('
|
|
45
|
+
siteName: koishi_1.Schema.string().default('Github仓库').description('图片底部站点名。'),
|
|
42
46
|
picture: koishi_1.Schema.boolean().default(true).description('是否以 Puppeteer 截图长图形式推送。关闭后推送纯文本。'),
|
|
43
47
|
appendLink: koishi_1.Schema.boolean().default(true).description('图片或文本后是否附带 Steam 原文链接。'),
|
|
44
48
|
trans: koishi_1.Schema.boolean().default(false).description('是否启用 AI 翻译。关闭时推送 Steam 返回的原文。'),
|
|
@@ -76,6 +80,9 @@ function apply(ctx, config) {
|
|
|
76
80
|
};
|
|
77
81
|
let stateInitialized = false;
|
|
78
82
|
let polling = false;
|
|
83
|
+
let appReady = false;
|
|
84
|
+
let stateLoadPromise;
|
|
85
|
+
let initialPollStarted = false;
|
|
79
86
|
function clearRuntimeCache(reason) {
|
|
80
87
|
cache.rssFetchedAt = 0;
|
|
81
88
|
cache.rssItems = [];
|
|
@@ -103,7 +110,9 @@ function apply(ctx, config) {
|
|
|
103
110
|
}
|
|
104
111
|
async function saveState(recentItems) {
|
|
105
112
|
try {
|
|
106
|
-
const recentGids = recentItems
|
|
113
|
+
const recentGids = recentItems
|
|
114
|
+
.map((item) => item.gid)
|
|
115
|
+
.filter((gid) => gid && knownGids.has(gid));
|
|
107
116
|
const recentSet = new Set(recentGids);
|
|
108
117
|
const gids = [
|
|
109
118
|
...recentGids,
|
|
@@ -130,13 +139,48 @@ function apply(ctx, config) {
|
|
|
130
139
|
logger.debug('使用 RSS 短时缓存:items=%d', cache.rssItems.length);
|
|
131
140
|
return cache.rssItems.slice(0, config.count);
|
|
132
141
|
}
|
|
133
|
-
const
|
|
142
|
+
const cacheBucket = Math.floor(Date.now() / RSS_CACHE_BUCKET_MS);
|
|
143
|
+
const fastlyUrl = `${STEAM_FASTLY_RSS_URL}?l=english&_=${cacheBucket}`;
|
|
144
|
+
const apiUrl = `${STEAM_NEWS_API_URL}?appid=${APP_ID}&count=${config.count}&maxlength=0&format=json`;
|
|
145
|
+
let items;
|
|
146
|
+
try {
|
|
147
|
+
items = await Promise.any([
|
|
148
|
+
fetchRssSource('Fastly RSS', fastlyUrl),
|
|
149
|
+
fetchApiSource('Steam Web API', apiUrl),
|
|
150
|
+
]);
|
|
151
|
+
}
|
|
152
|
+
catch (primaryError) {
|
|
153
|
+
const storeUrl = `${STEAM_STORE_RSS_URL}?l=english&_=${cacheBucket}`;
|
|
154
|
+
logger.warn('Fastly RSS 与 Steam Web API 均不可用,回退到 Store RSS:%s', formatAggregateError(primaryError));
|
|
155
|
+
items = await fetchRssSource('Store RSS', storeUrl);
|
|
156
|
+
}
|
|
157
|
+
const cachedItems = items.map((item) => {
|
|
158
|
+
const cached = cache.newsByGid.get(item.gid);
|
|
159
|
+
if (cached && cached.title === item.title && cached.content === item.content && cached.url === item.url && cached.date === item.date) {
|
|
160
|
+
return cached;
|
|
161
|
+
}
|
|
162
|
+
cache.newsByGid.set(item.gid, item);
|
|
163
|
+
return item;
|
|
164
|
+
});
|
|
165
|
+
cache.rssFetchedAt = Date.now();
|
|
166
|
+
cache.rssItems = cachedItems;
|
|
167
|
+
return cachedItems.slice(0, config.count);
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
logger.error('拉取 Steam CS2 官方新闻失败:\n%s', formatError(error));
|
|
171
|
+
return [];
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
async function fetchRssSource(source, url) {
|
|
175
|
+
const startedAt = Date.now();
|
|
176
|
+
try {
|
|
177
|
+
const xml = await ctx.http.get(url, {
|
|
134
178
|
headers: {
|
|
135
179
|
Accept: 'application/rss+xml, application/xml, text/xml;q=0.9, */*;q=0.8',
|
|
136
|
-
'User-Agent': 'koishi-plugin-cs2-update-log/2.
|
|
180
|
+
'User-Agent': 'koishi-plugin-cs2-update-log/2.2',
|
|
137
181
|
},
|
|
138
182
|
responseType: 'text',
|
|
139
|
-
timeout:
|
|
183
|
+
timeout: STEAM_REQUEST_TIMEOUT_MS,
|
|
140
184
|
});
|
|
141
185
|
const parsed = rssParser.parse(xml);
|
|
142
186
|
const rawItems = parsed.rss?.channel?.item;
|
|
@@ -144,21 +188,37 @@ function apply(ctx, config) {
|
|
|
144
188
|
const parsedItems = items
|
|
145
189
|
.map(parseRssItem)
|
|
146
190
|
.filter((item) => !!item?.gid && !!item.title);
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
191
|
+
if (!parsedItems.length)
|
|
192
|
+
throw new Error('返回内容中没有有效新闻');
|
|
193
|
+
logger.debug('%s 拉取成功:items=%d duration=%dms', source, parsedItems.length, Date.now() - startedAt);
|
|
194
|
+
return parsedItems;
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
logger.debug('%s 拉取失败:duration=%dms url=%s\n%s', source, Date.now() - startedAt, url, formatError(error));
|
|
198
|
+
throw new Error(`${source} 拉取失败:${formatError(error)}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
async function fetchApiSource(source, url) {
|
|
202
|
+
const startedAt = Date.now();
|
|
203
|
+
try {
|
|
204
|
+
const response = await ctx.http.get(url, {
|
|
205
|
+
headers: {
|
|
206
|
+
Accept: 'application/json',
|
|
207
|
+
'User-Agent': 'koishi-plugin-cs2-update-log/2.2',
|
|
208
|
+
},
|
|
209
|
+
timeout: STEAM_REQUEST_TIMEOUT_MS,
|
|
154
210
|
});
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
211
|
+
const parsedItems = (response.appnews?.newsitems || [])
|
|
212
|
+
.map(parseSteamApiItem)
|
|
213
|
+
.filter((item) => !!item?.gid && !!item.title);
|
|
214
|
+
if (!parsedItems.length)
|
|
215
|
+
throw new Error('返回内容中没有有效新闻');
|
|
216
|
+
logger.debug('%s 拉取成功:items=%d duration=%dms', source, parsedItems.length, Date.now() - startedAt);
|
|
217
|
+
return parsedItems;
|
|
158
218
|
}
|
|
159
219
|
catch (error) {
|
|
160
|
-
logger.
|
|
161
|
-
|
|
220
|
+
logger.debug('%s 拉取失败:duration=%dms url=%s\n%s', source, Date.now() - startedAt, url, formatError(error));
|
|
221
|
+
throw new Error(`${source} 拉取失败:${formatError(error)}`);
|
|
162
222
|
}
|
|
163
223
|
}
|
|
164
224
|
async function pollAndPush(source) {
|
|
@@ -185,7 +245,11 @@ function apply(ctx, config) {
|
|
|
185
245
|
let pushed = 0;
|
|
186
246
|
for (const item of freshItems) {
|
|
187
247
|
const classified = classifyNews(item);
|
|
188
|
-
await pushNews(classified);
|
|
248
|
+
const delivered = await pushNews(classified);
|
|
249
|
+
if (!delivered) {
|
|
250
|
+
logger.warn('新闻未成功送达,保留 gid 等待重试:gid=%s title=%s', item.gid, item.title);
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
189
253
|
knownGids.add(item.gid);
|
|
190
254
|
pushed += 1;
|
|
191
255
|
}
|
|
@@ -211,24 +275,57 @@ function apply(ctx, config) {
|
|
|
211
275
|
: buildTextMessage(news, displayTitle, displayMarkdown, link);
|
|
212
276
|
}
|
|
213
277
|
async function pushNews(news) {
|
|
214
|
-
const content = await buildNewsContent(news);
|
|
215
278
|
if (!config.targets.length) {
|
|
216
279
|
logger.warn('未配置推送目标,已跳过:%s', news.item.title);
|
|
217
|
-
return;
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
const deliveries = config.targets.map((target) => ({
|
|
283
|
+
target,
|
|
284
|
+
bot: findTargetBot(ctx, target),
|
|
285
|
+
}));
|
|
286
|
+
const unavailable = deliveries.filter(({ bot }) => !bot);
|
|
287
|
+
if (unavailable.length) {
|
|
288
|
+
for (const { target } of unavailable) {
|
|
289
|
+
logger.warn('找不到在线推送机器人 platform=%s selfId=%s', target.platform, target.selfId || '*');
|
|
290
|
+
}
|
|
291
|
+
return false;
|
|
218
292
|
}
|
|
219
|
-
|
|
293
|
+
const content = await buildNewsContent(news);
|
|
294
|
+
const results = await Promise.all(deliveries.map(async ({ target, bot }) => {
|
|
220
295
|
try {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
logger.warn('找不到推送机器人 platform=%s selfId=%s', target.platform, target.selfId || '*');
|
|
224
|
-
continue;
|
|
225
|
-
}
|
|
296
|
+
if (!bot)
|
|
297
|
+
return false;
|
|
226
298
|
await bot.sendMessage(target.channelId, content);
|
|
299
|
+
return true;
|
|
227
300
|
}
|
|
228
301
|
catch (error) {
|
|
229
302
|
logger.error('推送到 channelId=%s 失败:%s', target.channelId, formatError(error));
|
|
303
|
+
return false;
|
|
230
304
|
}
|
|
305
|
+
}));
|
|
306
|
+
return results.every(Boolean);
|
|
307
|
+
}
|
|
308
|
+
function ensureStateLoaded() {
|
|
309
|
+
return stateLoadPromise ||= loadState();
|
|
310
|
+
}
|
|
311
|
+
function allTargetBotsOnline() {
|
|
312
|
+
return !!config.targets.length && config.targets.every((target) => !!findTargetBot(ctx, target));
|
|
313
|
+
}
|
|
314
|
+
async function pollWhenTargetsOnline(source) {
|
|
315
|
+
if (!appReady) {
|
|
316
|
+
logger.debug('应用尚未 ready,跳过本次 %s 轮询。', source);
|
|
317
|
+
return 0;
|
|
318
|
+
}
|
|
319
|
+
await ensureStateLoaded();
|
|
320
|
+
if (!allTargetBotsOnline()) {
|
|
321
|
+
logger.debug('目标机器人尚未全部在线,跳过本次 %s 轮询。', source);
|
|
322
|
+
return 0;
|
|
323
|
+
}
|
|
324
|
+
if (!initialPollStarted) {
|
|
325
|
+
initialPollStarted = true;
|
|
326
|
+
return pollAndPush('startup');
|
|
231
327
|
}
|
|
328
|
+
return pollAndPush(source);
|
|
232
329
|
}
|
|
233
330
|
async function renderOrFallbackText(news, title, bodyMarkdown, link) {
|
|
234
331
|
const puppeteer = ctx.puppeteer;
|
|
@@ -267,7 +364,7 @@ function apply(ctx, config) {
|
|
|
267
364
|
});
|
|
268
365
|
}
|
|
269
366
|
await page.setContent(html, {
|
|
270
|
-
waitUntil: '
|
|
367
|
+
waitUntil: 'load',
|
|
271
368
|
});
|
|
272
369
|
const card = await page.$('#card');
|
|
273
370
|
if (!card)
|
|
@@ -385,7 +482,9 @@ function apply(ctx, config) {
|
|
|
385
482
|
});
|
|
386
483
|
ctx.command('cs2log.push', '手动检查并推送新的 CS2 官方公告')
|
|
387
484
|
.action(async () => {
|
|
388
|
-
|
|
485
|
+
if (!allTargetBotsOnline())
|
|
486
|
+
return '目标机器人尚未在线,暂未执行推送。';
|
|
487
|
+
const pushed = await pollWhenTargetsOnline('manual');
|
|
389
488
|
return pushed ? `已推送 ${pushed} 条新的 CS2 官方新闻。` : '没有发现新的 CS2 官方新闻。';
|
|
390
489
|
});
|
|
391
490
|
ctx.command('cs2log.test', '测试推送最近 2 条 CS2 官方新闻')
|
|
@@ -402,15 +501,36 @@ function apply(ctx, config) {
|
|
|
402
501
|
}
|
|
403
502
|
return `已向当前会话触发 ${testItems.length} 条 CS2 官方新闻测试推送。本次测试不会写入 gid 判重 state。`;
|
|
404
503
|
});
|
|
405
|
-
ctx.on('ready', () => {
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
504
|
+
ctx.on('ready', async () => {
|
|
505
|
+
appReady = true;
|
|
506
|
+
try {
|
|
507
|
+
await pollWhenTargetsOnline('startup');
|
|
508
|
+
}
|
|
509
|
+
catch (error) {
|
|
409
510
|
logger.error('启动 CS2 新闻轮询失败:%s', formatError(error));
|
|
511
|
+
}
|
|
512
|
+
});
|
|
513
|
+
ctx.on('bot-status-updated', async (bot) => {
|
|
514
|
+
if (bot.status !== 1 /* Universal.Status.ONLINE */)
|
|
515
|
+
return;
|
|
516
|
+
const matchesTarget = config.targets.some((target) => {
|
|
517
|
+
if (target.platform && bot.platform !== target.platform)
|
|
518
|
+
return false;
|
|
519
|
+
if (target.selfId && bot.selfId !== target.selfId)
|
|
520
|
+
return false;
|
|
521
|
+
return true;
|
|
410
522
|
});
|
|
523
|
+
if (!matchesTarget)
|
|
524
|
+
return;
|
|
525
|
+
try {
|
|
526
|
+
await pollWhenTargetsOnline('online');
|
|
527
|
+
}
|
|
528
|
+
catch (error) {
|
|
529
|
+
logger.error('机器人上线后检查 CS2 新闻失败:%s', formatError(error));
|
|
530
|
+
}
|
|
411
531
|
});
|
|
412
532
|
ctx.setInterval(() => {
|
|
413
|
-
void
|
|
533
|
+
void pollWhenTargetsOnline('timer');
|
|
414
534
|
}, Math.max(5, config.interval) * 1000);
|
|
415
535
|
ctx.setInterval(() => {
|
|
416
536
|
clearRuntimeCache('short cache interval');
|
|
@@ -440,11 +560,25 @@ function parseRssItem(item) {
|
|
|
440
560
|
gid,
|
|
441
561
|
title,
|
|
442
562
|
url,
|
|
443
|
-
author: '
|
|
563
|
+
author: 'BestBcz',
|
|
444
564
|
content: readXmlText(item.description),
|
|
445
565
|
date: Number.isFinite(dateValue) ? Math.floor(dateValue / 1000) : 0,
|
|
446
566
|
};
|
|
447
567
|
}
|
|
568
|
+
function parseSteamApiItem(item) {
|
|
569
|
+
const gid = String(item.gid || '').trim();
|
|
570
|
+
const title = decodeHtmlEntities(String(item.title || '').trim());
|
|
571
|
+
if (!gid || !title)
|
|
572
|
+
return null;
|
|
573
|
+
return {
|
|
574
|
+
gid,
|
|
575
|
+
title,
|
|
576
|
+
url: String(item.url || '').trim(),
|
|
577
|
+
author: String(item.author || 'Valve').trim(),
|
|
578
|
+
content: String(item.contents || ''),
|
|
579
|
+
date: Number.isFinite(item.date) ? Number(item.date) : 0,
|
|
580
|
+
};
|
|
581
|
+
}
|
|
448
582
|
function readXmlText(value) {
|
|
449
583
|
if (value == null)
|
|
450
584
|
return '';
|
|
@@ -762,13 +896,13 @@ body {
|
|
|
762
896
|
font-size: 14px;
|
|
763
897
|
}
|
|
764
898
|
.qr-code {
|
|
765
|
-
|
|
766
|
-
|
|
899
|
+
display: block;
|
|
900
|
+
width: 64px;
|
|
901
|
+
height: 64px;
|
|
767
902
|
flex: 0 0 auto;
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
border: 1px solid rgba(255, 255, 255, 0.22);
|
|
903
|
+
border-radius: 5px;
|
|
904
|
+
background: #ffffff;
|
|
905
|
+
object-fit: cover;
|
|
772
906
|
}
|
|
773
907
|
</style>
|
|
774
908
|
</head>
|
|
@@ -812,6 +946,8 @@ body {
|
|
|
812
946
|
function findTargetBot(ctx, target) {
|
|
813
947
|
const bots = Array.from(ctx.bots || []);
|
|
814
948
|
return bots.find((bot) => {
|
|
949
|
+
if (bot.status !== 1 /* Universal.Status.ONLINE */)
|
|
950
|
+
return false;
|
|
815
951
|
if (target.platform && bot.platform !== target.platform)
|
|
816
952
|
return false;
|
|
817
953
|
if (target.selfId && bot.selfId !== target.selfId)
|
|
@@ -917,6 +1053,12 @@ function formatError(error) {
|
|
|
917
1053
|
appendErrorDetails(lines, error);
|
|
918
1054
|
return lines.join('\n');
|
|
919
1055
|
}
|
|
1056
|
+
function formatAggregateError(error) {
|
|
1057
|
+
if (error instanceof AggregateError) {
|
|
1058
|
+
return error.errors.map((item) => formatError(item)).join(' | ');
|
|
1059
|
+
}
|
|
1060
|
+
return formatError(error);
|
|
1061
|
+
}
|
|
920
1062
|
function appendErrorDetails(lines, error, label = 'error', depth = 0, seen = new Set()) {
|
|
921
1063
|
if (error == null || typeof error !== 'object') {
|
|
922
1064
|
lines.push(`${label}: ${String(error)}`);
|
package/package.json
CHANGED