feat(auto-tools): 新增cron表达式解析功能并优化路径过滤逻辑

- 添加cron.js工具模块,实现cron表达式解析和下次执行时间计算功能
- 新增timeType枚举类型,支持小时和cron表达式两种时间配置方式
- 重构路径去重逻辑,使用Map按路径字符串和时间戳进行智能去重
- 移除废弃的getTimeDifference函数实现
- 在初始化配置中注册cron工具模块
- 实现基于时间配置的路径过滤机制,支持小时间隔和cron表达式触发
- 从pathing.json加载时间配置,按等级排序后应用到路径过滤逻辑
- 添加cron表达式解析失败的错误处理和日志记录
- 更新pathing.json默认配置,包含地方特产和矿物的时间规则示例
This commit is contained in:
yan
2026-01-12 12:06:06 +08:00
parent b2b76e1917
commit 251a6e7d38
3 changed files with 395 additions and 60 deletions

View File

@@ -67,6 +67,15 @@ const team = {
fightKeys: ['锄地专区', "敌人与魔物"],
SevenElements: settings.teamSevenElements ? settings.teamSevenElements.split(',').map(item => item.trim()) : [],
}
const timeType = Object.freeze({
hours: 'hours',//小时
cron: 'cron',//cron表达式
// 添加反向映射(可选)
fromValue(value) {
return Object.keys(this).find(key => this[key] === value);
}
});
/**
* 保存记录路径的函数
* 该函数将RecordPath对象中的Set类型数据转换为数组后保存到文件
@@ -77,17 +86,41 @@ async function saveRecordPaths() {
const recordToSave = {
// 使用展开运算符复制Record对象的所有属性保持其他数据不变
...RecordPath,
// paths Set转换为数组以便能够序列化为JSON
paths: [...RecordPath.paths].map(item => ({
// 保留name属性
timestamp: item.timestamp,
// 将item中的paths Set转换为数组
path: item.path
}))
// 处理 paths 数组
paths: (() => {
// 1. 使用 Map 来辅助去重Map 的 key 是 pathvalue 是完整的 item 对象
const pathMap = new Map();
// 假设 RecordPath.paths 是一个 Set先转为数组进行遍历
[...RecordPath.paths].forEach(item => {
// 获取当前项的路径字符串
const currentPath = item.path;
// 检查 Map 中是否已经存在该路径
if (pathMap.has(currentPath)) {
// 如果存在,比较时间戳
const existingItem = pathMap.get(currentPath);
// 如果当前项的时间戳比已存在的大,则更新 Map 中的值
if (item.timestamp > existingItem.timestamp) {
pathMap.set(currentPath, item);
}
} else {
// 如果不存在,直接存入 Map
pathMap.set(currentPath, item);
}
});
// 2. 将 Map 中的值(去重后的对象数组)转换回我们需要的格式
return Array.from(pathMap.values()).map(item => ({
timestamp: item.timestamp,
path: item.path
}));
})()
};
// 将记录列表转换为JSON字符串并同步写入文件
file.writeTextSync(RecordText, JSON.stringify(recordToSave))
}
/**
* 保存当前记录到记录列表并同步到文件
* 该函数在保存前会将Set类型的数据转换为数组格式确保JSON序列化正常进行
@@ -163,51 +196,6 @@ function getTimeDifference(startTime, endTime) {
}
/**
* 计算两个时间之间的差值并返回指定格式的JSON
* @param {number|Date} startTime - 开始时间时间戳或Date对象
* @param {number|Date} endTime - 结束时间时间戳或Date对象
* @returns {Object} diff_json - 包含info和total的对象
*/
function getTimeDifference(startTime, endTime) {
// 确保输入是时间戳
const start = typeof startTime === 'object' ? startTime.getTime() : startTime;
const end = typeof endTime === 'object' ? endTime.getTime() : endTime;
// 计算总差值(毫秒)
const diffMs = Math.abs(end - start);
// 计算总小时、分钟、秒(允许小数)
const totalSeconds = diffMs / 1000;
const totalHours = totalSeconds / 3600;
const totalMinutes = totalSeconds / 60;
// 计算剩余小时、分钟、秒(即去掉完整天数后的部分)
const infoHours = totalHours % 24;
const infoMinutes = totalMinutes;
const infoSeconds = totalSecs;
const diff_json = {
info: {
hours: infoHours,
minutes: infoMinutes,
seconds: infoSeconds
},
total: {
hours: totalHours,
minutes: totalMinutes,
seconds: totalSecs
}
};
return diff_json;
}
// 输出类似:
// {
// info: { hours: 整数, minutes: 小时后的整数, seconds: 分种后的整数 },
// total: {全是小数 hours: 1.1, minutes: 算出总分钟, seconds: 算出总秒 }
// }
/**
* 初始化记录函数
* 该函数用于初始化一条新的记录包括设置UID、时间戳和调整后的日期数据
@@ -445,6 +433,7 @@ async function getValueByMultiCheckboxName(name) {
async function init() {
let settingsConfig = await initSettings();
let utils = [
"cron",
"SwitchTeam",
"uid",
]
@@ -658,6 +647,9 @@ async function init() {
return map.keys().filter(key => key.startsWith(levelName))
}))
}
const timeJson = new Set(JSON.parse(file.readTextSync(`${pathingName}.json`)).sort(
(a, b) => b.level - a.level
))
// 初始化needRunMap
if (true) {
// for (let key of pathAsMap.keys()) {
@@ -669,6 +661,8 @@ async function init() {
const multiJson = await getJsonByMultiCheckboxName(settingsName)
const label = getBracketContent(multiJson.label)
let multi = multiJson.options
const settingsAsName = settingsNameAsList.find(item => item.settings_name === settingsName)
let list = PATH_JSON_LIST.filter(item =>
multi.some(element => item.path.includes(`\\${element}\\`) && item.path.includes(`\\${label}\\`))
@@ -677,14 +671,60 @@ async function init() {
const matchedElement = multi.find(element => item.path.includes(`\\${element}\\`) && item.path.includes(`\\${label}\\`));
return {name: item.name, parent_name: item.parent_name, selected: matchedElement || "", path: item.path}
});
if (list.length <= 0) {
continue
// 1. 预处理:将 Set/Map 转为数组,避免循环内重复转换
const recordPaths = Array.from(RecordPath.paths);
const timeConfigs = Array.from(timeJson);
const timeFilter = list.filter(item => {
// 2. 查找匹配的配置项 (假设这是必须的条件)
// 注意:这里保留了原有的 includes 逻辑,但在实际业务中建议评估是否需要精确匹配
const timeConfig = timeConfigs.find(e => item.path.includes(`\\${e.name}\\`));
if (!timeConfig) return false;
// 3. 查找匹配的历史记录
// 同样保留了 includes 逻辑
const matchedRecord = recordPaths.find(element => element.path.includes(item.path));
// 4. 如果没有记录,或者记录中没有时间戳,则跳过
if (!matchedRecord || !matchedRecord.timestamp) return false;
const {timestamp, value} = matchedRecord;
const now = Date.now();
// 5. 根据配置的类型进行时间判断
if (timeConfig.type) {
switch (timeType.fromValue(timeConfig.type)) {
case timeType.hours:
const timeDifference = getTimeDifference(timestamp, now);
return timeDifference.total.hours >= value;
case timeType.cron:
const nextCronTimestamp = cronUtil.getNextCronTimestamp(`${value}`, timestamp, now);
if (!nextCronTimestamp) {
log.error(`cron表达式解析失败: {value}`, value)
throw new Error(`cron表达式解析失败: ${value}`)
}
return now >= nextCronTimestamp;
default:
return false;
}
}
return false;
});
if (timeFilter?.length > 0) {
//移除CD
list = Array.from(new Set(list).difference(new Set(timeFilter)))
}
if (list?.length > 0) {
needRunMap.set(settingsAsName.settings_as_name, {
paths: list,
as_name: settingsAsName.settings_as_name,
name: settingsAsName.settings_name
})
}
needRunMap.set(settingsAsName.settings_as_name, {
paths: list,
as_name: settingsAsName.settings_as_name,
name: settingsAsName.settings_name
})
}
}
// 启用自动拾取的实时任务,并配置成启用急速拾取模式

View File

@@ -1 +1,20 @@
[]
[
{
"name": "晶蝶",
"type": "hours",
"level": 2,
"value": 12
},
{
"name": "地方特产",
"type": "hours",
"level": 1,
"value": 46
},
{
"name": "矿物",
"type": "cron",
"level": 1,
"value": "0 0 0 1/3 * ?"
}
]

View File

@@ -0,0 +1,276 @@
// 分钟 小时 日期 月份 星期 [年份可选]
const cronRegex = /^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)(?:\s+(\S+))?$/;
/**
* 解析单个 cron 字段,返回匹配的数值数组
* @param {string} field - 字段值 如 "1,3-5,* /10"
* @param {number} min - 最小值
* @param {number} max - 最大值
* @param {number} [stepBase=0] - 步进基准星期从0或1开始都支持
* @returns {Set<number>} 所有匹配的数值集合
*/
function parseCronField(field, min, max, stepBase = 0) {
const result = new Set();
const parts = field.split(',');
for (const part of parts) {
// 处理 */n 格式
if (part.startsWith('*/')) {
const step = parseInt(part.slice(2), 10);
if (isNaN(step) || step <= 0) continue;
for (let i = min; i <= max; i += step) {
result.add(i);
}
continue;
}
// 处理 n-n/n 格式
if (part.includes('/')) {
const [range, stepStr] = part.split('/');
const step = parseInt(stepStr, 10);
if (isNaN(step) || step <= 0) continue;
if (range === '*') {
for (let i = min; i <= max; i += step) {
result.add(i);
}
} else if (range.includes('-')) {
const [start, end] = range.split('-').map(Number);
if (isNaN(start) || isNaN(end)) continue;
for (let i = Math.max(min, start); i <= Math.min(max, end); i += step) {
result.add(i);
}
}
continue;
}
// 处理范围 n-n
if (part.includes('-')) {
const [start, end] = part.split('-').map(Number);
if (!isNaN(start) && !isNaN(end)) {
for (let i = Math.max(min, start); i <= Math.min(max, end); i++) {
result.add(i);
}
}
continue;
}
// 处理单个数字 / 列表
const num = parseInt(part, 10);
if (!isNaN(num) && num >= min && num <= max) {
result.add(num);
}
// 处理 L最后一天 - 只对日期字段有意义,这里简单处理
if (part === 'L' && max === 31) {
// 实际使用时需要知道当月天数,这里先占位
result.add(99); // 特殊标记,后续需处理
}
// ? 在日期/星期中代表「无特定值」 - 这里简单忽略具体值
if (part === '?') {
// 实际使用时需配合另一字段判断
}
}
return result;
}
/**
* 简单版 cron 解析器(只解析出每个字段允许的时间值)
* @param {string} cron - cron表达式 "0 9 * * 1-5"
* @returns {object|null} 解析结果 或 null格式错误
*/
function parseCron(cron) {
const match = cron.trim().match(cronRegex);
if (!match) return null;
const [, min, hour, day, month, dow] = match;
try {
const minutes = parseCronField(min, 0, 59);
const hours = parseCronField(hour, 0, 23);
const days = parseCronField(day, 1, 31);
const months = parseCronField(month, 1, 12);
const dows = parseCronField(dow, 0, 7); // 0和7都代表周日
// 星期字段 7 → 0
if (dows.has(7)) dows.add(0);
return {
minutes: [...minutes].sort((a, b) => a - b),
hours: [...hours].sort((a, b) => a - b),
days: [...days].sort((a, b) => a - b),
months: [...months].sort((a, b) => a - b),
dows: [...dows].sort((a, b) => a - b),
// 注days和dows同时有特定值时实际cron是「或」关系
// 这里仅简单分开列出,真实调度需复杂判断
original: cron.trim(),
isValid: true
};
} catch (e) {
return { isValid: false, error: e.message, original: cron };
}
}
/**
* 根据 cron 表达式和当前时间,计算下一次执行的时间戳(毫秒)
* 支持标准 5 段 cron 分钟 时 日 月 星期
* @param {string} cron - cron表达式例如 "30 2 * * 1-5"
* @param {number} [fromTime=Date.now()] - 从这个时间开始找下一个执行点
* @returns {number|null} 下一次执行的时间戳(毫秒),找不到返回 null
*/
function getNextCronTimestamp(cron, fromTime = Date.now(),endTime) {
const parts = cron.trim().split(/\s+/);
if (parts.length < 5 || parts.length > 6) {
throw new Error("不支持的 cron 格式,应为 5~6 段");
}
const [minStr, hourStr, dayStr, monthStr, dowStr] = parts;
// 解析每个字段
const minutes = parseField(minStr, 0, 59);
const hours = parseField(hourStr, 0, 23);
const days = parseField(dayStr, 1, 31);
const months = parseField(monthStr, 1, 12);
const dows = parseField(dowStr, 0, 7);
// 星期 7 → 0 (周日)
if (dows.has(7)) dows.add(0);
let current = new Date(fromTime);
// 如果没有指定 endTime默认设置为明天 00:00:00
if (endTime === undefined) {
const tomorrow = new Date(current);
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(0, 0, 0, 0);
endTime = tomorrow.getTime();
}
// 动态计算最大迭代次数
// 将时间差(毫秒)转换为分钟,向上取整,确保覆盖所有可能的分钟点
const timeDiffMinutes = Math.ceil((endTime - fromTime) / 60000);
// 设置最大迭代次数,防止意外情况(如 endTime 极大)导致内存溢出或死循环
// 即使 endTime 是 10 年后,也限制在约 2 年内(避免极端情况)
// 2年 ≈ 365 * 2 * 24 * 60 = 1,051,200
const MAX_ITERATIONS_LIMIT = 1051200;
const MAX_ITERATIONS = Math.min(timeDiffMinutes, MAX_ITERATIONS_LIMIT);
let iteration = 0;
while (iteration++ < MAX_ITERATIONS) {
// 先推进到下一分钟,避免死循环在同一分钟
current.setMinutes(current.getMinutes() + 1);
current.setSeconds(0);
current.setMilliseconds(0);
const m = current.getMinutes();
const h = current.getHours();
const d = current.getDate();
const mon = current.getMonth() + 1; // JS 月份 0~11
const dow = current.getDay(); // 0=周日, 1=周一, ..., 6=周六
// 核心匹配条件(日期和星期是 OR 关系)
const minuteMatch = minutes.has(m) || minutes.size === 0;
const hourMatch = hours.has(h) || hours.size === 0;
const monthMatch = months.has(mon) || months.size === 0;
const dayMatch = days.has(d) || days.size === 0;
const dowMatch = dows.has(dow) || dows.size === 0;
const dateOrDowMatch = (days.size === 0 && dows.size === 0) || // 两者都是 *
(days.size > 0 && dows.size === 0) || // 只指定了日期
(days.size === 0 && dows.size > 0) || // 只指定了星期
(dayMatch && dowMatch); // 两者都满足才算(最严格)
if (minuteMatch && hourMatch && monthMatch && dateOrDowMatch) {
return current.getTime();
}
}
// 如果是因为超过 MAX_ITERATIONS_LIMIT 而退出,说明时间跨度太大
if (timeDiffMinutes > MAX_ITERATIONS_LIMIT) {
log.warn("查找范围过大,已达到最大迭代次数限制");
} else {
log.warn("未找到合理下一次执行时间");
}
return null;
}
/**
* 解析单个 cron 字段,返回匹配的数值 Set
* 支持: * , - / 数值列表 * /n
*/
function parseField(field, min, max) {
const result = new Set();
if (field === '*' || field === '?') {
return result; // 空 set 代表任意
}
const parts = field.split(',');
for (let part of parts) {
part = part.trim();
// */n
if (part.startsWith('*/')) {
const step = Number(part.slice(2));
if (!isNaN(step) && step > 0) {
for (let i = min; i <= max; i += step) {
result.add(i);
}
}
continue;
}
// n-n
if (part.includes('-') && !part.includes('/')) {
const [start, end] = part.split('-').map(Number);
if (!isNaN(start) && !isNaN(end)) {
for (let i = Math.max(min, start); i <= Math.min(max, end); i++) {
result.add(i);
}
}
continue;
}
// n/n 或 */n 已处理,剩下是普通数字或范围/步进
if (part.includes('/')) {
const [range, stepStr] = part.split('/');
const step = Number(stepStr);
if (isNaN(step) || step <= 0) continue;
if (range === '*') {
for (let i = min; i <= max; i += step) result.add(i);
} else {
const [start, end] = range.split('-').map(Number);
if (!isNaN(start) && !isNaN(end)) {
for (let i = Math.max(min, start); i <= Math.min(max, end); i += step) {
result.add(i);
}
}
}
continue;
}
// 单个数字
const num = Number(part);
if (!isNaN(num) && num >= min && num <= max) {
result.add(num);
}
}
return result;
}
function a(){
return true
}
this.cronUtil = {
getNextCronTimestamp,
parseCron,
parseField,
parseCronField,
}