本文将展示一个 Python 爬虫,其目标网站是『拉勾网』;题图是其运行的结果,这个爬虫通过指定『关键字』抓取所有相关职位的『任职要求』,过滤条件有『城市』、『月薪范围』。并通过百度的分词和词性标注服务(免费的),提取其中的关键字,这个爬虫有什么用?
有那么一个问题模板,xx 语言 / 方向 xx 月薪需要掌握什么技能
对于这种问题,招聘网站上的信息大概是最为『公正客观』,所以这个爬虫的输出可以『公正客观』的作为求职者的技能树发展指南......个屁;如果全盘相信招聘网上写的,估计离凉凉就不远了。其上面写的东西一般都是泛泛而谈,大概率是这样的场景:
所以这篇文章的目的,不是通过『抓取数据』然后通过对『数据的分析』自动的生成各种职位的『技能需求』。它仅仅是通过一个『短小』、『可以运行的』的代码,展示下如何抓取数据,并在这个具体实例中,介绍几个工具和一些爬虫技巧;引入分词有两个目的 1)对分词有个初步印象,尝试使用新的工具挖掘潜在的数据价值 2)相对的希望大家可以客观看待机器学习的能力和适用领域,指望一项技术可以解决所有问题是不切实际的。
1.数据源
『拉勾网』
2.抓取工具
Python 3,并使用第三方库 Requests、lxml、AipNlp,代码共 100 + 行。
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招聘-拉勾网 获取到『任职要求』:
以上提取『任职要求』的方法存在一定的错误率,也会遗漏一些。这是因为『拉勾网』的『职位详情』文本描述多样性,以及粗暴的正则过滤逻辑导致的。有兴趣的同学可以考虑结合实际进行改进。
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 结语
附 代码和部分注释
#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 -