手写一个原神祈愿分析工具

之前一直通过游创工坊来进行祈愿抽卡数据分析,但是广告太多,而且担心​​auth_key​​泄露,于是自己花了一天时间动手实现了个数据分析工具,数据永久保存在本地,没有信息泄露风险,话不多说,先放源码链接和运行截图

手写一个原神祈愿分析工具_开发语言

第一步:解析请求数据​​Api​

  • 无论是电脑或是手机,点击历史祈愿记录后,本地都会发起一次​​API​​​请求到服务器,查询具体数据,通过抓包或者直接断网刷新,很容易拿到这个​​API​​​接口,如下(此处我删去了​​auth_key​​的部分数据,以免信息泄露):
https://webstatic.mihoyo.com/hk4e/event/e20190909gacha/index.html?authkey_ver=1&sign_type=2&auth_appid=webview_gacha&init_type=200&gacha_id=ebbfa80fdbc30f7cdfed84670d87c018950878×tamp=1653954735&lang=zh-cn&device_type=mobile&ext=%7b%22loc%22%3a%7b%22x%22%3a463.9453430175781%2c%22y%22%3a330.5148620605469%2c%22z%22%3a1488.346435546875%7d%2c%22platform%22%3a%22Android%22%7d&game_version=CNRELAndroid2.7.0_R8029328_S8227893_D8227893&plat_type=android®ion=cn_gf01&authkey=YJHBE%2bKkY%2fuKzXQ63s05Z2L%2f%2fn%2bEsN0XCx5dzsotpulgXnUovr6wOnbzJMrboBnD9KIhzOTxNkdOHtEZe9ZwZDDXvV70rTLydeHcDBBnQe6likCy0iiXkuEDfKtgBLb8ghbir%2bCDIy%2fsY0fQ4DYP4Ohht38ld%2fWudZR6Xp%2bbOxuQ249u%2fDCwDS4FudukFnKx7peYbhO1FtpFUn7zM%2fVgCum7vxTbk8vzO7wV53BtDmjEkRdyQz2%2bozzyNc4s9l6mNonFrK9rDGtHb2nLiM%2flRoKNYWDIazj7Fs8zaJSI%2bzo5Da%2fdNJwEpHaCAvVpbeDZ5YMqu8rdOZ3A1%2bpWxECTi9RfnaQXuSh1oi6nz3%2bbL9i4KC%2b%2b254wwbJQxLTWmL272gMtJtm7EZfaF21eXmfNhVbY5E2n9lq7P%2bcGZy%2bE4Wzrj7UEp%2fuQ9322Z7t%2b332kgjvwjAj7BGXt%2fZ2RU7jXtg4yLx4OGo0nIqE%2ftLJf2ZnzKZ75LR01WwUP4yKyOToe0un4LxeE3rVBHU4StrFrfN8C39gsE4lTr%2bDoU%2fP82UFIkeQ9lHXDK4H1%2bzvxldyytNr6eCdA5T5iaREMZ0cmq8VtYMr0zVbpyEXEIfjwTIcXoX9nCrh8KH8IKwb9v7OqaWV1Rr5dmjP%2bqlAyn9j1v7xebWLUJw4on%2bO6MIHlqE2t7mjGD%2b%2fsbNgHvLuMDuCGwXQrOFXNTev%2fgK2K9HgEA5gyaeXaotcYhfv2%2by9hi5On8oBaEtxeKiMIZphmb%2fJsEIiDK%2bQDnK4BujqJyekuX%2bz5JpT8tRbW1k%2bIqcRsDKIEO%2bje4iPM9kmOnS8IEngu4kQphc2Kz8CUjytd3VteQBipB%2bz%2f2494UAdkYHlgmUSdYnfR%2bnIzz9aGOb3OA9TkRKwl3R%2bJrIniF3HQdtOew72%2fVvAQ3yNYGMdYV8mfMvvK8GeSFNmAltD64YwQ%2fl%2foU9HzsTzQWB2DWNBExF5zX%2bNt%2fwfOTunHODQmTrMUTgerCuypev44CQZvISRc9CLNPZqRkcgueJOb2e2k5iGgi1BTe%2fjM6TmKQA6TDnANAqyurWKqBP%2b0TcHelBGCaeTbncpA4YLpbjnvdPHzzn2UjP%2bVg%2bWu1rmyTXscm1CI3w94I7AnAfgaHOWAotusROx%2bR%2b4f9WrVnUDpfTeztLiWzK7O%2fvabOT9y6cmgKFZ%2bN79iKiRM9Fm%2b3EOQ4tXF1MnJ7WqSkjUWRtfSwWBH5eg37cx0065aph4Bh4ZYKMLmwa0sjS6gC1F34Q%3d%3d&game_biz=hk4e_cn
  • 分析拿到关键参数:
  1. ​authkey_ver​​:用户身份类型
  2. ​authkey​​​:用户身份标识(一个​​authkey​​对应一个账户)
  3. ​lang​​:当前系统语言
  4. ​timestamp​​:请求时间戳(超过一定时间会使链接失效)
  • 此处仅能拿到这四个参数,但通过抓包发现,真正获取数据的​​API​​是这个:​​https://hk4e-api.mihoyo.com/event/gacha_info/api/getGachaLog​
  • 除以上四个参数外,还需拼接四个参数:
  1. ​gacha_type​​:祈愿类型(新手祈愿、常驻池祈愿、活动祈愿、武器祈愿)
  2. ​page​​:分页参数,当前页码
  3. ​size​​:分页参数,每一页的大小
  4. ​end_id​​​:查询起始数据编码,此次分页请求会从这个​​ID​​开始查询,默认为0时,查询所有数据

第二步:​​API​​请求链接转换以及解析数据

通过第一步可以看出,本地日志文件(​​{user.home}/AppData/LocalLow/miHoYo/原神/output_log.txt​​​)和断网刷新的请求​​API​​​都不是真正获取祈愿数据的链接,需要将这些链接中的四个关键参数和额外拼接的四个参数组合在一起,才是真正获取数据的​​API​​接口

  • 定义请求体(与​​API​​接口返回的字段相对应)
public class GenshinDataResponse {
private int retcode;
private String message;
private GenshinDataPage data;
}
public class GenshinDataPage {
private int page;
private int size;
private int total;
private List<GenshinData> list;
}
public class GenshinData {
private long uid;
private int gacha_type;
private long item_id;
private int count;
private String time;
private String name;
private String lang;
private String item_type;
private int rank_type;
private long id;
}

​API​​​会返回一个​​Response​​​,在​​Response​​​中的​​data​​​就是祈愿数据,其中每一个祈愿数据的属性与​​GenshinData​​​实体想对应,存储在​​GenshinDataPage​​​(分页数据)的​​list​​中

  • 构建​​API​​并解析数据
/**
* 通过祈愿接口获取祈愿数据
*
* @param api 祈愿接口
* @return 祈愿数据
*/
public static List<GenshinData> getData(String api) {
String content = HttpRequest.get(api).execute().body();
return Optional.ofNullable(JSON.parseObject(content, GenshinDataResponse.class))
.map(GenshinDataResponse::getData)
.map(GenshinDataPage::getList)
.orElse(null);
}

/**
* 获取祈愿的Api接口
*
* @param endId End数据ID
* @param type 祈愿类型
* @return Api接口
*/
public static String getApi(long endId, GenshinDataType type) {
return BASE_URL
+ DATA_API.substring(DATA_API.indexOf("?"))
+ "&gacha_type=" + type.getCode()
+ "&page=1&size=20&end_id="
+ endId;
}

此处的​​DATA_API​​​就是断网刷新获得的​​URL​​​或者是本地日志文件中的​​URL​​​,给这个​​URL​​拼接关键的四个参数即可,多余的参数也不需要删除,服务器不会处理

第三步:保存数据

这里既可以选择保存在内存中(​​Map​​​),也可以保存在​​Redis​​​、​​MySQL​​​数据库中,也可以序列化成​​JSON​​​文件保存在磁盘中,各有优劣,本文不做展开,此处选择的是保存在​​Sqlite​​数据库中,防止数据过多造成内存溢出并支持永久保存

/**
* 插入数据
*
* @param genshinData 祈愿记录
*/
public void insert(GenshinData genshinData) {
Entity entity = Entity.create(TABLE_NAME);
entity.set("id", genshinData.getId());
entity.set("uid", genshinData.getUid());
entity.set("gacha_type", genshinData.getGacha_type());
entity.set("item_id", genshinData.getItem_id());
entity.set("count", genshinData.getCount());
entity.set("time", genshinData.getTime());
entity.set("name", genshinData.getName());
entity.set("lang", genshinData.getLang());
entity.set("item_type", genshinData.getItem_type());
entity.set("rank_type", genshinData.getRank_type());
try {
Db.use(DatabaseSource.getDataSource()).insert(entity);
Log.get().info("保存数据成功[{}][{}][{}]",genshinData.getId(), genshinData.getTime(), genshinData.getName());
} catch (SQLException e) {
Log.get().error("保存数据出现错误[{}][{}]", genshinData, e.getMessage());
throw new RuntimeException(e);
}
}
public enum GenshinDataType {
TYPE1(100, "新手祈愿"),
TYPE2(200, "常驻祈愿"),
TYPE3(301, "活动祈愿"),
TYPE4(302, "武器祈愿");

private final int code;
private final String name;

GenshinDataType(int code, String name) {
this.code = code;
this.name = name;
}

public int getCode() {
return this.code;
}

public String getName() {
return this.name;
}
}
for (GenshinDataType value : GenshinDataType.values()) {
Log.get().info("开始同步[{}]祈愿类型数据", value.getName());
List<GenshinData> data = getData(getApi(0, value));
while (!CollUtil.isEmpty(data)) {
for (GenshinData genshinData : data) {
// 避免重复保存数据
if (CONNECTION.exists(genshinData.getId())) {
continue;
}
CONNECTION.insert(genshinData);
}
data = getData(getApi(data.get(data.size() - 1).getId(), value));
Log.get("等待数据同步[1000ms]");
ThreadUtil.safeSleep(1000);
}
Log.get().info("[{}]祈愿类型数据同步完成", value.getName());
}
  • 运行单元测试,可以看到可以成功保存数据,且数据与官网数据一致

手写一个原神祈愿分析工具_开发语言_02

第四步:数据分析

这一步每一个人的需求不一样,属于动态的业务需求,本人仅需要查看历史五星、四星出货量,平均出五星的抽卡次数,以及各池子历史出货次数。如果想二次开发的,还可以增加指定物品名称,查看出货时间以及命座数

/**
* 通过祈愿物品级别查询数量
*
* @param rankType 祈愿物品级别
* @return 此级别下的物品数量
*/
public int queryRankTypeCount(GenshinRankType rankType) {
String sql = "SELECT COUNT(*) FROM " + DatabaseConnection.TABLE_NAME + " WHERE rank_type = ?";
return CONNECTION.count(sql, rankType.getCode());
}
/**
* 查询祈愿数据总数
*
* @return 总数量
*/
public int queryTotal() {
String sql = "SELECT COUNT(*) FROM " + DatabaseConnection.TABLE_NAME;
return CONNECTION.count(sql);
}
/**
* 通过祈愿类型查询数量
*
* @param type 祈愿类型
* @return 此祈愿类型的总数量
*/
public int queryGenshinDataTypeCount(GenshinDataType type) {
String sql = "SELECT COUNT(*) FROM " + DatabaseConnection.TABLE_NAME + " WHERE gacha_type = ?";
return CONNECTION.count(sql, type.getCode());
}
/**
* 通过祈愿类型和祈愿物品级别查询数量
*
* @param type 祈愿类型
* @param rankType 祈愿物品级别
* @return 当前祈愿类型下的,此祈愿物品级别的总数量
*/
public int queryGenshinDataTypeAndRankTypeCount(GenshinDataType type, GenshinRankType rankType) {
String sql = "SELECT COUNT(*) FROM " + DatabaseConnection.TABLE_NAME + " WHERE gacha_type = ? AND rank_type = ?";
return CONNECTION.count(sql, type.getCode(), rankType.getCode());
}
/**
* 通过祈愿类型查询最近一次抽出五星物品后的抽卡数量
*
* @param dataType 祈愿类型
* @return 最近一次抽出五星物品后的抽卡数量
*/
public int queryGenshinDataTypeByOrderCount(GenshinDataType dataType) {
String sql = "select count(*) from genshin_data where gacha_type = ? " +
"and id > ifnull((select id from genshin_data where rank_type = 5 and gacha_type = ? order by id desc limit 1), 0)";
return CONNECTION.count(sql, dataType.getCode(), dataType.getCode());
}
/**
* 通过祈愿类型和祈愿物品级别查询祈愿物品名称
*
* @param type 祈愿类型
* @return 祈愿物品的名称集合
*/
public List<String> queryData(GenshinDataType type) {
String sql = "SELECT id, name, time FROM " + DatabaseConnection.TABLE_NAME + " WHERE gacha_type = ? AND rank_type = 5 ORDER BY id";
List<Entity> result = CONNECTION.find(sql, type.getCode());
if (CollUtil.isEmpty(result)) {
return new ArrayList<>();
}
List<Integer> countList = new ArrayList<>(result.size());
List<String> names = new ArrayList<>(result.size());
for (int i = 0; i < result.size(); i++) {
// 当前五星的名称
String name = (String) result.get(i).get("name");
// 当前抽取时间
String time = (String) result.get(i).get("time");
// 当前五星的ID
long id = (long) result.get(i).get("id");
// 上一个五星的ID
long lastId = (i > 0) ? (long) result.get(i - 1).get("id") : 0L;
String countSql = "SELECT COUNT(*) FROM " + DatabaseConnection.TABLE_NAME + " WHERE ID > ? and ID <= ? and gacha_type = ?";
// 抽取五星的次数
int count = CONNECTION.count(countSql, lastId, id, type.getCode());
countList.add(count);
// 保存每次抽取五星的次数
names.add(name + "[第" + count + "次祈愿][" + time.substring(0, 10) + "]");
}
RANK_TYPE5.put(type, countList);
return names;
}

总结

整体架构没什么好说的,属于普通的增删查改操作,此处没有选择​​SpringBoot​​​+​​Mybatis​​​,改用​​Hutool​​工具库提供的数据库操作工具,也没有编写前端展示页面,而是控制台输出结果并将结果保存在文件中,便于随时查看。

可优化地方:

  1. 本工具暂时仅考虑了手动粘贴断网刷新后的请求​​API​​​到配置文件中,其实可以利用​​Hutool​​​的提供的​​FileWatcher​​​自动监听用户目录下的日志文件,并过滤出请求参数,手动拼接时间戳,这样即使​​API​​时间过期也可以自动生成新的请求了。如果后续别的小伙伴使用的话,可以考虑做一个。
  2. 没有编写前端页面,这个也就是一两天的事情。如果有需求,再考虑。