node爬虫入门竟如此简单

前言

爬虫一直是软件工程师里看起来比较神秘高深的一门学问,它让人们想起黑客,以及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:使用各种工具对目标网页进行爬取
  • 自动化测试工具:内置浏览器,模拟用户行为

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

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,039评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,223评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,916评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,009评论 1 291
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,030评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,011评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,934评论 3 416
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,754评论 0 271
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,202评论 1 309
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,433评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,590评论 1 346
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,321评论 5 342
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,917评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,568评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,738评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,583评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,482评论 2 352

推荐阅读更多精彩内容