本地青龙:用 TypeScript 搭一个轻量任务调度器

本地青龙:用 TypeScript 搭一个轻量任务调度器
我有一堆定时脚本:RSS 日报每天 8 点跑、数据库备份凌晨 3 点跑、Homebrew 每周日更新。
以前全靠 crontab,但问题是:
- 跑没跑不知道,得自己去看日志
- 失败了没通知,第二天才发现昨天的备份挂了
- 想临时手动跑一下,得记住完整命令
后来写了个任务调度器,解决这三个问题:统一管理 + Web 界面 + 失败通知。
效果长这样
$ mt list
┌────────────────┬─────────────┬──────┬────────────┬──────┬────────────┐
│ ID │ 名称 │ 类型 │ 调度 │ 状态 │ 上次执行 │
├────────────────┼─────────────┼──────┼────────────┼──────┼────────────┤
│ <REDACTED_TASK_ID> │ RSS 日报 │ 定时 │ 0 8 * * * │ ● │ 2 小时前 │
│ <REDACTED_TASK_ID> │ 数据库备份 │ 定时 │ 0 3 * * * │ ● │ 7 小时前 │
│ <REDACTED_TASK_ID> │ R2 备份检查 │ 定时 │ 0 9 * * * │ ● │ 1 小时前 │
│ <REDACTED_TASK_ID> │ Homebrew 更新│ 定时 │ 0 2 * * 0 │ ● │ 3 天前 │
└────────────────┴─────────────┴──────┴────────────┴──────┴────────────┘
$ mt run <REDACTED_TASK_ID>
🚀 开始执行: RSS 日报
✅ 执行完成 (耗时 45s)
还有个 Web 界面(:5700),可以看状态、查日志、手动触发。
核心能力
| 能力 | 说明 |
|---|---|
| YAML 定义任务 | 一个文件管理所有定时任务 |
| CLI 管理 | mt list、mt run、mt logs |
| Web 界面 | 实时状态、历史记录、一键执行 |
| 失败通知 | 支持 |
| 统一生命周期 | CLI、Scheduler、Web 触发行为一致 |
5 分钟跑通
mkdir task-scheduler && cd task-scheduler
bun init -y
bun add commander chalk cli-table3 dayjs croner hono yaml
mkdir -p config scripts/{<REDACTED_TASK_ID>,<REDACTED_TASK_ID>} src/core data logs
创建 config/tasks.yaml:
tasks:
- id: <REDACTED_TASK_ID>
name: Hello World
cron: "*/5 * * * *" # 每 5 分钟
command: echo "Hello from scheduler!"
workdir: .
timeout: 60
enabled: true
创建核心代码后运行:
bun run src/index.ts list
bun run src/index.ts run <REDACTED_TASK_ID>
核心代码结构(点击展开)
src/
├── index.ts # CLI 入口
├── scheduler.ts # 调度器(cron 触发)
├── server.ts # Web UI + API
└── core/
├── config.ts # 加载 YAML 配置
├── runner.ts # 统一执行生命周期
├── executor.ts # 执行命令
├── logger.ts # 日志管理
├── state.ts # 状态/历史持久化
└── notifier.ts # 通知发送
关键设计:所有入口都走 runner.ts
// src/core/runner.ts 核心逻辑
export async function runTaskWithLifecycle(task: TaskConfig) {
// 1. 标记 running
updateState(task.id, { status: 'running', lastRun: new Date() });
// 2. 记录历史开始
const historyId = createHistory(task.id);
// 3. 执行命令
const result = await executeCommand(task.command, {
cwd: task.workdir,
timeout: task.timeout * 1000,
env: task.env,
});
// 4. 归一化状态
const status = result.timeout ? 'timeout'
: result.exitCode === 0 ? 'success' : 'failed';
// 5. 更新历史
updateHistory(historyId, { status, exitCode: result.exitCode });
// 6. 发通知
if (status !== 'success' && task.notify?.on_failure) {
await sendNotification(task, status);
}
return result;
}
这样不管从 CLI、Web、还是 Scheduler 触发,行为都一致。
同步到 launchd(macOS 开机自启)
# 生成 plist 并安装
bun run src/index.ts install
# 启动服务
launchctl load ~/Library/LaunchAgents/com.envvar.task-scheduler.plist
# 查看状态
launchctl list | grep task-scheduler
这样重启后调度器自动运行,不用手动启动。
我踩过的坑
1. cron 表达式时区问题
默认用的是系统时区,但如果你在 Docker 或服务器上跑,可能是 UTC。我直接在配置加载时强制指定:
const cron = new Cron(task.cron, { timezone: '<REDACTED_TIMEZONE>' }, callback);
2. 日志文件太大
每个任务每次执行都写日志,跑久了日志目录几个 G。加了个简单的轮转:只保留最近 7 天的日志文件。
3. Web 界面和 CLI 状态不同步
一开始 Web 有自己的状态缓存,CLI 有自己的。后来统一到 data/state.json,所有入口读写同一个文件。
任务配置示例
tasks:
# RSS 日报
- id: <REDACTED_TASK_ID>
name: RSS 日报
cron: "0 8 * * *" # 每天 8:00
command: bun run src/index.ts
workdir: <REDACTED_TASK_ID>
env:
AI_BASE_URL: <REDACTED_API_ENDPOINT>
SUMMARIZER_MODEL: <REDACTED_AI_MODEL>
timeout: 300
notify:
on_failure: true
enabled: true
# 数据库备份
- id: <REDACTED_TASK_ID>
name: 数据库备份
cron: "0 3 * * *" # 每天 3:00
command: ./run.sh
workdir: <REDACTED_TASK_ID>
timeout: 300
notify:
on_failure: true
enabled: true
下一步
- 加任务依赖:A 跑完再跑 B
- 加重试机制:失败后自动重试 N 次
- 加 Dashboard:汇总所有任务的成功率、平均耗时
crontab 能用,但不够用。花半天时间搭个调度器,以后省心很多。
附录:给 AI 的复现指令
复制下面这段给 Claude/GPT,让它帮你从零搭建:
帮我搭建一个本地任务调度系统,类似青龙面板但更轻量。
目标:用 YAML 定义定时任务,支持 CLI 管理、Web 界面、失败通知。
技术栈:Bun + TypeScript
依赖:commander, chalk, cli-table3, dayjs, croner, hono, yaml
项目结构:
task-scheduler/
config/
tasks.yaml -- 任务定义
env.yaml -- 环境变量(可选)
notify.yaml -- 通知配置(可选)
scripts/ -- 各任务脚本目录
src/
index.ts -- CLI 入口
scheduler.ts -- cron 调度器
server.ts -- Web UI + API (Hono)
core/
config.ts -- 加载 YAML 配置
runner.ts -- 统一执行生命周期
executor.ts -- 执行命令(spawn)
logger.ts -- 日志管理
state.ts -- 状态/历史持久化(JSON 文件)
notifier.ts -- 通知(ntfy/telegram/bark)
data/ -- 运行时状态
logs/ -- 任务日志
tasks.yaml 格式:
tasks:
- id: task-id
name: 任务名称
cron: "0 8 * * *"
command: bun run src/index.ts
workdir: task-dir
env: { KEY: value }
timeout: 300
notify: { on_failure: true }
enabled: true
CLI 命令:
mt list -- 列出任务
mt show <id> -- 任务详情
mt run <id> -- 立即执行
mt logs <id> -- 查看日志
mt history <id> -- 执行历史
mt serve --port 5700 -- 启动 Web
mt scheduler -- 启动调度器
mt install -- 安装 launchd 服务
核心设计:
- 所有入口(CLI/Web/Scheduler)都走 runner.ts 的统一生命周期
- 生命周期:标记 running → 记录历史 → 执行命令 → 更新状态 → 发通知
- 状态持久化到 data/state.json,确保多入口一致
Web API:
GET / -- 管理页面
GET /api/status/:id -- 任务状态
GET /api/logs/:id -- 日志
POST /api/run/:id -- 触发执行
定时任务(launchd):
- 提供 mt install 命令生成 plist
- 安装到 ~/Library/LaunchAgents/
- launchctl load 启动
成功标志:
- mt list 显示任务列表
- mt run <REDACTED_TASK_ID> 执行成功
- localhost:5700 显示 Web 界面
请创建完整项目结构和代码。