Files
bettergi-scripts-list/repo/js/ActivitySwitchNotice/utils/activity.js
云端客 cac08d4f15 [活动期限/周本通知器] 迭代 0.0.2 版本 (#2552)
* feat(ActivitySwitchNotice): 添加异步通知发送功能

- 新增异步发送通知函数 send,支持标题和内容拼接
- 添加通知发送前的日志记录和条件判断
- 导出新的 send 函数供外部调用
- 保留原有 sendNotice 函数兼容性

* feat(activity): 新增征讨领域次数识别与通知功能

- 实现征讨领域 OCR 识别逻辑,用于获取每周剩余次数
- 添加秘境与征讨领域的点击坐标配置
- 集成通知工具,发送剩余次数提醒
- 支持自动按键进入活动界面并执行点击操作
- 增加星期判断逻辑,跳过周日执行
- 提供字符串与整数解析工具函数,增强配置容错性

* fix(campaignArea): 修复周日不执行秘境征讨提醒的问题

- 将判断条件从等于0修改为不等于0,确保周日可以执行提醒逻辑
- 添加日志记录,便于追踪执行情况和调试
- 保留原有的延迟和按键操作逻辑

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

- 引入 campaignArea.js 工具模块
- 在主流程中调用 toMainUi 函数
- 执行活动区域主逻辑处理
- 整合活动切换与通知机制
- 增强主界面判断逻辑
- 优化异步流程控制

* feat(activity): 实现秘境征讨剩余次数提醒功能

- 添加了每周日自动检查秘境征讨剩余次数的功能
- 实现了通过OCR识别剩余次数的逻辑
- 集成了日志记录和通知发送机制
- 添加了完整的操作延迟和点击坐标配置
- 实现了热键触发和界面点击的自动化流程
- 增加了详细的函数注释和执行日志

* feat(campaignArea): 更新征讨领域坐标并优化OCR识别逻辑

- 调整征讨领域点击坐标为{x: 493, y: 537}
- 新增ocrWeeklyCount函数用于OCR识别周计数信息
- 增强OCR识别后的文本处理与日志记录
- 修复周日判断逻辑,确保仅在周日执行特定操作
- 调整主流程顺序,先执行征讨领域再返回主界面

* refactor(campaignArea): 优化星期名称获取逻辑

- 提取星期名称到独立变量以提高可读性
- 更新日志记录以使用新的变量名
- 确保返回对象中的星期名称正确引用新变量

* feat(ActivitySwitchNotice): 新增征讨领域每周提醒功能

- 在 README 中新增“每周日自动提醒征讨领域剩余次数”特性说明
- 更新 settings.json 配置项表格,增加 toTopCount、scrollPageCount 和 campaignAreaKey 参数
- 新增 campaignArea.js 模块,实现 OCR 识别与周日提醒逻辑
- 调整目录结构说明,加入 campaignArea.js 文件介绍
- 修改工作原理部分,补充征讨领域提醒的执行流程
- 统一 README 中代码引用格式为反引号包裹

* docs(ActivitySwitchNotice): 更新 README 版本历史记录

- 新增 0.0.2 版本的征讨领域周次数提醒功能
- 新增 campaignArea.js 模块及相关配置选项
- 改进 滚动到顶部功能的稳定性并新增相关配置
- 新增 0.1 版本的活动检测、OCR识别及通知功能
- 新增 多种智能解析与防重复检测机制
- 新增 异常处理和错误恢复机制

* fix(ActivitySwitchNotice): 调整日志级别与周日判断逻辑

- 将 info 级别日志调整为 debug 级别
- 修正周日判断条件,确保仅周日执行提醒
- 增强周日判断日志描述
- 更新剩余次数提示文案,明确显示“本周剩余消耗减半次数”
- 在通知消息前添加 Markdown 格式符号 `>` 以突出显示

* refactor(campaignArea): 将日志级别从 info 调整为 debug

- 修改日志记录方式,将 info 级别调整为 debug
- 减少生产环境中的日志噪音
- 提高调试信息的可读性与准确性

* feat(ActivitySwitchNotice): 新增征讨领域模块和配置选项

- 新增 campaignArea.js 模块,包含征讨领域相关功能
- 新增 campaignAreaKey 配置选项,用于自定义征讨领域页面快捷键
- 改进增强滚动到顶部功能的稳定性
- 新增 toTopCount 和 scrollPageCount 配置选项,提供更多滚动控制参数
- 新增活动期限检测与通知功能
- 新增 OCR 识别活动列表和剩余时间功能

* docs: 更新活动通知器功能说明

* feat(ActivitySwitchNotice): 支持征讨领域周次数提醒功能

- 更新插件名称以明确支持活动期限与周本提醒
- 提升版本号至 0.0.2
- 新增 campaignAreaKey 配置项用于自定义征讨领域页面快捷键
- 在 README 中更新 campaignAreaKey 的使用状态为启用
- 更新版本历史记录日期及新增功能说明
- 新增 campaignArea.js 模块实现相关功能逻辑

* feat(ActivitySwitchNotice): 支持自定义征讨领域提醒日

- 新增配置项 campaignAreaReminderDay,用于设置提醒日期
- 修改判断逻辑,使用配置的提醒日替代固定周日判断
- 添加相关注释说明配置用途

* feat(settings): 添加周本提醒日设置选项

- 在设置中新增周本提醒日选择器
- 支持设置提醒日为周日至周六任意一天
- 默认值设为周日
- 保留原有冒险之证按键设置功能

* docs: 更新文档,新增征讨领域提醒日配置选项说明

* fix: 修改周本提醒日配置值为字符串格式

* feat(activity): 添加活动描述字段支持

- 在活动映射中新增 desc 字段,默认值为 null
- 更新通知文本生成逻辑,支持显示活动描述信息
- 优化剩余时间文本格式,增强可读性
- 保持现有功能兼容性,不影响无描述场景显示

* feat(activity): 增加活动时间转换和OCR功能

- 添加日期枚举类型DATE_ENUM及反向映射方法
- 新增活动周期转换映射表activityTermConversionMap
- 新增特定活动OCR内容映射表needOcrOtherMap
- 实现根据活动名称获取日期枚举值的函数getDATE_ENUM
- 添加将总小时数转换为周/天/小时格式的函数convertHoursToWeeksDaysHours
- 在活动时间处理中增加对不同时间单位的支持
- 增加对特定活动额外OCR识别内容的支持
- 修复数组遍历时的缩进问题

* feat(activity): 更新活动时间显示逻辑

- 修改"砺行修远"活动的时间枚举为周
- 调整剩余时间文本的显示格式
- 优化通知文本的排版和分隔符
- 增强日期枚举获取函数的返回值结构
- 添加调试日志用于追踪活动时间和枚举值
- 改进OCR识别时间的显示方式

* feat(ActivitySwitchNotice): 添加黑名单活动名称过滤功能

- 在配置中新增 blackActivityNameList 字段,支持通过 | 分割多个活动名称
- 实现活动黑名单过滤逻辑,排除黑名单中的活动名称
- 更新设置界面,增加黑名单活动名称输入框
- 完善活动筛选流程,优先过滤黑名单活动再判断剩余时间阈值

* feat(ActivitySwitchNotice): 新增活动黑名单过滤功能

- 在 settings.json 中新增 blackActivityNameList 配置项
- 支持通过黑名单排除不关心的活动提醒
- 更新文档说明,添加黑名单使用示例
- 增强活动过滤逻辑,提高匹配准确性
- 在核心扫描流程中集成黑名单过滤机制
- 优化通知显示格式,增加活动描述信息
- 修复若干已知问题,提升脚本稳定性

* fix(activity): 修复活动过滤逻辑

- 修改黑名单活动名称过滤方式,从完全匹配改为包含匹配
- 确保活动名称中包含黑名单关键词时能被正确过滤
- 保持小时数阈值过滤逻辑不变
- 维持扫描完成后统一发送通知的机制

* fix(activity): 修复活动黑名单过滤逻辑及通知文本

- 修正黑名单关键词过滤条件判断
- 优化通知消息文本格式,增加黑名单提示信息

* feat(activity): 支持多个OCR识别键值

- 修改needOcrOtherMap结构以支持数组形式的键值
- 更新OCR识别逻辑以遍历多个键值并拼接结果
- 为"砺行修远"活动添加"完成进度"作为新的OCR识别目标

* fix(activity): 修复OCR时间和活动过滤逻辑

- 修复OCR剩余时间函数调用参数错误,从keys改为key
- 优化活动黑名单过滤逻辑,提高过滤准确性
- 增强活动名称关键字匹配的判断条件
- 修复过滤器提前返回导致的逻辑中断问题

* fix(ActivitySwitchNotice): 优化活动黑名单过滤和日期枚举匹配逻辑

- 黑名单活动名称过滤时增加去除空字符串逻辑
- 日期枚举匹配改为模糊包含匹配,提升识别准确率
- 修复黑名单提示条件判断错误导致的消息格式问题

* docs: 更新活动模块文档,添加配置项说明

* feat(activity): 添加黑名单活动名称过滤功能

- 在活动点击前增加黑名单关键词匹配逻辑
- 跳过匹配黑名单的活动,避免无效点击
- 移除原有冗余的活动过滤逻辑
- 优化活动重复点击判断流程

* style(docs): 格式化 README.md 中的表格样式

- 调整表格列对齐方式,使用冒号对齐格式
- 统一表格分隔符的格式和间距
- 修复表格列宽和对齐问题
- 优化表格的视觉呈现效果
2025-12-23 18:23:58 +08:00

529 lines
21 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天)
blackActivityNameList: (settings.blackActivityNameList ? settings.blackActivityNameList.split('|').filter(s => s.trim()) : []),//黑名单活动名称
}
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 DATE_ENUM = Object.freeze({
YEAR: '年',
MON: '月',
WEEK: '周',
DAY: '天',
HOUR: '小时',
// 添加反向映射(可选)
fromValue(value) {
return Object.keys(this).find(key => this[key] === value);
}
});
const activityTermConversionMap = new Map([
["砺行修远", {dateEnum: DATE_ENUM.WEEK}],
]);
const needOcrOtherMap = new Map([
["砺行修远", ["本周进度", "完成进度"]],
]);
const genshinJson = {
width: 1920,//genshin.width,
height: 1080,//genshin.height,
}
function getDATE_ENUM(activityName) {
for (let key of activityTermConversionMap.keys()) {
if (activityName.includes(key))
return activityTermConversionMap.get(key)
}
return {dateEnum: DATE_ENUM.HOUR}
}
/**
* 滚动页面的异步函数
* @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 + '小时' : ''}`;
}
/**
* 将总小时数转换为周、天和小时的组合表示
* @param {number} totalHours - 需要转换的总小时数
* @returns {string} 返回格式为"X周 Y天 Z小时"的字符串
*/
function convertHoursToWeeksDaysHours(totalHours) {
// 1周 = 168小时 (7 * 24)
const hoursPerWeek = 168;
const hoursPerDay = 24;
// 计算整周数 - 使用Math.floor获取完整的周数
const weeks = Math.floor(totalHours / hoursPerWeek);
// 剩余小时 - 总小时数减去完整周数对应的小时数
let remainingHours = totalHours % hoursPerWeek;
// 从剩余小时中计算天数 - 使用Math.floor获取完整的天数
const days = Math.floor(remainingHours / hoursPerDay);
// 剩余的小时 - 剩余小时数减去完整天数对应的小时数
const hours = remainingHours % hoursPerDay;
// 返回格式化后的字符串,包含周、天和小时
return `${weeks}${days}${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; // 不关心的活动,跳过不点击
}
}
if (config.blackActivityNameList.length > 0) {
const matched = config.blackActivityNameList.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 += '<即将结束>'
}
let desc = ""
let dateEnum = getDATE_ENUM(activityName);
log.debug(`activityName:{activityName},dateEnum{dateenum.dateEnum}`, activityName, dateEnum.dateEnum)
switch (dateEnum.dateEnum) {
case DATE_ENUM.WEEK:
desc += "|==>" + convertHoursToWeeksDaysHours(totalHours) + "<==|";
break;
case DATE_ENUM.HOUR:
break;
default:
break;
}
if (needOcrOtherMap.has(activityName)) {
const keys = needOcrOtherMap.get(activityName);
for (const key of keys) {
let text = await OcrRemainingTime(activityName, key);
if (text) {
remainingTimeText += " [" + text + "] "
}
}
}
activityMap.set(activityName, {
text: remainingTimeText,
hours: totalHours,
desc: desc
});
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} 小时的活动)${config.blackActivityNameList.length <= 0 ? "" : "|==>已开启黑名单:" + config.blackActivityNameList.join(",") + "<==|"}`);
} else {
log.warn("未识别到任何活动,未发送通知");
}
}
this.activityUtil = {
// config,
activityMain,
// OcrRemainingTime,
}