拉勾爬虫实战

0 引言

  一次简单的 Python 爬虫练习:输入 目标城市目标职位,从 拉勾网 爬取相关的职位列表数据(受拉勾网的展示机制限制,只能爬取 30 页共 450 条记录),并对数据进行清洗,最后进行简单的描述统计和回归分析。
# 主要参考了 闲庭信步 的拉勾爬虫代码。

1 分析网页

  首先,使用 chrome 打开拉勾网,选择城市为广州,搜索产品经理职位,会进入下图的职位列表页:

拉勾职位列表页

  接下来使用开发者工具进行发现,会发现页面上的职位信息其实是存在 positionAjax.json 上的,查看 positionAjax.jsonHeaders,获取爬虫所需要的信息:

使用开发者工具分析网页
# Request URL
url = 'https://www.lagou.com/jobs/positionAjax.json?city=%E5%B9%BF%E5%B7%9E&needAddtionalResult=false'

# Request Headers
my_headers = {  
  'User-Agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.139 Safari/537.36',  
  'Host':'www.lagou.com',  
  'Referer':'https://www.lagou.com/jobs/list_%E6%95%B0%E6%8D%AE%E4%BA%A7%E5%93%81%E7%BB%8F%E7%90%86?city=%E5%B9%BF%E5%B7%9E&cl=false&fromSearch=true&labelWords=&suginput=',  
  'X-Anit-Forge-Code':'0',  
  'X-Anit-Forge-Token': 'None',  
  'X-Requested-With':'XMLHttpRequest'  
}

# Form Data
my_data = {  
  'first': 'true',  
  'pn': 1,  
  'kd': '产品经理'
} 

  有了这些信息,我们就可以使用 POST 请求抓取网页了。但为了让程序可以更加方便,不需要每次都复制粘贴那么多信息,我们应该在这些信息中找出不变的和可由程序自行填充的,最好能达到只需要输入目标城市和职位,就可以自动爬取的效果。

2 爬取网页

2.1 导入相关包

  由于这次还会进行简单的描述统计和回归分析,因此需要导入较多包:

# 数据处理及导入导出
import pandas as pd  
# 爬虫
import requests  
import math  
import time
import sys, urllib
# 数据可视化
import seaborn as sns
import matplotlib.pyplot as plt
# 统计建模
import statsmodels.api as sm
# 词云
from wordcloud import WordCloud  
from imageio import imread
import jieba

2.2 构建爬虫函数

  这里一共构建了 4 个函数:

  • get_json(url, num, encode_city, position):从网页获取 JSON,使用 POST 请求,加上头部信息
  • get_page_num(count):根据职位数计算要抓取的页数(最多 30 页
  • get_page_info(jobs_list):对包含网页职位信息的 JSON 进行解析,返回列表
  • lagou_spider(city, position):获取键盘输入的目标城市和职位,调用其它 3 个函数,爬取拉勾职位列表数据并导出为 CSV 文件。
def get_json(url, num, encode_city, position):  
    '''
        从网页获取 JSON,使用 POST 请求,加上头部信息
    '''
    # Request Headers
    my_headers = {  
        'User-Agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.139 Safari/537.36',  
        'Host':'www.lagou.com',  
        'Referer':'https://www.lagou.com/jobs/list_%E6%95%B0%E6%8D%AE%E4%BA%A7%E5%93%81%E7%BB%8F%E7%90%86?city=' + encode_city + '&cl=false&fromSearch=true&labelWords=&suginput=',  
        'X-Anit-Forge-Code':'0',  
        'X-Anit-Forge-Token': 'None',  
        'X-Requested-With':'XMLHttpRequest'  
    }

    # Form Data
    my_data = {  
        'first': 'true',  
        'pn': num,  
        'kd': position
    }  
    
    res = requests.post(url, headers = my_headers, data = my_data)  
    res.raise_for_status()  
    res.encoding = 'utf-8'  
    
    # 得到包含职位信息的字典  
    page = res.json()  
    return page  
def get_page_num(count):  
    '''
        计算要抓取的页数
    '''  
    # 每页15个职位,向上取整  
    res = math.ceil(count/15)  
    # 拉勾网最多显示30页结果  
    if res > 30:  
        return 30  
    else:  
        return res  
def get_page_info(jobs_list):  
    ''''
        对一个网页的职位信息进行解析,返回列表
    '''  
    page_info_list = []  
    
    for i in jobs_list:  
        job_info = []  
        job_info.append(i['companyFullName'])  
        job_info.append(i['companyShortName'])  
        job_info.append(i['companySize']) 
        job_info.append(i['financeStage'])  
        job_info.append(i['district'])  
        job_info.append(i['industryField'])  
        job_info.append(i['positionName'])  
        job_info.append(i['jobNature']) 
        job_info.append(i['firstType']) 
        job_info.append(i['secondType']) 
        job_info.append(i['workYear'])  
        job_info.append(i['education'])  
        job_info.append(i['salary'])  
        job_info.append(i['positionAdvantage'])  
        page_info_list.append(job_info)  
    
    return page_info_list  
def lagou_spider(city, position):  
    '''
        爬取拉勾职位列表数据
    '''
    # encode city
    encode_city = urllib.parse.quote(city)
    
    # Request URL
    url = 'https://www.lagou.com/jobs/positionAjax.json?city=' + encode_city + '&needAddtionalResult=false'  
    
    # 先设定页数为1,获取总的职位数  
    first_page = get_json(url,1, encode_city, position)  
    total_count = first_page['content']['positionResult']['totalCount']  
    num = get_page_num(total_count)  
    total_info = []  
    time.sleep(20)  
    print('职位总数:{},页数:{}'.format(total_count,num))  

    for n in range(1,num+1):  
        # 对每个网页读取JSON, 获取每页数据  
        page = get_json(url, n, encode_city, position)  
        jobs_list = page['content']['positionResult']['result']  
        page_info = get_page_info(jobs_list)  
        total_info += page_info  
        print('已经抓取第{}页, 职位总数:{}'.format(n, len(total_info)))  
        # 每次抓取完成后,暂停一会,防止被服务器拉黑  
        time.sleep(30)  
       
    #将总数据转化为 DataFrame再输出  
    df = pd.DataFrame(data = total_info,columns = ['公司全名', '公司简称', '公司规模', '融资阶段', '区域', '行业', 
                                                   '职位名称', '工作性质', '一级类别', '二级类别', '工作经验', '学历要求', '工资','职位福利'])   
    data_output = 'data\\' + city + '—' + position + '.csv'
    df.to_csv(data_output, index = False)  
    
    print('已保存为csv文件.')  

2.3 运行爬虫

  完成爬虫函数之后,就可以开始运行爬虫了,在键盘输入工作城市和目标职位,爬虫开始运行:

# 输入工作城市和目标职位
city = input("工作城市:\n")
position = input("目标职位:\n")

工作城市:
广州
目标职位:
产品经理

# 运行爬虫
lagou_spider(city, position)

职位总数:1176,页数:30
已经抓取第1页, 职位总数:15
已经抓取第2页, 职位总数:30
已经抓取第3页, 职位总数:45
已经抓取第4页, 职位总数:60
已经抓取第5页, 职位总数:75
已经抓取第6页, 职位总数:90
已经抓取第7页, 职位总数:105
已经抓取第8页, 职位总数:120
已经抓取第9页, 职位总数:135
已经抓取第10页, 职位总数:150
已经抓取第11页, 职位总数:165
已经抓取第12页, 职位总数:180
已经抓取第13页, 职位总数:195
已经抓取第14页, 职位总数:210
已经抓取第15页, 职位总数:225
已经抓取第16页, 职位总数:240
已经抓取第17页, 职位总数:255
已经抓取第18页, 职位总数:270
已经抓取第19页, 职位总数:285
已经抓取第20页, 职位总数:300
已经抓取第21页, 职位总数:315
已经抓取第22页, 职位总数:330
已经抓取第23页, 职位总数:345
已经抓取第24页, 职位总数:360
已经抓取第25页, 职位总数:375
已经抓取第26页, 职位总数:390
已经抓取第27页, 职位总数:405
已经抓取第28页, 职位总数:420
已经抓取第29页, 职位总数:435
已经抓取第30页, 职位总数:450
已保存为csv文件.

  为了防止被封,因此爬虫在每次完成抓取后,都会暂停一会,这导致爬虫需要较多时间。爬虫运行完成后,将数据保存为 CSV 文件,使用 Pandas 打开:

# 读取数据  
file = open('data\\' + city + '—' + position + '.csv', 'rb')
df = pd.read_csv(file, encoding = 'utf-8')  

  查看头 5 行的数据:

df.head()
爬虫结果头 5 行数据

3 数据清洗

  首先剔除非全职的职位:

# 剔除实习岗位  
df.drop(df[df['工作性质'].str.contains('实习')].index, inplace=True)  

  将工作经验和工资由字符串形式转换为列表:

pattern = '\d+'  
df['工作年限'] = df['工作经验'].str.findall(pattern)

avg_work_year = []  
for i in df['工作年限']:  
    # 如果工作经验为 '不限' 或 '应届毕业生' ,那么匹配值为空,工作年限为 0  
    if len(i) == 0:  
        avg_work_year.append(0)  
   # 如果匹配值为一个数值,那么返回该数值  
    elif len(i) == 1:  
        avg_work_year.append(int(''.join(i)))  
   # 如果匹配值为一个区间,那么取平均值  
    else:  
        num_list = [int(j) for j in i]  
        avg_year = sum(num_list) / 2  
        avg_work_year.append(avg_year)
        
df['经验要求'] = avg_work_year
        
# 将字符串转化为列表,再取区间的前25%,比较贴近现实  
df['salary'] = df['工资'].str.findall(pattern)  

avg_salary = []  
for k in df['salary']:  
    int_list = [int(n) for n in k]  
    avg_wage = int_list[0] + (int_list[1] - int_list[0]) / 4  
    avg_salary.append(avg_wage) 

df['月工资'] = avg_salary

  保存再打开清洗后的数据:

# 将清洗后的数据保存
df.to_csv('data\\' + city + '—' + position + '(已清洗)' + '.csv', index = False)

# 读取清洗后的数据  
file = open('data\\' + city + '—' + position + '(已清洗)' + '.csv', 'rb')
df2 = pd.read_csv(file, encoding = 'utf-8')  

4 描述统计

# 描述统计  
print('工资描述:\n{}'.format(df2['月工资'].describe()))

工资描述:
count 450.000000
mean 12.885556
std 4.888166
min 1.250000
25% 9.750000
50% 12.000000
75% 16.000000
max 31.250000
Name: 月工资, dtype: float64

plt.rcParams['font.sans-serif'] = ['SimHei'] # 指定默认字体  
plt.rcParams['axes.unicode_minus'] = False # 解决保存图像是负号'-'显示为方块的问题 
sns.set(font='SimHei')  # 解决 Seaborn 中文显示问题
# 绘制频率直方图并保存  
sns.distplot(df2['月工资'], bins=15, kde=True, rug=True)
plt.title("工资直方图")
plt.xlabel('工资 (千元)')   
plt.ylabel('频数')      
plt.savefig('output\histogram.jpg')  
plt.show();
histogram.jpg
count = df2['区域'].value_counts()

# 绘制饼图并保存  
fig = plt.figure()
ax = fig.add_subplot(111)
ax.pie(count, labels = count.keys(), labeldistance=1.4, autopct='%2.1f%%')  
plt.axis('equal')  # 使饼图为正圆形  
plt.legend(loc='upper left', bbox_to_anchor=(-0.1, 1))  
plt.savefig('output\pie_chart.jpg')  
plt.show()  
pie_chart.jpg
# 绘制词云,将职位福利中的字符串汇总  
text = ''  
for line in df2['职位福利']:  
    text += line  
# 使用jieba模块将字符串分割为单词列表      
cut_text = ' '.join(jieba.cut(text))  
color_mask = imread('img\jobs.jpg')  #设置背景图  
cloud = WordCloud(  
    font_path = 'fonts\FZBYSK.ttf',   
    background_color = 'white',  
    mask = color_mask,  
    max_words = 1000,  
    max_font_size = 100          
)  

word_cloud = cloud.generate(cut_text)  

# 保存词云图片  
word_cloud.to_file('output\word_cloud.jpg')  
plt.imshow(word_cloud)  
plt.axis('off')  
plt.show()

Building prefix dict from the default dictionary ...
Loading model from cache C:\Users\gaiusyao\AppData\Local\Temp\jieba.cache
Loading model cost 0.796 seconds.
Prefix dict has been built succesfully.

word_cloud.jpg

5 实证统计

# 实证统计,将学历不限的职位要求认定为最低学历:大专  
df['学历要求'] = df['学历要求'].replace('不限', '大专')  
# 学历分为大专\本科\硕士,将它们设定为虚拟变量  
dummy_edu = pd.get_dummies(df['学历要求'], prefix = '学历') 
# 构建回归数组  
df_with_dummy = pd.concat([df['月工资'], df['经验要求'], dummy_edu], axis = 1)
# 建立多元回归模型  
y = df_with_dummy['月工资']  
X = df_with_dummy[['经验要求','学历_大专','学历_本科','学历_硕士']]  
X = sm.add_constant(X)   
model = sm.OLS(y, X.astype(float))  
results = model.fit()  
print('回归方程的参数:\n{}\n'.format(results.params))  
print('回归结果:\n{}'.format(results.summary()))

回归方程的参数:
const 7.302655
经验要求 1.192419
学历大专 0.159035
学历
本科 2.069740
学历_硕士 5.073880
dtype: float64

回归结果:
OLS Regression Results
==============================================================================
Dep. Variable: 月工资 R-squared: 0.240
Model: OLS Adj. R-squared: 0.235
Method: Least Squares F-statistic: 46.89
Date: Fri, 29 Jun 2018 Prob (F-statistic): 2.35e-26
Time: 20:57:54 Log-Likelihood: -1290.4
No. Observations: 450 AIC: 2589.
Df Residuals: 446 BIC: 2605.
Df Model: 3
Covariance Type: nonrobust
==============================================================================
coef std err t P>|t| [0.025 0.975]
------------------------------------------------------------------------------
const 7.3027 0.569 12.824 0.000 6.184 8.422
经验要求 1.1924 0.116 10.321 0.000 0.965 1.419
学历大专 0.1590 0.567 0.281 0.779 -0.954 1.272
学历
本科 2.0697 0.532 3.891 0.000 1.024 3.115
学历_硕士 5.0739 1.444 3.515 0.000 2.237 7.911
==============================================================================
Omnibus: 97.185 Durbin-Watson: 2.145
Prob(Omnibus): 0.000 Jarque-Bera (JB): 195.765
Skew: 1.166 Prob(JB): 3.09e-43
Kurtosis: 5.236 Cond. No. 1.23e+16
==============================================================================

Warnings:
[1] Standard Errors assume that the covariance matrix of the errors is correctly specified.
[2] The smallest eigenvalue is 4.61e-29. This might indicate that there are
strong multicollinearity problems or that the design matrix is singular.

6 小结

  这是一次很简单的爬虫和数据分析练习,最后回归分析的结果不能令人满意,这与产品经理这一职位较注重工作经验而非学历有关。
  但仅有职位的工作年限要求,并不能真正体现企业对于产品经理的需求,企业对产品经理更准确更详细的要求应在职位描述和工作要求这些文本数据中。其实不仅是产品经理,绝大部分的职位,仅凭结构化的数据,是不能很好地把握企业对于人才的真正需求。结构化数据仅是水面上的冰山,水面下的文本数据则是一个更大的宝矿,需要我们对其进行挖掘。
  如需完善这次的练习项目,除了进一步完善爬虫和统计分析以外,如何进行文本挖掘,也是一个重要的方向。另外,定时爬取职位数据,对数据集进行增删和更新,并生成自动化报告,也将是一个有趣的尝试。

源代码传送门

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

推荐阅读更多精彩内容