js脚本:药品消耗统计 (#2502)

This commit is contained in:
h
2025-12-16 08:29:57 +08:00
committed by GitHub
parent be7613e687
commit aa1c590686
4 changed files with 610 additions and 0 deletions

View File

@@ -0,0 +1,35 @@
## 一、功能概述
### 1. 初始化记录
- 用于建立药品库存的“基准值”
- 通常在第一次使用或库存发生大幅变化时执行
- 会将当前库存写入【history】
### 2. 记录 + 消耗计算
- 在存在初始化记录的前提下运行
- 自动查找最近一次初始化记录
- 计算当前库存相对于初始化时的消耗量
- 记录结果写入【history】
- 消耗结果写入【consumption】
### 3. 纯记录模式
- 不参与初始化与消耗计算
- 每次运行仅记录当前库存
- 用于长期留档或手动分析
- 输出至【snapshot】
## 二、输出文件说明
- 三个模式分别输出三个文件,格式为:年月日时分秒-模式-名称-数量,回血和复活药分行记录。
## 三、使用说明
- 纯记录模式:运行一次记录一次,自行操作记录。
- 消耗对比模式:普通记录减去上次初始化记录,得出消耗量,如果没有初始化,则自动转化为初始化数据。
## 四、版本与环境
- BGI ≥ v0.54

View File

@@ -0,0 +1,530 @@
function mapRecordMode(modeText) {
switch (modeText) {
case "仅记录库存":
return "snapshot";
case "记录并计算消耗":
return "record";
case "重新初始化":
return "init";
default:
return "record";
}
}
let userName = settings.userName || "默认账户";
const recoveryFoodName = settings.recoveryFoodName || "回血药名字没填";
const resurrectionFoodName = settings.resurrectionFoodName || "复活药名字没填";
const currentMode = mapRecordMode(settings.recordMode); // mode: "init" | "record" | "snapshot"
const ocrRegion = {
x: 150,
y: 250,
width: 220,
height: 270
};
(async function () {
// 检验账户名
async function getUserName() {
userName = userName.trim();
// 数字中英文长度在20个字符以内
if (!userName || !/^[\u4e00-\u9fa5A-Za-z0-9]{1,20}$/.test(userName)) {
log.error(`账户名${userName}违规暂时使用默认账户名请查看readme后修改`)
userName = "默认账户";
}
return userName;
}
// 格式化日期时间为 YYYY/MM/DD HH:mm:ss
async function formatDateTime(date) {
return `${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}`;
}
// 处理旧格式记录文件(迁移功能保留)
async function migrateOldFormatRecords(filePath) {
try {
const content = await file.readText(filePath);
const lines = content.split('\n').filter(line => line.trim());
// 检查是否有旧格式的记录如2025-12-10T02:02:32.460Z|179|546
const hasOldFormat = lines.some(line =>
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\|\d+\|\d+$/.test(line)
);
if (hasOldFormat) {
// 直接清空文件(不创建备份)
await file.writeText(filePath, '');
notification.send(`${settings.userName}: 检测到旧格式记录,已重置记录文件`);
return true;
}
} catch (error) {
// 文件不存在或其他错误
}
return false;
}
/**
* 文字OCR识别封装函数支持空文本匹配任意文字
* @param {string} text - 要识别的文字,默认为"空参数",空字符串会匹配任意文字
* @param {number} timeout - 超时时间单位为秒默认为10秒
* @param {number} afterBehavior - 点击模式0=不点击1=点击文字位置2=按F键默认为0
* @param {number} debugmodel - 调试模式0=无输出1=基础日志2=详细输出3=立即返回默认为0
* @param {number} x - OCR识别区域起始X坐标默认为0
* @param {number} y - OCR识别区域起始Y坐标默认为0
* @param {number} w - OCR识别区域宽度默认为1920
* @param {number} h - OCR识别区域高度默认为1080
* @param {number} matchMode - 匹配模式0=包含匹配1=精确匹配默认为0
* @returns {object} 包含识别结果的对象 {text, x, y, found}
*/
async function textOCREnhanced(
text = "空参数",
timeout = 10,
afterBehavior = 0,
debugmodel = 0,
x = 0,
y = 0,
w = 1920,
h = 1080,
matchMode = 0
) {
const startTime = Date.now();
const timeoutMs = timeout * 1000;
let lastResult = null;
let captureRegion = null; // 用于存储截图对象
// 只在调试模式1下输出基本信息
if (debugmodel === 1) {
if (text === "") {
log.info(`OCR: 空文本模式 - 匹配任意文字`);
} else if (text === "空参数") {
log.warn(`OCR: 使用默认参数"空参数"`);
}
}
while (Date.now() - startTime < timeoutMs) {
try {
// 获取截图并进行OCR识别
captureRegion = captureGameRegion();
const resList = captureRegion.findMulti(RecognitionObject.ocr(x, y, w, h));
// 遍历识别结果
for (let i = 0; i < resList.count; i++) {
const res = resList[i];
// 检查是否匹配
let isMatched = false;
if (text === "") {
// 空文本匹配任意文字
isMatched = true;
} else if (matchMode === 1) {
// 精确匹配
isMatched = res.text === text;
} else {
// 包含匹配(默认)
isMatched = res.text.includes(text);
}
if (isMatched) {
// 只在调试模式1下输出匹配成功信息
if (debugmodel === 1) {
log.info(`OCR成功: "${res.text}" 位置(${res.x},${res.y})`);
}
// 调试模式3: 立即返回
if (debugmodel === 3) {
// 释放内存
if (captureRegion) {
captureRegion.dispose();
}
return { text: res.text, x: res.x, y: res.y, found: true };
}
// 执行后续行为
switch (afterBehavior) {
case 1: // 点击文字位置
await sleep(1000);
click(res.x, res.y);
break;
case 2: // 按F键
await sleep(100);
keyPress("F");
break;
default:
// 不执行任何操作
break;
}
// 记录最后一个匹配结果但不立即返回
lastResult = { text: res.text, x: res.x, y: res.y, found: true };
}
}
// 释放截图对象内存
if (captureRegion) {
captureRegion.dispose();
}
// 如果找到匹配结果,根据调试模式决定是否立即返回
if (lastResult && debugmodel !== 2) {
return lastResult;
}
// 短暂延迟后继续下一轮识别
await sleep(100);
} catch (error) {
// 发生异常时释放内存
if (captureRegion) {
captureRegion.dispose();
}
log.error(`OCR异常: ${error.message}`);
await sleep(100);
}
}
if (debugmodel === 1) {
// 超时处理
if (text === "") {
log.info(`OCR超时: ${timeout}秒内未找到任何文字`);
} else {
log.info(`OCR超时: ${timeout}秒内未找到"${text}"`);
}
}
// 返回最后一个结果或未找到
return lastResult || { found: false };
}
// 背包过期物品识别
async function handleExpiredItems() {
const ifGuoqi = await textOCREnhanced("物品过期", 1.5, 0, 3, 870, 280, 170, 40);
if (ifGuoqi.found) {
log.info("检测到过期物品,正在处理...");
await sleep(500);
click(980, 750); // 点击确认按钮,关闭提示
}
else { log.info("未检测到过期物品"); }
}
async function recognizeNumberByOCR(ocrRegion, pattern) {
let captureRegion = null;
try {
const ocrRo = RecognitionObject.ocr(ocrRegion.x, ocrRegion.y, ocrRegion.width, ocrRegion.height);
captureRegion = captureGameRegion();
const resList = captureRegion.findMulti(ocrRo);
if (!resList || resList.length === 0) {
log.warn("OCR未识别到任何文本");
return null;
}
for (const res of resList) {
if (!res || !res.text) {
continue;
}
const numberMatch = res.text.match(pattern);
if (numberMatch) {
const number = parseInt(numberMatch[1] || numberMatch[0]);
if (!isNaN(number)) {
return number;
}
}
}
}
catch (error) {
log.error(`OCR识别时发生异常: ${error.message}`);
}
finally {
if (captureRegion) {
captureRegion.dispose();
}
}
return null;
}
// 获取食物数量(核心功能)
async function getFoodNum() {
keyPress("B");//打开背包
await handleExpiredItems(); //处理过期物品弹窗
await sleep(2000);
click(863, 51);//选择食物
await sleep(1000);
click(170, 1020);//筛选
await sleep(1000);
click(195, 1020);//重置
await sleep(1000);
click(110, 110);//输入名字
await sleep(1000);
inputText(recoveryFoodName);
await sleep(500);
click(490, 1020);//确认筛选
await sleep(1000);
var recoveryNumber = await recognizeNumberByOCR(ocrRegion, /\d+/) //识别回血药数量
// 处理回血药识别结果
if (recoveryNumber === null) {
recoveryNumber = 0;
notification.send(`未识别到回血药数量设置数量为0药品名${recoveryFoodName}`)
}
await sleep(1000);
click(170, 1020);//筛选
await sleep(1000);
click(195, 1020);//重置
await sleep(1000);
click(110, 110);//输入名字
await sleep(1000);
inputText(resurrectionFoodName);
await sleep(500);
click(490, 1020);//确认筛选
await sleep(1000);
var resurrectionNumber = await recognizeNumberByOCR(ocrRegion, /\d+/) //识别复活药数量
// 处理复活药识别结果
if (resurrectionNumber === null) {
resurrectionNumber = 0;
notification.send(`未识别到复活药数量设置数量为0药品名${resurrectionFoodName}`)
}
await sleep(1000);
click(170, 1020);//筛选
await sleep(1000);
click(195, 1020);//重置
await sleep(1000);
click(490, 1020);//确认筛选
await genshin.returnMainUi();
return { recoveryNumber, resurrectionNumber };
}
async function addSnapshotRecord(filePath, recoveryNum, resurrectionNum) {
const now = new Date();
const dateTimeStr = await formatDateTime(now);
const recordLine =
`${dateTimeStr}—库存—${recoveryFoodName}${recoveryNum}\n` +
`${dateTimeStr}—库存—${resurrectionFoodName}${resurrectionNum}`;
try {
let content = "";
try {
content = await file.readText(filePath);
} catch (e) {}
const newContent = content + (content ? "\n" : "") + recordLine;
await file.writeText(filePath, newContent);
log.info(`已记录库存快照`);
return true;
} catch (error) {
log.error(`库存快照记录失败: ${error.message}`);
return false;
}
}
// 查找最近一次初始化记录
async function findLastInitialization(historyFilePath) {
let content = "";
try {
content = await file.readText(historyFilePath);
} catch (e) {
return null;
}
const lines = content.split("\n").filter(l => l.trim());
if (lines.length === 0) return null;
// 只看初始化行
const initLines = lines.filter(l => l.includes("—初始化—"));
if (initLines.length === 0) return null;
// 从后往前,找最近的一组时间戳
for (let i = initLines.length - 1; i >= 0; i--) {
const initLine = initLines[i];
const parts = initLine.split("—");
if (parts.length < 4) continue;
const time = parts[0];
let recoveryNum = null;
let resurrectionNum = null;
// 收集同一时间戳的初始化记录
for (const line of lines) {
if (!line.startsWith(time)) continue;
if (!line.includes("—初始化—")) continue;
const seg = line.split("—");
if (seg.length < 4) continue;
const name = seg[2];
const num = parseInt(seg[3], 10);
if (isNaN(num)) continue;
if (name === recoveryFoodName) {
recoveryNum = num;
}
if (name === resurrectionFoodName) {
resurrectionNum = num;
}
}
if (recoveryNum !== null && resurrectionNum !== null) {
return {
time,
recoveryNum,
resurrectionNum
};
}
}
return null;
}
// 添加历史记录
async function addHistoryRecord(filePath, mode, recoveryNum, resurrectionNum) {
const now = new Date();
const dateTimeStr = await formatDateTime(now);
const recordLine = `${dateTimeStr}${mode}${recoveryFoodName}${recoveryNum}\n` +
`${dateTimeStr}${mode}${resurrectionFoodName}${resurrectionNum}`;
try {
// 检查旧格式并迁移
await migrateOldFormatRecords(filePath);
let content = "";
try {
content = await file.readText(filePath);
} catch (error) {
// 文件不存在,创建新文件
}
// 追加新记录
const newContent = content + (content ? "\n" : "") + recordLine;
await file.writeText(filePath, newContent);
log.info(`已添加历史记录: ${recordLine}`);
return { success: true, time: dateTimeStr };
} catch (error) {
log.error(`添加历史记录失败: ${error.message}`);
return { success: false, error: error.message };
}
}
// 添加消耗记录
async function addConsumptionRecord(filePath, recoveryConsumed, resurrectionConsumed, initTime) {
const now = new Date();
const dateTimeStr = await formatDateTime(now);
const recordLine = `${dateTimeStr}—消耗对比—${recoveryFoodName}${recoveryConsumed}\n` +
`${dateTimeStr}—消耗对比—${resurrectionFoodName}${resurrectionConsumed}(对比${initTime}`;
try {
let content = "";
try {
content = await file.readText(filePath);
} catch (error) {
// 文件不存在,创建新文件
}
// 追加新记录
const newContent = content + (content ? "\n" : "") + recordLine;
await file.writeText(filePath, newContent);
log.info(`已添加消耗记录: ${recordLine}`);
return { success: true };
} catch (error) {
log.error(`添加消耗记录失败: ${error.message}`);
return { success: false, error: error.message };
}
}
// 主执行流程
async function main() {
// 设置分辨率和缩放
setGameMetrics(1920, 1080, 1);
// 点击领月卡
await genshin.blessingOfTheWelkinMoon();
await sleep(1000);
await genshin.returnMainUi();
await sleep(1000);
// 获取食物数量
return await getFoodNum();
}
// ==================== 程序入口 ====================
userName = await getUserName();
const historyFilePath = `assets/${userName}_history.txt`;
const snapshotFilePath = `assets/${userName}_snapshot.txt`;
const consumptionFilePath = `assets/${userName}_consumption.txt`;
// 获取当前药物数量
const { recoveryNumber, resurrectionNumber } = await main();
if (currentMode === "init") {
// ============ 初始化模式 ============
const result = await addHistoryRecord(historyFilePath, "初始化", recoveryNumber, resurrectionNumber);
if (result.success) {
notification.send(`${userName}: 已记录初始库存!${recoveryFoodName}${recoveryNumber}个, ${resurrectionFoodName}${resurrectionNumber}`);
} else {
notification.send(`${userName}: 初始化记录失败!`);
}
} else if (currentMode === "record") {
// ============ 记录模式 ============
// 1. 查找最近一次初始化
const lastInit = await findLastInitialization(historyFilePath);
if (!lastInit) {
// 没有找到初始化记录,自动转为初始化模式
log.warn("未找到初始化记录,自动转为初始化模式");
const result = await addHistoryRecord(historyFilePath, "初始化", recoveryNumber, resurrectionNumber);
if (result.success) {
notification.send(`${userName}: 未找到初始化记录,已自动记录为初始库存!${recoveryFoodName}${recoveryNumber}个, ${resurrectionFoodName}${resurrectionNumber}`);
}
} else {
// 2. 计算消耗量
const recoveryConsumed = lastInit.recoveryNum - recoveryNumber;
const resurrectionConsumed = lastInit.resurrectionNum - resurrectionNumber;
// 3. 添加当前记录到历史文件
const historyResult = await addHistoryRecord(historyFilePath, "记录", recoveryNumber, resurrectionNumber);
// 4. 添加消耗记录到消耗文件
if (recoveryConsumed > 0 || resurrectionConsumed > 0) {
await addConsumptionRecord(
consumptionFilePath,
recoveryConsumed,
resurrectionConsumed,
lastInit.time
);
}
// 5. 发送通知
if (recoveryConsumed > 0 || resurrectionConsumed > 0) {
notification.send(`${userName}: 当前库存:${recoveryFoodName}${recoveryNumber}个, ${resurrectionFoodName}${resurrectionNumber}个 | 消耗:${recoveryFoodName}${recoveryConsumed}个, ${resurrectionFoodName}${resurrectionConsumed}个(对比${lastInit.time}`);
} else {
// 消耗为0或负数数量增加
const recoveryChange = recoveryConsumed >= 0 ? `消耗${recoveryConsumed}` : `新增${-recoveryConsumed}`;
const resurrectionChange = resurrectionConsumed >= 0 ? `消耗${resurrectionConsumed}` : `新增${-resurrectionConsumed}`;
notification.send(`${userName}: 当前库存:${recoveryFoodName}${recoveryNumber}个, ${resurrectionFoodName}${resurrectionNumber}个 | ${recoveryChange}, ${resurrectionChange}(对比${lastInit.time}`);
}
}
} else if (currentMode === "snapshot") {
// ✅ 新增的纯记录模式
const ok = await addSnapshotRecord(
snapshotFilePath,
recoveryNumber,
resurrectionNumber
);
if (ok) {
notification.send(
`${userName}: 当前库存 — ${recoveryFoodName}${recoveryNumber}个,${resurrectionFoodName}${resurrectionNumber}`
);
} else {
notification.send(`${userName}: 库存记录失败`);
}
} else {
notification.send(`${userName}: 错误!未知的模式: ${currentMode}`);
}
})();

View File

@@ -0,0 +1,18 @@
{
"manifest_version": 1,
"name": "药品消耗统计",
"version": "1.0",
"bgi_version": "0.54",
"description": "基于勺子佬的营养袋吃药统计拓展,本脚本用于自动记录并统计角色使用的回血药与复活药数量,支持初始化记录、纯记录、记录以及消耗计算三种工作方式,适合单账号或多账号长期使用。",
"authors": [
{
"name": "爱丽丝",
"links": "https://github.com/itslyh"
}
],
"settings_ui": "settings.json",
"main": "main.js",
"saved_files": [
"assets/*.txt"
]
}

View File

@@ -0,0 +1,27 @@
[
{
"name": "userName",
"type": "input-text",
"label": "账户名称\n用于多账户运行时区分不同账户",
"default": "默认账户"
},
{
"name": "recordMode",
"type": "select",
"label": "记录模式",
"options": ["仅记录库存", "记录并计算消耗", "重新初始化"],
"default": "record"
},
{
"name": "recoveryFoodName",
"type": "input-text",
"label": "回血药名称",
"default": ""
},
{
"name": "resurrectionFoodName",
"type": "input-text",
"label": "复活药名称",
"default": ""
}
]