爬虫初探(原神抽卡记录导出实践)

4,121 阅读3分钟

背景

  二月份入坑了原神,原神的抽卡记录网页没有汇总的扇形图且展示Table被定死为了6,对于自己抽卡次数,每次的出货频率以及下一次保底的距离的计算是很麻烦的,本着技术服务生活,于是我打算自制一个网页,用于获取原神抽卡数据,自制为更加清晰的统计+扇形图展示
  先上结果图~

image.png

预备工作

  获取原神抽卡页面的URL,手机打开原神抽卡页面,开启飞行模式,刷新页面即可获得一个错误网页,将其URL复制下来即可 BBA2EE465EBCE68C7A65726D71282E8D.jpg

获取数据的几种尝试

静态爬虫

  初探爬虫我做的第一件事就是去百度,看网上说的爬取一个网页只需要用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>image.png 这是因为原神抽卡记录的网址是https。需要配置SSL Proxying

  • Help>SSL Proxying>Install Charles Root Certificate 然后在钥匙串对于证书配置始终信任 image.png

  • 以上是搜索到的内容,但是我昨晚这些仍然显示unknown,后来发现还需要Proxy>SSL Proxy Setting中设置

image.png

  • 做完以上工作以后就可以开始对于数据包分析了

image.png

正式编码

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

小结

  经过一天的努力,终于可以愉快的查看自己的抽卡记录,安心抽卡了。
  于是

438472A769F9A9954D09A4719DE7A78A.jpg

image.png

即使最后没有人为你鼓掌,也要优雅的谢幕,感谢自己的认真付出