背景
二月份入坑了原神,原神的抽卡记录网页没有汇总的扇形图且展示Table被定死为了6,对于自己抽卡次数,每次的出货频率以及下一次保底的距离的计算是很麻烦的,本着技术服务生活,于是我打算自制一个网页,用于获取原神抽卡数据,自制为更加清晰的统计+扇形图展示
先上结果图~
预备工作
获取原神抽卡页面的URL,手机打开原神抽卡页面,开启飞行模式,刷新页面即可获得一个错误网页,将其URL复制下来即可
获取数据的几种尝试
静态爬虫
初探爬虫我做的第一件事就是去百度,看网上说的爬取一个网页只需要用http或者一些第三方库访问对应页面,获取dom节点中的元素即可。于是开始了我的第一次尝试,我采用的是superagent
库
// 引入所需要的第三方包
const superagent= require('superagent');
let recordData = [];
superagent.get('目标URL').end((err, res) => {
if (err) {
console.log(`抽卡记录抓取失败 - ${err}`)
} else {
// 抓取热点新闻数据
recordData = getRecord(res);// 从res中
}
});
const cheerio = require('cheerio');
let getRecord = (res) => {
let recordData = [];
/* 使用cheerio模块的cherrio.load()方法,将HTMLdocument作为参数传入函数
以后就可以使用类似jQuery的$(selectior)的方式来获取页面元素
*/
let $ = cheerio.load(res.text);
// 找到目标数据所在的页面元素,获取数据
$('.table-content>div>type').each((idx, ele) => {
// cherrio中$('selector').each()用来遍历所有匹配到的DOM元素
// 参数idx是当前遍历的元素的索引,ele就是当前便利的DOM元素
recordData.push({
type:$(ele).text()
}) // 存入最终结果数组
});
// ... 将其他数据填入数组
return recordData
};
于是尴尬的事情发生了,我一步一步log
发现返回的res里面压根就没有内部的表格元素,查阅资料以后才知道是因为Table
的数据是请求了另一个接口获取的,对于这种动态页面有两种方案
采取能够模拟浏览器或者通过抓包分析接口,自行请求。
模拟浏览器
模拟浏览器我采取了puppeteer
库,通过模拟对于按钮,选择器的点击进行不同的请求从而获取最终的数据
const puppeteer = require("puppeteer");
const TEST_URL =""
const getTargetData = async () => {
// 启动浏览器
const browser = await puppeteer.launch({
headless: false, // 默认是无头模式,这里为了示范所以使用正常模式
});
// 控制浏览器打开新标签页面
const page = await browser.newPage();
page.setViewport({
width: 0,
height: 1500,
});
// 在新标签中打开要爬取的网页
await page.goto(TEST_URL);
// 存储数据
const types = [];
const names = [];
const dates = [];
let poolSelect = [
{ poolName: "常驻", records: [] },
{ poolName: "新手", records: [] },
{ poolName: "角色", records: [] },
{ pullName: "武器", records: [] },
];
return await new Promise((resolve, reject) => {
const loadData = async (index) => {
let end = false;
await page.waitForTimeout(600 * index);
await page.click(".select-container");
await page.waitForSelector(".ul-list");
await page.click(`.item:nth-child(${index+1})`);
await page.waitForTimeout(300);
while (!end) {
const { currentData, length } = await page.evaluate(async () => {
// 因为需要请求时间 所以需要一个delay
const getData = async () => {
const currentTypes = document
.querySelectorAll(".table-content>div")[1]
.querySelectorAll(".type");
const currentNames = document
.querySelectorAll(".table-content>div")[1]
.querySelectorAll(".name");
const currentTimes = document
.querySelectorAll(".table-content>div")[1]
.querySelectorAll(".time");
const currentData = [...currentTypes].map((item, index) => {
const [name, level] = currentNames[index].innerHTML
.split("\n")
.filter(
(item) =>
item != undefined &&
item != null &&
item.trim().length !== 0
)
.map((item) => item.trim());
return {
type: currentTypes[index].innerHTML,
name,
level: level ? level : "(三星)",
time: currentTimes[index].innerHTML,
};
});
console.log(currentData);
return {
length: currentTypes.length,
currentData,
};
};
let { length, currentData } = await getData();
console.log(currentData);
return {
length,
currentData,
};
});
poolSelect[index] = {
...poolSelect[index],
records: [...poolSelect[index].records, ...currentData],
};
if (length < 6) {
end = true;
if (index >= poolSelect.length) {
break;
}
loadData(index + 1);
} else {
page.click(".page-item.to-next.selected");
await page.waitForTimeout(300);
}
}
};
loadData(0);
resolve(poolSelect);
})
.then((res) => console.log(res))
.catch((err) => console.log(err));
};
这回是可以获取一些数据的,但是可能是因为我网络的原因,对于一些请求速度较慢的数据产生了丢失,需要增长点击以后的等待时间才能正确的获取数据,那这样就导致爬取的时间很长。
抓包分析接口
抓包我采取的是 Mac 的 Charles
青花瓷。访问目标网站即可抓包
踩了一些坑
- 抓到的数据包访问的显示是
<unknown>
,
这是因为原神抽卡记录的网址是https。需要配置SSL Proxying
Help>SSL Proxying>Install Charles Root Certificate
然后在钥匙串
对于证书配置始终信任
- 以上是搜索到的内容,但是我昨晚这些仍然显示
unknown
,后来发现还需要Proxy>SSL Proxy Setting
中设置
- 做完以上工作以后就可以开始对于数据包分析了
正式编码
const router = require("koa-router")();
const https = require("https");
const queryString = require("query-string");
const targetUrl =
"https://hk4e-api.mihoyo.com/event/gacha_info/api/getGachaLog";
router.get("/test", async (ctx, next) => {
const { url } = ctx.request.query;
const { query } = queryString.parseUrl(url);
// 新手100 常驻200 角色301 武器302
const gacha_types = [100,200,301,302];
const page = 1;
const size = 6;
const end_id = 0;
const createReq = (page, size, gacha_type, end_id) => {
return new Promise((resolve, reject) => {
// 抓包可知 首次请求end_id 为 0 之后每下一页 end_id 为上一页的最后一个,直到返回结果长度小于size说明最大,结束递归
const targetQuery = { ...query, gacha_type, page, size, end_id };
const finalUrl = `${targetUrl}?${queryString.stringify(targetQuery)}`;
https.get(finalUrl, (res) => {
let info = "";
res.on("data", function (chunk) {
info += chunk;
});
res.on("end", async function (err) {
const resultList = JSON.parse(info).data.list;
if (resultList.length < size) {
console.log(`====正在请求==${gacha_type}==页码${page}`);
return resolve(resultList);
}
// end_id 上一页最后一个
const afterResultList = await createReq(page + 1, size, gacha_type, resultList[resultList.length-1].id)
resolve([
...resultList,
...afterResultList
]);
});
});
});
};
ctx.body = await new Promise((resolve) => {
const promiseList = gacha_types.map((gacha_type) => {
return new Promise(async (resolve) => {
const data = await createReq(page, size, gacha_type, end_id);
resolve({
gacha_type,
data: (await createReq(page, size, gacha_type, end_id)).reverse(),
});
});
});
resolve(
Promise.all(promiseList).then((values) => {
console.log(values);
return values;
})
);
});
});
前端编码
使用antd+umi+axios+echarts
进行前端页面的Code.
//App.js
export default function() {
const [url, setUrl] = useState('');
const [loading, setLoading] = useState(false);
const [result, setResult] = useState({});
const poolNameMap = {
newHandPool: {
gacha_type: 100,
name: '新手池',
},
alwaysPool: {
gacha_type: 200,
name: '常驻池',
},
rolePool: {
gacha_type: 301,
name: '角色池',
},
armsPool: {
gacha_type: 302,
name: '武器池',
},
};
const handleSearch = async () => {
setLoading(true);
const { data } = await Axios.get('/test', { params: { url } });
setResult(groupBy(data, 'gacha_type'));
setLoading(false);
};
useEffect(() => {
console.log(result);
}, [result]);
return (
<Spin tip="Loading... 读取数据中,可能需要几十秒" delay={200} spinning={loading}>
<div
style={{
width: '100vw',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
}}
>
<div style={{ display: 'flex', width: '30%', marginBottom: 16 }}>
<Input value={url} onChange={setUrl} />
<Button
type="primary"
onClick={() => {
handleSearch();
}}
style={{ marginLeft: 8 }}
>
查询
</Button>
</div>
<Row gutter={[16, 16]} style={{ width: '40%' }}>
<Col span={24}>
<PoolCard
name={poolNameMap.alwaysPool.name}
data={get(result, `${poolNameMap.alwaysPool.gacha_type}.0.data`)}
/>
</Col>
<Col span={24}>
<PoolCard
name={poolNameMap.rolePool.name}
data={get(result, `${poolNameMap.rolePool.gacha_type}.0.data`)}
/>
</Col>
<Col span={24}>
<PoolCard
name={poolNameMap.armsPool.name}
data={get(result, `${poolNameMap.armsPool.gacha_type}.0.data`)}
/>
</Col>
<Col span={24}>
<PoolCard
name={poolNameMap.newHandPool.name}
data={get(result, `${poolNameMap.newHandPool.gacha_type}.0.data`)}
/>
</Col>
</Row>
</div>
</Spin>
);
}
// PoolCard.jsx
import React, { useState, useEffect } from 'react'
import { Card, Empty, Tag } from 'antd'
import { isNil, filter, get, map } from 'lodash'
import PieChart from '../PieChart'
const PoolCard = ({ name, data }) => {
const [goldCards, setGoldCards] = useState([]);
const [noGoldTimes, setNoGoldTimes] = useState();
useEffect(() => {
setGoldCards(get5LevelDetail(data))
}, [data])
const get5LevelDetail = (data) => {
if (isNil(data)) {
return;
}
let lastLocation = 0;
let goldThings = []
data.forEach((item, index) => {
if (get(item, `rank_type`) === '5') {
goldThings.push(
{
nums: index - lastLocation + 1,
datail: item,
}
)
lastLocation = index + 1;
}
})
setNoGoldTimes(data.length - lastLocation);
return goldThings;
}
const getChartData = (data) => {
if (isNil(data)) {
return;
}
let level_5 = 0;
let level_4 = 0;
let level_3 = 0;
data.forEach((item, index) => {
if (get(item, `rank_type`) === '5') {
level_5++;
}
if (get(item, `rank_type`) === '4') {
level_4++;
}
if (get(item, `rank_type`) === '3') {
level_3++;
}
})
return [{
name: '五星', value: level_5
}, {
name: '四星', value: level_4
}, {
name: '三星', value: level_3
}];
}
return (
<Card style={{ position:'relative' }}>
<div>
<h3>{name}</h3>
<div style={{ display: 'flex', alignItems: 'center' }}>
{!isNil(noGoldTimes) ? <div>
已经<Tag color='processing' style={{ marginLeft: 8 }}>{noGoldTimes}</Tag>发没出金,
距离大保底还有<Tag color='warning' style={{ marginLeft: 8 }}>{90 - noGoldTimes}</Tag>发
</div> : null}
</div>
</div>
{!isNil(data) ? <div style={{position:'absolute',right:0,top:0,bottom:0,margin:'auto 0'}}>
<PieChart name={name} data={getChartData(data)} />
</div> : null}
{!isNil(data) ? <>
<div style={{ display: 'flex', justifyContent: 'space-between',maxWidth:300 }}>
<div>
<p>
抽取次数:{data.length}
</p>
<p>
五星次数:
<span style={{ marginRight: 8 }}>
{
get(goldCards, 'length')
}
</span>
{
map(goldCards, goldCard => <Tag color='success'>
{goldCard.datail.name}({goldCard.nums})
</Tag>)
}
</p>
<p>
四星次数:
{
filter(
data,
item => get(item, `rank_type`) === '4',
).length
}
</p>
<p>
三星次数:
{
filter(
data,
item => get(item, `rank_type`) === '3',
).length
}
</p>
</div>
</div>
</> : (
<Empty />
)}
</Card>
)
}
export default PoolCard
// PieChart.jsx
import React, { useEffect, useState } from 'react'
import ReactEcharts from 'echarts-for-react';
import config from './config'
const colorMap = {
'五星': 'yellow',
'四星': 'purple',
'三星': 'blue'
}
const RecordPieChart = ({ name, data }) => {
const [options, setOptions] = useState({});
useEffect(() => {
config.title.text = '';
config.legend.data = data.map(item => {
return {
name: item,
textStyle: {
color: colorMap[item.name]
}
}
});
config.series[0].data = data;;
setOptions(config);
}, [name, data])
return (
<div>
<ReactEcharts style={{ width: 400, height: 250 }} option={options} />
</div>
)
}
export default RecordPieChart
小结
经过一天的努力,终于可以愉快的查看自己的抽卡记录,安心抽卡了。
于是
即使最后没有人为你鼓掌,也要优雅的谢幕,感谢自己的认真付出
常见问题FAQ
- 免费下载或者VIP会员专享资源能否直接商用?
- 本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
- 提示下载完但解压或打开不了?
- 找不到素材资源介绍文章里的示例图片?
- 模板不会安装或需要功能定制以及二次开发?
发表评论
还没有评论,快来抢沙发吧!