用 Vercel Cron Jobs 做一个免费的网站巡检:每天定时访问你的几个网站,检查状态码和页面关键字,有异常就推送到群机器人。整个项目只有一个函数和一个配置文件。
先看清成品
成品是
api/cron.js加vercel.json里的crons配置。本文已用 Node.js 22 实际运行 2 个测试,全部通过:没有或错误的CRON_SECRET返回 401;本地模拟的正常页面、缺少关键字、HTTP 500 三种情况能正确区分,并向模拟 Webhook 发出一条包含两个异常的告警;带正确密钥时真实检查 example.com 返回 200。本文环境没有 Vercel 账号,Cron 没有在 Vercel 上实际触发。
注意:Vercel 的免费 Hobby 计划仅限个人、非商业用途(官方 Hobby 页面和公平使用准则写明),商业项目需要 Pro 计划。各计划额度以官方页面为准。
注意:官方文档写明,Hobby 计划的 Cron 每天只能运行一次,写成更频繁会导致部署失败,而且可能在指定小时内的任意时间触发;失败的调用不会重试。需要每几分钟检查一次的话,要升级计划或用专门的监控服务。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
| 网站清单 | sites.json,可选关键字检查 |
| 并发检查 | 每个网站 10 秒超时 |
| 鉴权 | 只接受带 CRON_SECRET 的调用 |
| 告警 | 有异常才推送 |
| 日志 | 每次结果打印到 Vercel 日志 |
需要准备哪些环境?
| 项目 | 要求 | 说明 |
|---|---|---|
| Vercel 账号 | Hobby 免费计划即可 | 用 GitHub 账号注册最方便 |
| Node.js | 20 或更高 | 本文使用 22.23.3 |
| Vercel CLI | npm i -g vercel |
本文核对时最新版为 65.0.0 |
| GitHub 仓库 | 可选 | 连接后每次推送自动部署 |
| 群机器人 Webhook | 可选 | 不配置就只记日志 |
第一步:Cron 是怎么工作的?
| 机制 | 官方说明 |
|---|---|
| 配置位置 | vercel.json 的 crons,path + schedule |
| 时区 | 表达式按 UTC 计算 |
| 调用方式 | Vercel 对生产部署的这个路径发 GET 请求 |
| 鉴权 | 设置了 CRON_SECRET 后,会带 Authorization: Bearer 密钥 |
| 重定向 | 不跟随 |
| 重复/漏调用 | 偶尔会发生,任务要能重复执行 |
本文的 0 1 * * * 是 UTC 1 点,也就是北京时间 9 点左右。
第二步:代码怎么写?
1. 项目结构
uptime-cron/
├── api/cron.js
├── sites.json
├── cron.test.js
├── vercel.json
└── package.json
2. sites.json
[
{ "name": "官网", "url": "https://example.com/" },
{ "name": "博客", "url": "https://example.org/", "expect": "Example Domain" }
]
3. api/cron.js
// 每天由 Vercel Cron 调用:检查网站是否可用,有异常就发通知
import sites from '../sites.json' with { type: 'json' };
export async function checkOne(site, timeoutMs = 10000) {
const started = Date.now();
try {
const r = await fetch(site.url, { signal: AbortSignal.timeout(timeoutMs), redirect: 'follow' });
const ms = Date.now() - started;
if (!r.ok) return { ...site, ok: false, reason: `HTTP ${r.status}`, ms };
if (site.expect && !(await r.text()).includes(site.expect)) {
return { ...site, ok: false, reason: `页面里没有“${site.expect}”`, ms };
}
return { ...site, ok: true, ms };
} catch (e) {
return { ...site, ok: false, reason: e.name === 'TimeoutError' ? '超时' : e.message, ms: Date.now() - started };
}
}
export async function GET(request) {
const secret = process.env.CRON_SECRET;
if (!secret || request.headers.get('authorization') !== `Bearer ${secret}`) {
return new Response('Unauthorized', { status: 401 });
}
return Response.json(await run(sites));
}
// 把检查逻辑单独导出,方便测试时传入自己的网站列表
export async function run(list) {
const results = await Promise.all(list.map((s) => checkOne(s)));
const bad = results.filter((r) => !r.ok);
if (bad.length && process.env.ALERT_WEBHOOK_URL) {
const text = '网站巡检异常:\n' + bad.map((b) => `${b.name} ${b.url}:${b.reason}`).join('\n');
await fetch(process.env.ALERT_WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, msg_type: 'text', content: { text } }),
});
}
console.log(JSON.stringify(results)); // 在 Vercel 日志里可以看到
return { checked: results.length, failed: bad.length, results };
}
| 要点 | 说明 |
|---|---|
| 鉴权写法 | 与官方示例一致,密钥为空时也拒绝 |
AbortSignal.timeout |
避免一个网站卡住整个任务 |
run 单独导出 |
测试时传入本地网站列表 |
| 只读操作 | 重复执行没有副作用,符合官方的幂等建议 |
4. vercel.json
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"crons": [
{ "path": "/api/cron", "schedule": "0 1 * * *" }
]
}
第三步:怎样测试和部署?
1. 本地测试
import test from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import { GET, run } from './api/cron.js';
let alerts = [];
const srv = http.createServer((q, s) => {
if (q.url === '/ok') return s.end('<h1>Hello 你好</h1>');
if (q.url === '/slow') return setTimeout(() => s.end('late'), 3000);
if (q.url === '/hook') { let d = ''; q.on('data', c => d += c); return q.on('end', () => { alerts.push(JSON.parse(d)); s.end(); }); }
s.statusCode = 500; s.end('err');
});
await new Promise(r => srv.listen(0, r));
const base = `http://127.0.0.1:${srv.address().port}`;
process.env.CRON_SECRET = 'test-secret-1234567890';
process.env.ALERT_WEBHOOK_URL = base + '/hook';
const auth = (v) => new Request('http://x/api/cron', { headers: v ? { authorization: v } : {} });
test('没有或错误的密钥返回 401', async () => {
assert.equal((await GET(auth())).status, 401);
assert.equal((await GET(auth('Bearer wrong'))).status, 401);
});
test('正常、关键字缺失、500 都能识别,并发送告警', async () => {
const list = [
{ name: 'A', url: base + '/ok', expect: '你好' },
{ name: 'B', url: base + '/ok', expect: '不存在的字' },
{ name: 'C', url: base + '/err' },
];
const j = await run(list);
assert.equal((await GET(auth('Bearer test-secret-1234567890'))).status, 200);
assert.equal(j.failed, 2);
assert.match(alerts[0].text, /B .*没有/);
assert.match(alerts[0].text, /HTTP 500/);
srv.close();
});
npm test
本文实际输出 # pass 2、# fail 0。
2. 部署
vercel
vercel env add CRON_SECRET production # 至少 16 位随机字符
vercel env add ALERT_WEBHOOK_URL production
vercel --prod
生成随机密钥:
openssl rand -hex 24
3. 手动触发一次
curl -H "Authorization: Bearer 你的密钥" https://你的项目.vercel.app/api/cron
期望返回 {"checked":2,"failed":0,...}。不带请求头应返回 Unauthorized。
4. 查看定时任务
项目 Settings → Cron Jobs 能看到任务列表,点 View Logs 查看每次执行的日志。
出问题时该怎么调?
1. 部署失败,提示 Cron 频率
Hobby 计划写了 */5 * * * * 这类每天多次的表达式。改成每天一次。
2. Cron 执行了但一直 401
CRON_SECRET 没加到 Production 环境,或者加了之后没重新部署。
3. 某个网站总是“超时”
可能是那个网站屏蔽了来自数据中心的访问。用自己电脑 curl -I 对比,必要时调大超时。
常见问题怎么处理?
| 现象 | 原因 | 解决方法 |
|---|---|---|
| 时间不对 | 表达式按 UTC | 北京时间减 8 小时 |
| 预览部署不触发 | Cron 只对生产部署生效 | 用 vercel --prod |
| 一直没收到告警 | 所有网站都正常 | 加一个故意错误的网址测试 |
| 告警格式报错 | 不同平台字段不同 | 只保留对应平台字段 |
| 想记录历史 | 函数本身不存数据 | 接入数据库或存储服务 |
项目资源
| 用途 | 地址 |
|---|---|
| Cron Jobs 介绍 | https://vercel.com/docs/cron-jobs |
| 管理和保护 Cron Jobs | https://vercel.com/docs/cron-jobs/manage-cron-jobs |
| Vercel Node.js 函数 | https://vercel.com/docs/functions/runtimes/node-js |
| vercel env 命令 | https://vercel.com/docs/cli/env |
| Hobby 计划 | https://vercel.com/docs/plans/hobby |
建议先跑 npm test,部署后用 curl 手动触发,确认能收到一次告警(可以临时放一个不存在的网址)。
确认告警正常后再换成真实网站清单,第二天到 Cron Jobs 日志里确认定时执行了。