爬虫实战-直聘-爬虫岗位分析

爬虫岗位数据分析

一 数据集

# boss直聘网址:https://www.zhipin.com/web/geek/job?query=%E7%88%AC%E8%99%AB&city=100010000&page=10,通过这个网址,我们看到页面呈现如下图。当前工作岗位导航页面感兴趣的标签的主要包含:提供岗位所在的城市,薪资标准,学历要求以及工作经验。

在这里插入图片描述

# 每个岗位都有详情页面,点击后来到二级页面即工作岗位详情页面。如下图,详情页面主要包含的信息是岗位所要求掌握的技术。

在这里插入图片描述

# 现在收集的标签主要包含三类:一类是岗位要求包含学历、经验以及技术要求3个标签;第二类是工薪福利主要包含期望的城市以及薪资水平2个标签;最后一类是岗位详情链接。其中技术要求在二级页面,其他标签均在一级页面。

二 抓包分析

2-1 DEVTOOLS抓包分析

# 通过浏览器的渲染过程可知,页面呈现的数据要么在原始的html文档上,要么通过XML架构获取目标数据然后导入document。首先判断是数据否在原始的html文件上,即原始数据通过静态请求加载而成。原始的html文档在devtools网络面板下的文档分类栏目中的文档中,要搜索的包对应当前的统一资源定位符,即名称为job/的包。通过搜索对比页面呈现的数据,如下图,比如工薪“20-40k",发现首页的数据不在原始的html上,那么排除静态请求的可能,页面呈现的数据由动态请求获取,动态请求的数据在文档分类栏目中的Fetch/xhr栏目中。

在这里插入图片描述

# 在fetch/xhr栏目下有众多请求,那么目标数据会在哪一个请求中?有一个技巧可以快速筛选出目标数据可能存在的请求包:通过分类栏目上的过滤器检索跟当前页面有关的关键字,比如工作、职位,那么这里的关键字就是工作的英文词,job,过滤功能的实施对象是当前分类栏目中请求包名称。那么通过最终搜素对比页面呈现的数据和包中响应报文的数据,结果锁定名为joblist_json的包。

在这里插入图片描述

在这里插入图片描述

2-2 报文分析

2-2-1 请求行
# 首先观测请求行:请求行中,记录三个重要的数据:请求方式 、http协议以及目标资源路径。请求行在devtools中的位置在包中的标头中即常规栏目下。但devtools只呈现两个数据即:请求方式以及目标网址,详细情况如下图1,由于是get请求,且统一资源定位符存在查询参数,所以查询参数会被格式化会呈现在载荷中,如下图2中中的查询字符串字段query之类的。报头中的http协议又分为两个版本:http1和http2,一般使用的协议版本为http1,协议版本对使用哪一种通信架构模拟请求有决定性的指导作用,例如构造http请求可以使用requests库,而构造htt2则需要使用到第三方库httx。

在这里插入图片描述

在这里插入图片描述

2-2-2 请求头
所谓请求头就是指客户请求的报头,主要包括三个重要的报头:用户代理user_agent、传送的数据类型content_type、来源referer以及身份码cookie,其中user_agent、content_type以及referer是默认设置的报头。不同通信架构构造请求包时,默认设置的content_type类型不同,如通后端没有做json序列化,那么后端有可能拿不到请求的数据,这也表明构造请求的对象并非正常客户。cookie是身份码,主要验证发送的请求是否符合后端要求,如果没有携带正确的cookie,后端就会返回错误的数据。cookie有两个来源,一是由后端响应直接要求客户端设置cookie,另外勒则是前端通过js设置cookie。当然,如果后端想要提高数据传送的安全度,也可以在前端设置其他待校验的报头,那么这些报头的功能就与cookie相同了。如下图中的标序为1和3的报头就是前端额外创建的报头。

在这里插入图片描述

2-2-3 响应头
# 响应包中有两个最重要的数据,一个是set_cookie,这个报头的作用就是让前端设置cookie,下次访问服务器的时候需要带上这个身份码,另一个数据自然是我们想要的报文。当然后端了可以将身份标示码放在报头中或者报文中,这种校验方式通常出现在登陆操作中。响应头在devtools的位置在包中标头的响应标头中,那么在存储目标数据的响应报头(如下图)中设置了三个cookie,包括__zp_sseed__、__zp_sname__、__zp_sts__。

在这里插入图片描述

2-2 模拟请求

2-2-1 fiddler模拟请求
# 为了测试请求包中是否携带了动态的校验数据,需要临时模拟请求测试请求包是否固定,具体操作如下:
# 打开fiddler,重新刷新网页,等待页面出现目标数据后,右键fiddler中的URL字段,选择Search in Column字段,键入目标数据所在的包名,搜索到指定的包后,重新对比目标数据,确认该包为目标数据所在的包后,点击右侧的composer栏目,然后将左侧的目标数据所在的包拖动到右侧功能栏目中,fiddler会自动将包中的请求头和请求数据格式化,结果如下图2。为了更好的观测请求包能否能否得到正常的反馈,先将左侧的数据包清空。清空后点击右侧的execute按钮,发送请求。结果如下图3,通过检测响应状态以及响应报文发现:模拟请求构造的包以及浏览器发送的包得到的响应没有区别,这说明网站没有设置动态校验的数据或者待校验数据的有效性颇长。

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

2-2-2 python模拟请求
# python中模拟通信最基础的架构为requests库,requests库支持的通信协议为http1。我们可以借助这个工具去模拟浏览器伪造请求包。
# 通过分析报文中的请求行,我们知晓了目标资源的路径,也就是请求网址的值,首先记录变量名为url(代码如下),另外还包含请求方法为post,那么需要用到requests库的post方法,传入参数url,以及请求报文data,则会构造并发送一个简单的请求,其中post提交的请求报文数据可以从DevTools请求中的载荷中快速获取,需要json格式构造,并在传参时json序列化。由于没有对请求的报头做设置,该请求包中的报头可能会遵循requests库的默认设置,这样模拟的请求与浏览器发出的请求差异较大,如果后端做了相应的报头校验,那么该请求就无法被正常响应,如下图就是没有设置报头所发出的结果。
url = 'https://www.zhipin.com/wapi/zpgeek/search/joblist.json?scene=1&query=%E7%88%AC%E8%99%AB&city=101250100&experience=&degree=&industry=&scale=&stage=&position=&salary=&multiBusinessDistrict=&page=1&pageSize=30'
data = {
    "scene": 1,
    "query": "爬虫",
    "city": "101250100",
    "experience": "",
    "degree": "",
    "industry": "",
    "scale": "",
    "stage":"", 
    "position":"", 
    "salary": "",
    "multiBusinessDistrict":"" ,
    "page": 1,
    "pageSize": 30
}
res = requests.post(url=url,data=data)
# 如下图使用res.status_code观测后端服务器是否正常响应,结果响应状态码为200,响应正常;接下来使用res.headers.get('Content-Type')观测响应类型,如下图响应类型为Application/json,也就是说响应类型为json,如此我们可以使用res.json()获取响应结果,如下响应结果提示:我们的访问行为异常,这恰好证明了我们前面说的,缺失的报头设置无法获取服务器正常的响应反馈。

在这里插入图片描述

requests的默认报头设置

# 为何requests架构中默认设置的报头无法获取到服务器的正常反馈,通过对比使用requests库构造的无法得到正常反馈的请求报头以及fiddler模拟的能得到正常反馈的请求的报头,我们获取到启发。通过对比两者的报头,我们发现一共有四个不一致:首先是请求的报文类型不同,requests库没有对该报头做任意设置,而浏览器的对报文类型报头的设置为application/x-www-form-urlencoded;其次是是用户代理不一致,reuquests库对报头的设置默认为使用的编程语言加上requests字符串再加上requests库的版本号,而浏览器的用户代理头为系统语言加上谷歌浏览器的版本号;再则是来源不一也就是报头referer的设置不同,reuqests库默认不对该报头做任何设置,而浏览器中referer的默认值为访问该网站的情况下的前一个网页的地址;最后则是身份校验码不同,requests库同样对该报头没有做任何设置,而浏览器对cookie的设置为服务端返回的身份校验码。

在这里插入图片描述

# 矫正requests库对报头的默认设置后,在此发送请求,检测结果如下图,我们发现,报头已经被设置好,响应报文与原始数据并无差别,请求得到正常反馈。

在这里插入图片描述

三 报文过滤

# 通过如上的报文分析,响应的结果为json数据,而在python中处理过滤json数据的第三方库为jsonpath,那么,接下来,我们将详细的介绍如何使用jsonpath库过滤筛选以及构建我们想要的数据集,总共六个标签:学历、经验、技术、城市、薪资以及工作详情链接。

3-1 标签路径分析

# 将DevTools中目标报文中的json数据复制张贴到vscode的任意空json文件中,右键点击格式化文档,即可展开json数据,关于岗位详情全部在jobList路径下,如下图

在这里插入图片描述

// 在jobList下可以找到五个感兴趣的标签如下,分别是薪资、工作技能、工作经验、学历要求以及岗位地址
"salaryDesc": "8-12K",
"skills": [
	"Python",
	"计算机相关专业",
	"大数据处理经验"
],
"jobExperience": "3-5年",
"jobDegree": "本科",
"cityName": "长沙",
// 那么还差一个岗位详情链接,按正常的逻辑来说,岗位详情链接应该也在json文件中,那么有一种可以就是,网址链接是由json对象中的某些标签拼接而来,那么由那些标签拼凑而来,通过对比chrome中岗位详情页面的网址链接以及json文件的数据,我们能获取启发,如下图,我们发现岗位详情链接是由json对象中的三个标签构成:首先是lid标签不变,其次是每个岗位的securityId和encryjobId标签。到此,我们已经明晰六大标签在json对象中的位置。此外,我们还需要对二级岗位详情链接进行搜索,获取岗位对职工的详细的技术要求。

在这里插入图片描述

# 通过DevTools对二级请求的抓包分析,如下图,岗位详情页面的数据就在原始的html文档上。

在这里插入图片描述

3-2 jsonpath 过滤

# 为了方便断点调试,构建json解析函数,内部逻辑为jsonpath解析json对象。详细代码如下图

在这里插入图片描述

# 通过函数get_json_data以及断点技术,我们能轻易获取六大标签的json路径,构建的相关jsonpath表达式如下:
# 获取根目录,数据集为list,获取每个列表中数据需要使用..
root_data = get_json_data(res,'$..jobList')[0]
# 薪资
salary = get_json_data(root_data,'$..salaryDesc')
# 技能
skills = get_json_data(root_data,'$..skills')
# 经验
experience = get_json_data(root_data,'$..jobExperience')
# 学历
studyDegree = get_json_data(root_data,'$..jobDegree')
# 地址
cityName = get_json_data(root_data,'$..cityName')
# 地址拼接
create_url = lambda x,y,z:f'https://www.zhipin.com/job_detail/{x}.html?lid={y}.search.1&securityId={z}'
lid = get_json_data(root_data,'$..encryptJobId')
encryptJobId = get_json_data(root_data,'$..encryptJobId')
securityId = get_json_data(root_data,'$..securityId')
job_url = [create_url(encryptJobId[i],lid[i],securityId[i]) for i in range(len(encryptJobId))]
# 经过反复检测调整jsonpath表达式,获取六大标签集结果如下

在这里插入图片描述

四 数据入库

# 由于获取的数据类型为json,类似技能以及岗位地址有更为细致的划分,为了方便数据入仓以及保证岗位信息的完整性方便后续搜索,我们更倾向于将json数据全部存储到mongo中。python中连接操控数据库的第三方库为pymongo,以下将详细介绍pymongo在爬虫入库中的详细使用。

4.1 pymongo连接配置

from pymongo import MongoClient
client = MongoClient('mongo://localhost:27017/')
# 创数据库
db = client.zhipin_data
# 创建数据集
collection = db.jobDetails

4.2 pymongo插入

# 为了方便调试,构建函数to_db,内部逻辑为collection.insert_one,详细代码如下
def to_db(data:dict):
    res = collection.insert_one(data)
    # 数据插入成功后,mongo会为插入的数据集配置id,通过判断该id判断是否安装成功
    if res.inserted_id:
        return True
    else:
        return False

4.3 数据入库测试

import os
# 尝试将保存好的json文档中的岗位数据入库
with open(os.path.dirname(__file__)+'/response.json','r', encoding='utf-8') as f:
    data = json.loads(f.read())
    res = to_db(data)
    res
# 测试结果如下图

在这里插入图片描述

五 爬虫架构设计

# 目标明晰,之前都是基于本地城市(比如长沙)的岗位的请求分析,而我们想要获取的岗位数据是全国性的。基于此重新分析请求,我们发现请求方式略微发生改变,由post变为get请求。
# 由于在直聘网址搜索全国的爬虫工程师岗位只有10页数据,每页数据30个岗位详情,也就是是总共只有300个岗位信息,如此少的数据量,使用多机协程操作如同杀鸡使用牛刀,所用直接使用单线程操作,访问一次,随机停顿3~5秒即可。虽说数据收集的逻辑简单,但为了更好的维护这个爬虫,我们还是使用面向对象的思想去构建该爬虫,直聘爬虫逻辑大致分为:调度、下载、解析、存储以及异常捕获。

5.0 初始化资源

import json
from multiprocessing import connection
import requests
from pymongo import MongoClient

class zhipinSpier(object):
    def __init__(self):
        # 目标网址
        self.target_url = lambda page:f'https://www.zhipin.com/wapi/zpgeek/search/joblist.json?scene=1&query=%E7%88%AC%E8%99%AB&city=101250100&experience=&degree=&industry=&scale=&stage=&position=&salary=&multiBusinessDistrict=&page={page}&pageSize=30'
        # 请求报头
        self.headers = {
            'content-type':'application/x-www-form-urlencoded',
            'user-agent':'Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.0.0 Safari/537.36',
            'referer':'https://www.seeyii.com/fintech/nonCo/index.html?from_wecom=1',
            'cookie':'lastCity=101250100; wd_guid=05a77392-b1cc-47e8-b78f-ad34a60b5749; historyState=state; _bl_uid=dLl2k90g8Xd54yidv9zkmyqn72zO; wt2=DP45SCfEruCrEUmKeAMNpAyJ-3fuC32A0iRPoXe-GvowDD0KzdGLwp1Scxd3ie4dVfuNfoCDAvG8QNSyt3MKwhA~~; wbg=0; __g=-; Hm_lvt_194df3105ad7148dcf2b98a91b5e727a=1665731428,1665749359,1665812532,1666010364; Hm_lpvt_194df3105ad7148dcf2b98a91b5e727a=1666010369; __zp_stoken__=c1b6eKUUFQwwHYE5raQBiRQkaJDhAawBCVCEUfjUKZnYQF2AmSBwLQ1EmNyApDGtANl1NR18MTBxIb0QNZUpveQxDdg4bVlw7eGRdQlRjEXoOOiV2R1xzBWgYL1JuFDondGR%2FTi0GZ34KIUU%3D; __c=1666010364; __l=l=%2Fwww.zhipin.com%2Fweb%2Fgeek%2Fjob%3Fquery%3D%25E7%2588%25AC%25E8%2599%25AB%26city%3D101250100&r=&g=&s=3&friend_source=0&s=3&friend_source=0; __a=21263788.1665391626.1665812533.1666010364.31.5.6.31; geek_zp_token=V1RNwjEeb5315sVtRvyRsYKCOw7T7VxC0~'
        }
        # 请求阈值
        self.request_frequence = [3,5]
        # 数据库配置
        client = MongoClient('mongo://localhost:27017/')
        db = client.zhipin
        self.collection = db.jobDetails
        pass

5.1 调度器设计

# 首先设计调度器,调度器的功能主要包含构造网址任务迭代器以及调用下载器
# 使用生成器,减少内存资源消耗
def scheduler(self)->None:
    """create url_list and call function named download"""
    for page in range(1,11):
        self.page = page
        self.download(self.target_url(page))
        time.sleep(random.randint(*self.request_frequence))
        pass

5.2 下载器设计

# 设计下载器,发送请求获取报文,并调用
# 设计异常报错机制,打印堆栈错误以及错误的请求页码
def download(self,url:str)->None:
    """send a request to remote host to get response and call function named parse"""
    try:
        res = requests.get(url=url)
        self.parse(res)
    except Exception as e:
        warning(e)
        pass

5.3 解析器设计

# 请求头类型判断,如果为json格式,获取报文内容
def parse(self,res:requests.Response)->None:
        """to parse the response and call function called to_mongo"""
        if 'json' in res.headers.get('Content-Type'):
            data = res.json()
            self.to_mongo(data)
        else:
            warning('source from response if not a json !')
        pass

5.4 存储器设计

# 数据入库,数据入库后是否分配id,如果没有,发出警告,以及错误页码
def to_mongo(self,data:json):
    res = self.collection.insert_one(data)
    if not res.inserted_id:
        warning('the error occured when inserting the mongodb !')
        pass

5.5 异常捕获设计

# 发生错误时请求页码
# 发生错误时的请求状态
# 发生错误时的解析状态
# 发生错误时的入库状态
def throw_waring(self):
    warning(f'error happend to sending the reuqest,failed page is {self.page}!')

六 数据捕获结果

6-1 捕获数据存在的问题

# 通过以上设计的爬虫架构抓取的数据集如下,捕获的第一个数据集成功,其他九个响应数据集不正常

在这里插入图片描述

# 通过反复比对数据,发现cookie字段中的__zp_stoken__会发生变化。经过堆栈跟踪,__zp_stoken__字段由全局变量ABC对象z方法生成,定位到ABC对象上,发现ABC对象所在的文档经过了重度混淆,包括变量名、变量值以及代码逻辑的混淆。解决方法有两种,一种则是直接扣取代码,通过断点调试以及堆栈回溯不断扣取原代码,由于代码所在的文档大概有两万行,扣取代码必然要花费很长的时间;而且我们要获取的数据量总共也就十页,所以我们直接祭出selenium神器。

6-2 selenium替换requests

import time
import random
import json
from logging import warning
from pymongo import MongoClient

from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.wait import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.common.keys import Keys
from selenium.webdriver import ActionChains
from lxml import etree



class zhipinSpier(object):
    def __init__(self):
        # 目标网址
        target_url = 'https://www.zhipin.com/web/geek/job?query=%E7%88%AC%E8%99%AB&city=100010000'
        #==============================> 配置浏览器 <=======================================
        options = webdriver.ChromeOptions()
        #干掉webdriver属性
        options.add_argument('--disable-blink-fetures=AutomationControlled')
        #干掉提示
        options.add_experimental_option('excludeSwitches',['enable-automation'])
        self.driver = webdriver.Chrome(options=options)
        self.driver.get(target_url)
        # 请求阈值
        self.request_frequence = [3, 5]
        # 数据库配置
        client = MongoClient('mongodb://localhost:27017/')
        db = client.zhipin
        self.collection = db.jobDetails
        pass

    def __call__(self):
        self.scheduler()

    def scheduler(self) -> None:
        """create url_list and call function named download"""
        for page in range(1, 11):
            self.page = page
            self.download(page)
            # time.sleep(random.randint(*self.request_frequence))
        pass

    def download(self, url: str) -> None:
        """send a request to remote host to get response and call function named parse"""
        try:
            if self.page !=1:
                father_expression = '//div[@class="options-pages"]/a'
                childr_expression = '//div[@class="options-pages"]/a[@class="selected"]'
                childres = self.driver.find_elements(By.XPATH,father_expression)
                child_a = self.driver.find_element(By.XPATH,childr_expression)
                order = childres.index(child_a)+1
                childres[order].click()
        except Exception as e:
            self.throw_waring()
            warning(e)
        self.parse()
        pass

    def parse(self) -> None:
        """to parse the response and call function called to_mongo"""
        time.sleep(10)
        WebDriverWait(self.driver,10).until(EC.presence_of_element_located((By.CLASS_NAME,'job-list-box')))
        # 获取页面元素
        html = etree.HTML(self.driver.page_source)
        details_div = html.xpath('//ul[@class="job-list-box"]/li')
        for details in details_div:
            job_detail = details[0].xpath('./a')[0]
            job_href = details[0].xpath('./a/@href')[0]
            city = job_detail.xpath('./div[1]/span[2]//text()')[0]
            salary = job_detail.xpath('./div[2]/span//text()')[0]
            work_year = job_detail.xpath('./div[2]/ul/li[1]//text()')[0]
            education = job_detail.xpath('./div[2]/ul/li[2]//text()')[0]
            welfare = details[1].xpath('./div//text()')
            skill = details[1].xpath('./ul//text()')
            skill
            item = dict(job_href=job_href,city=city,salary=salary,work_year=work_year,education=education,welfare=welfare,skill=skill)
            self.to_mongo(item)
        pass

    def to_mongo(self, data: json):
        res = self.collection.insert_one(data)
        if not res.inserted_id:
            self.throw_waring()
            warning('the error occured when inserting the mongodb !')
        pass

    def throw_waring(self):
        warning(
            f'error happend to sending the reuqest,failed page is {self.page}!')


if __name__ == '__main__':
    task = zhipinSpier()
    task()

七 数据分析

# 为了更好的观测爬虫工程师这个岗位的现状以及发展前景,我们将对爬虫工程师的岗位薪酬分布、岗位数量地域分布、平均薪酬地域分布、薪酬学历分布、薪酬经验分布以及岗位技术要求七个方向进行深度分析。

7-0 数据分析的代码逻辑

# 数据分析的大致逻辑分为三歩:首先使用pymongo从mongodb中抽取数据;其次使用jsonpath完成数据清洗;最后使用pycharts完成作图分析。第一步,数据抽取阶段是是完全复用的,这部分逻辑放入初始化拦截器中即可;而第二步数据清洗阶段根据指定的jsonpath表达式获取指定的数据集,构造函数即可;第三歩,构图阶段,如果构造相同的图像则会存在复用,七个分析方向中使用构造相同图像的研究方向为:岗位薪酬分布会单独使用箱型图;岗位数量地域分布以及平均薪酬地域分布可以使用柱状图以及玫瑰图;薪酬学历分布以及薪酬经验分布复用柱状图以及玫瑰图;岗位技术要求可以单独使用词云图分析。
初始化操作
from cProfile import label
import math
import os
import re
from turtle import title
from jsonpath import jsonpath
from pymongo import MongoClient
from pyecharts import options as opts
from pyecharts.charts import Pie
from pyecharts.globals import ThemeType
from pyecharts.charts import Bar
from pyecharts.charts import WordCloud
from pyecharts.globals import SymbolType
from pyecharts.charts import Map
from pyecharts.charts import Boxplot


class visit_data(object):
    def __init__(self):
        # 连接数据库
        client = MongoClient('mongodb://localhost:27017/')
        db = client.zhipin
        collection = db.jobDetails
        # 创建json数据容器
        self.data = []
        self.init_data_from_mongo(collection)
        self.file = lambda file_name: os.path.join(
            os.path.dirname(__file__), file_name+'.html')
        pass
数据清洗
# 利用jsonpath重新清洗岗位json数据
def init_data_from_mongo(self, collection) -> None:
    for i in tuple(collection.find()):
        if '月' in i['education']:
            continue
        # 清洗字段city
        i['city'] = i['city'].split('·')[0]
        # 清洗字段salary
        if 'K' not in i['salary']:
            continue
        salary_data = [eval(i) for i in re.findall('\d+', i['salary'])]
        i['salary'] = sum(salary_data[:2]) / \
            2 if len(salary_data) >= 2 else sum(salary_data)
        if '年' not in i['work_year']:
            i['work_year'] = '0'
        work_year = [eval(i) for i in re.findall('\d+', i['work_year'])]
        i['work_year'] = sum(work_year[:2]) / \
            2 if len(work_year) >= 2 else sum(work_year)
        self.data.append(i)        
pycharts作图
def draw_pie(self, label: list, values: list, title: str, html_name: str) -> None:
    item = tuple(zip(label, values))
    data = sorted(item, key=lambda x: x[1], reverse=True)
    if len(data) >= 5:
        label = [i[0] for i in data][:5]
        values = [i[1] for i in data][:5]
        label.append('其他')
        values.append(sum([i[1] for i in data][5:]))
    else:
        label = [i[0] for i in data]
        values = [i[1] for i in data]
    c = (
        Pie()
        .add("", [list(z) for z in zip(label, values)])
        .set_global_opts(title_opts=opts.TitleOpts(title))
        # 值得一提的是,{d}%为百分比
        .set_series_opts(label_opts=opts.LabelOpts(formatter="{b}:{c} {d}%"))
    ).render(self.file(html_name))

def draw_bar(self, label: list, values: list, html_name: str, y_name='') -> None:
    item = tuple(zip(label, values))
    data = sorted(item, key=lambda x: x[1], reverse=True)
    label = [i[0] for i in data][:20]
    values = [i[1] for i in data][:20]
    label.append('其他')
    other = [i[1] for i in data][20:]
    values.append(math.ceil(sum(other)/len(other)))
    bar = (
        Bar(init_opts=opts.InitOpts(theme=ThemeType.LIGHT))
        .add_xaxis(label)
        .add_yaxis(y_name, values)
        .set_global_opts(title_opts=opts.TitleOpts(title=title))
    ).render(self.file(html_name))

def draw_raw(self, label: list, values: list, html_name: str, title='') -> None:
    values = [math.floor(i) for i in values]
    item = tuple(zip(label, values))
    data = sorted(item, key=lambda x: x[1], reverse=True)
    label = [i[0] for i in data][:10]
    values = [i[1] for i in data][:10]
    label.append('其他')
    values.append(sum([i[1] for i in data][10:]))
    c = (
        Pie()
        .add(
            "",
            [list(z) for z in zip(label, values)],
            radius=["30%", "75%"],
            center=["50%", "50%"],
            rosetype="radius",
            label_opts=opts.LabelOpts(is_show=False),
        )
        .set_global_opts(title_opts=opts.TitleOpts(title=title))
        # 值得一提的是,{d}%为百分比
        .set_series_opts(label_opts=opts.LabelOpts(formatter="{b}:{c} {d}%"))
    ).render(self.file(html_name))

def draw_wordCloud(self, label: list, values: list, html_name: str, title='') -> None:
    # 添加词频数据
    words = [
        (label[i], values[i]) for i in range(len(label))
    ]
    # WordCloud模块,链式调用配置,最终生成html文件
    c = (
        WordCloud()
        .add("", words, word_size_range=[20, 100], shape=SymbolType.DIAMOND)
        .set_global_opts(title_opts=opts.TitleOpts(title=title))
    ).render(self.file(html_name))

def draw_box(self, values: list, html_name: str, title='', label=['']) -> None:
    v1 = [
        values
    ]
    if type(v1[0][0]) == list:
        v1 = [i for i in v1[0]]
    c = Boxplot({"Theme":ThemeType.ESSOS})
    c.add_xaxis(label)
    c.add_yaxis('',c.prepare_data(v1))
    c.set_global_opts(title_opts=opts.TitleOpts(title=title))
    c.render(self.file(html_name))

def draw_map(self, label: list, values: list, html_name: str) -> None:
    ultraman = [
        [label[i], values[i]] for i in range(len(label))
    ]
    Map().add(
        series_name="岗位数",
        data_pair=ultraman,
        maptype="china",
        # 是否默认选中,默认为True
        is_selected=True,
        # 是否启用鼠标滚轮缩放和拖动平移,默认为True
        is_roam=True,
        # 是否显示图形标记,默认为True
        is_map_symbol_show=False,
        # 图元样式配置
        itemstyle_opts={
            # 常规显示
            "normal": {"areaColor": "white", "borderColor": "red"},
            # 强调颜色
            "emphasis": {"areaColor": "pink"}
        }
    ).set_global_opts(
        # 设置标题
        title_opts=opts.TitleOpts(title="中国地图"),
        # 设置标准显示
        visualmap_opts=opts.VisualMapOpts(
            max_=1000, is_piecewise=False)
    ).set_series_opts(
        # 标签名称显示,默认为True
        label_opts=opts.LabelOpts(is_show=True, color="blue")
    ).render(self.file(html_name))

def run(self):
    def salary_aly():
        values = jsonpath(self.data, '$..salary')
        self.draw_box(values, '月薪分布分析-箱型图')

    def jobNum_area_aly():
        # 柱形图+饼图
        city_list = jsonpath(self.data, '$..city')
        label = list(set(city_list))
        values = [city_list.count(i) for i in label]
        self.draw_bar(label, values, '岗位数量地域分布分析-柱形图')
        self.draw_raw(label, values, '岗位数量地域分布分析-饼图')

    def salary_area_aly():
        # 柱形图+箱形图
        city_list = jsonpath(self.data, '$..city')
        salary_list = jsonpath(self.data, '$..salary')
        item = {}
        for i in range(len(city_list)):
            if city_list[i] not in item:
                item[city_list[i]] = [salary_list[i]]
            else:
                item[city_list[i]].append(salary_list[i])
        label = list(item.keys())
        labal_values = list(item.values())
        values = [math.ceil(sum(i)/len(i)) for i in labal_values]
        self.draw_bar(label, values, '平均月薪地域分布分析-柱形图')
        # 城市平均月薪箱型度
        self.draw_box(values, '平均月薪地域分布分析-箱型图')

    def skill_aly():
        # 词云
        job_skill_list = jsonpath(self.data, '$..skill')
        word_list = []
        for i in job_skill_list:
            world_list = word_list.extend(i)
        word_set = list(set(word_list))
        frequency_list = []
        for i in word_set:
            frequency_list.append(word_list.count(i))
        self.draw_wordCloud(word_set, frequency_list, '技能-词云')

    def education_salary_aly():
        label_list = jsonpath(self.data, '$..education')
        label = list(set(label_list))
        edcucation_count = []
        salary_count = []
        for i in label:
            edcucation_count.append(label_list.count(i))
            data = jsonpath(self.data, f'$.[?(@.education=="{i}")]')
            salary_count.append(jsonpath(data, '$..salary'))
        self.draw_raw(label, edcucation_count, '学历占比-玫瑰图')
        self.draw_box(salary_count, '薪资学历分布-箱型图', '', label)

    def experience_salary_aly():
        label_list = jsonpath(self.data, '$..work_year')
        label = list(set(label_list))
        experience_count = []
        salary_count = []
        for i in label:
            experience_count.append(label_list.count(i))
            data = jsonpath(self.data, f'$.[?(@.work_year=={i})]')
            salary_count.append(jsonpath(data, '$..salary'))
        label = [str(i)+'年' for i in label]
        self.draw_raw(label, experience_count, '经验占比-玫瑰图')
        self.draw_box(salary_count, '薪资经验分布-箱型图', '', label)
    salary_aly()
    jobNum_area_aly()
    salary_area_aly()
    education_salary_aly()
    experience_salary_aly()
    skill_aly()

7-1 岗位薪酬分布分析

在这里插入图片描述

# 对于一组数据集的统计描述一般从代表值以及分布特征两个方面进行分析。首先是代表值,所谓代表值就是一组数据集中最具有代表性的特征值,由于中位数是处于一组数据集中最中间的数,在一定程度上它能反应这组数据集的集中度,所以特征值可以用中位数表示;对于数据分布特征,我们可以用箱型图来具象数据分布特征,箱型图用三条线分割整个数据集合,这三根线分别是:下四分位数、中位数以及上四分位数。
# 如上图,从整体来看:前尾短,后尾长,箱体主要在下四分位数9.5k到上四分位数20k之间,爬虫工程师岗位薪资数据主要分布在中位数12.5k左右,这就是爬虫工程师普遍能拿到的工资。从数据集的分布来看:有50%的岗位能够提供的薪资在下四分位数9.5k左右;另有50%的岗位能够提供的薪资在上四分位数20k左右。

7-2 岗位数量地域分布分析

在这里插入图片描述

# 如上图柱形图,提供敢为数量最多的前五个城市为:北京、上海、深圳、杭州以及武汉,分别提供65、30、29、27以及14个岗位,分别占据总岗位300中的22.73%、10.49%、10.14%、9.44%以及4.89%。

7-3 平均薪酬地域分布分析

在这里插入图片描述

# 从如上图平均薪资地域分布来看,提供薪资最高的前五个城市为北京、上海、汉州、深圳以及佛山,这五个城市分别提供的平均薪资为21k、20k、19k、19k以及18k。结合岗位数量地域分布分析来看,提供薪资数量最高的城市大部分都是提供岗位数量最多的城市,这些数据反映了,提供薪资最高的前五个城市对爬虫工程师的需求强度要高于其他城市。

7-4 薪酬学历分布分析

在这里插入图片描述

# 如上玫瑰图,在整个爬虫工程师岗位数量学历占比来看,学历为本科的求职者占据主导优势,主要表现为:爬虫工程师岗位对求职者的学历要求普遍为60.49%,对学历要求为本科的最多占据60.49%,学历要求为大专的次之为28.67%,不限学历要求的岗位数量仅有10.14%。
# 如上箱型图,由于不限学历包含情况较多,所以主要对比学历为大专以及本科的薪资数据分布特征。对比两者的箱型图主体,大专的普遍工资在中位数11.25k左右,下上四分位数分别为9k以及16.5k,而本科学历的相对应的数据特征均要优于大专,本科学历的普遍工资在中位数12.5k左右,下上四分位数分别为8.25k和18k。从箱型图的拖尾来看,本科学历薪资上下限均要远高于大专,

7-5 薪酬经验分布分析

在这里插入图片描述

# 如上玫瑰图,爬虫工程岗位对工作经验要求颇高,其中要求工作经验有两年的占据总量的43.019%,要求工作经验有四年的占据总量的32.87%,两者共同占据了爬虫岗位市场的76%的份额。
# 如上箱型图,随着时间的推移,箱型图的各项数据大致都得到优化,其中薪资下限呈现明显的上升趋势,从2k增长到10k,这在一定程度上说明薪资会随着工作经验累积而稳步增长。

7-6 岗位技术要求分析

在这里插入图片描述

# 如上词云,从技能中要求的编程语言来看,python占据主导优势;从框架要求来看,主要是需要掌握scrapy;从数据库查询与存储来看。需要掌握的数据库包括mysql,mongodb以及redis。

八 待完善的部分

8-1 queston1

target_urlhttps://www.zhipin.com/web/geek/job?query=%E7%88%AC%E8%99%AB&city=100010000&page=5
加密字段cookie======>__zp_stoken__
混淆技术True
加密方式未知
# 解决方法
# 加密函数导出框架
# 加密接口导出框架
# ja3
# http1和http2
# content_type
# 二次重载
# 自定义均衡指标设置偏好
  • 19
    点赞
  • 68
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值