【0.0.5】活动期限/周本通知器 (#2618)

* feat(ActivitySwitchNotice): 支持解析包含分钟的时间文本

- 添加对分钟单位的解析支持,使用正则表达式匹配"分钟"
- 将天数、小时数、分钟数都改为浮点数解析以支持小数
- 添加数值非负验证,确保解析结果不为负数
- 将分钟转换为小时进行统一计算
- 对结果进行四舍五入取整
- 修复OCR识别结果不完整的问题,自动补全"小时"和"分钟"单位

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

- 新增 parseWhiteActivity 和 parseBlackActivity 函数,支持解析黑白名单格式
- 实现黑名单条件匹配机制,支持活动名-条件1,条件2的语法格式
- 添加 getMapByKey 函数支持反向匹配功能
- 更新配置初始化流程,添加 init 函数处理黑名单配置
- 改进活动过滤逻辑,支持条件匹配检查
- 更新 README.md 文档说明新的黑名单条件语法
- 修改设置项名称从 blackActivityNameList 到 blackActivity
- 优化配置项标签说明,添加条件语法使用说明
- 添加版本历史记录 0.0.5 版本更新内容

* refactor: 重构黑名单过滤机制,新增条件匹配功能

* feat(ActivitySwitchNotice): 添加manifest.json读取和版本日志功能

- 添加manifest.json文件读取功能
- 将初始化日志从info级别调整为debug级别
- 在启动时输出版本信息日志
- 初始化manifest变量以存储应用清单数据

* chore(docs): 更新版本发布日期

- 修正版本 0.0.5 的发布日期从 2026-01-03 为 2026-01-04

* chore(ActivitySwitchNotice): 更新版本号并清理manifest配置

- 将版本号从 0.0.4 更新到 0.0.5
- 移除了 http_allowed_urls 中多余的逗号,修复了JSON格式
- 保持了原有的依赖配置和URL访问权限设置

* fix(ActivitySwitchNotice): 修复OCR键函数调用参数缺失问题

- 修复OcrKey函数调用时缺少activityName参数的问题
- 确保黑名单条件检查时传递正确的活动名称参数
- 解决因参数缺失可能导致的条件匹配错误

* docs(ActivitySwitchNotice): 更新README文档添加逻辑流程图

- 添加了详细的逻辑流程说明
- 使用mermaid图表展示组件间交互流程
- 补充了配置初始化到活动过滤的完整流程
- 说明了黑名单匹配和OCR校验的具体逻辑
- 更新了用户使用指南章节结构

* docs(ActivitySwitchNotice): 更新活动过滤器流程图文档

- 修正了活动过滤器流程图中的条件判断逻辑描述
- 更新了黑名单匹配条件的处理流程说明
This commit is contained in:
云端客
2026-01-04 11:04:59 +08:00
committed by GitHub
parent 36c8687b79
commit 37ff6a6e5b
5 changed files with 250 additions and 44 deletions

View File

@@ -7,21 +7,71 @@ function settingsParseInt(str, defaultValue) {
}
}
const config = {
/**
* 解析白名单活动文本,将其转换为活动列表
* @param {string} text - 包含活动信息的文本,多个活动用'|'分隔
* @param {Array} defaultList - 当text为空时的默认返回列表默认为空数组
* @returns {Array} 解析后的活动列表,去除空字符串并过滤空白项
*/
function parseWhiteActivity(text, defaultList = []) {
return (text ? text.split('|').filter(s => s.trim()) : defaultList)
}
/**
* 解析黑名单活动文本
* @param {string} text - 包含活动信息的文本,使用'|'分隔多个活动,使用'-'分隔活动名称和条件,使用','分隔多个条件
* @param {Array<string>} excludeList - 需要排除的活动名称列表
* @returns {Map<string, Array<string>>|undefined} 解析后的活动映射键为活动名称值为条件数组如果输入文本为空则返回undefined
*/
function parseBlackActivity(text, excludeList = []) {
if (!text) return undefined;
const result = new Map();
// 使用'|'分割文本为多个活动元素
const splitList = text.split('|');
for (let element of splitList) {
element = element.trim(); // 清理空白字符
if (!element) continue; // 跳过空元素
// 使用'-'分割活动元素的名称和条件
const elementList = element.split('-');
const activityName = elementList[0].trim();
if (!activityName) continue; // 跳过没有名称的活动
// 检查是否在排除列表中
if (excludeList.includes(activityName)) {
continue;
}
// 解析条件如果有条件部分length > 1则分割条件否则为空数组
const conditions = elementList.length > 1
? Array.from(elementList[1].split(',').map(item => item.trim()))
: [];
log.debug(`parseBlackActivity: ${activityName} - ${conditions}`)
// 将解析后的活动对象添加到结果中
result.set(activityName, conditions);
}
return result.size > 0 ? result : undefined;
}
let config = {
//剩余时间,白名单 启用`和`关系(默认`与`关系)
relationship: settings.relationship,
whiteActivityNameList: (settings.whiteActivityNameList ? settings.whiteActivityNameList.split('|').filter(s => s.trim()) : []),
whiteActivityNameList: parseWhiteActivity(settings.whiteActivityNameList),
activityKey: (settings.activityKey ? settings.activityKey : 'F5'),
toTopCount: settingsParseInt(settings.toTopCount, 10),//滑动到顶最大尝试次数
scrollPageCount: settingsParseInt(settings.scrollPageCount, 4),//滑动次数/页
notifyHoursThreshold: settingsParseInt(settings.notifyHoursThreshold, 8760),//剩余时间阈值(默认 8760小时=365天)
// 黑名单活动名称列表,这些活动将被排除在识别和处理之外
// 通过 | 分隔多个活动名称,并过滤掉空白项
blackActivityMap: parseBlackActivity(settings.blackActivity, parseWhiteActivity(settings.whiteActivityNameList)),
// 同时确保黑名单中的活动名称不包含在白名单whiteActivityNameList
blackActivityNameList: (settings.blackActivityNameList ? settings.blackActivityNameList.split('|').filter(s => s.trim())
.filter(
item => !settings.whiteActivityNameList.split('|').filter(s => s.trim()).some(keyword => item.includes(keyword))
) : []),
blackActivityNameList: [],
}
const ocrRegionConfig = {
activity: {x: 267, y: 197, width: 226, height: 616},//活动识别区域坐标和尺寸
@@ -61,12 +111,17 @@ const genshinJson = {
*
* @param {Map} map - 要搜索的Map对象默认为空Map
* @param {string} key - 用于匹配的键名部分字符串
* @param {boolean} reverseMatch - 开启反向匹配
* @returns {*} 匹配键对应的值如果未找到匹配项则返回undefined
*/
function getMapByKey(map = new Map(), key) {
function getMapByKey(map = new Map(), key, reverseMatch = false) {
// 遍历Map的所有键名查找包含指定key的键
log.debug('Map=>size:{size}', map.size)
for (let keyName of map.keys()) {
if (keyName.includes(key)) {
log.debug('Map=>key:{key},keyName:{keyName},value:{value},ok:{ok}', key, keyName, JSON.stringify(map.get(keyName)), keyName.includes(key))
if (keyName.includes(key) && !reverseMatch) {
return map.get(keyName)
} else if (key.includes(keyName) && reverseMatch) {
return map.get(keyName)
}
}
@@ -131,21 +186,26 @@ async function scrollPage(totalDistance, isUp = false, waitCount = 6, stepDistan
await sleep(ms);
}
/**
* 根据活动状态 进行页面滚动
* @param {boolean} isUp - 是否向上滚动,默认为false
* 根据活动页面进行滚动操作
* @param {boolean} isUp - 滚动方向true表示向上滚动false表示向下滚动
* @param {number} total - 滚动总量
* @param {number} waitCount - 等待次数
* @param {number} stepDistance - 每次滚动的步长距离
* @param {number} scrollPageCount - 滚动页面次数默认从config中获取
*/
async function scrollPagesByActivity(isUp = false, total = 90, waitCount = 6, stepDistance = 30) {
async function scrollPagesByActivity(isUp = false, total = 90, waitCount = 6, stepDistance = 30, scrollPageCount = config.scrollPageCount) {
// 根据滚动方向设置坐标位置
// 如果是向上滚动,使用顶部坐标;否则使用底部坐标
let x = isUp ? xyConfig.top.x : xyConfig.bottom.x
let y = isUp ? xyConfig.top.y : xyConfig.bottom.y
let x = isUp ? xyConfig.top.x : xyConfig.bottom.x; // 根据滚动方向获取x坐标
let y = isUp ? xyConfig.top.y : xyConfig.bottom.y; // 根据滚动方向获取y坐标
// 记录滑动方向
log.info(`活动页面-${isUp ? '向上' : '向下'}滑动`)
log.info(`活动页面-${isUp ? '向上' : '向下'}滑动`);
// 注释:坐标信息已注释掉,避免日志过多
// log.info(`坐标:${x},${y}`)
// log.info(`坐标:${x},${y}`);
// 根据配置的滑动次数执行循环
for (let i = 0; i < config.scrollPageCount; i++) {
for (let i = 0; i < scrollPageCount; i++) {
// 移动到坐标位置
await moveMouseTo(x, y)
//80 18次滑动偏移量 46次测试未发现偏移
@@ -160,7 +220,7 @@ async function scrollPagesByActivity(isUp = false, total = 90, waitCount = 6, st
* @throws {Error} 如果超过最大尝试次数仍未检测到稳定顶部,则抛出错误
*/
async function scrollPagesByActivityToTop(ocrRegion = ocrRegionConfig.activity) {
let ms = 800
let ms = 800; // 等待时间,单位毫秒
let topActivityName = null; // 上一次检测到的顶部活动名称
let sameTopCount = 0; // 连续出现相同顶部名称的次数
const requiredSameCount = 1; // 需要连续几次相同才确认到顶(推荐 2~3
@@ -197,7 +257,7 @@ async function scrollPagesByActivityToTop(ocrRegion = ocrRegionConfig.activity)
log.warn("顶部OCR未识别到任何活动条目可能是页面为空或识别失败");
// 再尝试一次向上滚大距离
// await scrollPagesByActivity(true); // true = 向上
await scrollPagesByActivity(true, 80 * 4, 6, 60);
await scrollPagesByActivity(true, 80 * 4, 6, 60, 1);
await sleep(ms);
continue;
}
@@ -225,8 +285,7 @@ async function scrollPagesByActivityToTop(ocrRegion = ocrRegionConfig.activity)
// 未达到稳定状态,继续向上滚动一页(可根据实际情况调整滚动距离)
// 这里使用更大滚动距离确保能快速回顶
// await scrollPagesByActivity(true); // true = 向上
// 可选:加大单次滚动量(如果你发现默认一页不够)
await scrollPagesByActivity(true, 80 * 4, 6, 60);
await scrollPagesByActivity(true, 80 * 4, 6, 60, 1);
await sleep(ms); // 给页面滚动和渲染留时间
} finally {
@@ -260,24 +319,34 @@ function parseRemainingTimeToHours(timeText) {
return 0;
}
// 提取数字和单位(支持中英文冒号、空格等)
const dayMatch = timeText.match(/(\d+)\s*天/);
const hourMatch = timeText.match(/(\d+)\s*小时/);
let days = 0;
let hours = 0;
let minutes = 0;
// 如果上面的复杂正则有问题,可以使用原来的简化版本
const dayMatch = timeText.match(/(\d+(?:\.\d+)?)\s*天/);
const hourMatch = timeText.match(/(\d+(?:\.\d+)?)\s*小时/);
const minuteMatch = timeText.match(/(\d+(?:\.\d+)?)\s*分钟/);
if (dayMatch) {
days = parseInt(dayMatch[1], 10);
days = parseFloat(dayMatch[1]);
}
if (hourMatch) {
hours = parseInt(hourMatch[1], 10);
hours = parseFloat(hourMatch[1]);
}
if (minuteMatch) {
minutes = parseFloat(minuteMatch[1]);
}
// 天数转小时 + 原有小时
const totalHours = days * 24 + hours;
// 确保数值非负
days = Math.max(0, days);
hours = Math.max(0, hours);
minutes = Math.max(0, minutes);
return totalHours;
// 将分钟转换为小时
const totalHours = days * 24 + hours + minutes / 60;
return Math.round(totalHours); // 四舍五入到整数
}
/**
@@ -359,11 +428,20 @@ async function OcrKey(activityName, key = "剩余时间", ocrRegion = ocrRegionC
}
}
async function init() {
log.debug(`[init-config]-[{config}]`, JSON.stringify(config));
let blackActivityMap = config.blackActivityMap
config.blackActivityNameList = blackActivityMap ? Array.from(blackActivityMap.keys()) : [];
config.blackActivityNameList.length > 0 && log.debug(`[init]-[{blackActivityNameList}]`, config.blackActivityNameList.join('|'));
log.debug(`[init]-[{ket}]`, 'activity');
log.debug(`[init-config-end]-[{config}]`, JSON.stringify(config));
}
/**
* 活动主函数:扫描所有活动页面,识别剩余时间,最后统一发送通知
*/
async function activityMain() {
await init();
const ms = 1000;
await sleep(ms);
@@ -454,13 +532,42 @@ async function activityMain() {
}
}
// 检查当前活动名称是否在黑名单中
if (config.blackActivityNameList.length > 0) {
const matched = config.blackActivityNameList.some(keyword => activityName.includes(keyword));
let matched = config.blackActivityNameList.some(keyword => activityName.includes(keyword));
if (matched) {
continue; // 不关心的活动,跳过不点击
// 获取黑名单活动的条件配置
let blackActivityConditions = getMapByKey(config.blackActivityMap, activityName,true);
log.info(`[黑名单条件检测]blackActivityMap:{blackActivityMap},activityName:{activityName},blackActivityConditions:{blackActivityConditions}`,
config.blackActivityMap, activityName, blackActivityConditions);
if (blackActivityConditions && blackActivityConditions.length > 0) {
log.debug('[黑名单条件检测开始]')
matched = false;
// 遍历所有条件,检查是否满足黑名单条件
for (const blackActivityCondition of blackActivityConditions) {
try {
let condition = await OcrKey(activityName,blackActivityCondition);
if (condition) {
log.info(`满足黑名单条件==>{ac}->{ba}`, activityName, blackActivityCondition);
matched = true;
break;
}
} catch (error) {
log.error(`检查黑名单条件时发生错误: ${error.message}`, error);
// 继续检查下一个条件,不中断整个流程
continue;
}
}
}
// 如果匹配到黑名单活动,则跳过不点击
if (matched) {
continue; // 不关心的活动,跳过不点击
}
}
}
// 避免重复点击同一个活动(防止 OCR 误识别或页面抖动)
if (activityMap.has(activityName)) {
log.info(`活动已记录,跳过重复点击: ${activityName}`);
@@ -470,6 +577,11 @@ async function activityMain() {
let remainingTimeText = await OcrKey(activityName);
if (remainingTimeText) {
if (remainingTimeText.endsWith('小')) {
remainingTimeText += '时'
} else if (remainingTimeText.endsWith('分')) {
remainingTimeText += '钟'
}
const totalHours = parseRemainingTimeToHours(remainingTimeText);
if (totalHours <= 24 && totalHours > 0) {
remainingTimeText += '<即将结束>'
@@ -580,7 +692,15 @@ async function activityMain() {
let blackText = "";
if (config.blackActivityNameList.length > 0) {
blackText += `|==>已开启黑名单: ${config.blackActivityNameList.join(",")}<==|`
let blackAllText = []
for (let en of config.blackActivityNameList) {
let configTextList = config.blackActivityMap.get(en)
if (configTextList) {
en += (configTextList.length > 0 ? "-" : "") + configTextList.join(',')
blackAllText.push(en)
}
}
blackText += `==>{已开启黑名单: ${blackAllText.join("|")}}<==`
}
await noticeUtil.sendNotice(activityMapFilter, `原神活动剩余时间提醒(仅显示 ${titleKey} 的活动)${blackText}`);