那是一個風和日麗的下午,我入手了人生第一把基金,今後之後,這隻雞🐔就跌入了萬劫不復的深淵,以後我竟然還傻傻地追加了幾筆,到如今爲止此坑都還沒填平...html
「是時候動用一些封印的力量了」,我捂緊又皺又癟的荷包,扛起node大寶劍,重新手村起步,屠龍...哦不,殺雞之旅徐徐展開。前端
我詢遍了村中姓「網」的長老,終於拿到了3條相當重要的信息卷軸,有了它們,我即可以有機會一窺雞精國的全貌了。node
卷軸1——戶口卷軸
:fund.eastmoney.com/allfund.htm… react
卷軸2——檔案卷軸
:http://fund.eastmoney.com/f10/000001
.html jquery
卷軸3——M卷軸
:fund.eastmoney.com/f10/F10Data… git
code
、開始日期sdate
、截止日期edate
和分頁數量per
,它就能呈現出這隻雞的生活做息表,是肥了仍是瘦了,是開心了仍是不開心了... 至此,雞精國江山圖譜我已盡收心中。程序員
古老的卷軸已經給了我足夠多的線索,而我深知本身在這篇傳說中的主角光環,因此無需結印便召喚出了棲身V8莽林中的小神獸——爬蟲,擁有node血統的它身行動迅猛,嗅覺靈敏,給它一根雞毛,它就能幫我找到雞窩,但要想縱橫整個雞精國,還需對它加以訓練。github
首先我得獲取如下裝備,這樣爬蟲和雞和人就都能正常交流了。mongodb
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_fundItem
與correct_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的星星✨,後續我慢慢把其餘庫存貨往這上面挪吧(我怕是一個假程序員...)