码桶

发现社区成员的开源项目

奶狗 /

ng-webot

公开
main
ng-webot/lib/cron.js
cron.js3.7 KB
/**
 * lib/cron.js — 轻量 cron / 间隔 计算(无第三方依赖)
 *
 * 支持两种模式:
 *  1) 间隔 interval:每 N 分钟 / 每 N 小时(由调度器直接计算)
 *  2) 标准 5 字段 cron:分 时 日 月 周
 *     例:每 5 分钟 -> 分字段写 星号/5 :  * /5 * * * *
 *         工作日 09:00 ->  0 9 * * 1-5
 *         每月 1 号 00:00 ->  0 0 1 * *
 *         1 月里每个周日 ->  * * * 1 1 (月+周同时限制时按「或」匹配)
 *     字段支持:* ? , - / (步长) 以及 0-7(7 等同周日 0)
 *     注意:本注释内用「星号/5」表示,避免和块注释的 * / 冲突。
 */

// 解析单个字段为允许值集合,并返回是否等于通配(*/?)
function parseField(field, min, max) {
  const set = new Set();
  if (field === '*' || field === '?') {
    for (let i = min; i <= max; i++) set.add(i);
    return { set, star: true };
  }
  const parts = field.split(',');
  for (let part of parts) {
    let step = 1;
    let range = part;
    if (part.includes('/')) {
      const sp = part.split('/');
      step = parseInt(sp[1], 10);
      range = sp[0] === '' ? '*' : sp[0];
    }
    let lo, hi;
    if (range === '*' || range === '?') {
      lo = min; hi = max;
    } else if (range.includes('-')) {
      const seg = range.split('-');
      lo = parseInt(seg[0], 10);
      hi = parseInt(seg[1], 10);
    } else {
      lo = hi = parseInt(range, 10);
    }
    if (!Number.isFinite(lo) || !Number.isFinite(hi) || step < 1) {
      throw new Error('cron 字段格式错误: ' + field);
    }
    for (let i = lo; i <= hi; i += step) {
      if (i >= min && i <= max) set.add(i);
    }
  }
  if (set.size === 0) throw new Error('cron 字段无有效值: ' + field);
  return { set, star: false };
}

function parseCron(expr) {
  const fields = (expr || '').trim().split(/\s+/);
  if (fields.length !== 5) {
    throw new Error('cron 表达式需为 5 个字段(分 时 日 月 周),当前为 ' + fields.length + ' 个');
  }
  const minute = parseField(fields[0], 0, 59);
  const hour = parseField(fields[1], 0, 23);
  const dom = parseField(fields[2], 1, 31);
  const month = parseField(fields[3], 1, 12);
  const dow = parseField(fields[4], 0, 7); // 含 7 作为周日别名
  return { minute, hour, dom, month, dow };
}

function matches(c, date) {
  if (!c.minute.set.has(date.getMinutes())) return false;
  if (!c.hour.set.has(date.getHours())) return false;
  if (!c.month.set.has(date.getMonth() + 1)) return false;
  const dom = date.getDate();
  const wd = date.getDay(); // 0=周日
  const dowMatches = c.dow.set.has(wd) || (wd === 0 && c.dow.set.has(7));
  if (c.dom.star || c.dow.star) {
    // 任一为通配:按「与」匹配
    if (!c.dom.set.has(dom)) return false;
    if (!dowMatches) return false;
  } else {
    // 日 与 周 同时指定:按 cron 惯例「或」匹配
    if (!(c.dom.set.has(dom) || dowMatches)) return false;
  }
  return true;
}

/**
 * 计算 expr 在 afterEpochSec 之后的下一次触发时间(epoch 秒),找不到返回 null。
 * @param {string} expr 5 字段 cron
 * @param {number} afterEpochSec 起始时间(不含),通常为 Math.floor(Date.now()/1000)
 * @param {number} maxYears 搜索上限(年),默认 4 年
 */
function nextCronTime(expr, afterEpochSec, maxYears = 4) {
  const c = parseCron(expr);
  let t = (afterEpochSec + 1) * 1000; // 从下一秒开始
  const limit = t + maxYears * 366 * 24 * 3600 * 1000;
  while (t <= limit) {
    const d = new Date(t);
    if (matches(c, d)) return Math.floor(t / 1000);
    t += 60000; // 按分钟步进
  }
  return null;
}

module.exports = { parseCron, nextCronTime, matches };