koishi-plugin-maichuni-scorehelper 0.0.16-test → 0.0.18-test

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.
@@ -5,6 +5,9 @@ export interface Config {
5
5
  targetChannelId?: string;
6
6
  showRecovery: boolean;
7
7
  mainServiceName: string;
8
+ statusPageUrl: string;
9
+ heartbeatApiUrl: string;
10
+ debug?: boolean;
8
11
  }
9
12
  export declare const Config: Schema<Config>;
10
13
  declare module 'koishi' {
@@ -18,11 +21,10 @@ export declare class MaimaiStatus extends Service {
18
21
  private clientLogger;
19
22
  private timer;
20
23
  private groups;
21
- private readonly PAGE_URL;
22
- private readonly API_URL;
23
24
  private readonly CACHE_FILE;
24
25
  private readonly UA;
25
26
  constructor(ctx: Context, config: Config);
27
+ private logDebug;
26
28
  protected start(): Promise<void>;
27
29
  protected stop(): Promise<void>;
28
30
  /**
@@ -7,12 +7,17 @@ exports.MaimaiStatus = exports.Config = exports.name = void 0;
7
7
  const koishi_1 = require("koishi");
8
8
  const promises_1 = __importDefault(require("fs/promises"));
9
9
  const path_1 = __importDefault(require("path"));
10
+ const DEFAULT_STATUS_PAGE_URL = 'https://maimaistatusreverseproxy.muxyang.com/status/maimai';
11
+ const DEFAULT_HEARTBEAT_API_URL = 'https://maimaistatusreverseproxy.muxyang.com/api/status-page/heartbeat/maimai';
10
12
  exports.name = 'maimai-status-monitor';
11
13
  exports.Config = koishi_1.Schema.object({
12
14
  interval: koishi_1.Schema.number().default(60000).description('轮询间隔 (毫秒),建议不低于 30000'),
13
15
  targetChannelId: koishi_1.Schema.string().description('推送通知的目标群组/频道 ID (可选,留空则仅在控制台输出)'),
14
16
  showRecovery: koishi_1.Schema.boolean().default(true).description('是否在服务恢复时也发送通知'),
15
- mainServiceName: koishi_1.Schema.string().default('舞萌DX服务').description('主服务名称(去除了后缀的),当该服务下线时,屏蔽其他服务的通知')
17
+ mainServiceName: koishi_1.Schema.string().default('舞萌DX服务').description('主服务名称(去除了后缀的),当该服务下线时,屏蔽其他服务的通知'),
18
+ statusPageUrl: koishi_1.Schema.string().default(DEFAULT_STATUS_PAGE_URL).description('状态页 URL,默认使用内置反代,可自定义'),
19
+ heartbeatApiUrl: koishi_1.Schema.string().default(DEFAULT_HEARTBEAT_API_URL).description('心跳 API URL,默认使用内置反代,可自定义'),
20
+ debug: koishi_1.Schema.boolean().default(false).description('开启后输出详细调试日志')
16
21
  });
17
22
  // 常量配置
18
23
  const FLAP_WINDOW = 10 * 60 * 1000; // 10分钟
@@ -27,12 +32,16 @@ class MaimaiStatus extends koishi_1.Service {
27
32
  // 内存中保存的分组状态映射: GroupName -> GroupData
28
33
  this.groups = new Map();
29
34
  // API 地址常量(已切换到反向代理域名以绕过 CC)
30
- this.PAGE_URL = 'https://maimaistatusreverseproxy.muxyang.com/status/maimai';
31
- this.API_URL = 'https://maimaistatusreverseproxy.muxyang.com/api/status-page/heartbeat/maimai';
32
35
  this.CACHE_FILE = path_1.default.join(__dirname, '../../monitor_cache.json');
33
36
  this.UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
34
37
  this.clientLogger = ctx.logger('maimai-status');
35
38
  }
39
+ logDebug(...args) {
40
+ if (!this.config.debug)
41
+ return;
42
+ // 使用 apply 规避编译器对可变参数展开的限制
43
+ this.clientLogger.debug.apply(this.clientLogger, args);
44
+ }
36
45
  async start() {
37
46
  this.clientLogger.info('Maimai status monitor started.');
38
47
  // 立即执行一次
@@ -192,7 +201,7 @@ class MaimaiStatus extends koishi_1.Service {
192
201
  const page = await this.ctx.puppeteer.page();
193
202
  try {
194
203
  // 设置超时,防止卡死
195
- await page.goto(this.PAGE_URL, { waitUntil: 'domcontentloaded', timeout: 15000 });
204
+ await page.goto(this.config.statusPageUrl || DEFAULT_STATUS_PAGE_URL, { waitUntil: 'domcontentloaded', timeout: 15000 });
196
205
  // 等待可能的 JS 跳转 (CC Protect)
197
206
  try {
198
207
  await page.waitForNavigation({ waitUntil: 'networkidle0', timeout: 5000 });
@@ -200,6 +209,8 @@ class MaimaiStatus extends koishi_1.Service {
200
209
  catch (_) { /* ignore timeout */ }
201
210
  // 直接从页面上下文获取数据
202
211
  data = await page.evaluate(() => window.preloadData);
212
+ const bodySnippet = await page.evaluate(() => (document?.body?.innerText || '').slice(0, 50));
213
+ this.logDebug(`[monitor] puppeteer body head: ${bodySnippet}`);
203
214
  }
204
215
  finally {
205
216
  await page.close();
@@ -212,10 +223,11 @@ class MaimaiStatus extends koishi_1.Service {
212
223
  // 如果 Puppeteer 失败或未启用,回退到 HTTP (可能无法通过 CC 验证)
213
224
  if (!data) {
214
225
  try {
215
- const html = await this.ctx.http.get(this.PAGE_URL, {
226
+ const html = await this.ctx.http.get(this.config.statusPageUrl || DEFAULT_STATUS_PAGE_URL, {
216
227
  responseType: 'text',
217
228
  headers: { 'User-Agent': this.UA }
218
229
  });
230
+ this.logDebug(`[monitor] http body head: ${String(html).slice(0, 50)}`);
219
231
  const regex = /window\.preloadData\s*=\s*(\{.*?\});/s;
220
232
  const match = html.match(regex);
221
233
  if (match && match[1]) {
@@ -229,6 +241,7 @@ class MaimaiStatus extends koishi_1.Service {
229
241
  }
230
242
  if (data) {
231
243
  const monitorList = [];
244
+ this.logDebug('[monitor] parsed preloadData keys:', Object.keys(data || {}));
232
245
  if (data.config && Array.isArray(data.publicGroupList)) {
233
246
  for (const group of data.publicGroupList) {
234
247
  if (Array.isArray(group.monitorList)) {
@@ -236,6 +249,7 @@ class MaimaiStatus extends koishi_1.Service {
236
249
  }
237
250
  }
238
251
  }
252
+ this.logDebug(`[monitor] monitorList length=${monitorList.length}`);
239
253
  return monitorList;
240
254
  }
241
255
  return [];
@@ -292,8 +306,50 @@ class MaimaiStatus extends koishi_1.Service {
292
306
  * 步骤3: 获取 API JSON 数据
293
307
  */
294
308
  async fetchHeartbeats() {
309
+ const url = this.config.heartbeatApiUrl || DEFAULT_HEARTBEAT_API_URL;
310
+ // 使用 Puppeteer 绕过可能的 JS 质询 / CC 防护
311
+ const fetchViaPuppeteer = async () => {
312
+ if (!this.ctx.puppeteer)
313
+ return null;
314
+ let page = null;
315
+ try {
316
+ page = await this.ctx.puppeteer.page();
317
+ if (page.setUserAgent) {
318
+ await page.setUserAgent(this.UA);
319
+ }
320
+ await page.goto(url, { waitUntil: 'networkidle0', timeout: 20000 });
321
+ // 直接读取正文文本(可能被 JS 动态生成)
322
+ const body = await page.evaluate(() => document.body?.innerText || document.body?.textContent || '');
323
+ this.logDebug(`[heartbeat] puppeteer body head: ${body.slice(0, 50)}`);
324
+ if (!body)
325
+ return null;
326
+ try {
327
+ return JSON.parse(body);
328
+ }
329
+ catch (err2) {
330
+ this.clientLogger.warn(`Puppeteer heartbeat parse failed: ${err2.message}; body=${body.slice(0, 200)}`);
331
+ return null;
332
+ }
333
+ }
334
+ catch (pe) {
335
+ this.clientLogger.warn(`Puppeteer heartbeat fetch failed: ${pe.message}`);
336
+ return null;
337
+ }
338
+ finally {
339
+ if (page) {
340
+ try {
341
+ await page.close();
342
+ }
343
+ catch { /* ignore */ }
344
+ }
345
+ }
346
+ };
295
347
  try {
296
- let data = await this.ctx.http.get(this.API_URL);
348
+ let data = await this.ctx.http.get(url, {
349
+ headers: { 'User-Agent': this.UA },
350
+ timeout: 15000
351
+ });
352
+ this.logDebug(`[heartbeat] http body head: ${typeof data === 'string' ? data.slice(0, 50) : '[object]'}`);
297
353
  // 如果不是对象,尝试解析 JSON (因为 Object.keys 打印出了索引,说明是字符串)
298
354
  if (typeof data === 'string') {
299
355
  try {
@@ -303,48 +359,24 @@ class MaimaiStatus extends koishi_1.Service {
303
359
  const snippet = data.slice(0, 200);
304
360
  this.clientLogger.warn(`Heartbeat JSON parse failed: ${err.message}; snippet=${snippet}`);
305
361
  // 如果返回的是 CC HTML,尝试使用 Puppeteer 绕过
306
- if (this.ctx.puppeteer && snippet.includes('<!DOCTYPE')) {
307
- let page = null;
308
- try {
309
- page = await this.ctx.puppeteer.page();
310
- if (page.setUserAgent) {
311
- await page.setUserAgent(this.UA);
312
- }
313
- await page.goto(this.API_URL, { waitUntil: 'networkidle0', timeout: 15000 });
314
- const body = await page.evaluate(() => {
315
- const text = document.body?.innerText || document.body?.textContent || '';
316
- return text;
317
- });
318
- try {
319
- data = JSON.parse(body);
320
- }
321
- catch (err2) {
322
- this.clientLogger.warn(`Puppeteer heartbeat parse failed: ${err2.message}; body=${body.slice(0, 200)}`);
323
- return null;
324
- }
325
- }
326
- catch (pe) {
327
- this.clientLogger.warn(`Puppeteer heartbeat fetch failed: ${pe.message}`);
328
- return null;
329
- }
330
- finally {
331
- if (page) {
332
- try {
333
- await page.close();
334
- }
335
- catch { /* ignore */ }
336
- }
337
- }
338
- }
339
- else {
340
- return null;
362
+ if (snippet.includes('<!DOCTYPE')) {
363
+ const viaPuppeteer = await fetchViaPuppeteer();
364
+ if (viaPuppeteer)
365
+ return viaPuppeteer;
341
366
  }
367
+ return null;
342
368
  }
343
369
  }
344
370
  // 返回原始对象,包含 heartbeatList 和 uptimeList
371
+ this.logDebug('[heartbeat] parsed keys:', Array.isArray(data) ? `array length ${data.length}` : Object.keys(data || {}));
345
372
  return data || {};
346
373
  }
347
374
  catch (e) {
375
+ // HTTP 失败也尝试 Puppeteer(例如超时/质询)
376
+ this.clientLogger.warn(`HTTP heartbeat fetch failed, trying Puppeteer. reason=${e?.message || e}`);
377
+ const viaPuppeteer = await fetchViaPuppeteer();
378
+ if (viaPuppeteer)
379
+ return viaPuppeteer;
348
380
  this.clientLogger.error('Failed to fetch heartbeat JSON', e);
349
381
  return null;
350
382
  }
@@ -400,21 +432,12 @@ class MaimaiStatus extends koishi_1.Service {
400
432
  const isMainServiceOffline = mainServiceCurrentStatus === 'OFFLINE';
401
433
  for (const { group, currentStatus } of updates) {
402
434
  const { groupName, lastStatus, isMuted, muteUntil } = group;
435
+ this.logDebug(`[state] ${groupName}: last=${lastStatus} current=${currentStatus}`);
403
436
  // 检查静音过期
404
437
  if (isMuted && now > muteUntil) {
405
438
  group.isMuted = false;
406
439
  this.clientLogger.info(`[${groupName}] 静音结束,恢复监控通知。`);
407
440
  }
408
- // 0. 初始化或状态无变更的处理
409
- if (lastStatus === 'UNKNOWN') {
410
- // 首次初始化,静默更新
411
- group.lastStatus = currentStatus;
412
- group.consecutiveFailures = 0;
413
- continue;
414
- }
415
- if (currentStatus === lastStatus) {
416
- continue;
417
- }
418
441
  // 如果处于静音状态,只更新状态,不进行通知推送判定
419
442
  if (group.isMuted) {
420
443
  group.lastStatus = currentStatus;
@@ -427,9 +450,11 @@ class MaimaiStatus extends koishi_1.Service {
427
450
  // 静默更新状态,重置连续失败计数以防恢复瞬间误报
428
451
  group.consecutiveFailures = 0;
429
452
  group.lastStatus = currentStatus;
453
+ this.clientLogger.debug(`[${groupName}] 跳过通知:主服务离线,当前状态 ${currentStatus}`);
430
454
  continue;
431
455
  }
432
- // 正常的报警逻辑 (含 3 次确认)
456
+ // 正常的报警逻辑 (含 3 次确认)
457
+ // 注意:即便状态未变化,也需要累计 consecutiveFailures,以便长时间离线也能触发阈值。
433
458
  await this.processNotificationLogic(group, currentStatus);
434
459
  // 更新最后状态
435
460
  group.lastStatus = currentStatus;
@@ -447,12 +472,14 @@ class MaimaiStatus extends koishi_1.Service {
447
472
  const groupName = group.groupName || 'Unknown Service'; // Fallback
448
473
  const isAbnormal = currentStatus === 'OFFLINE' || currentStatus === 'PARTIAL_OFFLINE';
449
474
  const wasAbnormal = group.lastStatus === 'OFFLINE' || group.lastStatus === 'PARTIAL_OFFLINE';
475
+ const isInit = group.lastStatus === 'UNKNOWN';
450
476
  if (isAbnormal) {
451
- // 异常计数 +1
477
+ // 异常计数 +1(初次发现也要记一次)
452
478
  group.consecutiveFailures++;
453
479
  // 只有达到阈值(第3次)时才报警,且之前没有报过(或者这是一个新的连续序列)
454
480
  // 注意:如果已经是第 4, 5 次,保持当前状态不变,不再重复报
455
481
  if (group.consecutiveFailures === FAILURE_THRESHOLD) {
482
+ this.logDebug(`[notify] threshold reached for ${groupName}: last=${group.lastStatus} current=${currentStatus}`);
456
483
  await this.handleStatusChange(groupName, group.lastStatus, currentStatus);
457
484
  }
458
485
  else if (group.consecutiveFailures < FAILURE_THRESHOLD) {
@@ -463,11 +490,16 @@ class MaimaiStatus extends koishi_1.Service {
463
490
  // 当前是 ONLINE
464
491
  if (wasAbnormal && group.consecutiveFailures >= FAILURE_THRESHOLD) {
465
492
  // 之前是异常,且已经报警过了(超过阈值),现在恢复 -> 发送恢复通知
493
+ this.logDebug(`[notify] recovery for ${groupName}: last=${group.lastStatus} -> ONLINE`);
466
494
  await this.handleStatusChange(groupName, group.lastStatus, currentStatus);
467
495
  }
468
496
  // 重置计数
469
497
  group.consecutiveFailures = 0;
470
498
  }
499
+ // 首次初始化状态也要记录,防止后续一直停留在 UNKNOWN 导致不推送
500
+ if (isInit && group.lastStatus === 'UNKNOWN') {
501
+ group.lastStatus = currentStatus;
502
+ }
471
503
  }
472
504
  /**
473
505
  * 状态变更处理接口
@@ -502,6 +534,7 @@ class MaimaiStatus extends koishi_1.Service {
502
534
  }
503
535
  }
504
536
  if (shouldNotify) {
537
+ this.logDebug(`[notify] will push: ${name} ${from} -> ${to}`);
505
538
  // 防抖检测:仅在真正决定推送时记录
506
539
  const group = this.groups.get(name);
507
540
  if (group) {
@@ -532,6 +565,7 @@ class MaimaiStatus extends koishi_1.Service {
532
565
  // 获取 bot 实例发送 (需要确保 context 中有 bot)
533
566
  const bot = this.ctx.bots[0];
534
567
  if (bot) {
568
+ this.logDebug(`[push] send to ${this.config.targetChannelId} level=${level} msgHead=${message.slice(0, 50)}`);
535
569
  await bot.sendMessage(this.config.targetChannelId, message);
536
570
  }
537
571
  else {
@@ -542,6 +576,10 @@ class MaimaiStatus extends koishi_1.Service {
542
576
  this.clientLogger.error('Failed to send notification:', e);
543
577
  }
544
578
  }
579
+ else {
580
+ this.clientLogger.debug('Notification skipped: targetChannelId not configured.');
581
+ this.logDebug(`[push] skipped push, no target. level=${level} msgHead=${message.slice(0, 50)}`);
582
+ }
545
583
  // 示例:您也可以在这里添加 HTTP webhook 推送
546
584
  // await this.ctx.http.post('YOUR_WEBHOOK_URL', { content: message })
547
585
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-maichuni-scorehelper",
3
- "version": "0.0.16-test",
4
- "description": "【未完成】用于舞萌中二节奏分数辅助。",
3
+ "version": "0.0.18-test",
4
+ "description": "【未完成】一些舞萌中二节奏功能",
5
5
  "keywords": [
6
6
  "koishi",
7
7
  "plugin",
@@ -2,6 +2,9 @@ import { Context, Schema, Logger, Service } from 'koishi'
2
2
  import fs from 'fs/promises'
3
3
  import path from 'path'
4
4
 
5
+ const DEFAULT_STATUS_PAGE_URL = 'https://maimaistatusreverseproxy.muxyang.com/status/maimai'
6
+ const DEFAULT_HEARTBEAT_API_URL = 'https://maimaistatusreverseproxy.muxyang.com/api/status-page/heartbeat/maimai'
7
+
5
8
  export const name = 'maimai-status-monitor'
6
9
 
7
10
  export interface Config {
@@ -9,13 +12,19 @@ export interface Config {
9
12
  targetChannelId?: string
10
13
  showRecovery: boolean
11
14
  mainServiceName: string
15
+ statusPageUrl: string
16
+ heartbeatApiUrl: string
17
+ debug?: boolean
12
18
  }
13
19
 
14
20
  export const Config: Schema<Config> = Schema.object({
15
21
  interval: Schema.number().default(60000).description('轮询间隔 (毫秒),建议不低于 30000'),
16
22
  targetChannelId: Schema.string().description('推送通知的目标群组/频道 ID (可选,留空则仅在控制台输出)'),
17
23
  showRecovery: Schema.boolean().default(true).description('是否在服务恢复时也发送通知'),
18
- mainServiceName: Schema.string().default('舞萌DX服务').description('主服务名称(去除了后缀的),当该服务下线时,屏蔽其他服务的通知')
24
+ mainServiceName: Schema.string().default('舞萌DX服务').description('主服务名称(去除了后缀的),当该服务下线时,屏蔽其他服务的通知'),
25
+ statusPageUrl: Schema.string().default(DEFAULT_STATUS_PAGE_URL).description('状态页 URL,默认使用内置反代,可自定义'),
26
+ heartbeatApiUrl: Schema.string().default(DEFAULT_HEARTBEAT_API_URL).description('心跳 API URL,默认使用内置反代,可自定义'),
27
+ debug: Schema.boolean().default(false).description('开启后输出详细调试日志')
19
28
  })
20
29
 
21
30
  // 定义服务状态枚举
@@ -61,8 +70,6 @@ export class MaimaiStatus extends Service {
61
70
  private groups: Map<string, ServiceGroup> = new Map()
62
71
 
63
72
  // API 地址常量(已切换到反向代理域名以绕过 CC)
64
- private readonly PAGE_URL = 'https://maimaistatusreverseproxy.muxyang.com/status/maimai'
65
- private readonly API_URL = 'https://maimaistatusreverseproxy.muxyang.com/api/status-page/heartbeat/maimai'
66
73
  private readonly CACHE_FILE = path.join(__dirname, '../../monitor_cache.json')
67
74
  private readonly UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
68
75
 
@@ -71,6 +78,12 @@ export class MaimaiStatus extends Service {
71
78
  this.clientLogger = ctx.logger('maimai-status')
72
79
  }
73
80
 
81
+ private logDebug(...args: any[]) {
82
+ if (!this.config.debug) return
83
+ // 使用 apply 规避编译器对可变参数展开的限制
84
+ this.clientLogger.debug.apply(this.clientLogger, args as any)
85
+ }
86
+
74
87
  protected async start() {
75
88
  this.clientLogger.info('Maimai status monitor started.')
76
89
  // 立即执行一次
@@ -244,7 +257,7 @@ export class MaimaiStatus extends Service {
244
257
  const page = await this.ctx.puppeteer.page()
245
258
  try {
246
259
  // 设置超时,防止卡死
247
- await page.goto(this.PAGE_URL, { waitUntil: 'domcontentloaded', timeout: 15000 })
260
+ await page.goto(this.config.statusPageUrl || DEFAULT_STATUS_PAGE_URL, { waitUntil: 'domcontentloaded', timeout: 15000 })
248
261
  // 等待可能的 JS 跳转 (CC Protect)
249
262
  try {
250
263
  await page.waitForNavigation({ waitUntil: 'networkidle0', timeout: 5000 })
@@ -252,6 +265,8 @@ export class MaimaiStatus extends Service {
252
265
 
253
266
  // 直接从页面上下文获取数据
254
267
  data = await page.evaluate(() => (window as any).preloadData)
268
+ const bodySnippet = await page.evaluate(() => (document?.body?.innerText || '').slice(0, 50))
269
+ this.logDebug(`[monitor] puppeteer body head: ${bodySnippet}`)
255
270
  } finally {
256
271
  await page.close()
257
272
  }
@@ -263,10 +278,11 @@ export class MaimaiStatus extends Service {
263
278
  // 如果 Puppeteer 失败或未启用,回退到 HTTP (可能无法通过 CC 验证)
264
279
  if (!data) {
265
280
  try {
266
- const html = await this.ctx.http.get(this.PAGE_URL, {
281
+ const html = await this.ctx.http.get(this.config.statusPageUrl || DEFAULT_STATUS_PAGE_URL, {
267
282
  responseType: 'text',
268
283
  headers: { 'User-Agent': this.UA }
269
284
  })
285
+ this.logDebug(`[monitor] http body head: ${String(html).slice(0, 50)}`)
270
286
  const regex = /window\.preloadData\s*=\s*(\{.*?\});/s
271
287
  const match = html.match(regex)
272
288
 
@@ -281,6 +297,7 @@ export class MaimaiStatus extends Service {
281
297
 
282
298
  if (data) {
283
299
  const monitorList: MonitorItem[] = []
300
+ this.logDebug('[monitor] parsed preloadData keys:', Object.keys(data || {}))
284
301
  if (data.config && Array.isArray(data.publicGroupList)) {
285
302
  for (const group of data.publicGroupList) {
286
303
  if (Array.isArray(group.monitorList)) {
@@ -288,6 +305,7 @@ export class MaimaiStatus extends Service {
288
305
  }
289
306
  }
290
307
  }
308
+ this.logDebug(`[monitor] monitorList length=${monitorList.length}`)
291
309
  return monitorList
292
310
  }
293
311
 
@@ -350,8 +368,44 @@ export class MaimaiStatus extends Service {
350
368
  * 步骤3: 获取 API JSON 数据
351
369
  */
352
370
  private async fetchHeartbeats(): Promise<any> {
371
+ const url = this.config.heartbeatApiUrl || DEFAULT_HEARTBEAT_API_URL
372
+
373
+ // 使用 Puppeteer 绕过可能的 JS 质询 / CC 防护
374
+ const fetchViaPuppeteer = async (): Promise<any | null> => {
375
+ if (!this.ctx.puppeteer) return null
376
+ let page: any = null
377
+ try {
378
+ page = await this.ctx.puppeteer.page()
379
+ if (page.setUserAgent) {
380
+ await page.setUserAgent(this.UA)
381
+ }
382
+ await page.goto(url, { waitUntil: 'networkidle0', timeout: 20000 })
383
+ // 直接读取正文文本(可能被 JS 动态生成)
384
+ const body = await page.evaluate(() => document.body?.innerText || document.body?.textContent || '')
385
+ this.logDebug(`[heartbeat] puppeteer body head: ${body.slice(0, 50)}`)
386
+ if (!body) return null
387
+ try {
388
+ return JSON.parse(body)
389
+ } catch (err2) {
390
+ this.clientLogger.warn(`Puppeteer heartbeat parse failed: ${(err2 as any).message}; body=${body.slice(0, 200)}`)
391
+ return null
392
+ }
393
+ } catch (pe) {
394
+ this.clientLogger.warn(`Puppeteer heartbeat fetch failed: ${(pe as any).message}`)
395
+ return null
396
+ } finally {
397
+ if (page) {
398
+ try { await page.close() } catch { /* ignore */ }
399
+ }
400
+ }
401
+ }
402
+
353
403
  try {
354
- let data = await this.ctx.http.get(this.API_URL)
404
+ let data = await this.ctx.http.get(url, {
405
+ headers: { 'User-Agent': this.UA },
406
+ timeout: 15000
407
+ })
408
+ this.logDebug(`[heartbeat] http body head: ${typeof data === 'string' ? data.slice(0, 50) : '[object]'}`)
355
409
 
356
410
  // 如果不是对象,尝试解析 JSON (因为 Object.keys 打印出了索引,说明是字符串)
357
411
  if (typeof data === 'string') {
@@ -361,41 +415,23 @@ export class MaimaiStatus extends Service {
361
415
  const snippet = data.slice(0, 200)
362
416
  this.clientLogger.warn(`Heartbeat JSON parse failed: ${(err as any).message}; snippet=${snippet}`)
363
417
  // 如果返回的是 CC HTML,尝试使用 Puppeteer 绕过
364
- if (this.ctx.puppeteer && snippet.includes('<!DOCTYPE')) {
365
- let page: any = null
366
- try {
367
- page = await this.ctx.puppeteer.page()
368
- if (page.setUserAgent) {
369
- await page.setUserAgent(this.UA)
370
- }
371
- await page.goto(this.API_URL, { waitUntil: 'networkidle0', timeout: 15000 })
372
- const body = await page.evaluate(() => {
373
- const text = document.body?.innerText || document.body?.textContent || ''
374
- return text
375
- })
376
- try {
377
- data = JSON.parse(body)
378
- } catch (err2) {
379
- this.clientLogger.warn(`Puppeteer heartbeat parse failed: ${(err2 as any).message}; body=${body.slice(0,200)}`)
380
- return null
381
- }
382
- } catch (pe) {
383
- this.clientLogger.warn(`Puppeteer heartbeat fetch failed: ${(pe as any).message}`)
384
- return null
385
- } finally {
386
- if (page) {
387
- try { await page.close() } catch { /* ignore */ }
388
- }
389
- }
390
- } else {
391
- return null
418
+ if (snippet.includes('<!DOCTYPE')) {
419
+ const viaPuppeteer = await fetchViaPuppeteer()
420
+ if (viaPuppeteer) return viaPuppeteer
392
421
  }
422
+ return null
393
423
  }
394
424
  }
395
425
 
396
426
  // 返回原始对象,包含 heartbeatList 和 uptimeList
427
+ this.logDebug('[heartbeat] parsed keys:', Array.isArray(data) ? `array length ${data.length}` : Object.keys(data || {}))
397
428
  return data || {}
398
429
  } catch (e) {
430
+ // HTTP 失败也尝试 Puppeteer(例如超时/质询)
431
+ this.clientLogger.warn(`HTTP heartbeat fetch failed, trying Puppeteer. reason=${(e as any)?.message || e}`)
432
+ const viaPuppeteer = await fetchViaPuppeteer()
433
+ if (viaPuppeteer) return viaPuppeteer
434
+
399
435
  this.clientLogger.error('Failed to fetch heartbeat JSON', e)
400
436
  return null
401
437
  }
@@ -458,6 +494,7 @@ export class MaimaiStatus extends Service {
458
494
 
459
495
  for (const { group, currentStatus } of updates) {
460
496
  const { groupName, lastStatus, isMuted, muteUntil } = group
497
+ this.logDebug(`[state] ${groupName}: last=${lastStatus} current=${currentStatus}`)
461
498
 
462
499
  // 检查静音过期
463
500
  if (isMuted && now > muteUntil) {
@@ -465,18 +502,6 @@ export class MaimaiStatus extends Service {
465
502
  this.clientLogger.info(`[${groupName}] 静音结束,恢复监控通知。`)
466
503
  }
467
504
 
468
- // 0. 初始化或状态无变更的处理
469
- if (lastStatus === 'UNKNOWN') {
470
- // 首次初始化,静默更新
471
- group.lastStatus = currentStatus
472
- group.consecutiveFailures = 0
473
- continue
474
- }
475
-
476
- if (currentStatus === lastStatus) {
477
- continue
478
- }
479
-
480
505
  // 如果处于静音状态,只更新状态,不进行通知推送判定
481
506
  if (group.isMuted) {
482
507
  group.lastStatus = currentStatus
@@ -491,10 +516,12 @@ export class MaimaiStatus extends Service {
491
516
  // 静默更新状态,重置连续失败计数以防恢复瞬间误报
492
517
  group.consecutiveFailures = 0
493
518
  group.lastStatus = currentStatus
519
+ this.clientLogger.debug(`[${groupName}] 跳过通知:主服务离线,当前状态 ${currentStatus}`)
494
520
  continue
495
521
  }
496
522
 
497
- // 正常的报警逻辑 (含 3 次确认)
523
+ // 正常的报警逻辑 (含 3 次确认)
524
+ // 注意:即便状态未变化,也需要累计 consecutiveFailures,以便长时间离线也能触发阈值。
498
525
  await this.processNotificationLogic(group, currentStatus)
499
526
 
500
527
  // 更新最后状态
@@ -517,14 +544,16 @@ export class MaimaiStatus extends Service {
517
544
 
518
545
  const isAbnormal = currentStatus === 'OFFLINE' || currentStatus === 'PARTIAL_OFFLINE'
519
546
  const wasAbnormal = group.lastStatus === 'OFFLINE' || group.lastStatus === 'PARTIAL_OFFLINE'
547
+ const isInit = group.lastStatus === 'UNKNOWN'
520
548
 
521
549
  if (isAbnormal) {
522
- // 异常计数 +1
550
+ // 异常计数 +1(初次发现也要记一次)
523
551
  group.consecutiveFailures++
524
552
 
525
553
  // 只有达到阈值(第3次)时才报警,且之前没有报过(或者这是一个新的连续序列)
526
554
  // 注意:如果已经是第 4, 5 次,保持当前状态不变,不再重复报
527
555
  if (group.consecutiveFailures === FAILURE_THRESHOLD) {
556
+ this.logDebug(`[notify] threshold reached for ${groupName}: last=${group.lastStatus} current=${currentStatus}`)
528
557
  await this.handleStatusChange(groupName, group.lastStatus, currentStatus)
529
558
  } else if (group.consecutiveFailures < FAILURE_THRESHOLD) {
530
559
  this.clientLogger.debug(`[${groupName}] 异常计数 ${group.consecutiveFailures}/${FAILURE_THRESHOLD},暂不推送。`)
@@ -533,11 +562,17 @@ export class MaimaiStatus extends Service {
533
562
  // 当前是 ONLINE
534
563
  if (wasAbnormal && group.consecutiveFailures >= FAILURE_THRESHOLD) {
535
564
  // 之前是异常,且已经报警过了(超过阈值),现在恢复 -> 发送恢复通知
565
+ this.logDebug(`[notify] recovery for ${groupName}: last=${group.lastStatus} -> ONLINE`)
536
566
  await this.handleStatusChange(groupName, group.lastStatus, currentStatus)
537
567
  }
538
568
  // 重置计数
539
569
  group.consecutiveFailures = 0
540
570
  }
571
+
572
+ // 首次初始化状态也要记录,防止后续一直停留在 UNKNOWN 导致不推送
573
+ if (isInit && group.lastStatus === 'UNKNOWN') {
574
+ group.lastStatus = currentStatus
575
+ }
541
576
  }
542
577
 
543
578
  /**
@@ -574,6 +609,7 @@ export class MaimaiStatus extends Service {
574
609
  }
575
610
 
576
611
  if (shouldNotify) {
612
+ this.logDebug(`[notify] will push: ${name} ${from} -> ${to}`)
577
613
  // 防抖检测:仅在真正决定推送时记录
578
614
  const group = this.groups.get(name)
579
615
  if (group) {
@@ -607,6 +643,7 @@ export class MaimaiStatus extends Service {
607
643
  // 获取 bot 实例发送 (需要确保 context 中有 bot)
608
644
  const bot = this.ctx.bots[0]
609
645
  if (bot) {
646
+ this.logDebug(`[push] send to ${this.config.targetChannelId} level=${level} msgHead=${message.slice(0,50)}`)
610
647
  await bot.sendMessage(this.config.targetChannelId, message)
611
648
  } else {
612
649
  this.clientLogger.warn('No bot available to send notification.')
@@ -614,6 +651,9 @@ export class MaimaiStatus extends Service {
614
651
  } catch (e) {
615
652
  this.clientLogger.error('Failed to send notification:', e)
616
653
  }
654
+ } else {
655
+ this.clientLogger.debug('Notification skipped: targetChannelId not configured.')
656
+ this.logDebug(`[push] skipped push, no target. level=${level} msgHead=${message.slice(0,50)}`)
617
657
  }
618
658
 
619
659
  // 示例:您也可以在这里添加 HTTP webhook 推送