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.
|
|
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.
|
|
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(
|
|
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 (
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
@@ -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.
|
|
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.
|
|
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(
|
|
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 (
|
|
365
|
-
|
|
366
|
-
|
|
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 推送
|