一、爬蟲流程
我們最終的目標是實現爬取立刻理財每日的銷售額,並知道賣了哪些產品,每個產品又被哪些用戶在什麼時間點買的。首先,介紹下爬蟲爬取的主要步驟:
我們要爬取頁面的數據,第一步當然是要先分析清楚頁面結構,要爬哪些頁面,頁面的結構是怎樣的,需不需要登入;有沒有ajax接口,返回什麼樣的資料等。
分析清楚要爬取哪些頁面和ajax,就要去抓取資料了。如今的網頁的數據,大體分為同步頁面和ajax介面。同步頁面資料的抓取就需要我們先分析網頁的結構,python抓取資料一般是透過正規表示式匹配來取得需要的資料;node有一個cheerio的工具,可以將取得的頁面內容轉換成jquery對象,然後就可以用jquery強大的dom API來取得節點相關數據, 其實大家看源碼,這些API本質也就是正規匹配。 ajax介面資料一般都是json格式的,處理起來還是比較簡單的。
抓取的資料後,會做簡單的篩選,然後將所需的資料先保存起來,以便後續的分析處理。當然我們可以用MySQL和Mongodb等資料庫儲存資料。這裡,我們為了方便,直接採用文件儲存。
因為我們最終是要展示資料的,所以我們要將原始的資料依照一定維度去處理分析,然後回傳給客戶端。這個過程可以在儲存的時候去處理,也可以在展示的時候,前端發送請求,後台取出儲存的資料再處理。這個看我們要怎麼展示數據了。
做了這麼多工作,一點展示輸出都沒有,怎麼甘心呢?這又回到了我們的老本行,前端展示頁面大家應該都很熟悉了。將數據展示出來才更直觀,方便我們分析統計。
Superagent是個輕量的的http方面的函式庫,是nodejs裡一個非常方便的客戶端請求代理模組,當我們需要進行get、post、head等網路請求時,試試它。
Cheerio大家可以理解成一個 Node.js 版的 jquery,用來從網頁中以 css selector 取數據,使用方式跟 jquery 一模一樣。
Async是一個流程控制工具包,提供了直接而強大的非同步功能mapLimit(arr, limit, iterator, callback),我們主要用到這個方法,大家可以去看看官網的API。
arr-del是我自己寫的一個刪除陣列元素方法的工具。可以透過傳入待刪除數組元素index組成的數組進行一次刪除。
arr-sort是我自己寫的一個陣列排序方法的工具。可以根據一個或多個屬性進行排序,支援嵌套的屬性。而且可以再在每個條件中指定排序的方向,並支援傳入比較函數。
先屢一下我們爬取的想法。立刻理財線上的產品主要是定期和立馬金庫(最新上線的光大銀行理財產品因為手續比較麻煩,而且起投金額高,基本沒人買,這裡不統計)。定期我們可以爬取理財頁的ajax介面:https://www.lmlc.com/web/product/product_list?pageSize=10&pageNo=1&type=0
。 (update: 定期近期沒貨,可能看不到資料)資料如下圖所示:
#這裡包含了所有線上正在銷售的定期產品,ajax資料只有產品本身相關的訊息,例如產品id、籌集金額、當前銷售額、年化收益率、投資天數等,並沒有產品被哪些用戶購買的資訊。所以我們需要帶著id參數去它的產品詳情頁爬取,例如立刻聚財-12月期HLB01239511。詳情頁有一欄投資記錄,裡邊包含了我們需要的信息,如下圖所示:
#但是,詳情頁需要我們在登入的狀態下才可以查看,這就需要我們帶著cookie去訪問,而且cookie是有有效期限制的,如何保持我們cookie一直在登錄態?請看後文。
其實立刻金庫也有類似的ajax介面:https://www.lmlc.com/web/product/product_list?pageSize=10&pageNo=1&type=1
,但裡面的相關資料都是寫死的,沒有意義。而且金庫的詳情頁面也沒有投資記錄資訊。這需要我們爬取一開始說的首頁的ajax介面:https://www.lmlc.com/s/web/home/user_buying
。但後來才發現這個介面是三分鐘更新一次,就是說後台每隔三分鐘就向伺服器請求一次資料。而一次是10條數據,所以如果在三分鐘內,購買產品的記錄數超過10條,數據就會有遺漏。這是沒有辦法的,所以立刻金庫的統計數據會比真實的偏少。
因為產品詳情頁需要登入,所以我們要先拿到登入的cookie才行。 getCookie方法如下:
function getCookie() { superagent.post('https://www.lmlc.com/user/s/web/logon') .type('form') .send({ phone: phone, password: password, productCode: "LMLC", origin: "PC" }) .end(function(err, res) { if (err) { handleErr(err.message); return; } cookie = res.header['set-cookie']; //从response中得到cookie emitter.emit("setCookeie"); }) }
phone和password參數是從命令列傳進來的,就是立刻理財用手機號碼登入的帳號和密碼。我們用superagent去模擬請求立刻理財登入介面:https://www.lmlc.com/user/s/web/logon
。傳入對應的參數,在回呼中,我們拿到header的set-cookie訊息,並發出一個setCookeie事件。因為我們設定了監聽事件:emitter.on("setCookie", requestData)
,所以一旦取得cookie,我們就會去執行requestData方法。
requestData方法的程式碼如下:
function requestData() { superagent.get('https://www.lmlc.com/web/product/product_list?pageSize=100&pageNo=1&type=0') .end(function(err,pres){ // 常规的错误处理 if (err) { handleErr(err.message); return; } // 在这里清空数据,避免一个文件被同时写入 if(clearProd){ fs.writeFileSync('data/prod.json', JSON.stringify([])); clearProd = false; } let addData = JSON.parse(pres.text).data; let formatedAddData = formatData(addData.result); let pageUrls = []; if(addData.totalPage > 1){ handleErr('产品个数超过100个!'); return; } for(let i=0,len=addData.result.length; i<len; i++){ if(+new Date() < addData.result[i].buyStartTime){ if(preIds.indexOf(addData.result[i].id) == -1){ preIds.push(addData.result[i].id); setPreId(addData.result[i].buyStartTime, addData.result[i].id); } }else{ pageUrls.push('https://www.lmlc.com/web/product/product_detail.html?id=' + addData.result[i].id); } } function setPreId(time, id){ cache[id] = setInterval(function(){ if(time - (+new Date()) < 1000){ // 预售产品开始抢购,直接修改爬取频次为1s,防止丢失数据 clearInterval(cache[id]); clearInterval(timer); delay = 1000; timer = setInterval(function(){ requestData(); }, delay); // 同时删除id记录 let index = preIds.indexOf(id); sort.delArrByIndex(preIds, [index]); } }, 1000) } // 处理售卖金额信息 let oldData = JSON.parse(fs.readFileSync('data/prod.json', 'utf-8')); for(let i=0, len=formatedAddData.length; i<len; i++){ let isNewProduct = true; for(let j=0, len2=oldData.length; j<len2; j++){ if(formatedAddData[i].productId === oldData[j].productId){ isNewProduct = false; } } if(isNewProduct){ oldData.push(formatedAddData[i]); } } fs.writeFileSync('data/prod.json', JSON.stringify(oldData)); let time = (new Date()).format("yyyy-MM-dd hh:mm:ss"); console.log((`理财列表ajax接口爬取完毕,时间:${time}`).warn); if(!pageUrls.length){ delay = 32*1000; clearInterval(timer); timer = setInterval(function(){ requestData(); }, delay); return } getDetailData(); }); }
程式碼很長,getDetailData函數程式碼後面分析。
請求的ajax接口是個分頁接口,因為一般在售的總產品數不會超過10條,我們這裡設置參數pageSize為100,這樣就可以一次獲取所有產品。
clearProd是全域reset訊號,每天0點整的時候,會清空prod(定期產品)和user(首頁使用者)資料。
因為有時候產品較少會採用搶購的方式,例如每天10點,這樣在每天10點的時候資料會更新很快,我們必須要增加爬取的頻次,以防遺失資料。所以針對預售產品即buyStartTime大於目前時間,我們要記錄下,並設定計時器,當開售時,調整爬取頻次為1次/秒,見setPreId方法。
如果沒有正在販售的產品,即pageUrls為空,我們將爬取的頻次設定為最大32s。
requestData函數的這部分程式碼主要記錄下是否有新產品,如果有的話,新建一個對象,記錄產品信息,push到prod數組裡。 prod.json資料結構如下:
[{ "productName": "立马聚财-12月期HLB01230901", "financeTotalAmount": 1000000, "productId": "201801151830PD84123120", "yearReturnRate": 6.4, "investementDays": 364, "interestStartTime": "2018年01月23日", "interestEndTime": "2019年01月22日", "getDataTime": 1516118401299, "alreadyBuyAmount": 875000, "records": [ { "username": "刘**", "buyTime": 1516117093472, "buyAmount": 30000, "uniqueId": "刘**151611709347230,000元" }, { "username": "刘**", "buyTime": 1516116780799, "buyAmount": 50000, "uniqueId": "刘**151611678079950,000元" }] }]
是一個物件數組,每個物件表示一個新產品,records屬性記錄著售賣資訊。
我們再看下getDetailData的程式碼:
function getDetailData(){ // 请求用户信息接口,来判断登录是否还有效,在产品详情页判断麻烦还要造成五次登录请求 superagent .post('https://www.lmlc.com/s/web/m/user_info') .set('Cookie', cookie) .end(function(err,pres){ // 常规的错误处理 if (err) { handleErr(err.message); return; } let retcode = JSON.parse(pres.text).retcode; if(retcode === 410){ handleErr('登陆cookie已失效,尝试重新登陆...'); getCookie(); return; } var reptileLink = function(url,callback){ // 如果爬取页面有限制爬取次数,这里可设置延迟 console.log( '正在爬取产品详情页面:' + url); superagent .get(url) .set('Cookie', cookie) .end(function(err,pres){ // 常规的错误处理 if (err) { handleErr(err.message); return; } var $ = cheerio.load(pres.text); var records = []; var $table = $('.buy-records table'); if(!$table.length){ $table = $('.tabcontent table'); } var $tr = $table.find('tr').slice(1); $tr.each(function(){ records.push({ username: $('td', $(this)).eq(0).text(), buyTime: parseInt($('td', $(this)).eq(1).attr('data-time').replace(/,/g, '')), buyAmount: parseFloat($('td', $(this)).eq(2).text().replace(/,/g, '')), uniqueId: $('td', $(this)).eq(0).text() + $('td', $(this)).eq(1).attr('data-time').replace(/,/g, '') + $('td', $(this)).eq(2).text() }) }); callback(null, { productId: url.split('?id=')[1], records: records }); }); }; async.mapLimit(pageUrls, 10 ,function (url, callback) { reptileLink(url, callback); }, function (err,result) { let time = (new Date()).format("yyyy-MM-dd hh:mm:ss"); console.log(`所有产品详情页爬取完毕,时间:${time}`.info); let oldRecord = JSON.parse(fs.readFileSync('data/prod.json', 'utf-8')); let counts = []; for(let i=0,len=result.length; i<len; i++){ for(let j=0,len2=oldRecord.length; j<len2; j++){ if(result[i].productId === oldRecord[j].productId){ let count = 0; let newRecords = []; for(let k=0,len3=result[i].records.length; k<len3; k++){ let isNewRec = true; for(let m=0,len4=oldRecord[j].records.length; m<len4; m++){ if(result[i].records[k].uniqueId === oldRecord[j].records[m].uniqueId){ isNewRec = false; } } if(isNewRec){ count++; newRecords.push(result[i].records[k]); } } oldRecord[j].records = oldRecord[j].records.concat(newRecords); counts.push(count); } } } let oldDelay = delay; delay = getNewDelay(delay, counts); function getNewDelay(delay, counts){ let nowDate = (new Date()).toLocaleDateString(); let time1 = Date.parse(nowDate + ' 00:00:00'); let time2 = +new Date(); // 根据这次更新情况,来动态设置爬取频次 let maxNum = Math.max(...counts); if(maxNum >=0 && maxNum <= 2){ delay = delay + 1000; } if(maxNum >=8 && maxNum <= 10){ delay = delay/2; } // 每天0点,prod数据清空,排除这个情况 if(maxNum == 10 && (time2 - time1 >= 60*1000)){ handleErr('部分数据可能丢失!'); } if(delay <= 1000){ delay = 1000; } if(delay >= 32*1000){ delay = 32*1000; } return delay } if(oldDelay != delay){ clearInterval(timer); timer = setInterval(function(){ requestData(); }, delay); } fs.writeFileSync('data/prod.json', JSON.stringify(oldRecord)); }) }); }
我們先去請求用戶資訊接口,來判斷登入是否還有效,因為在產品詳情頁面判斷麻煩還要造成五次登入要求。帶cookie請求很簡單,在post後面set下我們之前得到的cookie即可:.set('Cookie', cookie)
。如果後台回傳的retcode為410表示登入的cookie已失效,則需要重新執行getCookie()。這樣就能保證爬蟲一直在登入狀態。
async的mapLimit方法,會將pageUrls進行並發請求,一次並發量為10。對於每個pageUrl會執行reptileLink方法。等所有的非同步執行完畢後,再執行回呼函數。回呼函數的result參數是每個reptileLink函數傳回資料組成的陣列。
reptileLink函數是獲取產品詳情頁的投資記錄列表信息,uniqueId是由已知的username、buyTime、buyAmount參數組成的字符串,用來排重的。
async的回呼主要是將最新的投資記錄資訊寫入對應的產品物件裡,同時產生了counts陣列。 counts數組是每個產品這次爬取新增的售賣記錄個數字組成的數組,和delay一起傳入getNewDelay函數。 getNewDelay動態調節爬取頻次,counts是調節delay的唯一依據。 delay過大可能產生資料遺失,過小會增加伺服器負擔,可能會被管理員封ip。這裡設定delay最大值為32,最小值為1。
先上程式碼:
function requestData1() { superagent.get(ajaxUrl1) .end(function(err,pres){ // 常规的错误处理 if (err) { handleErr(err.message); return; } let newData = JSON.parse(pres.text).data; let formatNewData = formatData1(newData); // 在这里清空数据,避免一个文件被同时写入 if(clearUser){ fs.writeFileSync('data/user.json', ''); clearUser = false; } let data = fs.readFileSync('data/user.json', 'utf-8'); if(!data){ fs.writeFileSync('data/user.json', JSON.stringify(formatNewData)); let time = (new Date()).format("yyyy-MM-dd hh:mm:ss"); console.log((`首页用户购买ajax爬取完毕,时间:${time}`).silly); }else{ let oldData = JSON.parse(data); let addData = []; // 排重算法,如果uniqueId不一样那肯定是新生成的,否则看时间差如果是0(三分钟内请求多次)或者三分钟则是旧数据 for(let i=0, len=formatNewData.length; i<len; i++){ let matchArr = []; for(let len2=oldData.length, j=Math.max(0,len2 - 20); j<len2; j++){ if(formatNewData[i].uniqueId === oldData[j].uniqueId){ matchArr.push(j); } } if(matchArr.length === 0){ addData.push(formatNewData[i]); }else{ let isNewBuy = true; for(let k=0, len3=matchArr.length; k<len3; k++){ let delta = formatNewData[i].time - oldData[matchArr[k]].time; if(delta == 0 || (Math.abs(delta - 3*60*1000) < 1000)){ isNewBuy = false; // 更新时间,这样下一次判断还是三分钟 oldData[matchArr[k]].time = formatNewData[i].time; } } if(isNewBuy){ addData.push(formatNewData[i]); } } } fs.writeFileSync('data/user.json', JSON.stringify(oldData.concat(addData))); let time = (new Date()).format("yyyy-MM-dd hh:mm:ss"); console.log((`首页用户购买ajax爬取完毕,时间:${time}`).silly); } }); }
user.js的爬取和prod.js類似,這裡主要想說如何排重的。 user.json資料格式如下:
[ { "payAmount": 5067.31, "productId": "jsfund", "productName": "立马金库", "productType": 6, "time": 1548489, "username": "郑**", "buyTime": 1516118397758, "uniqueId": "5067.31jsfund郑**" }, { "payAmount": 30000, "productId": "201801151830PD84123120", "productName": "立马聚财-12月期HLB01230901", "productType": 0, "time": 1306573, "username": "刘**", "buyTime": 1516117199684, "uniqueId": "30000201801151830PD84123120刘**" }]
和产品详情页类似,我们也生成一个uniqueId参数用来排除,它是payAmount、productId、username参数的拼成的字符串。如果uniqueId不一样,那肯定是一条新的记录。如果相同那一定是一条新记录吗?答案是否定的。因为这个接口数据是三分钟更新一次,而且给出的时间是相对时间,即数据更新时的时间减去购买的时间。所以每次更新后,即使是同一条记录,时间也会不一样。那如何排重呢?其实很简单,如果uniqueId一样,我们就判断这个buyTime,如果buyTime的差正好接近180s,那么几乎可以肯定是旧数据。如果同一个人正好在三分钟后购买同一个产品相同的金额那我也没辙了,哈哈。
每天零点我们需要整理user.json和prod.json数据,生成最终的数据。代码:
let globalTimer = setInterval(function(){ let nowTime = +new Date(); let nowStr = (new Date()).format("hh:mm:ss"); let max = nowTime; let min = nowTime - 24*60*60*1000; // 每天00:00分的时候写入当天的数据 if(nowStr === "00:00:00"){ // 先保存数据 let prod = JSON.parse(fs.readFileSync('data/prod.json', 'utf-8')); let user = JSON.parse(fs.readFileSync('data/user.json', 'utf-8')); let lmlc = JSON.parse(JSON.stringify(prod)); // 清空缓存数据 clearProd = true; clearUser = true; // 不足一天的不统计 // if(nowTime - initialTime < 24*60*60*1000) return // 筛选prod.records数据 for(let i=0, len=prod.length; i<len; i++){ let delArr1 = []; for(let j=0, len2=prod[i].records.length; j<len2; j++){ if(prod[i].records[j].buyTime < min || prod[i].records[j].buyTime >= max){ delArr1.push(j); } } sort.delArrByIndex(lmlc[i].records, delArr1); } // 删掉prod.records为空的数据 let delArr2 = []; for(let i=0, len=lmlc.length; i<len; i++){ if(!lmlc[i].records.length){ delArr2.push(i); } } sort.delArrByIndex(lmlc, delArr2); // 初始化lmlc里的立马金库数据 lmlc.unshift({ "productName": "立马金库", "financeTotalAmount": 100000000, "productId": "jsfund", "yearReturnRate": 4.0, "investementDays": 1, "interestStartTime": (new Date(min)).format("yyyy年MM月dd日"), "interestEndTime": (new Date(max)).format("yyyy年MM月dd日"), "getDataTime": min, "alreadyBuyAmount": 0, "records": [] }); // 筛选user数据 for(let i=0, len=user.length; i<len; i++){ if(user[i].productId === "jsfund" && user[i].buyTime >= min && user[i].buyTime < max){ lmlc[0].records.push({ "username": user[i].username, "buyTime": user[i].buyTime, "buyAmount": user[i].payAmount, }); } } // 删除无用属性,按照时间排序 lmlc[0].records.sort(function(a,b){return a.buyTime - b.buyTime}); for(let i=1, len=lmlc.length; i<len; i++){ lmlc[i].records.sort(function(a,b){return a.buyTime - b.buyTime}); for(let j=0, len2=lmlc[i].records.length; j<len2; j++){ delete lmlc[i].records[j].uniqueId } } // 爬取金库收益,写入前一天的数据,清空user.json和prod.json let dateStr = (new Date(nowTime - 10*60*1000)).format("yyyyMMdd"); superagent .get('https://www.lmlc.com/web/product/product_list?pageSize=10&pageNo=1&type=1') .end(function(err,pres){ // 常规的错误处理 if (err) { handleErr(err.message); return; } var data = JSON.parse(pres.text).data; var rate = data.result[0].yearReturnRate||4.0; lmlc[0].yearReturnRate = rate; fs.writeFileSync(`data/${dateStr}.json`, JSON.stringify(lmlc)); }) } }, 1000);
globalTimer是个全局定时器,每隔1s执行一次,当时间为00:00:00
时,clearProd和clearUser全局参数为true
,这样在下次爬取过程时会清空user.json和prod.json文件。没有同步清空是因为防止多处同时修改同一文件报错。取出user.json里的所有金库记录,获取当天金库相关信息,生成一条立马金库的prod信息并unshift进prod.json里。删除一些无用属性,排序数组最终生成带有当天时间戳的json文件,如:20180101.json。
前端总共就两个页面,首页和详情页,首页主要展示实时销售额、某一时间段内的销售情况、具体某天的销售情况。详情页展示某天的具体某一产品销售情况。页面有两个入口,而且比较简单,这里我们采用gulp来打包压缩构建前端工程。后台用express搭建的,匹配到路由,从data文件夹里取到数据再分析处理再返回给前端。
Echarts
Echarts是一个绘图利器,百度公司不可多得的良心之作。能方便的绘制各种图形,官网已经更新到4.0了,功能更加强大。我们这里主要用到的是直方图。
DataTables
Datatables是一款jquery表格插件。它是一个高度灵活的工具,可以将任何HTML表格添加高级的交互功能。功能非常强大,有丰富的API,大家可以去官网学习。
Datepicker
Datepicker是一款基于jquery的日期选择器,需要的功能基本都有,主要样式比较好看,比jqueryUI官网的Datepicker好看太多。
gulp配置比较简单,代码如下:
var gulp = require('gulp'); var uglify = require("gulp-uglify"); var less = require("gulp-less"); var minifyCss = require("gulp-minify-css"); var livereload = require('gulp-livereload'); var connect = require('gulp-connect'); var minimist = require('minimist'); var babel = require('gulp-babel'); var knownOptions = { string: 'env', default: { env: process.env.NODE_ENV || 'production' } }; var options = minimist(process.argv.slice(2), knownOptions); // js文件压缩 gulp.task('minify-js', function() { gulp.src('src/js/*.js') .pipe(babel({ presets: ['es2015'] })) .pipe(uglify()) .pipe(gulp.dest('dist/')); }); // js移动文件 gulp.task('move-js', function() { gulp.src('src/js/*.js') .pipe(babel({ presets: ['es2015'] })) .pipe(gulp.dest('dist/')) .pipe(connect.reload()); }); // less编译 gulp.task('compile-less', function() { gulp.src('src/css/*.less') .pipe(less()) .pipe(gulp.dest('dist/')) .pipe(connect.reload()); }); // less文件编译压缩 gulp.task('compile-minify-css', function() { gulp.src('src/css/*.less') .pipe(less()) .pipe(minifyCss()) .pipe(gulp.dest('dist/')); }); // html页面自动刷新 gulp.task('html', function () { gulp.src('views/*.html') .pipe(connect.reload()); }); // 页面自动刷新启动 gulp.task('connect', function() { connect.server({ livereload: true }); }); // 监测文件的改动 gulp.task('watch', function() { gulp.watch('src/css/*.less', ['compile-less']); gulp.watch('src/js/*.js', ['move-js']); gulp.watch('views/*.html', ['html']); }); // 激活浏览器livereload友好提示 gulp.task('tip', function() { console.log('\n<----- 请用chrome浏览器打开 http://localhost:5000 页面,并激活livereload插件 ----->\n'); }); if (options.env === 'development') { gulp.task('default', ['move-js', 'compile-less', 'connect', 'watch', 'tip']); }else{ gulp.task('default', ['minify-js', 'compile-minify-css']); }
开发和生产环境都是将文件打包到dist目录。不同的是:开发环境只是编译es6和less文件;生产环境会再压缩混淆。支持livereload插件,在开发环境下,文件改动会自动刷新页面。
相关推荐:
以上是NodeJS爬蟲詳解的詳細內容。更多資訊請關注PHP中文網其他相關文章!