node基金爬虫,自导自演了解一下?

9,587 阅读10分钟

那是一个风和日丽的下午,我入手了人生第一把基金,从此以后,这只鸡🐔就跌入了万劫不复的深渊,之后我居然还傻傻地追加了几笔,到现在为止此坑都还没填平...

“是时候动用一些封印的力量了”,我捂紧又皱又瘪的荷包,扛起node大宝剑,从新手村起步,屠龙...哦不,杀鸡之旅徐徐展开。

问道

我询遍了村中姓“网”的长老,终于拿到了3条至关重要的信息卷轴,有了它们,我便可以有机会一窥鸡精国的全貌了。

  • 卷轴1——户口卷轴fund.eastmoney.com/allfund.htm…

    这里列出了每只鸡的代码号以及它们听完令我虎躯一震的名字,不愧是鸡精国,点了点居然有7000多号鸡口。

  • 卷轴2——档案卷轴fund.eastmoney.com/f10/000001.html

    此卷轴神奇了,变换地址末尾的代码号,就能看到对应的那只鸡的基本档案,知鸡知彼,百战不殆。

  • 卷轴3——M卷轴fund.eastmoney.com/f10/F10Data…

    没想到这卷轴的力量实为霸道,居然还是个动态卷轴,改变地址咒语中的代码code、开始日期sdate、截止日期edate和分页数量per,它就能呈现出这只鸡的生活作息表,是肥了还是瘦了,是开心了还是不开心了...

至此,鸡精国江山图谱我已尽收心中。


修炼

古老的卷轴已经给了我足够多的线索,而我深知自己在这篇传说中的主角光环,所以无需结印便召唤出了栖身V8莽林中的小神兽——爬虫,拥有node血统的它身行动迅猛,嗅觉灵敏,给它一根鸡毛,它就能帮我找到鸡窝,但要想纵横整个鸡精国,还需对它加以训练。

首先我得获取以下装备,这样爬虫和鸡和人就都能正常交流了。

const express = require('express'); //搭建服务
const events = require('events'); //事件监听
const request = require('request'); //发送请求
const iconv = require('iconv-lite'); //网页解码
const cheerio = require('cheerio'); //网页解析
const MongoClient = require('mongodb').MongoClient; //数据库
const app = express(); //服务端实例
const Event = new events.EventEmitter(); //事件监听实例
const dbUrl = "mongodb://localhost:27017/"; //数据库连接地址

我给这可爱的小神兽取了个庸俗的名字:FundSpider,给了它一个封装后的嗅觉增强器fetch

// 基金爬虫
class FundSpider {
    // 数据库名,表名,并发片段数量
    constructor(dbName='fund', collectionName='fundData', fragmentSize=1000) {
        this.dbUrl = "mongodb://localhost:27017/";
        this.dbName = dbName;
        this.collectionName = collectionName;
        this.fragmentSize = fragmentSize;
    }
    // 获取url对应网址内容,除utf-8外,需指定网页编码
    fetch(url, coding, callback) {
        request({url: url, encoding : null}, (error, response, body) => {
            let _body = coding==="utf-8" ? body : iconv.decode(body, coding);
            if (!error && response.statusCode === 200){
                // 将请求到的网页装载到jquery选择器中
                callback(null, cheerio.load('<body>'+_body+'</body>'));
            }else{
                callback(error, cheerio.load('<body></body>'));
            }
        });
    }
}

现在,把每只鸡的代码号筛出来:

// 批量获取所有的基金代码
fetchFundCodes(callback) {
    let url = "http://fund.eastmoney.com/allfund.html";
    // 原网页编码是gb2312,需对应解码
    this.fetch(url, 'gb2312', (err, $) => {
        let fundCodesArray = [];
        if(!err){
            $("body").find('.num_right').find("li").each((i, item)=>{
                let codeItem = $(item);
                let codeAndName = $(codeItem.find("a")[0]).text();
                let codeAndNameArr = codeAndName.split(")");
                let code = codeAndNameArr[0].substr(1);
                let fundName = codeAndNameArr[1];
                if(code){
                    fundCodesArray.push(code);
                }
            });
        }
        callback(err, fundCodesArray);
    });
}

接着,给爬虫打造件定位追踪的装备,根据鸡的代码就能查到它的档案:

// 根据基金代码获取对应基本信息
fetchFundInfo(code, callback){
    let fundUrl = "http://fund.eastmoney.com/f10/" + code + ".html";
    let fundData = {fundCode: code};
    this.fetch(fundUrl,"utf-8", (err, $) => {
        if(!err){
            let dataRow = $("body").find(".detail .box").find("tr");
            fundData.fundName = $($(dataRow[0]).find("td")[0]).text();//基金全称
            fundData.fundNameShort = $($(dataRow[0]).find("td")[1]).text();//基金简称
            fundData.fundType = $($(dataRow[1]).find("td")[1]).text();//基金类型
            fundData.releaseDate = $($(dataRow[2]).find("td")[0]).text();//发行日期
            fundData.buildDate = $($(dataRow[2]).find("td")[1]).text();//成立日期/规模
            fundData.assetScale = $($(dataRow[3]).find("td")[0]).text();//资产规模
            fundData.shareScale = $($(dataRow[3]).find("td")[1]).text();//份额规模
            fundData.administrator = $($(dataRow[4]).find("td")[0]).text();//基金管理人
            fundData.custodian = $($(dataRow[4]).find("td")[1]).text();//基金托管人
            fundData.manager = $($(dataRow[5]).find("td")[0]).text();//基金经理人
            fundData.bonus = $($(dataRow[5]).find("td")[1]).text();//分红
            fundData.managementRate = $($(dataRow[6]).find("td")[0]).text();//管理费率
            fundData. trusteeshipRate = $($(dataRow[6]).find("td")[1]).text();//托管费率
            fundData.saleServiceRate = $($(dataRow[7]).find("td")[0]).text();//销售服务费率
            fundData.subscriptionRate = $($(dataRow[7]).find("td")[1]).text();//最高认购费率
        }
        callback(err, fundData);
    });
}

以上拿到的信息在鸡精国建国之日起就几乎未曾变动,即使它们建国后都成了精。要是后面我每次想翻看档案都得召唤爬虫,让它重复劳动,伙食费都怕不够。还好在新手成长礼包中领取到了一份MongoDB宝箱,有自如存取的能力,那便将这些档案统统保存起来,后日翻阅便可无患。

在训练的过程中,爬虫一出手便是并发地追踪,我发现一次性把7000多只鸡查个底朝天,总会有大概三分之一的鸡下落不明,看来是有些吃不消了,为了控制爬虫的追踪节奏,是时候得有新伙伴加入了。

// 并发控制器,控制单次并发调用的数量
class ConcurrentCtrl {
    // 调用者上下文环境,并发分段数量(建议不要超过1000),调用函数,总参数数组,数据库表名
    constructor(parent, splitNum, fn, dataArray=[], collection){
        this.parent = parent;
        this.splitNum = splitNum;
        this.fn = fn;
        this.dataArray = dataArray;
        this.length = dataArray.length; // 总次数
        this.itemNum = Math.ceil(this.length/splitNum); // 分段段数
        this.restNum = (this.length%splitNum)===0 ? splitNum : (this.length%splitNum); // 最后一次分段的余下次数
        this.collection = collection;
    }
    // go(0)启动调用,循环计数中达到分段数量便进行下一次片段并发
    go(index) {
        if((index%this.splitNum) === 0){
            if(index/this.splitNum !== (this.itemNum-1)){
                this.fn.call(this.parent, this.collection, this.dataArray.slice(index,index+this.splitNum));
            }else{
                this.fn.call(this.parent, this.collection, this.dataArray.slice(index,index+this.restNum));
            }
        }
    }
}

有了它的帮助,将爬虫每次行动的并发量控制在1000左右,会是一个比较理想的节奏;接着,教会爬虫自动把每次猎取到的鸡精档案放入MongoDB宝箱中,由小至大,先具体告诉爬虫,每次并发追踪后应该做什么。

// 并发获取的基金信息片段保存到数据库指定的表
fundFragmentSave(collection, codesArray){
    for (let i = 0; i < codesArray.length; i++) {
        this.fetchFundInfo(codesArray[i], (error, fundData) => {
            if(error){
                Event.emit("error_fundItem", codesArray[i]);
                Event.emit("fundItem", codesArray[i]);
            }else{
                // 指定每条数据的唯一标志是基金代码,便于查询与排序
                fundData["_id"] = fundData.fundCode;
                collection.save(fundData, (err, res) => {
                    Event.emit("correct_fundItem", codesArray[i]);
                    Event.emit("fundItem", codesArray[i]);
                    if (err) throw err;
                });
            }
        });
    }
}

如此,爬虫便学会了在追踪过程中随时报告情况,每条路线的追踪结束后都会发出名为fundItem的信号,出错或者成功时分别会发出error_fundItemcorrect_fundItem的信号。

接下来,配合新伙伴ConcurrentCtrl,只要告诉爬虫要追踪的代码号集合codesArray,捉鸡千里之外也不过瞬息之事:

// 并发获取给定基金代码数组中对应的基金基本信息,并保存到数据库
fundToSave(error, codesArray=[]){
    if(!error){
        let codesLength = codesArray.length;
        let itemNum = 0; // 已爬过的数量
        let errorItems = []; // 爬取失败的基金代码数组
        let errorItemNum = 0; // 爬取失败的基金代码数量
        let correctItems = []; // 爬取成功的基金代码数组
        let correctItemNum = 0; // 爬取成功的基金代码数量
        console.log(`基金代码共计 ${codesLength} 个`);
        // 数据库连接
        MongoClient.connect(this.dbUrl,  (err, db) => {
            if (err) throw err;
            // 数据库实例
            let fundDB = db.db(this.dbName);
            // 数据表实例
            let dbCollection = fundDB.collection(this.collectionName);
            // 并发控制器实例
            let concurrentCtrl = new ConcurrentCtrl(this, this.fragmentSize, this.fundFragmentSave, codesArray, dbCollection);
            // 事件监听
            Event.on("fundItem", (_code) => {
                // 计数
                itemNum++;
                console.log(`index: ${itemNum} --- code: ${_code}`);
                // 并发控制
                concurrentCtrl.go(itemNum);
                // 所有基金信息爬取完毕
                if (itemNum >= codesLength) {
                    console.log("save finished");
                    if(errorItems.length > 0){
                        console.log("---error code----");
                        console.log(errorItems);
                    }
                    // 关闭数据库
                    db.close();
                }
            });
            Event.on("error_fundItem", (_code) => {
                errorItems.push(_code);
                errorItemNum++;
                console.log(`error index: ${errorItemNum} --- error code: ${_code}`);
            });
            Event.on("correct_fundItem", (_code) => {
                correctItemNum++;
            });
            // 片段式并发启动
            concurrentCtrl.go(0);
        });
    }else{
        console.log("fundToSave error");
    }
}

那么,捉鸡大法便算是修炼成了,宏可纵览鸡精全国户口档案,微可轻取数只杀之于无形:

// 未传参则获取所有基金基本信息,给定基金代码数组则获取对应信息,均更新到数据库
fundSave(_codesArray){
    if(!_codesArray){
        // 所有基金信息爬取保存
        this.fetchFundCodes((err, codesArray) => {
            this.fundToSave(err, codesArray);
        })
    }else{
        // 过滤可能的非数组入参的情况
        _codesArray = Object.prototype.toString.call(_codesArray)==='[object Array]' ? _codesArray : [];
        if(_codesArray.length > 0){
            // 部分基金信息爬取保存
            this.fundToSave(null, _codesArray);
        }else{
            console.log("not enough codes to fetch");
        }
    }
}

那怎么发动呢?咒语如下,不过别忘了把MongoDB宝箱的盖子打开。

let fundSpider = new FundSpider("fund","fundData",1000);
// 更新保存全部基金基本信息
fundSpider.fundSave();
// 更新保存代码为000001和040008的基金的基本信息
// fundSpider.fundSave(['000001','040008']);

去吧,皮卡虫!我看着爬虫分出1000个幻影,然后嗖一声同时消失。当我默念10秒后,打开MongoDB宝箱,便见到了如下光景:

我仰天大笑,终于让我知道了你们这些鸡所有的底细!啊哈哈哈!

诶等等,就算我知道了每只鸡的一家老小、背景如何、房产几套,可天下的鸡是杀不完了,鸡精更是如此,我要这铁棒有何用?我要这档案又如何?( ˙-˙ ) 还是不安,还是氐惆...

我需要的是:定向杀鸡

差点忘了还有第三个动态卷轴:M卷轴,借助它的力量,便能知道任何一只鸡吃没吃饱、胖了还是瘦了,好不好逮。看来爬虫需要再加点技能了。

// 日期转字符串
getDateStr(dd){
    let y = dd.getFullYear();
    let m = (dd.getMonth()+1)<10 ? "0"+(dd.getMonth()+1) : (dd.getMonth()+1);
    let d = dd.getDate()<10 ? "0"+dd.getDate() : dd.getDate();
    return y + "-" + m + "-" + d;
}
// 爬取并解析基金的单位净值,增长率等信息
fetchFundUrl(url, callback){
    this.fetch(url, 'gb2312', (err, $)=>{
        let fundData = [];
        if(!err){
            let table = $('body').find("table");
            let tbody = table.find("tbody");
            try{
                tbody.find("tr").each((i,trItem)=>{
                    let fundItem = {};
                    let tdArray = $(trItem).find("td").map((j, tdItem)=>{
                        return $(tdItem);
                    });
                    fundItem.date = tdArray[0].text(); // 净值日期
                    fundItem.unitNet = tdArray[1].text(); // 单位净值
                    fundItem.accumulatedNet = tdArray[2].text(); // 累计净值
                    fundItem.changePercent  = tdArray[3].text(); // 日增长率
                    fundData.push(fundItem);
                });
                callback(err, fundData);
            }catch(e){
                console.log(e);
                callback(e, []);
            }
        }
    });
}
// 根据基金代码获取其选定日期范围内的基金变动数据
// 基金代码,开始日期,截止日期,数据个数,回调函数
fetchFundData(code, sdate, edate, per=9999, callback){
    let fundUrl = "http://fund.eastmoney.com/f10/F10DataApi.aspx?type=lsjz";
    let date = new Date();
    let dateNow = new Date();
    // 默认开始时间为当前日期的3年前
    sdate = sdate?sdate:this.getDateStr(new Date(date.setFullYear(date.getFullYear()-3)));
    edate = edate?edate:this.getDateStr(dateNow);
    fundUrl += ("&code="+code+"&sdate="+sdate+"&edate="+edate+"&per="+per);
    console.log(fundUrl);
    this.fetchFundUrl(fundUrl, callback);
}

使用如下:

let fundSpider = new FundSpider();
fundSpider.fetchFundData('040008', '2018-03-20', '2018-05-04', 30, (err, data) => {
    console.log(data);
});

修炼之路,厚积而薄发,我将洞察到的我所需要的关于鸡精国的一切,浓缩到了3颗永恒宝石上:

// 所有基金代码查询接口
app.get('/fetchFundCodes', (req, res) => {
    let fundSpider = new FundSpider();
    res.header("Access-Control-Allow-Origin", "*");
    fundSpider.fetchFundCodes((err, data)=>{
        res.send(data.toString());
	});
});
// 根据代码查询基金档案接口
app.get('/fetchFundInfo/:code', (req, res) => {
    let fundSpider = new FundSpider();
    res.header("Access-Control-Allow-Origin", "*");
    fundSpider.fetchFundInfo(req.params.code, (err, data) => {
        res.send(JSON.stringify(data));
    });
});
// 基金净值变动情况数据接口
app.get('/fetchFundData/:code/:per', (req, res) => {
    let fundSpider = new FundSpider();
    res.header("Access-Control-Allow-Origin", "*");
    fundSpider.fetchFundData(req.params.code, undefined, undefined, req.params.per, (err, data) => {
        res.send(JSON.stringify(data));
    });
});
app.listen(1234,()=>{
	console.log("service start on port 1234");
});


决斗

我来到了鸡精国的城池下,node大宝剑刚嵌上的宝石在阳光的照射下熠熠生辉。我剑指城门,大声喝到:

“你们所有,哦不,你们部分鸡的死期到了!”

鸡精国护城将出现在了城头,他瞥见我剑上的宝石,却冷冷的说到:

“哼,你能洞察到的,不过是那些冷冰冰的数据罢了, 就算将100鸡放在你面前,即使拔光了毛,给你一个时辰,就凭那些数字,我想你也无法找到你想要的吧!”

没想到这护城将真说到做到,他打开了城门,任由100只鸡站在我十米开外,面无惧色。

嘈杂的鸡鸣声令我有些慌乱,果真如他所说,我看着这些几乎一毛一样的鸡,额头的汗水开始滴滴坠落,可举到半空的剑却迟迟不敢落下。

“路过此地,见你有难,赠予你一件宝物,可助一臂之力。”

身边突然有一股浑厚的声音响起,原来是一位长者,我半信半疑接过此物,一个表面无比光滑的银色薄片,什么?这竟然是一片数据二向箔!可以将混杂的数字打击到二维图表上的二向箔!如此神器令我喜出望外。

“请问长者尊姓大名!”

伊查尔斯 ~”

声未消失,人却远去。

我将二向箔小心地丢向城门正中央,瞬间安静如斯,护城将错愕的眼神凝固在了原地,而其他鸡精们,都如同纸片般,平铺在了城墙上。

// 基金数据可视化(前端代码)
const React = require("react");
const Echarts = require("echarts");
const EcStat = require("echarts-stat");
const fetch = require("isomorphic-unfetch");
class FundChart extends React.Component{
    constructor(props) {
        super(props);
        // 按钮切换标志
        this.state = {
            switchIndex: 1
        }
    }
    // 获取基金档案
    fetchFundInfo(code, callback) {
        return fetch(`http://localhost:1234/fetchFundInfo/${code}`).then((res) => {
            res.json().then((data) => {
                callback(data);
            })
        }).catch((err) => {
            console.log(err);
        });
    }
    // 获取基金净值变动数据
    fetchFundData(code, per, callback) {
        return fetch(`http://localhost:1234/fetchFundData/${code}/${per.toString()}`).then((res) => {
            res.text().then((data) => {
                callback(JSON.parse(data));
            })
        }).catch((err) => {
            console.log(err);
        });
    }
    // 获取ECharts绘制的数据
    getChart(fundData) {
        // 起始点净值
        let startUnitNet = parseFloat(fundData[0].unitNet);
        // 计算其他时间点净值与起始点净值的相对百分比
        // 日期为横坐标,净值为纵坐标
        let data = fundData.map(function(item) {
            return [item.date, parseFloat((100.0 * ((parseFloat(item.unitNet) - startUnitNet) / startUnitNet)).toFixed(2))]
        });
        // 取数组下标为横坐标,净值为纵坐标,用于散点图与回归分析
        let dataRegression = data.map(function(item, i) {
            return [i, item[1]];
        });
        // 折线图横坐标数组
        let dateList = data.map(function(item) {
            return item[0];
        });
        // 折线图纵坐标数组
        let valueList = data.map(function(item) {
            return item[1];
        });
        // 计算线性回归
        let myRegression = EcStat.regression('linear', dataRegression);
        // 线性回归的的散点排序
        myRegression.points.sort(function(a, b) {
            return a[0] - b[0];
        });
        // 线性回归后的拟合方程y=Kx+B
        let K = myRegression.parameter.gradient;
        let B = myRegression.parameter.intercept;
        let optionFold = {
            title: [{
                left: 'center',
            }],
            tooltip: {
                trigger: 'axis'
            },
            xAxis: [{
                data: dateList
            }],
            yAxis: [{
                splitLine: {
                    show: false
                }
            }],
            series: [{
                type: 'line',
                showSymbol: false,
                data: valueList,
                itemStyle: {
                    color: '#3385ff'
                }
            }]
        };
        let optionRegression = {
            title: {
                subtext: 'linear regression',
                left: 'center'
            },
            tooltip: {
                trigger: 'axis',
                axisPointer: {
                    type: 'cross'
                }
            },
            xAxis: {
                type: 'value',
                splitLine: {
                    lineStyle: {
                        type: 'dashed'
                    }
                },
            },
            yAxis: {
                type: 'value',
                splitLine: {
                    lineStyle: {
                        type: 'dashed'
                    }
                },
            },
            series: [{
                name: 'scatter',
                type: 'scatter',
                itemStyle: {
                    color: '#3385ff'
                },
                label: {
                    emphasis: {
                        show: true,
                        position: 'left'
                    }
                },
                data: dataRegression
            }, {
                name: 'line',
                type: 'line',
                showSymbol: false,
                data: myRegression.points,
                markPoint: {
                    itemStyle: {
                        normal: {
                            color: 'transparent'
                        }
                    },
                    label: {
                        normal: {
                            show: true,
                            position: 'left',
                            formatter: myRegression.expression,
                            textStyle: {
                                color: '#333',
                                fontSize: 14
                            }
                        }
                    },
                    data: [{
                        coord: myRegression.points[myRegression.points.length - 1]
                    }]
                }
            }]
        };
        return {
            optionFold: optionFold,
            optionRegression: optionRegression,
            regression: myRegression,
            K: K,
            B: B
        }
    }
    // 绘制图表
    drawChart(fundData, fundInfo) {
        if (!this.chartFold) {
            this.chartFold = Echarts.init(document.getElementById('chart_fold'));
        }
        if (!this.chartPoints) {
            this.chartPoints = Echarts.init(document.getElementById('chart_points'));
        }
        if (fundData && (fundData.length > 0)) {
            // 更新图表绘制
            let chartObj = this.getChart(fundData);
            this.chartFold.setOption(chartObj.optionFold);
            this.chartPoints.setOption(chartObj.optionRegression);
        } else {
            // 更新图表标题
            this.chartFold.setOption({
                title: {
                    text: fundInfo.fundNameShort
                }
            });
            this.chartPoints.setOption({
                title: {
                    text: fundInfo.fundNameShort
                }
            });
        }
    }
    // 时间范围按钮切换
    dateSwitch(index, per) {
        this.setState({
            switchIndex: index
        }, () => {
            this.fetchFundData(this.props.code, per, (data) => {
                this.drawChart(data.reverse());
            });
        });
    }
    // 时间范围按钮
    getSwitchBtns() {
        let switchArray = [
            ['最近一周', 7],
            ['最近一月', 30],
            ['最近3月', 90],
            ['最近半年', 180],
            ['最近一年', 365],
            ['最近三年', 1095]
        ];
        let switchIndex = this.state.switchIndex;
        return (
            <div>
                {switchArray.map((item, i)=>{
                    let active = (i==switchIndex ? true : false);
                    let label = item[0];
                    let per = item[1];
                    return (<button className={"switch-btn"+(active?" active":"")} onClick={this.dateSwitch.bind(this,i,per)}>{label}</button>)
                })}
            </div>
        )
    }
    componentDidMount() {
        // 默认加载最近一月的基金数据
        this.fetchFundData(this.props.code, 30, (data) => {
            this.drawChart(data.reverse());
        });
        // 基金标题获取
        this.fetchFundInfo(this.props.code, (data) => {
            console.log(data);
            this.drawChart([], data);
        });
    }
    render() {
        return (
            <div className="fundChart-container">
                <div id="chartbox" className="chart-box">
                    <div className="chart-fold" id="chart_fold"></div>
                    <div className="chart-points" id="chart_points"></div>
                </div>
                <div className="switch-box">
                    {this.getSwitchBtns()}
                </div>
            </div>
        );
    }
    
}

“买低不买高,抄底要抄好!”

我一边大喊着口诀,一边挥舞着大宝剑,不少鸡已被我削成了碎末,弥散在空气里。

我目光如龙,当敌人是空,我战法无穷,我攻势如风,用宝剑入宫。

我终究还是被拦下了,对面是鸡精国的一员悍将,一身法力浑厚凶猛,竟令我节节败退。

我捂着胸口,强忍着仿佛要从胃里喷涌而出的血腥味:

“敢...敢问阁下名号?”

“吾乃鸡精国大祭师,古皮袄!”

居然是古皮袄!那个传说中一直罩着鸡精国的大祭师古皮袄!据说鸡精国国王名存实亡,是古皮袄垄断大权,他是掌握着命运之力的天才,是举国上下的风向标!

“世界上有很多东西,你是参不透的”

古皮袄轻蔑地说道。

“你早已经不是第一个死在我手里的闯入者了,但因为我的悲悯之心,为了纪念你们,我给你们都起了一个称谓。”

“什么称谓...?”

我已经是很勉强地支撑着身体了,但这股好奇心还是让我忍不住开口问道。

“韭菜”

他话音刚落,便挥起镰刀近身过来,在我的世界全部寂静之前,我只能看到他脸上冷漠的微笑。

初入掘金第二篇文章,写着写着发现自己编起了段子...感觉标题应该改为“韭菜传”?总之,瞎编不易,转载烦请注明出处,铭谢~
-----------优柔寡断的分割线---------

鉴于有评论里少侠中意源码,双手奉上我稚嫩的github地址:https://github.com/youngdro/fundSpider,少侠们有空可否顺便戳一戳那颗buling buling的星星✨,后续我慢慢把其他库存货往这上面挪吧(我怕是一个假程序员...)