21

前前言

本文首发于 github blog

不想看爬虫过程只想看职位钱途数据分析请看这里:
前端招聘岗位分析
C++招聘岗位分析
JAVA招聘岗位分析
PHP招聘岗位分析
Python招聘岗位分析

想看源码或想自己爬一个请看这里:本文github源码

前言

早在一年前大学校招期间,为了充实下简历,就写了个node爬虫,可惜当时能力有限,工程存在一定的局限性,不好意思拿出来装逼分享。

一年过去了,现在能力依然有限,但是脸皮却练厚了,于是就有了这篇文章。

题纲

关于爬虫,主流技术是用python,然而随着node的出现,对于对python了解有限的前端同学,用node来实现一个爬虫也不失为一个不错的选择。

当然无论是python爬虫还是node爬虫或者其他品种的爬虫,其实除了语言特性之外,其思路基本大同小异。下面我就为大家详细介绍下node爬虫的具体思路与实现,内容大概如下:

  • 爬前准备

    • 选择目标
    • 分析可收集数据与目标可爬取入口
  • 爬虫

    • 爬取JSON数据
    • 爬取HTML文档,提取有用信息
    • Mongodb 数据存储
    • 并发控制
    • 动态IP代理(防止IP被禁)
  • 数据可视化展示

爬前准备

选择目标

既然要写爬虫,当然要爬一些利益相关的数据比较好玩啦。爬取招聘网站的招聘信息,来看看互联网圈子里各个工种的目前薪酬状况及其发展前景,想来是不错的选择。

经我夜观天下,掐指一算,就选拉勾网吧。

分析可收集数据

一个职位招聘信息,一般来说,我们关注的重点信息会是:

  • 薪酬(毫无疑问,重中之重)
  • 工作城市
  • 学历要求
  • 工作年限要求
  • 雇主公司
  • 公司领域
  • 公司规模

带着想要收集的信息,首先,进入拉勾官网,搜索web前端岗位,能看到
clipboard.png

很好,我们想要的信息基本都有了。

分析目标可爬取入口

PC端入口

F12 分析请求资源,可得https://www.lagou.com/jobs/positionAjax.json?needAddtionalResult=false&isSchoolJob=0
post 请求体

{
    first:false,
    pn:1,
    kd:`web前端`
}

响应JSON数据

clipboard.png

完美!!! 数据格式都已经帮我们整理好了,直接爬就行了。

但,完美的数据总不会这么轻易让你得到,经我用 nodepython,还有postman 携带浏览器全部header信息一一测试,均发现:
clipboard.png
好吧,此路不通。(此接口反爬虫机制不明,有研究的大神请留言=_=)

所谓条条大路通罗马,此路不通,咱绕路走。

移动端入口

经过一番探索,发现 拉勾移动端站点 空门大开!

提示: 一般有点技术含量的网站都可能会存在不同强度的反爬虫机制,而一般其移动端站点的反爬虫机制相对于PC站点较弱,是一个不错的着手点。再不行的话,还可以去其app端抓包分析是否存在想要的请求哦。

clipboard.png

GET请求: https://m.lagou.com/search.js...
响应信息:
clipboard.png

很好,虽然数据信息有点少,但是总算是一个能爬的接口了。

爬虫

好了,分析也分析完了,现在正式设计爬虫程序。

JSON数据爬取

  1. 首先,把请求的路径与参数单独抽离。

    let spider = {
        requestUrl : "http://m.lagou.com/search.json",
        query: {
            city: '',
            pageNum: '',
            job: '',
        },
        ...
    }
  2. 发出请求,此处的服务端构造请求使用 superagent,当然,用 request 等类似的包也可以,并无限定。

    let spider = {
        ....
    /**
     * 发起单个请求
     * @return {<Promise<Array>> | <Promise<String>>} 请求成功resolve原始数据,否则reject
      **/
        request() {
            return new Promise((resolve,reject)=>{
                superagent
                .get(this.requestUrl)
                .query({
                    city: this.query.city,
                    pageNo: this.query.pageNum,
                    positionName: this.query.job
                }).end((err, res)=>{
                    let dataList = [];
                    if (err || !res || !res.ok) {
                        console.error(err);
                        reject('request failed!')
                    } else  {
                        dataList = res.body.content.data.page.result
                        if (dataList.length === 0) {
                            // 当请求结果数组长度为0,即认为已经到末页,结束爬虫
                            reject('finish');                     
                        } else {
                            resolve(dataList)
                        }
                    } 
                })
            })
        },
     
  3. 处理数据

    let spider = {
        ....
    /**
     * 处理爬取到的原始数据,提取出所需的数据
     * @param {<Array>} - companyList : 原始数据
     * @return {<Promise<Array>>} resolve处理过的数据
      **/
        handleCallbackData(companyList) {
           
            //处理数据
             let arr = companyList.map((item) => {
                let salary = item.salary.split('-');
                
                //工资两种情况:”10k以上“ or "10k-15k", 平均工资取中位数
                aveSalary = salary.length == 1 ? parseInt(salary[0])*1000 : (parseInt(salary[0]) + parseInt( salary[1] ) )*500;
    
                //过滤出所需数据
                return {
                    companyFullName: item.companyFullName,
                    positionId : item.positionId ,
                    salary:aveSalary ,
                    city:item.city ,
    
                    field: '',
                    companySize:'',
                    workYear:'' ,
                    qualification: '',
                }
            });
    
            return Promise.resolve(arr)
        }
  4. 保存数据,此处数据库使用mongodbORM使用 moogoose

    save2db(jobList) {
        return new Promise((resolve, reject)=>{
            Job.create(jobList,function (err,product) {
                if (err) {
                    console.error(err.errmsg)
                    err.code == 11000 && resolve('丢弃重复数据')
                    reject(err);
                } else {
                    resolve("save data to database successfully")
                }
            })    
        })
    },

HTML 数据解析爬取

从上述的json数据其实我们可以看到,JSON返回的信息十分有限,那么我们需要爬取更多的信息,就需要在招聘详情页解析 html 后提取出所需的信息
随便打开一个移动端的招聘详情页https://m.lagou.com/jobs/3638173.html,目测出url结构很简单,就是jobs/{{positionId}}.html
clipboard.png

从详情页中可以找出 JSON 数据中缺少的数据项:工作年限要求,学历要求,雇主公司领域,雇主公司融资情况,雇主公司规模大小。

爬取方法和上述爬取 JSON 数据相差无几,主要差别就是数据解析部分,这里需要用到cherrio来解析 爬取到的HTML,从而更简单地提取必要信息。

    handleCallbackData({res, jobId}) {
        var $ = cheerio.load(res.text);

        let workYear = $('#content > div.detail > div.items > span.item.workyear > span').text(),
            qualification = $('#content > div.detail > div.items > span.item.education').text().trim(),
            field = $('#content > div.company.activeable > div > div > p').text().trim().split(/\s*\/\s*/)[0]
            companySize = $('#content > div.company.activeable > div > div > p').text().trim().split(/\s*\/\s*/)[2];

        /* 如果这四项数据都没有提取到,很有可能是被拉勾的反爬虫机制拦截了 */
        if ( !(workYear || qualification || field || companySize) ) {
            console.log(res.text)
            return Promise.reject({code:-1, msg:'wrong response!', jobId});
        }

        return {
            id: jobId,
            jobInfo: {
                workYear,
                qualification,
                field,
                // financeStage,
                companySize,
            }
        }
    },

并发控制

做过爬虫的都知道,爬虫的请求并发量是必须要做的,为什么要控制并发?

  1. 控制其爬取频率,以免没爬几个就网站被封IP了。
  2. 控制爬虫应用运行内存,不控制并发的话一下子处理N个请求,内存分分钟爆炸。

实现并发控制可以使用npmasync.mapLimit,这里为了自由度更大我使用了自己实现的 15 行代码实现并发控制

具体代码如下:

let ids = [2213545,5332233, ...], // 招聘岗位详情id列表
    limit = 10, // 并发数
    runningRequestNum = 0 , // 当前并发数
    count = 0; // 累计爬取数据项计数
    
mapLimit(ids, limit, async (jobId)=>{
    let requestUrl = `http://m.lagou.com/jobs/${jobId}.html?source=home_hot&i=home_hot-6` ;
    let delay = parseInt(Math.random() * 2000);

    let currentIndex = count++;
    runningRequestNum++

    await sleep( delay );  // 避免爬太快被封ip,休眠一两秒
    
    let result = await spiderHTML.run({
                    requestUrl,
                    jobId,
                    proxyIp
                })
    console.log(`当前并发数`, runningRequestNum)
    runningRequestNum--
    
    return result;
}).then(mapResult => {
    // 并发控制下将 ids 全部迭代完毕
    // do something 
})



然而,即便严格控制了请求频率,我们还是不可避免地中招了。

clipboard.png

对于反爬虫措施比较暴躁的网站来说,一个IP爬取太过频繁,被识别成机器爬虫几乎是不可避免的。

一般来讲,我们最简单直接的方法就是:换IP。这个IP访问频率太高了被反爬拦截到,换个IP就行了嘛。

动态IP代理

单个IP爬虫对于反爬较为严厉的网站是走不通的。那么我们需要用到动态IP池,每次爬取时从IP池中拉取一个IP出来爬数据。

道理很简单,
1秒内1个IP访问了100个页面,即便是单身20多年的手速也无法企及。只能是机器爬虫无疑。
但1秒内100个IP访问100个页面,平均每个IP一秒内访问了1个页面,那基本不会被反爬干掉

怎么搭建动态IP池?

  1. 首先我们得有一个IP源,动态IP池的补充都从这里拉取,这个网上搜一下"免费代理IP"就有很多出来,选其中一个,收费的IP源比较稳定可靠,免费的就一分钱一分货了。
  2. 其次,每次从IP源中拉取的IP都是无法确认其是否可用的,我们必须筛选一遍,提取出可用的IP。(PS: 此处和步骤4目的一直,如果IP源较为可靠,可以省略)
  3. 设计从IP池中拉取单个IP的策略,使得每个IP使用频率均匀,尽量避免单个IP使用频率过高而失效。
  4. 移除失效IP。尽管设计了拉取策略,但依旧不可避免某些IP失效,此时需要将其移出IP池废弃。

动态IP池工作流程:

st=>start: 提取一个可用IP
e=>end: 根据策略返回一个可用IP
isEnought=>condition: 池中IP数量是否足够
fetch=>operation: 从IP源拉取IP
valid=>operation: 筛选出有效IP并存入IP池
st->isEnought(yes)->e
isEnought(no,right)->fetch(right)->valid(right)->isEnought

具体实现代码其实和上面的爬虫差不多,无非就是爬岗位变成了爬IP而已,具体实现源码在这,就不在这写了。

数据可视化分析

我们最终折腾爬虫,无非就是想要看爬到的数据到底说明了什么。
成功爬取了拉钩网上多个招聘岗位的具体信息后,数据可视化并得出分析结果如下:

clipboard.png
从整体看,北上广深杭这五个城市前端工程师招聘岗位,北京是遥遥领先,是深圳的两倍,是广州的三倍,其次到上海,深圳,杭州,广州居末。

从需求量大概可以看出,整体互联网产业发达程度是 北 > 上 > 深 > 杭 > 广

clipboard.png
由平均工资曲线图可以看到,每隔2K算一档的话,北京一档,上海一档,杭州深圳一档,空一档,广州吊车尾,杭州竟然比深圳高了300,这就代表着深圳虽然招聘需求比杭州大,但两者薪酬待遇其实差不多。

从不同薪酬的招聘数量也能看出一些很大的区别,招聘提供薪资水平中,普遍数量最多的是10k-20k这个水平,但,北京牛逼,招聘岗位60%以上都是20K以上的。我们具体来看看,各个城市对高端人才(提供薪酬20k以上)的招聘比例,那就可以看出明显区别了:

  • 北京:招聘的薪资水平是"20k以上",大概是招聘总数的59.7%
  • 上海:招聘的薪资水平是"20k以上",大概是招聘总数的41.3%
  • 深圳:招聘的薪资水平是"20k以上",大概是招聘总数的29.2%
  • 杭州:招聘的薪资水平是"20k以上",大概是招聘总数的30.4%,和深圳相差不大
  • 广州:招聘的薪资水平是"20k以上",大概是招聘总数的……10.4%。

clipboard.png
基本可以看到一个明显的趋势,公司规模越大,能提供的薪酬越高,不差钱。
另外,从不同规模的公司的前端招聘数量来看,北京又一枝独秀,大公司招聘需求很高。

但从全国来看,不同规模的公司(除了15人以下的)招聘数量基本在同一水平,基本说明:大公司少,但是每个公司招聘的人多;小公司多,但是每个公司招聘的人少。好像这是句废话。

clipboard.png
从图上看,工作经历在1-5年的现在需求最旺盛,并且理所当然地,工作资历越高,薪资越高。
其中3-5年的最吃香,广州有点奇怪,1-3年的最吃香?综合上面的多项数据,感觉像是1-3年工资比3-5年低所以广州互联网公司多招1-3年

当然,这里存在这一个幸存者偏差,拉勾上大部分的都是社招性质的招聘,而应届生和1年经验的大部分都跑校招去了吧,所以数量低也不出奇。

clipboard.png
移动互联网占据了大半壁江山,剩下之中,金融,电子商务,企业服务,数据服务在同一层次。另外,物联网,智能硬件各有一招聘岗位,薪酬都是5K...嗯虽说node现在也可以做物联网了(还别说,我还真的用node搞过硬件串口通信Orz),但是终究不是主流技术,数据展示表明,前端基本与硬件绝缘。

薪酬待遇倒是都在同一水平上,“大数据”工资倒是一枝独秀,但是数据量太少,参考价值不大。

总结:北京钱多机会多当之无愧第一档;上海稍逊一筹;杭州深圳又低一筹;广州真的是差了两个身位。 而对于前端来说,北京 移动互联网 大公司,钱多!坑多!速来!


微雨微语
1.6k 声望571 粉丝

程序猿