本文将展示一个 Python 爬虫,其目标网站是『拉勾网』;题图是其运行的结果,这个爬虫通过指定『关键字』抓取所有相关职位的『任职要求』,过滤条件有『城市』、『月薪范围』。并通过百度的分词和词性标注服务(免费的),提取其中的关键字(如题图),这个爬虫有什么用了?有那么一个问题模板,xx 语言 / 方向 xx 月薪需要掌握什么技能
对于这种问题,招聘网站上的信息大概是最为『公正客观』,所以这个爬虫的输出可以『公正客观』的作为求职者的技能树发展指南......个屁;如果全盘相信招聘网上写的,估计离凉凉就不远了。其上面写的东西一般都是泛泛而谈,大概率是这样的场景先用 5 分钟,把工作中用的各种系统先写上去,比如有一个接口调用是 HDFS 写文件,那就写上『熟悉 Hadoop 生态和分布式文件系统优先』,这样显得工作比较高大上;一定不能让人看出我们就是一个野鸡公司
再用 5 分钟,写些 比如『有较强的学习能力』、『责任感强』之类面试官都不一定有(多半没有)的废话
最后 5 分钟,改改错别字,强调下价值观之类的,搞定收工
所以这篇文章的目的,不是通过『抓取数据』然后通过对『数据的分析』自动的生成各种职位的『技能需求』。它仅仅是通过一个『短小』、『可以运行的』的代码,展示下如何抓取数据,并在这个具体实例中,介绍几个工具和一些爬虫技巧;引入分词有两个目的 1)对分词有个初步印象,尝试使用新的工具挖掘潜在的数据价值 2)相对的希望大家可以客观看待机器学习的能力和适用领域,指望一项技术可以解决所有问题是不切实际的
1 数据源
2 抓取工具
Python 3,并使用第三方库 Requests、lxml、AipNlp,代码共 100 + 行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 语法,获取以下
标签内的所有内容,返回 ['文本内容', '文本内容', '文本内容']
...
...
...
文本内容
文本内容
文本内容
...
...
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:])
欢迎关注我的专栏面向工资编程 —— 收录 Java 语言的面试向文章zhuanlan.zhihu.com编程的基础和一些理论zhuanlan.zhihu.com编程的日常娱乐 —— 用 Python 干些有趣的事zhuanlan.zhihu.com