import base from './base.js' import fetch from 'node-fetch' import lodash from 'lodash' import fs from 'node:fs' import { downFile, sleep } from 'yunzai/utils' import { gsCfg } from 'yunzai/mys' // tudo import { Character, Weapon } from '#miao.models' export default class GachaLog extends base { constructor(e) { super(e) this.model = 'gachaLog' if (!e.isSr && e.msg) e.isSr = /\/(common|hkrpg)\//.test(e.msg) this.urlKey = `${this.prefix}url:` /** 绑定的uid */ this.uidKey = this.e.isSr ? `Yz:srJson:mys:qq-uid:${this.userId}` : `Yz:genshin:mys:qq-uid:${this.userId}` this.path = this.e.isSr ? `./data/srJson/${this.e.user_id}/` : `./data/gachaJson/${this.e.user_id}/` const gsPool = [ { type: 301, typeName: '角色' }, { type: 302, typeName: '武器' }, { type: 500, typeName: '集录' }, { type: 200, typeName: '常驻' } ] const srPool = [ { type: 11, typeName: '角色' }, { type: 12, typeName: '光锥' }, { type: 1, typeName: '常驻' }, { type: 2, typeName: '新手' } ] this.pool = e.isSr ? srPool : gsPool } static getIcon(name, type = 'role', game = '') { if (type === 'role' || type === '角色') { let char = Character.get(name, game) if (!char) { console.log('not-found-char', name, game) } return char?.imgs?.face || '' } else if (type === 'weapon' || type === '武器' || type === '光锥') { let weapon = Weapon.get(name, game) if (!weapon) { console.log('not-found-weapon', `[${name}]`, game) } return weapon?.imgs?.icon || '' } } async logUrl() { let url = this.e.msg /** 处理url */ let param = this.dealUrl(url) if (!param) return if (!(await this.checkUrl(param))) return this.e.reply('链接发送成功,数据获取中……') /** 制作合并消息 */ let MakeMsg = [] let tmpMsg = '' /** 按卡池更新记录 */ for (let i in this.pool) { this.type = this.pool[i].type this.typeName = this.pool[i].typeName let res = await this.updateLog() if (res) { tmpMsg += `[${this.typeName}]记录获取成功,更新${res.num}条\n` } if (i <= 1) await sleep(500) } MakeMsg.push(tmpMsg) MakeMsg.push( `\n抽卡记录更新完成,您还可回复\n【${this?.e?.isSr ? '*' : '#'}全部记录】统计全部抽卡数据\n【${this?.e?.isSr ? '*光锥' : '#武器'}记录】统计${this?.e?.isSr ? '星铁光锥' : '武器'}池数据\n【${this?.e?.isSr ? '*' : '#'}角色统计】按卡池统计数据\n【${this?.e?.isSr ? '*' : '#'}导出记录】导出记录数据` ) await this.e.reply(MakeMsg) this.isLogUrl = true this.all = [] let data = await this.getLogData() this.e.msg = `[uid:${this.uid}]` return data } dealUrl(url) { // timestamp=1641338980〈=zh-cn 修复链接有奇怪符号 url = url.replace(/〈=/g, '&') if (url.includes('getGachaLog?')) url = url.split('getGachaLog?')[1] if (url.includes('index.html?')) url = url.split('index.html?')[1] // 处理参数 let arr = new URLSearchParams(url).entries() let params = {} for (let val of arr) { params[val[0]] = val[1] } if (!params.authkey) { this.e.reply('链接复制错误') return false } // 去除#/,#/log params.authkey = params.authkey.replace(/#\/|#\/log/g, '') return params } async downFile() { this.creatFile() let textPath = `${this.path}output_log.txt` // 获取文件下载链接 let fileUrl = await this.e.friend.getFileUrl(this.e.file.fid) let ret = await downFile(fileUrl, textPath) if (!ret) { this.e.reply('下载日志文件错误') return false } // 读取txt文件 let txt = fs.readFileSync(textPath, 'utf-8') let url = txt.match(/auth_appid=webview_gacha(.*)hk4e_cn/) /** 删除文件 */ fs.unlink(textPath, () => {}) if (!url || !url[0]) { return false } return url[0] } async checkUrl(param) { if (!param.region) { let res = await this.logApi({ size: 6, authkey: param.authkey, region: this.e.isSr ? 'prod_gf_cn' : 'cn_gf01' }) if (!res?.data?.region) { res = await this.logApi({ size: 6, authkey: param.authkey, region: this.e.isSr ? 'prod_official_usa' : 'os_usa' }) } if (res?.data?.region) { param.region = res?.data?.region } else { await this.e.reply('链接复制错误或已失效') return false } } let res = await this.logApi({ size: 6, authkey: param.authkey, region: param.region }) if (res.retcode == -109) { await this.e.reply( '2.3版本后,反馈的链接已无法查询!请用安卓方式获取链接' ) return false } if (res.retcode == -101) { await this.e.reply('该链接已失效,请重新进入游戏,重新复制链接') return false } if (res.retcode == 400) { await this.e.reply('获取数据错误') return false } if (res.retcode == -100) { if (this.e.msg.length == 1000) { await this.e.reply( '输入法限制,链接复制不完整,请更换输入法复制完整链接' ) return false } await this.e.reply( '链接不完整,请长按全选复制全部内容(可能输入法复制限制),或者复制的不是历史记录页面链接' ) return false } if (res.retcode != 0) { await this.e.reply('链接复制错误') return false } if (res?.data?.list && res.data.list.length > 0) { this.uid = res.data.list[0].uid await redis.setEx(this.uidKey, 3600 * 24 * 30, String(this.uid)) /** 保存authkey */ await redis.setEx(`${this.urlKey}${this.uid}`, 86400, param.authkey) return true } else { await this.e.reply('暂无数据,请等待记录后再查询') return false } } async logApi(param) { // 调用一次接口判断链接是否正确 let logUrl = 'https://public-operation-hk4e.mihoyo.com/gacha_info/api/getGachaLog?' /** 国际服 */ if (!['cn_gf01', 'cn_qd01'].includes(param.region)) { logUrl = 'https://hk4e-api-os.hoyoverse.com/gacha_info/api/getGachaLog?' } let logParam = new URLSearchParams({ authkey_ver: 1, lang: 'zh-cn', // 只支持简体中文 gacha_type: 301, page: 1, size: 20, end_id: 0, ...param }).toString() if (this.e.isSr) { logUrl = 'https://api-takumi.mihoyo.com/common/gacha_record/api/getGachaLog?' if (!['prod_gf_cn', 'prod_qd_cn'].includes(param.region)) { logUrl = 'https://api-os-takumi.mihoyo.com/common/gacha_record/api/getGachaLog?' } logParam = new URLSearchParams({ authkey_ver: 1, lang: 'zh-cn', // 只支持简体中文 gacha_type: 11, page: 1, size: 20, game_biz: 'hkrpg_cn', end_id: 0, ...param }).toString() } let res = await fetch(logUrl + logParam).catch(err => { logger.error(`[获取抽卡记录失败] ${err}`) }) if (!res || !res.ok) { return { retcode: 400 } } return await res.json() } /** 更新抽卡记录 */ async updateLog() { /** 获取authkey */ let authkey = await redis.get(`${this.urlKey}${this.uid}`) if (!authkey) return false /** 调一次接口判断是否有效 */ let res = await this.logApi({ gacha_type: this.type, authkey, region: this.getServer() }) /** key过期,或者没有数据 */ if (res.retcode !== 0 || !res?.data?.list || res.data.list.length <= 0) { logger.debug(`${this.e.logFnc} ${res.message || 'error'}`) return false } logger.mark( `${this.e.logFnc}[UID:${this.uid}] 开始获取:${this.typeName}记录...` ) let all = [] let logJson = this.readJson() /** 第一次获取增加提示 */ if (lodash.isEmpty(logJson.list) && this.type === 301) { await this.e.reply( `开始获取${this.typeName}记录,首次获取数据较多,请耐心等待...` ) } let logRes = await this.getAllLog(logJson.ids, authkey) if (logRes.hasErr) { this.e.reply(`获取${this.typeName}记录失败`) return false } /** 数据合并 */ let num = logRes.list.length if (num > 0) { all = logRes.list.concat(logJson.list) /** 保存json */ this.writeJson(all) this.all = all } return { num } } /** 递归获取所有数据 */ async getAllLog(ids, authkey, page = 1, endId = 0) { let res = await this.logApi({ gacha_type: this.type, page, end_id: endId, authkey, region: this.getServer() }) /** 延迟下防止武器记录获取失败 */ await sleep(1000) if (res.retcode != 0) { return { hasErr: true, list: [] } } if (!res?.data?.list || res.data.list.length <= 0) { logger.mark( `${this.e.logFnc}[UID:${this.uid}] 获取${this.typeName}记录完成,共${Number(page) - 1}页` ) return { hasErr: false, list: [] } } let list = [] for (let val of res.data.list) { if (ids.get(String(val.id))) { logger.mark( `${this.e.logFnc}[UID:${this.uid}] 获取${this.typeName}记录完成,暂无新记录` ) return { hasErr: false, list } } else { list.push(val) endId = val.id } } page++ if (page % 3 == 0) { await sleep(500) } else { await sleep(300) } let res2 = await this.getAllLog(ids, authkey, page, endId) list = list.concat(res2.list) return { hasErr: res2.hasErr, list } } // 读取本地json readJson() { let logJson = [] let ids = new Map() let file = `${this.path}/${this.uid}/${this.type}.json` if (fs.existsSync(file)) { // 获取本地数据 进行数据合并 logJson = JSON.parse(fs.readFileSync(file, 'utf8')) for (let val of logJson) { if (val.id) { ids.set(String(val.id), val.id) } } } return { list: logJson, ids } } creatFile() { if (!fs.existsSync(this.path)) { fs.mkdirSync(this.path) } if (!this.uid) return let file = `${this.path}${this.uid}/` if (!fs.existsSync(file)) { fs.mkdirSync(file) } } writeJson(data) { this.creatFile() let file = `${this.path}${this.uid}/` fs.writeFileSync(`${file}${this.type}.json`, JSON.stringify(data, '', '\t')) } /** #抽卡记录 */ async getLogData() { /** 判断uid */ await this.getUid() if (!this.uid) { return false } if (this.e?.isAll) { return await this.getAllGcLogData() } else { return await this.getGcLogData() } } async getAllGcLogData() { this.model = 'gachaAllLog' const poolList = ['角色', this.e?.isSr ? '光锥' : '武器', '集录', '常驻'] const logData = [] let fiveMaxNum = 0 const originalMsg = this.e.msg for (let i of poolList) { this.e.msg = i this.all = [] let data = await this.getGcLogData() if (!data || data.allNum === 0) { continue } if (fiveMaxNum <= data.fiveLog.length) { fiveMaxNum = data.fiveLog.length } data.max = i === '武器' || i === '光锥' ? 80 : 90 logData.push(data) } if (logData.length === 0) { this.e.reply( `暂无抽卡记录\n${this.e?.isSr ? '*' : '#'}记录帮助,查看配置说明`, false, { at: true } ) return true } for (let i of logData) { let diffNum = fiveMaxNum - i.fiveLog.length if (diffNum > 0) { i.fiveLog = i.fiveLog.concat( new Array(diffNum).fill({ isUp: false, isNull: true }) ) } } const data = { ...logData[0], data: logData } this.e.msg = originalMsg return data } async getGcLogData() { /** 卡池 */ const { type, typeName } = this.getPool() /** 更新记录 */ if (!this.isLogUrl) await this.updateLog() /** 统计计算记录 */ let data = this.analyse() data.type = type data.typeName = typeName /** 渲染数据 */ data = this.randData(data) return data } getPool() { let msg = this.e.msg.replace( /#|抽卡|记录|祈愿|分析|池|原神|星铁|崩坏星穹铁道|铁道/g, '' ) let type = this.e.isSr ? 11 : 301 let typeName = '角色' switch (msg) { case 'up': case '抽卡': case '角色': case '抽奖': type = this.e.isSr ? 11 : 301 typeName = '角色' break case '常驻': type = this.e.isSr ? 1 : 200 typeName = '常驻' break case '武器': type = this.e.isSr ? 12 : 302 typeName = this.e.isSr ? '光锥' : '武器' break case '集录': type = 500 typeName = '集录' break case '光锥': type = 12 typeName = '光锥' break case '新手': type = this.e.isSr ? 2 : 100 typeName = '新手' break } this.type = type this.typeName = typeName return { type, typeName } } async getUid() { if (!fs.existsSync(this.path)) { this.e.reply( `暂无抽卡记录\n${this.e?.isSr ? '*' : '#'}记录帮助,查看配置说明`, false, { at: true } ) return false } let logs = fs.readdirSync(this.path) if (lodash.isEmpty(logs)) { this.e.reply( `暂无抽卡记录\n${this.e?.isSr ? '*' : '#'}记录帮助,查看配置说明`, false, { at: true } ) return false } if (!this.uid) { this.e.at = false this.uid = this?.e?.isSr ? this.e.user?._games?.sr?.uid : this.e.user?._games?.gs?.uid || (await this.e.runtime.getUid(this.e)) || (await redis.get(this.uidKey)) } /** 记录有绑定的uid */ if (this.uid && logs.includes(String(this.uid))) { return this.uid } /** 拿修改时间最后的uid */ let uidArr = [] for (let uid of logs) { let json = this?.e?.isSr ? `${this.path}${uid}/11.json` : `${this.path}${uid}/301.json` if (!fs.existsSync(json)) { continue } let tmp = fs.statSync(json) uidArr.push({ uid, mtimeMs: tmp.mtimeMs }) } if (uidArr.length <= 0) { return false } uidArr = uidArr.sort(function (a, b) { return b.mtimeMs - a.mtimeMs }) this.uid = uidArr[0].uid return uidArr[0].uid } /** 统计计算记录 */ analyse() { if (lodash.isEmpty(this.all)) { this.all = this.readJson().list } let fiveLog = [] let fourLog = [] let fiveNum = 0 let fourNum = 0 let fiveLogNum = 0 let fourLogNum = 0 let noFiveNum = 0 let noFourNum = 0 let wai = 0 // 歪 let weaponNum = 0 let weaponFourNum = 0 let allNum = this.all.length let bigNum = 0 let game = this.e?.game for (let val of this.all) { this.role = val if (val.rank_type == 4) { fourNum++ if (noFourNum == 0) { noFourNum = fourLogNum } fourLogNum = 0 if (fourLog[val.name]) { fourLog[val.name]++ } else { fourLog[val.name] = 1 } if (val.item_type == '武器' || val.item_type == '光锥') { weaponFourNum++ } } fourLogNum++ if (val.rank_type == 5) { fiveNum++ if (fiveLog.length > 0) { fiveLog[fiveLog.length - 1].num = fiveLogNum } else { noFiveNum = fiveLogNum } fiveLogNum = 0 let isUp = false // 歪了多少个 if (val.item_type == '角色') { if (this.checkIsUp()) { isUp = true } else { wai++ } } else { weaponNum++ } fiveLog.push({ name: val.name, icon: GachaLog.getIcon(val.name, val.item_type, game), abbrName: gsCfg.shortName(val.name), item_type: val.item_type, num: 0, isUp }) } fiveLogNum++ } if (fiveLog.length > 0) { fiveLog[fiveLog.length - 1].num = fiveLogNum // 删除未知五星 for (let i in fiveLog) { if (fiveLog[i].name == '未知') { allNum = allNum - fiveLog[i].num fiveLog.splice(i, 1) fiveNum-- } else { // 上一个五星是不是常驻 let lastKey = Number(i) + 1 if (fiveLog[lastKey] && !fiveLog[lastKey].isUp) { fiveLog[i].minimum = true bigNum++ } else { fiveLog[i].minimum = false } } } } else { // 没有五星 noFiveNum = allNum } // 四星最多 let four = [] for (let i in fourLog) { four.push({ name: i, num: fourLog[i] }) } four = four.sort((a, b) => { return b.num - a.num }) if (four.length <= 0) { four.push({ name: '无', num: 0 }) } let fiveAvg = 0 let fourAvg = 0 if (fiveNum > 0) { fiveAvg = Math.round((allNum - noFiveNum) / fiveNum) } if (fourNum > 0) { fourAvg = Math.round((allNum - noFourNum) / fourNum) } // 有效抽卡 let isvalidNum = 0 if (fiveNum > 0 && fiveNum > wai) { if (fiveLog.length > 0 && !fiveLog[0].isUp) { isvalidNum = Math.round( (allNum - noFiveNum - fiveLog[0].num) / (fiveNum - wai) ) } else { isvalidNum = Math.round((allNum - noFiveNum) / (fiveNum - wai)) } } let upYs = isvalidNum * 160 if (upYs >= 10000) { upYs = (upYs / 10000).toFixed(2) + 'w' } else { upYs = upYs.toFixed(0) } // 小保底不歪概率 let noWaiRate = 0 if (fiveNum > 0) { noWaiRate = (fiveNum - bigNum - wai) / (fiveNum - bigNum) noWaiRate = (noWaiRate * 100).toFixed(1) } let firstTime = this.all[this.all.length - 1]?.time.substring(0, 16) let lastTime = this.all[0]?.time.substring(0, 16) return { allNum, noFiveNum, noFourNum, fiveNum, fourNum, fiveAvg, fourAvg, wai, isvalidNum, maxFour: four[0], weaponNum, weaponFourNum, firstTime, lastTime, fiveLog, upYs, noWaiRate } } checkIsUp() { if ( [ '莫娜', '七七', '迪卢克', '琴', '姬子', '杰帕德', '彦卿', '白露', '瓦尔特', '克拉拉', '布洛妮娅' ].includes(this.role.name) ) { return false } let role5join = { 刻晴: { start: '2021-02-17 18:00:00', end: '2021-03-02 15:59:59' }, 提纳里: { start: '2022-08-24 06:00:00', end: '2022-09-09 17:59:59' }, 迪希雅: { start: '2023-03-01 06:00:00', end: '2023-03-21 17:59:59' } } if (lodash.keys(role5join).includes(this.role.name)) { let start = new Date(role5join[this.role.name].start).getTime() let end = new Date(role5join[this.role.name].end).getTime() let logTime = new Date(this.role.time).getTime() if (logTime < start || logTime > end) { return false } else { return true } } return true } /** 渲染数据 */ randData(data) { const type = data.type || this.type const typeName = data.typeName || this.typeName const max = type === 12 || type === 302 ? 80 : 90 let line = [] let weapon = this.e.isSr ? '光锥' : '武器' //最非,最欧 let maxValue, minValue if (data && data.fiveLog) { const filteredFiveLog = data.fiveLog.filter(item => item.num !== 0) if (filteredFiveLog.length > 0) { maxValue = Math.max(...filteredFiveLog.map(item => item.num)) minValue = Math.min(...filteredFiveLog.map(item => item.num)) } else { if (data.fiveLog[0]) { maxValue = data.fiveLog[0] minValue = data.fiveLog[0] } else { maxValue = 0 minValue = 0 } } } else { maxValue = 0 minValue = 0 } if ([301, 11].includes(type)) { line = [ [ { lable: '未出五星', num: data.noFiveNum, unit: '抽' }, { lable: '五星', num: data.fiveNum, unit: '个' }, { lable: '五星平均', num: data.fiveAvg, unit: '抽', color: data.fiveColor }, { lable: '小保底不歪', num: data.noWaiRate + '%', unit: '' }, { lable: '最非', num: maxValue, unit: '抽' } ], [ { lable: '未出四星', num: data.noFourNum, unit: '抽' }, { lable: '五星常驻', num: data.wai, unit: '个' }, { lable: 'UP平均', num: data.isvalidNum, unit: '抽' }, { lable: `UP花费${this?.e?.isSr ? '星琼' : '原石'}`, num: data.upYs, unit: '' }, { lable: '最欧', num: minValue, unit: '抽' } ] ] } // 常驻池 if ([200, 1].includes(type)) { line = [ [ { lable: '未出五星', num: data.noFiveNum, unit: '抽' }, { lable: '五星', num: data.fiveNum, unit: '个' }, { lable: '五星平均', num: data.fiveAvg, unit: '抽', color: data.fiveColor }, { lable: `五星${weapon}`, num: data.weaponNum, unit: '个' }, { lable: '最非', num: maxValue, unit: '抽' } ], [ { lable: '未出四星', num: data.noFourNum, unit: '抽' }, { lable: '四星', num: data.fourNum, unit: '个' }, { lable: '四星平均', num: data.fourAvg, unit: '抽' }, { lable: '四星最多', num: data.maxFour.num, unit: data.maxFour.name.slice(0, 4) }, { lable: '最欧', num: minValue, unit: '抽' } ] ] } // 武器池 if ([302, 12].includes(type)) { line = [ [ { lable: '未出五星', num: data.noFiveNum, unit: '抽' }, { lable: '五星', num: data.fiveNum, unit: '个' }, { lable: '五星平均', num: data.fiveAvg, unit: '抽', color: data.fiveColor }, { lable: `四星${weapon}`, num: data.weaponFourNum, unit: '个' }, { lable: '最非', num: maxValue, unit: '抽' } ], [ { lable: '未出四星', num: data.noFourNum, unit: '抽' }, { lable: '四星', num: data.fourNum, unit: '个' }, { lable: '四星平均', num: data.fourAvg, unit: '抽' }, { lable: '四星最多', num: data.maxFour.num, unit: data.maxFour.name.slice(0, 4) }, { lable: '最欧', num: minValue, unit: '抽' } ] ] } // 集录池 if ([500].includes(type)) { line = [ [ { lable: '未出五星', num: data.noFiveNum, unit: '抽' }, { lable: '五星', num: data.fiveNum, unit: '个' }, { lable: '五星平均', num: data.fiveAvg, unit: '抽', color: data.fiveColor }, { lable: `四星${weapon}`, num: data.weaponFourNum, unit: '个' }, { lable: '最非', num: maxValue, unit: '抽' } ], [ { lable: '未出四星', num: data.noFourNum, unit: '抽' }, { lable: '四星', num: data.fourNum, unit: '个' }, { lable: '四星平均', num: data.fourAvg, unit: '抽' }, { lable: '四星最多', num: data.maxFour.num, unit: data.maxFour.name.slice(0, 4) }, { lable: '最欧', num: minValue, unit: '抽' } ] ] } // 新手池 if ([100, 2].includes(type)) { line = [ [ { lable: '未出五星', num: data.noFiveNum, unit: '抽' }, { lable: '五星', num: data.fiveNum, unit: '个' }, { lable: '五星平均', num: data.fiveAvg, unit: '抽', color: data.fiveColor }, { lable: `五星${weapon}`, num: data.weaponNum, unit: '个' }, { lable: '最非', num: maxValue, unit: '抽' } ], [ { lable: '未出四星', num: data.noFourNum, unit: '抽' }, { lable: '四星', num: data.fourNum, unit: '个' }, { lable: '四星平均', num: data.fourAvg, unit: '抽' }, { lable: '四星最多', num: data.maxFour.num, unit: data.maxFour.name.slice(0, 4) }, { lable: '最欧', num: minValue, unit: '抽' } ] ] } let hasMore = false // if (this.e.isGroup && data.fiveLog.length > 48) { // data.fiveLog = data.fiveLog.slice(0, 48) // hasMore = true // } return { ...this.screenData, saveId: this.uid, uid: this.uid, type, typeName, allNum: data.allNum, firstTime: data.firstTime, lastTime: data.lastTime, fiveLog: data.fiveLog, line, hasMore, max } } getServer() { switch (String(this.uid).slice(0, -8)) { case '1': case '2': return this.e.isSr ? 'prod_gf_cn' : 'cn_gf01' // 官服 case '5': return this.e.isSr ? 'prod_qd_cn' : 'cn_qd01' // B服 case '6': return this.e.isSr ? 'prod_official_usa' : 'os_usa' // 美服 case '7': return this.e.isSr ? 'prod_official_euro' : 'os_euro' // 欧服 case '8': case '18': return this.e.isSr ? 'prod_official_asia' : 'os_asia' // 亚服 case '9': return this.e.isSr ? 'prod_official_cht' : 'os_cht' // 港澳台服 } return 'cn_gf01' } }