const fs = require('fs-extra') const util = require('util') const path = require('path') const { URL } = require('url') const { app, ipcMain, shell } = require('electron') const { sleep, request, sendMsg, readJSON, saveJSON, detectLocale, getCacheText, userDataPath, userPath, localIp, langMap } = require('./utils') const config = require('./config') const i18n = require('./i18n') const { enableProxy, disableProxy } = require('./module/system-proxy') const mitmproxy = require('./module/node-mitmproxy') const { mergeData } = require('./utils/mergeData') const gachaTypeRaw = require('../gachaType.json') const dataMap = new Map() const order = ['2', '3', '1', '5'] let apiDomain = 'https://public-operation-nap.mihoyo.com' const saveData = async (data, url) => { const obj = Object.assign({}, data) obj.result = [...obj.result] await config.save() await saveJSON(`gacha-list-${data.uid}.json`, obj) } const defaultTypeMap = new Map([ ['2', '独家频段'], ['3', '音擎频段'], ['1', '常驻频段'], ['5', '邦布频段'] ]) const serverTimeZone = new Map([ ["prod_gf_cn", 8], ["prod_gf_jp", 8], ["prod_gf_us", -5], ["prod_gf_eu", 1], ["prod_gf_sg", 8] ]) const convertTimeZone = (dateTimeStr, fromTimeZoneOffset, toTimeZoneOffset) => { let date = new Date(dateTimeStr.replace(' ', 'T') + 'Z'); let utcDate = new Date(date.getTime() - fromTimeZoneOffset * 60 * 60 * 1000); let targetDate = new Date(utcDate.getTime() + toTimeZoneOffset * 60 * 60 * 1000); let year = targetDate.getUTCFullYear(); let month = String(targetDate.getUTCMonth() + 1).padStart(2, '0'); let day = String(targetDate.getUTCDate()).padStart(2, '0'); let hours = String(targetDate.getUTCHours()).padStart(2, '0'); let minutes = String(targetDate.getUTCMinutes()).padStart(2, '0'); let seconds = String(targetDate.getUTCSeconds()).padStart(2, '0'); return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; } const findDataFiles = async (dataPath, fileMap) => { const files = await readdir(dataPath) if (files?.length) { for (let name of files) { if (/^gacha-list-\d+\.json$/.test(name) && !fileMap.has(name)) { fileMap.set(name, dataPath) } } } } const collectDataFiles = async () => { await fs.ensureDir(userDataPath) const fileMap = new Map() await findDataFiles(userDataPath, fileMap) return fileMap } let localDataReaded = false const readdir = util.promisify(fs.readdir) const readData = async () => { if (localDataReaded) return localDataReaded = true const fileMap = await collectDataFiles() for (let [name, dataPath] of fileMap) { try { const data = await readJSON(dataPath, name) data.typeMap = new Map(data.typeMap) || defaultTypeMap data.result = new Map(data.result) data.result.forEach((value, key) => { value.forEach(item => { if (!('count' in item)) { item.count = "1"; } }); }); if (data.uid) { dataMap.set(data.uid, data) } } catch (e) { sendMsg(e, 'ERROR') } } if ((!config.current && dataMap.size) || (config.current && dataMap.size && !dataMap.has(config.current))) { await changeCurrent(dataMap.keys().next().value) } } const deleteData = async (uid, action) => { const data = dataMap.get(uid) if (data) { data.deleted = action await saveData(data) } } const changeCurrent = async (uid) => { config.current = uid await config.save() } const detectGameLocale = async (userPath) => { let list = [] const lang = app.getLocale() const arr = ['/miHoYo/绝区零/', '/miHoYo/ZenlessZoneZero/'] arr.forEach(str => { try { const pathname = path.join(userPath, '/AppData/LocalLow/', str, 'Player.log') fs.accessSync(pathname, fs.constants.F_OK) list.push(pathname) } catch (e) {} }) if (config.logType) { if (config.logType === 2) { list.reverse() } list = list.slice(0, 1) } else if (lang !== 'zh-CN') { list.reverse() } return list } const getLatestUrl = (list) => { let result = list[list.length - 1] // let time = 0 // for (let i = 0; i < list.length; i++) { // const tsMch = list[i].match(/timestamp=(\d+)/) // if (tsMch?.[1]) { // const ts = parseInt(tsMch[1]) // if (time <= parseInt(tsMch[1])) { // time = ts // result = list[i] // } // } // } return result } let cacheFolder = null const readLog = async () => { const text = i18n.log try { let userPath if (!process.env.WINEPREFIX) { userPath = app.getPath('home') } else { userPath = path.join(process.env.WINEPREFIX, 'drive_c/users', process.env.USER) } const logPaths = await detectGameLocale(userPath) if (!logPaths.length) { sendMsg(text.file.notFound) return false } const promises = logPaths.map(async logpath => { const logText = await fs.readFile(logpath, 'utf8') const url = logText.match(/https:\/\/.*?\/info/g) if (url) { return getLatestUrl(url) } const gamePathMch = logText.match(/([A-Z]:\/.*?\/)(?=ZenlessZoneZero_Data)/i) if (gamePathMch) { const[cacheText, cacheFile] = await getCacheText(gamePathMch[0]+"/ZenlessZoneZero_Data") const urlMch = cacheText.match(/https.+?authkey=.+?end_id=/g) if (urlMch) { cacheFolder = cacheFile.replace(/Cache_Data[/\\]data_2$/, '') return getLatestUrl(urlMch) } } }) const result = await Promise.all(promises) for (let url of result) { if (url) { return url } } sendMsg(text.url.notFound) return false } catch (e) { sendMsg(text.file.readFailed) return false } } const getGachaLog = async ({ key, page, name, retryCount, url, endId }) => { const text = i18n.log try { const res = await request(`${url}&real_gacha_type=${key}&page=${page}&size=${20}${endId ? '&end_id=' + endId : ''}`) if (res?.data?.list) { return res?.data } throw new Error(res?.message || res) } catch (e) { if (retryCount) { sendMsg(i18n.parse(text.fetch.retry, { name, page, count: 6 - retryCount })) await sleep(5) retryCount-- return await getGachaLog({ key, page, name, retryCount, url, endId }) } else { sendMsg(i18n.parse(text.fetch.retryFailed, { name, page })) throw e } } } const getGachaLogs = async ({ name, key }, queryString) => { const text = i18n.log let page = 1 let list = [] let res = null let logs = [] let uid = '' let region = '' let endId = '0' const url = `${apiDomain}/common/gacha_record/api/getGachaLog?${queryString}` do { if (page % 10 === 0) { sendMsg(i18n.parse(text.fetch.interval, { name, page })) await sleep(1) } sendMsg(i18n.parse(text.fetch.current, { name, page })) res = await getGachaLog({ key, page, name, url, endId, retryCount: 5 }) await sleep(0.3) logs = res?.list || [] if (!uid && logs.length) { uid = logs[0].uid } if (!region) { region = res.region } list.push(...logs) page += 1 if (logs.length) { endId = logs[logs.length - 1].id } if (!config.fetchFullHistory && logs.length && uid && dataMap.has(uid)) { const result = dataMap.get(uid).result if (result.has(key)) { const arr = result.get(key) if (arr.length) { const localLatestId = arr[arr.length - 1].id if (localLatestId) { let shouldBreak = false logs.forEach(item => { if (item.id === localLatestId) { shouldBreak = true } }) if (shouldBreak) { break } } } } } } while (logs.length > 0) return { list, uid, region } } const checkResStatus = (res) => { const text = i18n.log if (res.retcode !== 0) { let message = res.message if (res.message === 'authkey timeout') { message = text.fetch.authTimeout sendMsg(true, 'AUTHKEY_TIMEOUT') } sendMsg(message) throw new Error(message) } sendMsg(false, 'AUTHKEY_TIMEOUT') return res } const tryGetUid = async (queryString) => { const url = `${apiDomain}/common/gacha_record/api/getGachaLog?${queryString}` try { for (let [key] of defaultTypeMap) { const res = await request(`${url}&real_gacha_type=${key}&page=1&size=6`) if (res.data.list && res.data.list.length) { return res.data.list[0].uid } } } catch (e) {} return config.current } const gachaTypeMap = new Map(gachaTypeRaw) const getGachaType = (lang) => { const locale = detectLocale(lang) return gachaTypeMap.get(locale || lang) } const fixAuthkey = (url) => { const mr = url.match(/authkey=([^&]+)/) if (mr && mr[1] && mr[1].includes('=') && !mr[1].includes('%')) { return url.replace(/authkey=([^&]+)/, `authkey=${encodeURIComponent(mr[1])}`) } return url } const getQuerystring = (url) => { const text = i18n.log const { searchParams, host } = new URL(fixAuthkey(url)) if (host.includes('webstatic-sea') || host.includes('hoyoverse.com')) { apiDomain = 'https://public-operation-nap-sg.hoyoverse.com' } else { apiDomain = 'https://public-operation-nap.mihoyo.com' } const authkey = searchParams.get('authkey') if (!authkey) { sendMsg(text.url.lackAuth) return false } searchParams.delete('page') searchParams.delete('size') searchParams.delete('real_gacha_type') searchParams.delete('end_id') return searchParams } const proxyServer = (port) => { return new Promise((rev) => { mitmproxy.createProxy({ sslConnectInterceptor: (req, cltSocket, head) => { if (/webstatic([^\.]{2,10})?\.(mihoyo|hoyoverse)\.com/.test(req.url)) { return true } }, requestInterceptor: (rOptions, req, res, ssl, next) => { next() if (/webstatic([^\.]{2,10})?\.(mihoyo|hoyoverse)\.com/.test(rOptions.hostname)) { if (/authkey=[^&]+/.test(rOptions.path)) { rev(`${rOptions.protocol}//${rOptions.hostname}${rOptions.path}`) } } }, responseInterceptor: (req, res, proxyReq, proxyRes, ssl, next) => { next() }, getPath: () => path.join(userPath, 'node-mitmproxy'), port }) }) } let proxyServerPromise const useProxy = async () => { const text = i18n.log const ip = localIp() const port = config.proxyPort sendMsg(i18n.parse(text.proxy.hint, { ip, port })) await enableProxy('127.0.0.1', port) if (!proxyServerPromise) { proxyServerPromise = proxyServer(port) } const url = await proxyServerPromise await disableProxy() return url } const getUrlFromConfig = () => { if (config.urls.size) { if (config.current && config.urls.has(config.current)) { const url = config.urls.get(config.current) return url } } } const tryRequest = async (url, retry = false) => { const queryString = getQuerystring(url) if (!queryString) return false const gachaTypeUrl = `${apiDomain}/common/gacha_record/api/getGachaLog?${queryString}&page=1&size=5&real_gacha_type=1&end_id=0` try { const res = await request(gachaTypeUrl) checkResStatus(res) } catch (e) { if (e.code === 'ERR_PROXY_CONNECTION_FAILED' && !retry) { await disableProxy() return await tryRequest(url, true) } sendMsg(e.message.replace(url, '***'), 'ERROR') throw e } } const getUrl = async () => { let url = await readLog() if (!url && config.proxyMode) { url = await useProxy() } return url } const fetchData = async (urlOverride) => { const text = i18n.log await readData() let url = urlOverride if (!url) { url = await getUrl() } if (!url) { const message = text.url.notFound2 sendMsg(message) throw new Error(message) } await tryRequest(url) const searchParams = getQuerystring(url) if (!searchParams) { const message = text.url.incorrect sendMsg(message) throw new Error(message) } let queryString = searchParams.toString() const vUid = await tryGetUid(queryString) const localLang = dataMap.has(vUid) ? dataMap.get(vUid).lang : '' if (localLang) { searchParams.set('lang', localLang) } queryString = searchParams.toString() const gachaType = await getGachaType(searchParams.get('lang')) const result = new Map() const typeMap = new Map() const lang = searchParams.get('lang') let originUid = '' let localTimeZone for (const type of gachaType) { const { list, uid, region} = await getGachaLogs(type, queryString) const region_time_zone = serverTimeZone.get(region) if(!region_time_zone) { sendMsg('不支持此服务器') console.error('不支持此服务器') return } if (localTimeZone === undefined) { localTimeZone = dataMap.get(uid)?.region_time_zone if (localTimeZone === undefined) { localTimeZone = region_time_zone } } localTimeZone === Number(localTimeZone) list.forEach(item => { item.time = convertTimeZone(item.time, region_time_zone, localTimeZone) }) const logs = list.map((item) => { const { id, item_id, item_type, name, rank_type, time, gacha_id, gacha_type, count} = item return { id, item_id, item_type, name, rank_type, time, gacha_id, gacha_type, count } }) logs.reverse() typeMap.set(type.key, type.name) result.set(type.key, logs) if (!originUid) { originUid = uid } } const data = { result, typeMap, time: Date.now(), uid: originUid, lang, region_time_zone: localTimeZone } const localData = dataMap.get(originUid) const mergedResult = mergeData(localData, data) data.result = mergedResult dataMap.set(originUid, data) await changeCurrent(originUid) await saveData(data, url) } let proxyStarted = false const fetchDataByProxy = async () => { if (proxyStarted) return proxyStarted = true const url = await useProxy() await fetchData(url) } ipcMain.handle('FETCH_DATA', async (event, param) => { try { if (param === 'proxy') { await fetchDataByProxy() } else { await fetchData(param) } return { dataMap, current: config.current } } catch (e) { sendMsg(e, 'ERROR') console.error(e) } return false }) ipcMain.handle('READ_DATA', async () => { await readData() return { dataMap, current: config.current } }) ipcMain.handle('CHANGE_UID', (event, uid) => { changeCurrent(uid) }) ipcMain.handle('GET_CONFIG', () => { return config.value() }) ipcMain.handle('LANG_MAP', () => { return langMap }) ipcMain.handle('SAVE_CONFIG', (event, [key, value]) => { config[key] = value config.save() }) ipcMain.handle('DISABLE_PROXY', async () => { await disableProxy() }) ipcMain.handle('I18N_DATA', () => { return i18n.data }) ipcMain.handle('OPEN_CACHE_FOLDER', () => { if (cacheFolder) { shell.openPath(cacheFolder) } }) exports.getData = () => { return { dataMap, current: config.current } } exports.serverTimeZone = serverTimeZone exports.getUrl = getUrl exports.deleteData = deleteData exports.saveData = saveData exports.changeCurrent = changeCurrent exports.convertTimeZone = convertTimeZone