diff --git a/lib/assets.js b/lib/assets.js index 3dfc5ed..b9eeeee 100644 --- a/lib/assets.js +++ b/lib/assets.js @@ -1,10 +1,12 @@ import { findLowestLatencyUrl } from '../utils/network.js'; +// 保存上次找的节点 let lastFindFastestUrl = { url: null, time: 0, }; +// 节点列表 const URL_LIB = { '[JPFRP]': 'http://jp-3.lcf.1l1.icu:17217', '[HKFRP]': 'http://hk-1.lcf.1l1.icu:10200', @@ -15,35 +17,46 @@ const URL_LIB = { '[Singapore]': 'https://sg.qxqx.cf', }; +// 文件类型路径 const TYPE_PATH = { wiki: 'wiki', resource: 'resource', guide: 'guide', }; +// 资源路径 const RESOURCE_PATH = { role: 'role', role_circle: 'role_circle', weapon: 'weapon', }; +// 图鉴路径 const GUIDE_PATH = { flower: 'flower', }; +/** + * 获取最快节点 + * @returns {Promise} + */ export const getFatestUrl = async () => { if ( lastFindFastestUrl.url && Date.now() - lastFindFastestUrl.time < 1000 * 60 * 5 ) { + // 如果上次找到的节点在 5 分钟内,直接返回 return lastFindFastestUrl.url; } + // 获取最快节点 const urls = Object.values(URL_LIB); const url = findLowestLatencyUrl(urls); + // 保存节点 lastFindFastestUrl = { url, time: Date.now(), }; + // 返回节点 return url; }; diff --git a/lib/authkey.js b/lib/authkey.js index e65abb9..e184b64 100644 --- a/lib/authkey.js +++ b/lib/authkey.js @@ -14,15 +14,21 @@ try { * @param {string} mysid 米游社ID * @returns */ -export function getStoken(e, mysid = '') { - let userId = e.user_id; - let user = new User(e); - let file = `${user.stokenPath}${userId}.yaml`; +export const getStoken = (e, mysid = '') => { + // 获取QQ号 + const userId = e.user_id; + // 实例化用户 + const user = new User(e); + // 获取 sk 文件路径 + const filePath = `${user.stokenPath}${userId}.yaml`; try { - let cks = fs.readFileSync(file, 'utf-8'); - cks = YAML.parse(cks); + // 读取文件 + const file = fs.readFileSync(filePath, 'utf-8'); + // 解析文件 + const cks = YAML.parse(file); for (const ck in cks) { - if (cks[ck]['stuid'] === mysid) { + if (cks?.[ck]?.['stuid'] && cks[ck]['stuid'] === mysid) { + // 如果 ck 存在并且 stuid 与 mysid 相同则返回(这两者都是字符串,不需要类型转换,因此不需要使用弱比较,强比较速度更快,没有隐式转换一步骤,注意代码规范与代码风格) return cks[ck]; } } @@ -31,20 +37,26 @@ export function getStoken(e, mysid = '') { logger.debug(`[zzz:error]getStoken`, error); return null; } -} +}; /** * 此方法依赖逍遥插件 - * @returns {Promise} + * @param {*} e yunzai Event + * @param {*} _user + * @param {string} zzzUid + * @param {string} authAppid + * @returns {Promise} */ -export async function getAuthKey(e, _user, zzzUid, authAppid = 'csc') { +export const getAuthKey = async (e, _user, zzzUid, authAppid = 'csc') => { if (!User) { throw new Error('未安装逍遥插件,无法自动刷新抽卡链接'); } + // 获取 UID 数据 const uidData = _user.getUidData(zzzUid, 'zzz', e); if (!uidData || uidData?.type != 'ck' || !uidData?.ltuid) { throw new Error(`当前UID${zzzUid}未绑定cookie,请切换UID后尝试`); } + // 获取 sk let ck = getStoken(e, uidData.ltuid); if (!ck) { throw new Error( @@ -56,7 +68,9 @@ export async function getAuthKey(e, _user, zzzUid, authAppid = 'csc') { `当前UID${zzzUid}查询所使用的米游社ID${ck.stuid}与当前切换的米游社ID${uidData.ltuid}不匹配,请切换UID后尝试` ); } + // 拼接 sk ck = `stuid=${ck.stuid};stoken=${ck.stoken};mid=${ck.mid};`; + // 实例化 API const api = new MysZZZApi(zzzUid, ck); let type = 'zzzAuthKey'; switch (authAppid) { @@ -66,14 +80,16 @@ export async function getAuthKey(e, _user, zzzUid, authAppid = 'csc') { } default: } - logger.mark(type); + // 获取链接 const { url, headers, body } = api.getUrl(type); - logger.mark(url); + // 发送请求 let res = await fetch(url, { method: 'POST', headers, body, }); + // 获取数据 res = await res.json(); + // 返回 authkey return res?.data?.authkey; -} +}; diff --git a/lib/avatar.js b/lib/avatar.js index 6e65a82..c554332 100644 --- a/lib/avatar.js +++ b/lib/avatar.js @@ -11,17 +11,20 @@ import { char } from './convert.js'; * @param {boolean} origin 是否返回原始数据 * @returns {Promise} */ -export async function getAvatarBasicList(e, api, deviceFp, origin = false) { +export const getAvatarBasicList = async (e, api, deviceFp, origin = false) => { + // 获取米游社角色列表 const avatarBaseListData = await api.getFinalData(e, 'zzzAvatarList', { deviceFp, }); if (!avatarBaseListData) return null; + // 是否返回原始数据 if (origin) return avatarBaseListData.avatar_list; + // 格式化数据 const result = avatarBaseListData.avatar_list.map( item => new ZZZAvatarBasic(item) ); return result; -} +}; /** * 获取角色详细信息列表 @@ -31,9 +34,11 @@ export async function getAvatarBasicList(e, api, deviceFp, origin = false) { * @param {string} deviceFp * @param {boolean} origin 是否返回原始数据 */ -export async function getAvatarInfoList(e, api, deviceFp, origin = false) { +export const getAvatarInfoList = async (e, api, deviceFp, origin = false) => { + // 获取角色基础信息列表 const avatarBaseList = await getAvatarBasicList(e, api, deviceFp, origin); if (!avatarBaseList) return null; + // 获取角色详细信息 const avatarInfoList = await api.getFinalData(e, 'zzzAvatarInfo', { deviceFp, query: { @@ -41,12 +46,14 @@ export async function getAvatarInfoList(e, api, deviceFp, origin = false) { }, }); if (!avatarInfoList) return null; + // 是否返回原始数据 if (origin) return avatarInfoList.avatar_list; + // 格式化数据 const result = avatarInfoList.avatar_list.map( item => new ZZZAvatarInfo(item) ); return result; -} +}; /** * 刷新面板 @@ -56,38 +63,47 @@ export async function getAvatarInfoList(e, api, deviceFp, origin = false) { * @param {string} deviceFp * @returns {Promise} */ -export async function refreshPanel(e, api, uid, deviceFp) { +export const refreshPanel = async (e, api, uid, deviceFp) => { + // 获取已保存数据 const originData = getPanelData(uid); + // 获取新数据 const newData = await getAvatarInfoList(e, api, deviceFp, true); if (!newData) return null; + // 初始化最终数据 const finalData = [...newData]; + // 如果有已保存的数据 if (originData) { + // 合并数据 for (const item of originData) { if (!finalData.find(i => i.id === item.id)) { + // 将已保存的数据添加到最终数据中(放在后面) finalData.push(item); } } } + // 保存数据 savePanelData(uid, finalData); + // 格式化数据 finalData.forEach(item => { item.isNew = newData.find(i => i.id === item.id); }); const formattedData = finalData.map(item => new ZZZAvatarInfo(item)); for (const item of formattedData) { + // 下载图片资源 await item.get_basic_assets(); } return formattedData; -} +}; /** *获取面板数据 * @param {string} uid * @returns {ZZZAvatarInfo[]} */ -export function getPanelList(uid) { +export const getPanelList = uid => { const data = getPanelData(uid); return data.map(item => new ZZZAvatarInfo(item)); -} +}; /** * 获取某个角色的面板数据 @@ -95,12 +111,15 @@ export function getPanelList(uid) { * @param {string} name * @returns {ZZZAvatarInfo | null} */ -export function getPanel(uid, name) { +export const getPanel = (uid, name) => { const _data = getPanelData(uid); + // 获取所有面板数据 const data = _data.map(item => new ZZZAvatarInfo(item)); + // 通过名称(包括别名)获取角色 ID const id = char.atlasToID(name); if (!id) return null; + // 通过 ID 获取角色数据 const result = data.find(item => item.id === id); if (!result) return null; return result; -} +}; diff --git a/lib/common.js b/lib/common.js index e5e5132..238db44 100644 --- a/lib/common.js +++ b/lib/common.js @@ -3,12 +3,18 @@ import { getStoken } from './authkey.js'; export const rulePrefix = '^((#|\\%)?(zzz|ZZZ|绝区零))'; -export async function getCk(e, s = false) { +/** + * 获取米游社用户的 cookie + * @param {Object} e yunzai事件 + * @param {boolean} s 是否获取 stoken + * @returns {Promise} + */ +export const getCk = async (e, s = false) => { e.isSr = true; let stoken = ''; - let user = new User(e); + const user = new User(e); if (s) { - stoken = await getStoken(e); + stoken = getStoken(e); } if (typeof user.getCk === 'function') { let ck = user.getCk(); @@ -19,7 +25,7 @@ export async function getCk(e, s = false) { }); return ck; } - let mysUser = (await user.user()).getMysUser('zzz'); + const mysUser = (await user.user()).getMysUser('zzz'); let ck; if (mysUser) { ck = { @@ -33,4 +39,4 @@ export async function getCk(e, s = false) { }; } return ck; -} +}; diff --git a/lib/download.js b/lib/download.js index d897cf3..44485f2 100644 --- a/lib/download.js +++ b/lib/download.js @@ -25,19 +25,24 @@ const ZZZ_SUIT_PATH = path.join(imageResourcesPath, 'suit'); */ const downloadFile = async (url, savePath) => { const _download = async (url, savePath, retry = 0) => { + // 重试次数超过 5 次则返回 null if (retry > 5) { return null; } + // 下载文件 try { const download = await fetch(url); const arrayBuffer = await download.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); + // 保存文件 if (!fs.existsSync(path.dirname(savePath))) { fs.mkdirSync(path.dirname(savePath), { recursive: true }); } fs.writeFileSync(savePath, buffer); + // 返回保存路径 return savePath; } catch (error) { + // 下载失败,重试 return await _download(url, savePath, retry + 1); } }; @@ -97,7 +102,7 @@ export const getWeaponImage = async id => { */ export const getRoleImage = async id => { const sprite = char.IDToCharSprite(id); - if (sprite === null) return null; + if (!sprite) return null; const filename = `IconRole${sprite}.png`; const rolePath = path.join(ZZZ_ROLE_PATH, filename); if (fs.existsSync(rolePath)) return rolePath; @@ -114,7 +119,7 @@ export const getRoleImage = async id => { */ export const getRoleCircleImage = async id => { const sprite = char.IDToCharSprite(id); - if (sprite === null) return null; + if (!sprite) return null; const filename = `IconRoleCircle${sprite}.png`; const roleCirclePath = path.join(ZZZ_ROLE_CIRCLE_PATH, filename); if (fs.existsSync(roleCirclePath)) return roleCirclePath; diff --git a/lib/gacha.js b/lib/gacha.js index 00a2bee..2d23e1f 100644 --- a/lib/gacha.js +++ b/lib/gacha.js @@ -4,6 +4,7 @@ import { rank } from './convert.js'; import { getGachaLog, saveGachaLog } from './db.js'; import { ZZZ_GET_GACHA_LOG_API } from './mysapi/api.js'; +// 池子代码 export const gacha_type_meta_data = { 音擎频段: ['3001'], 独家频段: ['2001'], @@ -11,13 +12,50 @@ export const gacha_type_meta_data = { 邦布频段: ['5001'], }; +// 欧非阈值 +const FLOORS_MAP = { + 邦布频段: [50, 70], + 音擎频段: [50, 70], + 独家频段: [60, 80], + 常驻频段: [60, 80], +}; + +// 欧非标签 +const HOMO_TAG = ['非到极致', '运气不好', '平稳保底', '小欧一把', '欧狗在此']; + +// 欧非表情 +const EMOJI = [ + [4, 8, 13], + [1, 10, 5], + [16, 15, 2], + [12, 3, 9], + [6, 14, 7], +]; + +// 常驻名称 +const NORMAL_LIST = [ + '「11号」', + '猫又', + '莱卡恩', + '丽娜', + '格莉丝', + '珂蕾妲', + '拘缚者', + '燃狱齿轮', + '嵌合编译器', + '钢铁肉垫', + '硫磺石', + '啜泣摇篮', +]; + /** - * @param {string} authKey - * @param {string} gachaType + * 获取抽卡链接 + * @param {string} authKey 米游社认证密钥 + * @param {string} gachaType 祈愿类型(池子代码) * @param {string} initLogGachaBaseType - * @param {number} page - * @param {string} endId - * @returns {Promise} + * @param {number} page 页数 + * @param {string} endId 最后一个数据的 id + * @returns {Promise} 抽卡链接 */ export const getZZZGachaLink = async ( authKey, @@ -26,10 +64,11 @@ export const getZZZGachaLink = async ( page = 1, endId = '0' ) => { + // 暂时直接写死服务器为国服 const serverId = 'prod_gf_cn'; const url = ZZZ_GET_GACHA_LOG_API; const timestamp = Math.floor(Date.now() / 1000); - + // 请求参数 const params = new URLSearchParams({ authkey_ver: '1', sign_type: '2', @@ -50,26 +89,27 @@ export const getZZZGachaLink = async ( size: '20', end_id: endId, }); - + // 完整链接 return `${url}?${params}`; }; /** - * - * @param {string} authKey - * @param {*} gachaType - * @param {*} initLogGachaBaseType - * @param {number} page - * @param {string} endId - * @returns {Promise} + * 通过米游社认证密钥获取抽卡记录 + * @param {string} authKey 米游社认证密钥 + * @param {string} gachaType 祈愿类型(池子代码) + * @param {string} initLogGachaBaseType + * @param {number} page 页数 + * @param {string} endId 最后一个数据的 id + * @returns {Promise} 抽卡记录 */ -export async function getZZZGachaLogByAuthkey( +export const getZZZGachaLogByAuthkey = async ( authKey, gachaType = '2001', initLogGachaBaseType = '2', page = 1, endId = '0' -) { +) => { + // 获取抽卡链接 const link = await getZZZGachaLink( authKey, gachaType, @@ -77,25 +117,25 @@ export async function getZZZGachaLogByAuthkey( page, endId ); - + // 发送请求 const response = await fetch(link, { method: 'GET', headers: { 'Content-Type': 'application/json', }, }); - + // 获取数据 const data = await response.json(); if (!data || !data?.data) return null; return new ZZZGachaLogResp(data.data); -} +}; /** - * - * @param {string} authKey - * @param {string} uid + * 更新抽卡数据 + * @param {string} authKey 米游社认证密钥 + * @param {string} uid ZZZUID * @returns {Promise<{ * data: { * [x: string]: SingleGachaLog[]; @@ -103,26 +143,39 @@ export async function getZZZGachaLogByAuthkey( * count: { * [x: string]: number; * } - * }>} + * }>} 更新后的抽卡数据 */ -export async function updateGachaLog(authKey, uid) { +export const updateGachaLog = async (authKey, uid) => { + // 获取之前的抽卡数据 let previousLog = getGachaLog(uid); if (!previousLog) { + // 如果没有数据,初始化为空对象 previousLog = {}; } + // 新的抽卡数据 let newCount = {}; + // 遍历所有池子 for (const name in gacha_type_meta_data) { if (!previousLog[name]) { + // 如果没有数据,初始化为空数组 previousLog[name] = []; } + // 初始化新数据计数(当前池子) newCount[name] = 0; + // 转换为 SingleGachaLog 对象 previousLog[name] = previousLog[name].map(i => new SingleGachaLog(i)); + // 获取上一次保存的数据 const lastSaved = previousLog[name]?.[0]; + // 初始化页数和最后一个数据的 id let page = 1; let endId = '0'; + // 新数据 const newData = []; + // 遍历当前池子的所有类型 for (const type of gacha_type_meta_data[name]) { + // 循环获取数据 queryLabel: while (true) { + // 获取抽卡记录 const log = await getZZZGachaLogByAuthkey( authKey, type, @@ -133,58 +186,42 @@ export async function updateGachaLog(authKey, uid) { if (!log || !log?.list || log?.list?.length === 0) { break; } + // 遍历数据 (从最新的开始) for (const item of log.list) { if (lastSaved && lastSaved.equals(item)) { + // 如果数据相同,说明已经获取完毕 break queryLabel; } + // 添加到新数据中 newData.push(item); + // 当前池子新数据计数加一 newCount[name]++; } + // 更新页数和最后一个数据的 id endId = log.list[log.list.length - 1]?.id || endId; page++; + // 防止请求过快 await sleep(400); } } + // 合并新数据和之前的数据 previousLog[name] = [...newData, ...previousLog[name]]; } + // 保存数据到文件 saveGachaLog(uid, previousLog); + // 返回数据 return { data: previousLog, count: newCount, }; -} - -const HOMO_TAG = ['非到极致', '运气不好', '平稳保底', '小欧一把', '欧狗在此']; -const EMOJI = [ - [4, 8, 13], - [1, 10, 5], - [16, 15, 2], - [12, 3, 9], - [6, 14, 7], -]; -const NORMAL_LIST = [ - '「11号」', - '猫又', - '莱卡恩', - '丽娜', - '格莉丝', - '珂蕾妲', - '拘缚者', - '燃狱齿轮', - '嵌合编译器', - '钢铁肉垫', - '硫磺石', - '啜泣摇篮', -]; - -const FLOORS_MAP = { - 邦布频段: [50, 70], - 音擎频段: [50, 70], - 独家频段: [60, 80], - 常驻频段: [60, 80], }; -function getLevelFromList(ast, lst) { +/** + * 欧非分析 + * @param {number} ast + * @param {number[]} lst + */ +const getLevelFromList = (ast, lst) => { if (ast === 0) { return 2; } @@ -197,40 +234,77 @@ function getLevelFromList(ast, lst) { } } return level; -} +}; -export async function anaylizeGachaLog(uid) { +/** + * 抽卡分析 + * @param {string} uid ZZZUID + * @returns {Promise<{ + * name: string; + * timeRange: string; + * list: SingleGachaLog[]; + * lastFive: number | string; + * fiveStars: number; + * upCount: number; + * totalCount: number; + * avgFive: string; + * avgUp: string; + * level: number; + * tag: string; + * emoji: number; + * }[]>} 分析结果 + */ +export const anaylizeGachaLog = async uid => { + // 读取已保存的数据 const savedData = getGachaLog(uid); if (!savedData) { return null; } + // 初始化结果 const result = []; + // 遍历所有池子 for (const name in savedData) { + // 转换为 SingleGachaLog 对象 const data = savedData[name].map(item => new SingleGachaLog(item)); + // 获取最早和最晚的数据 const earliest = data[data.length - 1]; const latest = data[0]; + // 初始化五星列表 const list = []; + // 初始化最后五星位置 let lastFive = null; + // 初始化前一个索引 let preIndex = 0; + // 遍历数据 let i = 0; for (const item of data) { + // 是否为UP let isUp = true; + // 如果是五星 if (item.rank_type === '4') { + // 下载图片资源 await item.get_assets(); + // 判断是否为常驻 if (NORMAL_LIST.includes(item.name)) { isUp = false; } + // 如果是第一个五星 if (lastFive === null) { + // 记录位置 lastFive = i; } + // 如果不是第一个五星 if (list.length > 0) { + // 计算前一个五星到当前五星的次数(即前一个五星出卡所用的抽数) list[list.length - 1]['totalCount'] = i - preIndex; + // 根据次数设置颜色 if (i - preIndex <= FLOORS_MAP[name][0]) { list[list.length - 1]['color'] = 'rgb(63, 255, 0)'; } else if (i - preIndex >= FLOORS_MAP[name][1]) { list[list.length - 1]['color'] = 'rgb(255, 20, 20)'; } } + // 添加到列表中 list.push({ ...item, rank_type_label: rank.getRankChar(item.rank_type), @@ -240,8 +314,11 @@ export async function anaylizeGachaLog(uid) { }); preIndex = i; } + // 如果是最后一个数据 if (i === data.length - 1 && list.length > 0) { + // 计算前一个五星到当前五星的次数(即前一个五星出卡所用的抽数) list[list.length - 1]['totalCount'] = i - preIndex + 1; + // 根据次数设置颜色 if (i - preIndex + 1 <= FLOORS_MAP[name][0]) { list[list.length - 1]['color'] = 'rgb(63, 255, 0)'; } else if (i - preIndex + 1 >= FLOORS_MAP[name][1]) { @@ -250,23 +327,37 @@ export async function anaylizeGachaLog(uid) { } i++; } + // 计算五星数量和UP数量 const upCount = list.filter(i => i.isUp).length; + // 计算总次数 const totalCount = data.length; + // 计算五星数量 const fiveStars = list.length; + // 初始化时间范围 let timeRange = '还没有抽卡'; + // 初始化平均五星次数 let avgFive = '-'; + // 初始化平均UP次数 let avgUp = '-'; + // 初始化欧非等级 let level = 2; + // 如果有数据 if (data.length > 0) { + // 设置时间范围 timeRange = `${latest.time} ~ ${earliest.time}`; + // 计算平均五星次数 if (fiveStars > 0) avgFive = ((totalCount - lastFive) / fiveStars).toFixed(1); + // 计算平均UP次数 if (upCount > 0) avgUp = ((totalCount - lastFive) / upCount).toFixed(1); } + // 如果没有最后五星 if (!lastFive && fiveStars === 0) { + // 设置最后五星为 '-' if (totalCount > 0) lastFive = totalCount; else lastFive = '-'; } + // 根据不同池子计算欧非等级 if (avgUp !== '-') { if ('音擎频段' === name) { level = getLevelFromList(avgUp, [62, 75, 88, 99, 111]); @@ -281,10 +372,13 @@ export async function anaylizeGachaLog(uid) { level = getLevelFromList(avgFive, [53, 60, 68, 73, 75]); } } + // 设置欧非标签 const tag = HOMO_TAG[level]; + // 设置欧非表情 const emojis = EMOJI[level]; // 随机选取一个 const emoji = emojis[Math.floor(Math.random() * emojis.length)]; + // 写入数据 result.push({ name, timeRange, @@ -301,4 +395,4 @@ export async function anaylizeGachaLog(uid) { }); } return result; -} +}; diff --git a/lib/mysapi.js b/lib/mysapi.js index bb59936..fc5987a 100644 --- a/lib/mysapi.js +++ b/lib/mysapi.js @@ -2,10 +2,12 @@ import md5 from 'md5'; import _ from 'lodash'; import crypto from 'crypto'; import ZZZApiTool from './mysapi/tool.js'; +import { randomString } from '../utils/data.js'; import MysApi from '../../genshin/model/mys/mysApi.js'; // const DEVICE_ID = randomString(32).toUpperCase() -const DEVICE_NAME = randomString(_.random(1, 10)); +// const DEVICE_NAME = randomString(_.random(1, 10)); + const game_region = [ 'prod_gf_cn', 'prod_gf_us', @@ -13,9 +15,14 @@ const game_region = [ 'prod_gf_jp', 'prod_gf_sg', ]; + +/** + * 米游社ZZZAPI(继承自MysApi) + */ export default class MysZZZApi extends MysApi { constructor(uid, cookie, option = {}) { super(uid, cookie, option, true); + // 初始化 uid、server、apiTool this.uid = uid; this.server = this.getServer(uid); this.apiTool = new ZZZApiTool(uid, this.server); @@ -30,6 +37,10 @@ export default class MysZZZApi extends MysApi { } } + /** + * 获取服务器 + * @returns {string} + */ getServer() { const _uid = this.uid?.toString(); if (_uid.length < 10) { @@ -47,10 +58,19 @@ export default class MysZZZApi extends MysApi { } } + /** + * 获取请求网址 + * @param {string} type + * @param {object} data + * @returns {object|boolean} + */ getUrl(type, data = {}) { + // 设置设备ID data.deviceId = this._device; - let urlMap = this.apiTool.getUrlMap(data); + // 获取请求地址 + const urlMap = this.apiTool.getUrlMap(data); if (!urlMap[type]) return false; + // 获取请求参数(即APITool中默认的请求参数,此参数理应是不可获取的,详细请参照 lib/mysapi/tool.js`) let { url, query = '', @@ -58,7 +78,9 @@ export default class MysZZZApi extends MysApi { noDs = false, dsSalt = '', } = urlMap[type]; + // 如果有query,拼接到url上 if (query) url += `?${query}`; + // 如果传入了 query 参数,将 query 参数拼接到 url 上 if (data.query) { let str = ''; for (let key in data.query) { @@ -77,19 +99,22 @@ export default class MysZZZApi extends MysApi { url += `?${str}`; } } + // 写入 body if (body) body = JSON.stringify(body); - + // 获取请求头 let headers = this.getHeaders(query, body); if (data.deviceFp) { headers['x-rpc-device_fp'] = data.deviceFp; // 兼容喵崽 this._device_fp = { data: { device_fp: data.deviceFp } }; } + // 写入 cookie headers.cookie = this.cookie; - + // 写入设备ID if (this._device) { headers['x-rpc-device_id'] = this._device; } + // 写入DS switch (dsSalt) { case 'web': { headers.DS = this.getDS2(); @@ -106,6 +131,7 @@ export default class MysZZZApi extends MysApi { } else { headers.DS = this.getDs(query, body); } + // 如果不需要 DS,删除 DS if (noDs) { delete headers.DS; if (this._device) { @@ -115,9 +141,16 @@ export default class MysZZZApi extends MysApi { } } logger.debug(`[mysapi]请求url:${url}`); + // 返回请求参数 return { url, headers, body }; } + /** + * 获取DS + * @param {string} q + * @param {string} b + * @returns {string} + */ getDs(q = '', b = '') { let n = ''; if (['prod_gf_cn'].includes(this.server)) { @@ -125,19 +158,29 @@ export default class MysZZZApi extends MysApi { } else { n = 'okr4obncj8bw5a65hbnn5oo6ixjc3l9w'; } - let t = Math.round(new Date().getTime() / 1000); - let r = Math.floor(Math.random() * 900000 + 100000); - let DS = md5(`salt=${n}&t=${t}&r=${r}&b=${b}&q=${q}`); + const t = Math.round(new Date().getTime() / 1000); + const r = Math.floor(Math.random() * 900000 + 100000); + const DS = md5(`salt=${n}&t=${t}&r=${r}&b=${b}&q=${q}`); return `${t},${r},${DS}`; } + /** + * 获取DS2 + * @returns {string} + */ getDS2() { - let t = Math.round(new Date().getTime() / 1000); - let r = randomString(6); - let sign = md5(`salt=BIPaooxbWZW02fGHZL1If26mYCljPgst&t=${t}&r=${r}`); + const t = Math.round(new Date().getTime() / 1000); + const r = randomString(6); + const sign = md5(`salt=BIPaooxbWZW02fGHZL1If26mYCljPgst&t=${t}&r=${r}`); return `${t},${r},${sign}`; } + /** + * 获取请求头 + * @param {string} query + * @param {string} body + * @returns {object} + */ getHeaders(query = '', body = '') { const cn = { app_version: '2.63.1', @@ -191,7 +234,7 @@ export default class MysZZZApi extends MysApi { return false; } this.e = e; - this.e.isSr = true; + this.e.isZZZ = true; res.retcode = Number(res.retcode); switch (res.retcode) { case 0: @@ -255,20 +298,3 @@ export default class MysZZZApi extends MysApi { return _data.data; } } - -export function randomString(length) { - let randomStr = ''; - for (let i = 0; i < length; i++) { - randomStr += _.sample('abcdefghijklmnopqrstuvwxyz0123456789'); - } - return randomStr; -} - -export function generateSeed(length = 16) { - const characters = '0123456789abcdef'; - let result = ''; - for (let i = 0; i < length; i++) { - result += characters[Math.floor(Math.random() * characters.length)]; - } - return result; -} diff --git a/lib/plugin.js b/lib/plugin.js index 8330e85..a6e9a0c 100644 --- a/lib/plugin.js +++ b/lib/plugin.js @@ -6,49 +6,62 @@ import settings from '../lib/settings.js'; export class ZZZPlugin extends plugin { /** - * - * @returns {Promise} + * 获取用户 UID(如果需要同时获取API,可以直接调用 getAPI) + * @returns {Promise} */ async getUID() { + // 默认为当前用户 let user = this.e; + // 获取配置 const query = settings.getConfig('config').query; const allow = _.get(query, 'others', true); + // 如果 at 存在且允许查看其他用户 if (this.e.at && allow) { + // 将当前用户的 user_id 设置为 at 对象的 user_id this.e.user_id = this.e.at; + // 将当前用户设置为 at 对象 user = this.e.at; } + // 获取用户信息(米游社),因此这里会导致查询一次米游社的信息 this.User = await NoteUser.create(user); - // let uid = this.e.msg.match(/\d+/)?.[0]; + // 获取用户 UID const uid = this.User?.getUid('zzz'); + // 如果 UID 不存在,说明没有绑定 cookie if (!uid) { await this.reply('uid为空,米游社查询请先绑定cookie,其他查询请携带uid'); return false; } + // 返回 UID return uid; } /** - * + * 获取米游社 API * @returns {Promise<{api: MysZZZApi, uid: string, deviceFp: string}>} */ async getAPI() { - let uid = await this.getUID(); + // 直接调用获取 UID + const uid = await this.getUID(); if (!uid) return false; + // 获取用户的 cookie const ck = await getCk(this.e); + // 如果 cookie 不存在或者 cookie 为空,说明没有绑定 cookie if (!ck || Object.keys(ck).filter(k => ck[k].ck).length === 0) { await this.reply('尚未绑定cookie,请先绑定cookie'); return false; } + // 创建米游社 API 对象 const api = new MysZZZApi(uid, ck); + // 获取设备指纹 let deviceFp = await redis.get(`ZZZ:DEVICE_FP:${uid}`); if (!deviceFp) { - let sdk = api.getUrl('getFp'); - let res = await fetch(sdk.url, { + const sdk = api.getUrl('getFp'); + const res = await fetch(sdk.url, { headers: sdk.headers, method: 'POST', body: sdk.body, }); - let fpRes = await res.json(); + const fpRes = await res.json(); deviceFp = fpRes?.data?.device_fp; if (deviceFp) { await redis.set(`ZZZ:DEVICE_FP:${uid}`, deviceFp, { @@ -56,19 +69,25 @@ export class ZZZPlugin extends plugin { }); } } + // 返回数据(API、UID、设备指纹) return { api, uid, deviceFp }; } /** - * + * 获取玩家信息(当调用此方法时,会获取用户的玩家信息,并将其保存到`e.playerCard`中,方便渲染用户信息(此部分请查阅`lib/render.js`中两个模块的作用)) * @returns {Promise} */ async getPlayerInfo() { + // 获取 米游社 API const { api } = await this.getAPI(); if (!api) return false; + // 获取用户信息 let userData = await api.getFinalData(this.e, 'zzzUser'); if (!userData) return false; + // 取第一个用户信息 userData = userData?.list[0]; + + // 获取用户头像 let avatar = this.e.bot.avatar; if (this.e?.user_id) { avatar = `https://q1.qlogo.cn/g?b=qq&s=0&nk=${this.e.user_id}`; @@ -77,10 +96,12 @@ export class ZZZPlugin extends plugin { } else if (this.e.friend?.getAvatarUrl) { avatar = await this.e.friend.getAvatarUrl(); } + // 写入数据 this.e.playerCard = { avatar: avatar, player: userData, }; + // 返回数据 return userData; } } diff --git a/lib/render.js b/lib/render.js index 7441fa7..662607b 100644 --- a/lib/render.js +++ b/lib/render.js @@ -11,7 +11,7 @@ import version from './version.js'; * @param {object} cfg 配置 * @returns */ -function render(e, renderPath, renderData = {}, cfg = {}) { +const render = (e, renderPath, renderData = {}, cfg = {}) => { // 判断是否存在e.runtime if (!e.runtime) { console.log('未找到e.runtime,请升级至最新版Yunzai'); @@ -30,31 +30,55 @@ function render(e, renderPath, renderData = {}, cfg = {}) { // 调用e.runtime.render方法渲染 return e.runtime.render(pluginName, renderPath, renderData, { + // 合并传入的配置 ...cfg, beforeRender({ data }) { + // 资源路径 const resPath = data.pluResPath; + // 布局路径 const layoutPath = data.pluResPath + 'common/layout/'; + // 当前的渲染路径 const renderPathFull = data.pluResPath + renderPath.split('/')[0] + '/'; + // 合并数据 return { + // 玩家信息 player: e?.playerCard?.player, + // 玩家头像 avatar: e?.playerCard?.avatar, + // 传入的数据 ...data, + // 资源路径 _res_path: resPath, + // 布局路径 _layout_path: layoutPath, + // 默认布局路径 defaultLayout: path.join(layoutPathFull, 'index.html'), + // 系统配置 sys: { + // 缩放比例 scale: pct, + // 资源路径 resourcesPath: resPath, + // 当前渲染的路径 currentPath: renderPathFull, + /** + * 下面两个模块的作用在于,你可以在你的布局文件中使用这两个模块,就可以显示用户信息和特殊标题,使用方法如下: + * 1. 展示玩家信息:首先你要在查询的时候调用`this.getPlayerInfo()`,这样,玩家数据就会保存在`e.playerCard`中,然后你就可以在布局文件中使用`{{include sys.playerInfo}}`来展示玩家信息。 + * 2. 展示特殊标题:你可以在布局文件中使用`<% include(sys.specialTitle, {en: 'PROPERTY' , cn: '属性' , count: 6 }) %>`来展示特殊标题,其中`count`为可选参数,默认为9。 + */ + // 玩家信息模块 playerInfo: path.join(layoutPathFull, 'playerinfo.html'), + // 特殊标题模块 specialTitle: path.join(layoutPathFull, 'specialtitle.html'), + // 版权信息 copyright: `Created By ${version.name}${version.yunzai} & ${pluginName}${version.version}`, + // 版权信息(简化版) createdby: `Created By ${pluginName} & Powered By ZZZure`, }, quality: 100, }; }, }); -} +}; export default render; diff --git a/lib/settings.js b/lib/settings.js index e45143d..3fa8c9b 100644 --- a/lib/settings.js +++ b/lib/settings.js @@ -59,12 +59,17 @@ class Setting { // 配置对象分析 用于锅巴插件界面设置 analysis(config) { - for (let key of Object.keys(config)) { + for (const key of Object.keys(config)) { this.setConfig(key, config[key]); } } - // 获取对应模块数据文件 + /** + * 获取对应模块数据文件 + * @param {string} filepath + * @param {string} filename + * @returns {object | false} + */ getData(filepath, filename) { filename = `${filename}.yaml`; filepath = path.join(this.dataPath, filepath); @@ -79,7 +84,13 @@ class Setting { } } - // 写入对应模块数据文件 + /** + * 写入对应模块数据文件 + * @param {string} filepath + * @param {string} filename + * @param {object} data + * @returns {boolean} + */ setData(filepath, filename, data) { filename = `${filename}.yaml`; filepath = path.join(this.dataPath, filepath); @@ -93,24 +104,38 @@ class Setting { YAML.stringify(data), 'utf8' ); + return true; } catch (error) { logger.error(`[${pluginName}] [${filename}] 写入失败 ${error}`); return false; } } - // 获取对应模块默认配置 + /** + * 获取对应模块默认配置 + * @param {'atlas'|'config'|'gacha'|'panel'} app + * @returns {object} + */ getdefSet(app) { return this.getYaml(app, 'defSet'); } - // 获取对应模块用户配置 + /** + * 获取对应模块用户配置(配置文件名) + * @param {'atlas'|'config'|'gacha'|'panel'} app + * @returns {object} + */ getConfig(app) { return { ...this.getdefSet(app), ...this.getYaml(app, 'config') }; // return this.mergeConfigObjectArray({...this.getdefSet(app)},{...this.getYaml(app, 'config')}); } - //合并两个对象 相同的数组对象 主要用于yml的列表属性合并 并去重 先备份一下方法 + /** + * 合并两个对象 相同的数组对象 主要用于yml的列表属性合并 并去重 先备份一下方法 + * @param {object} obj1 + * @param {object} obj2 + * @returns {object} + */ mergeConfigObjectArray(obj1, obj2) { for (const key in obj2) { if (Array.isArray(obj2[key]) && Array.isArray(obj1[key])) { @@ -126,9 +151,14 @@ class Setting { return obj1; } - // 设置对应模块用户配置 - setConfig(app, Object) { - return this.setYaml(app, 'config', { ...this.getdefSet(app), ...Object }); + /** + * 设置对应模块用户配置 + * @param {'atlas'|'config'|'gacha'|'panel'} app + * @param {object} obj + * @returns + */ + setConfig(app, obj) { + return this.setYaml(app, 'config', { ...this.getdefSet(app), ...obj }); } // 将对象写入YAML文件 diff --git a/lib/version.js b/lib/version.js index dd53ed0..fe4311b 100644 --- a/lib/version.js +++ b/lib/version.js @@ -3,42 +3,70 @@ import lodash from 'lodash'; import path from 'path'; import { pluginPath } from './path.js'; +// 更新日志文件位置 const _logPath = path.join(pluginPath, 'CHANGELOG.md'); +// 存放日志 let logs = {}; + +// 存放更新日志 let changelogs = []; + +// 当前版本 let currentVersion; + +// 版本数量 let versionCount = 4; +// 读取 package.json(此处为读取Yunzai-Bot的package.json) let packageJson = JSON.parse(fs.readFileSync('package.json', 'utf8')); +/** + * 将 Markdown 行转换为 HTML + * @param {*} line + * @returns + */ const getLine = function (line) { + // 去除行首空格和换行符 line = line.replace(/(^\s*\*|\r)/g, ''); + // 替换行内代码块 line = line.replace(/\s*`([^`]+`)/g, '$1'); line = line.replace(/`\s*/g, ''); + // 替换行内加粗 line = line.replace(/\s*\*\*([^\*]+\*\*)/g, '$1'); line = line.replace(/\*\*\s*/g, ''); + // 替换行内表示更新内容 line = line.replace(/ⁿᵉʷ/g, ''); + // 返回转换后的行内容(HTML) return line; }; +// 尝试读取更新日志文件 try { if (fs.existsSync(_logPath)) { + // 如果文件存在,读取文件内容 logs = fs.readFileSync(_logPath, 'utf8') || ''; + // 将文件内容按行分割 logs = logs.split('\n'); + // 遍历每一行,提取版本号和更新内容 let temp = {}; let lastLine = {}; lodash.forEach(logs, line => { + // 如果版本数量小于0,返回false if (versionCount <= -1) { return false; } - let versionRet = /^#\s*([0-9a-zA-Z\\.~\s]+?)\s*$/.exec(line); + // 匹配版本号 + const versionRet = /^#\s*([0-9a-zA-Z\\.~\s]+?)\s*$/.exec(line); if (versionRet && versionRet[1]) { - let v = versionRet[1].trim(); + // 如果匹配到版本号,提取版本号 + const v = versionRet[1].trim(); if (!currentVersion) { + // 如果当前版本号不存在,将当前版本号设置为匹配到的版本号 currentVersion = v; } else { + // 写入更新日志 changelogs.push(temp); if (/0\s*$/.test(v) && versionCount > 0) { versionCount = 0; @@ -46,15 +74,16 @@ try { versionCount--; } } - temp = { version: v, logs: [], }; } else { + // 如果行为空,不继续执行 if (!line.trim()) { return; } + // 如果行以 * 开头,表示更新内容 if (/^\*/.test(line)) { lastLine = { title: getLine(line), @@ -71,18 +100,27 @@ try { // void error } +// 读取Yunzai-Bot的版本号 const yunzaiVersion = packageJson.version; + +// 判断是否为Yunzai-Bot v3 const isV3 = yunzaiVersion[0] === '3'; + +// 是否为喵(默认为否) let isMiao = false; +// bot名称 let name = 'Yunzai-Bot'; if (packageJson.name === 'miao-yunzai') { + // 如果是喵,将 isMiao 设置为 true,name 设置为 Miao-Yunzai isMiao = true; name = 'Miao-Yunzai'; } else if (packageJson.name === 'trss-yunzai') { + // 如果是 TRSS-Yunzai,将 isMiao 设置为 true,name 设置为 TRSS-Yunzai isMiao = true; name = 'TRSS-Yunzai'; } +// 导出版本信息 const version = { isV3, isMiao, diff --git a/utils/data.js b/utils/data.js new file mode 100644 index 0000000..910c25c --- /dev/null +++ b/utils/data.js @@ -0,0 +1,26 @@ +/** + * 生成随机字符串 + * @param {number} length 长度 + * @returns {string} + */ +export const randomString = length => { + let randomStr = ''; + for (let i = 0; i < length; i++) { + randomStr += _.sample('abcdefghijklmnopqrstuvwxyz0123456789'); + } + return randomStr; +}; + +/** + * 生成随机种子 + * @param {number} length 长度 + * @returns {string} + */ +export const generateSeed = (length = 16) => { + const characters = '0123456789abcdef'; + let result = ''; + for (let i = 0; i < length; i++) { + result += characters[Math.floor(Math.random() * characters.length)]; + } + return result; +};