Python —— 一个『拉勾网』的小爬虫

本文将展示一个 Python 爬虫,其目标网站是『拉勾网』;题图是其运行的结果,这个爬虫通过指定『关键字』抓取所有相关职位的『任职要求』,过滤条件有『城市』、『月薪范围』。并通过百度的分词和词性标注服务(免费的),提取其中的关键字,这个爬虫有什么用?

有那么一个问题模板,xx 语言 / 方向 xx 月薪需要掌握什么技能

对于这种问题,招聘网站上的信息大概是最为『公正客观』,所以这个爬虫的输出可以『公正客观』的作为求职者的技能树发展指南......个屁;如果全盘相信招聘网上写的,估计离凉凉就不远了。其上面写的东西一般都是泛泛而谈,大概率是这样的场景:

  • 先用 5 分钟,把工作中用的各种系统先写上去,比如有一个接口调用是 HDFS 写文件,那就写上『熟悉 Hadoop 生态和分布式文件系统优先』,这样显得工作比较高大上;一定不能让人看出我们就是一个野鸡公司;
  • 再用 5 分钟,写些 比如『有较强的学习能力』、『责任感强』之类面试官都不一定有(多半没有)的废话;
  • 最后 5 分钟,改改错别字,强调下价值观之类的,搞定收工。

所以这篇文章的目的,不是通过『抓取数据』然后通过对『数据的分析』自动的生成各种职位的『技能需求』。它仅仅是通过一个『短小』、『可以运行的』的代码,展示下如何抓取数据,并在这个具体实例中,介绍几个工具和一些爬虫技巧;引入分词有两个目的 1)对分词有个初步印象,尝试使用新的工具挖掘潜在的数据价值 2)相对的希望大家可以客观看待机器学习的能力和适用领域,指望一项技术可以解决所有问题是不切实际的。

1.数据源

『拉勾网』

2.抓取工具

Python 3,并使用第三方库 Requests、lxml、AipNlp,代码共 100 + 行。

  • 安装 Python 3,Download Python
  • Requests: 让 HTTP 服务人类 ,Requests 是一个结构简单且易用的 Python HTTP 库,几行代码就可以发起一个 HTTP 请求,并且有中文文档
  • Processing XML and HTML with Python ,lxml 是用于解析 HTML 页面结构的库,功能强大,但在代码里我们只需要用到其中一个小小的功能
  • 语言处理基础技术-百度AI,AipNlp 是百度云推出的自然语言处理服务库。其是远程调用后台接口,而不是使用本地模型运行,所以不能离线使用。之前写过一篇文章介绍了几个分词库 Python 中的那些中文分词器,这里为什么选用百度云的分词服务,是因为经过对拉勾的数据验证(其实就是拍脑袋),百度云的效果更好。该服务是免费的,具体如何申请会在 4.4 描述
  • 以上 三个库 都可以通过 pip 安装,一行命令

3.实现代码

见本文末尾。

4.逻辑拆解

以下过程建议对比 Chrome 或 Firefox 浏览器的开发者工具。

4.1 拉取『关键字』的相关职位列表

通过构造『拉勾网』的搜索 HTTP 请求,拉取『关键字』的相关职位列表:

1)同时指定过滤条件『城市』和『月薪范围』

2)HTTP 响应的职位列表是 Json 格式,且是分页结构,需要指定页号多次请求才能获取所有相关职位列表

def fetch_list(page_index):
    headers = {"User-Agent": USER_AGENT, "Referer": REFERER, "Cookie": COOKIE}
    params = {"px": "default", "city": CITY, "yx": SALARY}
    data = {"first": page_index == 1, "pn": page_index, "kd": KEY}

    #这是一个 POST 请求,请求的 URL 是一个固定值 https://www.lagou.com/jobs/positionAjax.json
    #附带的数据 HTTP body,其中 pn 是当前分页页号,kd 是关键字
    #附带的 Query 参数,city 是城市(如 北京),yx 是工资范围(如 10k-15k)
    #附带 header,全部是固定值
    s = requests.post(BASE_URL, headers=headers, params=params, data=data)

    return s.json()

这里会附带这些 header,是为了避免『拉勾网』的反爬虫策略。这里如果移除 referer 或修改 referer 值,会发现得不到期望的 json 响应;如果移除 cookie,会发现过几个请求就被封了。其返回 json 格式的响应:

#列表 json 结构
{
  ...
  "content": {
    "pageNo": 当前列表分页号
    ...
    "positionResult": {
      ...
      resultSize: 该列表的招聘职位数量,如果该值为 0,则代表所有信息也被获取
      result: 数组,该页中所有招聘职位的相关信息
      ...
    },
    
  }
  ...
}

#招聘职位信息 json 结构
{
  ...
  "companyFullName": "公司名称",
  "city": "城市",
  "education": "学历要求",
  "salary": "月薪范围",
  "positionName": "职位名称",
  "positionId": "职位 ID,后续要使用该 ID 抓取职位的详情页信息"
}

通过遍历返回 json 结构中 ["positionResult"]["result"] 即可得到该页所有职位的简略信息。

4.2 拉取『某职位』的详细信息

当通过 4.1 获取某一页职位列表时,同时会得到这些职位的 ID。通过 ID,可以获取这些这些职位的详细信息:

def fetch_detail(id):
    headers = {"User-Agent": USER_AGENT, "Referer": REFERER, "Cookie": COOKIE}
    url = DETAIL_URL.format(id)
    
    #这是一个 GET 请求
    #请求的 URL 是 https://www.lagou.com/jobs/职位 ID.html
    #附带 header,全部是固定值
    s = requests.get(url, headers=headers)

    #返回的是一个 HTML 结构
    return s.text

这个 URL 可以通过浏览器直接访问,比如 爬虫工程师招聘-360招聘-拉勾网

4.3 从『某职位』的详细信息中提取『任职要求』

从获取到的 HTML 中提取该职位的文字描述,这里是使用 lxml 的 xpath 来提取:

//dd[@class="job_bt"]/div/p/text()

这个 xpath 语法,获取以下 <p> 标签内的所有内容,返回 ['文本内容', '文本内容', '文本内容']:

<html>
...
  <dd class="job_bt">
    ...
    <div>
      ...
      <p>文本内容</p>
      <p>文本内容</p>
      <p>文本内容</p>
      ...
    </div>
  </dd>
...
</html>

xpath 的基础语法学习,参考 XPath 教程。它和 css 选择器语法可以认为是爬虫必须掌握的基本知识。

获取到这些文本数组后,为了提取『任职要求』,使用了一个非常粗暴的正则表达式:

\w?[\.、 ::]?(任职要求|任职资格|我们希望你|任职条件|岗位要求|要求:|职位要求|工作要求|职位需求)

标记文本数组中职位要求的开始,并将后续所有以符号 - 或 数字 开头的文本认为为『任职要求』。这样我们就从 爬虫工程师招聘-360招聘-拉勾网 获取到『任职要求』:

  • 有扎实的数据结构和算法功底;
  • 工作认真细致踏实,有较强的学习能力,熟悉常用爬虫工具;
  • 熟悉linux开发环境,熟悉python等;
  • 理解http,熟悉html, DOM, xpath, scrapy优先;
  • 有爬虫,信息抽取,文本分类相关经验者优先; 了解Hadoop、Spark等大数据框架和流处理技术者优先。

以上提取『任职要求』的方法存在一定的错误率,也会遗漏一些。这是因为『拉勾网』的『职位详情』文本描述多样性,以及粗暴的正则过滤逻辑导致的。有兴趣的同学可以考虑结合实际进行改进。

4.4 使用百度 AipNlp 进行分词和词性标注

分词和词性标注服务非常容易使用

from aip import AipNlp
client = AipNlp(APP_ID, API_KEY, SECRET_KEY)

text = "了解Hadoop、Spark等大数据框架和流处理技术者优先。"
client.lexer(text)

代码中,除了调用该接口,会进一步对返回结构进行加工。具体代码见本文末尾,在 segment 方法中。简略用文字描述,把结果中词性为其他专名和命令实体类型词单独列出来,其余名词性的词也提取出来并且如果连在一起则合并在一起(这么做,只是观察过几个例子后决定的;工程实践中,需要制定一个标准并对比不同方法的优劣,不应该像这样拍脑袋决定)。百度分词服务的词性标注含义 自然语言处理-常见问题-百度云

『任职要求』经过分词和词性标注处理后的结果如下:

Hadoop/Spark/http/爬虫/xpath/数据框架/scrapy/信息/数据结构/html/学习能力/开发环
境/linux/爬虫工具/算法功底/DOM/流处理技术者/python/文本分类相关经验者

这样我们就完成了这整套逻辑,通过循环请求 4.1,完成『关键字』的所有职位信息的抓取和『任职要求』的提取 / 分析。

百度的分词和词性标注服务需要申请,申请后得到 APP_ID, API_KEY, SECRET_KEY 并填入代码从来正常工作,申请流程如下,点击链接 语言处理基础技术-百度AI。

点击 立即使用,进入登录页面 百度帐号(贴吧、网盘通用)

点击创建应用,随便填写一些信息即可。

申请后,把 AppID、API Key、Secret Key 填入代码。

5.抓取结果

5 / 6 / 7 没有『任职要求』输出,是漏了还是真的没有?

还是北京工资高,成都只有 1 个可能在 25k 以上的爬虫职位。

6 结语

  • 如果实在不想申请百度云服务,可以使用其他的分词库 Python 中的那些中文分词器;对比下效果,也许有惊喜
  • 示例实现了一个基本且完整的结构,在这基础有很多地方可以很容易的修改 1)抓取多个城市以及多个薪资范围 2)增加过滤条件,比如工作经验和行业 3)将分词和爬虫过程分离,解耦逻辑,也方便断点续爬 4)分析其他数据,比如薪资和城市关系、薪资和方向的关系、薪资和『任职要求』的关系等
  • Mac 上实现的,Windows 没测过,理论上应该同样没问题。如果有同学爬过并愿意给我说下结果,那实在太感谢了
  • 写爬虫,有个节操问题,不要频次太高。特别这种出于兴趣的代码,里面的 sleep 时间不要改小

附 代码和部分注释

#coding: utf-8

import time
import re
import urllib.parse

import requests
from lxml import etree

KEY = "爬虫" #抓取的关键字
CITY = "北京" #目标城市
# 0:[0, 2k), 1: [2k, 5k), 2: [5k, 10k), 3: [10k, 15k), 4: [15k, 25k), 5: [25k, 50k), 6: [50k, +inf)
SALARY_OPTION = 3 #薪资范围,值范围 0 ~ 6,其他值代表无范围
#进入『拉勾网』任意页面,无需登录
#打开 Chrome / Firefox 的开发者工具,从中复制一个 Cookie 放在此处
#防止被封,若无法拉取任何信息,首先考虑换 Cookie
COOKIE = "JSESSIONID=ABAAABAACBHABBI7B238FB0BC8B6139070838B4D2D31CED; _ga=GA1.2.201890914.1522471658; _gat=1; Hm_lvt_4233e74dff0ae5bd0a3d81c6ccf756e6=1522471658; Hm_lpvt_4233e74dff0ae5bd0a3d81c6ccf756e6=1522471674; user_trace_token=20180331124738-a3407f45-349e-11e8-a62b-525400f775ce; LGSID=20180331124738-a34080db-349e-11e8-a62b-525400f775ce; PRE_UTM=; PRE_HOST=; PRE_SITE=; PRE_LAND=https%3A%2F%2Fwww.lagou.com%2F; LGRID=20180331124753-ac447493-349e-11e8-b664-5254005c3644; LGUID=20180331124738-a3408251-349e-11e8-a62b-525400f775ce; _gid=GA1.2.24217288.1522471661; index_location_city=%E6%88%90%E9%83%BD; TG-TRACK-CODE=index_navigation"

def init_segment():
    #按照 4.4 的方式,申请百度云分词,并填写到下面
    APP_ID = "xxxxxxxxx"
    API_KEY = "xxxxxxxxx"
    SECRET_KEY = "xxxxxxxxx"

    from aip import AipNlp
    #保留如下词性的词 https://cloud.baidu.com/doc/NLP/NLP-FAQ.html#NLP-FAQ
    retains = set(["n", "nr", "ns", "s", "nt", "an", "t", "nw", "vn"])

    client = AipNlp(APP_ID, API_KEY, SECRET_KEY)
    def segment(text):
        '''
        对『任职信息』进行切分,提取信息,并进行一定处理
        '''
        try:
            result = []
            #调用分词和词性标注服务,这里使用正则过滤下输入,是因为有特殊字符的存在
            items = client.lexer(re.sub('\s', '', text))["items"]

            cur = ""
            for item in items:
                #将连续的 retains 中词性的词合并起来
                if item["pos"] in retains:
                    cur += item["item"]
                    continue

                if cur:
                    result.append(cur)
                    cur = ""
                #如果是 命名实体类型 或 其它专名 则保留
                if item["ne"] or item["pos"] == "nz":
                    result.append(item["item"])
            if cur:
                result.append(cur)
                 
            return result
        except Exception as e:
            print("fail to call service of baidu nlp.")
            return []

    return segment

#以下无需修改,拉取『拉勾网』的固定参数
SALARY_INTERVAL = ("2k以下", "2k-5k", "5k-10k", "10k-15k", "15k-25k", "25k-50k", "50k以上")
if SALARY_OPTION < len(SALARY_INTERVAL) and SALARY_OPTION >= 0:
    SALARY = SALARY_INTERVAL[SALARY_OPTION]
else:
    SALARY = None
USER_AGENT = "Mozilla/5.0 (Windows NT 5.1) AppleWebKit/534.55.3 (KHTML, like Gecko) Version/5.1.5 Safari/534.55.3"
REFERER = "https://www.lagou.com/jobs/list_" + urllib.parse.quote(KEY)
BASE_URL = "https://www.lagou.com/jobs/positionAjax.json"
DETAIL_URL = "https://www.lagou.com/jobs/{0}.html"

#抓取职位详情页
def fetch_detail(id):
    headers = {"User-Agent": USER_AGENT, "Referer": REFERER, "Cookie": COOKIE}
    try:
        url = DETAIL_URL.format(id)
        print(url)
        s = requests.get(url, headers=headers)

        return s.text
    except Exception as e:
        print("fetch job detail fail. " + url)
        print(e)
        raise e

#抓取职位列表页
def fetch_list(page_index):
    headers = {"User-Agent": USER_AGENT, "Referer": REFERER, "Cookie": COOKIE}
    params = {"px": "default", "city": CITY, "yx": SALARY}
    data = {"first": page_index == 1, "pn": page_index, "kd": KEY}
    try:
        s = requests.post(BASE_URL, headers=headers, params=params, data=data)

        return s.json()
    except Exception as e:
        print("fetch job list fail. " + data)
        print(e)
        raise e

#根据 ID 抓取详情页,并提取『任职信息』
def fetch_requirements(result, segment):
    time.sleep(2)

    requirements = {}
    content = fetch_detail(result["positionId"])
    details = [detail.strip() for detail in etree.HTML(content).xpath('//dd[@class="job_bt"]/div/p/text()')]

    is_requirement = False
    for detail in details:
        if not detail:
            continue
        if is_requirement:
            m = re.match("([0-9]+|-)\s*[\.::、]?\s*", detail)
            if m:
                words = segment(detail[m.end():])
                for word in words:
                    if word not in requirements:
                        requirements[word] = 1
                    else:
                        requirements[word] += 1
            else:
                break
        elif re.match("\w?[\.、 ::]?(任职要求|任职资格|我们希望你|任职条件|岗位要求|要求:|职位要求|工作要求|职位需求)", detail):
            is_requirement = True

    return requirements

#循环请求职位列表
def scrapy_jobs(segment):
    #用于过滤相同职位
    duplications = set()
    #从页 1 开始请求
    page_index = 1
    job_count = 0

    print("key word {0}, salary {1}, city {2}".format(KEY, SALARY, CITY))
    stat = {}
    while True:
        print("current page {0}, {1}".format(page_index, KEY))
        time.sleep(2)

        content = fetch_list(page_index)["content"]

        # 全部页已经被请求
        if content["positionResult"]["resultSize"] == 0:
            break

        results = content["positionResult"]["result"]
        total = content["positionResult"]["totalCount"]
        print("total job {0}".format(total))

        # 处理该页所有职位信息
        for result in results:
            if result["positionId"] in duplications:
                continue
            duplications.add(result["positionId"])

            job_count += 1
            print("{0}. {1}, {2}, {3}".format(job_count, result["positionName"], result["salary"], CITY))
            requirements = fetch_requirements(result, segment)
            print("/".join(requirements.keys()) + "\n")
            #把『任职信息』数据统计到 stat 中
            for key in requirements:
                if key not in stat:
                    stat[key] = requirements[key]
                else:
                    stat[key] += requirements[key]

        page_index += 1
    return stat

segment = init_segment()
stat = scrapy_jobs(segment)

#将所有『任职信息』根据提及次数排序,输出前 10 位
import operator
sorted_stat = sorted(stat.items(), key=operator.itemgetter(1))
print(sorted_stat[-10:])

本文转载自

知乎专栏:https://zhuanlan.zhihu.com/p/35140404 作者:邓卓

《Python人工智能和全栈开发》2018年07月23日即将在北京开课,120天冲击Python年薪30万,改变速约~~~~

*声明:推送内容及图片来源于网络,部分内容会有所改动,版权归原作者所有,如来源信息有误或侵犯权益,请联系我们删除或授权事宜。

- END -


原文发布于微信公众号 - 马哥Linux运维(magedu-Linux)

原文发表时间:2018-04-02

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏领域驱动设计DDD实战进阶

DDD实战进阶第一波(三):开发一般业务的大健康行业直销系统(搭建支持DDD的轻量级框架二)

了解了DDD的好处与基本的核心组件后,我们先不急着进入支持DDD思想的轻量级框架开发,也不急于直销系统需求分析和具体代码实现,我们还少一块, 那就是经典DDD的...

42660
来自专栏大数据和云计算技术

新数仓系列:MongoDB关键能力和特性梳理

最近看一本书,铃木敏文的《零售的哲学》,里面提到一个很有意思的观点,711核心使命是提供便利,围绕便利场景,提供一系列食品、ATM服务等,而不是和超市去PK货物...

31660
来自专栏编舟记

架构整洁之道导读(二)

我是《架构整洁之道》(Clean Architecture) 中文版的技术审校者,在审校的过程当中略有感悟,所以希望通过撰写导读的方式分享给大家。

13220
来自专栏应用案例

独家分享 腾讯大神教你如何学习一门新的编程语言-以Python 为例

学习 Python ,进行 Django 开发也有一年了,小结一下,一年的学习历程。 1. 了解新语言产生的背景 ABC 是专门为非专业程序员设计的一种教学语言...

22360
来自专栏圣杰的专栏

eShopOnWeb 知多少

eShopOnWeb是基于ASP.NET Core构建,官方创建这样一个示例项目的目的,我想无非以下几点:

18310
来自专栏猿天地

聊聊Akka

当前社会,人们越来越享受互联网带来的种种便利,同时也对互联网产品有了更高的要求,比如更快的响应速度和更稳定的服务;另一方面,互联网产品在不断发展的过程中也面临着...

26330
来自专栏存储

集群NAS和对象存储的区别

这个话题乍一看可能有些奇怪,因为一个是集群NAS存储,一个是对象存储,没什么相同的地方,为啥还要比较? 而实际上,在考察两种技术的实际应用场景时,我们会发现,两...

483100
来自专栏养码场

一位资深Java的阿里系公司实战面试经验,套路还是面试官的多

占小狼:一位奋斗在魔都的资深Java开发。去年6月在简书上发第一篇技术文章,已坚持发表76篇技术文章,粉丝数突破4000。

24370
来自专栏程序员互动联盟

作为一名软件工程学生想要自学Linux,可以从哪方面开始学习?

很多linux初学者的首选书籍,linux学习先从基础的命令行入手,常用的命令大约20个,然后慢慢切入学习

8810
来自专栏HansBug's Lab

【备忘】Idea的那些事

说到Java的IDE,似乎eclipse和Idea是目前的主流。然而,OO的课程组却一直在推荐使用eclipse,于是很多人就这样错过了Idea这样强大的IDE...

43890

扫码关注云+社区

领取腾讯云代金券