【1x00】获取数据 get_51job_data.py 【01x01】构建请求地址 以 Python 职位为例,请求地址如下:
第一页:https://search.51job.com/list/000000,000000,0000,00,9,99,python,2,1.html
第二页:https://search.51job.com/list/000000,000000,0000,00,9,99,python,2,2.html
第三页:https://search.51job.com/list/000000,000000,0000,00,9,99,python,2,3.html
初始化函数:
1 2 3 4 def __init__ (self) : self.base_url = 'https://search.51job.com/list/000000,000000,0000,00,9,99,%s,2,%s.html' self.headers = {'user-agent' : 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.13 Safari/537.36' } self.keyword = input('请输入关键字:' )
【01x02】获取总页数 在页面的下方给出了该职位一共有多少页,使用 Xpath 和正则表达式提取里面的数字,方便后面翻页爬取使用,注意页面编码为 gbk
。
1 2 3 4 5 6 7 8 9 10 def tatal_url (self) : url = self.base_url % (self.keyword, str(1 )) response = requests.get(url=url, headers=self.headers) tree = etree.HTML(response.content.decode('gbk' )) text = tree.xpath("//div[@class='p_in']/span[1]/text()" )[0 ] number = re.findall('[0-9]' , text) number = int('' .join(number)) print('%s职位共有%d页' % (self.keyword, number)) return number
【01x03】提取详情页 URL 定义一个 detail_url()
方法,传入总页数,循环提取每一页职位详情页的 URL,将每一个详情页 URL 传递给 parse_data()
方法,用于解析详情页内的具体职位信息。
提取详情页时有以下几种特殊情况:
特殊情况一: 如果有前程无忧自己公司的职位招聘信息掺杂在里面,他的详情页结构和普通的不一样,页面编码也有差别。
页面示例:https://51rz.51job.com/job.html?jobid=115980776
页面真实数据请求地址类似于:https://coapi.51job.com/job_detail.php?jsoncallback=&key=&sign=params={"jobid":""}
请求地址中的各参数值通过 js 加密:https://js.51jobcdn.com/in/js/2018/coapi/coapi.min.js
特殊情况二: 部分公司有自己的专属页面,此类页面的结构也不同于普通页面
页面示例:http://dali.51ideal.com/jobdetail.html?jobid=121746338
为了规范化,本次爬取将去掉这部分特殊页面,仅爬取 URL 带有 jobs.51job.com
的数据
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 def detail_url (self, number) : for num in range(1 , number+1 ): url = self.base_url % (self.keyword, str(num)) response = requests.get(url=url, headers=self.headers) tree = etree.HTML(response.content.decode('gbk' )) detail_url1 = tree.xpath("//div[@class='dw_table']/div[@class='el']/p/span/a/@href" ) """ 深拷贝一个 url 列表,如果有连续的不满足要求的链接,若直接在原列表里面删除, 则会漏掉一些链接,因为每次删除后的索引已改变,因此在原列表中提取不符合元素 后,在深拷贝的列表里面进行删除。最后深拷贝的列表里面的元素均符合要求。 """ detail_url2 = copy.deepcopy(detail_url1) for url in detail_url1: if 'jobs.51job.com' not in url: detail_url2.remove(url) self.parse_data(detail_url2) print('第%d页数据爬取完毕!' % num) time.sleep(2 ) print('所有数据爬取完毕!' )
【01x04】提取职位信息 解析详情页时页面编码是 gbk
,但是某些页面在解析时仍然会报编码错误,因此使用 try-except
语句捕捉编码错误(UnicodeDecodeError),如果该页面有编码错误则直接 return 结束函数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 def parse_data (self, urls) : """ position: 职位 wages: 工资 region: 地区 experience: 经验 education: 学历 need_people: 招聘人数 publish_date: 发布时间 english: 英语要求 welfare_tags: 福利标签 job_information: 职位信息 work_address: 上班地址 company_name: 公司名称 company_nature: 公司性质 company_scale: 公司规模 company_industry: 公司行业 company_information: 公司信息 """ for url in urls: response = requests.get(url=url, headers=self.headers) try : text = response.content.decode('gbk' ) except UnicodeDecodeError: return tree = etree.HTML(text) """ 提取内容时使用 join 方法将列表转为字符串,而不是直接使用索引取值, 这样做的好处是遇到某些没有的信息直接留空而不会报错 """ position = '' .join(tree.xpath("//div[@class='cn']/h1/text()" )) wages = '' .join(tree.xpath("//div[@class='cn']/strong/text()" )) content = tree.xpath("//div[@class='cn']/p[2]/text()" ) content = [i.strip() for i in content] if content: region = content[0 ] else : region = '' experience = '' .join([i for i in content if '经验' in i]) education = '' .join([i for i in content if i in '本科大专应届生在校生硕士' ]) need_people = '' .join([i for i in content if '招' in i]) publish_date = '' .join([i for i in content if '发布' in i]) english = '' .join([i for i in content if '英语' in i]) welfare_tags = ',' .join(tree.xpath("//div[@class='jtag']/div//text()" )[1 :-2 ]) job_information = '' .join(tree.xpath("//div[@class='bmsg job_msg inbox']/p//text()" )).replace(' ' , '' ) work_address = '' .join(tree.xpath("//div[@class='bmsg inbox']/p//text()" )) company_name = '' .join(tree.xpath("//div[@class='tCompany_sidebar']/div[1]/div[1]/a/p/text()" )) company_nature = '' .join(tree.xpath("//div[@class='tCompany_sidebar']/div[1]/div[2]/p[1]//text()" )) company_scale = '' .join(tree.xpath("//div[@class='tCompany_sidebar']/div[1]/div[2]/p[2]//text()" )) company_industry = '' .join(tree.xpath("//div[@class='tCompany_sidebar']/div[1]/div[2]/p[3]/@title" )) company_information = '' .join(tree.xpath("//div[@class='tmsg inbox']/text()" )) job_data = [position, wages, region, experience, education, need_people, publish_date, english, welfare_tags, job_information, work_address, company_name, company_nature, company_scale, company_industry, company_information] save_mongodb(job_data)
【01x05】保存数据到 MongoDB 指定一个名为 job51_spider
的数据库和一个名为 data
的集合,依次将信息保存至 MongoDB。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 def save_mongodb (data) : client = pymongo.MongoClient(host='localhost' , port=27017 ) db = client.job51_spider collection = db.data save_data = { '职位' : data[0 ], '工资' : data[1 ], '地区' : data[2 ], '经验' : data[3 ], '学历' : data[4 ], '招聘人数' : data[5 ], '发布时间' : data[6 ], '英语要求' : data[7 ], '福利标签' : data[8 ], '职位信息' : data[9 ], '上班地址' : data[10 ], '公司名称' : data[11 ], '公司性质' : data[12 ], '公司规模' : data[13 ], '公司行业' : data[14 ], '公司信息' : data[15 ] } collection.insert_one(save_data)
【2x00】数据可视化 draw_bar_chart.py 【02x01】数据初处理 从 MongoDB 里面读取数据为 DataFrame 对象,本次可视化只分析工资与经验、学历的关系,所以只取这三项,由于获取的数据有些是空白值,因此使用 replace 方法将空白值替换成缺失值(NaN),然后使用 DataFrame 对象的 dropna()
方法删除带有缺失值(NaN)的行。将工资使用 apply
方法,将每个值应用于 wish_data
方法,即对每个值进行清洗。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 def processing_data () : client = pymongo.MongoClient(host='localhost' , port=27017 ) db = client.job51_spider collection = db.data data = pd.DataFrame(list(collection.find())) data = data[['工资' , '经验' , '学历' ]] data.replace(to_replace=r'^\s*$' , value=np.nan, regex=True , inplace=True ) data = data.dropna() data['工资' ] = data['工资' ].apply(wish_data) return data
【02x02】数据清洗 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 def wish_data (wages_old) : """ 数据清洗规则: 分为元/天,千(以上/下)/月,万(以上/下)/月,万(以上/下)/年 若数据是一个区间的,则求其平均值,最后的值统一单位为元/月 """ if '元/天' in wages_old: if '-' in wages_old.split('元' )[0 ]: wages1 = wages_old.split('元' )[0 ].split('-' )[0 ] wages2 = wages_old.split('元' )[0 ].split('-' )[1 ] wages_new = (float(wages2) + float(wages1)) / 2 * 30 else : wages_new = float(wages_old.split('元' )[0 ]) * 30 return wages_new elif '千/月' in wages_old or '千以下/月' in wages_old or '千以上/月' in wages_old: if '-' in wages_old.split('千' )[0 ]: wages1 = wages_old.split('千' )[0 ].split('-' )[0 ] wages2 = wages_old.split('千' )[0 ].split('-' )[1 ] wages_new = (float(wages2) + float(wages1)) / 2 * 1000 else : wages_new = float(wages_old.split('千' )[0 ]) * 1000 return wages_new elif '万/月' in wages_old or '万以下/月' in wages_old or '万以上/月' in wages_old: if '-' in wages_old.split('万' )[0 ]: wages1 = wages_old.split('万' )[0 ].split('-' )[0 ] wages2 = wages_old.split('万' )[0 ].split('-' )[1 ] wages_new = (float(wages2) + float(wages1)) / 2 * 10000 else : wages_new = float(wages_old.split('万' )[0 ]) * 10000 return wages_new elif '万/年' in wages_old or '万以下/年' in wages_old or '万以上/年' in wages_old: if '-' in wages_old.split('万' )[0 ]: wages1 = wages_old.split('万' )[0 ].split('-' )[0 ] wages2 = wages_old.split('万' )[0 ].split('-' )[1 ] wages_new = (float(wages2) + float(wages1)) / 2 * 10000 / 12 else : wages_new = float(wages_old.split('万' )[0 ]) * 10000 / 12 return wages_new
【02x03】绘制经验与平均薪资关系图 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 def wages_experience_chart (data) : wages_experience = data.groupby('经验' ).mean() w = wages_experience['工资' ].index.values e = wages_experience['工资' ].values wages = [w[6 ], w[1 ], w[2 ], w[3 ], w[4 ], w[5 ], w[0 ]] experience = [int(e[6 ]), int(e[1 ]), int(e[2 ]), int(e[3 ]), int(e[4 ]), int(e[5 ]), int(e[0 ])] plt.rcParams['font.sans-serif' ] = ['Microsoft YaHei' ] plt.figure(figsize=(9 , 6 )) x = wages y = experience color = ['#E41A1C' , '#377EB8' , '#4DAF4A' , '#984EA3' , '#FF7F00' , '#FFFF33' , '#A65628' ] plt.bar(x, y, color=color) for a, b in zip(x, y): plt.text(a, b, b, ha='center' , va='bottom' ) plt.title('Python 相关职位经验与平均薪资关系' , fontsize=13 ) plt.xlabel('经验' , fontsize=13 ) plt.ylabel('平均薪资(元 / 月)' , fontsize=13 ) plt.savefig('wages_experience_chart.png' ) plt.show()
【02x04】绘制学历与平均薪资关系图 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 def wages_education_chart (data) : wages_education = data.groupby('学历' ).mean() wages = wages_education['工资' ].index.values education = [int(i) for i in wages_education['工资' ].values] plt.rcParams['font.sans-serif' ] = ['Microsoft YaHei' ] plt.figure(figsize=(9 , 6 )) x = wages y = education color = ['#E41A1C' , '#377EB8' , '#4DAF4A' ] plt.bar(x, y, color=color) for a, b in zip(x, y): plt.text(a, b, b, ha='center' , va='bottom' ) plt.title('Python 相关职位学历与平均薪资关系' , fontsize=13 ) plt.xlabel('学历' , fontsize=13 ) plt.ylabel('平均薪资(元 / 月)' , fontsize=13 ) plt.savefig('wages_education_chart.png' ) plt.show()
【3x00】数据截图 一共有 34009 条数据,完整数据已放在 github,可自行下载。
MongoDB:
CSV 文件:
JSON 文件:
关系图:
【4x00】完整代码 完整代码地址(点亮 star 有 buff 加成):https://github.com/TRHX/Python3-Spider-Practice/tree/master/51job
其他爬虫实战代码合集(持续更新):https://github.com/TRHX/Python3-Spider-Practice
爬虫实战专栏(持续更新):https://itrhx.blog.csdn.net/article/category/9351278