refactor(auto-exchange): 优化抽卡资源兑换脚本 (#3053)

* refactor(auto-exchange): 优化抽卡资源兑换脚本的变量初始化

- 移除不必要的注释内容,保持代码简洁性
- 确保 contentList 变量正确初始化为空数组
- 提升代码可读性和维护性

refactor(auto-exchange): 优化抽卡资源自动兑换的时间记录逻辑

- 将存储结构从 Map 改为数组对象列表,每个对象包含 uid 和时间戳
- 使用 find 方法替代 get 方法查找用户对应的完成时间记录
- 实现了更准确的时间戳记录和更新机制
- 修改文件读写操作以适配新的数据结构
- 修复了时间记录的数据持久化逻辑
- 更新了调试日志的内容显示方式

feat(auto-exchange): 添加兑换抽卡资源任务的通知配置选项

- 实现通知发送条件控制,仅当 config.send_notification 为 true 时发送
- 将固定的通知消息改为变量存储并记录到日志
- 调整文件创建成功的日志级别从 info 降至 debug
- 优化代码结构以支持更灵活的通知策略

feat(auto-exchange): 添加通知配置和错误处理优化

- 引入 throwError 工具函数替代原有错误抛出方式
- 新增 send_notification 配置选项控制通知发送
- 优化星尘数量不足时的错误提示信息
- 实现条件通知发送机制,避免重复通知
- 更新设置界面添加通知配置复选框
- 重构错误处理逻辑提高代码健壮性

feat(exchange): 实现每月自动兑换抽卡资源功能

- 添加了OCR识别UID功能用于用户标识
- 实现了重试机制和错误处理逻辑
- 集成了定时刷新任务系统,默认每月1号凌晨4点执行
- 添加了商城抽卡资源自动兑换流程
- 集成了返回主界面的安全导航功能
- 添加了配置文件存储兑换记录和状态管理
- 实现了图像识别定位和点击操作自动化
- 添加了设置界面支持最大重试次数配置

feat(exchange): 实现基于UID的任务状态管理与重试机制

- 添加OCR识别UID功能,支持多用户任务状态区分
- 修改任务刷新逻辑,将单一时间戳改为UID映射的时间存储
- 实现重试机制,支持最大重试次数限制
- 优化资源兑换流程,添加空球检测和安全释放机制
- 改进错误处理,添加详细的日志记录和异常处理
- 升级版本号至1.2并添加新的开发者信息

* refactor(auto-exchange): 优化抽卡资源自动兑换的时间记录逻辑

- 移除重复的时间记录代码,将逻辑集中到兑换成功后执行
- 调整代码结构,先获取当前时间再进行兑换操作
- 简化最后一次执行时间的查找逻辑
- 删除无用的注释代码
- 统一时间戳更新流程,确保数据一致性

fix(auto-exchange): 解决UID识别验证问题

- 添加UID类型检查确保为正整数
- 验证OCR识别结果的有效性
- 在UID识别失败时抛出具体错误信息
- 防止无效UID导致后续兑换流程异常

```
refactor(exchange): 优化每月兑换抽卡资源任务逻辑

- 将代码块包裹在 try-finally 结构中确保调试日志始终输出
- 移除多余的注释代码以提高代码整洁性
- 保持原有的业务逻辑不变,仅调整代码结构
- 确保 contentList 调试信息在所有情况下都能正确记录
```

fix(auto-exchange): 修复抽卡资源自动兑换的时间处理逻辑

- 修正了查找用户最后兑换时间的变量名错误
- 更新了时间数据的获取方式,从字符串改为数字格式
- 修复了时间对象创建的逻辑以正确处理时间戳
- 确保当前时间戳能正确更新到用户记录中

* ```
feat(exchange): 添加兑换不足检测功能

- 在用户配置中增加insufficient_exchange状态标识
- 实现兑换不足时的错误抛出机制
- 添加星尘数量验证逻辑
- 当星尘数量少于750时设置兑换不足标志
- 集成通知发送功能用于兑换异常提醒
```
This commit is contained in:
云端客
2026-04-04 14:46:43 +08:00
committed by GitHub
parent 193a8e0488
commit 4f49fbb4d3
6 changed files with 387 additions and 86 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -1,5 +1,18 @@
(async function () {
import {ocrUid} from "./utils/uid.js";
import {toMainUi, throwError} from "./utils/tool.js";
const config = {
tryRe: {
max: 3,
count: 0
},
user: {
uid: undefined,
insufficient_exchange: false,//兑换不足
},
send_notification: false
}
/**
* 判断任务是否已刷新
* @param {string} filePath - 存储最后完成时间的文件路径
@@ -23,26 +36,52 @@ async function isTaskRefreshed(filePath, options = {}) {
monthlyDay = 1, // 每月刷新默认第1天
monthlyHour = 4 // 每月刷新默认凌晨4点
} = options;
if (config.user.insufficient_exchange){
throwError("兑换不足,请手动兑换", config.send_notification)
}
config.tryRe.count++
const retry = config.tryRe;
const try_count_max = retry.max
const try_count = retry.count
if (try_count_max < try_count) {
throw new Error("已重试" + (try_count - 1) + "次数,超出最大重试" + try_count_max + "次数");
}
if (!config.user.uid) {
const resolvedUid = await ocrUid();
if (!Number.isInteger(resolvedUid) || resolvedUid <= 0) {
throw new Error(`UID 识别失败: ${resolvedUid}`);
}
config.user.uid = resolvedUid;
}
const uid = config.user.uid;
const current = {uid: uid, time: undefined}
// 读取文件内容
let contentList = [];
try {
contentList = JSON.parse(file.readTextSync(filePath))
} catch (e) {
log.debug("warn:" + e.message)
}
const last = contentList.find(item => item.uid === uid)
const lastTimeNumber = last?.time
const lastTime = lastTimeNumber ? new Date(lastTimeNumber) : new Date(0);
try {
// 读取文件内容
let content = await file.readText(filePath);
const lastTime = new Date(content);
const nowTime = new Date();
current.time = nowTime.getTime();
let shouldRefresh = false;
switch (refreshType) {
case 'hourly': // 每小时刷新
shouldRefresh = (nowTime - lastTime) >= 3600 * 1000;
break;
case 'daily': // 每天固定时间刷新
// 检查是否已经过了当天的刷新时间
const todayRefresh = new Date(nowTime);
todayRefresh.setHours(dailyHour, 0, 0, 0);
// 如果当前时间已经过了今天的刷新时间,检查上次完成时间是否在今天刷新之前
if (nowTime >= todayRefresh) {
shouldRefresh = lastTime < todayRefresh;
@@ -53,7 +92,7 @@ async function isTaskRefreshed(filePath, options = {}) {
shouldRefresh = lastTime < yesterdayRefresh;
}
break;
case 'weekly': // 每周固定时间刷新
// 获取本周的刷新时间
const thisWeekRefresh = new Date(nowTime);
@@ -61,7 +100,7 @@ async function isTaskRefreshed(filePath, options = {}) {
const dayDiff = (thisWeekRefresh.getDay() - weeklyDay + 7) % 7;
thisWeekRefresh.setDate(thisWeekRefresh.getDate() - dayDiff);
thisWeekRefresh.setHours(weeklyHour, 0, 0, 0);
// 如果当前时间已经过了本周的刷新时间
if (nowTime >= thisWeekRefresh) {
shouldRefresh = lastTime < thisWeekRefresh;
@@ -72,14 +111,14 @@ async function isTaskRefreshed(filePath, options = {}) {
shouldRefresh = lastTime < lastWeekRefresh;
}
break;
case 'monthly': // 每月固定时间刷新
// 获取本月的刷新时间
const thisMonthRefresh = new Date(nowTime);
// 设置为本月指定日期的凌晨
thisMonthRefresh.setDate(monthlyDay);
thisMonthRefresh.setHours(monthlyHour, 0, 0, 0);
// 如果当前时间已经过了本月的刷新时间
if (nowTime >= thisMonthRefresh) {
shouldRefresh = lastTime < thisMonthRefresh;
@@ -94,39 +133,60 @@ async function isTaskRefreshed(filePath, options = {}) {
case 'custom': // 自定义小时数刷新
shouldRefresh = (nowTime - lastTime) >= customHours * 3600 * 1000;
break;
default:
throw new Error(`未知的刷新类型: ${refreshType}`);
}
// 如果文件内容无效或不存在,视为需要刷新
if (!content || isNaN(lastTime.getTime())) {
await file.writeText(filePath, nowTime.toISOString());
shouldRefresh = true;
// // 如果文件内容无效或不存在,视为需要刷新
// if (!contentList || isNaN(lastTime.getTime())) {
// //todo:写入也要改 contentList.put(uid, nowTime.toISOString())
// // await file.writeText(filePath, JSON.stringify(contentList));
// shouldRefresh = true;
// }
try {
if (shouldRefresh) {
const message = `任务已刷新,执行每月兑换抽卡资源`;
log.info(message)
if (config.send_notification) {
notification.send(message);
}
await exchangeGoods();
if (contentList.some(item => item.uid === current.uid)) {
contentList.forEach(item => {
if (item.uid === current.uid) {
item.time = current.time;
}
})
} else {
contentList.push(current);
}
// 更新最后完成时间
await file.writeText(filePath, JSON.stringify(contentList));
return true;
} else {
const message = `任务未刷新,跳过每月兑换抽卡资源`;
log.info(message)
if (config.send_notification) {
notification.send(message);
}
return false;
}
} finally {
log.debug("contentList:", JSON.stringify(contentList))
}
if (shouldRefresh) {
notification.send(`任务已刷新,执行每月兑换抽卡资源`);
await exchangeGoods();
// 更新最后完成时间
await file.writeText(filePath, nowTime.toISOString());
return true;
} else {
notification.send(`任务未刷新,跳过每月兑换抽卡资源`);
return false;
}
} catch (error) {
// 如果文件不存在创建新文件并返回true(视为需要刷新)
const createResult = await file.writeText(filePath, '');
log.error(`刷新任务失败: ${error}`);
const createResult = await file.writeText(filePath, JSON.stringify(contentList));
if (createResult) {
log.info("创建新文件成功");
await isTaskRefreshed(filePath, options = {});
log.debug("创建新文件成功");
await isTaskRefreshed(filePath, options = {});
}
}
}
//检查是否为正整数
function positiveIntegerJudgment(testNumber) {
// 如果输入是字符串,尝试转换为数字
@@ -135,76 +195,149 @@ function positiveIntegerJudgment(testNumber) {
const cleaned = testNumber.replace(/[^\d]/g, '');
testNumber = parseInt(cleaned, 10);
}
// 检查是否为有效的数字
if (typeof testNumber !== 'number' || isNaN(testNumber)) {
throw new Error(`无效的值: ${testNumber} (必须为数字)`);
}
// 检查是否为整数
if (!Number.isInteger(testNumber)) {
throw new Error(`必须为整数: ${testNumber}`);
}
return testNumber;
}
async function exchangeGoods() {
await genshin.returnMainUi();await sleep(1000);
keyPress("ESCAPE"); await sleep(2000);//呼叫派蒙
click(198,416);await sleep(2000);//点击商城
click(127,434);await sleep(1000);//尘辉兑换
click(998,125);await sleep(1000);//星辰兑换
await toMainUi();
await sleep(1000);
keyPress("ESCAPE");
await sleep(2000);//呼叫派蒙
click(198, 416);
await sleep(2000);//点击商城
click(127, 434);
await sleep(1000);//尘辉兑换
click(998, 125);
await sleep(1000);//星辰兑换
let materialQuantity = "";
//检查星辰的数量
const region = RecognitionObject.ocr(1400, 31, 150, 50); // 星辰数量区域
let capture = captureGameRegion();
let res = capture.find(region);
capture.dispose();
let materialQuantity = res.text;
let validatedMaterialQuantity = positiveIntegerJudgment(materialQuantity);
if(validatedMaterialQuantity < 750){
notification.send(`星尘数量为:${validatedMaterialQuantity},无法全部兑换`);
throw new Error(`星尘数量为:${validatedMaterialQuantity},不能完全兑换`);
try {
let res = capture.find(region);
materialQuantity = res.text;
} finally {
if (capture) {
capture.dispose();
}
}
log.info(`星尘数量为:${validatedMaterialQuantity},数量充足,可以全部兑换`);
const pinkBallRo = RecognitionObject.TemplateMatch(file.ReadImageMatSync("assets/pinkBall.png"));
let ro1 = captureGameRegion();
let pinkBall = ro1.find(pinkBallRo);
ro1.dispose();
if (pinkBall.isExist()) {
pinkBall.click();await sleep(1000);
click(1290,604);await sleep(500);//增加
click(1290,604);await sleep(500);//增加
click(1290,604);await sleep(500);//增加
click(1290,604);await sleep(500);//增加
click(1164,782);await sleep(500);//确认兑换
click(960,754);await sleep(1000);//点击空白处继续
let pinkBallExist = false
let pinkBall
try {
let pinkBallFind = ro1.find(pinkBallRo);
pinkBallExist = pinkBallFind.isExist();
pinkBall = {
x: pinkBallFind.x,
y: pinkBallFind.y
}
} finally {
if (ro1) {
ro1.dispose();
}
}
const blueBallRo = RecognitionObject.TemplateMatch(file.ReadImageMatSync("assets/blueBall.png"));
let ro2 = captureGameRegion();
let blueBall = ro2.find(blueBallRo);
ro2.dispose();
if (blueBall.isExist()) {
blueBall.click();await sleep(1000);
click(1290,604);await sleep(500);//增加
click(1290,604);await sleep(500);//增加
click(1290,604);await sleep(500);//增加
click(1290,604);await sleep(500);//增加
click(1164,782);await sleep(500);//确认兑换
click(960,754);await sleep(1000);//点击空白处继续
let blueBallExist = false
let blueBall
try {
let blueBallFind = ro2.find(blueBallRo);
blueBallExist = blueBallFind.isExist();
blueBall = {
x: blueBallFind.x,
y: blueBallFind.y
}
} finally {
if (ro2) {
ro2.dispose();
}
}
if (!pinkBallExist && !blueBallExist) {
log.info(`没有粉球和蓝球,跳过兑换`);
return
}
let validatedMaterialQuantity = positiveIntegerJudgment(materialQuantity);
if (validatedMaterialQuantity < 750) {
config.user.insufficient_exchange=true
throwError(`星尘数量为:${validatedMaterialQuantity},数量不足,无法全部兑换`, config.send_notification)
// notification.send(`星尘数量为:${validatedMaterialQuantity},无法全部兑换`);
// throw new Error(`星尘数量为:${validatedMaterialQuantity},不能完全兑换`);
}
log.info(`星尘数量为:${validatedMaterialQuantity},数量充足,可以全部兑换`);
if (pinkBallExist && pinkBall) {
// pinkBall.click();
click(pinkBall.x, pinkBall.y)
await sleep(1000);
click(1290, 604);
await sleep(500);//增加
click(1290, 604);
await sleep(500);//增加
click(1290, 604);
await sleep(500);//增加
click(1290, 604);
await sleep(500);//增加
click(1164, 782);
await sleep(500);//确认兑换
click(960, 754);
await sleep(1000);//点击空白处继续
}
if (blueBallExist && blueBall) {
// blueBall.click();
click(blueBall.x, blueBall.y)
await sleep(1000);
click(1290, 604);
await sleep(500);//增加
click(1290, 604);
await sleep(500);//增加
click(1290, 604);
await sleep(500);//增加
click(1290, 604);
await sleep(500);//增加
click(1164, 782);
await sleep(500);//确认兑换
click(960, 754);
await sleep(1000);//点击空白处继续
}
const message = `商城抽卡资源兑换完成`;
log.info(message)
if (config.send_notification) {
notification.send(message);
}
notification.send(`商城抽卡资源兑换完成`);
}
async function main() {
try {
config.tryRe.max = parseInt(settings.try_count_max + "") || config.tryRe.max
config.send_notification = settings.send_notification
} catch (e) {
}
try {
await isTaskRefreshed("assets/monthly.txt", {
refreshType: 'monthly',
monthlyDay: 1, // 每月第1天默认值可省略
monthlyHour: 4 // 凌晨4点默认值可省略
});
} finally {
await toMainUi()
}
}
await isTaskRefreshed("assets/monthly.txt", {
refreshType: 'monthly',
monthlyDay: 1, // 每月第1天默认值可省略
monthlyHour: 4 // 凌晨4点默认值可省略
});
})();
await main();

View File

@@ -1,12 +1,16 @@
{
"manifest_version": 1,
"name": "每月自动兑换抽卡资源",
"version": "1.1",
"version": "1.2",
"description": "每个月自动兑换蓝球和粉球,兑换资源不够会提醒(需要打开 js 通知),本月兑换过会自动跳过,想要重置 CD可以把monthly.txt中的时间删掉",
"authors": [
{
"name": "柒叶子",
"links": "https://github.com/5117600049"
},
{
"name": "云端客",
"links": "https://github.com/Kirito520Asuna"
}
],
"settings_ui": "settings.json",

View File

@@ -0,0 +1,18 @@
[
{
"name": "send_notification",
"type": "checkbox",
"label": "发送通知"
},
{
"name": "try_count_max",
"type": "select",
"label": "最大重试次数",
"options": [
"1","2","3",
"4","5","6",
"7","8","9"
] ,
"default": "3"
}
]

View File

@@ -0,0 +1,82 @@
const commonPath = 'assets/'
const commonMap = new Map([
['main_ui', {
path: `${commonPath}`,
name: 'paimon_menu',
type: '.png',
}],
])
const genshinJson = {
width: 1920,//genshin.width,
height: 1080,//genshin.height,
}
/**
* 根据键值获取JSON路径
* @param {string} key - 要查找的键值
* @returns {any} 返回与键值对应的JSON路径值
*/
function getJsonPath(key) {
return commonMap.get(key); // 通过commonMap的get方法获取指定键对应的值
}
// 判断是否在主界面的函数
const isInMainUI = () => {
// let name = '主界面'
let main_ui = getJsonPath('main_ui');
// 定义识别对象
let paimonMenuRo = RecognitionObject.TemplateMatch(
file.ReadImageMatSync(`${main_ui.path}${main_ui.name}${main_ui.type}`),
0,
0,
genshinJson.width / 3.0,
genshinJson.width / 5.0
);
let captureRegion = captureGameRegion();
try {
let res = captureRegion.find(paimonMenuRo);
return !res.isEmpty();
} finally {
captureRegion.dispose()
}
};
async function toMainUi() {
let ms = 300
let index = 1
await sleep(ms);
while (!isInMainUI()) {
await sleep(ms);
await genshin.returnMainUi(); // 如果未启用,则返回游戏主界面
await sleep(ms);
if (index > 3) {
throwError(`多次尝试返回主界面失败`);
}
index += 1
}
}
/**
* 抛出错误函数
* 该函数用于显示错误通知并抛出错误对象
* @param {string} msg - 错误信息,将用于通知和错误对象
*/
function throwError(msg, isNotification = false) {
// 使用notification组件显示错误通知
// notification.error(`${msg}`);
if (isNotification) {
notification.error(`${msg}`);
}
// 抛出一个包含错误信息的Error对象
throw new Error(`${msg}`);
}
export {
getJsonPath,
isInMainUI,
toMainUi,
throwError,
}

View File

@@ -0,0 +1,64 @@
async function saveOnlyNumber(str) {
str = str ? str : '';
// 使用正则表达式匹配字符串中的所有数字
// \d匹配一个或多个数字
// .join('') 将匹配到的数字数组连接成一个字符串
// parseInt 将连接后的字符串转换为整数
// return parseInt(str.match(/\d+/g).join(''));
const matches = str.match(/\d+/g);
if (!matches) {
return 0; // 或抛出错误
}
return parseInt(matches.join(''), 10);
}
/**
* 对指定区域进行OCR文字识别
* @param {number} x - 区域左上角x坐标默认为0
* @param {number} y - 区域左上角y坐标默认为0
* @param {number} w - 区域宽度默认为1920
* @param {number} h - 区域高度默认为1080
* @returns {Promise<string|null>} 返回识别到的文本内容如果识别失败则返回null
*/
async function ocrRegion(x = 0,
y = 0,
w = 1920,
h = 1080) {
// 创建OCR识别对象使用指定的坐标和尺寸
let recognitionObjectOcr = RecognitionObject.Ocr(x, y, w, h);
// 捕获游戏区域图像
let region3 = captureGameRegion()
try {
// 在捕获的区域中查找OCR识别对象
let res = region3.find(recognitionObjectOcr);
// 返回识别到的文本内容如果不存在则返回undefined
return res?.text
} catch (e) {
// 捕获并记录错误信息
log.error("识别异常:{1}", e.message)
return null
} finally {
// 确保释放区域资源
region3.dispose()
}
}
/**
* OCR识别UID的异步函数
* 该函数用于通过OCR技术识别屏幕上特定位置的UID文本
* @returns {Promise<number>} - 异步函数,没有明确的返回值
*/
async function ocrUid() {
// 定义OCR识别的坐标和尺寸参数
let uid_json = {
x: 1683, // OCR识别区域的左上角x坐标
y: 1051, // OCR识别区域的左上角y坐标
width: 234, // OCR识别区域的宽度
height: 28, // OCR识别区域的高度
}
let text = await ocrRegion(uid_json.x, uid_json.y, uid_json.width, uid_json.height);
return await saveOnlyNumber(text);
}
export {
ocrUid,
}