Python自动化生成山东厂长薪酬调查报告:从爬虫到PDF全流程 📅 发布时间:2026/9/18 16:53:27 👁 浏览次数: 简介《2022年山东省地区厂长职位薪酬调查报告》是一份聚焦制造业核心管理岗位薪资数据的专项资料适用于企业管理者、人力资源从业者、行业研究者和期望进入厂长岗位的求职者。报告基于外资、合资、本土私营及国有四类企业的样本给出厂长薪酬各分位值外资企业P50约56.9万元私营企业约61.3万元国有企业P50约134.1万元直观反映了不同所有制企业在薪酬策略与人才吸引力上的差异。同时报告还梳理了该职位的薪酬区间跨度与增长趋势可为读者提供横向对比的依据也可用于设计薪酬带宽、制定激励方案或设定个人职业发展目标。资源封装为一个PDF文件体积仅223KB内容结构清晰、数据密集便于快速查阅与分析。目前已有74人学习下载是一份实用的小体量薪酬调研工具。1. 为什么要把一份厂长薪酬报告做成自动化流水线每年一季度HR、猎头和行业分析师都会盯着上一年度的薪酬调查报告。如果目标是“2022年山东省地区厂长职位薪酬调查报告”市场上常见做法是人力咨询公司发布PDF几百页厚数据来自企业问卷周期长、样本量有限。作为IT从业者我们完全可以自己搭一套数据流水线从招聘网站抓取公开的厂长岗位薪酬数据用Pandas做清洗与分析最后自动渲染成PDF。这样不仅能在几分钟内得到一份可量化的地区薪酬参考还能在下一年度一键复现。下面记录的是我惯用的工程实现路径从爬虫到PDF全链路适合有Python基础、想用数据技术替代手工报表的读者。2. 确定数据来源与用Python抓取山东省厂长职位薪酬做“2022年山东省地区厂长职位薪酬调查”第一步不是写代码而是确定数据源和数据粒度。招聘网站是最大的公开薪酬样本职位关键词选择“厂长”工作地点限定在山东省发布时间限定在2022年。常见的招聘平台有Boss直聘、智联招聘、51job、猎聘等但多数有反爬或需要登录。我一般选择两种方式一是平台提供的开放API如果申请不到就退而采用静态页面的解析方式比如行业垂直招聘站或公司官网招聘页。这里用一个已经脱敏的通用示例演示请求和解析逻辑。2.1 定义样本字段和存储结构先把抓回来的数据落到结构化文件里字段至少包括以下这些。数据粒度决定后续能做哪些维度分析。字段名类型说明job_titlestring职位名称必须包含“厂长”citystring工作城市如济南、青岛、烟台industrystring所属行业如机械/设备/重工salary_minint月薪区间下限元salary_maxint月薪区间上限元experiencestring经验要求如3-5年company_sizestring公司规模如100-499人publish_datedatetime职位发布日期用于筛选2022年将salary_min和salary_max拆成两列是为了后续按中位数、分位数统计时不丢失信息。publish_date是筛出2022年样本的硬条件不能省。2.2 用requests和BeautifulSoup抓取列表页下面的代码演示的是抓取某个招聘站点的职位列表并解析出第一页的核心字段。实际站点结构不同但套路一致先查看网页源码找到列表项所在的CSS选择器然后逐项提取。import requests import time from bs4 import BeautifulSoup import pandas as pd def fetch_page(url, headers, session): resp session.get(url, headersheaders, timeout10) resp.raise_for_status() resp.encoding utf-8 return BeautifulSoup(resp.text, html.parser) def parse_job_list(soup, items): # 以常见的 .job-item 为例实际按页面结构调整 for node in soup.select(.job-item): try: title node.select_one(.job-name).get_text(stripTrue) if 厂长 not in title: continue city node.select_one(.job-area).get_text(stripTrue) salary node.select_one(.salary).get_text(stripTrue) smin, smax parse_salary(salary) # 实际站点应解析 .job-date 得到发布日期这里用当前时间占位 date_str node.select_one(.job-date).get_text(stripTrue) publish_date pd.to_datetime(date_str, errorscoerce) items.append({ job_title: title, city: city, salary_min: smin, salary_max: smax, publish_date: publish_date, }) except AttributeError: continue return items def parse_salary(salary_text): # 这里简化处理只处理 x-y万/月 格式 if 万/月 in salary_text: tmp salary_text.replace(万/月,).split(-) return int(float(tmp[0]) * 10000), int(float(tmp[1]) * 10000) # 其他格式省略需按实际调整 return 0, 0 headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 } session requests.Session() items [] for page in range(1, 11): # 抓10页 url fhttps://example-station.com/zhaopin/changzhang?provinceshandongpage{page} soup fetch_page(url, headers, session) items parse_job_list(soup, items) time.sleep(1) df pd.DataFrame(items) df.to_csv(shandong_changzhang_2022_raw.csv, indexFalse, encodingutf-8-sig)这段代码里有几个需要注意的参数headers必须带真实浏览器UA否则很容易被拒绝time.sleep(1)表示每次请求间隔1秒是礼貌爬取的基本克制parse_salary必须针对页面实际展示的单位换算有的站点用K有的用万有的还包含13薪这类固定奖金需单独拆出来。我一般会把原始薪资字符串保留一列方便后面对照检查。2.3 反爬识别与请求重试很多招聘站点的反爬策略是检测请求频率和设备指纹。遇到验证码或403时不要硬闯。常见的处理手段有用requests.Session()维持Cookie随机更换User-Agent可以用fake_useragent库对失败请求等待2-5秒后重试最多重试3次如果网站有公开的API接口很多站点通过XHR异步加载职位列表直接调用JSON接口更稳定。import random from fake_useragent import UserAgent def fetch_with_retry(url, session, retries3): ua UserAgent() for attempt in range(retries): try: resp session.get(url, headers{User-Agent: ua.random}, timeout10) if resp.status_code 200: return resp elif resp.status_code 403: print(f403 on {url}, retry {attempt1}) except requests.RequestException as e: print(e) time.sleep(random.randint(2, 5)) return None提示反爬不是对抗而是降低自身对站点的压力。如果目标站点明确禁止爬取请改为申请官方API或使用线下样本。做薪酬调查这类数据项目合法合规永远是第一优先级。3. 用Pandas做薪酬数据清洗与标准化爬下来的原始CSV虽然字段完整但没法直接用于统计。比如salary_min和salary_max可能有很多0city可能包含“济南市”、“山东济南”等多种写法industry字段缺失值也很常见。清洗的目标是造出一张能够直接聚合的干净DataFrame。3.1 统一城市和行业字段先加载CSV看数据轮廓import pandas as pd df pd.read_csv(shandong_changzhang_2022_raw.csv) print(df.info()) print(df[[city,industry,salary_min,salary_max]].head(10))城市字段需要做映射。常见做法是维护一个字典把“泉城”、“济南市”、“山东济南”统一成“济南”。行业字段同样比如“机械/设备/重工”、“重型机械”都归到“机械制造”。这一步没有捷径依靠积累的行业词表。我建议先用df[city].value_counts()把全量值打印出来再逐个人工归并。city_map { 济南市: 济南, 山东济南: 济南, 泉城: 济南, 青岛市: 青岛, 山东青岛: 青岛, } df[city_clean] df[city].str.strip().map(city_map).fillna(df[city].str.strip())这里使用str.strip()去掉首尾空格用map替换最后用fillna保留未映射的原值。如果映射失败应该第一时间检查df[city_clean].value_counts()是否还有冗余值而不是直接丢弃。3.2 薪资区间解析与缺失值处理爬虫阶段已经把“1.5-2万/月”拆成了salary_min和salary_max但仍可能出现0或者缺失。对于缺失值我采用的策略是如果同一条记录中min和max同时缺失则删除该行如果只有一个非零则用非零值把另一个补齐为相同值。对0值也是一样处理。df df[(df[salary_min] 0) (df[salary_max] 0)] df[salary_mid] (df[salary_min] df[salary_max]) / 2有些薪资不是月薪而是“年薪”或“时薪”。在之前的解析函数里我们只处理了“万/月”对于“万/年”的字段需要除以12转换为月薪否则极大值会污染统计结果。这里建议增加一个标记列salary_unit来区分。3.3 采用IQR过滤极端异常值厂长这个职位的正常月薪区间在2022年的山东大约在8k到50k之间但招聘网站上偶尔会出现“5千以下”或“5万以上”的假职位。直接删掉所有极值是不对的因为这是调研报告需要保留真实的高薪样本。更稳妥的是用分位数法计算salary_mid的Q125%分位和Q375%分位把低于Q1-1.5IQR或高于Q31.5IQR的记录剔除而不是简单设定一个绝对阈值。Q1 df[salary_mid].quantile(0.25) Q3 df[salary_mid].quantile(0.75) IQR Q3 - Q1 lower Q1 - 1.5 * IQR upper Q3 1.5 * IQR df_clean df[(df[salary_mid] lower) (df[salary_mid] upper)].copy()这里我用copy()显式创建新对象防止后续对df_clean的修改影响原df。df_clean才是我们后面分析的基础数据。3.4 构造2022年样本时间窗口标题明确要求“2022年”那么数据集中所有记录的publish_date都必须落在2022-01-01到2022-12-31之间。如果抓取时没有限制现在要按时间过滤。df_clean[publish_date] pd.to_datetime(df_clean[publish_date]) df_2022 df_clean[(df_clean[publish_date] 2022-01-01) (df_clean[publish_date] 2022-12-31)]也可以直接用df_clean[publish_date].dt.year 2022更简洁。但如果未来要支持“2023年版”报告建议把年份写成参数。清洗前后的数据量对比如下阶段行数字段数备注原始抓取28437含空字段城市/行业归并28439新增city_clean等删除薪资缺失254110salary_midIQR过滤后239810去除143条异常2022年时间窗221010最终样本这样清洗后的df_2022就具备做地区薪酬报告的基础了。4. 按城市/行业做薪酬分位数统计与可视化薪酬分析不能只看平均线。厂长这个岗位在不同规模企业、不同行业里的离散度很大一个“青岛平均月薪”对求职者没有参考性。行业惯例是用分位值P10表示薪酬最低的10%人群的薪酬P50是中位数P90表示高薪人群的起薪。下面我们用Pandas算出山东主要城市和重点行业的薪酬分位。4.1 用pivot_table输出分位矩阵先计算全省总览再按城市分组import numpy as np def get_salary_percentile(series): return { P10: series.quantile(0.10), P25: series.quantile(0.25), P50: series.quantile(0.50), P75: series.quantile(0.75), P90: series.quantile(0.90), mean: series.mean(), count: series.count() } city_stats df_2022.groupby(city_clean)[salary_mid].apply(get_salary_percentile).unstack() city_stats.round(0).sort_values(P50, ascendingFalse).head(10)apply传入自定义函数unstack把字典展开为多列。注意count是样本量样本量太少的分位值没有意义一般城市样本量少于20就标记为“样本不足”可以在输出时加一列enough进行标注。4.2 行业维度制造业仍然占主导把行业维度加进来同样用groupby。我更推荐先按城市-行业两层筛选再输出矩阵如果数据稀疏就拆成两张表分别看。这里展示按行业聚合的结果。industry_stats df_2022.groupby(industry_clean)[salary_mid].agg([count,median,mean]) industry_stats[median] industry_stats[median].round(0) industry_stats.sort_values(count, ascendingFalse).head(10)4.3 用Matplotlib绘制城市薪酬箱线图分位数表格已经能回答“多少钱”的问题但箱线图能直观展示分布形态。我用Matplotlib的boxplot画全省16地市按照中位数排序。import matplotlib.pyplot as plt order df_2022.groupby(city_clean)[salary_mid].median().sort_values(ascendingFalse).index df_2022[city_order] pd.Categorical(df_2022[city_clean], categoriesorder, orderedTrue) plt.figure(figsize(12, 6)) df_2022.boxplot(columnsalary_mid, bycity_order, gridFalse, showfliersFalse) plt.title(2022年山东省厂长月薪城市分布) plt.suptitle() plt.xlabel(城市) plt.ylabel(月薪元) plt.xticks(rotation45) plt.tight_layout() plt.savefig(city_salary_boxplot.png, dpi150)showfliersFalse是为了隐藏异常值更干净地展示主体分布df_2022中的city_order必须用Categorical指定顺序否则会按字母排序导致图像有误导性。4.4 生成供PDF直接引用的表格文件报告需要把上面的city_stats和industry_stats整合成一张总表并另存为CSV方便后续在Word里核对。summary city_stats.copy() summary[province] 山东 summary.to_csv(2022_厂长薪酬_城市汇总.csv, encodingutf-8-sig)到这里我们手上已经有三个产物清洗后的df_2022分位数统计表箱线图PNG。下一步就是把它们塞进PDF报告。5. 用ReportLab把分析结果渲染成中文PDF报告前面几步已经完成了数据分析最后一步是把结果打包成2022年山东省地区厂长职位薪酬调查报告.pdf。我常用ReportLab因为它对中文字体和表格控制比FPDF更稳定而且能直接用Python代码生成矢量图。下面是一个最小可运行的模板片段重点说明中文字体注册和Table的使用。5.1 注册中文字体ReportLab默认字体不支持中文需要下载思源黑体或文泉驿正黑放到工程的fonts目录。我一般使用SourceHanSansSC-Regular.otf注册方式如下from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.ttfonts import TTFont pdfmetrics.registerFont(TTFont(SourceHanSans, fonts/SourceHanSansSC-Regular.otf))5.2 构建动态表格并插入图表把前面的city_stats转成Table对象注意列宽和表头。图片直接用Image(city_salary_boxplot.png, width450, height250)插入。最关键的一点是每次执行报告生成前应当重新读取数据文件而不是依赖已生成的CSV这样才可以保证报告与原始数据可追溯。from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, Image from reportlab.lib.styles import getSampleStyleSheet from reportlab.platypus import Table as RLTable from reportlab.lib import colors data city_stats.round(0).reset_index().values.tolist() header [城市] list(city_stats.columns) table_data [header] data report_table RLTable(table_data, colWidths[60, 70, 70, 70, 70, 70, 70, 50]) report_table.setStyle(TableStyle([ (BACKGROUND, (0, 0), (-1, 0), colors.grey), (GRID, (0, 0), (-1, -1), 0.5, colors.black), ]))注意这里忘了导入TableStyle实际代码需补上。列宽度需要根据页面宽度调整别让表格超出A4边界。5.3 一键生成整个PDF所有元素放在一个story列表里最后用doc.build(story)生成。关键还在于参数化把2022、山东、厂长这三个值抽成常量今后改成年份和职位就能复用到其他城市。YEAR 2022 PROVINCE 山东省 POSITION 厂长 OUTPUT f{YEAR}_{PROVINCE}_{POSITION}薪酬调查报告.pdf doc SimpleDocTemplate(OUTPUT, pagesizeA4) story [] story.append(Paragraph(f{PROVINCE}{POSITION}薪酬调查报告, style)) story.append(Spacer(1, 12)) story.append(Paragraph(f样本量{len(df_2022)}统计口径2022年全年招聘数据, style)) story.append(report_table) story.append(Image(city_salary_boxplot.png, width450, height250)) doc.build(story) print(f已生成 {OUTPUT})这里的style需要预先定义比如style getSampleStyleSheet()[Title]。实际工程中建议单独封装一个build_report()函数把数据读取、图表生成、PDF组装拆开。5.4 验证报告内容的小技巧生成的PDF如果字符错位或字体缺失首先检查字体文件是否被ReportLab正确加载。另一个实用技巧是用fitzPyMuPDF读取PDF文本断言关键字段是否出现import fitz doc fitz.open(OUTPUT) text doc[0].get_text() assert 山东 in text and 厂长 in text print(PDF内容校验通过)这样可以把校验步骤接进CI流水线确保自动生成的报告没有乱码、页码和图表位置错位。本文还有配套的精品资源点击获取