/** * UptimeRobot 只读数据源(API v2)—— Cloudflare Workers 版 * * 与 prober/node/lib/uptimerobot.js 共用同一份语义(术语对齐见该文件顶部注释), * 但按 Workers 环境改了两处: * 1. 没有 node:crypto / node:fs,API Key 从 env(wrangler secret)读取 * 2. 限流改为「按 KV 里记录的上次同步时间判断」,KV 最终一致对分钟级同步无影响 * * 只读:仅调用 getMonitors,不做任何写操作,因此用 ur* 开头的只读 Key 即可。 */ const API_URL = 'https://api.uptimerobot.com/v2/getMonitors'; const TYPE_LABEL = { 1: 'HTTP', 2: 'KEYWORD', 3: 'PING', 4: 'PORT' }; // 暂停/未检测都归入 pending:否则会被误报成「离线」 const STATUS_MAP = { 0: 'pending', 1: 'pending', 2: 'up', 8: 'down', 9: 'down' }; function splitNums(value) { return String(value ?? '').split('-').map(Number).filter(Number.isFinite); } function ratiosToObject(value, ratios) { const nums = splitNums(value); const out = {}; ratios.forEach((r, i) => { if (Number.isFinite(nums[i])) out[r] = nums[i]; }); return out; } function targetOf(m) { if (m.type === 4 && m.port) return `${m.url ? `${m.url}:` : ''}${m.port}`; return m.url || ''; } function latestDownReason(logs) { const down = (logs ?? []).find((l) => l.type === 1); const reason = down?.reason; if (!reason) return null; const code = String(reason.code ?? '').trim(); const detail = String(reason.detail ?? '').trim(); return { code: code || null, text: [code, detail].filter(Boolean).join(' ') || null }; } /** 把 UptimeRobot 的 getMonitors 响应映射成内部结构(纯函数,可离线单测) */ export function mapUptimeRobotMonitors(payload, options = {}) { const group = options.group || 'UptimeRobot'; const ratios = options.ratios ?? [1, 7, 30, 90]; const now = options.now ?? Date.now(); const historyLimit = options.historyLimit ?? 48; const monitors = []; for (const m of payload?.monitors ?? []) { const status = STATUS_MAP[m.status] ?? 'pending'; const samples = Array.isArray(m.response_times) ? m.response_times : []; const logs = Array.isArray(m.logs) ? m.logs : []; // UR 侧的真实检测时刻:正常取最新响应样本,故障取最近一次故障日志 const lastCheckSec = status === 'down' ? logs.find((l) => l.type === 1)?.datetime : samples[0]?.datetime; const lastCheckIso = new Date((Number(lastCheckSec) || Math.floor(now / 1000)) * 1000).toISOString(); const history = samples .slice(0, historyLimit) .map((s) => ({ t: new Date(s.datetime * 1000).toISOString(), ok: Number(s.value) > 0, ms: Math.max(0, Number(s.value) || 0) })) .reverse(); if (status === 'down') history.push({ t: lastCheckIso, ok: false, ms: 0 }); const last = samples[0]?.value; const avg = m.average_response_time; const latency = Number.isFinite(Number(last)) && Number(last) > 0 ? Math.round(Number(last)) : Number.isFinite(Number(avg)) ? Math.round(Number(avg)) : null; const reason = latestDownReason(logs); let detail; if (status === 'up') detail = 'UptimeRobot:运行正常'; else if (status === 'down') detail = `UptimeRobot:${reason?.text ?? '连接失败'}`; else detail = m.status === 0 ? 'UptimeRobot:监控已暂停' : 'UptimeRobot:尚未检测'; monitors.push({ id: `ur-${m.id}`, name: m.friendly_name || `UptimeRobot #${m.id}`, group, type: 'ur', url: targetOf(m), source: 'uptimerobot', sourceId: m.id, sourceType: m.type, sourceLabel: TYPE_LABEL[m.type] ?? `TYPE${m.type}`, interval: Number(m.interval) || 300, status, latency, code: status === 'down' && reason?.code ? Number(reason.code) || null : null, detail, lastCheckIso, history, uptimeRatios: ratiosToObject(m.custom_uptime_ratio, ratios), downSeconds: ratiosToObject(m.custom_down_durations, ratios), }); } return monitors; } /** * 拉取 UptimeRobot 监控列表。 * @param {string} apiKey * @param {{ratios?: number[], timeoutMs?: number}} [options] */ export async function fetchUptimeRobotMonitors({ apiKey, ratios = [1, 7, 30, 90], timeoutMs = 15000 }) { if (!apiKey) throw new Error('缺少 UptimeRobot API Key'); const body = new URLSearchParams({ api_key: apiKey, format: 'json', response_times: '1', response_times_average: '30', custom_uptime_ratios: ratios.join('-'), logs: '1', log_types: '1-2', // 注意:custom_down_durations 不能作为请求参数传入(v2 会返回 invalid_parameter) }); const res = await fetch(API_URL, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' }, body, signal: AbortSignal.timeout(timeoutMs), }); if (!res.ok) throw new Error(`UptimeRobot HTTP ${res.status}`); const payload = await res.json(); if (payload.stat !== 'ok') throw new Error(`UptimeRobot 返回错误:${payload.error?.message || payload.error?.type || 'unknown'}`); return mapUptimeRobotMonitors(payload, { ratios }); }