node爬蟲入門竟如此簡單

前言

爬蟲一直是軟件工程師里看起來比較神秘高深的一門學(xué)問,它讓人們想起黑客,以及SEO等等。
目前市面上也有專門的爬蟲工程師,并且在大企業(yè)的大數(shù)據(jù)部門,大數(shù)據(jù)工程師們也會兼任一些爬取競對數(shù)據(jù)的工作,當(dāng)然也有專門做安全的工程師應(yīng)對爬蟲的危害。所以爬蟲真的那么高深莫測嗎?下面就來揭開它的神秘面紗,帶你入門node爬蟲!


我們的目標(biāo)是:爬取鏈家官網(wǎng)租房市場相關(guān)數(shù)據(jù),并形成可視化圖表

最終成果

在這之前,我們先普及一些爬蟲的相關(guān)知識:

爬蟲的概念

網(wǎng)絡(luò)爬蟲(Web crawler),是一種按照一定的規(guī)則,自動地抓取萬維網(wǎng)信息的程序或者腳本。(百度百科)

爬蟲的分類

通用網(wǎng)絡(luò)爬蟲(全網(wǎng)爬蟲)
爬行對象從一些 種子URL 擴(kuò)充到整個 Web,主要為門戶站點(diǎn)搜索引擎和大型 Web 服務(wù)提供商采集數(shù)據(jù)。
聚焦網(wǎng)絡(luò)爬蟲(主題網(wǎng)絡(luò)爬蟲)
是 指選擇性 地爬行那些與預(yù)先定義好的主題相關(guān)頁面的網(wǎng)絡(luò)爬蟲。
增量式網(wǎng)絡(luò)爬蟲
指對已下載網(wǎng)頁采取增量式更新和只爬行新產(chǎn)生的或者已經(jīng)發(fā)生變化網(wǎng)頁 的爬蟲,它能夠在一定程度上保證所爬行的頁面是盡可能新的頁面。
Deep Web 爬蟲
爬行對象是一些在用戶填入關(guān)鍵字搜索或登錄后才能訪問到的深層網(wǎng)頁信息的爬蟲。

爬蟲工作原理

工作原理

從種子URL進(jìn)入,獲取待抓取URL隊列,再進(jìn)入各URL網(wǎng)頁,進(jìn)行信息抓取并存儲,對隊列進(jìn)行入隊和出隊操作。

爬蟲的爬行及反爬策略

爬行策略

網(wǎng)頁的抓取策略可以分為深度優(yōu)先、廣度優(yōu)先和最佳優(yōu)先三種。深度優(yōu)先在很多情況下會導(dǎo)致爬蟲的陷入(trapped)問題,目前常見的是廣度優(yōu)先和最佳優(yōu)先方法。

  • 廣度優(yōu)先
    廣度優(yōu)先搜索策略是指在抓取過程中,在完成當(dāng)前層次的搜索后,才進(jìn)行下一層次的搜索。該算法的設(shè)計和實(shí)現(xiàn)相對簡單。在目前為覆蓋盡可能多的網(wǎng)頁,一般使用廣度優(yōu)先搜索方法。也有很多研究將廣度優(yōu)先搜索策略應(yīng)用于聚焦爬蟲中。其基本思想是認(rèn)為與初始URL在一定鏈接距離內(nèi)的網(wǎng)頁具有主題相關(guān)性的概率很大。另外一種方法是將廣度優(yōu)先搜索與網(wǎng)頁過濾技術(shù)結(jié)合使用,先用廣度優(yōu)先策略抓取網(wǎng)頁,再將其中無關(guān)的網(wǎng)頁過濾掉。這些方法的缺點(diǎn)在于,隨著抓取網(wǎng)頁的增多,大量的無關(guān)網(wǎng)頁將被下載并過濾,算法的效率將變低。
  • 最佳優(yōu)先
    最佳優(yōu)先搜索策略按照一定的網(wǎng)頁分析算法,預(yù)測候選URL與目標(biāo)網(wǎng)頁的相似度,或與主題的相關(guān)性,并選取評價最好的一個或幾個URL進(jìn)行抓取。它只訪問經(jīng)過網(wǎng)頁分析算法預(yù)測為“有用”的網(wǎng)頁。存在的一個問題是,在爬蟲抓取路徑上的很多相關(guān)網(wǎng)頁可能被忽略,因?yàn)樽罴褍?yōu)先策略是一種局部最優(yōu)搜索算法。因此需要將最佳優(yōu)先結(jié)合具體的應(yīng)用進(jìn)行改進(jìn),以跳出局部最優(yōu)點(diǎn)。將在第4節(jié)中結(jié)合網(wǎng)頁分析算法作具體的討論。研究表明,這樣的閉環(huán)調(diào)整可以將無關(guān)網(wǎng)頁數(shù)量降低30%~90%。
  • 深度優(yōu)先
    深度優(yōu)先搜索策略從起始網(wǎng)頁開始,選擇一個URL進(jìn)入,分析這個網(wǎng)頁中的URL,選擇一個再進(jìn)入。如此一個鏈接一個鏈接地抓取下去,直到處理完一條路線之后再處理下一條路線。深度優(yōu)先策略設(shè)計較為簡單。然而門戶網(wǎng)站提供的鏈接往往最具價值,PageRank也很高,但每深入一層,網(wǎng)頁價值和PageRank都會相應(yīng)地有所下降。這暗示了重要網(wǎng)頁通常距離種子較近,而過度深入抓取到的網(wǎng)頁卻價值很低。同時,這種策略抓取深度直接影響著抓取命中率以及抓取效率,對抓取深度是該種策略的關(guān)鍵。相對于其他兩種策略而言。此種策略很少被使用。

反爬策略

后端的反爬策略一般是通過限制IP訪問頻率以及接口請求頻率來反爬,而前端的反爬策略五花八門,讓人大開眼界:

  • FONT-FACE拼湊式
    代表: 貓眼
    頁面

    字體

    頁面使用了font-face定義了字符集,并通過unicode去映射展示。
    其中woff字體是網(wǎng)頁開放字體格式。
    每次刷新頁面,字符集的url都會變化,加大爬取成本。
    只能通過圖像識別(OCR)or爬取字符集去爬取相關(guān)信息。
  • back-ground拼湊式
    代表:美團(tuán)
    頁面

    背景圖

    數(shù)字其實(shí)是圖片,根據(jù)不同的background偏移,顯示不同字符。類似精靈圖。
    不同頁面,圖片的字符及順序都不同,增大了爬蟲難度,增加安全性。
  • 字符干擾式
    代表:微信公眾號
    頁面

    下劃線部分為干擾文字,方框里為真實(shí)文字。
    通過設(shè)置opacity: 0或者display: none的方式將干擾文字隱藏,起到反爬作用。
  • 偽元素隱藏式
    代表: 汽車之家
    頁面

    把關(guān)鍵的廠商信息,做到了偽元素的content里。
    爬蟲必須要解析css,拿到偽元素的content,提升爬蟲難度。
  • 元素定位覆蓋式
    代表: 去哪兒
    頁面

    對于4位數(shù)字的機(jī)票價格,先用4個i標(biāo)簽渲染,再用兩個b標(biāo)簽通過絕對定位覆蓋故意展示錯誤的i標(biāo)簽,最后在視覺上形成正確的價格。
    爬蟲不僅要會解析css,還要會做數(shù)學(xué)題。

Coding

使用工具:

  • Node.js —— 搭建后臺服務(wù)器
  • Express —— 實(shí)現(xiàn)node.js的http封裝及使用
  • Superagent —— 基于node的客戶端請求代理模塊
  • Cheerio —— 基于node的網(wǎng)頁DOM元素操作模塊
  • Nightmare —— 瀏覽器模擬自動化庫
  • Ejs —— ssr服務(wù)端渲染ejs模板
  • Echarts —— 基于canvas的可視化圖表模塊

具體實(shí)現(xiàn)步驟
1、Express啟動http服務(wù),初始化ejs
2、分析目標(biāo)頁面DOM結(jié)構(gòu),找到目標(biāo)元素,使用工具請求目標(biāo)頁面并獲取數(shù)據(jù)
3、將數(shù)據(jù)注入ejs模板,并形成可視化圖表
4、使用自動化工具模擬瀏覽器與用戶行為進(jìn)行測試

分析DOM結(jié)構(gòu)說明:

目標(biāo)頁面

這個頁面就是所謂的種子URL,我想要每個城區(qū)的數(shù)據(jù),就需要進(jìn)入到每個區(qū)域去獲取數(shù)據(jù),也就是URL隊列,那么就需要獲取每個區(qū)域的DOM元素里的URL:

$('.filter .filter__wrapper ul[data-target=area] li>a').each((index, ele) => {
...
})

核心代碼:

// 核心js
const superagent = require('superagent')
const express = require('express')
var router = express.Router()
require('node-jsx').install()
const app = express()
const url = require('url')
const cheerio = require('cheerio')
const fs = require('fs')
const Nightmare = require('nightmare')         // 自動化測試包,處理動態(tài)頁面
const nightmare = Nightmare({ show: true })    // show:true  顯示內(nèi)置模擬瀏覽器

//服務(wù)端渲染ejs模板
var ejs = require('ejs')
app.engine('.html',ejs.__express)
app.set('view engine','ejs')
let data = []   // 存放房源具體數(shù)據(jù)
let count = []  // 存放各區(qū)域房源數(shù)量
let allUrl = [] // 存放待抓取url隊列
//目標(biāo)網(wǎng)站 
let lianjiaUrl = 'https://bj.lianjia.com/'  // url前綴
let zufangUrl = 'https://bj.lianjia.com/zufang/'  // 種子url1
let haidianUrl = 'https://bj.lianjia.com/zufang/haidian/rt200600000001/'    // 種子url2
//分頁規(guī)律 https://bj.lianjia.com/zufang/pg2/#contentList
// #content .content__list--item--main .content__list--item--title a .text()
// href地址
let server = app.listen(3001, function () {
    let host = server.address().address
    let port = server.address().port
    console.log('Your App is running at http://%s:%s', host, port)
  })


// 獲取各區(qū)域房屋套數(shù)
superagent.get(zufangUrl).end((err, res) => {
    if (err) throw err
    let $ = cheerio.load(res.text)
    $('.filter .filter__wrapper ul[data-target=area] li>a').each((index, ele) => {
        let $ele = $(ele)
        let href = url.resolve(lianjiaUrl, $ele.attr('href'))
        superagent.get(href).end((err, res) => {
            if (err) throw err
            let $ = cheerio.load(res.text)
            let houseData = {
                'name': $('.filter .filter__wrapper ul:nth-child(2) li.strong>a').text(),
                'value': $('.content .content__article .content__title .content__title--hl').text()
            }
            count.push(houseData)
        })
    })
})

// 獲取海淀首頁所有房源鏈接元素
superagent.get(haidianUrl).end((err,res)=>{
    if(err) return console.log(err)
    let $ = cheerio.load(res.text)
    $('#content .content__list--item .content__list--item--main>p:first-child>a:first-child').each((index, ele)=>{
        let $ele = $(ele)
        // 拼接單獨(dú)房源url
        let href = url.resolve(lianjiaUrl, $ele.attr('href'))
        allUrl.push(href)
        
        superagent.get(href).end((err, res)=>{
            if(err){
                return console.log(err)
            }
            let $ = cheerio.load(res.text)
            //標(biāo)題  .content p.content__title text()
            //租金 #aside .content__aside--title span內(nèi)的文字
            //格局 .content__aside__list .content__article__table span:nth-child(2) i.typ旁邊的元素
            //平方數(shù) .content__aside__list .content__article__table span:nth-child(2) i.area 旁邊的元素
            // orient旁邊的元素是房間的朝向
            //房源上架時間 content__subtitle 房源上架時間 截取10位
            let title = $('div.content .content__title:first-child').text()
            let money = $('#aside .content__aside--title>span:first-child').text()
            let houseType = $('#aside .content__aside__list .content__article__table>span').eq(1).last().html()
            let area = $('#aside .content__aside__list .content__article__table>span').eq(2).last().html()
            
            houseType = houseType.substr(houseType.indexOf('</i>') + 4)
            area = area.substr(area.indexOf('</i>')+4)
            let code = $('#aside .content__aside__list .content__aside__list--bottom:first-child').attr('housecode')
            let upTime = $('div.content .content__subtitle').html()
            upTime = upTime.substr(upTime.indexOf('<i class="hide">')+ 16, upTime.indexOf('<i class="house_code">') - 28)
            upTime = upTime.substr(upTime.indexOf('</i>') + 4)
           // console.log(unescape(upTime.replace(/&#x/g,'%u').replace(/;/g,'')))
            
            let houseData = {
                'title':title,
                'houseCode': code,
                'money':money,
                'href': href,
                'houseType':unescape(houseType.replace(/&#x/g,'%u').replace(/;/g,'')),
                'area': unescape(area.replace(/&#x/g,'%u').replace(/;/g,'')),
                'upTime':unescape(upTime.replace(/&#x/g,'%u').replace(/;/g,'')),
            }
            fs.appendFile('data/result1.json', `${JSON.stringify(houseData)},` ,'utf-8', function (err) {
                if(err) throw new Error("appendFile failed...")
                //console.log("數(shù)據(jù)寫入success...")
            })
            
           // console.log(houseData)
            data.push(houseData)
        })
    })
   
})
app.set('views', './views')
// app.set('view engine', 'pug')
app.get('/',(req, res)=>{
    // res.send('Hello World!')
    res.render('index', { name: '鏈家爬蟲數(shù)據(jù)可視化', data: data})
})

app.post('/fetch',function(req,res){
    res.json({data: data, count: count})
})

app.get('/show',(req, res) =>{
    res.send({
        data: data
    })
})

// 核心ejs模板
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title><%= name %></title>
</head>
<style>
</style>

<body>
    <h1><%= name %></h1>
    <div>
        <div id="main" style="width: 600px;height:400px;">123</div>
        <div id="submain" style="width: 600px;height:400px;">123</div>
    </div>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/echarts/4.2.1/echarts-en.common.min.js"></script>
    <script src="https://code.jquery.com/jquery-3.4.1.min.js"></script>
    <script type="text/javascript">
    
        $.ajax({
            type: 'POST',
            url: '/fetch'
        }).done(function (results) {
            console.log('results', results); //=>params
            const areaRange = ['西城', '東城', '海淀', '朝陽', '昌平', '通州', '大興']
            // 計算一、二、三居均價及面積
            let oneRoomPrice = [];
            let oneRoomArea = [];
            let twoRoomPrice = [];
            let twoRoomArea = [];
            results.data.forEach(v => {
                if (v.title.includes('1室')) {
                    oneRoomPrice.push(parseInt(v.money))
                    oneRoomArea.push(parseInt(v.area))
                } else if (v.title.includes('2室')) {
                    twoRoomPrice.push(parseInt(v.money))
                    twoRoomArea.push(parseInt(v.area))
                }
            })

            function average (data) {
                return parseInt(data.reduce((a, b) => a + b) / data.length)
            }

            // 基于準(zhǔn)備好的dom,初始化echarts實(shí)例
            var myChart = echarts.init(document.getElementById('main'));
            var pieChart = echarts.init(document.getElementById('submain'));

            // 指定圖表的配置項和數(shù)據(jù)
            var option = {
                color: ['#4cabce', '#e5323e'],
                title: {
                    text: '海淀區(qū)房屋租賃情況統(tǒng)計'
                },
                tooltip: {
                    trigger: 'axis',
                    axisPointer: {
                        type: 'shadow'
                    }
                },
                legend: {
                    data: ['均價', '面積']
                },
                xAxis: {
                    axisTick: {show: true},
                    data: ['一居', '兩居']
                },
                yAxis: [{type: 'value'}],
                series: [{
                    name: '均價',
                    type: 'bar',
                    data: [average(oneRoomPrice), average(twoRoomPrice)]
                }, {
                    name: '面積',
                    type: 'bar',
                    data: [average(oneRoomArea) * 100, average(twoRoomArea) * 100]
                }]
            };

            var pieOption = {
                    backgroundColor: '#2c343c',

                    title: {
                        text: '北京各區(qū)域房源數(shù)量統(tǒng)計',
                        left: 'center',
                        top: 20,
                        textStyle: {
                            color: '#ccc'
                        }
                    },

                    tooltip : {
                        trigger: 'item',
                        formatter: "{a} <br/>{b} : {c} (htq36hd%)"
                    },

                    visualMap: {
                        show: false,
                        min: 80,
                        max: 600,
                        inRange: {
                            colorLightness: [0, 1]
                        }
                    },
                    series : [
                        {
                            name:'訪問來源',
                            type:'pie',
                            radius : '55%',
                            center: ['50%', '50%'],
                            data:results.count.filter(v => areaRange.includes(v.name)).sort(function (a, b) { return a.value - b.value; }),
                            roseType: 'radius',
                            label: {
                                normal: {
                                    textStyle: {
                                        color: 'rgba(255, 255, 255, 0.3)'
                                    }
                                }
                            },
                            labelLine: {
                                normal: {
                                    lineStyle: {
                                        color: 'rgba(255, 255, 255, 0.3)'
                                    },
                                    smooth: 0.2,
                                    length: 10,
                                    length2: 20
                                }
                            },
                            itemStyle: {
                                normal: {
                                    color: '#c23531',
                                    shadowBlur: 200,
                                    shadowColor: 'rgba(0, 0, 0, 0.5)'
                                }
                            },

                            animationType: 'scale',
                            animationEasing: 'elasticOut',
                            animationDelay: function (idx) {
                                return Math.random() * 200;
                            }
                        }
                    ]
            };

            // 使用剛指定的配置項和數(shù)據(jù)顯示圖表。
            myChart.setOption(option);
            pieChart.setOption(pieOption);
        })
    </script>
</body>

</html>

拿到的數(shù)據(jù)是這樣的:


數(shù)據(jù)

形成可視化:


最終成果

瀏覽器自動化測試神器:Nightmare
特異功能:

  • 內(nèi)置模擬瀏覽器,掌控一切
  • 等待DOM元素出現(xiàn),應(yīng)對異步加載
  • 模擬用戶行為,自動輸入文本
  • 模擬用戶行為,自動點(diǎn)擊元素
  • 在客戶端注入JS腳本并執(zhí)行
    。。。
// 通過瀏覽器自動化庫獲取數(shù)據(jù)
nightmare
.goto(zufangUrl)
.wait('.filter .filter__wrapper ul[data-target=area] li>a')
.type('.search__wrap input.search__input', '海淀第一海景房')
// .click('.filter .filter__wrapper ul[data-target=area] li:nth-child(2)>a')
.evaluate(() => document.querySelector(".wrapper").innerHTML)
.then(htmlStr => {
    let $ = cheerio.load(htmlStr)
    $('.filter .filter__wrapper ul[data-target=area] li>a').each((index, ele) => {
        let $ele = $(ele)
        let href = url.resolve(lianjiaUrl, $ele.attr('href'))
        superagent.get(href).end((err, res) => {
            if (err) throw err
            let $ = cheerio.load(res.text)
            let houseData = {
                'name': $('.filter .filter__wrapper ul:nth-child(2) li.strong>a').text(),
                'value': $('.content .content__article .content__title .content__title--hl').text()
            }
            count.push(houseData)
        })
    })
})
.catch(error => {
  console.log(`抓取失敗 - ${error}`)
})

它可以打開一個無頭的模擬瀏覽器窗口,去進(jìn)行各種常規(guī)瀏覽器不能進(jìn)行的操作,比如模擬用戶輸入內(nèi)容:


模擬瀏覽器

總結(jié)

  • 概念:抓取萬維網(wǎng)數(shù)據(jù)的執(zhí)行腳本
  • 工作原理:從種子url進(jìn)入,展開工作
  • 爬行策略:廣度優(yōu)先、最佳優(yōu)先、深度優(yōu)先
  • 反爬策略:增大爬行成本,但無法完全防止
  • Coding:使用各種工具對目標(biāo)網(wǎng)頁進(jìn)行爬取
  • 自動化測試工具:內(nèi)置瀏覽器,模擬用戶行為

關(guān)于爬蟲,還有很多深入的有趣的東西可以研究,比如身份驗(yàn)證以及其他反爬攻防戰(zhàn),歡迎各位共同探討!

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

推薦閱讀更多精彩內(nèi)容