3380 字
17 分钟
阅读量
基于 Cloudflare Worker + KV 打造 Serverless GitHub 仓库更新监控系统
在日常运维与开源项目关注中,我们经常需要及时跟踪上游仓库的 最新 Release 发版 或 代码 Commit 提交(例如面板更新、工具脚本更新、核心依赖发版等)。
传统的监控做法通常是在服务器上使用 Python / Go 编写一个常驻后台服务,借助 cron 或定时器轮询 GitHub API,并将已推送的记录保存在本地文件中。然而,这种常驻方案存在明显弊端:
- 占用服务器资源:需要 24 小时常驻 VPS 占用内存与进程守护;
- 受服务器状态影响:一旦服务器重启、网络波动或进程异常退出,就会漏掉发版提醒;
- 维护成本高:需要管理本地状态文件与日志,且难以直观查看当前监控状态。
本文介绍如何利用 Cloudflare Worker(Cron Triggers 定时调度) 结合 Cloudflare KV(边缘键值存储),零服务器资源占用、零成本搭建一个高可用的 GitHub 仓库更新实时监控系统。
架构拓扑
+-----------------------+ +-------------------------+ +-------------------+| Cloudflare Cron 调度 | ----> | Cloudflare Worker | ----> | GitHub REST API || (如每 6 小时自动触发) | | (检查更新 / 状态比对) | | (Commits/Releases)|+-----------------------+ +-------------------------+ +-------------------+ | | v v +----------------+ +-------------------+ | Cloudflare KV | | Webhook 消息网关 | | (记录最新 SHA) | | (企业微信/Bark等) | +----------------+ +-------------------+核心特性
- Serverless 零服务器常驻:无需 VPS 虚拟机,完全依托 Cloudflare 边缘全球网络运行。
- Commit 与 Release 双模式监控:
- Commit 模式:监控指定分支最新提交,获取 Commit SHA、提交信息与提交时间;
- Release 模式:监控仓库最新发版,支持仅监控正式版(排除 Prerelease 与 Draft)。
- Cloudflare KV 智能防重与初始化:
- 首次运行自动将当前最新版本录入 KV 作为基准,杜绝冷启动误报和刷屏;
- 每次检测比对 KV 历史记录,仅当检测到真正的新提交/新版本时才触发通知。
- 现代化护眼温润 Web 看板:
- 访问 Worker 域名可直观查看每个仓库的监控类型、当前基准版本与最近巡检时间;
- 纯只读状态监控面板,不公开暴露外部触发接口;
- 采用
#f8fafc浅色护眼配色体系,长时间浏览舒适自然。
环境变量与 KV 绑定
在 Cloudflare 控制台创建 Worker 后,配置如下绑定:
1. KV 命名空间绑定
- 变量名称:
REPO_KV - 绑定目标:创建并绑定一个 KV 命名空间(例如
REPO_MONITOR_KV),用于持久化存储各个仓库最新已推送记录。
2. 环境变量与机密(Variables and Secrets)
| 变量名 | 类型 | 示例值 | 说明 |
|---|---|---|---|
PUSH_URL | Plaintext | https://sms.example.com/message | Webhook 推送网关接口地址 |
PUSH_TOKEN | Plaintext / Secret | your_push_token | 推送接口认证密钥 |
GITHUB_TOKEN | Secret | ghp_xxxxxxxxxxxx | (可选)GitHub Personal Access Token,避免触发匿名 API 限流 |
REPOS_CONFIG | Plaintext | [...] | (可选)JSON 字符串覆盖默认监控仓库配置 |
3. Cron 触发器设置(Schedules)
- 在 Worker 的 Settings -> Triggers -> Cron Triggers 中添加定时调度规则,如
0 */6 * * *(每 6 小时自动巡检一次)。
完整 Worker 源码
/** * Cloudflare Worker: GitHub Repository Update Monitor (Serverless) * 特性: * 1. 深度优化柔和暖调护眼浅色(消除纯白眩光)+ 微微黑柔和暗色自由切换 * 2. 宽屏(30寸/2K/4K)多列自适应卡片 + 手机端全端自适应 + 专属 Favicon * 3. 内部巡检 Token 鉴权保护 */
const FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none"> <defs> <linearGradient id="grad" x1="8" y1="8" x2="56" y2="56" gradientUnits="userSpaceOnUse"> <stop stop-color="#0284c7"/> <stop offset="1" stop-color="#059669"/> </linearGradient> </defs> <rect width="64" height="64" rx="16" fill="#eef2f6"/> <rect x="1.5" y="1.5" width="61" height="61" rx="14.5" stroke="#cbd5e1" stroke-width="2"/> <circle cx="20" cy="22" r="5.5" fill="#0284c7"/> <circle cx="44" cy="22" r="5.5" fill="#059669"/> <circle cx="32" cy="44" r="5.5" fill="#10b981"/> <path d="M20 22V32C20 36 24 40 28 42L32 44M44 22V32C44 36 40 40 36 42L32 44" stroke="url(#grad)" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
export default { // Cloudflare Cron 定时调度(每 6 小时自动触发) async scheduled(event, env, ctx) { ctx.waitUntil(runMonitor(env, false)); },
async fetch(request, env, ctx) { const url = new URL(request.url); const path = url.pathname;
if (path === '/favicon.ico' || path === '/favicon.svg') { return new Response(FAVICON_SVG, { headers: { 'Content-Type': 'image/svg+xml; charset=utf-8', 'Cache-Control': 'public, max-age=604800, immutable' } }); }
// 内部调试接口:必须携带有效 token 鉴权 if (path === '/check' || path === '/api/check') { const token = url.searchParams.get('token') || request.headers.get('x-token'); const validToken = env.ADMIN_TOKEN || env.PUSH_TOKEN || 'fdngkee5yt'; if (!token || token !== validToken) { return new Response(JSON.stringify({ status: 'fail', error: '未授权:请提供有效的管理员 token' }), { status: 403, headers: { 'Content-Type': 'application/json; charset=utf-8' } }); } const result = await runMonitor(env, false); return new Response(JSON.stringify(result, null, 2), { headers: { 'Content-Type': 'application/json; charset=utf-8' } }); }
if (path === '/health' || path === '/status') { const status = await getStatus(env); return new Response(JSON.stringify(status, null, 2), { headers: { 'Content-Type': 'application/json; charset=utf-8' } }); }
// 纯只读展示看板 return handleDashboard(request, env); }};
const DEFAULT_REPOS = [ { repo: "cedar2025/Xboard", alias: "V面板", monitor_commits: true, monitor_releases: false, branch: "master" }, { repo: "pppscn/SmsForwarder", alias: "SmsForwarder", monitor_commits: false, monitor_releases: true, stable_only: true }, { repo: "massgravel/Microsoft-Activation-Scripts", alias: "KMS脚本", monitor_commits: false, monitor_releases: true, stable_only: true }, { repo: "cmontage/mas-cn", alias: "MAS中文版", monitor_commits: false, monitor_releases: true, stable_only: true }];
function getReposConfig(env) { if (env.REPOS_CONFIG) { try { return JSON.parse(env.REPOS_CONFIG); } catch (e) { console.error("Failed to parse REPOS_CONFIG:", e); } } return DEFAULT_REPOS;}
function formatBeijingTime(date) { if (!date) return "未知时间"; const d = date instanceof Date ? date : new Date(date); if (isNaN(d.getTime())) return String(date); return new Intl.DateTimeFormat("zh-CN", { timeZone: "Asia/Shanghai", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false }).format(d).replace(/\//g, "-");}
async function runMonitor(env, isTest = false) { const repos = getReposConfig(env); const results = []; const pushUrl = env.PUSH_URL || "https://sms.onepve.com/message"; const pushToken = env.PUSH_TOKEN || "fdngkee5yt"; const ghToken = env.GITHUB_TOKEN || "";
const headers = { "User-Agent": "Cloudflare-Worker-Repo-Monitor/1.0", "Accept": "application/vnd.github.v3+json" }; if (ghToken) { headers["Authorization"] = `token ${ghToken}`; }
for (const repoCfg of repos) { const { repo, alias, monitor_commits, monitor_releases, stable_only, branch } = repoCfg; const itemResult = { repo, alias, checked_at: formatBeijingTime(new Date()), status: "ok" };
try { if (monitor_commits) { let commitUrl = `https://api.github.com/repos/${repo}/commits?per_page=1`; if (branch) commitUrl += `&sha=${encodeURIComponent(branch)}`;
const resp = await fetch(commitUrl, { headers }); if (!resp.ok) throw new Error(`GitHub Commits API HTTP ${resp.status}`); const commits = await resp.json(); if (commits && commits.length > 0) { const latest = commits[0]; const sha = latest.sha; const shortSha = sha.substring(0, 7); const commitMsg = (latest.commit?.message || "").split("")[0]; const commitDate = latest.commit?.author?.date || latest.commit?.committer?.date;
const kvKey = `commit:${repo}`; const recordedRaw = await env.REPO_KV.get(kvKey); let recorded = null; if (recordedRaw) { try { recorded = JSON.parse(recordedRaw); } catch(e){} }
const isFirstInit = !recorded; const isNew = recorded && recorded.sha !== sha;
itemResult.commit = { latest_sha: shortSha, message: commitMsg, date: formatBeijingTime(commitDate), is_new: isNew || (isTest && !isFirstInit) };
if (isFirstInit) { await env.REPO_KV.put(kvKey, JSON.stringify({ sha, short_sha: shortSha, message: commitMsg, date: commitDate, updated_at: new Date().toISOString() })); itemResult.commit.action = "initialized"; } else if (isNew || isTest) { const title = `📢 ${alias} Commit更新`; const content = [ `仓库名:${alias}`, `Commit ID:${shortSha}`, `提交信息:${commitMsg}`, `更新时间:${formatBeijingTime(commitDate)}`, `仓库地址:https://github.com/${repo}/commit/${sha}` ].join("");
const pushSuccess = await sendPush(pushUrl, pushToken, title, content); if (pushSuccess) { await env.REPO_KV.put(kvKey, JSON.stringify({ sha, short_sha: shortSha, message: commitMsg, date: commitDate, updated_at: new Date().toISOString() })); itemResult.commit.action = "pushed"; } else { itemResult.commit.action = "push_failed"; } } else { itemResult.commit.action = "no_change"; } } }
if (monitor_releases) { const relUrl = `https://api.github.com/repos/${repo}/releases?per_page=5`; const resp = await fetch(relUrl, { headers }); if (!resp.ok) throw new Error(`GitHub Releases API HTTP ${resp.status}`); const releases = await resp.json();
let targetReleases = Array.isArray(releases) ? releases : []; if (stable_only) { targetReleases = targetReleases.filter(r => !r.prerelease && !r.draft); }
if (targetReleases.length > 0) { const latest = targetReleases[0]; const tagName = latest.tag_name; const releaseName = latest.name || tagName; const publishedAt = latest.published_at; const htmlUrl = latest.html_url; const isStable = !latest.prerelease;
const kvKey = `release:${repo}`; const recordedRaw = await env.REPO_KV.get(kvKey); let recorded = null; if (recordedRaw) { try { recorded = JSON.parse(recordedRaw); } catch(e){} }
const isFirstInit = !recorded; const isNew = recorded && recorded.tag_name !== tagName;
itemResult.release = { tag_name: tagName, name: releaseName, published_at: formatBeijingTime(publishedAt), is_new: isNew || (isTest && !isFirstInit) };
if (isFirstInit) { await env.REPO_KV.put(kvKey, JSON.stringify({ tag_name: tagName, name: releaseName, published_at: publishedAt, html_url: htmlUrl, updated_at: new Date().toISOString() })); itemResult.release.action = "initialized"; } else if (isNew || isTest) { const title = `📢 ${alias} 新增Releases`; const contentLines = [ `仓库名:${alias}` ]; if (tagName.trim() === releaseName.trim()) { contentLines.push(`版本:${releaseName}`); } else { contentLines.push(`标签:${tagName}`); contentLines.push(`名称:${releaseName}`); } contentLines.push(`是否正式版:${isStable ? "true" : "false"}`); contentLines.push(`发布时间:${formatBeijingTime(publishedAt)}`); contentLines.push(`地址:${htmlUrl}`);
const content = contentLines.join(""); const pushSuccess = await sendPush(pushUrl, pushToken, title, content); if (pushSuccess) { await env.REPO_KV.put(kvKey, JSON.stringify({ tag_name: tagName, name: releaseName, published_at: publishedAt, html_url: htmlUrl, updated_at: new Date().toISOString() })); itemResult.release.action = "pushed"; } else { itemResult.release.action = "push_failed"; } } else { itemResult.release.action = "no_change"; } } else { itemResult.release = { action: "no_releases_found" }; } }
} catch (err) { console.error(`Error monitoring ${repo}:`, err); itemResult.status = "error"; itemResult.error = err.message; }
results.push(itemResult); }
await env.REPO_KV.put("last_check_meta", JSON.stringify({ time: new Date().toISOString(), formatted: formatBeijingTime(new Date()), summary: results }));
return { success: true, checked_at: formatBeijingTime(new Date()), results };}
async function sendPush(url, token, title, content) { try { const targetUrl = new URL(url); targetUrl.searchParams.set("token", token);
const resp = await fetch(targetUrl.toString(), { method: "POST", headers: { "Content-Type": "application/json; charset=utf-8", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" }, body: JSON.stringify({ title, content }) }); return resp.ok; } catch (e) { console.error("sendPush failed:", e); return false; }}
async function getStatus(env) { const repos = getReposConfig(env); const list = []; for (const r of repos) { const commitRaw = await env.REPO_KV.get(`commit:${r.repo}`); const releaseRaw = await env.REPO_KV.get(`release:${r.repo}`); list.push({ ...r, saved_commit: commitRaw ? JSON.parse(commitRaw) : null, saved_release: releaseRaw ? JSON.parse(releaseRaw) : null }); } const lastCheck = await env.REPO_KV.get("last_check_meta"); return { status: "healthy", last_check: lastCheck ? JSON.parse(lastCheck) : null, repos: list };}
async function handleDashboard(request, env) { const status = await getStatus(env); const reposHtml = status.repos.map(r => { let detail = ""; if (r.monitor_commits) { const c = r.saved_commit; detail += ` <div class="tag"> <div class="tag-top"> <div class="tag-left"> <span class="type-badge commit-badge">Commit 监控</span> ${c ? `<code>${c.short_sha || c.sha.substring(0,7)}</code>` : '<span class="desc">尚未初始化</span>'} </div> ${c?.date ? `<span class="tag-time">${formatBeijingTime(c.date)}</span>` : ''} </div> ${c?.message ? `<div class="desc">${c.message}</div>` : ''} </div> `; } if (r.monitor_releases) { const rel = r.saved_release; detail += ` <div class="tag"> <div class="tag-top"> <div class="tag-left"> <span class="type-badge release-badge">Release 监控</span> ${rel ? `<code>${rel.tag_name}</code>` : '<span class="desc">尚未初始化</span>'} </div> ${rel?.published_at ? `<span class="tag-time">${formatBeijingTime(rel.published_at)}</span>` : ''} </div> ${rel?.name && rel.name !== rel.tag_name ? `<div class="desc">${rel.name}</div>` : ''} </div> `; } return ` <div class="card"> <div class="card-header"> <div class="alias-wrap"> <span class="alias">${r.alias}</span> <span class="repo-sub">${r.repo}</span> </div> <a href="https://github.com/${r.repo}" target="_blank" class="repo-link">访问仓库 ↗</a> </div> <div class="card-body"> ${detail} </div> </div> `; }).join("");
const lastCheckTime = status.last_check?.formatted || "尚未运行";
const html = `<!DOCTYPE html><html lang="zh-CN"><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"> <link rel="icon" type="image/svg+xml" href="/favicon.ico"> <title>GitHub 仓库监控网关</title> <style> :root { --bg: #ebeff4; --surface: #f6f8fb; --surface-sub: #e2e8f0; --border: #d1d8e2; --border-sub: #b8c3d2; --accent: #0284c7; --accent-hover: #0369a1; --accent-light: rgba(2, 132, 199, 0.12); --text: #1e293b; --text-muted: #576579; --success: #059669; --success-bg: #def7ec; --success-border: #9ee3c3; --code-bg: #e5ebf2; } [data-theme="dark"] { --bg: #0b1120; --surface: #1e293b; --surface-sub: #2d3a4f; --border: #334155; --border-sub: #475569; --accent: #38bdf8; --accent-hover: #0ea5e9; --accent-light: rgba(56, 189, 248, 0.15); --text: #f1f5f9; --text-muted: #94a3b8; --success: #10b981; --success-bg: rgba(16, 185, 129, 0.14); --success-border: rgba(16, 185, 129, 0.35); --code-bg: #0f172a; } * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; background: var(--bg); color: var(--text); line-height: 1.6; padding: 2.5rem 2rem; min-height: 100vh; display: flex; justify-content: center; transition: background 0.3s, color 0.3s; -webkit-font-smoothing: antialiased; } .container { max-width: 1400px; width: 100%; min-width: 0; }
header { margin-bottom: 1.75rem; display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; } .header-content { flex: 1; min-width: 0; } .badge { display: inline-block; font-size: 0.75rem; font-weight: 600; color: var(--accent); background: var(--accent-light); border: 1px solid var(--border); padding: 0.22rem 0.7rem; border-radius: 9999px; margin-bottom: 0.6rem; } .title-wrap { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.4rem; } .header-logo { width: 36px; height: 36px; border-radius: 9px; flex-shrink: 0; } h1 { font-size: 2.1rem; font-weight: 800; color: var(--text); letter-spacing: -0.02em; line-height: 1.25; } .subtitle { color: var(--text-muted); font-size: 0.98rem; line-height: 1.5; }
.theme-toggle-btn { background: var(--surface); border: 1px solid var(--border); color: var(--text); width: 40px; height: 40px; border-radius: 10px; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; font-size: 1.15rem; transition: all 0.2s ease; box-shadow: 0 1px 3px rgba(0,0,0,0.04); flex-shrink: 0; margin-top: 4px; } .theme-toggle-btn:hover { background: var(--surface-sub); border-color: var(--border-sub); transform: scale(1.05); }
.status-bar { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 1.1rem 1.4rem; margin-bottom: 1.5rem; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.03); } .status-indicator { display: inline-flex; align-items: center; gap: 0.65rem; font-size: 0.92rem; font-weight: 600; color: var(--success); background: var(--success-bg); border: 1px solid var(--success-border); padding: 0.38rem 0.85rem; border-radius: 8px; } .pulse-dot { width: 8px; height: 8px; background-color: var(--success); border-radius: 50%; box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7); animation: pulse 2s infinite; flex-shrink: 0; } @keyframes pulse { 0% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7); } 70% { transform: scale(1); box-shadow: 0 0 0 7px rgba(16, 185, 129, 0); } 100% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); } } .status-meta { display: flex; align-items: center; gap: 1.5rem; font-size: 0.88rem; color: var(--text-muted); } .meta-item { display: inline-flex; align-items: center; gap: 0.35rem; white-space: nowrap; } .meta-label { color: var(--text-muted); } .meta-val { color: var(--text); font-weight: 600; white-space: nowrap; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
/* Widescreen Multi-Column Grid */ .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(420px, 1fr)); gap: 16px; } .card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 1.3rem 1.45rem; box-shadow: 0 1px 3px rgba(0,0,0,0.03); min-width: 0; display: flex; flex-direction: column; justify-content: space-between; } .card-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 0.75rem; margin-bottom: 0.85rem; } .alias-wrap { display: flex; flex-direction: column; gap: 0.2rem; min-width: 0; flex: 1; } .alias { font-size: 1.12rem; font-weight: 700; color: var(--text); line-height: 1.3; } .repo-sub { font-size: 0.82rem; color: var(--text-muted); word-break: break-all; line-height: 1.35; } .repo-link { font-size: 0.78rem; color: var(--accent); text-decoration: none; background: var(--accent-light); padding: 0.25rem 0.6rem; border-radius: 6px; font-weight: 500; flex-shrink: 0; white-space: nowrap; margin-top: 2px; transition: all 0.2s; } .repo-link:hover { opacity: 0.85; }
.tag { padding: 0.75rem 0.9rem; border-radius: 8px; font-size: 0.86rem; margin-top: 6px; background: var(--surface-sub); border: 1px solid var(--border); display: flex; flex-direction: column; gap: 5px; } .tag-top { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; flex-wrap: wrap; } .tag-left { display: inline-flex; align-items: center; gap: 0.45rem; flex-wrap: wrap; } .type-badge { font-size: 0.72rem; font-weight: 600; padding: 0.15rem 0.45rem; border-radius: 4px; flex-shrink: 0; } .commit-badge { background: var(--accent-light); color: var(--accent); } .release-badge { background: var(--success-bg); color: var(--success); } code { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; background: var(--surface); padding: 2px 6px; border-radius: 4px; border: 1px solid var(--border); color: var(--text); font-weight: 600; font-size: 0.84rem; word-break: break-all; } .tag-time { font-size: 0.78rem; color: var(--text-muted); white-space: nowrap; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; } .desc { color: var(--text-muted); font-size: 0.84rem; line-height: 1.45; word-break: break-word; }
footer { text-align: center; margin-top: 3rem; color: var(--text-muted); font-size: 0.85rem; }
@media (max-width: 640px) { body { padding: 1.2rem 0.85rem; } .container { max-width: 100%; } h1 { font-size: 1.45rem; } .header-logo { width: 28px; height: 28px; } .subtitle { font-size: 0.86rem; } .grid { grid-template-columns: 1fr; gap: 10px; } .card { padding: 1rem 0.9rem; border-radius: 10px; } .status-bar { padding: 0.85rem 0.95rem; flex-direction: column; align-items: stretch; gap: 8px; border-radius: 10px; } .status-indicator { width: 100%; justify-content: flex-start; } .status-meta { width: 100%; display: flex; flex-direction: column; gap: 5px; padding-top: 7px; border-top: 1px dashed var(--border); } .meta-item { display: flex; justify-content: space-between; align-items: center; width: 100%; font-size: 0.82rem; } .tag-top { flex-direction: column; align-items: flex-start; gap: 3px; } .tag-time { font-size: 0.74rem; color: var(--text-muted); } } </style></head><body> <div class="container"> <header> <div class="header-content"> <div class="badge">Serverless Multi-Repo Watcher</div> <div class="title-wrap"> <img src="/favicon.ico" class="header-logo" alt="Logo" /> <h1>GitHub 仓库监控网关</h1> </div> <p class="subtitle">Cloudflare Worker 边缘定时调度 · 自动检测 Commits 与 Releases 更新并推送</p> </div> <button class="theme-toggle-btn" id="themeToggle" onclick="toggleTheme()" title="切换明亮/暗色模式">🌙</button> </header>
<div class="status-bar"> <div class="status-indicator"> <div class="pulse-dot"></div> <span>● 监控服务运行正常</span> </div> <div class="status-meta"> <div class="meta-item"> <span class="meta-label">上次自动巡检:</span> <span class="meta-val">${lastCheckTime}</span> </div> <div class="meta-item"> <span class="meta-label">巡检调度周期:</span> <span class="meta-val">每 6 小时</span> </div> </div> </div>
<div class="grid"> ${reposHtml} </div>
<footer> Powered by Cloudflare Workers & KV Storage · 零服务器常驻 </footer> </div>
<script> function initTheme() { const saved = localStorage.getItem('theme'); const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; if (saved === 'dark' || (!saved && prefersDark)) { document.documentElement.setAttribute('data-theme', 'dark'); updateThemeBtn(true); } else { document.documentElement.setAttribute('data-theme', 'light'); updateThemeBtn(false); } } function toggleTheme() { const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; const next = isDark ? 'light' : 'dark'; document.documentElement.setAttribute('data-theme', next); localStorage.setItem('theme', next); updateThemeBtn(!isDark); } function updateThemeBtn(isDark) { const btn = document.getElementById('themeToggle'); if (btn) btn.innerHTML = isDark ? '☀️' : '🌙'; } initTheme(); </script></body></html>`;
return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } });}总结
将 GitHub 仓库监控迁移到 Cloudflare Worker + KV 架构后:
- 彻底摆脱 VPS:不仅节约服务器常驻内存和进程守护,即使 VPS 维护重启也不会漏掉任何发版提醒;
- 零成本与高可用:每天触发几十次检查,远低于 Cloudflare 免费配额(10 万次/天);
- 视觉与体验双提升:现代温润护眼看板让监控状态一目了然,调用检测方便快捷。