diff --git a/repo/js/CD-Aware-AutoGather/CooldownData.txt b/repo/js/CD-Aware-AutoGather/CooldownData.txt
deleted file mode 100644
index d55957b5e..000000000
--- a/repo/js/CD-Aware-AutoGather/CooldownData.txt
+++ /dev/null
@@ -1,69 +0,0 @@
-沉玉仙茗: 24小时
-发光髓: 12小时
-蝴蝶翅膀: 12小时
-晶核: 12小时
-鳗肉: 12小时
-螃蟹: 12小时
-禽肉: 12小时
-青蛙: 12小时
-鳅鳅宝玉: 12小时
-神秘的肉: 12小时
-兽肉: 12小时
-「冷鲜肉」: 12小时
-蜥蜴尾巴: 12小时
-鱼肉: 12小时
-白萝卜: 每天0点
-薄荷: 每天0点
-澄晶实: 每天0点
-墩墩桃: 每天0点
-海草: 每天0点
-红果果菇: 每天0点
-胡萝卜: 每天0点
-金鱼草: 每天0点
-堇瓜: 每天0点
-烬芯花: 每天0点
-久雨莲: 每天0点
-颗粒果: 每天0点
-苦种: 每天0点
-莲蓬: 每天0点
-烈焰花花蕊: 每天0点
-马尾: 每天0点
-蘑菇: 每天0点
-茉洁草: 每天0点
-鸟蛋: 每天0点
-泡泡桔: 每天0点
-苹果: 每天0点
-日落果: 每天0点
-树莓: 每天0点
-松果: 每天0点
-松茸: 每天0点
-甜甜花: 每天0点
-汐藻: 每天0点
-香辛果: 每天0点
-星蕈: 每天0点
-须弥蔷薇: 每天0点
-枣椰: 每天0点
-竹笋: 每天0点
-烛伞蘑菇: 每天0点
-宿影花: 每天0点
-寒涌石: 每天0点
-白灵果: 每天0点
-夏槲果: 每天0点
-沉玉仙茗: 24小时
-晶蝶: 每天4点
-
-铁块: 每天0点
-白铁块: 每2天0点
-电气水晶: 每2天0点
-星银矿石: 每2天0点
-萃凝晶: 每3天0点
-水晶块: 每3天0点
-紫晶块: 每3天0点
-虹滴晶: 每3天0点
-奇异的「牙齿」: 46小时
-冰雾花花朵: 46小时
-冰雾花: 46小时
-烈焰花花蕊: 46小时
-烈焰花: 46小时
-
-地方特产: 46小时
\ No newline at end of file
diff --git a/repo/js/CD-Aware-AutoGather/README.md b/repo/js/CD-Aware-AutoGather/README.md
index eba769021..f677bcbe1 100644
--- a/repo/js/CD-Aware-AutoGather/README.md
+++ b/repo/js/CD-Aware-AutoGather/README.md
@@ -1,8 +1,21 @@
+**由于使用了尚处于测试版BetterGI中的API,使用稳定版BetterGI的用户请等待`0.54.1`或更高的版本发布后再订阅此脚本**
+
+# 功能特点
+
+- 自动同步你订阅的地图追踪任务,无需手动复制文件夹
+- 自动匹配各类材料的CD,支持联机模式(会识别是在谁的世界进行采集)
+- 可识别背包物品数量,并按设定的目标数量按需运行
+- 方便的材料选择界面,并支持按地区、按类别批量选择
+- 可设置一个或多个不运行的时间段
+- 采集过程自动切换合适的队伍
+
# 使用前准备
+(在脚本仓库页面阅读此文档,会比在BGI的已订阅脚本界面获得更好的渲染效果)
+
**双击运行脚本所在目录下的`SymLink.bat`文件,以创建符号链接。**
-(脚本所在目录为:你的BetterGI安装位置下面的`repo\js\CD-Aware-AutoGather`文件夹)
+(在BGI的`全自动——JS脚本`界面,右键点击本脚本,选择`打开目录`可快速打开脚本所在目录)
此操作只需要一次。运行后,脚本下的`pathing`文件夹将指向Better GI的地图追踪文件夹`User\AutoPathing`,你通过Better GI新增或删除地图追踪任务后,脚本这边看到的也是修改后的。
@@ -12,46 +25,152 @@
## 1. 扫描文件夹更新可选材料列表
-扫描脚本`pathing`目录(等价于你在Better GI里订阅的地图追踪任务目录)下的`地方特产`、`矿物`、`食材与炼金`内的材料,并自动匹配材料的刷新时间。
+扫描脚本`pathing`目录(等价于你在Better GI里订阅的地图追踪任务目录)下的`地方特产`、`矿物`、`食材与炼金`内的材料,并更新脚本可用的配置菜单。
-扫描完成后,将自动更新脚本可用的配置菜单。此时再次打开右键的`修改JS脚本自定义配置`,将看到新增了多个配置项,其中包含刚刚扫描到的材料目录。
+运行后,再次打开右键的`修改JS脚本自定义配置`,将看到新增了多个配置项。
-
-
-如果你订阅了很多地图追踪任务,那么扫描结果也会比较多,选项列表也会比较长,但不影响脚本运行。
-
-只有你新增或者删除了地图追踪任务的订阅时,才需要运行此模式。
+完成扫描后会自动设置下次的脚本运行模式为`采集选中的材料`。
## 2. 采集选中的材料
-此模式下有这些选项可以配置:
-
-| 选项 | 说明 |
-| ---- | ---- |
-| `地图追踪`中已订阅的任务目录的处理方式 | 有三种处理方式:
- `每次自动扫描,并采集扫描到的所有材料`
- `手动扫描,并采集扫描到的所有材料`
- `手动扫描,只采集已勾选的材料`
`自动扫描`会在每次执行采集前扫描当前已订阅的任务目录,`手动扫描`则是手动将运行模式切换到扫描模式执行脚本进行扫描。
`采集所有`会无视后面的每个材料⬇️是否选中,`采集已勾选`则是根据勾选的列表进行采集 |
-| 设置首选队伍名称 | 执行采集任务前切换到指定的队伍,未设置则不切换。 |
-| 设置备选队伍名称 | 首选队伍缺少对应的采集角色时使用。
两支队伍的名称不要存在包含关系,例如不能一支叫`特产`一支叫`特产备选` |
-| 停止运行时间 | 超过此时间后,停止后续的任务(会等待正在运行的那条json路线结束)。 |
-| 我肝的账号不止一个 | 如果你有多个账号,可在输入框内为每个游戏帐号填写一个唯一的字符串以使他们的刷新时间分开保存,互不干扰。这个唯一的字符串并不必须是帐号的UID,只要互不相同就可以了。
如果填空,会自动使用原神中右下角UID作为区分账号的唯一字符串 |
-| 即使同一种材料有多个版本的路线,也全都执行采集 | 如果某种材料选中了多个版本的路线(常见于不同作者),默认只会执行第一个。勾选此选项后会每个版本都执行,可能造成部分点位重复(空跑)。 |
-| `↓` 地方特产\稻妻\绯樱绣球 | 根据你订阅的路径追踪任务数量,这里将会显示相应个数的选择框。
勾选后将执行你选中的条目的采集任务。
Tip: `↓`符号是在提示你应该勾选文本下面的选择框 |
-
-**推荐的配置**: 运行模式设置为`采集选中的材料`,已订阅的任务目录的处理方式设置为`每次自动扫描,并采集扫描到的所有材料`。这样一来,待采集的材料列表自动保持与你订阅的列表一致,新增/删除材料也直接通过BetterGI的界面执行,每次直接运行脚本即可。
-
运行此模式后,将按照你勾选的条目,执行相应的采集任务。每执行完一条json路线后,将会计算它的下次刷新时间并写入`record`文件夹下的记录文件。下次运行脚本时,未刷新的路线将自动跳过。
+采集过程中,如果所有材料均达到了你设置的目标数量,或者到达你设置的不执行时间段,会自动停止运行。
+
可以同时勾选多种材料,会逐个进行采集。
+此模式下有这些选项可以配置:
+
+### 基本配置
+
+| 选项 | 说明 |
+| ---- | ---- |
+| 按关键词筛选材料路线 | 默认为黑名单模式。此时,文件路径中含有任意一个关键词的路线,在扫描时会被忽略。其他模式见后文 |
+| 设置首选队伍名称 | 执行采集任务前切换到指定的队伍,未设置则不切换。 |
+| 设置备选队伍名称 | 首选队伍缺少对应的采集角色时使用。
两支队伍的名称不要存在包含关系,例如不能一支叫`特产`一支叫`特产备选` |
+| 不在以下时间段内运行 | 字面意思。如果脚本启动时间在设定的时间段内,会立即终止;如果运行过程中进入设定的时间段内,也会停止后续路线。支持设置多个时间段,用空格分隔即可,比如`05:20-09:28 21:28-00:20` |
+| 物品采集数量目标 | 填写目标数量,留空表示不限制。也可以设置为`csv`,将按csv文件的配置详细设定不同材料的目标,具体见后文 |
+| 使用指定的账号名保存运行记录 | 留空时,将自动识别UID,将其作为账号名记录材料刷新时间。你也可以主动填写账号名称(不推荐),会导致联机互采时(例如大小号)无法正确维护材料刷新时间,建议仅在你遇到脚本无法稳定识别你的UID时填写 |
+
+### 材料采集配置
+
+这部分配置分为4个大类,根据你订阅的路线,可能只会出现其中的部分配置。
+
+为了方便用户查找:
+
+- “地区”类型的选项,是按照在游戏中开放的顺序排序的。
+- 每个小类别下的选项,是按照拼音的先后顺序排序的。
+
+**【常用采集选项】**
+
+开发了按类别、按地区批量选择的功能,并和常用材料汇集到了一起以方便使用,如图所示。
+
+`按大类选择`和`按地区选择地方特产`都是批量选择。批量选择和后面的具体项选择可以结合使用,实现灵活的采集目标设定。
+
+比如勾选 `按大类选择`的矿物 + `按地区选择地方特产`里的蒙德 + `单独选择地方特产`下的稻妻地区的绯樱绣球。
+
+
+
+**【单独选择地方特产】**
+
+
+
+**【食材与炼金】**
+
+
+
+**【对于具有多版本路线的物品,选择要使用的路线】**
+
+如果你为某种材料订阅了多个版本的路线,可以在这里进行选择其中的一个或多个选项。
+
+如果你没有指定路线,会默认执行第一项的路线。
+
+
+
+### 队伍配置推荐
+
采集任务可能用到的元素共有`火水雷风`4种,此外还有挖矿类(如钟离)以及纳西妲两个类型,可以考虑建立两支队伍`钟纳水雷`和`钟纳火风`,即可满足所有采集任务的需要。
-支持使用配置组`更多功能`——`日志分析`分析运行记录(参考了[mno](https://github.com/Bedrockx)大佬的写法)。
+配置好两支队伍后,脚本会在执行路线前计算所需角色,如果当前队伍不满足要求,会自动切换到另一支队伍。
-
+注意:队伍切换功能在联机模式下无法执行,因此联机采集时请先调整好队伍中的角色。
-## 3. 清除运行记录(重置材料刷新时间)
+# 进阶用法
-此模式下,相关的选项只有`我肝的账号不止一个`和以`↓`开头的任务名,作用同上文。
+### 1. 不想每次订阅材料路线后都要在脚本设置中再勾选,怎么办?
-运行此模式后,将重置你选中的任务相应材料的刷新时间。
+利用好`按大类选择`选项。你可以勾选上全部三个大类(或者你实际需要的类别),然后,只要在BGI本体里订阅路线即可。
-如果你需要删除全部运行记录,可以直接删除脚本`record`文件夹下,以账号为名称的文件夹内的文件。
+### 2. 怎么清除运行记录(重置材料刷新时间)?
+
+运行记录保存在脚本所在路径的`record`文件夹中,以UID(或者用户指定的名字)作为账号文件夹。打开子文件夹后,删除对应材料的txt文件即可。
+
+### 3. `按关键词筛选材料路线`的进阶用法
+
+如前面配置描述中所述,此选项默认以黑名单模式运行,设置的关键词即为黑名单,在扫描时会被忽略。可以在选项的开头加上`include:`,将切换为以白名单模式运行,例如`include:无草神`将只扫描含有"无草神"字样的路线。
+
+或者更进阶的,使用`regex:`开头,将完全使用你设置的正则表达式进行筛选。如果你不了解什么是正则表达式,说明你大概率也不需要这种方式。
+
+使用该选项设置的进阶用法时,希望你充分理解了自己的设置会产生什么样的效果。
+
+### 4. 在`物品采集数量目标`选项中,如何为不同物品设置不同的目标?
+
+请将该选项设置为`csv`,然后执行一次材料扫描模式。扫描完成后,在record下找到对应账号的子文件夹。打开这个子文件夹,会找到一个`采集目标.csv`的csv文件。
+
+你可以用Excel或者WPS等打开此csv文件,打开后就是一个表格(如果你了解csv文件格式,也可以用任意文本编辑器打开,不过不推荐)。表格内容示例如下:
+
+| 物品 | 目标数量 |
+| -------------- | ---- |
+| 地方特产 | |
+| 矿物 | |
+| 食材与炼金 | |
+| 地方特产\\璃月 | |
+| 地方特产\\稻妻 | |
+| 矿物\\萃凝晶 | |
+| 矿物\\虹滴晶 | |
+| 食材与炼金\\晶蝶 | |
+| 食材与炼金\\松果 | |
+| 地方特产\\璃月\\夜泊石 | |
+| 地方特产\\璃月\\星螺 | |
+| 地方特产\\稻妻\\绯樱绣球 | |
+| 地方特产\\须弥\\树王圣体菇 | |
+
+默认情况下`目标数量`这一列是空的,填写具体的数字,保存文件后关闭Excel(或者其他你用来编辑csv的工具)。然后,再次运行脚本即可。
+
+**如你所见,这个csv表格也具有和脚本选项中相似的层次关系。 是的,数量是按层次继承的~** 🎉
+
+- 如果低层次的材料没有设置目标,则会自动尝试较高层次所设定的数量
+- 如果向上遍历所有层次都没有目标数量,则表示不限制数量
+- 数量为`0`将不进行采集
+
+利用好脚本设置中的批量选择功能和这个层次关系,可以轻松实现灵活的采集目标。比如:
+
+- 希望背包材料能立即把一个角色拉到90级,于是在`按大类选择`中勾选了地方特产,并将地方特产类别的数量设置为168
+- 但是,又觉得不会再用到树王圣体菇,所以把`地方特产\须弥\树王圣体菇`的数量设置为0
+- 璃月的特产希望多采集一点,那就把`地方特产\璃月`的数量增加了一点到199
+- 还想做用`红炉一点雪`填满背包,那就把对应的特产`地方特产\稻妻\绯樱绣球`设置为9999
+- 想要随时能锻造武器,于是于是在`按大类选择`中勾选了矿物,并将矿物类别的数量设置为50
+- 不想抓晶蝶,随便设个100个晶蝶吧
+- 对了,还有每周参量质变仪要用的薄荷,要不就2倍数量吧,300
+- ……
+
+选好材料和数量,配置好队伍,然后把脚本加到你的每日配置组或一条龙中,从此实现背包自由。
+
+
+# 致谢
+
+此脚本的2.0版本进行了重写,基于新的API重新设计了主要逻辑,以解决此前我想要处理却不能的痛点。在此,
+
+- 诚挚致敬[鸭蛋老师](https://github.com/huiyadanli)和BGI的诸位贡献者,是各位使我的提瓦特之旅得以苟延残喘
+
+- 得益于[FishmanTheMurloc](https://github.com/FishmanTheMurloc)的鼎力相助,脚本才能实现无图的批量材料扫描
+
+- 受惠于[JamisHoo](https://github.com/JamisHoo)的配置界面优化,使得更便捷的材料选择成为可能
+
+- 感谢[this-Fish](https://github.com/this-Fish)的改进,基于坐标判断是否更新记录、将材料是否刷新的检查提前都是沿袭的TA的思路
+
+最后,要特别感谢绫华,是她陪伴了我的提瓦特之旅。在弃坑之后,唯有这份牵挂,支撑着我重新回到这里。
+
+没有她就没有今天的这个脚本。
+
+
diff --git a/repo/js/CD-Aware-AutoGather/assets/avatar_elements.json b/repo/js/CD-Aware-AutoGather/assets/avatar_elements.json
new file mode 100644
index 000000000..af8d6097e
--- /dev/null
+++ b/repo/js/CD-Aware-AutoGather/assets/avatar_elements.json
@@ -0,0 +1,123 @@
+{
+ "冰": [
+ "神里绫华",
+ "丝柯克",
+ "爱可菲",
+ "茜特菈莉",
+ "夏洛蒂",
+ "莱欧斯利",
+ "菲米尼",
+ "米卡",
+ "莱依拉",
+ "申鹤",
+ "埃洛伊",
+ "优菈",
+ "罗莎莉亚",
+ "甘雨",
+ "迪奥娜",
+ "七七",
+ "凯亚",
+ "重云"
+ ],
+ "火": [
+ "杜林",
+ "玛薇卡",
+ "阿蕾奇诺",
+ "嘉明",
+ "夏沃蕾",
+ "林尼",
+ "迪希雅",
+ "托马",
+ "宵宫",
+ "烟绯",
+ "胡桃",
+ "辛焱",
+ "可莉",
+ "迪卢克",
+ "安柏",
+ "班尼特",
+ "香菱"
+ ],
+ "水": [
+ "爱诺",
+ "塔利雅",
+ "玛拉妮",
+ "希格雯",
+ "芙宁娜",
+ "那维莱特",
+ "妮露",
+ "坎蒂丝",
+ "夜兰",
+ "神里绫人",
+ "珊瑚宫心海",
+ "达达利亚",
+ "莫娜",
+ "芭芭拉",
+ "行秋"
+ ],
+ "风": [
+ "雅珂达",
+ "伊法",
+ "梦见月瑞希",
+ "蓝砚",
+ "恰斯卡",
+ "闲云",
+ "琳妮特",
+ "流浪者",
+ "珐露珊",
+ "鹿野院平藏",
+ "早柚",
+ "枫原万叶",
+ "魈",
+ "温迪",
+ "琴",
+ "砂糖"
+ ],
+ "雷": [
+ "菲林斯",
+ "伊涅芙",
+ "瓦雷莎",
+ "伊安珊",
+ "欧洛伦",
+ "克洛琳德",
+ "赛索斯",
+ "赛诺",
+ "多莉",
+ "久岐忍",
+ "八重神子",
+ "雷电将军",
+ "九条裟罗",
+ "刻晴",
+ "菲谢尔",
+ "丽莎",
+ "北斗",
+ "雷泽"
+ ],
+ "草": [
+ "奈芙尔",
+ "菈乌玛",
+ "基尼奇",
+ "艾梅莉埃",
+ "绮良良",
+ "白术",
+ "卡维",
+ "艾尔海森",
+ "瑶瑶",
+ "纳西妲",
+ "提纳里",
+ "柯莱"
+ ],
+ "岩": [
+ "希诺宁",
+ "卡齐娜",
+ "千织",
+ "娜维娅",
+ "云堇",
+ "荒泷一斗",
+ "五郎",
+ "阿贝多",
+ "钟离",
+ "凝光",
+ "诺艾尔"
+ ]
+}
\ No newline at end of file
diff --git a/repo/js/CD-Aware-AutoGather/assets/pic-1.png b/repo/js/CD-Aware-AutoGather/assets/pic-1.png
new file mode 100644
index 000000000..7a9dfde01
Binary files /dev/null and b/repo/js/CD-Aware-AutoGather/assets/pic-1.png differ
diff --git a/repo/js/CD-Aware-AutoGather/assets/pic-2.png b/repo/js/CD-Aware-AutoGather/assets/pic-2.png
new file mode 100644
index 000000000..13454d8b8
Binary files /dev/null and b/repo/js/CD-Aware-AutoGather/assets/pic-2.png differ
diff --git a/repo/js/CD-Aware-AutoGather/assets/pic-3.png b/repo/js/CD-Aware-AutoGather/assets/pic-3.png
new file mode 100644
index 000000000..497ef6e40
Binary files /dev/null and b/repo/js/CD-Aware-AutoGather/assets/pic-3.png differ
diff --git a/repo/js/CD-Aware-AutoGather/assets/pic-4.png b/repo/js/CD-Aware-AutoGather/assets/pic-4.png
new file mode 100644
index 000000000..75745181c
Binary files /dev/null and b/repo/js/CD-Aware-AutoGather/assets/pic-4.png differ
diff --git a/repo/js/CD-Aware-AutoGather/assets/settings.template.json b/repo/js/CD-Aware-AutoGather/assets/settings.template.json
new file mode 100644
index 000000000..e78573190
--- /dev/null
+++ b/repo/js/CD-Aware-AutoGather/assets/settings.template.json
@@ -0,0 +1,95 @@
+[
+ {
+ "name": "runMode",
+ "type": "select",
+ "label": "运行模式",
+ "options": [
+ "扫描文件夹更新可选材料列表",
+ "采集选中的材料"
+ ],
+ "default": "扫描文件夹更新可选材料列表"
+ },
+ {
+ "name": "filterPathByKeywords",
+ "type": "input-text",
+ "label": "按关键词筛选材料路线(使用空格分隔,默认为黑名单模式)",
+ "default": "低成功率 不建议"
+ },
+ {
+ "name": "partyName",
+ "type": "input-text",
+ "label": "设置首选队伍名称(留空则不进行切换)"
+ },
+ {
+ "name": "partyName2nd",
+ "type": "input-text",
+ "label": "设置备选队伍名称(首选队伍缺少对应的采集角色时使用)"
+ },
+ {
+ "name": "excludeTimeRange",
+ "type": "input-text",
+ "label": "不在以下时间段内运行(HH:mm-HH:mm,24小时制):"
+ },
+ {
+ "name": "targetCountOfSelected",
+ "type": "input-text",
+ "label": "物品采集数量目标(可使用csv为不同物品设置不同数量)"
+ },
+ {
+ "name": "manualSetAccountName",
+ "type": "input-text",
+ "label": "使用指定的账号名保存运行记录"
+ },
+ {
+ "type": "separator"
+ },
+ {
+ "label": "\n【常用采集选项】\n\n按大类选择",
+ "type": "multi-checkbox",
+ "name": "selectByCategory",
+ "options": [
+ "地方特产",
+ "矿物",
+ "食材与炼金"
+ ]
+ },
+ {
+ "label": "按地区选择地方特产",
+ "type": "multi-checkbox",
+ "name": "selectLocalSpecialtyByCountry",
+ "options": [
+ "蒙德",
+ "璃月",
+ "稻妻",
+ "须弥",
+ "枫丹",
+ "纳塔",
+ "挪德卡莱"
+ ]
+ },
+ {
+ "label": "矿物",
+ "type": "multi-checkbox",
+ "name": "selectForgingOre",
+ "options": [
+ "白铁块",
+ "水晶块",
+ "星银矿石",
+ "紫晶块",
+ "萃凝晶",
+ "虹滴晶"
+ ]
+ },
+ {
+ "label": "特殊物品",
+ "type": "multi-checkbox",
+ "name": "selectMiscellaneous",
+ "options": [
+ "晶蝶",
+ "沉玉仙茗"
+ ]
+ },
+ {
+ "type": "separator"
+ }
+]
\ No newline at end of file
diff --git a/repo/js/CD-Aware-AutoGather/lib/inventory.js b/repo/js/CD-Aware-AutoGather/lib/inventory.js
new file mode 100644
index 000000000..fa4f4eb1e
--- /dev/null
+++ b/repo/js/CD-Aware-AutoGather/lib/inventory.js
@@ -0,0 +1,246 @@
+
+/**
+ * @author Ayaka-Main
+ * @link https://github.com/Patrick-Ze
+ * @description 对背包API的易用包装。使用方法: 将此文件放在脚本目录下的 lib 文件夹中,然后在你的脚本开头处执行下面这行:
+ eval(file.readTextSync("lib/inventory.js"));
+ */
+
+let scriptContext = {
+ version: "1.0",
+};
+
+// 原本是csv格式,但是为了方便js重用,还是内置在代码中
+const csvText = `物品,刷新机制,背包分类
+「冷鲜肉」,12小时,材料
+发光髓,12小时,材料
+蝴蝶翅膀,12小时,材料
+晶蝶,12小时,材料
+晶核,12小时,材料
+鳗肉,12小时,材料
+螃蟹,12小时,材料
+禽肉,12小时,材料
+青蛙,12小时,材料
+鳅鳅宝玉,12小时,材料
+神秘的肉,12小时,材料
+兽肉,12小时,材料
+蜥蜴尾巴,12小时,材料
+鱼肉,12小时,材料
+沉玉仙茗,24小时,材料
+便携轴承,46小时,材料
+冰雾花花朵,46小时,材料
+苍晶螺,46小时,材料
+赤念果,46小时,材料
+初露之源,46小时,材料
+悼灵花,46小时,材料
+嘟嘟莲,46小时,材料
+绯樱绣球,46小时,材料
+风车菊,46小时,材料
+钩钩果,46小时,材料
+鬼兜虫,46小时,材料
+海灵芝,46小时,材料
+海露花,46小时,材料
+虹彩蔷薇,46小时,材料
+湖光铃兰,46小时,材料
+劫波莲,46小时,材料
+晶化骨髓,46小时,材料
+绝云椒椒,46小时,材料
+枯叶紫英,46小时,材料
+浪沫羽鳃,46小时,材料
+烈焰花花蕊,46小时,材料
+琉璃百合,46小时,材料
+琉璃袋,46小时,材料
+琉鳞石,46小时,材料
+落落莓,46小时,材料
+鸣草,46小时,材料
+慕风蘑菇,46小时,材料
+霓裳花,46小时,材料
+帕蒂沙兰,46小时,材料
+蒲公英籽,46小时,材料
+奇异的「牙齿」,46小时,材料
+青蜜莓,46小时,材料
+清水玉,46小时,材料
+清心,46小时,材料
+柔灯铃,46小时,材料
+肉龙掌,46小时,材料
+塞西莉亚花,46小时,材料
+沙脂蛹,46小时,材料
+珊瑚真珠,46小时,材料
+圣金虫,46小时,材料
+石珀,46小时,材料
+树王圣体菇,46小时,材料
+霜盏花,46小时,材料
+天云草实,46小时,材料
+万相石,46小时,材料
+微光角菌,46小时,材料
+小灯草,46小时,材料
+星螺,46小时,材料
+血斛,46小时,材料
+夜泊石,46小时,材料
+幽灯蕈,46小时,材料
+幽光星星,46小时,材料
+月莲,46小时,材料
+月落银,46小时,材料
+云岩裂叶,46小时,材料
+灼灼彩菊,46小时,材料
+子探测单元,46小时,材料
+白铁块,每2天0点,材料
+电气水晶,每2天0点,材料
+星银矿石,每2天0点,材料
+萃凝晶,每3天0点,材料
+虹滴晶,每3天0点,材料
+水晶块,每3天0点,材料
+紫晶块,每3天0点,材料
+白灵果,每天0点,材料
+白萝卜,每天0点,材料
+薄荷,每天0点,材料
+澄晶实,每天0点,材料
+墩墩桃,每天0点,材料
+海草,每天0点,材料
+寒涌石,每天0点,材料
+红果果菇,每天0点,材料
+胡萝卜,每天0点,材料
+金鱼草,每天0点,材料
+堇瓜,每天0点,材料
+烬芯花,每天0点,材料
+久雨莲,每天0点,材料
+颗粒果,每天0点,材料
+苦种,每天0点,材料
+莲蓬,每天0点,材料
+马尾,每天0点,材料
+蘑菇,每天0点,材料
+茉洁草,每天0点,材料
+鸟蛋,每天0点,材料
+树莓,每天0点,材料
+松果,每天0点,材料
+松茸,每天0点,材料
+宿影花,每天0点,材料
+甜甜花,每天0点,材料
+铁块,每天0点,材料
+汐藻,每天0点,材料
+夏槲果,每天0点,材料
+香辛果,每天0点,材料
+须弥蔷薇,每天0点,材料
+枣椰,每天0点,材料
+竹笋,每天0点,材料
+泡泡桔,每天0点,食物
+苹果,每天0点,食物
+日落果,每天0点,食物
+星蕈,每天0点,食物
+烛伞蘑菇,每天0点,食物
+`
+
+const materialMetadata = {};
+
+function parseCsvTextToDict() {
+ // 1. 将文本按行分割成数组
+ const lines = csvText.trim().split("\n");
+
+ // 预期标题是:['物品', '刷新机制', '背包分类']
+ const headers = lines[0].split(",").map((header) => header.trim());
+
+ // 检查标题是否符合预期,防止解析错误
+ if (headers[0] !== "物品" || headers.length < 3) {
+ console.error("CSV格式的标题行不符合预期。");
+ return {};
+ }
+
+ // 确定我们需要的值对应的索引
+ const cdIndex = headers.indexOf("刷新机制");
+ const typeIndex = headers.indexOf("背包分类");
+
+ // 3. 处理数据行,构建结果字典
+ const resultDict = {};
+
+ // 从第二行(索引 1)开始遍历数据
+ const typeMap = {"食物": "Food", "材料": "Materials"};
+ for (let i = 1; i < lines.length; i++) {
+ const line = lines[i].trim();
+ if (line === "") continue; // 跳过空行
+
+ // 将数据行按逗号分割
+ const values = line.split(",").map((value) => value.trim());
+
+ // 确保数据行有足够的值
+ if (values.length > Math.max(cdIndex, typeIndex) && values[0]) {
+ const itemName = values[0];
+ const cdValue = values[cdIndex];
+ const typeValue = typeMap[values[typeIndex]];
+
+ // 存入结果字典,以物品名为 key
+ resultDict[itemName] = {
+ cd: cdValue,
+ type: typeValue,
+ };
+ }
+ }
+
+ return resultDict;
+}
+
+/**
+ * 获取背包中物品的数量。
+ * 如果没有找到,则为-1;如果找到了但数字识别失败,则为-2
+ *
+ * 暂不支持 冷鲜肉, 红果果菇, 奇异的「牙齿」
+ */
+async function getItemCount(itemList=null, retry=true) {
+ if (Object.keys(materialMetadata).length === 0) {
+ Object.assign(materialMetadata, parseCsvTextToDict());
+ }
+
+ if (typeof itemList === "string") {
+ itemList = [itemList];
+ } else if (itemList == null || itemList.length === 0) {
+ itemList = Object.keys(materialMetadata);
+ }
+ const renameMap = {"晶蝶": "晶核", "「冷鲜肉」":"冷鲜肉"};
+ const groupByType = {};
+ for (const itemName of itemList) {
+ const metadata = materialMetadata[itemName];
+ const itemType = metadata?.type ?? "Materials";
+ if (!metadata?.type) {
+ log.warn("未查找到{0}所属的背包分类,默认它是{1}", itemName, itemType);
+ }
+ const normalizedName = renameMap[itemName] || itemName;
+ groupByType[itemType] = groupByType[itemType] || [];
+ groupByType[itemType].push(normalizedName);
+ }
+
+ let results = {};
+ for (const type in groupByType) {
+ const names = groupByType[type];
+ const countResult = await dispatcher.runTask(new SoloTask("CountInventoryItem", {
+ "gridScreenName": type,
+ "itemNames": names,
+ }));
+ Object.assign(results, countResult);
+ }
+ if (retry && itemList.some(item => !(item in results))) {
+ // 即使在白天,大多数情况也能识别成功,因此不作为常态机制,仅在失败时使用
+ log.info("部分物品识别失败,调整时间和视角后重试");
+ await genshin.returnMainUi();
+ await genshin.setTime(0, 0);
+ await sleep(300);
+ moveMouseBy(0, 9280);
+ await sleep(300);
+ const retryResults = await getItemCount(itemList, false);
+ await genshin.returnMainUi();
+ keyPress("MBUTTON");
+ return retryResults;
+ }
+ const finalResults = {};
+ for (const itemName of itemList) {
+ const normalizedName = renameMap[itemName] || itemName;
+ // 如果某个元素没有找到,则不会存在对应的键值,赋值为-1以保持与单个物品查找时一致的行为
+ finalResults[itemName] = results[normalizedName] ?? -1;
+ }
+ return finalResults;
+}
+
+function getItemCD(itemName) {
+ if (Object.keys(materialMetadata).length === 0) {
+ Object.assign(materialMetadata, parseCsvTextToDict());
+ }
+ return materialMetadata[itemName]?.cd || null;
+}
diff --git a/repo/js/CD-Aware-AutoGather/lib/lib.js b/repo/js/CD-Aware-AutoGather/lib/lib.js
index 65e4d1c84..7e9c8ca2c 100644
--- a/repo/js/CD-Aware-AutoGather/lib/lib.js
+++ b/repo/js/CD-Aware-AutoGather/lib/lib.js
@@ -1,611 +1,743 @@
-/**
- * @author Ayaka-Main
- * @link https://github.com/Patrick-Ze
- * @description 提供一些通用性的功能函数。使用方法: 将此文件放在脚本目录下的 lib 文件夹中,然后在你的脚本开头处执行下面这行:
- eval(file.readTextSync("lib/lib.js"));
- */
-
-let scriptContext = {
- scriptStartTime: new Date(),
- version: "1.3",
-};
-
-/**
- * 将 Date 对象格式化为 ISO 8601 字符串,包含本地时区(如:2020-09-28T20:20:20+08:00)
- * @param {Date} date - 要格式化的日期对象
- * @returns {string} 格式化后的字符串
- */
-function formatDateTime(date) {
- const pad = (n) => n.toString().padStart(2, "0");
-
- const year = date.getFullYear();
- const month = pad(date.getMonth() + 1);
- const day = pad(date.getDate());
- const hour = pad(date.getHours());
- const minute = pad(date.getMinutes());
- const second = pad(date.getSeconds());
-
- // 获取时区偏移(分钟),转换成±HH:MM
- const offset = -date.getTimezoneOffset();
- const sign = offset >= 0 ? "+" : "-";
- const offsetHour = pad(Math.floor(Math.abs(offset) / 60));
- const offsetMin = pad(Math.abs(offset) % 60);
-
- return `${year}-${month}-${day}T${hour}:${minute}:${second}${sign}${offsetHour}:${offsetMin}`;
-}
-
-/**
- * 将 Date 对象以本地时区格式化为字符串,格式为 "MM-DD HH:mm:ss"
- * @param {Date} date - 要格式化的日期对象
- * @returns {string} 格式化后的字符串
- */
-function formatDateTimeShort(date) {
- const pad = (n) => n.toString().padStart(2, "0");
-
- const month = pad(date.getMonth() + 1);
- const day = pad(date.getDate());
- const hour = pad(date.getHours());
- const minute = pad(date.getMinutes());
- const second = pad(date.getSeconds());
-
- return `${month}-${day} ${hour}:${minute}:${second}`;
-}
-
-/**
- * 判断当前时间是否已达到目标时间(目标时间基于脚本启动时间,支持跨天)。
- * @param {string} targetTimeStr - 目标时间,格式为 "HH:mm"。
- * @returns {boolean} 如果已达到目标时间,返回 true,否则返回 false。
- */
-function isTargetTimeReached(targetTimeStr) {
- const now = new Date();
- const [targetHour, targetMinute] = targetTimeStr.split(":").map(Number);
-
- const target = new Date(scriptContext.scriptStartTime);
- target.setHours(targetHour, targetMinute, 0, 0);
-
- // 如果目标时间早于脚本启动时间,则认为是第二天
- if (target <= scriptContext.scriptStartTime) {
- target.setDate(target.getDate() + 1);
- }
-
- return now >= target;
-}
-
-/**
- * 判断当前时间是否在给定时间范围内(支持跨天)。
- * @param {*} startStr 起始时间,格式为"HH:mm"
- * @param {*} endStr 结束时间,格式为"HH:mm"
- * @returns {boolean} 如果当前时间在范围内,返回 true,否则返回 false。
- */
-function isNowInTimeRange(startStr, endStr) {
- const now = new Date();
- const [startHour, startMinute] = startStr.split(":").map(Number);
- const [endHour, endMinute] = endStr.split(":").map(Number);
-
- const start = new Date(now);
- start.setHours(startHour, startMinute, 0, 0);
-
- const end = new Date(now);
- end.setHours(endHour, endMinute, 0, 0);
-
- // 如果结束时间早于开始时间,表示跨天
- if (end <= start) {
- end.setDate(end.getDate() + 1);
- }
- return now >= start && now <= end;
-}
-
-/**
- * 根据上期刷新时间字符串和刷新模式计算下一次的刷新时间。
- *
- * @param {string} lastRefreshTimeStr 上次刷新时间。如果为空或无效,将使用 getDefaultTime()。
- * @param {string} refreshMode 刷新模式,例如 "每X周", "每X天Y点", "每24:05" (表示每24小时零5分), "X小时"
- * @returns {Date | null} 计算出的下一次刷新时间Date对象,如果模式无法解析则返回null。
- * @example 已进行过的测试用例(用例中 GetDefaultTime() 返回 1970-01-01T00:00:00.000+08:00):
- * calculateNextRefreshTime("2025-06-01T10:00:00.000+08:00", "每1周"); // 2025-06-02T04:00:00.000+08:00
- * calculateNextRefreshTime("2025-06-02T03:00:00.000+08:00", "每1周"); // 2025-06-02T04:00:00.000+08:00
- * calculateNextRefreshTime("2025-06-02T05:00:00.000+08:00", "每1周"); // 2025-06-09T04:00:00.000+08:00
- * calculateNextRefreshTime(null, "每周"); // 1970-01-05T04:00:00.000+08:00
- * calculateNextRefreshTime("2025-06-02T03:00:00.000+08:00", "每2周"); // 2025-06-09T04:00:00.000+08:00
- * calculateNextRefreshTime("2025-06-20T22:00:00.000+08:00", "每天8点"); // 2025-06-21T08:00:00.000+08:00
- * calculateNextRefreshTime("2025-06-21T07:00:00.000+08:00", "每天08点"); // 2025-06-21T08:00:00.000+08:00
- * calculateNextRefreshTime("2025-06-21T09:00:00.000+08:00", "每天08点"); // 2025-06-22T08:00:00.000+08:00
- * calculateNextRefreshTime(null, "每天12点"); // 1970-01-01T12:00:00.000+08:00
- * calculateNextRefreshTime("2025-06-20T10:00:00.000+08:00", "每2天10点"); // 2025-06-22T10:00:00.000+08:00
- * calculateNextRefreshTime("2025-06-20T10:00:00.000+08:00", "每3天0点"); // 2025-06-23T00:00:00.000+08:00
- * calculateNextRefreshTime("2025-06-21T11:00:00.000+08:00", "00:30"); // 2025-06-21T11:30:00.000+08:00
- * calculateNextRefreshTime("2025-06-21T23:00:00.000+08:00", "02:00"); // 2025-06-22T01:00:00.000+08:00
- * calculateNextRefreshTime("2025-06-20T04:00:00.000+08:00", "每24:05"); // 2025-06-21T04:05:00.000+08:00
- * calculateNextRefreshTime(null, "01:00"); // 1970-01-01T01:00:00.000+08:00
- * calculateNextRefreshTime("2025-06-21T10:00:00.000+08:00", "2小时"); // 2025-06-21T12:00:00.000+08:00
- * calculateNextRefreshTime("2025-06-21T23:00:00.000+08:00", "3小时"); // 2025-06-22T02:00:00.000+08:00
- * calculateNextRefreshTime(null, "5小时"); // 1970-01-01T05:00:00.000+08:00
- * calculateNextRefreshTime("2025-06-20T10:00:00.000+08:00", "每1周 每天10点"); // 2025-06-23T04:00:00.000+08:00
- * calculateNextRefreshTime("2025-06-20T10:00:00.000+08:00", "每天10点 02:00 2小时"); // 2025-06-21T10:00:00.000+08:00
- * calculateNextRefreshTime("2025-06-20T10:00:00.000+08:00", "00:30 2小时"); // 2025-06-20T10:30:00.000+08:00
- * calculateNextRefreshTime("2025-06-21T10:00:00.000+08:00", "无效模式"); // null
- */
-function calculateNextRefreshTime(lastRefreshTimeStr, refreshMode) {
- let lastRunTime = lastRefreshTimeStr ? new Date(lastRefreshTimeStr) : getDefaultTime();
- let nextRunTime = null;
- const lowerCaseRefreshMode = refreshMode.toLowerCase();
-
- // 1. 匹配 "每(\d*)周"
- let match = lowerCaseRefreshMode.match(/每(\d*)周/);
- if (match) {
- const weeks = parseInt(match[1] || "1", 10); // 如果没有数字,默认为1周
-
- nextRunTime = new Date(lastRunTime);
- // 找到 lastRunTime 所在周的周一 04:00
- nextRunTime.setDate(lastRunTime.getDate() - ((lastRunTime.getDay() + 6) % 7)); // 调整到上一个或当前周一
- nextRunTime.setHours(4, 0, 0, 0); // 固定到周一 04:00
-
- // 确保 nextRunTime 至少晚于 lastRunTime。
- // 如果 lastRunTime 是周一 05:00,而计算出的是周一 04:00,则需要推到下个周期。
- while (nextRunTime <= lastRunTime) {
- nextRunTime.setDate(nextRunTime.getDate() + 7);
- }
- if (weeks > 1) {
- // 如果是多周周期,直接加上 weeks 周
- nextRunTime.setDate(nextRunTime.getDate() + 7 * (weeks - 1));
- }
- }
-
- // 2. 匹配 "每(\d*)天(\d{1,2})点"
- if (!nextRunTime) {
- match = lowerCaseRefreshMode.match(/每(\d*)天(\d{1,2})点/);
- if (match) {
- const days = parseInt(match[1] || "1", 10); // 如果没有数字,默认为1天
- const hours = parseInt(match[2], 10);
-
- nextRunTime = new Date(lastRunTime);
- nextRunTime.setHours(hours, 0, 0, 0); // 设置固定小时和分钟
-
- // 确保 nextRunTime 至少晚于 lastRunTime。
- while (nextRunTime <= lastRunTime) {
- nextRunTime.setDate(nextRunTime.getDate() + days);
- }
- }
- }
-
- // 3. 匹配 "每(\d\d):(\d\d)" (作为间隔)
- if (!nextRunTime) {
- match = lowerCaseRefreshMode.match(/(\d{1,2}):(\d{2})/);
- if (match) {
- const intervalHours = parseInt(match[1], 10);
- const intervalMinutes = parseInt(match[2], 10);
- const intervalMs = (intervalHours * 60 + intervalMinutes) * 60 * 1000;
-
- if (intervalMs > 0) {
- // 确保间隔有效
- nextRunTime = new Date(lastRunTime.getTime() + intervalMs);
- }
- }
- }
-
- // 4. 匹配 "(\d+)小时"
- if (!nextRunTime) {
- match = lowerCaseRefreshMode.match(/(\d+)小时/);
- if (match) {
- const intervalHours = parseInt(match[1], 10);
- const intervalMs = intervalHours * 60 * 60 * 1000;
-
- if (intervalMs > 0) {
- // 确保间隔有效
- nextRunTime = new Date(lastRunTime.getTime() + intervalMs);
- }
- }
- }
-
- return nextRunTime;
-}
-
-/**
- * 判断任务是否达到刷新时间
- *
- * @param {string} refreshMode 刷新模式,例如 "每X周", "每X天Y点", "X小时", "每24:05" (表示每24小时零5分)
- * @param {string} taskName 任务名称或采集资源名称
- * @param {string} [accountName] 账户名称,可选
- * @returns {{isRefreshed: boolean, lastRunTime: Date | null, nextRunTime: Date | null}}
- * 返回一个对象,包含:
- * - isRefreshed: boolean - 任务是否达到刷新时间。
- * - lastRunTime: Date | null - 任务上次运行的时间(如果未找到,则是getDefaultTime()返回的远古时间)。
- * - nextRunTime: Date | null - 计算出的下一次刷新时间。
- */
-function isTaskRefreshed(refreshMode, taskName, accountName = null) {
- let record = {};
- const recordPath = `record/${accountName || "默认账号"}.json`;
- try {
- const content = file.readTextSync(recordPath);
- record = JSON.parse(content);
- } catch (e) {
- log.debug(`无法读取或解析记录文件 ${recordPath},错误: ${e.message}`);
- }
-
- taskName = taskName || "默认任务";
- const lastRunTimeStr = record[taskName];
- const currentTime = new Date();
- const nextRunTime = calculateNextRefreshTime(lastRunTimeStr, refreshMode);
-
- let isRefreshed = false;
- if (!nextRunTime) {
- log.error(`无法解析刷新模式 "{0}",请检查格式`, refreshMode);
- } else {
- isRefreshed = currentTime >= nextRunTime;
- }
-
- const lastRunTime = lastRunTimeStr ? new Date(lastRunTimeStr) : getDefaultTime();
- return {
- isRefreshed: isRefreshed,
- lastRunTime: lastRunTime, // 返回实际的 Date 对象
- nextRunTime: nextRunTime,
- };
-}
-
-/**
- * 判断任务或资源是否仍然未刷新(对`isTaskRefreshed`的易用封装)
- *
- * @param {string} refreshMode 刷新模式,例如 "每X周", "每X天Y点", "X小时", "每24:05" (表示每24小时零5分)
- * @param {string} taskName 任务名称或采集资源名称,可选
- * @param {string} [accountName] 账户名称,可选
- * @example
- * // 运行结束时调用
- * updateTaskRunTime();
- * // 在脚本开头检查是否已刷新
- * if (taskIsNotRefresh("每天4点")) {
- * return;
- * }
- */
-function taskIsNotRefresh(refreshMode, taskName = null, accountName = null) {
- const { isRefreshed, lastRunTime, nextRunTime } = isTaskRefreshed(refreshMode, taskName, accountName);
-
- taskName = taskName || "默认任务";
- if (!isRefreshed) {
- log.info("{0}未刷新(上次运行: {1}), 刷新时间: {2}", taskName, formatDateTimeShort(lastRunTime), formatDateTimeShort(nextRunTime));
- }
- return !isRefreshed;
-}
-
-/**
- * 更新指定任务的上次运行时间为当前时间。
- *
- * @param {string} taskName 任务名称。
- * @param {string} [accountName=null] 账户名称,可选,默认为null,表示使用默认账户。
- * @returns {boolean} 如果成功更新了任务的上次运行时间则返回true,否则返回false。
- */
-function updateTaskRunTime(taskName = null, accountName = null) {
- let record = {};
- taskName = taskName || "默认任务";
- const recordPath = `record/${accountName || "默认账号"}.json`;
-
- // 1. 读取记录文件
- try {
- const content = file.readTextSync(recordPath);
- record = JSON.parse(content);
- } catch (e) {
- log.debug(`未能读取或解析记录文件 ${recordPath},将创建新记录。错误: ${e.message}`);
- }
-
- // 2. 更新指定任务的上次运行时间
- const currentTime = new Date();
- record[taskName] = formatDateTime(currentTime); // 格式化为本地时间字符串,便于人阅读
-
- // 3. 将更新后的记录写回文件
- try {
- file.writeTextSync(recordPath, JSON.stringify(record, null, 2));
- return true;
- } catch (e) {
- log.error(`写入文件 ${recordPath} 失败: ${e.message}`);
- return false;
- }
-}
-
-/**
- * 尝试切换队伍,如果失败则传送到七天神像后重试。
- * @param {string} partyName - 要切换的队伍名
- * @returns {Promise}
- */
-async function switchPartySafely(partyName) {
- if (!partyName) return;
-
- try {
- if (!(await genshin.switchParty(partyName))) {
- log.info("切换队伍失败,前往七天神像重试");
- await genshin.tpToStatueOfTheSeven();
- await genshin.returnMainUi(); // 确保传送完成
- await genshin.switchParty(partyName);
- await genshin.returnMainUi();
- }
- } catch {
- log.error("队伍切换失败,可能处于联机模式或其他不可切换状态");
- await genshin.returnMainUi();
- }
-}
-
-/**
- * 获取账号名(通常用于区分不同账号的数据)
- *
- * @async
- * @param {*} multiAccount 是否使用OCR区分多个账号(可以传入一个设置项)
- * @param {boolean} mask 对UID进行掩码,只保留开头和结尾
- * @returns {Promise} 当前账号的UID,如果不区分多账号或OCR失败则返回"默认账号"。
- */
-async function getGameAccount(multiAccount = false, mask = true) {
- let account = "默认账号";
- if (!multiAccount) {
- return account;
- }
-
- // 打开背包避免界面背景干扰
- // 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);
- region.dispose();
-
- 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];
- if (mask) {
- // 避免完整UID出现在log中造成意外暴露
- account = account.replace(/\d*(\d{4})\d{4}/, (match, group1) => match.replace(group1, "xxxx"));
- }
- }
- break;
- }
- }
-
- if (account === "默认账号") {
- log.error("未能提取到UID");
- }
-
- return account;
-}
-
-/**
- * 从 manifest.json 获取脚本自身名称
- * @returns {string} 脚本名称
- */
-function getScriptName() {
- const content = file.readTextSync("manifest.json");
- const manifest = JSON.parse(content);
- return manifest.name;
-}
-
-/**
- * 从文件路径中提取文件名。
- * @param {string} filePath - 文件路径。
- * @returns {string} - 文件名。
- */
-function basename(filePath) {
- const lastSlashIndex = filePath.lastIndexOf("\\"); // 或者使用 '/'
- return filePath.substring(lastSlashIndex + 1);
-}
-
-/**
- * 将路径分割为目录和文件名
- * @param {string} path - 文件完整路径
- * @returns {[string, string]} 返回数组,第一个元素是目录路径,第二个是文件名
- * @example
- * const [dir, file] = splitPath('稻妻\\绯樱绣球\\06-绯樱绣球-神里屋敷-10个.json'); // ['稻妻\\绯樱绣球', '06-绯樱绣球-神里屋敷-10个.json']
- */
-function splitPath(path) {
- const normalizedPath = path.replace(/\\/g, "/");
- const lastSlashIndex = normalizedPath.lastIndexOf("/");
- if (lastSlashIndex === -1) {
- return ["", path];
- }
- const dir = path.slice(0, lastSlashIndex);
- const file = path.slice(lastSlashIndex + 1);
- return [dir, file];
-}
-
-/**
- * 将路径分割为主名和扩展名
- * @param {string} filename - 文件名或路径中的文件部分
- * @returns {[string, string]} 返回数组,第一个是主文件名,第二个是扩展名(含点)
- * @example
- * const [dir, file] = splitPath('稻妻\\绯樱绣球\\06-绯樱绣球-神里屋敷-10个.json'); // ['稻妻\\绯樱绣球\\06-绯樱绣球-神里屋敷-10个', '.json']
- */
-function splitExt(filename) {
- const baseName = filename.includes("/") ? filename.slice(filename.lastIndexOf("/") + 1) : filename;
- const lastDotIndex = baseName.lastIndexOf(".");
- if (lastDotIndex <= 0) {
- return [filename, ""];
- }
- return [
- filename.slice(0, filename.length - (baseName.length - lastDotIndex)),
- filename.slice(filename.length - (baseName.length - lastDotIndex)),
- ];
-}
-
-/**
- * 如果你需要一个很久以前的时间,作为默认时间
- * @returns {Date} 默认时间的Date对象
- */
-function getDefaultTime() {
- const now = new Date();
- const year = now.getFullYear() - 18;
- return new Date(year, 8, 28, 0, 0, 0); // 9月是month=8(0起始)
-}
-
-/**
- * 获取指定目录下所有指定后缀的文件列表(不含子文件夹)
- * @param {string} taskDir - 目标目录路径
- * @param {string} [ext=".json"] - 文件后缀名(默认.json)
- * @returns {string[]} 返回符合后缀的文件路径数组
- */
-function getFilesByExtension(taskDir, ext = ".json") {
- const allFilesRaw = file.ReadPathSync(taskDir);
- const extFiles = [];
-
- for (const filePath of allFilesRaw) {
- if (filePath.endsWith(ext)) {
- extFiles.push(filePath);
- }
- }
-
- return extFiles;
-}
-
-/**
- * 获取指定路径下所有最底层的文件夹(即不包含任何子文件夹的文件夹)
- * @param {string} folderPath - 要遍历的根文件夹路径
- * @param {string[]} result - 用于收集最底层文件夹路径的数组
- * @returns {Promise} 所有最底层文件夹的路径
- */
-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;
-}
-
-// 参考了 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) {
- // 传入开始时间是为了在跟踪路径耗时的同时仍然保留对脚本运行时间的统计
- // 但是如果脚本开始时间和结束时间跨天,就不能使用传入时间,否则会影响日志分析(Seconds cannot be negative)
- if (logTime.getDay() === dateIn.getDay()) {
- logTime = dateIn;
- }
- }
-
- const ms = logTime.getMilliseconds().toString().padStart(3, "0");
- // 时间部分从第11位开始,长度是12("20:20:20")
- const formattedTime = formatDateTime(logTime).slice(11, 19) + "." + ms;
-
- 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;
-}
-
-/**
- * 在日志文件中创建可供BGI解析耗时的路径追踪记录,Start和End两个函数需配对使用
- * @param {string} name 要写入到日志的事项名,例如路径追踪的json文件名
- * @returns {Date} 此函数的调用时间的Date对象
- * @example
- * let pathStart = logFakePathStart(fileName);
- * // await pathingScript.runFile(jsonPath);
- * logFakePathEnd(fileName, pathStart);
- */
-function logFakePathStart(name) {
- return _fakeLogCore(name, false);
-}
-
-/**
- * 在日志文件中创建可供BGI解析耗时的路径追踪记录,Start和End两个函数需配对使用
- * @param {string} name 要写入到日志的事项名,通常传入路径追踪的json文件名
- * @param {Date} startTime 调用`logFakePathStart`时返回的Date对象
- * @example
- * let pathStart = logFakePathStart(fileName);
- * // await pathingScript.runFile(jsonPath);
- * logFakePathEnd(fileName, pathStart);
- */
-function logFakePathEnd(name, startTime) {
- return _fakeLogCore(name, false, startTime);
-}
-
-/**
- * 在日志文件中创建可供BGI解析耗时的脚本运行记录,Start和End两个函数需配对使用
- * @param {string} scriptName 脚本名,留空时将自动获取
- * @returns {Date} 此函数的调用时间的Date对象
- * @example
- * let startTime = logFakeScriptStart();
- * // do something;
- * logFakeScriptEnd({ startTime: startTime });
- */
-function logFakeScriptStart(scriptName = null) {
- if (!scriptName) {
- if (!scriptContext.scriptName) {
- scriptContext.scriptName = getScriptName();
- }
- scriptName = scriptContext.scriptName;
- }
- return _fakeLogCore(scriptName, true);
-}
-
-/**
- * 在日志文件中创建可供BGI解析耗时的脚本运行记录,Start和End两个函数需配对使用
- * @param {Object} params
- * @param {string|null} [params.scriptName=null] - 脚本名,留空时将自动获取
- * @param {Date} [params.startTime=new Date()] - 调用`logFakeScriptStart`时返回的Date对象
- * @returns {Date} 此函数的调用时间的Date对象
- * @example
- * let startTime = logFakeScriptStart();
- * // do something;
- * logFakeScriptEnd({ startTime: startTime });
- */
-function logFakeScriptEnd({ scriptName = null, startTime = new Date() } = {}) {
- if (!scriptName) {
- if (!scriptContext.scriptName) {
- scriptContext.scriptName = getScriptName();
- }
- scriptName = scriptContext.scriptName;
- }
- return _fakeLogCore(scriptName, true, startTime);
-}
-
-/**
- * 等待传送结束
- * @param {Int} timeout 单位为ms
- * @note 参考了七圣召唤七日历练脚本
- */
-async function waitTpFinish(timeout = 30000) {
- const region = RecognitionObject.ocr(1690, 230, 75, 350); // 队伍名称区域
- const startTime = Date.now();
-
- await sleep(500); //点击传送后等待一段时间避免误判
- while (Date.now() - startTime < timeout) {
- const ro = captureGameRegion();
- let res = ro.find(region);
- ro.dispose();
- if (!res.isEmpty()) {
- await sleep(600); //传送结束后有僵直
- return;
- }
- await sleep(100);
- }
- throw new Error("传送时间超时");
-}
+/**
+ * @author Ayaka-Main
+ * @link https://github.com/Patrick-Ze
+ * @description 提供一些通用性的功能函数。使用方法: 将此文件放在脚本目录下的 lib 文件夹中,然后在你的脚本开头处执行下面这行:
+ eval(file.readTextSync("lib/lib.js"));
+ */
+
+let scriptContext = {
+ scriptStartTime: new Date(),
+ version: "2.0",
+};
+
+/**
+ * 将 Date 对象格式化为 ISO 8601 字符串,包含本地时区(如:2020-09-28T20:20:20+08:00)
+ * @param {Date} date - 要格式化的日期对象
+ * @returns {string} 格式化后的字符串
+ */
+function formatDateTime(date) {
+ const pad = (n) => n.toString().padStart(2, "0");
+
+ const year = date.getFullYear();
+ const month = pad(date.getMonth() + 1);
+ const day = pad(date.getDate());
+ const hour = pad(date.getHours());
+ const minute = pad(date.getMinutes());
+ const second = pad(date.getSeconds());
+
+ // 获取时区偏移(分钟),转换成±HH:MM
+ const offset = -date.getTimezoneOffset();
+ const sign = offset >= 0 ? "+" : "-";
+ const offsetHour = pad(Math.floor(Math.abs(offset) / 60));
+ const offsetMin = pad(Math.abs(offset) % 60);
+
+ return `${year}-${month}-${day}T${hour}:${minute}:${second}${sign}${offsetHour}:${offsetMin}`;
+}
+
+/**
+ * 将 Date 对象以本地时区格式化为字符串,格式为 "MM-DD HH:mm:ss"
+ * @param {Date} date - 要格式化的日期对象
+ * @returns {string} 格式化后的字符串
+ */
+function formatDateTimeShort(date) {
+ const pad = (n) => n.toString().padStart(2, "0");
+
+ const month = pad(date.getMonth() + 1);
+ const day = pad(date.getDate());
+ const hour = pad(date.getHours());
+ const minute = pad(date.getMinutes());
+ const second = pad(date.getSeconds());
+
+ return `${month}-${day} ${hour}:${minute}:${second}`;
+}
+
+/**
+ * 计算两个 Date 对象的差值,并格式化为 HH:mm:ss 字符串。
+ *
+ * @param {Date} date1 第一个 Date 对象。
+ * @param {Date} date2 第二个 Date 对象。
+ * @returns {string} 格式为 "HH:mm:ss" 的时间差字符串。
+ */
+function formatTimeDifference(date1, date2) {
+ // 计算毫秒差值,确保总是正数
+ const diffInMilliseconds = Math.abs(date2.getTime() - date1.getTime());
+
+ // 将毫秒转换为总秒数
+ const totalSeconds = Math.floor(diffInMilliseconds / 1000);
+
+ // 计算时、分、秒
+ const hours = Math.floor(totalSeconds / 3600);
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
+ const seconds = totalSeconds % 60;
+
+ // 格式化为两位数
+ const formattedHours = String(hours).padStart(2, "0");
+ const formattedMinutes = String(minutes).padStart(2, "0");
+ const formattedSeconds = String(seconds).padStart(2, "0");
+
+ return `${formattedHours}:${formattedMinutes}:${formattedSeconds}`;
+}
+
+/**
+ * 判断当前时间是否已达到目标时间(目标时间基于脚本启动时间,支持跨天)。
+ * @param {string} targetTimeStr - 目标时间,格式为 "HH:mm"。
+ * @returns {boolean} 如果已达到目标时间,返回 true,否则返回 false。
+ */
+function isTargetTimeReached(targetTimeStr) {
+ const now = new Date();
+ const [targetHour, targetMinute] = targetTimeStr.split(":").map(Number);
+
+ const target = new Date(scriptContext.scriptStartTime);
+ target.setHours(targetHour, targetMinute, 0, 0);
+
+ // 如果目标时间早于脚本启动时间,则认为是第二天
+ if (target <= scriptContext.scriptStartTime) {
+ target.setDate(target.getDate() + 1);
+ }
+
+ return now >= target;
+}
+
+/**
+ * 检查当前时间是否在排除的时间段内
+ * @param {string} excludeTimeRange - 格式如 "05:20-09:28 20:20-21:28 23:20-00:30"
+ * @returns {Object} { duringRange: false or string, nearestStopTime: string}
+ */
+function checkExecutionExcludeTime(excludeTimeRange) {
+ const now = new Date();
+ const currentMinutes = now.getHours() * 60 + now.getMinutes();
+ const ONE_DAY_MINUTES = 1440; // 24 * 60
+
+ // 辅助函数:HH:mm 转分钟
+ const timeToMinutes = (timeStr) => {
+ const [hrs, mins] = timeStr.trim().split(":").map(Number);
+ return hrs * 60 + mins;
+ };
+
+ const ranges = excludeTimeRange.split(/\s+/).map((range) => {
+ range = range.trim().replace(":", ":");
+ const [startStr, endStr] = range.split("-");
+ return {
+ start: timeToMinutes(startStr),
+ end: timeToMinutes(endStr),
+ raw: range,
+ startStr: startStr,
+ };
+ });
+
+ let duringRange = false;
+ let nearestStopTime = null;
+ let minWaitTime = Infinity;
+ for (const range of ranges) {
+ const { start, end } = range;
+ // 判断是否处于当前范围(支持跨天逻辑,如 23:00-01:00)
+ const inThisRange = end < start ? currentMinutes >= start || currentMinutes < end : currentMinutes >= start && currentMinutes < end;
+ if (inThisRange) {
+ duringRange = range.raw;
+ break;
+ }
+ // 计算到该时间段开始的分钟数 (取模处理跨天)
+ const diff = (start - currentMinutes + ONE_DAY_MINUTES) % ONE_DAY_MINUTES;
+ if (diff < minWaitTime) {
+ minWaitTime = diff;
+ nearestStopTime = range.startStr;
+ }
+ }
+ return { duringRange, nearestStopTime };
+}
+
+/**
+ * 判断当前时间是否在给定时间范围内(支持跨天)。
+ * @param {*} startStr 起始时间,格式为"HH:mm"
+ * @param {*} endStr 结束时间,格式为"HH:mm"
+ * @returns {boolean} 如果当前时间在范围内,返回 true,否则返回 false。
+ */
+function isNowInTimeRange(startStr, endStr) {
+ const now = new Date();
+ const [startHour, startMinute] = startStr.split(":").map(Number);
+ const [endHour, endMinute] = endStr.split(":").map(Number);
+
+ const start = new Date(now);
+ start.setHours(startHour, startMinute, 0, 0);
+
+ const end = new Date(now);
+ end.setHours(endHour, endMinute, 0, 0);
+
+ // 如果结束时间早于开始时间,表示跨天
+ if (end <= start) {
+ end.setDate(end.getDate() + 1);
+ }
+ return now >= start && now <= end;
+}
+
+/**
+ * 根据上期刷新时间字符串和刷新模式计算下一次的刷新时间。
+ *
+ * @param {string} lastRefreshTimeStr 上次刷新时间。如果为空或无效,将使用 getDefaultTime()。
+ * @param {string} refreshMode 刷新模式,例如 "每X周", "每X天Y点", "每24:05" (表示每24小时零5分), "X小时", "每版本"
+ * @returns {Date | null} 计算出的下一次刷新时间Date对象,如果模式无法解析则返回null。
+ * @example 已进行过的测试用例(用例中 GetDefaultTime() 返回 1970-01-01T00:00:00.000+08:00):
+ * calculateNextRefreshTime("2025-06-01T10:00:00.000+08:00", "每1周"); // 2025-06-02T04:00:00.000+08:00
+ * calculateNextRefreshTime("2025-06-02T03:00:00.000+08:00", "每1周"); // 2025-06-02T04:00:00.000+08:00
+ * calculateNextRefreshTime("2025-06-02T05:00:00.000+08:00", "每1周"); // 2025-06-09T04:00:00.000+08:00
+ * calculateNextRefreshTime(null, "每周"); // 1970-01-05T04:00:00.000+08:00
+ * calculateNextRefreshTime("2025-06-02T03:00:00.000+08:00", "每2周"); // 2025-06-09T04:00:00.000+08:00
+ * calculateNextRefreshTime("2025-06-20T22:00:00.000+08:00", "每天8点"); // 2025-06-21T08:00:00.000+08:00
+ * calculateNextRefreshTime("2025-06-21T07:00:00.000+08:00", "每天08点"); // 2025-06-21T08:00:00.000+08:00
+ * calculateNextRefreshTime("2025-06-21T09:00:00.000+08:00", "每天08点"); // 2025-06-22T08:00:00.000+08:00
+ * calculateNextRefreshTime(null, "每天12点"); // 1970-01-01T12:00:00.000+08:00
+ * calculateNextRefreshTime("2025-06-20T10:00:00.000+08:00", "每2天10点"); // 2025-06-22T10:00:00.000+08:00
+ * calculateNextRefreshTime("2025-06-20T10:00:00.000+08:00", "每3天0点"); // 2025-06-23T00:00:00.000+08:00
+ * calculateNextRefreshTime("2025-06-21T11:00:00.000+08:00", "00:30"); // 2025-06-21T11:30:00.000+08:00
+ * calculateNextRefreshTime("2025-06-21T23:00:00.000+08:00", "02:00"); // 2025-06-22T01:00:00.000+08:00
+ * calculateNextRefreshTime("2025-06-20T04:00:00.000+08:00", "每24:05"); // 2025-06-21T04:05:00.000+08:00
+ * calculateNextRefreshTime(null, "01:00"); // 1970-01-01T01:00:00.000+08:00
+ * calculateNextRefreshTime("2025-06-21T10:00:00.000+08:00", "2小时"); // 2025-06-21T12:00:00.000+08:00
+ * calculateNextRefreshTime("2025-06-21T23:00:00.000+08:00", "3小时"); // 2025-06-22T02:00:00.000+08:00
+ * calculateNextRefreshTime(null, "5小时"); // 1970-01-01T05:00:00.000+08:00
+ * calculateNextRefreshTime("2025-06-20T10:00:00.000+08:00", "每1周 每天10点"); // 2025-06-23T04:00:00.000+08:00
+ * calculateNextRefreshTime("2025-06-20T10:00:00.000+08:00", "每天10点 02:00 2小时"); // 2025-06-21T10:00:00.000+08:00
+ * calculateNextRefreshTime("2025-06-20T10:00:00.000+08:00", "00:30 2小时"); // 2025-06-20T10:30:00.000+08:00
+ * calculateNextRefreshTime("2025-06-21T10:00:00.000+08:00", "无效模式"); // null
+ */
+function calculateNextRefreshTime(lastRefreshTimeStr, refreshMode) {
+ let lastRunTime = lastRefreshTimeStr ? new Date(lastRefreshTimeStr) : getDefaultTime();
+ let nextRunTime = null;
+ const lowerCaseRefreshMode = refreshMode.toLowerCase();
+
+ // 1. 匹配 "每(\d*)周"
+ let match = lowerCaseRefreshMode.match(/每(\d*)周/);
+ if (match) {
+ const weeks = parseInt(match[1] || "1", 10); // 如果没有数字,默认为1周
+
+ nextRunTime = new Date(lastRunTime);
+ // 找到 lastRunTime 所在周的周一 04:00
+ nextRunTime.setDate(lastRunTime.getDate() - ((lastRunTime.getDay() + 6) % 7)); // 调整到上一个或当前周一
+ nextRunTime.setHours(4, 0, 0, 0); // 固定到周一 04:00
+
+ // 确保 nextRunTime 至少晚于 lastRunTime。
+ // 如果 lastRunTime 是周一 05:00,而计算出的是周一 04:00,则需要推到下个周期。
+ while (nextRunTime <= lastRunTime) {
+ nextRunTime.setDate(nextRunTime.getDate() + 7);
+ }
+ if (weeks > 1) {
+ // 如果是多周周期,直接加上 weeks 周
+ nextRunTime.setDate(nextRunTime.getDate() + 7 * (weeks - 1));
+ }
+ }
+
+ // 2. 匹配 "每(\d*)天(\d{1,2})点"
+ if (!nextRunTime) {
+ match = lowerCaseRefreshMode.match(/每(\d*)天(\d{1,2})点/);
+ if (match) {
+ const days = parseInt(match[1] || "1", 10); // 如果没有数字,默认为1天
+ const hours = parseInt(match[2], 10);
+
+ nextRunTime = new Date(lastRunTime);
+ nextRunTime.setHours(hours, 0, 0, 0); // 设置固定小时和分钟
+
+ // 确保 nextRunTime 至少晚于 lastRunTime。
+ while (nextRunTime <= lastRunTime) {
+ nextRunTime.setDate(nextRunTime.getDate() + days);
+ }
+ }
+ }
+
+ // 3. 匹配 "每(\d\d):(\d\d)" (作为间隔)
+ if (!nextRunTime) {
+ match = lowerCaseRefreshMode.match(/(\d{1,2}):(\d{2})/);
+ if (match) {
+ const intervalHours = parseInt(match[1], 10);
+ const intervalMinutes = parseInt(match[2], 10);
+ const intervalMs = (intervalHours * 60 + intervalMinutes) * 60 * 1000;
+
+ if (intervalMs > 0) {
+ // 确保间隔有效
+ nextRunTime = new Date(lastRunTime.getTime() + intervalMs);
+ }
+ }
+ }
+
+ // 4. 匹配 "(\d+)小时"
+ if (!nextRunTime) {
+ match = lowerCaseRefreshMode.match(/(\d+)小时/);
+ if (match) {
+ const intervalHours = parseInt(match[1], 10);
+ const intervalMs = intervalHours * 60 * 60 * 1000;
+
+ if (intervalMs > 0) {
+ // 确保间隔有效
+ nextRunTime = new Date(lastRunTime.getTime() + intervalMs);
+ }
+ }
+ }
+
+ // 5. 匹配 "每版本"
+ if (!nextRunTime) {
+ match = lowerCaseRefreshMode.match(/每(\d*)版本/);
+ if (match) {
+ const intervalVersions = parseInt(match[1] || "1", 10); // 如果没有数字,默认为1
+ const baseDate = new Date("2025-01-01T04:00:00"); // v5.3 作为base版本
+ const days = (lastRunTime - baseDate) / (1000 * 60 * 60 * 24);
+ const versionIndex = Math.trunc(days / 42);
+ nextRunTime = new Date(baseDate.getTime() + (versionIndex + intervalVersions) * 42 * 24 * 60 * 60 * 1000);
+ }
+ }
+
+ return nextRunTime;
+}
+
+/**
+ * 判断任务是否达到刷新时间
+ *
+ * @param {string} refreshMode 刷新模式,例如 "每X周", "每X天Y点", "X小时", "每24:05" (表示每24小时零5分), "X小时", "每版本"
+ * @param {string} taskName 任务名称或采集资源名称
+ * @param {string} [accountName] 账户名称,可选
+ * @returns {{isRefreshed: boolean, lastRunTime: Date | null, nextRunTime: Date | null}}
+ * 返回一个对象,包含:
+ * - isRefreshed: boolean - 任务是否达到刷新时间。
+ * - lastRunTime: Date | null - 任务上次运行的时间(如果未找到,则是getDefaultTime()返回的远古时间)。
+ * - nextRunTime: Date | null - 计算出的下一次刷新时间。
+ */
+function isTaskRefreshed(refreshMode, taskName, accountName = null) {
+ let record = {};
+ const recordPath = `record/${accountName || "默认账号"}.json`;
+ try {
+ const content = file.readTextSync(recordPath);
+ record = JSON.parse(content);
+ } catch (e) {
+ log.debug(`无法读取或解析记录文件 ${recordPath},错误: ${e.message}`);
+ }
+
+ taskName = taskName || "默认任务";
+ const lastRunTimeStr = record[taskName];
+ const currentTime = new Date();
+ const nextRunTime = calculateNextRefreshTime(lastRunTimeStr, refreshMode);
+
+ let isRefreshed = false;
+ if (!nextRunTime) {
+ log.error(`无法解析刷新模式 "{0}",请检查格式`, refreshMode);
+ } else {
+ isRefreshed = currentTime >= nextRunTime;
+ }
+
+ const lastRunTime = lastRunTimeStr ? new Date(lastRunTimeStr) : getDefaultTime();
+ return {
+ isRefreshed: isRefreshed,
+ lastRunTime: lastRunTime, // 返回实际的 Date 对象
+ nextRunTime: nextRunTime,
+ };
+}
+
+/**
+ * 判断任务或资源是否仍然未刷新(对`isTaskRefreshed`的易用封装)
+ *
+ * @param {string} refreshMode 刷新模式,例如 "每X周", "每X天Y点", "X小时", "每24:05" (表示每24小时零5分), "X小时", "每版本"
+ * @param {string} taskName 任务名称或采集资源名称,可选
+ * @param {string} [accountName] 账户名称,可选
+ * @example
+ * // 运行结束时调用
+ * await updateTaskRunTime();
+ * // 在脚本开头检查是否已刷新
+ * if (taskIsNotRefresh("每天4点")) {
+ * return;
+ * }
+ */
+function taskIsNotRefresh(refreshMode, taskName = null, accountName = null) {
+ const { isRefreshed, lastRunTime, nextRunTime } = isTaskRefreshed(refreshMode, taskName, accountName);
+
+ taskName = taskName || "默认任务";
+ if (!isRefreshed) {
+ log.info("{0}未刷新(上次运行: {1}), 刷新时间: {2}", taskName, formatDateTimeShort(lastRunTime), formatDateTimeShort(nextRunTime));
+ }
+ return !isRefreshed;
+}
+
+/**
+ * 更新指定任务的上次运行时间为当前时间。
+ *
+ * @param {string} taskName 任务名称。
+ * @param {string} [accountName=null] 账户名称,可选,默认为null,表示使用默认账户。
+ * @returns {boolean} 如果成功更新了任务的上次运行时间则返回true,否则返回false。
+ */
+async function updateTaskRunTime(taskName = null, accountName = null) {
+ let record = {};
+ taskName = taskName || "默认任务";
+ const recordPath = `record/${accountName || "默认账号"}.json`;
+
+ //捕获任务取消的信息,避免执行到后面的进度更新函数
+ try {
+ await sleep(10);
+ } catch (error) {
+ return error.toString();
+ }
+ // 1. 读取记录文件
+ try {
+ const content = file.readTextSync(recordPath);
+ record = JSON.parse(content);
+ } catch (e) {
+ log.debug(`未能读取或解析记录文件 ${recordPath},将创建新记录。错误: ${e.message}`);
+ }
+
+ // 2. 更新指定任务的上次运行时间
+ const currentTime = new Date();
+ record[taskName] = formatDateTime(currentTime); // 格式化为本地时间字符串,便于人阅读
+
+ // 3. 将更新后的记录写回文件
+ try {
+ file.writeTextSync(recordPath, JSON.stringify(record, null, 2));
+ return true;
+ } catch (e) {
+ log.error(`写入文件 ${recordPath} 失败: ${e.message}`);
+ return false;
+ }
+}
+
+/**
+ * 尝试切换队伍,如果失败则传送到七天神像后重试。
+ * @param {string} partyName - 要切换的队伍名
+ * @returns {boolean} 切换过程触使用了强制传送到神像
+ */
+async function switchPartySafely(partyName) {
+ let teleported = false;
+ if (!partyName) return teleported;
+
+ try {
+ if (!(await genshin.switchParty(partyName))) {
+ log.info("切换队伍失败,前往七天神像重试");
+ await genshin.tpToStatueOfTheSeven();
+ await genshin.returnMainUi(); // 确保传送完成
+ teleported = true;
+ await genshin.switchParty(partyName);
+ await genshin.returnMainUi();
+ }
+ } catch {
+ log.error("队伍切换失败,可能处于联机模式或其他不可切换状态");
+ await genshin.returnMainUi();
+ }
+ return teleported;
+}
+
+/**
+ * 获取账号名(通常用于区分不同账号的数据)
+ *
+ * @async
+ * @param {*} multiAccount 是否使用OCR区分多个账号(可以传入一个设置项)
+ * @param {boolean} mask 对UID进行掩码,只保留开头和结尾
+ * @returns {Promise} 当前账号的UID,如果不区分多账号或OCR失败则返回"默认账号"。
+ */
+async function getGameAccount(multiAccount = false, mask = true) {
+ let account = "默认账号";
+ if (!multiAccount) {
+ return account;
+ }
+
+ // 打开背包避免界面背景干扰
+ // 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);
+ region.dispose();
+
+ 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];
+ if (mask) {
+ // 避免完整UID出现在log中造成意外暴露
+ account = account.replace(/\d*(\d{4})\d{4}/, (match, group1) => match.replace(group1, "xxxx"));
+ }
+ }
+ break;
+ }
+ }
+
+ if (account === "默认账号") {
+ log.error("未能提取到UID");
+ }
+
+ return account;
+}
+
+/**
+ * 从 manifest.json 获取脚本自身名称
+ * @returns {string} 脚本名称
+ */
+function getScriptName() {
+ const content = file.readTextSync("manifest.json");
+ const manifest = JSON.parse(content);
+ return manifest.name;
+}
+
+function fileExists(fullPath) {
+ const [dirpath, filename] = splitPath(fullPath);
+ const normalizedPath = fullPath.replaceAll("/", "\\");
+ const files = Array.from(file.ReadPathSync(dirpath));
+ return files.includes(normalizedPath);
+}
+
+/**
+ * 重写内置函数,解决每次读取不存在的文件时必然出现的、不可抑制的Error消息问题
+ *
+ * 该问题由于 [PR#2412](https://github.com/babalae/better-genshin-impact/pull/2412) 导致,
+ * 并且[看起来也不会修复](https://github.com/babalae/better-genshin-impact/issues/2474)
+ */
+function readTextSync(fullPath) {
+ if (fileExists(fullPath)) {
+ return file.readTextSync(fullPath);
+ } else {
+ throw new Error("FileNotFound: " + fullPath);
+ }
+}
+
+/**
+ * 从文件路径中提取文件名。
+ * @param {string} filePath - 文件路径。
+ * @returns {string} - 文件名。
+ */
+function basename(filePath) {
+ const normalizedPath = filePath.replace(/\\/g, "/");
+ const lastSlashIndex = normalizedPath.lastIndexOf("/");
+ return filePath.substring(lastSlashIndex + 1);
+}
+
+/**
+ * 将路径分割为目录和文件名
+ * @param {string} path - 文件完整路径
+ * @returns {[string, string]} 返回数组,第一个元素是目录路径,第二个是文件名
+ * @example
+ * const [dir, file] = splitPath('稻妻\\绯樱绣球\\06-绯樱绣球-神里屋敷-10个.json'); // ['稻妻\\绯樱绣球', '06-绯樱绣球-神里屋敷-10个.json']
+ */
+function splitPath(path) {
+ const normalizedPath = path.replace(/\\/g, "/");
+ const lastSlashIndex = normalizedPath.lastIndexOf("/");
+ if (lastSlashIndex === -1) {
+ return [".", path];
+ }
+ const dir = path.slice(0, lastSlashIndex);
+ const file = path.slice(lastSlashIndex + 1);
+ return [dir, file];
+}
+
+/**
+ * 将路径分割为主名和扩展名
+ * @param {string} filename - 文件名或路径中的文件部分
+ * @returns {[string, string]} 返回数组,第一个是主文件名,第二个是扩展名(含点)
+ * @example
+ * const [dir, file] = splitExt('稻妻\\绯樱绣球\\06-绯樱绣球-神里屋敷-10个.json'); // ['稻妻\\绯樱绣球\\06-绯樱绣球-神里屋敷-10个', '.json']
+ */
+function splitExt(filename) {
+ const baseName = filename.includes("/") ? filename.slice(filename.lastIndexOf("/") + 1) : filename;
+ const lastDotIndex = baseName.lastIndexOf(".");
+ if (lastDotIndex <= 0) {
+ return [filename, ""];
+ }
+ return [
+ filename.slice(0, filename.length - (baseName.length - lastDotIndex)),
+ filename.slice(filename.length - (baseName.length - lastDotIndex)),
+ ];
+}
+
+/**
+ * 如果你需要一个很久以前的时间,作为默认时间
+ * @returns {Date} 默认时间的Date对象
+ */
+function getDefaultTime() {
+ const now = new Date();
+ const year = now.getFullYear() - 18;
+ return new Date(year, 8, 28, 0, 0, 0); // 9月是month=8(0起始)
+}
+
+/**
+ * 获取指定目录下指定后缀的文件
+ * @param {string} folderPath - 要遍历的根文件夹路径
+ * @param {string} suffix - 目标文件后缀
+ * @returns {string[]} 匹配后缀的所有文件路径
+ */
+function getFilesBySuffix(folderPath, suffix, recursive = true) {
+ let files = [];
+ const items = file.ReadPathSync(folderPath);
+
+ for (const itemPath of items) {
+ if (recursive && file.IsFolder(itemPath)) {
+ const subFolderFiles = getFilesBySuffix(itemPath, suffix, recursive);
+ // 将子目录返回的数组合并到当前数组中
+ files = files.concat(subFolderFiles);
+ } else {
+ // 如果是文件且匹配后缀
+ if (itemPath.endsWith(suffix)) {
+ files.push(itemPath);
+ }
+ }
+ }
+
+ return files;
+}
+
+/**
+ * 获取指定路径下所有最底层的文件夹(即不包含任何子文件夹的文件夹)
+ * @param {string} folderPath - 要遍历的根文件夹路径
+ * @param {string[]} result - 用于收集最底层文件夹路径的数组
+ * @returns {Promise} 所有最底层文件夹的路径
+ */
+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;
+}
+
+// 参考了 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) {
+ // 传入开始时间是为了在跟踪路径耗时的同时仍然保留对脚本运行时间的统计
+ // 但是如果脚本开始时间和结束时间跨天,就不能使用传入时间,否则会影响日志分析(Seconds cannot be negative)
+ if (logTime.getDate() === dateIn.getDate()) {
+ logTime = dateIn;
+ }
+ }
+
+ const ms = logTime.getMilliseconds().toString().padStart(3, "0");
+ // 时间部分从第11位开始,长度是12("20:20:20")
+ const formattedTime = formatDateTime(logTime).slice(11, 19) + "." + ms;
+
+ 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;
+}
+
+/**
+ * 在日志文件中创建可供BGI解析耗时的路径追踪记录,Start和End两个函数需配对使用
+ * @param {string} name 要写入到日志的事项名,例如路径追踪的json文件名
+ * @returns {Date} 此函数的调用时间的Date对象
+ * @example
+ * let pathStart = logFakePathStart(fileName);
+ * // await pathingScript.runFile(jsonPath);
+ * logFakePathEnd(fileName, pathStart);
+ */
+function logFakePathStart(name) {
+ return _fakeLogCore(name, false);
+}
+
+/**
+ * 在日志文件中创建可供BGI解析耗时的路径追踪记录,Start和End两个函数需配对使用
+ * @param {string} name 要写入到日志的事项名,通常传入路径追踪的json文件名
+ * @param {Date} startTime 调用`logFakePathStart`时返回的Date对象
+ * @example
+ * let pathStart = logFakePathStart(fileName);
+ * // await pathingScript.runFile(jsonPath);
+ * logFakePathEnd(fileName, pathStart);
+ */
+function logFakePathEnd(name, startTime) {
+ return _fakeLogCore(name, false, startTime);
+}
+
+/**
+ * 在日志文件中创建可供BGI解析耗时的脚本运行记录,Start和End两个函数需配对使用
+ * @param {string} scriptName 脚本名,留空时将自动获取
+ * @returns {Date} 此函数的调用时间的Date对象
+ * @example
+ * let startTime = logFakeScriptStart();
+ * // do something;
+ * logFakeScriptEnd({ startTime: startTime });
+ */
+function logFakeScriptStart(scriptName = null) {
+ if (!scriptName) {
+ if (!scriptContext.scriptName) {
+ scriptContext.scriptName = getScriptName();
+ }
+ scriptName = scriptContext.scriptName;
+ }
+ return _fakeLogCore(scriptName, true);
+}
+
+/**
+ * 在日志文件中创建可供BGI解析耗时的脚本运行记录,Start和End两个函数需配对使用
+ * @param {Object} params
+ * @param {string|null} [params.scriptName=null] - 脚本名,留空时将自动获取
+ * @param {Date} [params.startTime=new Date()] - 调用`logFakeScriptStart`时返回的Date对象
+ * @returns {Date} 此函数的调用时间的Date对象
+ * @example
+ * let startTime = logFakeScriptStart();
+ * // do something;
+ * logFakeScriptEnd({ startTime: startTime });
+ */
+function logFakeScriptEnd({ scriptName = null, startTime = new Date() } = {}) {
+ if (!scriptName) {
+ if (!scriptContext.scriptName) {
+ scriptContext.scriptName = getScriptName();
+ }
+ scriptName = scriptContext.scriptName;
+ }
+ return _fakeLogCore(scriptName, true, startTime);
+}
+
+/**
+ * 等待传送结束
+ * @param {Int} timeout 单位为ms
+ * @note 参考了七圣召唤七日历练脚本
+ */
+async function waitTpFinish(timeout = 30000) {
+ const region = RecognitionObject.ocr(1690, 230, 75, 350); // 队伍名称区域
+ const startTime = Date.now();
+
+ await sleep(500); //点击传送后等待一段时间避免误判
+ while (Date.now() - startTime < timeout) {
+ const ro = captureGameRegion();
+ let res = ro.find(region);
+ ro.dispose();
+ if (!res.isEmpty()) {
+ await sleep(600); //传送结束后有僵直
+ return;
+ }
+ await sleep(100);
+ }
+ throw new Error("传送时间超时");
+}
+
+function calculateDistance(point1, point2) {
+ const deltaX = point1.x - point2.x;
+ const deltaY = point1.y - point2.y;
+ const distance = Math.sqrt(Math.pow(deltaX, 2) + Math.pow(deltaY, 2));
+ return distance;
+}
diff --git a/repo/js/CD-Aware-AutoGather/lib/ocr.js b/repo/js/CD-Aware-AutoGather/lib/ocr.js
new file mode 100644
index 000000000..90ec5efc3
--- /dev/null
+++ b/repo/js/CD-Aware-AutoGather/lib/ocr.js
@@ -0,0 +1,200 @@
+const defaultReplacementMap = {
+ 监: "盐",
+ 卵: "卯",
+};
+
+/**
+ * 绘制方框以标记OCR区域
+ *
+ * @param {number[4] | {x: number, y: number, width: number, height: number}} ocrRegion - OCR 区域,数组或对象形式,表示 [x, y, width, height]
+ * @param {object|null} [captureRegion=null] - 截图区域对象。为 null 时自动调用 captureGameRegion()
+ * @throws {TypeError} 当 ocrRegion 既不是数组也不是对象时抛出。
+ */
+function drawOcrRegion(ocrRegion, captureRegion = null) {
+ let x, y, width, height;
+ if (Array.isArray(ocrRegion)) {
+ [x, y, width, height] = ocrRegion;
+ } else if (typeof ocrRegion === "object" && ocrRegion !== null) {
+ ({ x, y, width, height } = ocrRegion);
+ } else {
+ throw new TypeError("'ocrRegion' must be an array [x, y, width, height] or an object with x, y, width, height properties");
+ }
+
+ let auto_created = false;
+ if (captureRegion === null) {
+ captureRegion = captureGameRegion();
+ auto_created = true;
+ }
+ let region = captureRegion.DeriveCrop(x, y, width, height);
+ let name = [x, y, width, height].toString();
+ region.DrawSelf(name);
+ region.dispose();
+ if (auto_created) {
+ captureRegion.dispose();
+ }
+}
+
+async function getTextInRegion(ocrRegion, timeout = 5000, retryInterval = 50, replacementMap = defaultReplacementMap) {
+ let x, y, width, height;
+ if (Array.isArray(ocrRegion)) {
+ [x, y, width, height] = ocrRegion;
+ } else if (typeof ocrRegion === "object" && ocrRegion !== null) {
+ ({ x, y, width, height } = ocrRegion);
+ } else {
+ throw new Error("Invalid parameter 'ocrRegion'");
+ }
+
+ const debugThreshold = timeout / retryInterval / 3;
+ let startTime = Date.now();
+ let retryCount = 0; // 重试计数
+ while (Date.now() - startTime < timeout) {
+ let captureRegion = captureGameRegion();
+ try {
+ // 尝试 OCR 识别
+ let resList = captureRegion.findMulti(RecognitionObject.ocr(x, y, width, height)); // 指定识别区域
+ // 遍历识别结果,检查是否找到目标文本
+ for (let res of resList) {
+ // 后处理:根据替换映射表检查和替换错误识别的字符
+ let correctedText = res.text;
+ for (let [wrongChar, correctChar] of Object.entries(replacementMap)) {
+ correctedText = correctedText.replace(new RegExp(wrongChar, "g"), correctChar);
+ }
+
+ captureRegion.dispose();
+ return correctedText.trim();
+ }
+ } catch (error) {
+ log.warn(`页面标志识别失败,正在进行第 ${retryCount} 次重试...`);
+ }
+ retryCount++; // 增加重试计数
+ if (retryCount > debugThreshold) {
+ let region = captureRegion.DeriveCrop(x, y, width, height);
+ region.DrawSelf("debug");
+ region.dispose();
+ }
+ captureRegion.dispose();
+ await sleep(retryInterval);
+ }
+ return null;
+}
+
+async function waitForTextAppear(targetText, ocrRegion, timeout = 5000, retryInterval = 50, replacementMap = defaultReplacementMap) {
+ let x, y, width, height;
+
+ if (Array.isArray(ocrRegion)) {
+ [x, y, width, height] = ocrRegion;
+ } else if (typeof ocrRegion === "object" && ocrRegion !== null) {
+ ({ x, y, width, height } = ocrRegion);
+ } else {
+ throw new Error("Invalid parameter 'ocrRegion'");
+ }
+
+ const debugThreshold = timeout / retryInterval / 3;
+ let startTime = Date.now();
+ let retryCount = 0; // 重试计数
+ while (Date.now() - startTime < timeout) {
+ let captureRegion = captureGameRegion();
+ try {
+ // 尝试 OCR 识别
+ let resList = captureRegion.findMulti(RecognitionObject.ocr(x, y, width, height)); // 指定识别区域
+ // 遍历识别结果,检查是否找到目标文本
+ for (let res of resList) {
+ // 后处理:根据替换映射表检查和替换错误识别的字符
+ let correctedText = res.text;
+ for (let [wrongChar, correctChar] of Object.entries(replacementMap)) {
+ correctedText = correctedText.replace(new RegExp(wrongChar, "g"), correctChar);
+ }
+
+ if (correctedText.includes(targetText)) {
+ captureRegion.dispose();
+ return { success: true, wait_time: Date.now() - startTime };
+ }
+ }
+ } catch (error) {
+ log.warn(`页面标志识别失败,正在进行第 ${retryCount} 次重试...`);
+ }
+ retryCount++; // 增加重试计数
+ if (retryCount > debugThreshold) {
+ let region = captureRegion.DeriveCrop(x, y, width, height);
+ region.DrawSelf("debug");
+ region.dispose();
+ }
+ captureRegion.dispose();
+ await sleep(retryInterval);
+ }
+ return { success: false };
+}
+
+async function recognizeTextAndClick(targetText, ocrRegion, timeout = 5000, retryInterval = 50, replacementMap = defaultReplacementMap) {
+ let x, y, width, height;
+
+ if (Array.isArray(ocrRegion)) {
+ [x, y, width, height] = ocrRegion;
+ } else if (typeof ocrRegion === "object" && ocrRegion !== null) {
+ ({ x, y, width, height } = ocrRegion);
+ } else {
+ throw new Error("Invalid parameter 'ocrRegion'");
+ }
+
+ const debugThreshold = timeout / retryInterval / 3;
+ let startTime = Date.now();
+ let retryCount = 0; // 重试计数
+ while (Date.now() - startTime < timeout) {
+ let captureRegion = captureGameRegion();
+ try {
+ // 尝试 OCR 识别
+ let resList = captureRegion.findMulti(RecognitionObject.ocr(x, y, width, height)); // 指定识别区域
+ // 遍历识别结果,检查是否找到目标文本
+ for (let res of resList) {
+ // 后处理:根据替换映射表检查和替换错误识别的字符
+ let correctedText = res.text;
+ for (let [wrongChar, correctChar] of Object.entries(replacementMap)) {
+ correctedText = correctedText.replace(new RegExp(wrongChar, "g"), correctChar);
+ }
+
+ if (correctedText.includes(targetText)) {
+ // 如果找到目标文本,计算并点击文字的中心坐标
+ let centerX = Math.round(res.x + res.width / 2);
+ let centerY = Math.round(res.y + res.height / 2);
+ await click(centerX, centerY);
+ await sleep(50);
+ captureRegion.dispose();
+ return { success: true, x: centerX, y: centerY };
+ }
+ }
+ } catch (error) {
+ log.warn(`页面标志识别失败,正在进行第 ${retryCount} 次重试...`);
+ }
+ retryCount++; // 增加重试计数
+ if (retryCount > debugThreshold) {
+ let region = captureRegion.DeriveCrop(x, y, width, height);
+ region.DrawSelf("debug");
+ region.dispose();
+ }
+ captureRegion.dispose();
+ await sleep(retryInterval);
+ }
+ return { success: false };
+}
+
+async function isTextExistedInRegion(searchText, ocrRegion) {
+ let x, y, width, height;
+ if (Array.isArray(ocrRegion)) {
+ [x, y, width, height] = ocrRegion;
+ } else if (typeof ocrRegion === "object" && ocrRegion !== null) {
+ ({ x, y, width, height } = ocrRegion);
+ } else {
+ throw new Error("Invalid parameter 'ocrRegion'");
+ }
+ let captureRegion = captureGameRegion();
+ const result = captureRegion.find(RecognitionObject.ocr(x, y, width, height));
+ captureRegion.dispose();
+ if (result.text) {
+ if (typeof searchText === "string") {
+ return result.text.includes(searchText);
+ } else if (searchText instanceof RegExp) {
+ return result.text.match(searchText);
+ }
+ }
+ return false;
+}
diff --git a/repo/js/CD-Aware-AutoGather/main.js b/repo/js/CD-Aware-AutoGather/main.js
index 423632a21..c97d64b30 100644
--- a/repo/js/CD-Aware-AutoGather/main.js
+++ b/repo/js/CD-Aware-AutoGather/main.js
@@ -1,23 +1,24 @@
eval(file.readTextSync("lib/lib.js"));
+eval(file.readTextSync("lib/ocr.js"));
+eval(file.readTextSync("lib/inventory.js"));
const settingFile = "settings.json";
const defaultTime = getDefaultTime();
-const CooldownDataBase = readRefreshInfo("CooldownData.txt");
+const countryList = ["蒙德", "璃月", "稻妻", "须弥", "枫丹", "纳塔", "挪德卡莱", "至冬"];
+const collectAbility = {
+ hydro_collect: "水",
+ electro_collect: "雷",
+ anemo_collect: "风",
+ pyro_collect: "火",
+ nahida_collect: "纳西妲",
+};
let stopAtTime = null;
let currentParty = null;
+let currentMap = "Teyvat";
+let worldInfo = null;
+let partyAbility = {};
let runMode = settings.runMode;
-let subscribeMode = settings.subscribeMode;
-
-// 用用戶自定義的唯一ID來區分不同帳號
-async function get_profile_name() {
- if (!settings.iHaveMultipleAccounts || settings.iHaveMultipleAccounts.trim() === "") {
- // 使用OCR讀取UID作為目錄名
- const uid = await getGameAccount(true, true);
- return String(uid);
- }
- return settings.iHaveMultipleAccounts.toString().trim();
-}
class ReachStopTime extends Error {
constructor(message) {
@@ -33,171 +34,229 @@ class UserCancelled extends Error {
}
(async function () {
+ setGameMetrics(1920, 1080, 1.25);
+
if (!file.IsFolder("pathing")) {
let batFile = "SymLink.bat";
- log.error("{0}文件夹不存在,请双击运行本脚本目录下的{1}文件以创建文件夹链接", "pathing", batFile);
+ log.error("{0}文件夹不存在,请在BetterGI中右键点击本脚本,选择{1}。然后双击脚本目录下的{2}文件以创建文件夹链接", "pathing", "打开所在目录", batFile);
return;
}
if (!runMode) {
- const defaultRunMode = "采集选中的材料";
+ const defaultRunMode = "扫描文件夹更新可选材料列表";
log.warn("运行模式 未选择或无效: {0},默认为{1}", runMode, defaultRunMode);
runMode = defaultRunMode;
- }
- if (!subscribeMode) {
- const defaultSubscribeMode = "每次自动扫描,并采集扫描到的所有材料";
- log.warn("已订阅的任务目录的处理方式 未选择或无效: {0},默认为{1}", subscribeMode, defaultSubscribeMode);
- subscribeMode = defaultSubscribeMode;
- }
- if (!settings.runMode || !settings.subscribeMode) {
await sleep(3000);
}
log.info("当前运行模式:{0}", runMode);
if (runMode === "扫描文件夹更新可选材料列表") {
await runScanMode();
+ settings.runMode = "采集选中的材料";
+ log.info("扫描完成,自动更新设置:下次脚本将以{0}模式运行", settings.runMode);
} else if (runMode === "采集选中的材料") {
let startTime = logFakeScriptStart();
- log.info("已订阅的任务目录的处理方式:{0}", subscribeMode);
- if (subscribeMode === "每次自动扫描,并采集扫描到的所有材料") {
- await runScanMode();
- }
await runGatherMode();
logFakeScriptEnd({ startTime: startTime });
- } else if (runMode === "清除运行记录(重置材料刷新时间)") {
- await runClearMode();
}
})();
// 扫描文件夹更新可选材料列表
async function runScanMode() {
- // 1. 扫描所有最底层路径
- const focusFolders = ["地方特产", "矿物", "食材与炼金"];
- const pathList = focusFolders.flatMap((fd) => getLeafFolders(`pathing/${fd}`));
-
- // 2. 读取配置模板
- const templateText = file.readTextSync("settings.template.json");
+ // 读取配置模板
+ const templateText = readTextSync("assets/settings.template.json");
let config = JSON.parse(templateText);
+ const configMap = {};
- // 将地方特产按照国家顺序排序
- const countryList = ["蒙德", "璃月", "稻妻", "须弥", "枫丹", "纳塔", "挪德卡莱", "至冬"];
- const sortedList = pathList.slice().sort((a, b) => {
- const getRegion = (p) => p.split("\\")[2];
- const aIndex = countryList.indexOf(getRegion(a));
- const bIndex = countryList.indexOf(getRegion(b));
- return (aIndex === -1 ? Infinity : aIndex) - (bIndex === -1 ? Infinity : bIndex);
- });
-
- // 3. 处理每个路径
- let count = 0;
- actions_map = {};
- for (const path of sortedList) {
- const info = getCooldownInfoFromPath(path);
- const jsonFiles = filterFilesInTaskDir(info.label);
-
- if (jsonFiles.length === 0) {
- log.info("{0}内无json文件,跳过", path);
- } else if (info.coolType === null) {
- log.warn("路径{0}未匹配到对应的刷新机制,跳过", path);
- await sleep(100);
- } else {
- config.push({
- name: info.name,
- label: "⬇️ " + info.label,
- type: "checkbox",
- });
- count += 1;
-
- const actions = scanSpecialCollectMethod(jsonFiles);
- if (actions.length > 0) {
- actions_map[path] = actions;
- }
- }
+ // 扫描地方特产
+ const { countryToSpecialties, specialtyToFiles } = scanLocalSpecialty();
+ const localSpecialtyByCountry = {};
+ for (const [country, specialties] of Object.entries(countryToSpecialties)) {
+ localSpecialtyByCountry[country] = {};
+ specialties.forEach((specialty) => {
+ localSpecialtyByCountry[country][specialty] = specialtyToFiles[specialty] || [];
+ });
}
- // 4. 写入新的配置(格式化输出)
+ configMap["selectLocalSpecialtyByCountry"] = localSpecialtyByCountry;
+ const cfgLocalSpecialtyByCountry = Object.keys(localSpecialtyByCountry).map((country, index) => {
+ const name = "selectLocalSpecialty_" + country;
+ configMap[name] = localSpecialtyByCountry[country];
+ return {
+ label: (index === 0 ? "\n【单独选择地方特产】\n\n" : "") + `${country}`,
+ type: "multi-checkbox",
+ name: name,
+ options: Object.keys(localSpecialtyByCountry[country]).sort((a, b) => a.localeCompare(b, "zh")),
+ };
+ });
+ config = config.concat(cfgLocalSpecialtyByCountry);
+
+ // 扫描食材与炼金材料
+ const otherJsonFiles = scanAndFilterJsonFiles("pathing/食材与炼金");
+ const otherMaterialByName = await groupByMaterialName(otherJsonFiles);
+ const cfgOtherMaterial = {
+ label: "\n【食材与炼金】",
+ type: "multi-checkbox",
+ name: "selectFoodAndAlchemy",
+ options: Object.keys(otherMaterialByName).sort((a, b) => a.localeCompare(b, "zh")),
+ };
+ const miscEntry = config.find((entry) => entry.name === "selectMiscellaneous");
+ const miscOptions = miscEntry && miscEntry.options ? miscEntry.options : [];
+ configMap["selectMiscellaneous"] = miscOptions.reduce((acc, k) => {
+ if (k in otherMaterialByName) {
+ acc[k] = otherMaterialByName[k];
+ }
+ return acc;
+ }, {});
+ configMap["selectFoodAndAlchemy"] = otherMaterialByName;
+
+ if (Object.keys(otherMaterialByName).length > 0) {
+ config.push({ type: "separator" });
+ config.push(cfgOtherMaterial);
+ }
+
+ const forgingOreJsonFiles = scanAndFilterJsonFiles("pathing/矿物");
+ const forgingOreByname = await groupByMaterialName(forgingOreJsonFiles);
+ configMap["selectForgingOre"] = forgingOreByname;
+ const flattenedSpecialties = Object.assign({}, ...Object.values(localSpecialtyByCountry));
+ const allMaterials = {
+ ...flattenedSpecialties,
+ ...forgingOreByname,
+ ...(otherMaterialByName["晶蝶"] ? { 晶蝶: otherMaterialByName["晶蝶"] } : {}),
+ ...otherMaterialByName,
+ };
+
+ // 生成按大类选择的配置数据
+ configMap["selectByCategory"] = {
+ 地方特产: specialtyToFiles,
+ 矿物: forgingOreByname,
+ 食材与炼金: otherMaterialByName,
+ };
+
+ const multiRouteMaterials = {};
+ for (const [material, paths] of Object.entries(allMaterials)) {
+ const dirs = [...new Set(paths.map((p) => p.substring(0, p.lastIndexOf("\\"))))];
+ if (dirs.length <= 1) continue;
+
+ // 1. 计算公共前缀长度
+ const sorted = [...dirs].sort();
+ let pLen = 0;
+ while (pLen < sorted[0].length && sorted[0][pLen] === sorted[sorted.length - 1][pLen]) pLen++;
+
+ const groupMap = {};
+ paths.forEach((path) => {
+ const dir = path.substring(0, path.lastIndexOf("\\"));
+
+ // 2. 仅去除公共前缀、开头的材料名及反斜杠
+ let name = dir.slice(pLen);
+ if (name.startsWith(material)) name = name.slice(material.length);
+ name = name.replace(/^\\+/, "") || "(根目录)";
+
+ (groupMap[name] ??= []).push(path);
+ });
+
+ multiRouteMaterials[material] = groupMap;
+ }
+
+ const cfgMultiRouteMaterials = Object.entries(multiRouteMaterials).map(([material, groupMap], idx) => {
+ const firstPaths = Object.values(groupMap)[0][0]; // 假设 groupMap 的值是路径数组
+ const items = firstPaths.split("\\");
+ const mIndex = items.indexOf(material);
+ const tip_items = items.slice(1, mIndex + 1);
+ const name = "selectRoute_" + material;
+ configMap[name] = groupMap;
+ return {
+ label: (idx === 0 ? "\n【对于具有多版本路线的物品,选择要使用的路线】\n\n" : "") + tip_items.join("\\"),
+ type: "multi-checkbox",
+ name: name,
+ options: Object.keys(groupMap).sort((a, b) => a.localeCompare(b, "zh")),
+ };
+ });
+ if (Object.keys(multiRouteMaterials).length > 0) {
+ config.push({ type: "separator" });
+ config = config.concat(cfgMultiRouteMaterials);
+ }
+ // 写入新的配置(格式化输出)
file.writeTextSync(settingFile, JSON.stringify(config, null, 2));
- log.info("共{0}组有效路线,请在脚本配置中勾选需要采集的材料", count);
- // 5. 分析所需角色信息
- analysisCharacterRequirement(actions_map);
- await sleep(3000);
+ return configMap;
}
// 采集选中的材料
async function runGatherMode() {
- if (settings.stopAtTime) {
- stopAtTime = settings.stopAtTime;
+ if (settings.excludeTimeRange) {
+ const { duringRange, nearestStopTime } = checkExecutionExcludeTime(settings.excludeTimeRange);
+ if (duringRange) {
+ log.info("当前处于设定的不运行时间段: {0}", duringRange);
+ return;
+ }
+ stopAtTime = nearestStopTime;
log.info("脚本已被配置为达到{0}后停止运行", stopAtTime);
}
- const selectedMaterials = getSelectedMaterials();
+ const configMap = await runScanMode();
+ // file.writeTextSync("configMap.json", JSON.stringify(configMap, null, 2));
+ const isAllEmpty = Object.values(configMap.selectByCategory).every((value) => Object.keys(value).length === 0);
+ if (isAllEmpty) {
+ log.error("尚未订阅任何路线,请在BetterGI中订阅需要的路线后再运行");
+ return;
+ }
- if (selectedMaterials.length === 0) {
+ const selectedMaterials = getSelectedMaterials(configMap);
+ const materialNames = Object.keys(selectedMaterials);
+ if (materialNames.length === 0) {
log.error("未选择任何材料,请在脚本配置中勾选所需项目");
return;
}
+ log.info("共选中{0}种材料: {1}", materialNames.length, materialNames.join(", "));
- 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 = settings.manualSetAccountName || "";
+ if (!account) {
+ worldInfo = await getCoOpModeAndHostUid();
+ // 使用掩码后的UID作为账户名,避免浮窗和日志等意外暴露用户UID
+ account = worldInfo.maskUid;
}
- let account = await get_profile_name();
- log.info("为{0}采集材料并管理CD", account);
-
- // 1. 先检查是否有任何任务需要执行(CD已刷新)
- let hasExpiredTask = false;
- const defaultTimeValue = getDefaultTime();
-
- for (const pathTask of selectedMaterials) {
- const recordFile = getRecordFilePath(account, pathTask);
- const jsonFiles = pathTask.jsonFiles;
-
- // 读取记录文件(与原始逻辑完全相同)
- 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);
- // 记录文件不存在或格式错误,说明有任务需要执行
- hasExpiredTask = true;
- break;
- }
-
- // 检查是否有文件过期(使用相同的保底机制)
- for (const jsonPath of jsonFiles) {
- const fileName = basename(jsonPath);
- const lastTime = recordMap[fileName] || defaultTimeValue; // 相同的保底逻辑
- if (Date.now() > lastTime) {
- hasExpiredTask = true;
- break;
- }
- }
-
- if (hasExpiredTask) {
- break;
- }
- }
-
- if (!hasExpiredTask) {
+ const groupedTasks = groupTasksByMaterialsName(selectedMaterials, account);
+ const refreshedMaterials = Object.entries(groupedTasks)
+ .filter(([name, info]) => info.refreshed === true)
+ .map(([name, info]) => name);
+ if (refreshedMaterials.length === 0) {
log.info("所有选中的材料都还在冷却中,无需执行");
return;
}
-
- // 2. 有任务需要执行,现在才切换队伍
- await switchPartySafely(settings.partyName);
- currentParty = settings.partyName;
+ // 按物品数量计算实际待执行的任务,并按照数量差额从大到小的顺序排序
+ updateTargetCountOfTasks(groupedTasks, configMap, account, settings.targetCountOfSelected);
+ const groupedTasksToRun = await calculateTodoTasksByCount(groupedTasks);
+ const sortedTasksToRun = sortTasksByGap(groupedTasksToRun);
+ // file.writeTextSync("sortedTasksToRun.json", JSON.stringify(sortedTasksToRun, null, 2));
+
+ log.info("共{0}种材料需要采集,将从缺失数量最多的材料开始", Object.keys(sortedTasksToRun).length);
+ for (const [name, { target, current, tasks }] of Object.entries(sortedTasksToRun)) {
+ const coolType = tasks[0].coolType;
+ const targetTxt = target === null ? "∞" : target;
+ log.info(` - {0} (${coolType}, ${current} -> ${targetTxt})`, name);
+ // 短暂地休眠以便用户有机会看清日志。相比大世界随便个动作花的时间,这都不算啥
+ await sleep(50);
+ }
+
+ log.info("在{0}的世界采集材料并管理CD", account);
+ // 传送到神像,回血,安全切换队伍,确保 currentMap 是提瓦特
+ log.info("前往神像进行采集前的准备工作");
+ await genshin.tpToStatueOfTheSeven();
+ if (worldInfo === null) {
+ worldInfo = await getCoOpModeAndHostUid();
+ }
+ if (worldInfo.coOpMode) {
+ log.info("当前处于联机模式,不执行队伍切换");
+ } else {
+ await switchPartySafely(settings.partyName);
+ currentParty = settings.partyName;
+ }
+
+ // 开始实际采集
dispatcher.addTimer(new RealtimeTimer("AutoPick"));
- // 3. 执行所有选中的任务(原始函数内部会再次检查CD)
try {
- for (const pathTask of selectedMaterials) {
- await runPathTaskIfCooldownExpired(account, pathTask);
+ for (const [name, taskInfo] of Object.entries(sortedTasksToRun)) {
+ await runPathTaskIfCooldownExpired(name, taskInfo);
}
} catch (e) {
if (e instanceof ReachStopTime) {
@@ -210,32 +269,218 @@ async function runGatherMode() {
}
}
-// 清除运行记录(重置材料刷新时间)
-async function runClearMode() {
- const selectedMaterials = getSelectedMaterials();
+function scanAndFilterJsonFiles(folderPath) {
+ const jsonFiles = getFilesBySuffix(folderPath, ".json");
+ jsonFiles.sort((a, b) => a.localeCompare(b, "zh", { numeric: true }));
- if (selectedMaterials.length === 0) {
- log.error("未选择任何材料,请在脚本配置中勾选所需项目");
+ const filterConfig = settings.filterPathByKeywords;
+ if (!filterConfig || !filterConfig.trim()) return jsonFiles;
+
+ let finalRegex;
+ // 1. 如果以 regex: 开头,直接解析后面的内容
+ if (filterConfig.startsWith("regex:")) {
+ const pattern = filterConfig.replace("regex:", "").trim();
+ finalRegex = new RegExp(pattern);
}
- const resetTimeStr = formatDateTime(getDefaultTime());
- let account = await get_profile_name();
- for (const pathTask of selectedMaterials) {
- const recordFile = getRecordFilePath(account, pathTask);
- const lines = pathTask.jsonFiles.map((filePath) => {
- return `${basename(filePath)}\t${resetTimeStr}`;
+ // 2. 如果以 include: 开头,生成“至少包含其一”的正则
+ else if (filterConfig.startsWith("include:")) {
+ const keywords = filterConfig.replace("include:", "").trim().split(/\s+/);
+ // 逻辑:(词A|词B|词C)
+ const pattern = `(${keywords.join("|")})`;
+ finalRegex = new RegExp(pattern);
+ }
+ // 3. 否则,生成“不得包含任一”的正则 (排除模式)
+ else {
+ const keywords = filterConfig.trim().split(/\s+/);
+ // 逻辑:使用负向先行断言 ^((?!词A|词B|词C).)*$
+ // 这表示从头到尾的任何位置都不能匹配到关键词
+ const pattern = `^((?!(?:${keywords.join("|")})).)*$`;
+ finalRegex = new RegExp(pattern);
+ }
+
+ const result = jsonFiles.reduce(
+ (acc, path) => {
+ if (finalRegex.test(path)) {
+ acc.passed.push(path);
+ } else {
+ acc.excluded.push(path);
+ }
+ return acc;
+ },
+ { passed: [], excluded: [] }
+ );
+
+ if (result.excluded.length > 0) {
+ log.info(`{0}扫描完成:根据配置排除了${result.excluded.length}条路线,详见日志`, basename(folderPath));
+ log.debug("过滤配置: {0}, 排除的文件:\n" + result.excluded.join("\n"), filterConfig);
+ }
+ return result.passed;
+}
+
+function getMaterialCD(name, path = null) {
+ let cdType = getItemCD(name);
+ if (cdType === null && path !== null) {
+ if (path.includes("地方特产")) {
+ cdType = "46小时";
+ }
+ }
+ return cdType;
+}
+
+function groupTasksByMaterialsName(selectedMaterials, account) {
+ const groupedTasks = {};
+ const materialRefreshStatus = {};
+ for (const [name, jsonFiles] of Object.entries(selectedMaterials)) {
+ // 每个材料名下可能有多个不同的目录任务。使用对象暂存,以便按 dirSlug 去重
+ const tasksMap = {};
+ const coolType = getMaterialCD(name, jsonFiles[0]);
+ materialRefreshStatus[name] = false;
+
+ for (const jsonPath of jsonFiles) {
+ const parts = jsonPath.split("\\");
+ const fileName = parts[parts.length - 1]; // 获取最后一行(文件名)
+ // 路径处理:去掉首项(pathing)和末项(文件名),提取中间目录
+ const dirPath = parts.slice(1, parts.length - 1).join("\\");
+ const dirSlug = dirPath.replace(/[^\u4e00-\u9fa5\w]+/g, "_");
+ const recordFile = `record/${account}/${dirSlug}.txt`;
+ // 如果这个分组还没初始化,则初始化
+ if (!tasksMap.hasOwnProperty(dirSlug)) {
+ const refreshTime = {};
+ if (fileExists(recordFile)) {
+ try {
+ const text = readTextSync(recordFile);
+ if (text) {
+ for (const line of text.split("\n")) {
+ const pair = line.trim().split("\t");
+ if (pair.length >= 2) {
+ const [fName, t] = pair;
+ refreshTime[fName] = new Date(t);
+ }
+ }
+ }
+ } catch (error) {
+ log.debug("解析运行记录文件时出错: {0}", error.toString());
+ }
+ } else {
+ log.debug("记录文件不存在: {0}", recordFile);
+ }
+
+ // 将同一目录下的所有 JSON 归纳为一个 Task
+ tasksMap[dirSlug] = {
+ label: dirSlug,
+ coolType: coolType,
+ recordFile: recordFile,
+ jsonFiles: [],
+ refreshTime: refreshTime,
+ };
+ }
+
+ // 将当前 JSON 文件加入到对应分组的 jsonFiles 列表中
+ if (!tasksMap[dirSlug].jsonFiles.includes(jsonPath)) {
+ tasksMap[dirSlug].jsonFiles.push(jsonPath);
+ }
+ const nextRefreshTime = tasksMap[dirSlug]['refreshTime'][fileName] || defaultTime;
+ if (Date.now() > nextRefreshTime) {
+ materialRefreshStatus[name] = true;
+ }
+ }
+ // 将该材料下所有的任务组转为数组存入 groupedTasks
+ groupedTasks[name] = { refreshed: false, target: null, current: 0, tasks: Object.values(tasksMap) };
+ }
+ Object.entries(materialRefreshStatus).forEach(([name, status]) => {
+ groupedTasks[name].refreshed = status;
+ });
+ return groupedTasks;
+}
+
+function updateTargetCountOfTasks(groupedTasks, configMap, account, targetCount) {
+ const csvFile = `record/${account}/采集目标.csv`;
+ if (targetCount) {
+ const targetCountText = targetCount.trim().toLowerCase();
+ if (targetCountText === "csv") {
+ log.info("使用{0}中设置的采集目标", csvFile);
+ // 1 基于当前的 configMap 得到结构
+ const { hierarchy, materialPaths } = getInitialHierarchy(configMap);
+ // 2 与 CSV 文件同步(读取旧值 + 补全缺失 + 写回)
+ const syncedData = syncWithCsv(csvFile, hierarchy);
+ // 3 计算最终生效的数量:结果将是 { "绯樱绣球": 20, ... }
+ const materialsTarget = calculateFinalTargets(syncedData, materialPaths);
+ for (const [name, target] of Object.entries(materialsTarget)) {
+ if (groupedTasks.hasOwnProperty(name)) {
+ groupedTasks[name].target = target;
+ }
+ }
+ } else {
+ const fixedCount = parseInt(targetCountText, 10);
+ if (isNaN(fixedCount)) {
+ log.error("采集目标数量设置无效{0},终止运行", targetCount);
+ throw new Error("Invalid target count");
+ }
+ for (const info of Object.values(groupedTasks)) {
+ info.target = fixedCount;
+ }
+ }
+ } else {
+ log.info("未设置采集目标数量");
+ }
+}
+
+async function calculateTodoTasksByCount(groupedTasks) {
+ // 获取目标不为null的材料的当前数量
+ const materialsHasTarget = Object.keys(groupedTasks).filter((name) => groupedTasks[name].target !== null);
+ let currentCounts = {};
+ if (materialsHasTarget.length === 0) {
+ log.info("所有选中材料的采集目标均为空");
+ } else {
+ currentCounts = await getItemCount(materialsHasTarget);
+ const unknownCountMaterials = [];
+ Object.entries(currentCounts).forEach(([key, value]) => {
+ if (value < 0) {
+ unknownCountMaterials.push(key);
+ }
+ groupedTasks[key].current = value < 0 ? 0 : value;
});
- const content = lines.join("\n");
- file.writeTextSync(recordFile, content);
- log.info("已重置{0}的刷新时间", pathTask.label);
+ if (unknownCountMaterials.length > 0) {
+ log.warn("获取以下材料的数量失败,默认视为0: {0}", unknownCountMaterials.join(", "));
+ }
}
- log.info("已重置{0}组刷新时间。如需重置所有材料刷新时间,请直接删除record目录下对应账号的文件夹", selectedMaterials.length);
+
+ const groupedTasksToRun = {};
+ const skippedSummary = { 未刷新: [], 数量已达标: [] };
+ for (const [name, info] of Object.entries(groupedTasks)) {
+ const { refreshed, target, current } = info;
+ let reason = "";
+ if (refreshed) {
+ if (target !== null && target <= current) {
+ log.debug(`{0}的数量已达标 (${current}/${target})`, name);
+ reason = "数量已达标";
+ }
+ } else {
+ reason = "未刷新";
+ }
+ if (reason) {
+ skippedSummary[reason].push(name);
+ } else {
+ groupedTasksToRun[name] = info;
+ }
+ }
+ for (const [reason, names] of Object.entries(skippedSummary)) {
+ if (names.length > 0) {
+ log.info(`跳过{0}种${reason}的材料: {1}`, names.length, names.join(","));
+ await sleep(100);
+ }
+ }
+ return groupedTasksToRun;
}
function scanSpecialCollectMethod(jsonFiles) {
const actions = jsonFiles.flatMap((filePath) => {
try {
- const data = JSON.parse(file.readTextSync(filePath));
- return data.positions.map((p) => p.action).filter((a) => a);
+ const data = JSON.parse(readTextSync(filePath));
+ return data.positions
+ .map((p) => p.action)
+ .filter((a) => a) // 确保 action 存在
+ .map((a) => collectAbility[a] ?? a);
} catch (e) {
log.warn(`json文件无效: {0}: ${e.message}`, filePath);
return [];
@@ -244,15 +489,310 @@ function scanSpecialCollectMethod(jsonFiles) {
return [...new Set(actions)];
}
-function readRefreshInfo(filePath) {
- const lines = file.readTextSync(filePath).split(/\r?\n/);
- const dict = {};
- for (const line of lines) {
- if (!line.trim()) continue; // 跳过空行
- const [key, value] = line.split(":");
- dict[key.trim()] = value ? value.trim() : "";
+// 扫描地方特产并按国家排序
+function scanLocalSpecialty() {
+ const countryToSpecialtiesRaw = {}; // 暂存 国家 -> Set(特产名)
+ const specialtyToFiles = {}; // 映射 特产名 -> [路径列表]
+ const separator = "\\";
+
+ const jsonFiles = scanAndFilterJsonFiles("pathing/地方特产");
+ // 1. 遍历并归类数据
+ jsonFiles.forEach((path) => {
+ const parts = path.split(separator);
+ const idx = parts.indexOf("地方特产");
+ if (idx !== -1 && parts[idx + 2]) {
+ const country = parts[idx + 1];
+ const specialty = parts[idx + 2];
+ // 填充 特产名 -> 路径列表
+ (specialtyToFiles[specialty] ??= []).push(path);
+ // 填充 国家 -> 特产名集合 (使用 Set 自动去重)
+ (countryToSpecialtiesRaw[country] ??= new Set()).add(specialty);
+ }
+ });
+
+ // 2. 按照 countryList 排序国家映射,并将 Set 转换为 Array
+ const sortedCountries = Object.keys(countryToSpecialtiesRaw).sort((a, b) => {
+ const indexA = countryList.indexOf(a);
+ const indexB = countryList.indexOf(b);
+ return (indexA === -1 ? 999 : indexA) - (indexB === -1 ? 999 : indexB);
+ });
+
+ const countryToSpecialties = {};
+ sortedCountries.forEach((country) => {
+ // 将 Set 转换为数组,这样结果就是 { "璃月": ["夜泊石", "星螺"] }
+ countryToSpecialties[country] = Array.from(countryToSpecialtiesRaw[country]);
+ });
+
+ return {
+ countryToSpecialties, // 格式: { "国家名": ["特产1", "特产2"] }
+ specialtyToFiles, // 格式: { "特产名": ["路径1.json", "路径2.json"] }
+ };
+}
+
+async function groupByMaterialName(jsonFiles) {
+ const missingCdInfo = new Set();
+ const materialPathMap = {};
+ const separator = "\\";
+
+ for (const path of jsonFiles) {
+ const parts = path.split(separator);
+ if (parts.length > 2) {
+ const name = parts[2];
+ const cdType = getMaterialCD(name, path);
+ if (cdType === null) {
+ missingCdInfo.add(name);
+ } else {
+ (materialPathMap[name] || (materialPathMap[name] = [])).push(path);
+ }
+ }
}
- return dict;
+ if (missingCdInfo.size > 0) {
+ log.warn("未获取到以下物品的CD信息: {0}", Array.from(missingCdInfo).join(", "));
+ await sleep(200);
+ }
+ return materialPathMap;
+}
+
+/**
+ * 1. 生成扁平化层级字典 (带名字映射)
+ */
+function getInitialHierarchy(configMap) {
+ const hierarchy = {};
+ const materialPaths = new Set(); // 用于记录哪些路径是叶子节点(材料)
+
+ const nameMapping = {
+ selectLocalSpecialtyByCountry: "地方特产",
+ selectForgingOre: "矿物",
+ selectFoodAndAlchemy: "食材与炼金",
+ };
+
+ function traverse(currentObj, currentPath, currentKey) {
+ hierarchy[currentPath] = null;
+
+ // 如果是数组,说明 currentPath 是一个具体的材料路径
+ if (Array.isArray(currentObj)) {
+ materialPaths.add(currentPath);
+ return;
+ }
+
+ if (typeof currentObj !== "object" || currentObj === null) return;
+
+ for (const key in currentObj) {
+ if (Object.prototype.hasOwnProperty.call(currentObj, key)) {
+ traverse(currentObj[key], currentPath + "\\" + key, key);
+ }
+ }
+ }
+
+ for (const [apiKey, chineseName] of Object.entries(nameMapping)) {
+ if (configMap[apiKey]) traverse(configMap[apiKey], chineseName, chineseName);
+ }
+
+ return { hierarchy, materialPaths };
+}
+
+/**
+ * 2. 同步 CSV 文件并回写
+ * 逻辑:保留 CSV 已有的值,新增 configMap 里的新路径,删除已废弃路径
+ * 读取时:非法/空内容 -> null
+ * 写入时:null -> 空字符串
+ */
+function syncWithCsv(filePath, configHierarchy) {
+ const csvData = {};
+
+ // 1. 读取并解析现有 CSV
+ if (fileExists(filePath)) {
+ try {
+ const content = readTextSync(filePath).replace(/^\ufeff/, "");
+ // 使用正则切分行,同时兼容 Windows (\r\n) 和 Linux (\n) 换行符
+ const lines = content.split(/\r?\n/).slice(1); // 跳过标题行
+
+ // 匹配 CSV 字段的正则表达式:
+ // 1. (?:^|,) -> 匹配行首或逗号
+ // 2. "(?:[^"]|"")*" -> 匹配双引号括起来的内容(允许其中包含连续两个双引号 "")
+ // 3. [^,]* -> 或者匹配不含逗号的普通文本
+ const csvRegex = /"(?:[^"]|"")*"|[^,]+/g;
+
+ lines.forEach((line) => {
+ const trimmedLine = line.trim();
+ if (!trimmedLine) return; // 跳过空行
+
+ const parts = [];
+ let match;
+
+ // 使用 exec 循环获取所有匹配的字段
+ while ((match = csvRegex.exec(trimmedLine)) !== null) {
+ let field = match[0];
+
+ // 如果字段被双引号包裹,进行还原处理
+ if (field.startsWith('"') && field.endsWith('"')) {
+ // 去掉首尾引号,并将内部的 "" 还原为 "
+ field = field.slice(1, -1).replace(/""/g, '"');
+ }
+ parts.push(field);
+ }
+
+ if (parts.length >= 2) {
+ const path = parts[0];
+ const rawVal = parts[1];
+
+ // 尝试解析为整数
+ const parsedInt = parseInt(rawVal, 10);
+ // 无法解析为 int 的内容(NaN)都视为 null
+ csvData[path] = isNaN(parsedInt) ? null : parsedInt;
+ }
+ });
+ } catch (e) {
+ log.debug("读取{0}文件时失败,未正确获取到采集目标 ({1})", filePath, e.toString());
+ }
+ } else {
+ log.info("{0}不存在,建立新文件供用户使用", filePath);
+ }
+
+ // 2. 以 configMap 的结构为基准进行合并
+ const updatedDict = {};
+ Object.keys(configHierarchy).forEach((path) => {
+ // 如果 CSV 里有就用 CSV 的解析结果,否则初始化为 null
+ updatedDict[path] = csvData.hasOwnProperty(path) ? csvData[path] : null;
+ });
+
+ // 3. 排序(按深度从浅到深)
+ const sortedKeys = Object.keys(updatedDict).sort((a, b) => {
+ const depthA = (a.match(/\\/g) || []).length;
+ const depthB = (b.match(/\\/g) || []).length;
+ return depthA - depthB || 0;
+ });
+
+ // 4. 写回 CSV (UTF-8 BOM)
+ let csvContent = "\ufeff物品,目标数量\n";
+ sortedKeys.forEach((key) => {
+ const val = updatedDict[key];
+ // 写入时:null 表达为空字符串
+ const displayVal = val === null ? "" : val;
+
+ // 处理路径中可能存在的特殊字符
+ let escapedPath = key;
+ if (key.includes(",") || key.includes('"')) {
+ escapedPath = `"${key.replace(/"/g, '""')}"`;
+ }
+
+ csvContent += `${escapedPath},${displayVal}\n`;
+ });
+
+ try {
+ file.writeTextSync(filePath, csvContent);
+ log.info("CSV配置同步成功: {0}", filePath);
+ } catch (e) {
+ log.info("CSV写入失败: {0} ({1})",filePath, e.toString());
+ }
+
+ return updatedDict;
+}
+
+/**
+ * 计算最终目标数量 (结果只保留材料名称)
+ * @param {Object} syncedDict - 从 CSV 同步后的带路径字典
+ * @param {Set} materialPaths - 材料路径集合
+ */
+function calculateFinalTargets(syncedDict, materialPaths) {
+ const tempPathResults = {}; // 存储路径到计算值的映射
+ const finalMaterialResults = {}; // 存储材料名到计算值的映射
+
+ const sortedPaths = Object.keys(syncedDict);
+
+ sortedPaths.forEach((path) => {
+ const currentVal = syncedDict[path];
+ let calculatedVal;
+
+ // --- 继承逻辑 ---
+ if (currentVal !== null) {
+ calculatedVal = currentVal;
+ } else {
+ // 当前节点为 null,尝试寻找父级
+ const lastSlashIndex = path.lastIndexOf("\\");
+ if (lastSlashIndex !== -1) {
+ const parentPath = path.substring(0, lastSlashIndex);
+ // 继承父级的值 (如果父级也是 null,则继续保持 null)
+ calculatedVal = tempPathResults.hasOwnProperty(parentPath) ? tempPathResults[parentPath] : null;
+ } else {
+ // 顶层节点且为 null
+ calculatedVal = null;
+ }
+ }
+
+ // 存入临时表供后代节点参考
+ tempPathResults[path] = calculatedVal;
+
+ // --- 核心修改:如果是材料节点,则提取名称存入最终结果 ---
+ if (materialPaths.has(path)) {
+ const pathParts = path.split("\\");
+ const materialName = pathParts[pathParts.length - 1];
+ // 最终结果:可能是 数字,也可能是 null
+ finalMaterialResults[materialName] = calculatedVal;
+ }
+ });
+
+ return finalMaterialResults;
+}
+
+/**
+ * 对待运行任务进行排序:按缺失数量降序,null 排最后
+ * @param {Object} tasksToRun - 筛选出的待运行任务字典
+ */
+function sortTasksByGap(tasksToRun) {
+ // 1. 将对象转换为数组 [ [name, info], [name, info], ... ]
+ const taskEntries = Object.entries(tasksToRun);
+
+ // 2. 执行排序
+ taskEntries.sort((a, b) => {
+ const infoA = a[1];
+ const infoB = b[1];
+
+ const isANull = infoA.target === null;
+ const isBNull = infoB.target === null;
+
+ if (isANull && !isBNull) return 1;
+ if (!isANull && isBNull) return -1;
+ if (isANull && isBNull) return 0;
+
+ // 正常数值情况:按 (target - current) 降序排列
+ const gapA = infoA.target - infoA.current;
+ const gapB = infoB.target - infoB.current;
+
+ return gapB - gapA; // 降序:差距大的在前
+ });
+
+ // 3. 将排序后的数组重新构建回对象
+ const sortedTasks = {};
+ taskEntries.forEach(([name, info]) => {
+ sortedTasks[name] = info;
+ });
+
+ return sortedTasks;
+}
+
+function calculateAvatarsAbility(avatars) {
+ const elements_map = JSON.parse(readTextSync("assets/avatar_elements.json"));
+ const avatar2element = {};
+ for (const key in elements_map) {
+ if (elements_map.hasOwnProperty(key)) {
+ const values = elements_map[key];
+ values.forEach((ele) => {
+ avatar2element[ele] = key;
+ });
+ }
+ }
+ const ability_set = new Set();
+ for (const avatar of avatars) {
+ if (avatar === "纳西妲") {
+ ability_set.add(avatar);
+ }
+ const element = avatar2element[avatar];
+ if (element) {
+ ability_set.add(element);
+ }
+ }
+ return [...ability_set];
}
function analysisCharacterRequirement(actions_map) {
@@ -304,7 +844,6 @@ function analysisCharacterRequirement(actions_map) {
const outFile = `各条路线所需角色.txt`;
let text = "";
- // text = JSON.stringify(analysisResult, null, 2);
for (const [key, values] of Object.entries(analysisResult)) {
text += `${key}\n`;
for (const v of values) {
@@ -315,15 +854,6 @@ function analysisCharacterRequirement(actions_map) {
log.info("详细路线需求见{x},可考虑组两支队伍{0}和{1}以满足采集需要", outFile, "钟纳水雷", "钟纳火风");
}
-function getRecordFilePath(account, pathTask) {
- const taskName = pathTask.name.replace(/^OPT_/, "");
- return `record/${account}/${taskName}.txt`;
-}
-
-function filterFilesInTaskDir(taskDir) {
- return getFilesByExtension("pathing\\" + taskDir, ".json");
-}
-
async function runPathScriptFile(jsonPath) {
await pathingScript.runFile(jsonPath);
//捕获任务取消的信息并跳出循环
@@ -335,298 +865,270 @@ async function runPathScriptFile(jsonPath) {
return false;
}
-async function runPathTaskIfCooldownExpired(account, pathTask) {
- const recordFile = getRecordFilePath(account, pathTask);
- const jsonFiles = pathTask.jsonFiles;
+async function runPathTaskIfCooldownExpired(material, taskInfo) {
+ let { current } = taskInfo;
+ const { target, tasks } = taskInfo;
+ const totalPathCount = tasks.reduce((sum, t) => sum + t.jsonFiles.length, 0);
+ log.info("{0}有{1}组任务,共{2}条路线", material, tasks.length, totalPathCount);
- 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);
- }
-
- // 坐标检查函数
- async function checkPositionChange(mapName, startX, startY) {
- try {
- await genshin.returnMainUi();
- const endPos = await genshin.getPositionFromMap(mapName);
- const endX = endPos.x;
- const endY = endPos.y;
-
- const diffX = Math.abs(endX - startX);
- const diffY = Math.abs(endY - startY);
- const totalDiff = diffX + diffY;
-
- return {
- success: true,
- totalDiff: totalDiff,
- shouldUpdate: totalDiff >= 5
- };
- } catch (error) {
- log.error(`获取结束坐标失败: ${error.message}`);
- return {
- success: false,
- totalDiff: 0,
- shouldUpdate: false
- };
- }
- }
-
- // 3. 检查哪些 json 文件已过刷新时间
- for (let i = 0; i < jsonFiles.length; i++) {
- const jsonPath = jsonFiles[i];
- const fileName = basename(jsonPath);
- const pathName = fileName.split(".")[0];
- const lastTime = recordMap[fileName] || defaultTime;
- const progress = `[${i + 1}/${jsonFiles.length}]`;
-
- if (stopAtTime && isTargetTimeReached(stopAtTime)) {
- throw new ReachStopTime("达到设置的停止时间,终止运行");
- }
-
- if (Date.now() > lastTime) {
- let pathStart = logFakePathStart(fileName);
- log.info(`${progress}{0}: 开始执行`, pathName);
-
- let pathStartTime = new Date();
-
- // 声明变量用于坐标检查
- let startX, startY, mapName, gotStartPos = false;
-
- // 获取地图名称和起始坐标
- try {
- await genshin.returnMainUi();
- const jsonContent = file.readTextSync(jsonPath);
- const jsonData = JSON.parse(jsonContent);
- mapName = jsonData.info?.map_name || "Teyvat";
- const startPos = await genshin.getPositionFromMap(mapName);
- startX = startPos.x;
- startY = startPos.y;
- gotStartPos = true;
- } catch (error) {
- log.error(`获取起始坐标失败: ${error.message}`);
+ // 开始执行任务
+ const knownAbilities = Object.values(collectAbility);
+ const allJsonFiles = tasks.flatMap(task => task.jsonFiles);
+ totalLoop: for (const pathTask of tasks) {
+ const { coolType, recordFile, jsonFiles, refreshTime } = pathTask;
+ for (const jsonPath of jsonFiles) {
+ if (stopAtTime && isTargetTimeReached(stopAtTime)) {
+ throw new ReachStopTime("达到设置的停止时间,终止运行");
}
- // 延迟抛出`UserCancelled`,以便正确更新运行记录
- const cancel = await runPathScriptFile(jsonPath);
+ const fileName = basename(jsonPath);
+ const pathName = fileName.split(".")[0];
+ const pathRefreshTime = refreshTime[fileName] || defaultTime;
+ // 使用indexOf计算进度,避免数数的方法在continue时繁琐的处理
+ const progress = `[${allJsonFiles.indexOf(jsonPath)+1}/${totalPathCount}]`;
- let diffTime = new Date() - pathStartTime;
+ if (Date.now() > pathRefreshTime) {
+ log.info(`${progress}{0}: 开始执行`, pathName);
- // 如果用户取消,抛出异常
- if (cancel) {
- log.info(`${progress}{0}: 用户取消任务,不更新记录`, pathName);
- throw new UserCancelled(cancel);
- }
-
- // 如果运行时间太短,尝试切换队伍
- if (diffTime < 1000) {
- // "队伍中没有对应元素角色"的错误不会抛出为异常,只能通过路径文件迅速结束来推测
- if (settings.partyName && settings.partyName2nd) {
- let newParty = (currentParty === settings.partyName) ? settings.partyName2nd : settings.partyName;
-
- log.info("当前队伍{0}缺少该路线所需角色,尝试切换到{1}", currentParty, newParty);
-
- await switchPartySafely(newParty);
-
- // 切换队伍后重新获取起始坐标
- let secondStartX, secondStartY, secondGotStartPos = false;
- try {
- await genshin.returnMainUi();
- const secondStartPos = await genshin.getPositionFromMap(mapName);
- secondStartX = secondStartPos.x;
- secondStartY = secondStartPos.y;
- secondGotStartPos = true;
- } catch (error) {
- log.error(`第二次获取起始坐标失败: ${error.message}`);
- }
-
- // 记录第二次执行的开始时间
- let secondPathStartTime = new Date();
- const secondCancel = await runPathScriptFile(jsonPath);
- let secondDiffTime = new Date() - secondPathStartTime;
-
- if (secondCancel) {
- log.info(`${progress}{0}: 用户取消任务,不更新记录`, pathName);
- throw new UserCancelled(secondCancel);
- }
-
- // 检查坐标变化
- let positionCheckResult = { success: false, shouldUpdate: false };
- if (secondGotStartPos) {
- positionCheckResult = await checkPositionChange(mapName, secondStartX, secondStartY);
- }
-
- // 根据运行时间和坐标变化决定是否更新记录
- if (secondDiffTime < 1000) {
- log.info(`${progress}{0}: 切换队伍后仍无法完成,不更新记录`, pathName);
- } else if (secondDiffTime >= 5000 && positionCheckResult.success && positionCheckResult.shouldUpdate) {
- // 二次执行成功&坐标变化足够 才更新记录
- recordMap[fileName] = calculateNextRunTime(new Date(), jsonPath);
- const lines = [];
- for (const [p, t] of Object.entries(recordMap)) {
- lines.push(`${p}\t${formatDateTime(t)}`);
- }
- const content = lines.join("\n");
- file.writeTextSync(recordFile, content);
- log.info(`${progress}{0}: 已完成,下次刷新: ${formatDateTimeShort(recordMap[fileName])}`, pathName);
- } else if (!positionCheckResult.success) {
- log.info(`${progress}{0}: 坐标获取失败,不更新记录`, pathName);
- } else if (!positionCheckResult.shouldUpdate) {
- log.info(`${progress}{0}: 切换队伍后出发点与终点过于接近,不记录运行数据`, pathName);
- } else {
- log.info(`${progress}{0}: 执行时间过短,不更新记录`, pathName);
- }
+ // 队伍采集能力判定
+ let avatarAbilities;
+ if (currentParty in partyAbility) {
+ avatarAbilities = partyAbility[currentParty];
} else {
- log.info(`${progress}{0}: 执行时间过短,且无第二队伍,不更新记录`, pathName);
+ avatarAbilities = calculateAvatarsAbility(getAvatars());
+ partyAbility[currentParty] = avatarAbilities;
}
- } else {
- // 检查坐标变化
- let positionCheckResult = { success: false, shouldUpdate: false };
- if (gotStartPos) {
- positionCheckResult = await checkPositionChange(mapName, startX, startY);
+ const specialMethods = scanSpecialCollectMethod([jsonPath]);
+ const requiredAbilities = specialMethods.filter((method) => knownAbilities.includes(method));
+ const missingAbilities = requiredAbilities.filter((element) => {
+ return !avatarAbilities.includes(element);
+ });
+ if (requiredAbilities.length > 0) {
+ log.debug("所需角色: {0}", requiredAbilities.join(", "));
+ }
+ if (missingAbilities.length > 0 && (! worldInfo.coOpMode)) {
+ // 联机模式下无法自动切换队伍,同时此时BGI本体的报错信息也足够详细,因此也不再打印日志
+ if (settings.partyName && settings.partyName2nd) {
+ let newParty = currentParty === settings.partyName ? settings.partyName2nd : settings.partyName;
+ if (!partyAbility[newParty]) {
+ log.info("当前队伍{0}缺少该路线所需角色{1},尝试切换到{2}", currentParty, missingAbilities.join(", "), newParty);
+ const teleported = await switchPartySafely(newParty);
+ currentParty = newParty;
+ if (teleported) {
+ currentMap = "Teyvat";
+ }
+ partyAbility[newParty] = calculateAvatarsAbility(getAvatars());
+ }
+
+ const avatarAbilities = partyAbility[newParty];
+ const missingAbilitiesNew = requiredAbilities.filter((element) => {
+ return !avatarAbilities.includes(element);
+ });
+ if (missingAbilitiesNew.length > 0) {
+ log.warn("另一队伍{0}也缺少该路线所需角色{1},跳过路线", newParty, missingAbilitiesNew.join(", "));
+ continue;
+ }
+ } else {
+ log.warn("当前队伍缺少该路线要求的采集角色,且用户未配置两支队伍,跳过路线");
+ continue;
+ }
}
- // 根据运行时间和坐标变化决定是否更新记录
- if (diffTime >= 5000 && positionCheckResult.success && positionCheckResult.shouldUpdate) {
- recordMap[fileName] = calculateNextRunTime(new Date(), jsonPath);
+ let pathStart = logFakePathStart(fileName);
+ let pathStartPos = await genshin.getPositionFromMap(currentMap);
+ // 延迟抛出`UserCancelled`,以便正确更新运行记录
+ let cancel = await runPathScriptFile(jsonPath);
+
+ await genshin.returnMainUi();
+ let pathEndPos = await genshin.getPositionFromMap(currentMap);
+ let distance = calculateDistance(pathStartPos, pathEndPos);
+ if (distance >= 5) {
+ const jsonData = JSON.parse(readTextSync(jsonPath));
+ const jsonRegion = jsonData.info?.map_name || "Teyvat";
+ if (jsonRegion !== currentMap) {
+ log.info("当前地图区域: {0}", currentMap);
+ currentMap = jsonRegion;
+ }
+
+ refreshTime[fileName] = calculateNextRefreshTime(new Date(), coolType);
const lines = [];
- for (const [p, t] of Object.entries(recordMap)) {
+ for (const [p, t] of Object.entries(refreshTime)) {
lines.push(`${p}\t${formatDateTime(t)}`);
}
const content = lines.join("\n");
file.writeTextSync(recordFile, content);
- log.info(`${progress}{0}: 已完成,下次刷新: ${formatDateTimeShort(recordMap[fileName])}`, pathName);
- } else if (!positionCheckResult.success) {
- log.info(`${progress}{0}: 坐标获取失败,不更新记录`, pathName);
- } else if (!positionCheckResult.shouldUpdate) {
- log.info(`${progress}{0}: 出发点与终点过于接近,不记录运行数据`, pathName);
+ log.info(`${progress}{0}: 已完成,下次刷新: ${formatDateTimeShort(refreshTime[fileName])}`, pathName);
} else {
- log.info(`${progress}{0}: 执行时间过短,不更新记录`, pathName);
+ log.info(`${progress}{0}: 位置几乎未变化,不更新刷新时间`, pathName);
+ }
+ logFakePathEnd(fileName, pathStart);
+
+ if (cancel) {
+ throw new UserCancelled(cancel);
+ }
+ // 不嵌套到if-distance里,以确保fake log和cancel得到正确执行
+ if (distance >= 5 && target !== null) {
+ const match = pathName.match(/-(\d+)个/);
+ const collectByPath = parseInt(match ? match[1] : null, 10);
+ if (!isNaN(collectByPath) && collectByPath > 0) {
+ current = current + collectByPath;
+ if (current > target) {
+ log.info("{0}可能已达成目标数量,打开背包确认", material);
+ const result = await getItemCount(material);
+ current = result[material] || 0;
+ if (current >= target) {
+ log.info("{0}已达成目标数量({1}>={2}),停止该材料剩余任务", material, current, target);
+ break totalLoop;
+ } else {
+ log.info("{0}实际数量未达标({1}<{2}),更新材料当前数量", material, current, target);
+ }
+ }
+ }
+ }
+ } else {
+ log.info(`${progress}{0}: 已跳过 (${formatDateTimeShort(refreshTime[fileName])}刷新)`, pathName);
+ }
+ }
+ }
+}
+
+function getSelectedMaterials(configMap) {
+ const configText = readTextSync(settingFile);
+ const config = JSON.parse(configText);
+
+ const selectedMaterials = {};
+ const knownKeys = ["selectForgingOre", "selectMiscellaneous", "selectFoodAndAlchemy"];
+
+ // 辅助函数:合并路径并记录日志
+ const mergeToResult = (materialName, paths) => {
+ if (!Array.isArray(paths)) return;
+ if (!selectedMaterials[materialName]) {
+ selectedMaterials[materialName] = [];
+ }
+ selectedMaterials[materialName].push(...paths);
+ };
+
+ config.forEach((entry) => {
+ if (!entry.name || entry.type !== "multi-checkbox") return;
+
+ const { name, label } = entry;
+ const options = settings[name] ? Array.from(settings[name]) : [];
+
+ // 2. 处理 selectLocalSpecialtyByCountry
+ if (name === "selectByCategory") {
+ if (options.length === 0) return;
+ const categoryMap = configMap[name];
+ if (!categoryMap) return;
+
+ options.forEach((categoryName) => {
+ const materialsInCategory = categoryMap[categoryName];
+ if (materialsInCategory) {
+ log.debug("选择了{0}下的所有材料: {1}", categoryName, Object.keys(materialsInCategory).join(", "));
+ for (const [materialName, paths] of Object.entries(materialsInCategory)) {
+ mergeToResult(materialName, paths);
+ }
+ }
+ });
+ } else if (name === "selectLocalSpecialtyByCountry") {
+ if (options.length === 0) return;
+ const countryMap = configMap[name];
+ if (!countryMap) return;
+
+ options.forEach((countryName) => {
+ const materialsInCountry = countryMap[countryName];
+ if (materialsInCountry) {
+ log.debug("选择了{0}的所有地方特产: {1}", countryName, Object.keys(materialsInCountry).join(", "));
+ for (const [materialName, paths] of Object.entries(materialsInCountry)) {
+ mergeToResult(materialName, paths);
+ }
+ }
+ });
+ }
+ // 3. 处理已知 key 或以 selectLocalSpecialty_ 开头的项
+ else if (knownKeys.includes(name) || name.startsWith("selectLocalSpecialty_")) {
+ if (options.length === 0) return;
+ const categoryMap = configMap[name];
+ if (!categoryMap) return;
+
+ const lines = label.trim().split(/\r?\n/);
+ const last_line = lines[lines.length - 1];
+ log.debug("选择了{0}分类下的材料: {1}", last_line, options.join(", "));
+ options.forEach((materialName) => {
+ const paths = categoryMap[materialName];
+ if (paths) {
+ mergeToResult(materialName, paths, "分类选择:" + name);
+ }
+ });
+ }
+ // 4. 处理 selectRoute_:执行覆盖逻辑并记录日志
+ else if (name.startsWith("selectRoute_")) {
+ const targetMaterial = name.replace("selectRoute_", "");
+ const routeMap = configMap[name];
+ if (!routeMap) return;
+
+ let finalRouteKeys = [];
+ let logAction = "";
+
+ if (options.length > 0) {
+ if (selectedMaterials.hasOwnProperty(targetMaterial)) {
+ // 已存在该材料,使用用户勾选的路线
+ finalRouteKeys = options;
+ logAction = "使用用户勾选的路线";
+ } else {
+ log.debug("未选中材料{0},忽略该材料勾选的路线{1}", targetMaterial, options.join(", "));
+ return;
+ }
+ } else {
+ // 如果 selectRoute 这一项用户什么都没勾,强制选择 entry.options 中的第一项
+ if (entry.options && entry.options.length > 0 && selectedMaterials.hasOwnProperty(targetMaterial)) {
+ finalRouteKeys = [entry.options[0]];
+ logAction = "用户未指定路线,自动选择第一组";
}
}
- logFakePathEnd(fileName, pathStart);
- } else {
- log.info(`${progress}{0}: 已跳过 (${formatDateTimeShort(recordMap[fileName])}刷新)`, pathName);
+ if (finalRouteKeys.length > 0) {
+ // 执行取代:先清空,再添加
+ selectedMaterials[targetMaterial] = [];
+ finalRouteKeys.forEach((routeKey) => {
+ const specificPaths = routeMap[routeKey];
+ if (specificPaths) {
+ selectedMaterials[targetMaterial].push(...specificPaths);
+ }
+ });
+ log.debug(`{0}: ${logAction} {1}`, targetMaterial, finalRouteKeys.join(", "));
+ }
}
- }
+ });
+
+ return selectedMaterials;
}
/**
- * 根据路径逐级查找最匹配的物品,返回去除前缀的路径、标准化名称、刷新时间
- * @param {string} fullPath - 单个完整路径(包含公共前缀)
- * @returns {{ label: string, name: string, coolType: string }}
+ * 获取世界主人的UID
*/
-function getCooldownInfoFromPath(fullPath) {
- const parts = fullPath.split(/[\\/]/); // 支持 \ 或 / 分隔符
- let cooldown = null;
- 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 = calculateNextRefreshTime(base, coolType);
- return nextTime;
-}
-
-function getSelectedMaterials() {
- const configText = file.readTextSync(settingFile);
- const config = JSON.parse(configText); // 配置数组
-
- const selectedMaterials = [];
-
- const selectAllMaterials = subscribeMode.includes("采集扫描到的所有材料");
- for (const entry of config) {
- if (entry.name && entry.name.startsWith("OPT_") && entry.type === "checkbox") {
- if (selectAllMaterials || settings[entry.name] === true) {
- let index = entry.label.indexOf(" ");
- entry.label = entry.label.slice(index + 1); // 去除⬇️指示
- const jsonFiles = filterFilesInTaskDir(entry.label);
- if (jsonFiles.length > 0) {
- entry.jsonFiles = jsonFiles;
- selectedMaterials.push(entry);
- } else {
- log.debug("跳过空文件夹: {0}", entry.label);
- }
- }
- }
- }
-
- const materialDict = {};
- selectedMaterials.forEach((item) => {
- const label = item.label;
- const match = label.match(/\\(.*?)\\\1/); // \落落莓\落落莓@Author
- let materialName;
-
- if (match) {
- materialName = match[1];
+async function getCoOpModeAndHostUid() {
+ await genshin.returnMainUi();
+ keyPress("F2");
+ await waitForTextAppear("多人游戏", [130, 20, 129, 57]);
+ let uid = await getGameAccount(true, false);
+ const coOpMode = !(await isTextExistedInRegion("搜索", [1638, 90, 87, 63]));
+ if (coOpMode) {
+ const btnText = await getTextInRegion([1560, 992, 191, 55]);
+ // 仅在多人模式且非房主时需要
+ if (btnText === "离开队伍") {
+ log.info("当前处于联机模式,且玩家不是房主");
+ click(332, 218);
+ await recognizeTextAndClick("查看资料", [555, 182, 118, 49]);
+ await waitForTextAppear("角色展柜", [1082, 204, 107, 49]);
+ await sleep(100);
+ uid = await getTextInRegion([623, 192, 118, 37]);
} else {
- const parts = label.split("\\");
- materialName = parts[parts.length - 1];
- }
-
- if (!materialDict[materialName]) {
- materialDict[materialName] = [];
- }
- materialDict[materialName].push(item);
- });
-
- const firstRoutes = [];
- const multiRoutes = {};
- for (const materialName in materialDict) {
- const routes = materialDict[materialName];
- if (routes.length > 0) {
- firstRoutes.push(routes[0]);
- if (materialDict[materialName].length > 1) {
- multiRoutes[materialName] = materialDict[materialName];
- }
+ log.info("当前处于联机模式,玩家是房主");
}
+ } else {
+ log.info("当前处于单人模式");
}
- const countOfMultiRoutes = Object.keys(multiRoutes).length;
- if (countOfMultiRoutes > 0) {
- let text = `${countOfMultiRoutes}种材料存在多个版本的路线:\n`;
- for (const [key, values] of Object.entries(multiRoutes)) {
- text += `${key}\n`;
- for (const v of values) {
- text += ` ${v.label}\n`;
- }
- }
- log.debug(text);
- if (settings.acceptMultiplePathOfSameMaterial) {
- log.warn("{0}种材料选中了多个版本的路线(详见日志文件),根据脚本设置,将执行全部版本", countOfMultiRoutes);
- } else {
- log.warn("{0}种材料选中了多个版本的路线(详见日志文件),默认只执行第一个版本", countOfMultiRoutes);
- return firstRoutes;
- }
- }
-
- return selectedMaterials;
-}
\ No newline at end of file
+ await genshin.returnMainUi();
+ const maskUid = uid.replace(/\d\d(\d{3})\d{4}/, (match, group1) => match.replace(group1, "xxx"));
+ return { coOpMode: coOpMode, uid: uid, maskUid: maskUid };
+}
diff --git a/repo/js/CD-Aware-AutoGather/manifest.json b/repo/js/CD-Aware-AutoGather/manifest.json
index a164d1ace..2f9877bb3 100644
--- a/repo/js/CD-Aware-AutoGather/manifest.json
+++ b/repo/js/CD-Aware-AutoGather/manifest.json
@@ -1,19 +1,21 @@
{
"manifest_version": 1,
- "name": "带CD管理的自动采集",
- "version": "1.7.9",
- "bgi_version": "0.45.0",
- "description": "自动同步你通过BetterGI订阅的地图追踪任务,执行采集任务,并管理材料刷新时间(支持多账号)。\n首次使用前请先简单阅读说明(可在`全自动`——`JS脚本`页面,点击本脚本名称查看)",
+ "name": "带CD管理和目标设定的自动采集",
+ "version": "2.0",
+ "bgi_version": "0.54.1-alpha.1",
+ "description": "自动同步你通过BetterGI订阅的地图追踪任务,执行采集任务,并管理材料采集目标和刷新时间。\n支持联机模式采集和多账号记录。\n首次使用前请先简单阅读说明",
"authors": [
{
"name": "Ayaka-Main",
"links": "https://github.com/Patrick-Ze"
- },
- {
- "name": "蜜柑魚",
- "links": "https://github.com/this-Fish"
}
],
"settings_ui": "settings.json",
- "main": "main.js"
+ "main": "main.js",
+ "saved_files": [
+ "pathing",
+ "record/*.csv",
+ "record/*.txt",
+ "record/*.json"
+ ]
}
\ No newline at end of file
diff --git a/repo/js/CD-Aware-AutoGather/settings.json b/repo/js/CD-Aware-AutoGather/settings.json
index e32d58edb..e78573190 100644
--- a/repo/js/CD-Aware-AutoGather/settings.json
+++ b/repo/js/CD-Aware-AutoGather/settings.json
@@ -1,48 +1,95 @@
-[
- {
- "name": "runMode",
- "type": "select",
- "label": "运行模式",
- "options": [
- "扫描文件夹更新可选材料列表",
- "采集选中的材料",
- "清除运行记录(重置材料刷新时间)"
- ]
- },
- {
- "name": "subscribeMode",
- "type": "select",
- "label": "'地图追踪'中已订阅的任务目录的处理方式:",
- "options": [
- "每次自动扫描,并采集扫描到的所有材料",
- "手动扫描,并采集扫描到的所有材料",
- "手动扫描,只采集已勾选的材料"
- ]
- },
- {
- "name": "partyName",
- "type": "input-text",
- "label": "设置首选队伍名称(留空则不进行切换)"
- },
- {
- "name": "partyName2nd",
- "type": "input-text",
- "label": "设置备选队伍名称(首选队伍缺少对应的采集角色时使用)"
- },
- {
- "name": "stopAtTime",
- "type": "input-text",
- "label": "停止运行时间(HH:mm,24小时制。例如09:28为上午9点28分)"
- },
- {
- "name": "iHaveMultipleAccounts",
- "type": "input-text",
- "label": "我肝的账号不止一个\n根据唯一ID区分账号维护对应的材料刷新时间\n留空时将使用右下角UID",
- "default": "默认账号"
- },
- {
- "name": "acceptMultiplePathOfSameMaterial",
- "type": "checkbox",
- "label": "即使同一种材料有多个版本的路线,也全都执行采集"
- }
+[
+ {
+ "name": "runMode",
+ "type": "select",
+ "label": "运行模式",
+ "options": [
+ "扫描文件夹更新可选材料列表",
+ "采集选中的材料"
+ ],
+ "default": "扫描文件夹更新可选材料列表"
+ },
+ {
+ "name": "filterPathByKeywords",
+ "type": "input-text",
+ "label": "按关键词筛选材料路线(使用空格分隔,默认为黑名单模式)",
+ "default": "低成功率 不建议"
+ },
+ {
+ "name": "partyName",
+ "type": "input-text",
+ "label": "设置首选队伍名称(留空则不进行切换)"
+ },
+ {
+ "name": "partyName2nd",
+ "type": "input-text",
+ "label": "设置备选队伍名称(首选队伍缺少对应的采集角色时使用)"
+ },
+ {
+ "name": "excludeTimeRange",
+ "type": "input-text",
+ "label": "不在以下时间段内运行(HH:mm-HH:mm,24小时制):"
+ },
+ {
+ "name": "targetCountOfSelected",
+ "type": "input-text",
+ "label": "物品采集数量目标(可使用csv为不同物品设置不同数量)"
+ },
+ {
+ "name": "manualSetAccountName",
+ "type": "input-text",
+ "label": "使用指定的账号名保存运行记录"
+ },
+ {
+ "type": "separator"
+ },
+ {
+ "label": "\n【常用采集选项】\n\n按大类选择",
+ "type": "multi-checkbox",
+ "name": "selectByCategory",
+ "options": [
+ "地方特产",
+ "矿物",
+ "食材与炼金"
+ ]
+ },
+ {
+ "label": "按地区选择地方特产",
+ "type": "multi-checkbox",
+ "name": "selectLocalSpecialtyByCountry",
+ "options": [
+ "蒙德",
+ "璃月",
+ "稻妻",
+ "须弥",
+ "枫丹",
+ "纳塔",
+ "挪德卡莱"
+ ]
+ },
+ {
+ "label": "矿物",
+ "type": "multi-checkbox",
+ "name": "selectForgingOre",
+ "options": [
+ "白铁块",
+ "水晶块",
+ "星银矿石",
+ "紫晶块",
+ "萃凝晶",
+ "虹滴晶"
+ ]
+ },
+ {
+ "label": "特殊物品",
+ "type": "multi-checkbox",
+ "name": "selectMiscellaneous",
+ "options": [
+ "晶蝶",
+ "沉玉仙茗"
+ ]
+ },
+ {
+ "type": "separator"
+ }
]
\ No newline at end of file
diff --git a/repo/js/CD-Aware-AutoGather/settings.template.json b/repo/js/CD-Aware-AutoGather/settings.template.json
deleted file mode 100644
index 6316beb99..000000000
--- a/repo/js/CD-Aware-AutoGather/settings.template.json
+++ /dev/null
@@ -1,48 +0,0 @@
-[
- {
- "name": "runMode",
- "type": "select",
- "label": "运行模式",
- "options": [
- "扫描文件夹更新可选材料列表",
- "采集选中的材料",
- "清除运行记录(重置材料刷新时间)"
- ]
- },
- {
- "name": "subscribeMode",
- "type": "select",
- "label": "'地图追踪'中已订阅的任务目录的处理方式:",
- "options": [
- "每次自动扫描,并采集扫描到的所有材料",
- "手动扫描,并采集扫描到的所有材料",
- "手动扫描,只采集已勾选的材料"
- ]
- },
- {
- "name": "partyName",
- "type": "input-text",
- "label": "设置首选队伍名称(留空则不进行切换)"
- },
- {
- "name": "partyName2nd",
- "type": "input-text",
- "label": "设置备选队伍名称(首选队伍缺少对应的采集角色时使用)"
- },
- {
- "name": "stopAtTime",
- "type": "input-text",
- "label": "停止运行时间(HH:mm,24小时制。例如09:28为上午9点28分)"
- },
- {
- "name": "iHaveMultipleAccounts",
- "type": "input-text",
- "label": "我肝的账号不止一个\n根据唯一ID区分账号维护对应的材料刷新时间\n填空将使用右下角帐号UID区分账号维护对应的材料刷新时间",
- "default": "默认账号"
- },
- {
- "name": "acceptMultiplePathOfSameMaterial",
- "type": "checkbox",
- "label": "即使同一种材料有多个版本的路线,也全都执行采集"
- }
-]
\ No newline at end of file
diff --git a/repo/js/CD-Aware-AutoGather/脚本思路.txt b/repo/js/CD-Aware-AutoGather/脚本思路.txt
deleted file mode 100644
index 3b4bc18ad..000000000
--- a/repo/js/CD-Aware-AutoGather/脚本思路.txt
+++ /dev/null
@@ -1,44 +0,0 @@
-提供一个bat文件
-1. 创建符号链接,指向路径追踪文件夹
-
-js脚本逻辑:
-1. 设置文件完全由js脚本生成(可以提交一个生成版本进库作为参考)
-2. 提供3种运行模式
- a 扫描文件夹更新可用配置
- b 采集选中的材料
- c 清除运行记录(重置刷新时间)
-
-可行性探索:
-1. 当配置选项新增可选物品时:获取该项目的值会得到undefined(等同于未配置)
-2. 当已选中物品从配置选项中删除时:获取该项目的值会得到undefined,但会保存在脚本组的配置里。如果再加回这个选项,会获得之前设置的值。不存在于当前settings.json文件的字段不会自动从脚本组配置中删除
-3. 设置属性里不支持特殊字符
-多账户支持:单账户时记录保存到`默认账号`文件夹,多账户时根据UID创建对应的记录文件夹
-
-索引文件列属性:
-只记录采集物名称以及对应CD,不记录完整路径。这样的话只要新的路径追踪是符合文件夹结构的,也能自动支持。
-工作时,基于文件夹路径,从前到后全词匹配路径的每个部分,直到找到对应的项目
-
-a 扫描模式
-1. 提示可以运行bat脚本或者手动创建符号链接
-2. 遍历追踪文件夹内的所有子文件夹
-3. 基于子文件夹的相对路径,查找索引文件,对于那些在索引中的条目:更新settings.json,创建 options 可选列表,并记录所有选项
-4. settings.json中还需要提供的配置项:
- - 运行模式
- - 队伍名称(如果不同的采集物需要使用不同队伍,那要求用户重复添加多个JS运行项)
- - 终止运行时间
- - 要采集的物品列表
- - 我肝的账号不止一个
-5. 如果用户添加的文件夹太多,设置项也会很多。可以建议用户适当删除一些
-
-b 采集模式:
-1. 根据用户选中的采集物,枚举对应文件夹下的路径追踪文件。逐个处理完所有选项
-2. 根据子文件夹的相对路径,查找索引文件,得知其刷新模式
-3. 对于每个追踪文件,循环执行:
- a. 查询运行记录,获知刷新时间。如果查询不到,视为未运行过
- b. 如果当前时间大于刷新时间,则执行采集
- c. 执行采集后,计算下次刷新时间并更新运行记录
-
-c 清除模式
-1. 根据用户选中的采集物,获取其对应的运行记录
-2. 重置运行记录中的刷新时间为绫华生日
-