Files
bettergi-scripts-list/repo/js/ActivitySwitchNotice/utils/activity.js
云端客 f01750b034 活动期限通知器 (#2535)
* feat(js): 初始化活动期限通知器插件

- 创建主执行文件 main.js
- 添加插件配置文件 manifest.json
- 编写基础设置界面 settings.json
- 建立项目说明文档 README.md
- 实现异步主函数结构
- 配置插件元数据和依赖项
- 定义用户可配置选项
- 设置默认启用状态和快捷键
- 添加作者信息和联系方式
- 描述核心功能和使用注意事项

* feat(activity): 添加活动切换通知功能

- 新增活动配置读取逻辑,支持指定活动名称列表和活动键
- 实现活动主函数,根据配置决定通知所有或指定活动
- 添加通知工具模块,支持异步发送包含标题和内容的通知
- 在主文件中引入活动和通知工具模块,完善执行流程

* feat(activity): 新增OCR点击活动功能

- 实现OcrClickActivity函数,支持OCR识别并点击活动
- 添加活动名称匹配与切换计数逻辑
- 记录活动坐标及剩余时间信息
- 更新activityMain函数以配合新活动切换流程
- 优化活动页面打开与关闭的键盘操作逻辑

* feat(activity): 增加活动OCR识别与剩余时间获取功能

- 新增ocrRegionConfig配置对象,定义活动及剩余时间识别区域
- 修改OcrClickActivity函数,支持默认OCR区域配置和活动识别逻辑优化
- 增加activityOk状态标识,用于判断是否完成所有活动识别
- 实现OcrRemainingTime函数,用于识别并返回指定活动的剩余时间
- 重构activityMain主流程,支持循环识别多个活动及其剩余时间
- 集成通知模块,发送活动名称和对应剩余时间的通知消息
- 引入外部脚本执行机制,通过eval动态加载notice.js配置文件

* chore(activity): 添加待办注释以优化活动切换逻辑

- 在活动切换循环前添加 '//todo:拉到顶部' 注释
- 为后续优化活动切换时的滚动行为做准备

* feat(activity): 增加活动切换尝试次数限制

- 添加 index 和 maxIndex 变量控制循环次数
- 设置最大尝试次数为10次
- 超出最大尝试次数时记录警告日志并退出循环
- 避免无限循环导致脚本卡死

* chore(activity): 添加待办注释和空行优化代码结构

- 在循环开始前添加了两个新的待办注释
- 在条件判断后添加了滑动一页的待办注释
- 为代码逻辑段落间增加了空行以提高可读性

* chore(activity): 更新活动切换注释

- 将注释中的“滑动一页”修改为“向下滑动一页”以提高可读性

* feat(activity): 实现活动自动化处理主函数

- 添加活动主函数 activityMain 用于自动化处理活动流程
- 集成打开活动页面、OCR识别与点击功能
- 引入活动Map记录已识别活动避免重复处理
- 实现循环处理机制并支持指定活动列表过滤
- 增加页面滑动到底判断逻辑防止无限循环
- 添加最大尝试次数限制及对应日志警告
- 完善注释说明提升代码可读性与维护性

* fix(ActivitySwitchNotice): 调整OCR识别区域坐标配置

- 更新活动识别区域坐标为(197, 220, 292, 701)
- 更新剩余时间识别区域坐标为(497, 202, 1417, 670)
- 优化识别精度以提高切换检测准确性

* refactor(activity): 重构活动切换通知逻辑

- 移除冗余的notice.js文件引用
- 引入activity.js工具文件
- 更新主函数调用activityUtil.activityMain方法
- 确保异步执行上下文正确处理

* feat(activity): 添加页面滚动功能

- 新增 scrollPage 异步函数用于控制页面滚动
- 支持设置滚动方向、距离和速度参数
- 实现鼠标按键控制和步进式滚动逻辑
- 添加详细的函数文档注释
- 集成延时控制和步长配置选项

* feat(activity): 添加活动切换通知功能

- 新增xyConfig配置对象定义top和bottom坐标
- 在activity.js中引入notice.js脚本
- 优化代码格式调整maxIndex变量声明空格
- 修复条件判断语句中的语法错误
- 完善活动切换逻辑注释说明

* feat(activity): 新增活动页面滚动功能

- 添加genshinJson配置对象定义画布尺寸
- 实现scrollPagesByActivity函数支持页面滚动
- 支持向上或向下滚动操作
- 集成坐标移动与页面滚动逻辑
- 设置默认滚动参数适配画布高度
- 保留原有OCR点击活动功能

* refactor(activity): 优化活动点击处理函数参数和映射逻辑

* refactor(activity): 优化活动切换通知的滚动逻辑

- 移除eval执行外部脚本的方式,提高安全性
- 更新scrollPagesByActivity函数实现更稳定的滚动操作
- 增加循环次数与调整滚动参数以提升准确性
- 导出新增的工具方法和配置对象供其他模块使用

* feat(activity): 优化活动切换与滚动逻辑

- 修改鼠标移动注释,明确移动到指定坐标位置
- 为OcrClickActivity函数新增defaultActivityCount参数,支持传入初始活动计数
- 在活动识别主循环中提前声明switchToActivityCount变量,便于复用
- 调整OcrClickActivity调用方式,传递已有的switchToActivityCount值

* feat(activity): 新增滑动到顶功能并优化滑动逻辑

- 新增 scrollPagesByActivityToTop 方法实现自动滚动至顶部
- 增加 toTopCount 和 scrollPageCount 配置项用于控制滑动行为
- 修改 scrollPagesByActivity 方法支持可配置的滑动次数
- 调整 OCR 活动点击函数参数格式以提高可读性

* feat(activity): 实现活动页面滚动功能

- 添加滚动到顶部功能,调用scrollPagesByActivityToTop方法
- 实现向下滑动一页功能,调用scrollPagesByActivity方法
- 移除待办注释,完成页面滚动逻辑
- 整合滚动功能到主循环中,优化用户体验

* feat(ActivitySwitchNotice): 增加返回主界面逻辑并优化设置提示

- 新增 isInMainUI 函数用于判断当前是否处于主界面
- 实现 toMainUi 异步函数确保脚本运行前回到主界面
- 更新设置项标签,明确活动名称可选填且默认推送所有有剩余时间的活动
- 调整代码结构以支持新增功能模块
- 修复部分语法问题以提高代码健壮性

* 图

* feat(ActivitySwitchNotice): 添加控制是否先返回主界面的设置选项

- 修改设置项名称从 enable 为 toMainUi
- 根据设置值控制是否执行返回主界面逻辑
- 更新设置标签描述信息
- 默认启用该功能以保持向后兼容性

* chore(activity): 调整活动工具函数导出并优化主入口执行逻辑

- 注释掉 activityUtil 中的 OcrRemainingTime 导出项
- 在 main.js 中添加异步立即执行函数以支持 toMainUi 切换逻辑
- 为主入口逻辑增加 settings.toMainUi 条件判断

* feat(ActivitySwitchNotice): 添加默认按键设置选项

- 在设置中为活动页面按键添加默认值"F5"
- 更新配置以支持自定义按键绑定
- 优化用户体验,减少手动输入需求

* feat(ActivitySwitchNotice): 添加活动配置并触发按键事件

- 在 activityUtil 中暴露 config 配置对象
- 在主流程中添加按键事件调用逻辑
- 使用 activityKey 配置触发键盘操作

* fix(ActivitySwitchNotice): 优化活动切换逻辑并移除冗余配置

- 将活动快捷键模拟从 keyDown/keyUp 改为 keyPress
- 移除多余的按键释放操作
- 注释掉未使用的 config 导出
- 移除主函数中重复的按键触发逻辑

* refactor(activity): 替换等待函数提升代码可读性

- 将 wait() 函数调用替换为 sleep(ms)
- 统一使用 sleep 函数处理异步等待逻辑
- 提高代码一致性和维护性

* fix(activity): 修复回到顶部逻辑判断条件

- 修改循环跳出条件判断逻辑,避免无限循环
- 确保超过最大尝试次数时能正确抛出错误
- 提高页面滚动到顶部功能的稳定性

* fix(activity): 更新OCR识别区域配置

- 将OCR识别区域从ocrRegion变量更改为config.activity配置
- 确保OCR识别使用正确的活动区域参数
- 保持原有的注释和逻辑结构不变

* feat(activity): 支持自定义OCR识别区域配置

- 修改scrollPagesByActivityTop函数签名,新增ocrRegion参数支持传入自定义OCR区域配置
- 默认使用ocrRegionConfig.activity作为OCR识别区域配置
- 更新OCR对象创建逻辑,使用传入的ocrRegion参数替代原有的config.activity配置
- 保持原有功能不变,增强函数灵活性和可配置性

* fix(activity): 调整活动识别区域坐标和尺寸

- 修改activity识别区域的x、y坐标及宽高参数
- 更新width从292到226,height从701到616
- 调整x坐标从197到267,y坐标从220到197

* fix(activity): 修复活动地图更新逻辑

- 修正 activityMap 初始化赋值错误
- 更新活动地图遍历方式,使用 keys() 方法
- 修复活动地图新增逻辑,避免重复设置相同键值
- 确保只添加新发现的活动到 activityMap 中

* fix(ActivitySwitchNotice): 修复通知文本中键值对顺序错误

- 调整forEach回调函数参数顺序,确保键值对正确显示
- 保持代码逻辑一致性,避免数据展示混乱

* feat(activity): 增加活动切换次数记录功能

- 在OcrClickActivity函数中新增lastName参数用于记录上一个活动名称
- 新增resObject对象统一管理活动切换状态和坐标信息
- 优化活动识别逻辑,避免重复识别相同活动
- 更新函数调用方式以传递LastActivityName参数
- 修复活动切换计数可能不准确的问题
- 完善活动地图更新机制,确保数据一致性

* feat(activity): 添加鼠标移动操作以优化活动切换逻辑

- 在回到顶部逻辑前添加鼠标移动至坐标(0,20)的操作
- 在OCR点击活动逻辑前添加鼠标移动至坐标(0,20)的操作
- 提升活动切换准确性与稳定性

* fix(ActivitySwitchNotice): 调整通知文本格式

- 修改键值对分隔符为单个空格
- 移除键值之间的冒号符号
- 保持每行末尾换行符一致

* fix(ActivitySwitchNotice): 修复活动切换工具中的OCR识别逻辑

- 统一鼠标移动函数调用的代码风格,增加参数间的空格
- 在成功返回顶部时添加日志信息输出
- 增强OCR识别结果判断条件,避免访问空数组导致异常

* fix(ActivitySwitchNotice): 优化活动切换逻辑和OCR识别流程

- 修改OcrClickActivity函数参数默认值,将lastName默认值设为null
- 增强活动识别结束条件判断,避免无限循环
- 添加活动识别过程中的日志记录以便调试
- 引入双变量记录上一个活动名称以提高准确性
- 重构主循环逻辑,确保能正确识别所有活动
- 修复在特定条件下无法退出循环的问题
- 完善活动切换次数统计和活动映射更新机制

* feat(activity): 优化活动页面滚动与OCR识别逻辑

- 新增 scrollPagesByActivityToTop 函数,通过连续检测顶部活动名称相同来确认已到顶
- 修改 scrollPagesByActivity 支持自定义滚动参数(total, waitCount, stepDistance)
- 重构 activityMain 函数,改为逐页扫描活动并统一发送通知
- 调整 OCR 日志级别从 info 改为 debug
- 注释掉旧版 OcrClickActivity 和 activityMain 实现
- 在 main.js 中增加一次 toMainUi 调用以确保界面状态稳定

* refactor(activity): 移除废弃的活动处理函数并优化日志输出

- 删除了已注释的 OcrClickActivity 函数实现
- 移除了已注释的 activityMain 主函数代码
- 优化了活动剩余时间识别的日志格式
- 清理了无用的活动处理逻辑和冗余注释
- 简化了活动识别与点击的核心流程
- 提升了代码可读性和维护性

* fix(notice): 修复通知文本格式化问题

- 修复了模板字符串中变量未正确插入的问题
- 确保键值对在通知中正确显示
- 保持换行符一致以维持原有格式

* fix(ActivitySwitchNotice): 修复通知文本格式化问题

- 将字符串拼接方式从双引号改为模板字符串
- 修复了键值对显示异常的问题
- 确保通知文本正确换行显示

* fix(notice): 移除多余的通知文本包装

- 删除了发送通知时多余的反引号包裹
- 确保通知文本格式正确显示
- 避免了因格式错误导致的通知发送失败问题

* feat(activity): 新增活动剩余时间解析与排序通知功能

- 添加解析原神活动剩余时间字符串为总小时数的函数
- 支持多种时间格式如"22天14小时"、"5小时"等
- 实现按剩余小时数升序排列活动通知内容
- 更新活动时间存储结构以包含原始文本和计算后的小时数
- 优化通知消息展示格式,突出剩余时间信息

* fix(activity): 修复活动时间记录日志格式

- 在日志信息中明确添加“小时”单位以提高可读性
- 保持原有变量替换逻辑不变
- 确保日志输出的一致性和准确性

* feat(activity): 增强活动页面底部检测逻辑

- 新增基于活动名称集合的重复页判断机制
- 计算当前页与上一页活动名称重合率,防止无效滚动
- 设置重合率阈值为70%,超过则判定已到底部
- 每页新增活动计数器,若为零且非首页则停止扫描
- 保留原有底部判断方式,形成双重保险机制
- 优化日志输出,增强调试与运行状态可视化

* feat(activity): 新增活动去重逻辑

- 引入previousPageActivities集合用于记录已识别活动
- 防止同一页活动被重复处理
- 提升活动切换提醒准确性

* feat(activity): 增强活动扫描与通知功能

- 新增 settingsParseInt 函数用于安全解析配置数值
- 添加 notifyHoursThreshold 配置项,支持自定义通知时间阈值
- 优化滚动逻辑中的参数格式和日志输出
- 增加活动即将结束标识(剩余时间≤24小时)
- 过滤掉超出时间阈值的活动,仅通知符合条件的活动
- 更新通知文案,显示时间筛选条件
- 修复多处代码格式和空格问题,提升可读性

* docs(ActivitySwitchNotice): 更新README文档内容

- 补充项目概述与功能特性说明
- 添加用户使用指南与快速开始步骤
- 详细描述配置选项与高级设置
- 增加文件结构与核心模块介绍
- 完善注意事项与工作原理说明
- 提供输出示例与使用环境要求
- 整理文档结构,提升可读性与实用性

* docs(ActivitySwitchNotice): 更新 README 配置说明

- 修正 settings.json 文件链接格式
- 统一文档中文件引用的展示方式
- 提高配置说明的可读性

* fix(ActivitySwitchNotice): 修复设置项默认值语法错误

- 修正 settings.json 中 notifyHoursThreshold 字段的默认值语法
- 添加缺失的冒号以符合 JSON 格式要求

* fix(ActivitySwitchNotice): 修复活动通知筛选逻辑并优化排序

- 调整活动筛选条件,确保仅包含剩余时间小于阈值的活动
- 对筛选后的活动按剩余时间进行升序排序
- 更新通知发送逻辑以使用排序后的活动列表
- 修正日志记录中的活动数量显示问题
- 移除无用的代码注释和空行

* feat(activity): 优化活动通知逻辑以提高性能

- 使用 Map 过滤活动数据而不是数组排序
- 减少不必要的数据转换操作
- 提升活动扫描和通知发送的效率
- 保持原有通知阈值和日志功能不变

* fix(activity): 修复活动名称列表分割方法

- 将 splice 方法更正为 split 方法以正确分割活动名称
- 更新 README 中的默认通知阈值描述
2025-12-21 16:22:52 +08:00

447 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
function settingsParseInt(str, defaultValue) {
try {
return str ? parseInt('' + str) : defaultValue;
} catch (e) {
log.warn(`settingsParseInt error:${e}`)
return defaultValue;
}
}
const config = {
activityNameList: (settings.activityNameList ? settings.activityNameList.split('|') : []),
activityKey: (settings.activityKey ? settings.activityKey : 'F5'),
toTopCount: settingsParseInt(settings.toTopCount,10),//滑动到顶最大尝试次数
scrollPageCount: settingsParseInt(settings.scrollPageCount,4),//滑动次数/页
notifyHoursThreshold: settingsParseInt(settings.notifyHoursThreshold, 8760),//剩余时间阈值(默认 8760小时=365天)
}
const ocrRegionConfig = {
activity: {x: 267, y: 197, width: 226, height: 616},//活动识别区域坐标和尺寸
remainingTime: {x: 497, y: 202, width: 1417, height: 670},//剩余时间识别区域坐标和尺寸
}
const xyConfig = {
top: {x: 344, y: 273},
bottom: {x: 342, y: 791},
}
const genshinJson = {
width: 1920,//genshin.width,
height: 1080,//genshin.height,
}
/**
* 滚动页面的异步函数
* @param {number} totalDistance - 总滚动距离
* @param {boolean} [isUp=false] - 是否向上滚动默认为false(向下滚动)
* @param {number} [waitCount=6] - 每隔多少步等待一次
* @param {number} [stepDistance=30] - 每步滚动的距离
* @param {number} [delayMs=1] - 等待的延迟时间(毫秒)
*/
async function scrollPage(totalDistance, isUp = false, waitCount = 6, stepDistance = 30, delayMs = 1000) {
let ms = 600
await sleep(ms);
leftButtonDown(); // 按下左键
await sleep(ms);
// 计算总步数
let steps = Math.floor(totalDistance / stepDistance);
// 开始循环滚动
for (let j = 0; j < steps; j++) {
// 计算剩余距离
let remainingDistance = totalDistance - j * stepDistance;
// 确定本次移动距离
let moveDistance = remainingDistance < stepDistance ? remainingDistance : stepDistance;
// 如果是向上滚动,则移动距离取反
if (isUp) {
//向上活动
moveDistance = -moveDistance
}
// 执行鼠标移动
moveMouseBy(0, -moveDistance);
// 取消注释后会在每一步后等待
// await sleep(delayMs);
// 每隔waitCount步等待一次
if (j % waitCount === 0) {
await sleep(delayMs);
}
}
// 滚动完成后释放左键
await sleep(ms);
leftButtonUp();
await sleep(ms);
}
/**
* 根据活动状态进行页面滚动
* @param {boolean} isUp - 是否向上滚动默认为false
*/
async function scrollPagesByActivity(isUp = false, total = 90, waitCount = 6, stepDistance = 30) {
// 根据滚动方向设置坐标位置
// 如果是向上滚动,使用顶部坐标;否则使用底部坐标
let x = isUp ? xyConfig.top.x : xyConfig.bottom.x
let y = isUp ? xyConfig.top.y : xyConfig.bottom.y
// 记录滑动方向
log.info(`活动页面-${isUp ? '向上' : '向下'}滑动`)
// 注释:坐标信息已注释掉,避免日志过多
// log.info(`坐标:${x},${y}`)
// 根据配置的滑动次数执行循环
for (let i = 0; i < config.scrollPageCount; i++) {
// 移动到坐标位置
await moveMouseTo(x, y)
//80 18次滑动偏移量 46次测试未发现偏移
await scrollPage(total, isUp, waitCount, stepDistance)
}
}
/**
* 通过滚动页面直到到达顶部位置
* 该函数会持续滚动页面,直到检测到页面顶部的标识不再变化为止
* @returns {Promise<void>} 无返回值,当到达顶部时函数执行结束
* @throws {Error} 如果尝试滚动超过10次仍未到达顶部抛出错误
*/
async function scrollPagesByActivityToTop(ocrRegion = ocrRegionConfig.activity) {
let topName = null // 用于存储检测到的顶部标识文本
let index = 0 // 记录滚动尝试次数的计数器
// 无限循环直到到达顶部后通过return退出
while (true) {
// 检查是否已超过最大尝试次数(10次)
if (index >= config.toTopCount) {
throw new Error("回到顶部失败") // 超过尝试次数抛出错误
}
await moveMouseTo(0, 20)
index++ // 增加尝试次数计数器
let captureRegion = captureGameRegion(); // 获取游戏区域截图
const ocrObject = RecognitionObject.Ocr(ocrRegion.x, ocrRegion.y, ocrRegion.width, ocrRegion.height); // 创建OCR识别对象
// ocrObject.threshold = 1.0;
let resList = captureRegion.findMulti(ocrObject); // 在指定区域进行OCR识别
captureRegion.dispose(); // 释放截图资源
if (topName !== resList[0].text) {
topName = resList[0].text
} else {
log.info(`回到顶部成功`)
// break
return
}
await scrollPagesByActivity(true, 80 * 4, 6, 60)
}
}
/**
* 滚动到活动页面最顶部(优化版)
* 通过连续检测顶部活动名称相同来确认已到顶,更加健壮
* @param {Object} ocrRegion - OCR识别区域默认为活动列表区域
* @throws {Error} 如果超过最大尝试次数仍未检测到稳定顶部,则抛出错误
*/
async function scrollPagesByActivityToTop(ocrRegion = ocrRegionConfig.activity) {
let ms = 800
let topActivityName = null; // 上一次检测到的顶部活动名称
let sameTopCount = 0; // 连续出现相同顶部名称的次数
const requiredSameCount = 1; // 需要连续几次相同才确认到顶(推荐 2~3
let attemptIndex = 0; // 总尝试次数计数器
const maxAttempts = config.toTopCount; // 可配置默认为15次
log.info("开始滚动到活动页面顶部...");
while (attemptIndex < maxAttempts) {
attemptIndex++;
log.info(`第 {attemptIndex} 次尝试回顶`, attemptIndex);
// 移动鼠标到安全位置,避免干扰截图
await moveMouseTo(0, 20);
// 截图 + OCR 识别活动列表区域
let captureRegion = captureGameRegion();
const ocrObject = RecognitionObject.Ocr(
ocrRegion.x,
ocrRegion.y,
ocrRegion.width,
ocrRegion.height
);
// 可选:提升识别率
// ocrObject.threshold = 0.8;
let resList = captureRegion.findMulti(ocrObject);
captureRegion.dispose();
// 如果完全没识别到任何活动,可能是页面异常或已在顶(极少情况)
if (resList.length === 0) {
log.warn("顶部OCR未识别到任何活动条目可能是页面为空或识别失败");
// 再尝试一次向上滚大距离
// await scrollPagesByActivity(true); // true = 向上
await scrollPagesByActivity(true, 80 * 4, 6, 60);
await sleep(ms);
continue;
}
// 取当前识别到的最顶部活动名称resList[0] 通常是列表最上面的)
const currentTopName = resList[0].text.trim();
log.info(`当前检测到的顶部活动: {currentTopName}`, currentTopName);
// 判断是否与上一次相同
if (currentTopName === topActivityName) {
sameTopCount++;
log.debug(`顶部活动连续相同 ${sameTopCount}`);
if (sameTopCount >= requiredSameCount) {
log.info(`已连续 {sameTopCount} 次检测到相同顶部活动,确认回到页面最顶部!`, sameTopCount);
return; // 成功回到顶部
}
} else {
// 顶部名称变了,说明还在向上滚动,重置计数
topActivityName = currentTopName;
sameTopCount = 1; // 这次算第一次
}
// 未达到稳定状态,继续向上滚动一页(可根据实际情况调整滚动距离)
// 这里使用更大滚动距离确保能快速回顶
// await scrollPagesByActivity(true); // true = 向上
// 可选:加大单次滚动量(如果你发现默认一页不够)
await scrollPagesByActivity(true, 80 * 4, 6, 60);
await sleep(ms); // 给页面滚动和渲染留时间
}
// 超过最大尝试次数仍未稳定
throw new Error(`回到活动页面顶部失败:尝试 ${attemptIndex} 次后仍未检测到稳定顶部活动`);
}
/**
* 解析原神活动剩余时间字符串,返回总小时数
* 支持格式示例:
* "剩余时间22天14小时" → 54222*24 + 14
* "剩余时间5小时" → 5
* "剩余时间3天" → 723*24 + 0
* "剩余时间1天23小时" → 47
* "剩余10天" → 240也支持部分关键词匹配
*
* @param {string} timeText - OCR识别出的剩余时间文本
* @returns {number} 总剩余小时数(整数,四舍五入向下取整)
* 如果解析失败,返回 0
*/
function parseRemainingTimeToHours(timeText) {
if (!timeText || typeof timeText !== 'string') {
return 0;
}
// 提取数字和单位(支持中英文冒号、空格等)
const dayMatch = timeText.match(/(\d+)\s*天/);
const hourMatch = timeText.match(/(\d+)\s*小时/);
let days = 0;
let hours = 0;
if (dayMatch) {
days = parseInt(dayMatch[1], 10);
}
if (hourMatch) {
hours = parseInt(hourMatch[1], 10);
}
// 天数转小时 + 原有小时
const totalHours = days * 24 + hours;
return totalHours;
}
/**
* 可选:返回格式化字符串,如 "542小时22天14小时"
*/
function formatRemainingTime(timeText) {
if (!timeText || typeof timeText !== 'string') {
return "解析失败";
}
const dayMatch = timeText.match(/(\d+)\s*天/);
const hourMatch = timeText.match(/(\d+)\s*小时/);
const days = dayMatch ? parseInt(dayMatch[1], 10) : 0;
const hours = hourMatch ? parseInt(hourMatch[1], 10) : 0;
const totalHours = days * 24 + hours;
const original = timeText.trim();
return `${totalHours}小时(${days > 0 ? days + '天' : ''}${hours > 0 ? hours + '小时' : ''}`;
}
/**
* OCR识别活动剩余时间的函数
* @param {Object} ocrRegion - OCR识别的区域坐标和尺寸
* @param {string} activityName - 活动名称
* @param {string} key - 要识别的关键词,默认为"剩余时间"
* @returns {string|null} 返回识别到的剩余时间文本若未识别到则返回null
*/
async function OcrRemainingTime(activityName, key = "剩余时间", ocrRegion = ocrRegionConfig.remainingTime) {
let captureRegion = captureGameRegion(); // 获取游戏区域截图
const ocrObject = RecognitionObject.Ocr(ocrRegion.x, ocrRegion.y, ocrRegion.width, ocrRegion.height); // 创建OCR识别对象
// ocrObject.threshold = 1.0;
let resList = captureRegion.findMulti(ocrObject); // 在指定区域进行OCR识别
captureRegion.dispose(); // 释放截图资源
for (let res of resList) {
if (res.text.includes(key)) { // 检查识别结果是否包含关键词
log.debug(`{activityName}--{time}`, activityName, res.text); // 记录日志
return res.text // 返回识别到的文本
}
}
// 没有识别到剩余时间
return null;
}
/**
* 活动主函数:扫描所有活动页面,识别剩余时间,最后统一发送通知
*/
async function activityMain() {
const ms = 1000;
await sleep(ms);
// 1. 打开活动页面(默认 F5
await keyPress(config.activityKey);
await sleep(ms * 2); // 给活动页面多点加载时间
// 2. 先强制滚动到最顶部(非常重要!)
try {
await scrollPagesByActivityToTop();
await sleep(ms);
} catch (e) {
log.warn("回到顶部失败,但继续尝试执行");
}
// 3. 初始化存储所有活动的 Map
let activityMap = new Map(); // key: 活动名称, value: 剩余时间文本
let previousPageActivities = new Set(); // 新增:记录上一页识别到的所有活动名称(用于重复页判断)
let lastPageBottomName = null; // 上一次扫描到的页面最底部活动名
let sameBottomCount = 0; // 连续出现相同底部活动名的次数
let scannedPages = 0;
const maxPages = 25; // 防止意外死循环的安全上限
let sameBottomCountMax = 1; // 连续相同底部活动名的最大次数
// 4. 主循环:逐页向下扫描
while (scannedPages < maxPages) {
scannedPages++;
log.info(`正在扫描第 {scannedPages} 页`, scannedPages);
// 移动鼠标到安全位置,避免干扰截图
await moveMouseTo(0, 20);
// 获取当前页面活动列表区域截图并 OCR
let captureRegion = captureGameRegion();
const ocrObject = RecognitionObject.Ocr(
ocrRegionConfig.activity.x,
ocrRegionConfig.activity.y,
ocrRegionConfig.activity.width,
ocrRegionConfig.activity.height
);
let resList = captureRegion.findMulti(ocrObject);
captureRegion.dispose();
// 如果本页完全没有识别到活动,可能是到底了或 OCR 失败
if (resList.length === 0) {
log.info("当前页未识别到任何活动,视为已到页面底部");
break;
}
// ============ 新增:提前判断是否为重复页 ============
const currentPageNames = new Set();
for (let res of resList) {
currentPageNames.add(res.text.trim());
}
// 计算与上一页的重合率
if (previousPageActivities.size > 0) {
let overlapCount = 0;
for (let name of currentPageNames) {
if (previousPageActivities.has(name)) overlapCount++;
}
const overlapRatio = overlapCount / previousPageActivities.size;
// 如果重合率 >= 70%(可调整),认为滚动未生效,是重复页
if (overlapRatio >= 0.7) {
log.info(`检测到当前页与上一页高度重复(重合率 ${Math.round(overlapRatio * 100)}%),已到达底部,停止扫描`);
break;
}
}
// 更新上一页记录(为下一轮做准备)
previousPageActivities = currentPageNames;
// =================================================
let currentPageBottomName = null; // 本页最下面的活动名
let newActivityCountThisPage = 0;
// 遍历当前页所有识别到的活动条目
for (let res of resList) {
const activityName = res.text.trim();
// 如果设置了指定活动列表,只处理包含这些关键词的活动
if (config.activityNameList.length > 0) {
const matched = config.activityNameList.some(keyword => activityName.includes(keyword));
if (!matched) {
continue; // 不关心的活动,跳过不点击
}
}
// 避免重复点击同一个活动(防止 OCR 误识别或页面抖动)
if (activityMap.has(activityName)) {
log.info(`活动已记录,跳过重复点击: ${activityName}`);
} else {
await click(res.x, res.y); // 点击进入活动详情
await sleep(ms);
let remainingTimeText = await OcrRemainingTime(activityName);
if (remainingTimeText) {
const totalHours = parseRemainingTimeToHours(remainingTimeText);
if (totalHours <= 24 && totalHours > 0) {
remainingTimeText += '<即将结束>'
}
activityMap.set(activityName, {
text: remainingTimeText,
hours: totalHours
});
log.info(`成功记录 → {activityName} {remainingTime} 共计: {hours} 小时`, activityName, remainingTimeText, totalHours);
newActivityCountThisPage++;
}
await sleep(ms);
}
// 更新本页最下面的活动名
currentPageBottomName = activityName;
}
// 备用判断:本页一个新活动都没加,也认为到底(双保险)
if (newActivityCountThisPage === 0 && scannedPages > 1) {
log.info("本页无新活动添加,确认已到底");
break;
}
// 5. 判断是否已到达页面底部
if (currentPageBottomName && currentPageBottomName === lastPageBottomName) {
sameBottomCount++;
if (sameBottomCount >= sameBottomCountMax) {
log.info("连续{sameBottomCountMax}次检测到相同底部活动,已确认到达页面最底部,扫描结束", sameBottomCountMax);
break;
}
} else {
sameBottomCount = 0; // 重置计数
}
lastPageBottomName = currentPageBottomName;
// 6. 向下滑动一页,继续下一轮
await scrollPagesByActivity(false); // false = 向下滚动
await sleep(ms);
}
let activityMapFilter = new Map();
Array.from(activityMap.entries())
.filter(([name, info]) => info.hours <= config.notifyHoursThreshold)
.forEach(([name, info]) => activityMapFilter.set(name, info));
// 7. 全部扫描完毕,统一发送通知(只发一次!)
if (activityMapFilter.size > 0) {
log.info(`扫描完成,共记录 {activityMap.size} 个活动,即将发送通知`, activityMapFilter.size);
await noticeUtil.sendNotice(activityMapFilter, `原神活动剩余时间提醒(仅显示剩余 ≤ ${config.notifyHoursThreshold} 小时的活动):`);
} else {
log.warn("未识别到任何活动,未发送通知");
}
}
this.activityUtil = {
// config,
activityMain,
// OcrRemainingTime,
}