mirror of
https://github.com/HolographicHat/Yae.git
synced 2025-12-14 02:18:13 +08:00
code
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,4 +3,5 @@ config.json
|
|||||||
out.*
|
out.*
|
||||||
node_modules
|
node_modules
|
||||||
.idea
|
.idea
|
||||||
|
secret.js
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
140
app.js
Normal file
140
app.js
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
const zlib = require("zlib")
|
||||||
|
const proxy = require("udp-proxy")
|
||||||
|
const cp = require("child_process")
|
||||||
|
const rs = require("./regionServer")
|
||||||
|
const appcenter = require("./appcenter")
|
||||||
|
const { initConfig, splitPacket, upload, decodeProto, log, setupHost, KPacket, debug, checkCDN, checkUpdate } = require("./utils")
|
||||||
|
const { exportData } = require("./export");
|
||||||
|
|
||||||
|
// TODO: i18n
|
||||||
|
// TODO: send ack to avoid resend
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
appcenter.init()
|
||||||
|
let conf = await initConfig()
|
||||||
|
try {
|
||||||
|
cp.execSync("net session", { stdio: "ignore" })
|
||||||
|
} catch (e) {
|
||||||
|
console.log("\x1b[91m请使用管理员身份运行此程序\x1b[0m")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await checkUpdate()
|
||||||
|
checkCDN().then(_ => debug("CDN check success."))
|
||||||
|
let unexpectedExit = true
|
||||||
|
const gameProcess = cp.execFile(conf.executable, { cwd: conf.path },err => {
|
||||||
|
if (err !== null && !err.killed) {
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
})
|
||||||
|
gameProcess.on("exit", () => {
|
||||||
|
if (unexpectedExit) process.exit(0)
|
||||||
|
})
|
||||||
|
rs.create(conf,() => {
|
||||||
|
setupHost()
|
||||||
|
},(ip, port, hServer) => {
|
||||||
|
let login = false
|
||||||
|
let cache = new Map()
|
||||||
|
let lastRecvTimestamp = 0
|
||||||
|
// noinspection JSUnusedGlobalSymbols
|
||||||
|
const options = {
|
||||||
|
address: ip,
|
||||||
|
port: port,
|
||||||
|
localaddress: "127.0.0.1",
|
||||||
|
localport: 45678,
|
||||||
|
middleware: {
|
||||||
|
message: (msg, sender, next) => {
|
||||||
|
const buf = Buffer.from(msg)
|
||||||
|
if (!(login && buf.readUInt8(8) === 0x51)) {
|
||||||
|
next(msg, sender)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
proxyMsg: (msg, sender, peer, next) => {
|
||||||
|
try { next(msg, sender, peer) } catch (e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let monitor;
|
||||||
|
const createMonitor = () => {
|
||||||
|
monitor = setInterval(async () => {
|
||||||
|
if (login && lastRecvTimestamp + 2 < parseInt(Date.now() / 1000)) {
|
||||||
|
unexpectedExit = false
|
||||||
|
server.close()
|
||||||
|
hServer.close()
|
||||||
|
gameProcess.kill()
|
||||||
|
clearInterval(monitor)
|
||||||
|
console.log("正在处理数据,请稍后...")
|
||||||
|
let packets = Array.from(cache.values())
|
||||||
|
cache.clear()
|
||||||
|
packets.sort((a, b) => a.frg - b.frg)
|
||||||
|
.sort((a, b) => a.sn - b.sn)
|
||||||
|
.filter(i => i.data.byteLength !== 0)
|
||||||
|
.forEach(i => {
|
||||||
|
const psn = i.sn + i.frg
|
||||||
|
cache.has(psn) ? (() => {
|
||||||
|
const arr = cache.get(psn)
|
||||||
|
arr.push(i.data)
|
||||||
|
cache.set(psn, arr)
|
||||||
|
})() : cache.set(psn, [i.data])
|
||||||
|
})
|
||||||
|
packets = Array.from(cache.values())
|
||||||
|
.map(arr => {
|
||||||
|
const data = Buffer.concat(arr)
|
||||||
|
const len = Buffer.alloc(4)
|
||||||
|
len.writeUInt32LE(data.length)
|
||||||
|
return Buffer.concat([len, data])
|
||||||
|
})
|
||||||
|
const merged = Buffer.concat(packets)
|
||||||
|
const compressed = zlib.brotliCompressSync(merged)
|
||||||
|
const response = await upload(compressed)
|
||||||
|
if (response.status !== 200) {
|
||||||
|
log(`发生错误: ${response.data.toString()}`)
|
||||||
|
log(`请求ID: ${response.headers["x-api-requestid"]}`)
|
||||||
|
log("请联系开发者以获取帮助")
|
||||||
|
} else {
|
||||||
|
const data = zlib.brotliDecompressSync(response.data)
|
||||||
|
const proto = await decodeProto(data,"AllAchievement")
|
||||||
|
await exportData(proto)
|
||||||
|
}
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
},1000)
|
||||||
|
}
|
||||||
|
const server = proxy.createServer(options)
|
||||||
|
server.on("message", (msg, _) => {
|
||||||
|
if (msg.byteLength > 500) {
|
||||||
|
login = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
server.on("proxyMsg", (msg, _) => {
|
||||||
|
lastRecvTimestamp = parseInt(Date.now() / 1000)
|
||||||
|
let buf = Buffer.from(msg)
|
||||||
|
if (buf.byteLength <= 20) {
|
||||||
|
switch(buf.readUInt32BE(0)) {
|
||||||
|
case 325:
|
||||||
|
createMonitor()
|
||||||
|
debug("服务端握手应答")
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
console.log(`Unhandled: ${buf.toString("hex")}`)
|
||||||
|
process.exit(2)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
splitPacket(buf).forEach(sb => {
|
||||||
|
if (sb.readUInt8(8) === 0x51) {
|
||||||
|
const p = new KPacket(sb)
|
||||||
|
if (!cache.has(p.hash)) cache.set(p.hash, p)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return server
|
||||||
|
}).then(() => console.log("加载完毕."))
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error) {
|
||||||
|
appcenter.uploadError(e, true)
|
||||||
|
} else {
|
||||||
|
appcenter.uploadError(Error(e), true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()
|
||||||
131
appcenter.js
Normal file
131
appcenter.js
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
const cp = require("child_process")
|
||||||
|
const axios = require("axios")
|
||||||
|
const crypto = require("crypto")
|
||||||
|
const { version } = require("./version")
|
||||||
|
const { pid, argv0, uptime, report } = require("node:process")
|
||||||
|
|
||||||
|
const getTimestamp = (d = new Date()) => {
|
||||||
|
const p = i => i.toString().padStart(2, "0")
|
||||||
|
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}T${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}.${p(d.getUTCMilliseconds())}Z`
|
||||||
|
}
|
||||||
|
|
||||||
|
const readRegistry = (path, key) => {
|
||||||
|
const i = cp.execSync(`reg query "${path}" /v ${key}`, {
|
||||||
|
encoding: "utf-8"
|
||||||
|
}).split("\n")[2].split(" ").filter(s => s.length > 0).map(s => s.trim())
|
||||||
|
switch (i[1]) {
|
||||||
|
case "REG_SZ":
|
||||||
|
return i[2]
|
||||||
|
case "REG_DWORD":
|
||||||
|
return parseInt(i[2])
|
||||||
|
default:
|
||||||
|
throw "Unsupported"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const queue = []
|
||||||
|
const session = crypto.randomUUID()
|
||||||
|
const key = "648b83bf-d439-49bd-97f4-e1e506bdfe39"
|
||||||
|
|
||||||
|
const install = (() => {
|
||||||
|
const s = readRegistry("HKCU\\SOFTWARE\\miHoYoSDK", "MIHOYOSDK_DEVICE_ID")
|
||||||
|
return `${s.substring(0, 8)}-${s.substring(8, 12)}-${s.substring(12, 16)}-${s.substring(16, 20)}-${s.substring(20, 32)}`
|
||||||
|
})()
|
||||||
|
|
||||||
|
const device = (() => {
|
||||||
|
const csi = cp.execSync("wmic computersystem get manufacturer,model /format:csv", {
|
||||||
|
encoding: "utf-8"
|
||||||
|
}).split("\n")[2].split(",").map(s => s.trim())
|
||||||
|
const osi = cp.execSync("wmic os get currentTimeZone, version /format:csv", {
|
||||||
|
encoding: "utf-8"
|
||||||
|
}).split("\n")[2].split(",").map(s => s.trim())
|
||||||
|
return {
|
||||||
|
model: csi[2],
|
||||||
|
oemName: csi[1],
|
||||||
|
timeZoneOffset: parseInt(osi[1]),
|
||||||
|
osBuild: `${osi[2]}.${readRegistry("HKLM\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion", "UBR")}`,
|
||||||
|
osVersion: osi[2],
|
||||||
|
locale: readRegistry("HKCU\\Control Panel\\International", "LocaleName"),
|
||||||
|
carrierCountry: readRegistry("HKCU\\Control Panel\\International\\Geo", "Name"),
|
||||||
|
sdkName: "appcenter.wpf.netcore",
|
||||||
|
sdkVersion: "4.5.0",
|
||||||
|
osName: "WINDOWS",
|
||||||
|
appVersion: version.name,
|
||||||
|
appBuild: version.code,
|
||||||
|
appNamespace: "default"
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
const upload = () => {
|
||||||
|
if (queue.length > 0) {
|
||||||
|
try {
|
||||||
|
const data = JSON.stringify({ "logs": queue })
|
||||||
|
axios.post("https://in.appcenter.ms/logs?api-version=1.0.0", data,{
|
||||||
|
headers: {
|
||||||
|
"App-Secret": key,
|
||||||
|
"Install-ID": install
|
||||||
|
}
|
||||||
|
}).then(_ => {
|
||||||
|
queue.length = 0
|
||||||
|
})
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadError = (err, fatal) => {
|
||||||
|
const eid = crypto.randomUUID()
|
||||||
|
const reportJson = report.getReport(err)
|
||||||
|
const reportAttachment = {
|
||||||
|
type: "errorAttachment",
|
||||||
|
device: device,
|
||||||
|
timestamp: getTimestamp(),
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
sid: session,
|
||||||
|
errorId: eid,
|
||||||
|
contentType: "application/json",
|
||||||
|
fileName: "report.json",
|
||||||
|
data: Buffer.from(JSON.stringify(reportJson, null, 2), "utf-8").toString("base64")
|
||||||
|
}
|
||||||
|
// noinspection JSUnresolvedVariable
|
||||||
|
const errorContent = {
|
||||||
|
type: "managedError",
|
||||||
|
id: eid,
|
||||||
|
sid: session,
|
||||||
|
architecture: "AMD64",
|
||||||
|
userId: install,
|
||||||
|
fatal: fatal,
|
||||||
|
processId: pid,
|
||||||
|
processName: argv0.replaceAll("\\", "/").split("/").pop(),
|
||||||
|
timestamp: getTimestamp(),
|
||||||
|
appLaunchTimestamp: getTimestamp(new Date(Date.now() - uptime())),
|
||||||
|
exception: {
|
||||||
|
"type": err.name,
|
||||||
|
"message": err.message,
|
||||||
|
"stackTrace": err.stack
|
||||||
|
},
|
||||||
|
device: device
|
||||||
|
}
|
||||||
|
queue.push(errorContent, reportAttachment)
|
||||||
|
upload()
|
||||||
|
}
|
||||||
|
|
||||||
|
const init = () => {
|
||||||
|
queue.push({
|
||||||
|
type: "startService",
|
||||||
|
services: [ "Analytics","Crashes" ],
|
||||||
|
timestamp: getTimestamp(),
|
||||||
|
device: device
|
||||||
|
})
|
||||||
|
queue.push({
|
||||||
|
type: "startSession",
|
||||||
|
sid: session,
|
||||||
|
timestamp: getTimestamp(),
|
||||||
|
device: device
|
||||||
|
})
|
||||||
|
upload()
|
||||||
|
setInterval(() => upload(), 10000)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
init, upload, uploadError
|
||||||
|
}
|
||||||
129
export.js
Normal file
129
export.js
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
const fs = require("fs")
|
||||||
|
const util = require("util")
|
||||||
|
const readline = require("readline")
|
||||||
|
const { spawnSync } = require("child_process")
|
||||||
|
const { loadCache } = require("./utils")
|
||||||
|
|
||||||
|
const exportToSeelie = proto => {
|
||||||
|
const out = { achievements: {} }
|
||||||
|
proto.list.filter(achievement => achievement.status === 3).forEach(({id}) => {
|
||||||
|
out.achievements[id] = { done: true }
|
||||||
|
})
|
||||||
|
const fp = `./export-${Date.now()}-seelie.json`
|
||||||
|
fs.writeFileSync(fp, JSON.stringify(out))
|
||||||
|
console.log(`导出为文件: ${fp}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportToPaimon = async proto => {
|
||||||
|
const out = { achievements: {} }
|
||||||
|
const achTable = new Map()
|
||||||
|
const excel = await loadCache("ExcelBinOutput/AchievementExcelConfigData.json")
|
||||||
|
excel.forEach(({GoalId, Id}) => {
|
||||||
|
achTable.set(Id, GoalId === undefined ? 0 : GoalId)
|
||||||
|
})
|
||||||
|
proto.list.filter(achievement => achievement.status === 3).forEach(({id}) => {
|
||||||
|
const gid = achTable.get(id)
|
||||||
|
if (out.achievements[gid] === undefined) {
|
||||||
|
out.achievements[gid] = {}
|
||||||
|
}
|
||||||
|
out.achievements[gid][id] = true
|
||||||
|
})
|
||||||
|
const fp = `./export-${Date.now()}-paimon.json`
|
||||||
|
fs.writeFileSync(fp, JSON.stringify(out))
|
||||||
|
console.log(`导出为文件: ${fp}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportToCocogoat = async proto => {
|
||||||
|
const out = {
|
||||||
|
value: {
|
||||||
|
achievements: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const achTable = new Map()
|
||||||
|
const preStageAchievementIdList = []
|
||||||
|
const excel = await loadCache("ExcelBinOutput/AchievementExcelConfigData.json")
|
||||||
|
excel.forEach(({GoalId, Id, PreStageAchievementId}) => {
|
||||||
|
if (PreStageAchievementId !== undefined) {
|
||||||
|
preStageAchievementIdList.push(PreStageAchievementId)
|
||||||
|
}
|
||||||
|
achTable.set(Id, GoalId === undefined ? 0 : GoalId)
|
||||||
|
})
|
||||||
|
const p = i => i.toString().padStart(2, "0")
|
||||||
|
const getDate = ts => {
|
||||||
|
const d = new Date(parseInt(`${ts}000`))
|
||||||
|
return `${d.getFullYear()}/${p(d.getMonth()+1)}/${p(d.getDate())}`
|
||||||
|
}
|
||||||
|
proto.list.filter(achievement => achievement.status === 3).forEach(({current, finishTimestamp, id, require}) => {
|
||||||
|
out.value.achievements.push({
|
||||||
|
id: id,
|
||||||
|
status: current === undefined || current === 0 || preStageAchievementIdList.includes(id) ? `${require}/${require}` : `${current}/${require}`,
|
||||||
|
categoryId: achTable.get(id),
|
||||||
|
date: getDate(finishTimestamp)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
spawnSync("clip", { input: JSON.stringify(out,null,2) })
|
||||||
|
console.log("导出内容已复制到剪贴板.")
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportToCsv = async proto => {
|
||||||
|
const excel = await loadCache("achievement-data.json", "HolographicHat/genshin-achievement-export")
|
||||||
|
const achievementMap = new Map()
|
||||||
|
excel["achievement"].forEach(obj => {
|
||||||
|
achievementMap.set(parseInt(obj.id), obj)
|
||||||
|
})
|
||||||
|
const outputLines = ["ID,状态,特辑,名称,描述,当前进度,目标进度,完成时间"]
|
||||||
|
const getStatusText = i => {
|
||||||
|
switch (i) {
|
||||||
|
case 1: return "未完成"
|
||||||
|
case 2: return "已完成,未领取奖励"
|
||||||
|
case 3: return "已完成"
|
||||||
|
default: return "未知"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const getTime = ts => {
|
||||||
|
const d = new Date(parseInt(`${ts}000`))
|
||||||
|
const p = i => i.toString().padStart(2, "0")
|
||||||
|
return `${d.getFullYear()}/${p(d.getMonth() + 1)}/${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
|
||||||
|
}
|
||||||
|
proto.list.forEach(({current, finishTimestamp, id, status, require}) => {
|
||||||
|
const desc = achievementMap.get(id) === undefined ? (() => {
|
||||||
|
console.log(`Error get id ${id} in excel`)
|
||||||
|
return {
|
||||||
|
goal: "未知",
|
||||||
|
name: "未知",
|
||||||
|
desc: "未知"
|
||||||
|
}
|
||||||
|
})() : achievementMap.get(id)
|
||||||
|
outputLines.push(`${id},${getStatusText(status)},${excel.goal[desc.goal]},${desc.name},${desc.desc},${status !== 1 ? current === 0 ? require : current : current},${require},${status === 1 ? "" : getTime(finishTimestamp)}`)
|
||||||
|
})
|
||||||
|
const fp = `./export-${Date.now()}.csv`
|
||||||
|
fs.writeFileSync(fp, `\uFEFF${outputLines.join("\n")}`)
|
||||||
|
console.log(`导出为文件: ${fp}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportData = async proto => {
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout
|
||||||
|
})
|
||||||
|
const question = util.promisify(rl.question).bind(rl)
|
||||||
|
const chosen = await question("导出至: \n[0] 椰羊 (https://cocogoat.work/achievement)\n[1] Paimon.moe\n[2] Seelie.me\n[3] 表格文件 (默认)\n> ")
|
||||||
|
rl.close()
|
||||||
|
switch (chosen) {
|
||||||
|
case "0":
|
||||||
|
await exportToCocogoat(proto)
|
||||||
|
break
|
||||||
|
case "1":
|
||||||
|
await exportToPaimon(proto)
|
||||||
|
break
|
||||||
|
case "2":
|
||||||
|
await exportToSeelie(proto)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
await exportToCsv(proto)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
exportData
|
||||||
|
}
|
||||||
90
regionServer.js
Normal file
90
regionServer.js
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
const fs = require("fs")
|
||||||
|
const https = require("https")
|
||||||
|
const axios = require("axios")
|
||||||
|
const { decodeProto, encodeProto, debug } = require("./utils")
|
||||||
|
|
||||||
|
const preparedRegions = {}
|
||||||
|
let currentProxy = undefined
|
||||||
|
|
||||||
|
const getModifiedRegionList = async (conf) => {
|
||||||
|
const d = await axios.get(`https://${conf.dispatchUrl}/query_region_list`, {
|
||||||
|
responseType: "text",
|
||||||
|
params: {
|
||||||
|
version: conf.version,
|
||||||
|
channel_id: conf.channel,
|
||||||
|
sub_channel_id: conf.subChannel
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const regions = await decodeProto(Buffer.from(d.data,"base64"),"QueryRegionList")
|
||||||
|
regions.list = regions.list.map(item => {
|
||||||
|
const host = new URL(item.url).host
|
||||||
|
if (regions.list.length === 1) {
|
||||||
|
preparedRegions[host] = true
|
||||||
|
}
|
||||||
|
item.url = `https://localdispatch.yuanshen.com/query_cur_region/${host}`
|
||||||
|
return item
|
||||||
|
})
|
||||||
|
return (await encodeProto(regions,"QueryRegionList")).toString("base64")
|
||||||
|
}
|
||||||
|
|
||||||
|
const getModifiedRegionInfo = async (url, uc, hs) => {
|
||||||
|
const splitUrl = url.split("?")
|
||||||
|
const host = splitUrl[0].split("/")[2]
|
||||||
|
const noQueryRequest = splitUrl[1] === undefined
|
||||||
|
const query = noQueryRequest ? "" : `?${splitUrl[1]}`
|
||||||
|
const d = await axios.get(`https://${host}/query_cur_region${query}`, {
|
||||||
|
responseType: "text"
|
||||||
|
})
|
||||||
|
if (noQueryRequest) {
|
||||||
|
preparedRegions[host] = true
|
||||||
|
return d.data
|
||||||
|
} else {
|
||||||
|
const region = await decodeProto(Buffer.from(d.data,"base64"),"QueryCurRegion")
|
||||||
|
const info = region.info
|
||||||
|
if (preparedRegions[host]) {
|
||||||
|
if (currentProxy !== undefined) {
|
||||||
|
currentProxy.close()
|
||||||
|
}
|
||||||
|
debug("Create udp proxy: %s:%d", info.ip, info.port)
|
||||||
|
currentProxy = uc(info.ip, info.port, hs)
|
||||||
|
} else {
|
||||||
|
preparedRegions[host] = true
|
||||||
|
}
|
||||||
|
info.ip = "127.0.0.1"
|
||||||
|
info.port = 45678
|
||||||
|
return (await encodeProto(region,"QueryCurRegion")).toString("base64")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const agent = new https.Agent({
|
||||||
|
rejectUnauthorized: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const create = async (conf, regionListLoadedCallback, regionSelectCallback) => {
|
||||||
|
const regions = await getModifiedRegionList(conf)
|
||||||
|
regionListLoadedCallback()
|
||||||
|
const hServer = https.createServer({
|
||||||
|
pfx: fs.readFileSync("./cert/root.p12"),
|
||||||
|
passphrase: ""
|
||||||
|
}, async (request, response) => {
|
||||||
|
const url = request.url
|
||||||
|
debug("HTTP请求: %s", url)
|
||||||
|
response.writeHead(200, { "Content-Type": "text/html" })
|
||||||
|
if (url.startsWith("/query_region_list")) {
|
||||||
|
response.end(regions)
|
||||||
|
} else if (url.startsWith("/query_cur_region")) {
|
||||||
|
const regionInfo = await getModifiedRegionInfo(url, regionSelectCallback, hServer)
|
||||||
|
response.end(regionInfo)
|
||||||
|
} else {
|
||||||
|
const frontResponse = await axios.get(`https://${conf.dispatchIP}${url}`, {
|
||||||
|
responseType: "arraybuffer",
|
||||||
|
httpsAgent: agent
|
||||||
|
})
|
||||||
|
response.end(frontResponse.data)
|
||||||
|
}
|
||||||
|
}).listen(443, "127.0.0.1")
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
create
|
||||||
|
}
|
||||||
286
utils.js
Normal file
286
utils.js
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
const fs = require("fs")
|
||||||
|
const dns = require("dns")
|
||||||
|
const ini = require("ini")
|
||||||
|
const util = require("util")
|
||||||
|
const zlib = require("zlib")
|
||||||
|
const cloud = require("./secret")
|
||||||
|
const readline = require("readline")
|
||||||
|
const protobuf = require("protobufjs")
|
||||||
|
const { version } = require("./version")
|
||||||
|
const { createHash } = require("crypto")
|
||||||
|
|
||||||
|
let axios = require("axios")
|
||||||
|
|
||||||
|
const sleep = ms => new Promise(resolve => {
|
||||||
|
setTimeout(resolve, ms)
|
||||||
|
})
|
||||||
|
|
||||||
|
const encodeProto = (object, name) => protobuf.load(`proto/${name}.proto`).then(r => {
|
||||||
|
const msgType = r.lookupType(name)
|
||||||
|
const msgInst = msgType.create(object)
|
||||||
|
return msgType.encode(msgInst).finish()
|
||||||
|
})
|
||||||
|
|
||||||
|
const decodeProto = (buf, name) => protobuf.load(`proto/${name}.proto`).then(r => {
|
||||||
|
return r.lookupType(name).decode(buf)
|
||||||
|
})
|
||||||
|
|
||||||
|
const checkPath = (path, cb) => {
|
||||||
|
if (!fs.existsSync(`${path}/UnityPlayer.dll`) && !fs.existsSync(`${path}/pkg_version`)) {
|
||||||
|
throw Error(`路径有误: ${path}`)
|
||||||
|
} else {
|
||||||
|
cb(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let conf
|
||||||
|
|
||||||
|
const initConfig = async () => {
|
||||||
|
const configFileName = "./config.json"
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout
|
||||||
|
})
|
||||||
|
const lookup = util.promisify(dns.lookup).bind(dns)
|
||||||
|
const question = util.promisify(rl.question).bind(rl)
|
||||||
|
if (fs.existsSync(configFileName)) {
|
||||||
|
conf = JSON.parse(fs.readFileSync(configFileName, "utf-8"))
|
||||||
|
} else {
|
||||||
|
const p = await question("原神主程序(YuanShen.exe或GenshinImpact.exe)所在路径: (支持多个路径, 使用符号'*'分隔)\n")
|
||||||
|
conf = {
|
||||||
|
path: [],
|
||||||
|
offlineResource: false,
|
||||||
|
customCDN: ""
|
||||||
|
}
|
||||||
|
p.split("*").forEach(s => {
|
||||||
|
checkPath(s, () => {
|
||||||
|
if (!conf.path.includes(s)) {
|
||||||
|
conf.path.push(s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
fs.writeFileSync(configFileName, JSON.stringify(conf, null, 2))
|
||||||
|
rl.close()
|
||||||
|
}
|
||||||
|
if (conf.proxy !== undefined) {
|
||||||
|
axios = axios.create({
|
||||||
|
proxy: conf.proxy
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (conf.path.length === 1) {
|
||||||
|
checkPath(conf.path[0], p => {
|
||||||
|
conf.path = p
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const idx = await question(`选择客户端: \n${conf.path.map((s, i) => {
|
||||||
|
const fp = fs.existsSync(`${s}/GenshinImpact.exe`) ? `${s}\\GenshinImpact.exe` : `${s}\\YuanShen.exe`
|
||||||
|
return `[${i}] ${fp}`
|
||||||
|
}).join("\n")}\n> `)
|
||||||
|
checkPath(conf.path[parseInt(idx)], p => {
|
||||||
|
conf.path = p
|
||||||
|
})
|
||||||
|
}
|
||||||
|
rl.close()
|
||||||
|
conf.isOversea = fs.existsSync(conf.path + "/GenshinImpact.exe")
|
||||||
|
conf.dataDir = conf.isOversea ? conf.path + "/GenshinImpact_Data" : conf.path + "/YuanShen_Data"
|
||||||
|
const readGameRes = (path) => fs.readFileSync(conf.dataDir + path)
|
||||||
|
// noinspection JSUnresolvedVariable
|
||||||
|
const genshinConf = ini.parse(fs.readFileSync(conf.path + "/config.ini", "utf-8")).General
|
||||||
|
conf.channel = genshinConf.channel
|
||||||
|
// noinspection JSUnresolvedVariable
|
||||||
|
conf.subChannel = genshinConf.sub_channel
|
||||||
|
conf.version = readGameRes("/Persistent/ChannelName").toString() + readGameRes("/Persistent/ScriptVersion").toString()
|
||||||
|
conf.executable = conf.isOversea ? conf.path + "/GenshinImpact.exe" : conf.path + "/YuanShen.exe"
|
||||||
|
conf.dispatchUrl = `dispatch${conf.isOversea ? "os" : "cn"}global.yuanshen.com`
|
||||||
|
conf.dispatchIP = (await lookup(conf.dispatchUrl, 4)).address
|
||||||
|
return conf
|
||||||
|
}
|
||||||
|
|
||||||
|
const splitPacket = buf => {
|
||||||
|
let offset = 0
|
||||||
|
let arr = []
|
||||||
|
while (offset < buf.length) {
|
||||||
|
let dataLength = buf.readUInt32LE(offset + 24)
|
||||||
|
arr.push(buf.subarray(offset, offset + 28 + dataLength))
|
||||||
|
offset += dataLength + 28
|
||||||
|
}
|
||||||
|
return arr
|
||||||
|
}
|
||||||
|
|
||||||
|
const md5 = str => {
|
||||||
|
const h = createHash("md5")
|
||||||
|
h.update(str)
|
||||||
|
return h.digest("hex")
|
||||||
|
}
|
||||||
|
|
||||||
|
let cdnUrlFormat = null
|
||||||
|
|
||||||
|
String.prototype.format = function() {
|
||||||
|
const args = arguments;
|
||||||
|
return this.replace(/{(\d+)}/g, (match, number) => typeof args[number] != "undefined" ? args[number] : match)
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkCDN = async () => {
|
||||||
|
try {
|
||||||
|
cdnUrlFormat = "https://cdn.jsdelivr.net/gh/{0}@master/{1}"
|
||||||
|
await axios.head(cdnUrlFormat.format("github/fetch", ".gitignore"))
|
||||||
|
return
|
||||||
|
} catch (e) {}
|
||||||
|
try {
|
||||||
|
cdnUrlFormat = "https://raw.githubusercontent.com/{0}/master/{1}"
|
||||||
|
await axios.head(cdnUrlFormat.format("github/fetch", ".gitignore"))
|
||||||
|
return
|
||||||
|
} catch (e) {}
|
||||||
|
try {
|
||||||
|
const s = conf === undefined ? "" : conf.customCDN.trim()
|
||||||
|
if (s.length > 0) {
|
||||||
|
cdnUrlFormat = s
|
||||||
|
await axios.head(cdnUrlFormat.format("github/fetch", ".gitignore"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
try {
|
||||||
|
cdnUrlFormat = "https://raw.fastgit.org/{0}/master/{1}"
|
||||||
|
await axios.head(cdnUrlFormat.format("github/fetch", ".gitignore"))
|
||||||
|
return
|
||||||
|
} catch (e) {}
|
||||||
|
try {
|
||||||
|
cdnUrlFormat = "https://ghproxy.net/https://raw.githubusercontent.com/{0}/master/{1}"
|
||||||
|
await axios.head(cdnUrlFormat.format("github/fetch", ".gitignore"))
|
||||||
|
return
|
||||||
|
} catch (e) {}
|
||||||
|
throw "没有可用的CDN"
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadCache = async (fp, repo = "Dimbreath/GenshinData") => {
|
||||||
|
console.log(cdnUrlFormat.format(repo, fp))
|
||||||
|
fs.mkdirSync("./cache", { recursive: true })
|
||||||
|
const localPath = `./cache/${md5(fp)}`
|
||||||
|
if (conf.offlineResource) {
|
||||||
|
const fd = brotliDecompressSync(fs.readFileSync(localPath))
|
||||||
|
return JSON.parse(fd.subarray(1 + fd.readUInt8()).toString())
|
||||||
|
}
|
||||||
|
const header = {}
|
||||||
|
let fd = Buffer.alloc(0)
|
||||||
|
if (fs.existsSync(localPath)) {
|
||||||
|
fd = brotliDecompressSync(fs.readFileSync(localPath))
|
||||||
|
const etagLength = fd.readUInt8()
|
||||||
|
header["If-None-Match"] = fd.subarray(1, 1 + etagLength).toString()
|
||||||
|
}
|
||||||
|
const headResponse = await axios.head(cdnUrlFormat.format(repo, fp), {
|
||||||
|
headers: header,
|
||||||
|
validateStatus: _ => true
|
||||||
|
})
|
||||||
|
if (headResponse.status === 304) {
|
||||||
|
debug("文件 %s 命中缓存", fp)
|
||||||
|
const etagLength = fd.readUInt8()
|
||||||
|
return JSON.parse(fd.subarray(1 + etagLength).toString())
|
||||||
|
} else {
|
||||||
|
console.log("正在下载资源, 请稍后...")
|
||||||
|
const response = await axios.get(cdnUrlFormat.format(repo, fp))
|
||||||
|
const etag = response.headers.etag
|
||||||
|
const str = JSON.stringify(response.data)
|
||||||
|
const comp = brotliCompressSync(Buffer.concat([Buffer.of(etag.length), Buffer.from(etag), Buffer.from(str)]))
|
||||||
|
fs.writeFileSync(localPath, comp)
|
||||||
|
console.log("完成.")
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDebug = false
|
||||||
|
|
||||||
|
const debug = (msg, ...params) => {
|
||||||
|
if (isDebug) log(msg, ...params)
|
||||||
|
}
|
||||||
|
|
||||||
|
const log = (msg, ...params) => {
|
||||||
|
const time = new Date()
|
||||||
|
const timeStr = time.getHours().toString().padStart(2, "0") + ":" + time.getMinutes().toString().padStart(2, "0") + ":" + time.getSeconds().toString().padStart(2, "0")
|
||||||
|
console.log(`${timeStr} ${msg}`, ...params)
|
||||||
|
}
|
||||||
|
|
||||||
|
const upload = async data => {
|
||||||
|
return await cloud.post("/achievement-export", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkUpdate = async () => {
|
||||||
|
const data = (await cloud.get("/latest-version")).data
|
||||||
|
if (data["vc"] !== version.code) {
|
||||||
|
console.log(`有可用更新: ${version.name} => ${data["vn"]}`)
|
||||||
|
console.log(`更新内容: \n${data["ds"]}`)
|
||||||
|
console.log("下载地址: https://github.com/HolographicHat/genshin-achievement-export/releases\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const brotliCompressSync = data => zlib.brotliCompressSync(data,{
|
||||||
|
params: {
|
||||||
|
[zlib.constants.BROTLI_PARAM_QUALITY]: zlib.constants.BROTLI_MAX_QUALITY,
|
||||||
|
[zlib.constants.BROTLI_PARAM_SIZE_HINT]: data.length
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const brotliDecompressSync = data => zlib.brotliDecompressSync(data)
|
||||||
|
|
||||||
|
let hostsContent = ""
|
||||||
|
|
||||||
|
const setupHost = _ => {
|
||||||
|
const path = "C:\\Windows\\System32\\drivers\\etc\\hosts"
|
||||||
|
fs.chmodSync(path, 0o777)
|
||||||
|
hostsContent = fs.readFileSync(path, "utf-8")
|
||||||
|
const requireHosts = new Map()
|
||||||
|
requireHosts.set(conf.dispatchUrl, "127.0.0.1")
|
||||||
|
requireHosts.set("localdispatch.yuanshen.com", "127.0.0.1")
|
||||||
|
const currentHosts = new Map()
|
||||||
|
hostsContent.split("\n").map(l => l.trim()).filter(l => !l.startsWith("#") && l.length > 0).forEach(value => {
|
||||||
|
const pair = value.trim().split(" ").filter(v => v.trim().length !== 0)
|
||||||
|
currentHosts.set(pair[1], pair[0])
|
||||||
|
})
|
||||||
|
requireHosts.forEach((value, key) => {
|
||||||
|
if (currentHosts.has(key)) {
|
||||||
|
if (currentHosts.get(key) === value) {
|
||||||
|
requireHosts.delete(key)
|
||||||
|
} else {
|
||||||
|
currentHosts.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
requireHosts.forEach((ip, host) => {
|
||||||
|
currentHosts.set(host, ip)
|
||||||
|
})
|
||||||
|
const newContent = Array.from(currentHosts.entries()).map(pair => {
|
||||||
|
return `${pair[1]} ${pair[0]}`
|
||||||
|
}).join("\n")
|
||||||
|
fs.writeFileSync(path, newContent)
|
||||||
|
debug("修改SystemHosts")
|
||||||
|
process.on("exit", () => {
|
||||||
|
fs.writeFileSync(path, hostsContent)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// noinspection JSUnusedGlobalSymbols
|
||||||
|
class KPacket {
|
||||||
|
|
||||||
|
constructor(data) {
|
||||||
|
this.origin = data
|
||||||
|
this.conv = data.readUInt32BE(0)
|
||||||
|
this.token = data.readUInt32BE(4)
|
||||||
|
this.cmd = data.readUInt8(8)
|
||||||
|
this.frg = data.readUInt8(9)
|
||||||
|
this.wnd = data.readUInt16LE(10)
|
||||||
|
this.ts = data.readUInt32LE(12)
|
||||||
|
this.sn = data.readUInt32LE(16)
|
||||||
|
this.una = data.readUInt32LE(20)
|
||||||
|
this.length = data.readUInt32LE(24)
|
||||||
|
this.data = data.subarray(28)
|
||||||
|
this.hash = (() => {
|
||||||
|
const h = createHash("sha256")
|
||||||
|
h.update(Buffer.concat([Buffer.of(this.sn, this.frg), this.data]))
|
||||||
|
return h.digest("hex")
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
log, sleep, encodeProto, decodeProto, initConfig, splitPacket, upload, brotliCompressSync, brotliDecompressSync,
|
||||||
|
setupHost, loadCache, debug, checkCDN, checkUpdate, KPacket, cdnUrlFormat
|
||||||
|
}
|
||||||
6
version.js
Normal file
6
version.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
const version = {
|
||||||
|
code: 1,
|
||||||
|
name: "1.0.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { version }
|
||||||
Reference in New Issue
Block a user