js: CD-Aware-AutoGather: 队伍中没有对应元素角色时自动切换采集队伍 (#1166)

* js: CD-Aware-AutoGather: 队伍中没有对应元素角色时自动切换采集队伍

其他细节优化:
- 修复路径中有空格时匹配不到刷新机制的问题(`1. 高成功率路线`)
- 扫描材料时统计角色需求
- 将辅助功能抽取为库,`main.js`只保留核心逻辑

* js: CD-Aware-AutoGather: 增加全选选项,便于直接采用全部路径订阅的路线
This commit is contained in:
Patrick-Ze
2025-06-22 23:25:25 +08:00
committed by GitHub
parent 63807ccfdc
commit 4fe2512d7c
7 changed files with 878 additions and 385 deletions

View File

@@ -0,0 +1,596 @@
/**
* @author Ayaka-Main
* @link https://github.com/Patrick-Ze
* @description 提供一些通用性的功能函数。使用方法: 将此文件放在脚本目录下的 lib 文件夹中,然后在你的脚本开头处执行下面这行:
eval(file.readTextSync("lib/lib.js"));
*/
let scriptContext = {
scriptStartTime: new Date(),
version: "1.0"
};
/**
* 将 Date 对象格式化为 ISO 8601 字符串包含本地时区2020-09-28T20:20:20.999+08:00
* @param {Date} date - 要格式化的日期对象
* @returns {string} 格式化后的字符串
*/
function formatDateTime(date) {
const pad = (n) => n.toString().padStart(2, "0");
const padMs = (n) => n.toString().padStart(3, "0");
const year = date.getFullYear();
const month = pad(date.getMonth() + 1);
const day = pad(date.getDate());
const hour = pad(date.getHours());
const minute = pad(date.getMinutes());
const second = pad(date.getSeconds());
const ms = padMs(date.getMilliseconds());
// 获取时区偏移分钟转换成±HH:MM
const offset = -date.getTimezoneOffset();
const sign = offset >= 0 ? "+" : "-";
const offsetHour = pad(Math.floor(Math.abs(offset) / 60));
const offsetMin = pad(Math.abs(offset) % 60);
return `${year}-${month}-${day}T${hour}:${minute}:${second}.${ms}${sign}${offsetHour}:${offsetMin}`;
}
/**
* 将 Date 对象以本地时区格式化为字符串,格式为 "MM-DD HH:mm:ss"
* @param {Date} date - 要格式化的日期对象
* @returns {string} 格式化后的字符串
*/
function formatDateTimeShort(date) {
const pad = (n) => n.toString().padStart(2, "0");
const month = pad(date.getMonth() + 1);
const day = pad(date.getDate());
const hour = pad(date.getHours());
const minute = pad(date.getMinutes());
const second = pad(date.getSeconds());
return `${month}-${day} ${hour}:${minute}:${second}`;
}
/**
* 判断当前时间是否已达到目标时间(目标时间基于脚本启动时间,支持跨天)。
* @param {string} targetTimeStr - 目标时间,格式为 "HH:mm"。
* @returns {boolean} 如果已达到目标时间,返回 true否则返回 false。
*/
function isTargetTimeReached(targetTimeStr) {
const now = new Date();
const [targetHour, targetMinute] = targetTimeStr.split(":").map(Number);
const target = new Date(scriptContext.scriptStartTime);
target.setHours(targetHour, targetMinute, 0, 0);
// 如果目标时间早于脚本启动时间,则认为是第二天
if (target <= scriptContext.scriptStartTime) {
target.setDate(target.getDate() + 1);
}
return now >= target;
}
/**
* 判断当前时间是否在给定时间范围内(支持跨天)。
* @param {*} startStr 起始时间,格式为"HH:mm"
* @param {*} endStr 结束时间,格式为"HH:mm"
* @returns {boolean} 如果当前时间在范围内,返回 true否则返回 false。
*/
function isNowInTimeRange(startStr, endStr) {
const now = new Date();
const [startHour, startMinute] = startStr.split(":").map(Number);
const [endHour, endMinute] = endStr.split(":").map(Number);
const start = new Date(now);
start.setHours(startHour, startMinute, 0, 0);
const end = new Date(now);
end.setHours(endHour, endMinute, 0, 0);
// 如果结束时间早于开始时间,表示跨天
if (end <= start) {
end.setDate(end.getDate() + 1);
}
return now >= start && now <= end;
}
/**
* 根据上期刷新时间字符串和刷新模式计算下一次的刷新时间。
*
* @param {string} lastRefreshTimeStr 上次刷新时间。如果为空或无效,将使用 getDefaultTime()。
* @param {string} refreshMode 刷新模式,例如 "每X周", "每X天Y点", "每24:05" (表示每24小时零5分), "X小时"
* @returns {Date | null} 计算出的下一次刷新时间Date对象如果模式无法解析则返回null。
* @example 已进行过的测试用例(用例中 GetDefaultTime() 返回 1970-01-01T00:00:00.000+08:00)
* calculateNextRefreshTime("2025-06-01T10:00:00.000+08:00", "每1周"); // 2025-06-02T04:00:00.000+08:00
* calculateNextRefreshTime("2025-06-02T03:00:00.000+08:00", "每1周"); // 2025-06-02T04:00:00.000+08:00
* calculateNextRefreshTime("2025-06-02T05:00:00.000+08:00", "每1周"); // 2025-06-09T04:00:00.000+08:00
* calculateNextRefreshTime(null, "每周"); // 1970-01-05T04:00:00.000+08:00
* calculateNextRefreshTime("2025-06-02T03:00:00.000+08:00", "每2周"); // 2025-06-09T04:00:00.000+08:00
* calculateNextRefreshTime("2025-06-20T22:00:00.000+08:00", "每天8点"); // 2025-06-21T08:00:00.000+08:00
* calculateNextRefreshTime("2025-06-21T07:00:00.000+08:00", "每天08点"); // 2025-06-21T08:00:00.000+08:00
* calculateNextRefreshTime("2025-06-21T09:00:00.000+08:00", "每天08点"); // 2025-06-22T08:00:00.000+08:00
* calculateNextRefreshTime(null, "每天12点"); // 1970-01-01T12:00:00.000+08:00
* calculateNextRefreshTime("2025-06-20T10:00:00.000+08:00", "每2天10点"); // 2025-06-22T10:00:00.000+08:00
* calculateNextRefreshTime("2025-06-20T10:00:00.000+08:00", "每3天0点"); // 2025-06-23T00:00:00.000+08:00
* calculateNextRefreshTime("2025-06-21T11:00:00.000+08:00", "00:30"); // 2025-06-21T11:30:00.000+08:00
* calculateNextRefreshTime("2025-06-21T23:00:00.000+08:00", "02:00"); // 2025-06-22T01:00:00.000+08:00
* calculateNextRefreshTime("2025-06-20T04:00:00.000+08:00", "每24:05"); // 2025-06-21T04:05:00.000+08:00
* calculateNextRefreshTime(null, "01:00"); // 1970-01-01T01:00:00.000+08:00
* calculateNextRefreshTime("2025-06-21T10:00:00.000+08:00", "2小时"); // 2025-06-21T12:00:00.000+08:00
* calculateNextRefreshTime("2025-06-21T23:00:00.000+08:00", "3小时"); // 2025-06-22T02:00:00.000+08:00
* calculateNextRefreshTime(null, "5小时"); // 1970-01-01T05:00:00.000+08:00
* calculateNextRefreshTime("2025-06-20T10:00:00.000+08:00", "每1周 每天10点"); // 2025-06-23T04:00:00.000+08:00
* calculateNextRefreshTime("2025-06-20T10:00:00.000+08:00", "每天10点 02:00 2小时"); // 2025-06-21T10:00:00.000+08:00
* calculateNextRefreshTime("2025-06-20T10:00:00.000+08:00", "00:30 2小时"); // 2025-06-20T10:30:00.000+08:00
* calculateNextRefreshTime("2025-06-21T10:00:00.000+08:00", "无效模式"); // null
*/
function calculateNextRefreshTime(lastRefreshTimeStr, refreshMode) {
let lastRunTime = lastRefreshTimeStr ? new Date(lastRefreshTimeStr) : getDefaultTime();
let nextRunTime = null;
const lowerCaseRefreshMode = refreshMode.toLowerCase();
// 1. 匹配 "每(\d*)周"
let match = lowerCaseRefreshMode.match(/每(\d*)周/);
if (match) {
const weeks = parseInt(match[1] || "1", 10); // 如果没有数字默认为1周
nextRunTime = new Date(lastRunTime);
// 找到 lastRunTime 所在周的周一 04:00
nextRunTime.setDate(lastRunTime.getDate() - ((lastRunTime.getDay() + 6) % 7)); // 调整到上一个或当前周一
nextRunTime.setHours(4, 0, 0, 0); // 固定到周一 04:00
// 确保 nextRunTime 至少晚于 lastRunTime。
// 如果 lastRunTime 是周一 05:00而计算出的是周一 04:00则需要推到下个周期。
while (nextRunTime <= lastRunTime) {
nextRunTime.setDate(nextRunTime.getDate() + 7);
}
if (weeks > 1) {
// 如果是多周周期,直接加上 weeks 周
nextRunTime.setDate(nextRunTime.getDate() + 7 * (weeks - 1));
}
}
// 2. 匹配 "每(\d*)天(\d{1,2})点"
if (!nextRunTime) {
match = lowerCaseRefreshMode.match(/每(\d*)天(\d{1,2})点/);
if (match) {
const days = parseInt(match[1] || "1", 10); // 如果没有数字默认为1天
const hours = parseInt(match[2], 10);
nextRunTime = new Date(lastRunTime);
nextRunTime.setHours(hours, 0, 0, 0); // 设置固定小时和分钟
// 确保 nextRunTime 至少晚于 lastRunTime。
while (nextRunTime <= lastRunTime) {
nextRunTime.setDate(nextRunTime.getDate() + days);
}
}
}
// 3. 匹配 "每(\d\d):(\d\d)" (作为间隔)
if (!nextRunTime) {
match = lowerCaseRefreshMode.match(/(\d{1,2}):(\d{2})/);
if (match) {
const intervalHours = parseInt(match[1], 10);
const intervalMinutes = parseInt(match[2], 10);
const intervalMs = (intervalHours * 60 + intervalMinutes) * 60 * 1000;
if (intervalMs > 0) {
// 确保间隔有效
nextRunTime = new Date(lastRunTime.getTime() + intervalMs);
}
}
}
// 4. 匹配 "(\d+)小时"
if (!nextRunTime) {
match = lowerCaseRefreshMode.match(/(\d+)小时/);
if (match) {
const intervalHours = parseInt(match[1], 10);
const intervalMs = intervalHours * 60 * 60 * 1000;
if (intervalMs > 0) {
// 确保间隔有效
nextRunTime = new Date(lastRunTime.getTime() + intervalMs);
}
}
}
return nextRunTime;
}
/**
* 判断任务是否达到刷新时间
*
* @param {string} refreshMode 刷新模式,例如 "每X周", "每X天Y点", "X小时", "每24:05" (表示每24小时零5分)
* @param {string} taskName 任务名称或采集资源名称
* @param {string} [accountName] 账户名称,可选
* @returns {{isRefreshed: boolean, lastRunTime: Date | null, nextRunTime: Date | null}}
* 返回一个对象,包含:
* - isRefreshed: boolean - 任务是否达到刷新时间。
* - lastRunTime: Date | null - 任务上次运行的时间如果未找到则是getDefaultTime()返回的远古时间)。
* - nextRunTime: Date | null - 计算出的下一次刷新时间。
*/
function isTaskRefreshed(refreshMode, taskName, accountName = null) {
let record = {};
const recordPath = `record/${accountName || "默认账号"}.json`;
try {
const content = file.readTextSync(recordPath);
record = JSON.parse(content);
} catch (e) {
log.debug(`无法读取或解析记录文件 ${recordPath},错误: ${e.message}`);
}
taskName = taskName || "默认任务";
const lastRunTimeStr = record[taskName];
const currentTime = new Date();
const nextRunTime = calculateNextRefreshTime(lastRunTimeStr, refreshMode);
let isRefreshed = false;
if (!nextRunTime) {
log.error(`无法解析刷新模式 "{0}",请检查格式`, refreshMode);
} else {
isRefreshed = currentTime >= nextRunTime;
}
const lastRunTime = lastRunTimeStr ? new Date(lastRunTimeStr) : getDefaultTime();
return {
isRefreshed: isRefreshed,
lastRunTime: lastRunTime, // 返回实际的 Date 对象
nextRunTime: nextRunTime,
};
}
/**
* 判断任务或资源是否仍然未刷新(对`isTaskRefreshed`的易用封装)
*
* @param {string} refreshMode 刷新模式,例如 "每X周", "每X天Y点", "X小时", "每24:05" (表示每24小时零5分)
* @param {string} taskName 任务名称或采集资源名称,可选
* @param {string} [accountName] 账户名称,可选
* @example
* // 运行结束时调用
* updateTaskRunTime();
* // 在脚本开头检查是否已刷新
* if (taskIsNotRefresh("每天4点")) {
* return;
* }
*/
function taskIsNotRefresh(refreshMode, taskName = null, accountName = null) {
const { isRefreshed, lastRunTime, nextRunTime } = isTaskRefreshed(refreshMode, taskName, accountName);
taskName = taskName || "默认任务";
if (!isRefreshed) {
log.info("{0}未刷新(上次运行: {1}), 刷新时间: {2}", taskName, formatDateTimeShort(lastRunTime), formatDateTimeShort(nextRunTime));
}
return !isRefreshed;
}
/**
* 更新指定任务的上次运行时间为当前时间。
*
* @param {string} taskName 任务名称。
* @param {string} [accountName=null] 账户名称可选默认为null表示使用默认账户。
* @returns {boolean} 如果成功更新了任务的上次运行时间则返回true否则返回false。
*/
function updateTaskRunTime(taskName = null, accountName = null) {
let record = {};
taskName = taskName || "默认任务";
const recordPath = `record/${accountName || "默认账号"}.json`;
// 1. 读取记录文件
try {
const content = file.readTextSync(recordPath);
record = JSON.parse(content);
} catch (e) {
log.debug(`未能读取或解析记录文件 ${recordPath},将创建新记录。错误: ${e.message}`);
}
// 2. 更新指定任务的上次运行时间
const currentTime = new Date();
record[taskName] = formatDateTime(currentTime); // 格式化为本地时间字符串,便于人阅读
// 3. 将更新后的记录写回文件
try {
file.writeTextSync(recordPath, JSON.stringify(record, null, 2));
return true;
} catch (e) {
log.error(`写入文件 ${recordPath} 失败: ${e.message}`);
return false;
}
}
/**
* 尝试切换队伍,如果失败则传送到七天神像后重试。
* @param {string} partyName - 要切换的队伍名
* @returns {Promise<void>}
*/
async function switchPartySafely(partyName) {
if (!partyName) return;
try {
if (!(await genshin.switchParty(partyName))) {
log.info("切换队伍失败,前往七天神像重试");
await genshin.tpToStatueOfTheSeven();
await genshin.returnMainUi(); // 确保传送完成
await genshin.switchParty(partyName);
await genshin.returnMainUi();
}
} catch {
log.error("队伍切换失败,可能处于联机模式或其他不可切换状态");
await genshin.returnMainUi();
}
}
/**
* 获取账号名(通常用于区分不同账号的数据)
*
* @async
* @param {*} multiAccount 是否使用OCR区分多个账号可以传入一个设置项
* @returns {Promise<string>} 当前账号的UID如果不区分多账号或OCR失败则返回"默认账号"。
*/
async function getGameAccount(multiAccount = false) {
let account = "默认账号";
if (!multiAccount) {
return account;
}
// 打开背包避免界面背景干扰
// await genshin.returnMainUi();
// keyPress("B");
// await sleep(1000);
const region = captureGameRegion();
const ocrResults = RecognitionObject.ocr(region.width * 0.75, region.height * 0.75, region.width * 0.25, region.height * 0.25);
const resList = region.findMulti(ocrResults);
for (let i = 0; i < resList.count; i++) {
const text = resList[i].text;
if (text.includes("UID")) {
const match = text.match(/\d+/);
if (match) {
account = match[0];
}
break;
}
}
if (account === "默认账号") {
log.error("未能提取到UID");
}
return account;
}
/**
* 获取脚本所在文件夹路径
* @returns {string|null} 脚本所在文件夹路径,若未获取到则返回 null
*/
function getScriptDirPath() {
try {
file.readTextSync(`Ayaka-Main-${Math.random()}.txt`);
} catch (error) {
const err_msg = error.toString();
const match = err_msg.match(/'([^']+)'/);
const fullPath = match ? match[1] : null;
const folderPath = fullPath ? fullPath.replace(/\\[^\\]+$/, "") : null;
return folderPath;
}
return null;
}
/**
* 从 manifest.json 获取脚本自身名称
* @returns {string} 脚本名称
*/
function getScriptName() {
const content = file.readTextSync("manifest.json");
const manifest = JSON.parse(content);
return manifest.name;
}
/**
* 从文件路径中提取文件名。
* @param {string} filePath - 文件路径。
* @returns {string} - 文件名。
*/
function basename(filePath) {
const lastSlashIndex = filePath.lastIndexOf('\\'); // 或者使用 '/'
return filePath.substring(lastSlashIndex + 1);
}
/**
* 将路径分割为目录和文件名
* @param {string} path - 文件完整路径
* @returns {[string, string]} 返回数组,第一个元素是目录路径,第二个是文件名
* @example
* const [dir, file] = splitPath('稻妻\\绯樱绣球\\06-绯樱绣球-神里屋敷-10个.json'); // ['稻妻\\绯樱绣球', '06-绯樱绣球-神里屋敷-10个.json']
*/
function splitPath(path) {
const normalizedPath = path.replace(/\\/g, "/");
const lastSlashIndex = normalizedPath.lastIndexOf("/");
if (lastSlashIndex === -1) {
return ["", path];
}
const dir = path.slice(0, lastSlashIndex);
const file = path.slice(lastSlashIndex + 1);
return [dir, file];
}
/**
* 将路径分割为主名和扩展名
* @param {string} filename - 文件名或路径中的文件部分
* @returns {[string, string]} 返回数组,第一个是主文件名,第二个是扩展名(含点)
* @example
* const [dir, file] = splitPath('稻妻\\绯樱绣球\\06-绯樱绣球-神里屋敷-10个.json'); // ['稻妻\\绯樱绣球\\06-绯樱绣球-神里屋敷-10个', '.json']
*/
function splitExt(filename) {
const baseName = filename.includes("/") ? filename.slice(filename.lastIndexOf("/") + 1) : filename;
const lastDotIndex = baseName.lastIndexOf(".");
if (lastDotIndex <= 0) {
return [filename, ""];
}
return [
filename.slice(0, filename.length - (baseName.length - lastDotIndex)),
filename.slice(filename.length - (baseName.length - lastDotIndex)),
];
}
/**
* 如果你需要一个很久以前的时间,作为默认时间
* @returns {Date} 默认时间的Date对象
*/
function getDefaultTime() {
const now = new Date();
const year = now.getFullYear() - 18;
return new Date(year, 8, 28, 0, 0, 0); // 9月是month=80起始
}
/**
* 获取指定目录下所有指定后缀的文件列表(不含子文件夹)
* @param {string} taskDir - 目标目录路径
* @param {string} [ext=".json"] - 文件后缀名(默认.json
* @returns {string[]} 返回符合后缀的文件路径数组
*/
function getFilesByExtension(taskDir, ext = ".json") {
const allFilesRaw = file.ReadPathSync(taskDir);
const extFiles = [];
for (const filePath of allFilesRaw) {
if (filePath.endsWith(ext)) {
extFiles.push(filePath);
}
}
return extFiles;
}
/**
* 获取指定路径下所有最底层的文件夹(即不包含任何子文件夹的文件夹)
* @param {string} folderPath - 要遍历的根文件夹路径
* @param {string[]} result - 用于收集最底层文件夹路径的数组
* @returns {Promise<string[]>} 所有最底层文件夹的路径
*/
function getLeafFolders(folderPath, result = []) {
const filesInSubFolder = file.ReadPathSync(folderPath);
let hasSubFolder = false;
for (const filePath of filesInSubFolder) {
if (file.IsFolder(filePath)) {
hasSubFolder = true;
// 递归查找子文件夹
getLeafFolders(filePath, result);
}
}
// 如果没有发现任何子文件夹,则当前为最底层文件夹
if (!hasSubFolder) {
result.push(folderPath);
}
return result;
}
// 参考了 mno 大佬的函数
function _fakeLogCore(name, isJs = true, dateIn = null) {
const isStart = isJs === (dateIn !== null);
const lastRun = isJs ? new Date() : dateIn;
const task = isJs ? "JS脚本" : "地图追踪任务";
let logMessage = "";
let logTime = new Date();
if (isJs && isStart) {
logTime = dateIn;
}
// 时间部分从第11位开始长度是12"20:20:20.999"
const formattedTime = formatDateTime(logTime).slice(11, 23);
if (isStart) {
logMessage =
`正在伪造开始的日志记录\n\n` +
`[${formattedTime}] [INF] BetterGenshinImpact.Service.ScriptService\n` +
`------------------------------\n\n` +
`[${formattedTime}] [INF] BetterGenshinImpact.Service.ScriptService\n` +
`→ 开始执行${task}: "${name}"`;
} else {
const durationInSeconds = (logTime.getTime() - lastRun.getTime()) / 1000;
const durationMinutes = Math.floor(durationInSeconds / 60);
const durationSeconds = (durationInSeconds % 60).toFixed(3); // 保留三位小数
logMessage =
`正在伪造结束的日志记录\n\n` +
`[${formattedTime}] [INF] BetterGenshinImpact.Service.ScriptService\n` +
`→ 脚本执行结束: "${name}", 耗时: ${durationMinutes}${durationSeconds}\n\n` +
`[${formattedTime}] [INF] BetterGenshinImpact.Service.ScriptService\n` +
`------------------------------`;
}
log.debug(logMessage);
return logTime;
}
/**
* 在日志文件中创建可供BGI解析耗时的路径追踪记录Start和End两个函数需配对使用
* @param {string} name 要写入到日志的事项名例如路径追踪的json文件名
* @returns {Date} 此函数的调用时间的Date对象
* @example
* let pathStart = logFakePathStart(fileName);
* // await pathingScript.runFile(jsonPath);
* logFakePathEnd(fileName, pathStart);
*/
function logFakePathStart(name) {
return _fakeLogCore(name, false);
}
/**
* 在日志文件中创建可供BGI解析耗时的路径追踪记录Start和End两个函数需配对使用
* @param {string} name 要写入到日志的事项名通常传入路径追踪的json文件名
* @param {Date} startTime 调用`logFakePathStart`时返回的Date对象
* @example
* let pathStart = logFakePathStart(fileName);
* // await pathingScript.runFile(jsonPath);
* logFakePathEnd(fileName, pathStart);
*/
function logFakePathEnd(name, startTime) {
return _fakeLogCore(name, false, startTime);
}
/**
* 在日志文件中创建可供BGI解析耗时的脚本运行记录Start和End两个函数需配对使用
* @param {string} scriptName 脚本名,留空时将自动获取
* @returns {Date} 此函数的调用时间的Date对象
* @example
* let startTime = logFakeScriptStart();
* // do something;
* logFakeScriptEnd({ startTime: startTime });
*/
function logFakeScriptStart(scriptName = null) {
if (!scriptName) {
if (!scriptContext.scriptName) {
scriptContext.scriptName = getScriptName();
}
scriptName = scriptContext.scriptName;
}
return _fakeLogCore(scriptName, true);
}
/**
* 在日志文件中创建可供BGI解析耗时的脚本运行记录Start和End两个函数需配对使用
* @param {Object} params
* @param {string|null} [params.scriptName=null] - 脚本名,留空时将自动获取
* @param {Date} [params.startTime=new Date()] - 调用`logFakeScriptStart`时返回的Date对象
* @returns {Date} 此函数的调用时间的Date对象
* @example
* let startTime = logFakeScriptStart();
* // do something;
* logFakeScriptEnd({ startTime: startTime });
*/
function logFakeScriptEnd({ scriptName = null, startTime = new Date() } = {}) {
if (!scriptName) {
if (!scriptContext.scriptName) {
scriptContext.scriptName = getScriptName();
}
scriptName = scriptContext.scriptName;
}
return _fakeLogCore(scriptName, true, startTime);
}