koishi-plugin-cs2-update-log 2.2.3 → 2.3.0

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 +106 -31
  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 返回的原文。'),
@@ -130,13 +134,48 @@ function apply(ctx, config) {
130
134
  logger.debug('使用 RSS 短时缓存:items=%d', cache.rssItems.length);
131
135
  return cache.rssItems.slice(0, config.count);
132
136
  }
133
- const xml = await ctx.http.get(STEAM_RSS_URL, {
137
+ const cacheBucket = Math.floor(Date.now() / RSS_CACHE_BUCKET_MS);
138
+ const fastlyUrl = `${STEAM_FASTLY_RSS_URL}?l=english&_=${cacheBucket}`;
139
+ const apiUrl = `${STEAM_NEWS_API_URL}?appid=${APP_ID}&count=${config.count}&maxlength=0&format=json`;
140
+ let items;
141
+ try {
142
+ items = await Promise.any([
143
+ fetchRssSource('Fastly RSS', fastlyUrl),
144
+ fetchApiSource('Steam Web API', apiUrl),
145
+ ]);
146
+ }
147
+ catch (primaryError) {
148
+ const storeUrl = `${STEAM_STORE_RSS_URL}?l=english&_=${cacheBucket}`;
149
+ logger.warn('Fastly RSS 与 Steam Web API 均不可用,回退到 Store RSS:%s', formatAggregateError(primaryError));
150
+ items = await fetchRssSource('Store RSS', storeUrl);
151
+ }
152
+ const cachedItems = items.map((item) => {
153
+ const cached = cache.newsByGid.get(item.gid);
154
+ if (cached && cached.title === item.title && cached.content === item.content && cached.url === item.url && cached.date === item.date) {
155
+ return cached;
156
+ }
157
+ cache.newsByGid.set(item.gid, item);
158
+ return item;
159
+ });
160
+ cache.rssFetchedAt = Date.now();
161
+ cache.rssItems = cachedItems;
162
+ return cachedItems.slice(0, config.count);
163
+ }
164
+ catch (error) {
165
+ logger.error('拉取 Steam CS2 官方新闻失败:\n%s', formatError(error));
166
+ return [];
167
+ }
168
+ }
169
+ async function fetchRssSource(source, url) {
170
+ const startedAt = Date.now();
171
+ try {
172
+ const xml = await ctx.http.get(url, {
134
173
  headers: {
135
174
  Accept: 'application/rss+xml, application/xml, text/xml;q=0.9, */*;q=0.8',
136
- 'User-Agent': 'koishi-plugin-cs2-update-log/2.1',
175
+ 'User-Agent': 'koishi-plugin-cs2-update-log/2.2',
137
176
  },
138
177
  responseType: 'text',
139
- timeout: 30000,
178
+ timeout: STEAM_REQUEST_TIMEOUT_MS,
140
179
  });
141
180
  const parsed = rssParser.parse(xml);
142
181
  const rawItems = parsed.rss?.channel?.item;
@@ -144,21 +183,37 @@ function apply(ctx, config) {
144
183
  const parsedItems = items
145
184
  .map(parseRssItem)
146
185
  .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;
186
+ if (!parsedItems.length)
187
+ throw new Error('返回内容中没有有效新闻');
188
+ logger.debug('%s 拉取成功:items=%d duration=%dms', source, parsedItems.length, Date.now() - startedAt);
189
+ return parsedItems;
190
+ }
191
+ catch (error) {
192
+ logger.debug('%s 拉取失败:duration=%dms url=%s\n%s', source, Date.now() - startedAt, url, formatError(error));
193
+ throw new Error(`${source} 拉取失败:${formatError(error)}`);
194
+ }
195
+ }
196
+ async function fetchApiSource(source, url) {
197
+ const startedAt = Date.now();
198
+ try {
199
+ const response = await ctx.http.get(url, {
200
+ headers: {
201
+ Accept: 'application/json',
202
+ 'User-Agent': 'koishi-plugin-cs2-update-log/2.2',
203
+ },
204
+ timeout: STEAM_REQUEST_TIMEOUT_MS,
154
205
  });
155
- cache.rssFetchedAt = Date.now();
156
- cache.rssItems = cachedItems;
157
- return cachedItems.slice(0, config.count);
206
+ const parsedItems = (response.appnews?.newsitems || [])
207
+ .map(parseSteamApiItem)
208
+ .filter((item) => !!item?.gid && !!item.title);
209
+ if (!parsedItems.length)
210
+ throw new Error('返回内容中没有有效新闻');
211
+ logger.debug('%s 拉取成功:items=%d duration=%dms', source, parsedItems.length, Date.now() - startedAt);
212
+ return parsedItems;
158
213
  }
159
214
  catch (error) {
160
- logger.error('拉取 Steam CS2 RSS 失败:url=%s\n%s', STEAM_RSS_URL, formatError(error));
161
- return [];
215
+ logger.debug('%s 拉取失败:duration=%dms url=%s\n%s', source, Date.now() - startedAt, url, formatError(error));
216
+ throw new Error(`${source} 拉取失败:${formatError(error)}`);
162
217
  }
163
218
  }
164
219
  async function pollAndPush(source) {
@@ -211,24 +266,24 @@ function apply(ctx, config) {
211
266
  : buildTextMessage(news, displayTitle, displayMarkdown, link);
212
267
  }
213
268
  async function pushNews(news) {
214
- const content = await buildNewsContent(news);
215
269
  if (!config.targets.length) {
216
270
  logger.warn('未配置推送目标,已跳过:%s', news.item.title);
217
271
  return;
218
272
  }
219
- for (const target of config.targets) {
273
+ const content = await buildNewsContent(news);
274
+ await Promise.all(config.targets.map(async (target) => {
220
275
  try {
221
276
  const bot = findTargetBot(ctx, target);
222
277
  if (!bot) {
223
278
  logger.warn('找不到推送机器人 platform=%s selfId=%s', target.platform, target.selfId || '*');
224
- continue;
279
+ return;
225
280
  }
226
281
  await bot.sendMessage(target.channelId, content);
227
282
  }
228
283
  catch (error) {
229
284
  logger.error('推送到 channelId=%s 失败:%s', target.channelId, formatError(error));
230
285
  }
231
- }
286
+ }));
232
287
  }
233
288
  async function renderOrFallbackText(news, title, bodyMarkdown, link) {
234
289
  const puppeteer = ctx.puppeteer;
@@ -267,7 +322,7 @@ function apply(ctx, config) {
267
322
  });
268
323
  }
269
324
  await page.setContent(html, {
270
- waitUntil: 'networkidle0',
325
+ waitUntil: 'load',
271
326
  });
272
327
  const card = await page.$('#card');
273
328
  if (!card)
@@ -440,11 +495,25 @@ function parseRssItem(item) {
440
495
  gid,
441
496
  title,
442
497
  url,
443
- author: 'Valve',
498
+ author: 'BestBcz',
444
499
  content: readXmlText(item.description),
445
500
  date: Number.isFinite(dateValue) ? Math.floor(dateValue / 1000) : 0,
446
501
  };
447
502
  }
503
+ function parseSteamApiItem(item) {
504
+ const gid = String(item.gid || '').trim();
505
+ const title = decodeHtmlEntities(String(item.title || '').trim());
506
+ if (!gid || !title)
507
+ return null;
508
+ return {
509
+ gid,
510
+ title,
511
+ url: String(item.url || '').trim(),
512
+ author: String(item.author || 'Valve').trim(),
513
+ content: String(item.contents || ''),
514
+ date: Number.isFinite(item.date) ? Number(item.date) : 0,
515
+ };
516
+ }
448
517
  function readXmlText(value) {
449
518
  if (value == null)
450
519
  return '';
@@ -762,13 +831,13 @@ body {
762
831
  font-size: 14px;
763
832
  }
764
833
  .qr-code {
765
- width: 62px;
766
- height: 62px;
834
+ display: block;
835
+ width: 64px;
836
+ height: 64px;
767
837
  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);
838
+ border-radius: 5px;
839
+ background: #ffffff;
840
+ object-fit: cover;
772
841
  }
773
842
  </style>
774
843
  </head>
@@ -917,6 +986,12 @@ function formatError(error) {
917
986
  appendErrorDetails(lines, error);
918
987
  return lines.join('\n');
919
988
  }
989
+ function formatAggregateError(error) {
990
+ if (error instanceof AggregateError) {
991
+ return error.errors.map((item) => formatError(item)).join(' | ');
992
+ }
993
+ return formatError(error);
994
+ }
920
995
  function appendErrorDetails(lines, error, label = 'error', depth = 0, seen = new Set()) {
921
996
  if (error == null || typeof error !== 'object') {
922
997
  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.0",
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",