js: CD-Aware-AutoGather: 同步订阅的地图追踪任务,执行采集,并管理刷新时间 (#967)

This commit is contained in:
Patrick-Ze
2025-06-03 00:40:19 +08:00
committed by GitHub
parent f6d668cd82
commit acd8ca4a36
10 changed files with 758 additions and 2 deletions

View File

@@ -0,0 +1,592 @@
const CooldownType = {
Unknown: "未配置刷新机制",
Every1DayMidnight: "每1天的0点",
Every2DaysMidnight: "每2天的0点",
Every3DaysMidnight: "每3天的0点",
Daily4AM: "每天凌晨4点",
Every12Hours: "12小时刷新",
Every24Hours: "24小时刷新",
Every46Hours: "46小时刷新",
};
const CooldownDataBase = {
沉玉仙茗: CooldownType.Every24Hours,
发光髓: CooldownType.Every12Hours,
蝴蝶翅膀: CooldownType.Every12Hours,
晶核: CooldownType.Every12Hours,
鳗肉: CooldownType.Every12Hours,
螃蟹: CooldownType.Every12Hours,
禽肉: CooldownType.Every12Hours,
青蛙: CooldownType.Every12Hours,
鳅鳅宝玉: CooldownType.Every12Hours,
神秘的肉: CooldownType.Every12Hours,
兽肉: CooldownType.Every12Hours,
蜥蜴尾巴: CooldownType.Every12Hours,
鱼肉: CooldownType.Every12Hours,
白萝卜: CooldownType.Every1DayMidnight,
薄荷: CooldownType.Every1DayMidnight,
澄晶实: CooldownType.Every1DayMidnight,
墩墩桃: CooldownType.Every1DayMidnight,
海草: CooldownType.Every1DayMidnight,
红果果菇: CooldownType.Every1DayMidnight,
胡萝卜: CooldownType.Every1DayMidnight,
金鱼草: CooldownType.Every1DayMidnight,
堇瓜: CooldownType.Every1DayMidnight,
烬芯花: CooldownType.Every1DayMidnight,
久雨莲: CooldownType.Every1DayMidnight,
颗粒果: CooldownType.Every1DayMidnight,
苦种: CooldownType.Every1DayMidnight,
莲蓬: CooldownType.Every1DayMidnight,
烈焰花花蕊: CooldownType.Every1DayMidnight,
马尾: CooldownType.Every1DayMidnight,
蘑菇: CooldownType.Every1DayMidnight,
茉洁草: CooldownType.Every1DayMidnight,
鸟蛋: CooldownType.Every1DayMidnight,
泡泡桔: CooldownType.Every1DayMidnight,
苹果: CooldownType.Every1DayMidnight,
日落果: CooldownType.Every1DayMidnight,
树莓: CooldownType.Every1DayMidnight,
松果: CooldownType.Every1DayMidnight,
松茸: CooldownType.Every1DayMidnight,
甜甜花: CooldownType.Every1DayMidnight,
汐藻: CooldownType.Every1DayMidnight,
香辛果: CooldownType.Every1DayMidnight,
星蕈: CooldownType.Every1DayMidnight,
须弥蔷薇: CooldownType.Every1DayMidnight,
枣椰: CooldownType.Every1DayMidnight,
竹笋: CooldownType.Every1DayMidnight,
烛伞蘑菇: CooldownType.Every1DayMidnight,
沉玉仙茗: CooldownType.Every24Hours,
晶蝶: CooldownType.Daily4AM,
铁块: CooldownType.Every1DayMidnight,
白铁块: CooldownType.Every2DaysMidnight,
电气水晶: CooldownType.Every2DaysMidnight,
星银矿石: CooldownType.Every2DaysMidnight,
萃凝晶: CooldownType.Every3DaysMidnight,
水晶块: CooldownType.Every3DaysMidnight,
紫晶块: CooldownType.Every3DaysMidnight,
奇异的龙牙: CooldownType.Every46Hours,
冰雾花: CooldownType.Every46Hours,
烈焰花: CooldownType.Every46Hours,
地方特产: CooldownType.Every46Hours,
};
const settingFile = "settings.json";
const baseTime = getBaseTime();
const baseTimeStr = baseTime.toISOString();
const timeOffset = Date.parse(baseTimeStr) - Date.parse(baseTimeStr.slice(0, -1)); // 计算时区偏移量
const timeOffsetStr = offsetToTimezone(timeOffset);
let stopTime = null;
class ReachStopTime extends Error {
constructor(message) {
super(message);
this.name = "ReachStopTime";
}
}
(async function () {
if (! file.IsFolder("pathing")) {
let batFile = "SymLink.bat";
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;
if (folderPath) {
batFile = `${folderPath}\\${batFile}`;
}
}
log.error("{0}文件夹不存在,请双击运行下列位置的脚本以创建文件夹链接\n{1}", "pathing", batFile);
return;
}
const runMode = settings.runMode;
const scriptName = getScriptItselfName();
// 结束真正由BGI产生的那次开始记录
startTime = fakeLogCore(scriptName, true);
log.info("当前运行模式:{0}", runMode);
if (runMode === "扫描文件夹更新可选材料列表") {
await runScanMode();
} else if (runMode === "采集选中的材料") {
await runGatherMode();
} else if (runMode === "清除运行记录(重置材料刷新时间)") {
await runClearMode();
} else {
log.warn("未选择运行模式或运行模式无效: {0}\n这可能是你的首次运行将为你执行{1}模式", runMode, "扫描文件夹更新可选材料列表");
await sleep(3000);
await runScanMode();
}
// 重新开始一条记录与BGI产生的结束记录配对
fakeLogCore(scriptName, true, startTime);
})();
// 扫描文件夹更新可选材料列表
async function runScanMode() {
// 1. 扫描所有最底层路径
const focusFolders = ["地方特产", "矿物", "食材与炼金"];
const pathList = focusFolders.flatMap(fd => getLeafFolders(`pathing/${fd}`));
// 2. 读取配置模板
const templateText = file.readTextSync("settings.template.json");
let config = JSON.parse(templateText);
// 3. 处理每个路径
let count = 0;
for (const path of pathList) {
const info = getCooldownInfoFromPath(path);
if (info.coolType !== CooldownType.Unknown) {
config.push({
name: info.name,
label: "⬇️ " + info.label,
type: "checkbox"
});
count += 1;
} else {
log.warn("路径{0}未找到对应的刷新机制,跳过", path);
}
}
// 4. 写入新的配置(格式化输出)
file.writeTextSync(settingFile, JSON.stringify(config, null, 2));
log.info("共{0}组有效路线,请在脚本配置中勾选需要采集的材料", count);
}
// 采集选中的材料
async function runGatherMode() {
const selectedMaterials = getSelectedMaterials();
if (selectedMaterials.length === 0) {
log.error("未选择任何材料,请在脚本配置中勾选所需项目");
return;
}
if (settings.stopAtTime) {
stopTime = calcStopTime(settings.stopAtTime);
log.info("脚本已被配置为达到{0}后停止运行", strftime(stopTime, true));
}
log.info("共{0}组材料路线待执行:", selectedMaterials.length);
for (const item of selectedMaterials) {
const info = getCooldownInfoFromPath(item.label);
log.info(` - {0} (${info.coolType})`, item.label || item.name);
}
let account = await getCurrentAccount();
log.info("为{0}采集材料并管理CD", account);
if (settings.partyName) {
try {
if (!(await genshin.switchParty(settings.partyName))) {
log.info("切换队伍失败,前往七天神像重试");
await genshin.tpToStatueOfTheSeven();
await sleep(1000);
await genshin.switchParty(settings.partyName);
}
} catch {
log.error("队伍切换失败,可能处于联机模式或其他不可切换状态");
await genshin.returnMainUi();
}
}
dispatcher.addTimer(new RealtimeTimer("AutoPick"));
// 可在此处继续处理 selectedMaterials 列表
try {
for (const pathTask of selectedMaterials) {
await runPathTaskIfCooldownExpired(account, pathTask);
}
} catch (e) {
if (e instanceof ReachStopTime) {
log.info("达到设置的停止时间 {0},终止运行", strftime(stopTime, true));
} else {
throw e;
}
}
}
// 清除运行记录(重置材料刷新时间)
async function runClearMode() {
const selectedMaterials = getSelectedMaterials();
if (selectedMaterials.length === 0) {
log.error("未选择任何材料,请在脚本配置中勾选所需项目");
}
const resetTime = strftime(baseTime);
let account = await getCurrentAccount();
for (const pathTask of selectedMaterials) {
const jsonFiles = filterFilesInTaskDir(pathTask);
const recordFile = getRecordFilePath(account, pathTask);
const lines = jsonFiles.map((filePath) => {
return `${basename(filePath)}\t${resetTime}`;
});
const content = lines.join("\n");
file.writeTextSync(recordFile, content);
log.info("已重置{0}的刷新时间", pathTask.label);
}
log.info("已重置{0}组刷新时间。如需重置所有材料刷新时间请直接删除record目录下对应账号的文件夹", selectedMaterials.length);
}
function getRecordFilePath(account, pathTask) {
const taskName = pathTask.name.replace(/^OPT_/, "");
return `record/${account}/${taskName}.txt`;
}
function filterFilesInTaskDir(pathTask, ext=".json") {
const taskDir = pathTask.label;
const allFilesRaw = file.ReadPathSync("pathing\\" + taskDir);
const extFiles = [];
for (const filePath of allFilesRaw) {
if (filePath.endsWith(ext)) {
extFiles.push(filePath);
}
}
return extFiles;
}
async function runPathTaskIfCooldownExpired(account, pathTask) {
const recordFile = getRecordFilePath(account, pathTask);
const jsonFiles = filterFilesInTaskDir(pathTask);
log.info("{0}共有{1}条路线", pathTask.label, jsonFiles.length);
// 2. 读取记录文件(路径 -> 时间)
const recordMap = {};
try {
const text = file.readTextSync(recordFile);
for (const line of text.split("\n")) {
const [p, t] = line.trim().split("\t");
if (p && t) {
recordMap[p] = new Date(t);
}
}
} catch (error) {
log.debug(`记录文件{0}不存在或格式错误`, recordFile);
}
// 3. 检查哪些 json 文件已过刷新时间
for (let i = 0; i < jsonFiles.length; i++) {
const jsonPath = jsonFiles[i];
const fileName = basename(jsonPath);
const lastTime = recordMap[fileName] || baseTime;
const pathName = fileName.split(".")[0];
const progress = `[${i + 1}/${jsonFiles.length}]`;
if (stopTime && Date.now() >= stopTime) {
throw new ReachStopTime("达到设置的停止时间,终止运行");
}
if (Date.now() > lastTime) {
let pathStart = addFakePathLog(fileName);
log.info(`${progress}{0}: 开始执行`, pathName);
let pathStartTime = new Date();
try {
await pathingScript.runFile(jsonPath);
} catch (error) {
log.error(`${progress}{0}: 文件不存在或执行失败: {1}`, pathName, error.toString());
addFakePathLog(fileName, pathStart);
continue; // 跳过当前任务
}
// 更新记录
if (new Date() - pathStartTime > 5000) {
recordMap[fileName] = calculateNextRunTime(new Date(), jsonPath);
const lines = [];
for (const [p, t] of Object.entries(recordMap)) {
lines.push(`${p}\t${strftime(t)}`);
}
const content = lines.join("\n");
file.writeTextSync(recordFile, content);
log.info(`${progress}{0}: 已完成,下次刷新: ${strftime(recordMap[fileName], true)}`, pathName);
} else {
log.info(`${progress}{0}: 执行时间过短,不更新记录`, pathName);
}
addFakePathLog(fileName, pathStart);
} else {
log.info(`${progress}{0}: 已跳过 (${strftime(recordMap[fileName], true)}刷新)`, pathName);
}
}
}
/**
* 根据路径逐级查找最匹配的物品,返回去除前缀的路径、标准化名称、刷新时间
* @param {string} fullPath - 单个完整路径(包含公共前缀)
* @returns {{ label: string, name: string, coolType: string }}
*/
function getCooldownInfoFromPath(fullPath) {
const parts = fullPath.split(/[\\/]/); // 支持 \ 或 / 分隔符
let cooldown = CooldownType.Unknown;
let cleanPart = "";
for (const part of parts) {
cleanPart = part.split("@")[0]; // 去除 @ 后缀
if (CooldownDataBase.hasOwnProperty(cleanPart)) {
cooldown = CooldownDataBase[cleanPart];
break;
}
}
const label = parts.slice(1).join("\\"); // 去除公共前缀
const name = "OPT_" + label.replace(/[^\u4e00-\u9fa5\w]/g, "_"); // 添加前缀并格式化名称
return {
label,
name,
coolType: cooldown,
};
}
function calculateNextRunTime(base, fullPath) {
const {coolType} = getCooldownInfoFromPath(fullPath);
let nextTime = baseTime;
switch (coolType) {
case CooldownType.Every1DayMidnight: {
const next = new Date(base.getTime() + 1 * 24 * 60 * 60 * 1000);
next.setHours(0, 0, 0, 0);
nextTime = next;
break;
}
case CooldownType.Every2DaysMidnight: {
const next = new Date(base.getTime() + 2 * 24 * 60 * 60 * 1000);
next.setHours(0, 0, 0, 0);
nextTime = next;
break;
}
case CooldownType.Every3DaysMidnight: {
const next = new Date(base.getTime() + 3 * 24 * 60 * 60 * 1000);
next.setHours(0, 0, 0, 0);
nextTime = next;
break;
}
case CooldownType.Daily4AM: {
const next = new Date(base);
next.setHours(4, 0, 0, 0);
if (base.getHours() >= 4) {
// 如果已过今天凌晨4点则设为明天的4点
next.setDate(next.getDate() + 1);
}
nextTime = next;
break;
}
case CooldownType.Every12Hours: {
nextTime = new Date(base.getTime() + 12 * 60 * 60 * 1000);
break;
}
case CooldownType.Every24Hours: {
nextTime = new Date(base.getTime() + 24 * 60 * 60 * 1000);
break;
}
case CooldownType.Every46Hours: {
nextTime = new Date(base.getTime() + 46 * 60 * 60 * 1000);
break;
}
default:
throw new Error(`未识别的冷却类型: ${coolType}`);
}
return nextTime;
}
/**
* 获取指定路径下所有最底层的文件夹(即不包含任何子文件夹的文件夹)
* @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;
}
async function getCurrentAccount() {
let account = "默认账号";
if (settings.iHaveMultipleAccounts) {
// 打开背包避免界面背景干扰
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;
}
function getSelectedMaterials() {
const configText = file.readTextSync(settingFile);
const config = JSON.parse(configText); // 配置数组
const selectedMaterials = [];
for (const entry of config) {
if (
entry.name &&
entry.name.startsWith("OPT_") &&
entry.type === "checkbox"
) {
if (settings[entry.name] === true) {
entry.label = entry.label.split(" ")[1]; // 去除⬇️指示
selectedMaterials.push(entry);
}
}
}
return selectedMaterials;
}
// Happy Birthday
function getBaseTime() {
const now = new Date();
const year = now.getFullYear() - 18;
return new Date(year, 8, 28, 0, 0, 0); // 9月是month=80起始
}
function strftime(dateObj, shortFormat = false) {
const timestamp = dateObj.getTime() + timeOffset;
const newDate = new Date(timestamp);
let s = newDate.toISOString();
s = s.replace("Z", timeOffsetStr);
if (shortFormat) {
// 截取出 MM-DD HH:MM:SS
const [datePart, timePart] = s.split("T");
const [year, month, day] = datePart.split("-");
const time = timePart.split(".")[0]; // 去掉毫秒部分
s = `${month}-${day} ${time}`;
}
return s;
}
function basename(filePath) {
const lastSlashIndex = filePath.lastIndexOf('\\');
return filePath.substring(lastSlashIndex + 1);
}
function offsetToTimezone(offsetMs) {
const totalMinutes = offsetMs / (1000 * 60);
const sign = totalMinutes >= 0 ? "+" : "-";
const absMinutes = Math.abs(totalMinutes);
const hours = String(Math.floor(absMinutes / 60)).padStart(2, "0");
const minutes = String(absMinutes % 60).padStart(2, "0");
return `${sign}${hours}:${minutes}`;
}
function calcStopTime(timeStr) {
const match = timeStr.match(/\b\d{2}[:]\d{2}\b/); // 匹配 HH:mm
if (!match) {
return new Date(0xFFFFFFFF * 1000); // 不停止
}
const [hour, minute] = match[0].split(":").map(Number);
const now = new Date();
const next = new Date(now);
next.setHours(hour, minute, 0, 0);
if (next <= now) {
next.setDate(next.getDate() + 1);
}
return next;
}
function getScriptItselfName() {
const content = file.readTextSync("manifest.json");
const manifest = JSON.parse(content);
return manifest.name;
}
// 参考了 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;
}
const logTimeWithOffset = new Date(logTime.getTime() + timeOffset);
const formattedTime = logTimeWithOffset.toISOString().split("T")[1].replace("Z", "");
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;
}
function addFakePathLog(name, lastRun = null) {
return fakeLogCore(name, false, lastRun);
}