// ==UserScript== // @name 脚本健康度看板 (Script Health Dashboard) // @namespace https://github.com/scriptcat/script-health-dashboard // @version 2.5.3 // @description 后台监控所有已安装脚本的运行状态,提供健康度评分、历史趋势、智能告警、可视化看板、版本检测、钉钉群机器人通知 ⚙️ 支持后台配置面板 // @author ScriptCat User // @background // @crontab 0 * * * * // @grant GM_info // @grant GM_setValue // @grant GM_getValue // @grant GM_listValues // @grant GM_deleteValue // @grant GM_log // @grant GM_registerMenuCommand // @grant GM_notification // @grant GM_openInTab // @grant GM_xmlhttpRequest // @connect * // @storageName script-health-dashboard // @license MIT // ==/UserScript== /* ==UserConfig== general: enable_notification: title: '启用桌面通知' description: '开启后,当脚本出现严重健康问题时通过浏览器桌面通知告警' type: checkbox default: true alert_threshold: title: '告警阈值(健康度分数)' description: '当脚本健康度分数低于此值时触发告警。可选值 30/40/50/60/70,数值越低告警越宽松' type: select default: 50 values: [30, 40, 50, 60, 70] check_interval: title: '健康检查间隔(分钟)' description: '后台健康检查的执行频率。注意:@crontab 在脚本加载时解析,修改后需等待下次触发或重启脚本生效' type: select default: 60 values: [30, 60, 120] dingtalk: enable_dingtalk: title: '启用钉钉群机器人通知' description: '开启后,当脚本出现严重健康问题时自动向钉钉群发送告警消息' type: checkbox default: false dingtalk_webhook: title: '钉钉 Webhook 地址' description: '钉钉机器人的完整 Webhook URL,格式如 https://oapi.dingtalk.com/robot/send?access_token=xxx。⚠️ 敏感信息,请勿分享给他人' type: text default: '' password: false dingtalk_secret: title: '钉钉加签密钥' description: '如果机器人安全设置选择了加签,请在此输入密钥(以 SEC 开头)。留空表示不使用加签。⚠️ 敏感信息,请妥善保管' type: text default: '' password: true dingtalk_at_all: title: '@所有人' description: '开启后钉钉消息将 @ 群内所有人' type: checkbox default: false ==/UserConfig== */ /* ============================================================ * README - 其他脚本接入指南 * ============================================================ * * 本看板采用"反向注册"策略:其他脚本主动向本看板的存储写入 * 自己的统计数据,本看板通过 GM_listValues 遍历读取。 * * 【接入方式一:调用全局接口】 * 在你的脚本中,确保 @grant 了 GM_setValue / GM_getValue, * 然后加入以下代码即可上报数据: * * const stats = { * name: '我的脚本', * totalExecutions: 10, * successCount: 9, * totalDuration: 5000, * lastExecTime: Date.now(), * version: '1.0.0' * }; * if (typeof reportScriptStats === 'function') { * reportScriptStats('my_script_id', stats); * } * * 【接入方式二:直接写存储】 * 按照 SHD:stats:{scriptId} 键名规范,直接调用 GM_setValue 写入: * * const KEY = 'SHD:stats:my_script_id'; * let data = GM_getValue(KEY, null); * if (!data) { * data = { scriptId: 'my_script_id', name: '我的脚本', version: '1.0.0', * totalExecutions: 0, successCount: 0, failureCount: 0, * totalDuration: 0, lastExecTime: 0, * firstSeenTime: Date.now(), lastUpdated: Date.now(), * latestVersion: '1.0.0', hasUpdate: false }; * } * data.totalExecutions++; * if (success) data.successCount++; * data.failureCount = data.totalExecutions - data.successCount; * data.totalDuration += durationMs; * data.lastExecTime = Date.now(); * data.lastUpdated = Date.now(); * GM_setValue(KEY, data); * * 【存储键命名规范】 * SHD:stats:{scriptId} - 单个脚本的运行统计数据 * SHD:report:latest - 最新一次健康报告 * SHD:report:history - 健康报告历史记录(数组,最多100条) * SHD:alert:{scriptId} - 单个脚本的最后告警时间戳 * SHD:config - 用户配置 * SHD:updatecheck:{scriptId} - 版本检测缓存 * dingtalk_config - 钉钉机器人配置 * dingtalk_last_alert_time - 钉钉告警去重时间戳 * ============================================================ */ (async function () { 'use strict'; // ============================================================ // 常量定义区 // ============================================================ const STORAGE_PREFIX_STATS = 'SHD:stats:'; // 脚本统计数据前缀 const STORAGE_PREFIX_ALERT = 'SHD:alert:'; // 告警去重前缀 const STORAGE_PREFIX_UPDATE = 'SHD:updatecheck:'; // 版本检测缓存前缀 const STORAGE_KEY_REPORT = 'SHD:report:latest'; // 最新健康报告存储键 const STORAGE_KEY_HISTORY = 'SHD:report:history'; // 历史报告存储键 const STORAGE_KEY_FIRST_RUN = 'SHD:initialized'; // 首次运行标记 const STORAGE_KEY_DINGTALK_LAST_ALERT = 'dingtalk_last_alert_time'; // 钉钉告警去重时间戳 const STATS_PREFIX_LENGTH = STORAGE_PREFIX_STATS.length; const SELF_SCRIPT_ID = 'script-health-dashboard'; const SELF_SCRIPT_NAME = '脚本健康度看板'; const MAX_HISTORY_SIZE = 100; // 历史记录最大保留条数 const ALERT_DEDUP_WINDOW_MS = 60 * 60 * 1000; // 告警去重时间窗口:1 小时 const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 版本检测缓存有效期:24 小时 const TREND_CHART_MAX_POINTS = 30; // 趋势图最大数据点数 const DINGTALK_ALERT_DEDUP_MS = 30 * 60 * 1000; // 钉钉告警去重窗口:30 分钟 const HEALTH_LEVEL = { HEALTHY: 'healthy', WARNING: 'warning', CRITICAL: 'critical' }; const THRESHOLD_SCORE_HEALTHY = 80; const THRESHOLD_SCORE_WARNING = 50; // 健康度评分扣分阈值 const SCORE_RULES = { SUCCESS_RATE_CRITICAL: 0.50, SUCCESS_RATE_WARNING: 0.80, DEDUCT_SUCCESS_RATE_CRITICAL: 35, DEDUCT_SUCCESS_RATE_WARNING: 20, LAST_EXEC_SEVERE_DAYS: 30, LAST_EXEC_WARNING_DAYS: 7, DEDUCT_LAST_EXEC_SEVERE: 25, DEDUCT_LAST_EXEC_WARNING: 10, AVG_DURATION_SEVERE_MS: 10000, AVG_DURATION_WARNING_MS: 5000, DEDUCT_AVG_DURATION_SEVERE: 10, DEDUCT_AVG_DURATION_WARNING: 5, DEDUCT_OUTDATED_VERSION: 10 }; const ONE_DAY_MS = 24 * 60 * 60 * 1000; // ============================================================ // 【UserConfig】配置读取 // ============================================================ // // 配置通过脚本猫的 ==UserConfig== 块声明,用户可在脚本猫后台 // 通过齿轮按钮进行配置。GM_getValue 读取键名格式为 "分组名.配置项名"。 // 分组名使用英文键(general / dingtalk),配置项名为 UserConfig 中声明的键。 // 每次定时任务触发时实时读取最新值,无需重启脚本即可生效。 // // 通用配置默认值(与 UserConfig 块中 default 保持一致) const DEFAULT_ENABLE_NOTIFICATION = true; // 桌面通知默认开启 const DEFAULT_ALERT_THRESHOLD = 50; // 告警阈值默认 50 分 const DEFAULT_CHECK_INTERVAL = 60; // 检查间隔默认 60 分钟 // 钉钉配置默认值 const DEFAULT_ENABLE_DINGTALK = false; // 钉钉通知默认关闭 const DEFAULT_DINGTALK_WEBHOOK = ''; // Webhook 默认空 const DEFAULT_DINGTALK_SECRET = ''; // 加签密钥默认空 const DEFAULT_DINGTALK_AT_ALL = false; // @所有人默认关闭 /** * 读取用户配置(统一入口) * 通过 GM_getValue 实时读取脚本猫 UserConfig 存储的值 * 键名格式为 "分组名.配置项名",如 general.enable_notification * * @returns {Object} 合并后的完整配置对象 */ function getConfig() { return { // 通用设置(general 组) enableNotification: GM_getValue('general.enable_notification', DEFAULT_ENABLE_NOTIFICATION), alertThreshold: parseInt(GM_getValue('general.alert_threshold', DEFAULT_ALERT_THRESHOLD), 10), checkInterval: parseInt(GM_getValue('general.check_interval', DEFAULT_CHECK_INTERVAL), 10), // 钉钉通知(dingtalk 组) enableDingtalk: GM_getValue('dingtalk.enable_dingtalk', DEFAULT_ENABLE_DINGTALK), dingtalkWebhook: GM_getValue('dingtalk.dingtalk_webhook', DEFAULT_DINGTALK_WEBHOOK), dingtalkSecret: GM_getValue('dingtalk.dingtalk_secret', DEFAULT_DINGTALK_SECRET), dingtalkAtAll: GM_getValue('dingtalk.dingtalk_at_all', DEFAULT_DINGTALK_AT_ALL) }; } // ============================================================ // 日志工具函数 // ============================================================ function log(message, level = 'info') { try { GM_log(message, level); } catch (e) { console.log(`[SHD][${level}] ${message}`); } } // ============================================================ // 存储工具函数 // ============================================================ function safeGetValue(key, defaultValue = null) { try { const raw = GM_getValue(key, null); if (raw === null || raw === undefined) { return defaultValue; } if (typeof raw === 'string') { try { return JSON.parse(raw); } catch (e) { return raw; } } return raw; } catch (e) { log(`读取存储失败 [${key}]: ${e.message}`, 'error'); return defaultValue; } } function safeSetValue(key, value, silent = false) { try { GM_setValue(key, value); if (!silent) { log(`数据已写入存储 [${key}]`, 'info'); } } catch (e) { log(`写入存储失败 [${key}]: ${e.message}`, 'error'); } } function safeDeleteValue(key) { try { GM_deleteValue(key); log(`数据已从存储删除 [${key}]`, 'info'); } catch (e) { log(`删除存储失败 [${key}]: ${e.message}`, 'error'); } } // ============================================================ // 【钉钉通知】配置校验 // ============================================================ /** * 校验钉钉配置是否完整有效 * @param {Object} config - 钉钉配置对象(含 webhookUrl、secret 等字段) * @returns {{valid: boolean, error: string}} */ function validateDingTalkConfig(config) { if (!config) { return { valid: false, error: '配置为空' }; } if (!config.webhookUrl || config.webhookUrl.trim() === '') { return { valid: false, error: 'Webhook 地址不能为空' }; } if (!config.webhookUrl.startsWith('https://')) { return { valid: false, error: 'Webhook 地址必须以 https:// 开头' }; } // 如果填写了 secret,校验长度 if (config.secret && config.secret.length === 0) { return { valid: false, error: '加签密钥不能为空字符串' }; } return { valid: true, error: '' }; } // ============================================================ // 【钉钉通知】签名算法与消息发送 // ============================================================ // // 【方案选择说明】 // 本实现选择方案 a(直接使用 GM_xmlhttpRequest),原因如下: // 1. 脚本已有 @grant GM_xmlhttpRequest 和 @connect *,无需额外引入依赖 // 2. 不依赖外部 msg-push.js 库,减少加载失败风险,脚本自包含更可靠 // 3. GM_xmlhttpRequest 可绕过浏览器 CORS 限制,适合后台脚本场景 // 4. 签名算法使用浏览器原生 Web Crypto API (crypto.subtle),无需引入 CryptoJS // /** * 生成钉钉加签签名 * * 按照钉钉官方文档: * 1. stringToSign = timestamp + "\n" + secret * 2. 使用 HmacSHA256 对 stringToSign 加密 * 3. Base64 编码得到签名 * 4. 最终 URL 追加 ×tamp={timestamp}&sign={sign} * * 使用 Web Crypto API (crypto.subtle) 实现,无需引入 CryptoJS。 * * @param {number} timestamp - 毫秒时间戳 * @param {string} secret - 加签密钥 * @returns {Promise} Base64 编码后的签名 */ async function generateDingTalkSign(timestamp, secret) { const stringToSign = timestamp + '\n' + secret; const encoder = new TextEncoder(); const keyData = encoder.encode(secret); // 导入 HMAC 密钥 const cryptoKey = await crypto.subtle.importKey( 'raw', keyData, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] ); // 使用 HmacSHA256 签名 const signBuffer = await crypto.subtle.sign( 'HMAC', cryptoKey, encoder.encode(stringToSign) ); // Base64 编码 const signBase64 = btoa(String.fromCharCode(...new Uint8Array(signBuffer))); return signBase64; } /** * 发送钉钉消息(使用 GM_xmlhttpRequest) * * @param {Object} config - 钉钉配置对象 * @param {string} content - 消息文本内容 * @returns {Promise<{success: boolean, error: string}>} */ async function sendDingTalkMessage(config, content) { // 校验配置 const validation = validateDingTalkConfig(config); if (!validation.valid) { log(`钉钉配置校验失败: ${validation.error}`, 'error'); return { success: false, error: validation.error }; } try { // 构建最终的 Webhook URL(含签名参数) let webhookUrl = config.webhookUrl; // 如果配置了加签密钥,生成签名并追加到 URL if (config.secret && config.secret.length > 0) { const timestamp = Date.now(); const sign = await generateDingTalkSign(timestamp, config.secret); webhookUrl += '×tamp=' + timestamp + '&sign=' + encodeURIComponent(sign); } // 构建 text 类型消息体 const messageBody = { msgtype: 'text', text: { content: content }, at: { atMobiles: config.atMobiles || [], isAtAll: config.isAtAll === true } }; log('正在发送钉钉消息...', 'info'); // 使用 GM_xmlhttpRequest 发送 POST 请求 return new Promise((resolve) => { GM_xmlhttpRequest({ method: 'POST', url: webhookUrl, headers: { 'Content-Type': 'application/json' }, data: JSON.stringify(messageBody), timeout: 15000, onload: function (response) { try { const result = JSON.parse(response.responseText); if (result.errcode === 0) { log('钉钉消息发送成功', 'info'); resolve({ success: true, error: '' }); } else { const errMsg = `钉钉返回错误: errcode=${result.errcode}, errmsg=${result.errmsg}`; log(errMsg, 'error'); resolve({ success: false, error: errMsg }); } } catch (e) { const errMsg = `解析钉钉响应失败: ${e.message}, raw=${response.responseText}`; log(errMsg, 'error'); resolve({ success: false, error: errMsg }); } }, onerror: function (error) { const errMsg = `钉钉消息发送网络错误: ${JSON.stringify(error)}`; log(errMsg, 'error'); resolve({ success: false, error: errMsg }); }, ontimeout: function () { const errMsg = '钉钉消息发送超时(15秒)'; log(errMsg, 'error'); resolve({ success: false, error: errMsg }); } }); }); } catch (e) { const errMsg = `发送钉钉消息时发生异常: ${e.message}`; log(errMsg, 'error'); return { success: false, error: errMsg }; } } /** * 构建钉钉告警消息内容 * * @param {Object} report - 健康报告对象 * @returns {string} 格式化的消息文本 */ function buildDingTalkAlertContent(report) { const alertTime = formatDateTime(Date.now()); const criticalScripts = report.details.filter(d => d.healthLevel === HEALTH_LEVEL.CRITICAL); const criticalCount = criticalScripts.length; // 最多列出前 5 个严重脚本名称 const topScripts = criticalScripts.slice(0, 5); const scriptNames = topScripts.map(s => ` · ${s.name}(评分: ${s.healthScore})`).join('\n'); const moreHint = criticalCount > 5 ? `\n ...还有 ${criticalCount - 5} 个严重脚本` : ''; // 拼接完整消息内容 let content = `【脚本健康度告警】\n`; content += `告警时间:${alertTime}\n`; content += `严重脚本数量:${criticalCount} 个\n`; content += `总脚本数:${report.totalScripts} | 平均评分:${report.avgScore}\n`; content += `\n严重脚本列表(最多显示5个):\n${scriptNames}${moreHint}\n`; content += `\n建议操作:\n`; content += ` 1. 检查上述脚本的执行日志\n`; content += ` 2. 确认脚本是否需要更新或修复\n`; content += ` 3. 访问脚本健康度看板查看详情`; return content; } /** * 触发钉钉告警(含 30 分钟去重机制) * * 【UserConfig 改造】钉钉配置直接从 GM_getValue 读取(不再使用单独的存储键) * 当存在严重脚本且 enable_dingtalk === true 时发送告警。 * 同一时间段内(30分钟)不重复发送,防止刷屏。 * * @param {Object} report - 健康报告对象 */ async function triggerDingTalkAlert(report) { try { const config = getConfig(); // 未启用钉钉通知,直接返回 if (!config.enableDingtalk) { return; } // 没有严重脚本,不触发告警 if (!report.criticalCount || report.criticalCount === 0) { return; } // 去重检查:30 分钟内不重复发送 const now = Date.now(); const lastAlertTime = safeGetValue(STORAGE_KEY_DINGTALK_LAST_ALERT, 0); if (lastAlertTime && (now - lastAlertTime) < DINGTALK_ALERT_DEDUP_MS) { const remainMin = Math.ceil((DINGTALK_ALERT_DEDUP_MS - (now - lastAlertTime)) / 60000); log(`钉钉告警处于去重窗口内(剩余 ${remainMin} 分钟),跳过发送`, 'info'); return; } // 构建钉钉配置对象(从 UserConfig 读取的字段映射为发送函数所需格式) const dingtalkConfig = { webhookUrl: config.dingtalkWebhook, secret: config.dingtalkSecret, atMobiles: [], // UserConfig 未提供 @ 手机号功能,留空 isAtAll: config.dingtalkAtAll }; // 构建告警消息内容 const content = buildDingTalkAlertContent(report); // 发送钉钉消息 const result = await sendDingTalkMessage(dingtalkConfig, content); if (result.success) { // 更新最后告警时间 safeSetValue(STORAGE_KEY_DINGTALK_LAST_ALERT, now, true); log(`钉钉告警已发送:${report.criticalCount} 个严重脚本`, 'info'); } else { log(`钉钉告警发送失败: ${result.error}`, 'error'); } } catch (e) { // 钉钉告警失败不影响主流程 log(`触发钉钉告警时发生异常: ${e.message}`, 'error'); } } /** * 测试钉钉通知(供菜单命令调用) * 发送一条测试消息,验证配置是否正确 */ async function testDingTalkNotification() { // 从 UserConfig 读取钉钉配置 const config = getConfig(); const dingtalkConfig = { webhookUrl: config.dingtalkWebhook, secret: config.dingtalkSecret, atMobiles: [], isAtAll: config.dingtalkAtAll }; const validation = validateDingTalkConfig(dingtalkConfig); if (!validation.valid) { sendNotification('❌ 钉钉配置无效', validation.error); return; } sendNotification('钉钉测试', '⏳ 正在发送测试消息...'); const testContent = `【脚本健康度看板 - 测试消息】\n测试时间:${formatDateTime(Date.now())}\n这是一条来自脚本健康度看板的测试消息,如果你收到了说明钉钉配置正确!`; const result = await sendDingTalkMessage(dingtalkConfig, testContent); if (result.success) { sendNotification('✅ 钉钉测试成功', '测试消息已成功发送到钉钉群'); } else { sendNotification('❌ 钉钉测试失败', result.error); } } // ============================================================ // 默认统计数据生成 // ============================================================ function createDefaultStats(scriptId, name = '', version = '') { return { scriptId: scriptId, name: name || scriptId, version: version, totalExecutions: 0, successCount: 0, failureCount: 0, totalDuration: 0, lastExecTime: 0, firstSeenTime: Date.now(), lastUpdated: Date.now(), latestVersion: version, hasUpdate: false }; } // ============================================================ // 【第四阶段优化】数据采集核心函数(并发 + 异常隔离) // ============================================================ /** * 采集所有已注册脚本的统计数据 * * 第四阶段改进: * 1. 使用 Promise.all 并发加载多个脚本数据,提升性能 * 2. 单个脚本数据损坏时 try-catch 隔离,不影响其他脚本 * 3. 返回 abnormalCount(异常脚本数) * * @returns {Promise<{statsList: Array, totalCount: number, migratedCount: number, abnormalCount: number, abnormalIds: Array}>} */ async function collectScriptsInfo() { log('====== 开始采集脚本数据 ======', 'info'); const statsList = []; let migratedCount = 0; let abnormalCount = 0; const abnormalIds = []; try { const allKeys = GM_listValues(); log(`存储中共有 ${allKeys.length} 个键`, 'info'); const statsKeys = allKeys.filter(key => key.startsWith(STORAGE_PREFIX_STATS)); log(`发现 ${statsKeys.length} 个脚本统计记录`, 'info'); // 第四阶段:使用 map + Promise.all 并发处理每个键 // 注意:GM_getValue 是同步的,这里用 async 包装主要是为了 // 将每个脚本的处理隔离在独立的 try-catch 中,并模拟并发场景 const results = await Promise.all(statsKeys.map(async (key) => { const scriptId = key.substring(STATS_PREFIX_LENGTH); try { let stats = safeGetValue(key, null); if (!stats) { log(`脚本 [${scriptId}] 无统计数据,初始化默认值`, 'warn'); stats = createDefaultStats(scriptId); safeSetValue(key, stats, true); return { ok: true, stats, migrated: true }; } // 数据完整性校验:必须是对象 if (typeof stats !== 'object' || Array.isArray(stats)) { throw new Error(`脚本数据格式异常(非对象)`); } // 字段完整性检查(向前兼容) let needMigrate = false; const defaults = createDefaultStats(scriptId); const requiredFields = [ 'scriptId', 'name', 'version', 'totalExecutions', 'successCount', 'failureCount', 'totalDuration', 'lastExecTime', 'firstSeenTime', 'lastUpdated', 'latestVersion', 'hasUpdate' ]; for (const field of requiredFields) { if (stats[field] === undefined || stats[field] === null) { log(`脚本 [${scriptId}] 缺失字段 [${field}],执行数据迁移`, 'warn'); stats[field] = defaults[field]; needMigrate = true; } } // 数值字段类型校验(防止损坏数据导致后续计算 NaN) const numericFields = ['totalExecutions', 'successCount', 'failureCount', 'totalDuration', 'lastExecTime']; for (const field of numericFields) { if (typeof stats[field] !== 'number' || isNaN(stats[field])) { log(`脚本 [${scriptId}] 字段 [${field}] 类型异常,重置为 0`, 'warn'); stats[field] = 0; needMigrate = true; } } if (needMigrate) { stats.lastUpdated = Date.now(); safeSetValue(key, stats, true); return { ok: true, stats, migrated: true }; } return { ok: true, stats, migrated: false }; } catch (e) { // 单个脚本异常被隔离,记录后继续处理其他脚本 log(`采集脚本 [${scriptId}] 时发生异常: ${e.message}`, 'error'); return { ok: false, scriptId, error: e.message }; } })); // 汇总结果 for (const r of results) { if (r.ok) { statsList.push(r.stats); if (r.migrated) migratedCount++; } else { abnormalCount++; abnormalIds.push(r.scriptId); } } log( `====== 采集完成:共 ${statsList.length} 个脚本,迁移 ${migratedCount} 个,异常 ${abnormalCount} 个 ======`, 'info' ); } catch (e) { log(`采集脚本数据时发生严重异常: ${e.message}`, 'error'); } return { statsList: statsList, totalCount: statsList.length, migratedCount: migratedCount, abnormalCount: abnormalCount, abnormalIds: abnormalIds }; } // ============================================================ // 健康度评分引擎 // ============================================================ function calculateHealthScore(stats) { let score = 100; const deductDetails = { successRate: 0, lastExec: 0, avgDuration: 0, version: 0 }; const total = stats.totalExecutions || 0; const success = stats.successCount || 0; const successRate = total > 0 ? success / total : 1; const avgDuration = total > 0 ? Math.round(stats.totalDuration / total) : 0; if (total > 0) { if (successRate < SCORE_RULES.SUCCESS_RATE_CRITICAL) { deductDetails.successRate = SCORE_RULES.DEDUCT_SUCCESS_RATE_CRITICAL; } else if (successRate < SCORE_RULES.SUCCESS_RATE_WARNING) { deductDetails.successRate = SCORE_RULES.DEDUCT_SUCCESS_RATE_WARNING; } } score -= deductDetails.successRate; const now = Date.now(); let daysSinceLastExec; if (stats.lastExecTime && stats.lastExecTime > 0) { daysSinceLastExec = (now - stats.lastExecTime) / ONE_DAY_MS; } else { daysSinceLastExec = Infinity; } if (daysSinceLastExec > SCORE_RULES.LAST_EXEC_SEVERE_DAYS) { deductDetails.lastExec = SCORE_RULES.DEDUCT_LAST_EXEC_SEVERE; } else if (daysSinceLastExec > SCORE_RULES.LAST_EXEC_WARNING_DAYS) { deductDetails.lastExec = SCORE_RULES.DEDUCT_LAST_EXEC_WARNING; } score -= deductDetails.lastExec; if (avgDuration > SCORE_RULES.AVG_DURATION_SEVERE_MS) { deductDetails.avgDuration = SCORE_RULES.DEDUCT_AVG_DURATION_SEVERE; } else if (avgDuration > SCORE_RULES.AVG_DURATION_WARNING_MS) { deductDetails.avgDuration = SCORE_RULES.DEDUCT_AVG_DURATION_WARNING; } score -= deductDetails.avgDuration; if (stats.hasUpdate === true) { deductDetails.version = SCORE_RULES.DEDUCT_OUTDATED_VERSION; } score -= deductDetails.version; score = Math.max(0, Math.min(100, score)); let level; if (score >= THRESHOLD_SCORE_HEALTHY) { level = HEALTH_LEVEL.HEALTHY; } else if (score >= THRESHOLD_SCORE_WARNING) { level = HEALTH_LEVEL.WARNING; } else { level = HEALTH_LEVEL.CRITICAL; } return { score: score, level: level, successRate: successRate, avgDuration: avgDuration, deductDetails: deductDetails }; } function calculateHealth(stats) { const result = calculateHealthScore(stats); return { level: result.level, successRate: result.successRate, avgDuration: result.avgDuration, score: result.score, deductDetails: result.deductDetails }; } // ============================================================ // 健康报告生成函数 // ============================================================ function generateHealthReport(statsList, abnormalCount = 0, abnormalIds = []) { let healthyCount = 0; let warningCount = 0; let criticalCount = 0; let totalScore = 0; const details = statsList.map(stats => { const health = calculateHealth(stats); totalScore += health.score; if (health.level === HEALTH_LEVEL.HEALTHY) { healthyCount++; } else if (health.level === HEALTH_LEVEL.WARNING) { warningCount++; } else { criticalCount++; } return { scriptId: stats.scriptId, name: stats.name, version: stats.version, totalExecutions: stats.totalExecutions, successCount: stats.successCount, failureCount: stats.failureCount, successRate: parseFloat(health.successRate.toFixed(4)), avgDuration: health.avgDuration, totalDuration: stats.totalDuration, lastExecTime: stats.lastExecTime, healthLevel: health.level, healthScore: health.score, deductDetails: health.deductDetails, hasUpdate: stats.hasUpdate === true }; }); const avgScore = statsList.length > 0 ? parseFloat((totalScore / statsList.length).toFixed(2)) : 100; const report = { timestamp: Date.now(), totalScripts: statsList.length, healthyCount: healthyCount, warningCount: warningCount, criticalCount: criticalCount, avgScore: avgScore, abnormalCount: abnormalCount, // 【第四阶段新增】异常脚本数 abnormalIds: abnormalIds, // 【第四阶段新增】异常脚本ID列表 details: details }; safeSetValue(STORAGE_KEY_REPORT, report, true); log( `健康报告已生成:总 ${report.totalScripts} 个脚本,平均分 ${avgScore},` + `健康 ${healthyCount} / 警告 ${warningCount} / 严重 ${criticalCount} / 异常 ${abnormalCount}`, 'info' ); return report; } // ============================================================ // 历史记录管理 // ============================================================ function appendHistoryRecord(report) { try { const historyEntry = { timestamp: report.timestamp, totalScripts: report.totalScripts, healthy: report.healthyCount, warning: report.warningCount, critical: report.criticalCount, avgScore: report.avgScore }; const history = safeGetValue(STORAGE_KEY_HISTORY, []); if (!Array.isArray(history)) { log('历史记录格式异常,重置为空数组', 'warn'); history.length = 0; } history.push(historyEntry); while (history.length > MAX_HISTORY_SIZE) { history.shift(); } safeSetValue(STORAGE_KEY_HISTORY, history, true); log(`历史记录已追加(当前共 ${history.length} 条,上限 ${MAX_HISTORY_SIZE} 条)`, 'info'); } catch (e) { log(`追加历史记录失败: ${e.message}`, 'error'); } } /** * 读取历史记录(用于趋势图) * @param {number} [limit=30] - 返回最近 N 条 * @returns {Array} */ function getHistory(limit = TREND_CHART_MAX_POINTS) { const history = safeGetValue(STORAGE_KEY_HISTORY, []); if (!Array.isArray(history)) return []; return history.slice(-limit); } // ============================================================ // 【智能告警触发】基于 UserConfig 配置的阈值 // ============================================================ /** * 检查并发送告警 * * 【UserConfig 改造】阈值逻辑变更: * 旧版:alertThreshold 为 'critical' / 'warning' 字符串,按级别匹配 * 新版:alertThreshold 为数字(30/40/50/60/70),按分数比较 * 当 detail.healthScore < alertThreshold 时触发告警 * * @param {Array} details - 健康报告中的脚本详情列表 */ function checkAndAlert(details) { try { const config = getConfig(); // 未开启通知则直接返回 if (!config.enableNotification) { log('桌面通知已关闭,跳过告警检查', 'info'); return; } // 阈值为数字:分数低于此值则告警 const threshold = config.alertThreshold; log(`当前告警阈值:${threshold} 分`, 'info'); const now = Date.now(); let alertCount = 0; for (const detail of details) { // 按分数判断是否触发告警 if (detail.healthScore >= threshold) { continue; } // 告警去重 const alertKey = STORAGE_PREFIX_ALERT + detail.scriptId; const lastAlertTime = safeGetValue(alertKey, 0); if (lastAlertTime && (now - lastAlertTime) < ALERT_DEDUP_WINDOW_MS) { const remainMin = Math.ceil((ALERT_DEDUP_WINDOW_MS - (now - lastAlertTime)) / 60000); log( `脚本 [${detail.name}] 处于告警去重窗口内(剩余 ${remainMin} 分钟),跳过告警`, 'info' ); continue; } // 通知图标根据级别选择 const icon = detail.healthLevel === HEALTH_LEVEL.CRITICAL ? '🔴' : '🟡'; const levelText = detail.healthLevel === HEALTH_LEVEL.CRITICAL ? '严重' : '警告'; const title = `${icon} 脚本健康告警:${detail.name}`; const text = `健康度评分:${detail.healthScore} / 100(${levelText})\n` + `总执行次数:${detail.totalExecutions}\n` + `成功率:${(detail.successRate * 100).toFixed(1)}%\n` + `平均耗时:${detail.avgDuration} ms\n` + `请检查该脚本是否正常运行。`; GM_notification({ title: title, text: text, onclick: () => { log(`用户点击了 [${detail.name}] 的告警通知`, 'info'); } }); safeSetValue(alertKey, now, true); log(`已发送${levelText}告警:[${detail.name}] 评分=${detail.healthScore}`, 'warn'); alertCount++; } if (alertCount > 0) { log(`本次共发送 ${alertCount} 条告警通知`, 'warn'); } else { log('本次无需发送告警', 'info'); } } catch (e) { log(`智能告警检查失败: ${e.message}`, 'error'); } } // ============================================================ // 【第四阶段新增】版本更新检测 // ============================================================ /** * 检测单个脚本是否有新版本可更新 * * 通过 GM_xmlhttpRequest 请求脚本的 updateURL(如果有), * 解析远程脚本的 @version 字段,与本地版本对比。 * 检测结果缓存 24 小时,避免频繁请求。 * * @param {Object} stats - 脚本统计数据 * @returns {Promise<{hasUpdate: boolean, latestVersion: string, checkedAt: number}>} */ function checkScriptForUpdate(stats) { return new Promise((resolve) => { // 本看板自身暂无 updateURL,跳过 // 其他脚本的版本检测需要其统计数据中包含 updateURL 字段 const updateURL = stats.updateURL; if (!updateURL) { resolve({ hasUpdate: false, latestVersion: stats.version, checkedAt: Date.now() }); return; } // 检查缓存是否在有效期内 const cacheKey = STORAGE_PREFIX_UPDATE + stats.scriptId; const cached = safeGetValue(cacheKey, null); const now = Date.now(); if (cached && cached.checkedAt && (now - cached.checkedAt) < UPDATE_CHECK_INTERVAL_MS) { log(`脚本 [${stats.name}] 版本检测使用缓存(${new Date(cached.checkedAt).toLocaleString()})`, 'info'); resolve(cached); return; } // 发起远程请求获取最新版本 log(`正在检测脚本 [${stats.name}] 的版本更新...`, 'info'); GM_xmlhttpRequest({ method: 'GET', url: updateURL, timeout: 10000, onload: function (response) { try { // 从远程脚本内容中解析 @version 字段 const versionMatch = response.responseText.match(/@version\s+([^\r\n]+)/); if (!versionMatch) { log(`脚本 [${stats.name}] 远程元数据中未找到 @version`, 'warn'); const result = { hasUpdate: false, latestVersion: stats.version, checkedAt: now }; safeSetValue(cacheKey, result, true); resolve(result); return; } const remoteVersion = versionMatch[1].trim(); const hasUpdate = compareVersions(remoteVersion, stats.version) > 0; const result = { hasUpdate: hasUpdate, latestVersion: remoteVersion, checkedAt: now }; safeSetValue(cacheKey, result, true); log(`脚本 [${stats.name}] 版本检测完成:本地=${stats.version} 远程=${remoteVersion} 可更新=${hasUpdate}`, 'info'); resolve(result); } catch (e) { log(`解析脚本 [${stats.name}] 版本信息失败: ${e.message}`, 'error'); const result = { hasUpdate: false, latestVersion: stats.version, checkedAt: now, error: e.message }; safeSetValue(cacheKey, result, true); resolve(result); } }, onerror: function (error) { log(`检测脚本 [${stats.name}] 版本时网络错误`, 'error'); const result = { hasUpdate: false, latestVersion: stats.version, checkedAt: now, error: 'network_error' }; safeSetValue(cacheKey, result, true); resolve(result); }, ontimeout: function () { log(`检测脚本 [${stats.name}] 版本请求超时`, 'warn'); const result = { hasUpdate: false, latestVersion: stats.version, checkedAt: now, error: 'timeout' }; safeSetValue(cacheKey, result, true); resolve(result); } }); }); } /** * 版本号比较(语义化版本) * @returns {number} 正数表示 a>b,负数表示 a} 更新后的统计列表(hasUpdate/latestVersion 已更新) */ async function checkAllForUpdates(statsList) { log('====== 开始版本更新检测 ======', 'info'); // 使用 Promise.all 并发检测 const results = await Promise.all(statsList.map(async (stats) => { try { const updateResult = await checkScriptForUpdate(stats); stats.hasUpdate = updateResult.hasUpdate; stats.latestVersion = updateResult.latestVersion; // 如果检测到更新,写回存储 if (updateResult.hasUpdate) { const key = STORAGE_PREFIX_STATS + stats.scriptId; safeSetValue(key, stats, true); log(`脚本 [${stats.name}] 有新版本可更新: ${updateResult.latestVersion}`, 'warn'); } return stats; } catch (e) { log(`检测脚本 [${stats.name}] 版本更新时异常: ${e.message}`, 'error'); return stats; } })); log('====== 版本更新检测完成 ======', 'info'); return results; } // ============================================================ // 主健康检查流程 // ============================================================ /** * 执行完整的健康检查流程 * 流程:采集数据 → 版本检测 → 计算评分 → 生成报告 → 追加历史 → 触发告警 */ async function runHealthCheck() { log('>>>>>> 健康检查流程启动 <<<<<<', 'info'); // 第一步:采集脚本数据(含异常隔离) const { statsList, abnormalCount, abnormalIds } = await collectScriptsInfo(); // 第二步:【第四阶段新增】版本更新检测 const updatedStatsList = await checkAllForUpdates(statsList); // 第三步:生成健康报告 const report = generateHealthReport(updatedStatsList, abnormalCount, abnormalIds); // 第四步:追加历史记录 appendHistoryRecord(report); // 第五步:触发智能告警(桌面通知) checkAndAlert(report.details); // 第六步:【钉钉通知】触发钉钉群机器人告警(含 30 分钟去重) await triggerDingTalkAlert(report); log('>>>>>> 健康检查流程完成 <<<<<<', 'info'); return { totalScripts: report.totalScripts, healthy: report.healthyCount, warning: report.warningCount, critical: report.criticalCount, abnormal: report.abnormalCount, avgScore: report.avgScore }; } // ============================================================ // 通知工具函数 // ============================================================ function sendNotification(title, text, onclick) { try { const config = getConfig(); if (!config.enableNotification) { log(`桌面通知已关闭,跳过:${title}`, 'info'); return; } GM_notification({ title: title, text: text, onclick: onclick || (() => {}) }); } catch (e) { log(`发送通知失败: ${e.message}`, 'error'); } } // ============================================================ // 格式化工具函数 // ============================================================ function formatDateTime(ts) { if (!ts || ts === 0) return '从未执行'; const d = new Date(ts); const pad = (n) => (n < 10 ? '0' + n : '' + n); return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()); } function formatDuration(ms) { if (!ms || ms === 0) return '0 ms'; if (ms < 1000) return ms + ' ms'; return (ms / 1000).toFixed(2) + ' s'; } function formatSuccessRate(rate) { if (rate === undefined || rate === null) return '-'; return (rate * 100).toFixed(1) + '%'; } // ============================================================ // 【第四阶段新增】首次运行初始化与示例数据 // ============================================================ /** * 首次运行时创建示例数据,演示看板效果 */ function initializeOnFirstRun() { const initialized = safeGetValue(STORAGE_KEY_FIRST_RUN, false); if (initialized) { return false; } log('====== 首次运行,创建示例数据 ======', 'info'); try { const now = Date.now(); // 示例脚本1:健康脚本 const demo1 = createDefaultStats('demo-healthy-script', '示例:稳健运行脚本', '1.2.0'); demo1.totalExecutions = 156; demo1.successCount = 154; demo1.failureCount = 2; demo1.totalDuration = 15600; demo1.lastExecTime = now - 3600000; // 1 小时前 demo1.firstSeenTime = now - 30 * ONE_DAY_MS; safeSetValue(STORAGE_PREFIX_STATS + 'demo-healthy-script', demo1, true); // 示例脚本2:警告脚本(成功率偏低) const demo2 = createDefaultStats('demo-warning-script', '示例:偶发失败脚本', '0.9.1'); demo2.totalExecutions = 80; demo2.successCount = 65; demo2.failureCount = 15; demo2.totalDuration = 45000; demo2.lastExecTime = now - 8 * ONE_DAY_MS; // 8 天前(触发执行时间扣分) demo2.firstSeenTime = now - 20 * ONE_DAY_MS; safeSetValue(STORAGE_PREFIX_STATS + 'demo-warning-script', demo2, true); // 示例脚本3:严重脚本(成功率低、长期未执行) const demo3 = createDefaultStats('demo-critical-script', '示例:长期失败脚本', '0.5.0'); demo3.totalExecutions = 40; demo3.successCount = 12; demo3.failureCount = 28; demo3.totalDuration = 120000; demo3.lastExecTime = now - 45 * ONE_DAY_MS; // 45 天前 demo3.firstSeenTime = now - 60 * ONE_DAY_MS; safeSetValue(STORAGE_PREFIX_STATS + 'demo-critical-script', demo3, true); // 标记已初始化 safeSetValue(STORAGE_KEY_FIRST_RUN, true, true); log('示例数据创建完成(3个示例脚本)', 'info'); // 发送欢迎通知 GM_notification({ title: '🎉 脚本健康度看板已启动!', text: '其他脚本可调用 reportScriptStats 接口上报数据。\n已为你创建 3 个示例脚本,打开看板即可查看效果。', onclick: () => { log('用户点击了欢迎通知', 'info'); } }); return true; } catch (e) { log(`首次运行初始化失败: ${e.message}`, 'error'); // 即使失败也标记为已初始化,避免每次都重试 safeSetValue(STORAGE_KEY_FIRST_RUN, true, true); return false; } } // ============================================================ // 【第四阶段新增】看板 HTML 生成(含趋势图) // ============================================================ /** * 生成完整的看板 HTML 页面 * 第四阶段新增:健康度趋势图区域(Canvas 绘制) */ function generateDashboardHTML(report, history) { const dashboardData = { report: report, history: history || [], generatedAt: Date.now() }; const dataJSON = JSON.stringify(dashboardData).replace(/ 📊 脚本健康度看板

📊 脚本健康度看板

加载中...
提示:通过脚本猫菜单重新打开看板可获取最新数据
`; } /** * 打开看板页面(第四阶段:附加历史数据用于趋势图) */ function openDashboard() { log('用户请求打开健康度看板', 'info'); try { const report = safeGetValue(STORAGE_KEY_REPORT, null); const history = getHistory(TREND_CHART_MAX_POINTS); const html = generateDashboardHTML(report, history); const blob = new Blob([html], { type: 'text/html;charset=utf-8' }); const url = URL.createObjectURL(blob); GM_openInTab(url, { active: true }); log('看板页面已在新标签页打开', 'info'); } catch (e) { log(`打开看板失败: ${e.message}`, 'error'); sendNotification('❌ 打开看板失败', `错误信息: ${e.message}`); } } // ============================================================ // 【UserConfig 改造】已移除自建配置面板 // ============================================================ // 配置功能现已迁移到脚本猫标准的 ==UserConfig== 块, // 用户可在脚本猫后台管理界面通过齿轮按钮进行配置。 // 旧的 generateConfigHTML() 和 openConfigPanel() 已删除。 // ============================================================ // 菜单命令注册 // ============================================================ GM_registerMenuCommand('📊 查看健康度看板', () => { openDashboard(); }); GM_registerMenuCommand('🔄 立即执行健康检查', async () => { log('用户点击菜单:立即执行健康检查', 'info'); sendNotification('健康检查', '⏳ 正在执行健康检查,请稍候...'); try { const result = await runHealthCheck(); const icon = result.critical > 0 ? '⚠️' : (result.warning > 0 ? '🔔' : '✅'); sendNotification( `${icon} 健康检查完成`, `共监控 ${result.totalScripts} 个脚本\n` + `平均评分:${result.avgScore} / 100\n` + `健康: ${result.healthy} | 警告: ${result.warning} | 严重: ${result.critical}` + (result.abnormal > 0 ? ` | 异常: ${result.abnormal}` : '') ); } catch (e) { log(`健康检查执行失败: ${e.message}`, 'error'); sendNotification('❌ 健康检查失败', `执行过程中发生错误: ${e.message}`); } }); // 【钉钉通知】测试发送菜单 GM_registerMenuCommand('🧪 测试钉钉通知', async () => { log('用户点击菜单:测试钉钉通知', 'info'); await testDingTalkNotification(); }); // ============================================================ // 为其他脚本提供的上报接口 // ============================================================ function reportScriptStats(scriptId, stats) { if (!scriptId) { log('reportScriptStats: 缺少 scriptId 参数', 'error'); return; } if (!stats || typeof stats !== 'object') { log(`reportScriptStats: 脚本 [${scriptId}] 的 stats 参数无效`, 'error'); return; } const key = STORAGE_PREFIX_STATS + scriptId; let existing = safeGetValue(key, null); if (!existing) { existing = createDefaultStats( scriptId, stats.name || '', stats.version || '' ); log(`脚本 [${scriptId}] 首次上报,已创建统计记录`, 'info'); } if (stats.name !== undefined) existing.name = stats.name; if (stats.version !== undefined) existing.version = stats.version; if (stats.totalExecutions !== undefined) existing.totalExecutions = stats.totalExecutions; if (stats.successCount !== undefined) existing.successCount = stats.successCount; if (stats.totalDuration !== undefined) existing.totalDuration = stats.totalDuration; if (stats.lastExecTime !== undefined) existing.lastExecTime = stats.lastExecTime; if (stats.latestVersion !== undefined) existing.latestVersion = stats.latestVersion; if (stats.hasUpdate !== undefined) existing.hasUpdate = stats.hasUpdate === true; if (stats.updateURL !== undefined) existing.updateURL = stats.updateURL; existing.failureCount = (existing.totalExecutions || 0) - (existing.successCount || 0); if (existing.failureCount < 0) existing.failureCount = 0; existing.lastUpdated = Date.now(); safeSetValue(key, existing, true); log(`脚本 [${scriptId}] 数据已上报更新`, 'info'); } /** * 本看板脚本自身上报运行数据(以身作则) * 第四阶段增强:记录执行开始/结束时间 */ function reportSelfStats(success, duration) { try { const key = STORAGE_PREFIX_STATS + SELF_SCRIPT_ID; let self = safeGetValue(key, null); if (!self) { self = createDefaultStats(SELF_SCRIPT_ID, SELF_SCRIPT_NAME, GM_info.script.version); } self.totalExecutions = (self.totalExecutions || 0) + 1; if (success) { self.successCount = (self.successCount || 0) + 1; } self.failureCount = self.totalExecutions - self.successCount; self.totalDuration = (self.totalDuration || 0) + (duration || 0); self.lastExecTime = Date.now(); self.version = GM_info.script.version; self.latestVersion = GM_info.script.version; self.hasUpdate = false; self.lastUpdated = Date.now(); safeSetValue(key, self, true); log(`本看板自身执行数据已上报:第 ${self.totalExecutions} 次执行,耗时 ${duration}ms`, 'info'); } catch (e) { log(`自身数据上报失败: ${e.message}`, 'error'); } } // ============================================================ // 调试接口暴露 // ============================================================ try { window.SC_HEALTH_DASHBOARD = { version: GM_info.script.version, collectScriptsInfo, runHealthCheck, reportScriptStats, reportSelfStats, generateHealthReport, calculateHealth, calculateHealthScore, appendHistoryRecord, checkAndAlert, generateDashboardHTML, openDashboard, getConfig, checkScriptForUpdate, checkAllForUpdates, getHistory, // 钉钉通知相关接口 sendDingTalkMessage, triggerDingTalkAlert, testDingTalkNotification, formatDateTime, formatDuration, formatSuccessRate, log }; } catch (e) { // 后台脚本环境无 window 对象,忽略此错误 } // ============================================================ // 脚本启动入口 / Crontab 定时任务执行体 // ============================================================ log('========================================', 'info'); log(`脚本健康度看板 v${GM_info.script.version} 已启动`, 'info'); log('运行模式:后台脚本 | 定时任务:每小时执行一次', 'info'); log('========================================', 'info'); // ============================================================ // 【UserConfig】首次运行 / 每次启动时打印当前配置(便于调试) // ============================================================ // 从脚本猫 UserConfig 读取用户配置,并打印到日志。 // 敏感字段(钉钉 Webhook、加签密钥)仅显示是否配置,不打印明文。 try { const currentConfig = getConfig(); log('---------- 当前用户配置 ----------', 'info'); log(`[通用设置] 桌面通知: ${currentConfig.enableNotification ? '开启' : '关闭'}`, 'info'); log(`[通用设置] 告警阈值: ${currentConfig.alertThreshold} 分`, 'info'); log(`[通用设置] 检查间隔: ${currentConfig.checkInterval} 分钟`, 'info'); log(`[钉钉通知] 启用: ${currentConfig.enableDingtalk ? '是' : '否'}`, 'info'); log(`[钉钉通知] Webhook: ${currentConfig.dingtalkWebhook ? '已配置(隐藏显示)' : '未配置'}`, 'info'); log(`[钉钉通知] 加签密钥: ${currentConfig.dingtalkSecret ? '已配置(隐藏显示)' : '未配置'}`, 'info'); log(`[钉钉通知] @所有人: ${currentConfig.dingtalkAtAll ? '是' : '否'}`, 'info'); log('----------------------------------', 'info'); } catch (e) { log(`读取/打印用户配置失败: ${e.message}`, 'error'); } // 第一步:【第四阶段新增】首次运行初始化 const isFirstRun = initializeOnFirstRun(); // 第二步:记录执行起始时间 const execStartTime = Date.now(); // 第三步:执行完整的健康检查流程 try { const result = await runHealthCheck(); log( `健康检查完成:共 ${result.totalScripts} 个脚本,平均分 ${result.avgScore},` + `异常 ${result.abnormal || 0} 个`, 'info' ); // 第四步:上报本看板自身的运行数据(以身作则) const execDuration = Date.now() - execStartTime; reportSelfStats(true, execDuration); // 首次运行时提示用户打开看板查看示例 if (isFirstRun) { setTimeout(() => { sendNotification( '💡 提示', '已为你创建示例数据,点击脚本猫菜单中的"📊 查看健康度看板"查看效果。' ); }, 3000); } } catch (e) { log(`健康检查执行异常: ${e.message}`, 'error'); const execDuration = Date.now() - execStartTime; reportSelfStats(false, execDuration); } // ============================================================ // Crontab 定时任务说明 // ============================================================ // 当 @crontab "0 * * * *" 触发时,ScriptCat 会重新执行本后台脚本。 // 上面的"启动入口"代码即为定时任务的执行体: // 1. 首次运行时创建示例数据并发送欢迎通知 // 2. 执行 runHealthCheck() 进行完整健康检查 // - 采集所有脚本数据(并发 + 异常隔离) // - 版本更新检测(24小时缓存) // - 计算健康度评分 // - 生成汇总报告 // - 追加历史记录(保留最近 100 条) // - 触发告警通知(根据配置阈值,1 小时去重) // 3. 上报本看板自身的运行数据(以身作则) // 每小时自动执行一次。 // // 【定时间隔调整说明】 // 由于 @crontab 元数据在脚本安装时固定为 "0 * * * *"(每小时), // 用户如需修改执行频率,可在脚本猫后台的配置面板(齿轮按钮)中 // 设置 check_interval(健康检查间隔),脚本内部会在非对应周期时 // 跳过执行(实现动态调整)。 })();