美文网首页
node爬虫入门竟如此简单

node爬虫入门竟如此简单

作者: 南宫__ | 来源:发表于2019-09-20 20:15 被阅读0次

    前言

    爬虫一直是软件工程师里看起来比较神秘高深的一门学问,它让人们想起黑客,以及SEO等等。
    目前市面上也有专门的爬虫工程师,并且在大企业的大数据部门,大数据工程师们也会兼任一些爬取竞对数据的工作,当然也有专门做安全的工程师应对爬虫的危害。所以爬虫真的那么高深莫测吗?下面就来揭开它的神秘面纱,带你入门node爬虫!


    我们的目标是:爬取链家官网租房市场相关数据,并形成可视化图表

    最终成果

    在这之前,我们先普及一些爬虫的相关知识:

    爬虫的概念

    网络爬虫(Web crawler),是一种按照一定的规则,自动地抓取万维网信息的程序或者脚本。(百度百科)

    爬虫的分类

    通用网络爬虫(全网爬虫)
    爬行对象从一些 种子URL 扩充到整个 Web,主要为门户站点搜索引擎和大型 Web 服务提供商采集数据。
    聚焦网络爬虫(主题网络爬虫)
    是 指选择性 地爬行那些与预先定义好的主题相关页面的网络爬虫。
    增量式网络爬虫
    指对已下载网页采取增量式更新和只爬行新产生的或者已经发生变化网页 的爬虫,它能够在一定程度上保证所爬行的页面是尽可能新的页面。
    Deep Web 爬虫
    爬行对象是一些在用户填入关键字搜索或登录后才能访问到的深层网页信息的爬虫。

    爬虫工作原理

    工作原理

    从种子URL进入,获取待抓取URL队列,再进入各URL网页,进行信息抓取并存储,对队列进行入队和出队操作。

    爬虫的爬行及反爬策略

    爬行策略

    网页的抓取策略可以分为深度优先、广度优先和最佳优先三种。深度优先在很多情况下会导致爬虫的陷入(trapped)问题,目前常见的是广度优先和最佳优先方法。

    • 广度优先
      广度优先搜索策略是指在抓取过程中,在完成当前层次的搜索后,才进行下一层次的搜索。该算法的设计和实现相对简单。在目前为覆盖尽可能多的网页,一般使用广度优先搜索方法。也有很多研究将广度优先搜索策略应用于聚焦爬虫中。其基本思想是认为与初始URL在一定链接距离内的网页具有主题相关性的概率很大。另外一种方法是将广度优先搜索与网页过滤技术结合使用,先用广度优先策略抓取网页,再将其中无关的网页过滤掉。这些方法的缺点在于,随着抓取网页的增多,大量的无关网页将被下载并过滤,算法的效率将变低。
    • 最佳优先
      最佳优先搜索策略按照一定的网页分析算法,预测候选URL与目标网页的相似度,或与主题的相关性,并选取评价最好的一个或几个URL进行抓取。它只访问经过网页分析算法预测为“有用”的网页。存在的一个问题是,在爬虫抓取路径上的很多相关网页可能被忽略,因为最佳优先策略是一种局部最优搜索算法。因此需要将最佳优先结合具体的应用进行改进,以跳出局部最优点。将在第4节中结合网页分析算法作具体的讨论。研究表明,这样的闭环调整可以将无关网页数量降低30%~90%。
    • 深度优先
      深度优先搜索策略从起始网页开始,选择一个URL进入,分析这个网页中的URL,选择一个再进入。如此一个链接一个链接地抓取下去,直到处理完一条路线之后再处理下一条路线。深度优先策略设计较为简单。然而门户网站提供的链接往往最具价值,PageRank也很高,但每深入一层,网页价值和PageRank都会相应地有所下降。这暗示了重要网页通常距离种子较近,而过度深入抓取到的网页却价值很低。同时,这种策略抓取深度直接影响着抓取命中率以及抓取效率,对抓取深度是该种策略的关键。相对于其他两种策略而言。此种策略很少被使用。

    反爬策略

    后端的反爬策略一般是通过限制IP访问频率以及接口请求频率来反爬,而前端的反爬策略五花八门,让人大开眼界:

    • FONT-FACE拼凑式
      代表: 猫眼
      页面
      字体
      页面使用了font-face定义了字符集,并通过unicode去映射展示。
      其中woff字体是网页开放字体格式。
      每次刷新页面,字符集的url都会变化,加大爬取成本。
      只能通过图像识别(OCR)or爬取字符集去爬取相关信息。
    • back-ground拼凑式
      代表:美团
      页面
      背景图
      数字其实是图片,根据不同的background偏移,显示不同字符。类似精灵图。
      不同页面,图片的字符及顺序都不同,增大了爬虫难度,增加安全性。
    • 字符干扰式
      代表:微信公众号
      页面
      下划线部分为干扰文字,方框里为真实文字。
      通过设置opacity: 0或者display: none的方式将干扰文字隐藏,起到反爬作用。
    • 伪元素隐藏式
      代表: 汽车之家
      页面
      把关键的厂商信息,做到了伪元素的content里。
      爬虫必须要解析css,拿到伪元素的content,提升爬虫难度。
    • 元素定位覆盖式
      代表: 去哪儿
      页面
      对于4位数字的机票价格,先用4个i标签渲染,再用两个b标签通过绝对定位覆盖故意展示错误的i标签,最后在视觉上形成正确的价格。
      爬虫不仅要会解析css,还要会做数学题。

    Coding

    使用工具:

    • Node.js —— 搭建后台服务器
    • Express —— 实现node.js的http封装及使用
    • Superagent —— 基于node的客户端请求代理模块
    • Cheerio —— 基于node的网页DOM元素操作模块
    • Nightmare —— 浏览器模拟自动化库
    • Ejs —— ssr服务端渲染ejs模板
    • Echarts —— 基于canvas的可视化图表模块

    具体实现步骤
    1、Express启动http服务,初始化ejs
    2、分析目标页面DOM结构,找到目标元素,使用工具请求目标页面并获取数据
    3、将数据注入ejs模板,并形成可视化图表
    4、使用自动化工具模拟浏览器与用户行为进行测试

    分析DOM结构说明:

    目标页面
    这个页面就是所谓的种子URL,我想要每个城区的数据,就需要进入到每个区域去获取数据,也就是URL队列,那么就需要获取每个区域的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')         // 自动化测试包,处理动态页面
    const nightmare = Nightmare({ show: true })    // show:true  显示内置模拟浏览器
    
    //服务端渲染ejs模板
    var ejs = require('ejs')
    app.engine('.html',ejs.__express)
    app.set('view engine','ejs')
    let data = []   // 存放房源具体数据
    let count = []  // 存放各区域房源数量
    let allUrl = [] // 存放待抓取url队列
    //目标网站 
    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
    //分页规律 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)
      })
    
    
    // 获取各区域房屋套数
    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)
            // 拼接单独房源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)
                //标题  .content p.content__title text()
                //租金 #aside .content__aside--title span内的文字
                //格局 .content__aside__list .content__article__table span:nth-child(2) i.typ旁边的元素
                //平方数 .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("数据写入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: '链家爬虫数据可视化', 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)
                }
    
                // 基于准备好的dom,初始化echarts实例
                var myChart = echarts.init(document.getElementById('main'));
                var pieChart = echarts.init(document.getElementById('submain'));
    
                // 指定图表的配置项和数据
                var option = {
                    color: ['#4cabce', '#e5323e'],
                    title: {
                        text: '海淀区房屋租赁情况统计'
                    },
                    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: '北京各区域房源数量统计',
                            left: 'center',
                            top: 20,
                            textStyle: {
                                color: '#ccc'
                            }
                        },
    
                        tooltip : {
                            trigger: 'item',
                            formatter: "{a} <br/>{b} : {c} ({d}%)"
                        },
    
                        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;
                                }
                            }
                        ]
                };
    
                // 使用刚指定的配置项和数据显示图表。
                myChart.setOption(option);
                pieChart.setOption(pieOption);
            })
        </script>
    </body>
    
    </html>
    

    拿到的数据是这样的:


    数据

    形成可视化:


    最终成果

    浏览器自动化测试神器:Nightmare
    特异功能:

    • 内置模拟浏览器,掌控一切
    • 等待DOM元素出现,应对异步加载
    • 模拟用户行为,自动输入文本
    • 模拟用户行为,自动点击元素
    • 在客户端注入JS脚本并执行
      。。。
    // 通过浏览器自动化库获取数据
    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}`)
    })
    

    它可以打开一个无头的模拟浏览器窗口,去进行各种常规浏览器不能进行的操作,比如模拟用户输入内容:


    模拟浏览器

    总结

    • 概念:抓取万维网数据的执行脚本
    • 工作原理:从种子url进入,展开工作
    • 爬行策略:广度优先、最佳优先、深度优先
    • 反爬策略:增大爬行成本,但无法完全防止
    • Coding:使用各种工具对目标网页进行爬取
    • 自动化测试工具:内置浏览器,模拟用户行为

    关于爬虫,还有很多深入的有趣的东西可以研究,比如身份验证以及其他反爬攻防战,欢迎各位共同探讨!

    相关文章

      网友评论

          本文标题:node爬虫入门竟如此简单

          本文链接:https://www.haomeiwen.com/subject/afohuctx.html