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.
Files changed (3) hide show
  1. package/README.md +4 -3
  2. package/lib/index.js +185 -43
  3. 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 `https://store.steampowered.com/feeds/news/app/730/` 拉取 AppID `730` 的官方公告流。
10
- - 默认每 30 秒轮询一次。
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
- - 内置短时运行时缓存:RSS 列表缓存 15 秒,新闻内容、AI 翻译结果、长图 PNG 和卡片静态资源每 5 分钟清理一次,减少连续测试/重复推送的开销。
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 STEAM_RSS_URL = `https://store.steampowered.com/feeds/news/app/${APP_ID}/`;
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 = 15 * 1000;
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(30).description('轮询间隔,单位:秒。'),
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('CS2官方').description('图片底部站点名。'),
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.map((item) => item.gid).filter(Boolean);
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 xml = await ctx.http.get(STEAM_RSS_URL, {
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.1',
180
+ 'User-Agent': 'koishi-plugin-cs2-update-log/2.2',
137
181
  },
138
182
  responseType: 'text',
139
- timeout: 30000,
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
- const cachedItems = parsedItems.map((item) => {
148
- const cached = cache.newsByGid.get(item.gid);
149
- if (cached && cached.title === item.title && cached.content === item.content && cached.url === item.url && cached.date === item.date) {
150
- return cached;
151
- }
152
- cache.newsByGid.set(item.gid, item);
153
- return item;
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
- cache.rssFetchedAt = Date.now();
156
- cache.rssItems = cachedItems;
157
- return cachedItems.slice(0, config.count);
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.error('拉取 Steam CS2 RSS 失败:url=%s\n%s', STEAM_RSS_URL, formatError(error));
161
- return [];
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
- for (const target of config.targets) {
293
+ const content = await buildNewsContent(news);
294
+ const results = await Promise.all(deliveries.map(async ({ target, bot }) => {
220
295
  try {
221
- const bot = findTargetBot(ctx, target);
222
- if (!bot) {
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: 'networkidle0',
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
- const pushed = await pollAndPush('manual');
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
- void loadState()
407
- .then(() => pollAndPush('startup'))
408
- .catch((error) => {
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 pollAndPush('timer');
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: 'Valve',
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
- width: 62px;
766
- height: 62px;
899
+ display: block;
900
+ width: 64px;
901
+ height: 64px;
767
902
  flex: 0 0 auto;
768
- padding: 5px;
769
- border-radius: 8px;
770
- background: rgba(255, 255, 255, 0.94);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koishi-plugin-cs2-update-log",
3
- "version": "2.2.3",
3
+ "version": "2.3.1",
4
4
  "description": "Koishi plugin for monitoring CS2 official Steam announcements and update logs.",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",