简介:这是一份基于Python的北京市大数据岗位招聘数据分析与可视化完整工程包,面向数据采集、清洗、分析与可视化初学者,也适合希望提升爬虫与数据分析全流程能力的求职者和在校生。资源共111个文件,压缩包约6.58MB,除了py爬虫与处理脚本、csv/xls数据文件外,还包含html/js/css可视化页面、jpg/png图表截图及地图相关资源,目录结构完整,便于对照源码与报表学习。项目基于Boss直聘真实职位数据,覆盖requests/Scrapy抓取、pandas清洗聚合、numpy数值计算、matplotlib/seaborn可视化等关键环节,并利用bootstrap等前端技术呈现可交互图表。借助此包可快速搭建同类招聘分析项目,理解岗位地域分布、薪资区间与经验要求等洞察,并迁移到其他城市、行业的数据实战中,也可作为课程设计与个人项目参考。目前已有727人学习下载。
1. 北京市大数据岗位招聘数据,值得你亲手搭一个采集到可视化的完整工程
一个很反直觉的结论:你在招聘网站上搜“大数据”,翻到的几百页岗位卡片,真正能回答“现在市场上哪个方向最缺人、给钱最多、要什么技能”的,从来不是网站自带的筛选器,而是你自己清洗、统计过的那份数据。原始JD里全是“15k-25k·14薪”“面议”“弹性工作、扁平管理”这类非结构化文本,直接看是噪声,可一旦转成薪资区间、技能词频、经验分层,它就是一份城市级岗位需求画像。这篇文章讲的是一条 Python 全链路方案:爬虫采集北京市大数据岗位数据,pandas 做清洗和薪资解析,pyecharts 做可视化展示,最终落成一个你拿得到数据、跑得通代码、讲得清逻辑的工程。适合正在求职的大数据方向新人,也适合拿来做课程设计或简历项目、需要自证“从采集到展示”完整能力的开发者。读完这条线,你能复现结果,也能自己往深扩展。
2. 招聘爬虫怎么写:先定字段,再写单页抓取,最后控反爬
2.1 字段设计:一次抓取要存哪些列,才够后续分析和可视化用
写爬虫最容易犯的错是一边抓一边改字段。Python 爬虫改起来成本看起来低,实际上字段一旦变更,历史数据全废,清洗代码、图表代码都要跟着换字段名,等于整个项目返工。我一般在写第一行 requests 之前先定数据表结构,拿张纸列出要存哪些列、什么类型、从页面哪里取。招聘类字段表,按我多次做类似项目的经验,下面这些列已经足够支撑后续分析:
| 字段名 | 类型 | 来源说明 |
|---|---|---|
| 岗位名称 | str | 岗位卡片标题,例如“大数据开发工程师” |
| 公司名称 | str | 卡片中的公司名或企业标识 |
| 薪资文本 | str | 原始薪资描述,例如“15k-25k·14薪”,保留原文 |
| 工作地点 | str | 通常是“北京·某区”格式 |
| 经验要求 | str | “1-3年”或“经验不限” |
| 学历要求 | str | “本科及以上”“大专”等 |
| 技能标签 | list | 从职位描述里抽取的 Python、Spark、Hadoop 等关键词 |
| 职位描述 | str | JD 全文,留作后续深度文本分析 |
| 发布日期 | str | 用于判断数据新鲜度 |
| 详情页 URL | str | 去重和溯源用,避免数据来源不可查 |
为什么一定要保留薪资原文、JD 全文、URL 这三个字段?因为解析代码会迭代。第一次写的正则“15k-25k”能解析,但第二周你发现还有“15K-25K·14薪”“底薪+提成”“面议”这些写法,此时只有原文还在,才能重新解析。URL 则是去重和反查的关键,没有它,重复岗位很难判定。字段定好了,后面所有环节——薪资解析、技能分析、区域统计——都在这个结构上做增量。
2.2 单页抓取最小实现:requests + BeautifulSoup 从列表页提取岗位卡片
字段定完,先写一个能单页跑通的爬虫。很多人一上来就上 Scrapy 框架,结果反爬、中间件、Pipeline 概念还没理顺,就卡在配置上。对于“北京市大数据岗位”这种单城市、单关键词、数据量几万条以内的场景,requests + BeautifulSoup 足够,没必要用框架。下面是最小实现,抓一页列表里的岗位卡片。
import requests import time from bs4 import BeautifulSoup HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/120.0.0.0 Safari/537.36", "Accept-Language": "zh-CN,zh;q=0.9", } BASE_URL = "https://example-job-site.example/jobs?keyword=大数据&page={page}" def fetch_list_page(page): """抓取列表页 HTML,返回解析后的岗位卡片列表""" url = BASE_URL.format(page=page) resp = requests.get(url, headers=HEADERS, timeout=10) resp.raise_for_status() # 关键:手动修正编码,避免中文乱码 if resp.apparent_encoding: resp.encoding = resp.apparent_encoding soup = BeautifulSoup(resp.text, "html.parser") cards = [] # 行业惯例:岗位卡片通常按 li 或 div[class*=job-card] 组织 for item in soup.select("li.job-card, div.job-card"): title_tag = item.select_one(".job-title") company_tag = item.select_one(".company-name") salary_tag = item.select_one(".salary") location_tag = item.select_one(".location") link_tag = item.select_one("a[href]") if title_tag is None: continue cards.append({ "岗位名称": title_tag.get_text(strip=True), "公司名称": company_tag.get_text(strip=True) if company_tag else "", "薪资文本": salary_tag.get_text(strip=True) if salary_tag else "", "工作地点": location_tag.get_text(strip=True) if location_tag else "", "详情页URL": link_tag.get("href") if link_tag else "", }) return cards if __name__ == "__main__": page_cards = fetch_list_page(1) print(f"第 1 页抓取到 {len(page_cards)} 条岗位") for c in page_cards[:3]: print(c)逻辑说明:代码先请求列表页,用apparent_encoding修正页面编码,这是中文招聘页面最常见的乱码源头;然后通过 CSS 选择器定位岗位卡片容器,逐条提取标题、公司、薪资、地点和详情链接。注意我用了get_text(strip=True),面试官最爱的“爬虫拿到的字符串全是空格换行”问题,这一行就能解决。参数说明:timeout=10是请求超时,设太短容易因网络波动误判失败,设太长翻页慢,10 秒是招聘网站这类响应稳定页面的常见取值。title_tag is None的跳过逻辑很重要,因为第一页顶部往往有置顶推荐位,结构跟普通岗位卡片不同,解析不到标题就该放弃而不是报错。
这版代码只抓了列表页字段,职位描述、技能标签这类详情字段需要进详情页。但从工程代价看,先跑通列表页比一次到位更稳:列表页抓完,确认字段对得上,再写详情页解析函数复用同一个请求头和编码处理逻辑,成本很低。
2.3 反爬对策与频率控制:三个必调参数和一个保底策略
招聘网站对爬虫的容忍度不算高,但也不像电商那种强反爬。以合规抓取公开页面为前提,重点做三件事:请求头伪装、抓取频率控制、失败重试。下面这段代码是把 2.2 的方案翻页并加上频率控制的完整形态。
import random import time from urllib.parse import urljoin USER_AGENTS = [ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "Chrome/120.0.0.0 Safari/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " "Chrome/119.0.0.0 Safari/537.36", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "Edge/118.0.0.0", ] def fetch_with_retry(page, max_retries=3): headers = dict(HEADERS) headers["User-Agent"] = random.choice(USER_AGENTS) for attempt in range(max_retries): try: return fetch_list_page_with_headers(page, headers) except requests.RequestException as e: print(f"第 {page} 页第 {attempt+1} 次请求失败: {e}") time.sleep(3) def crawl_pages(start_page, end_page): all_cards = [] for page in range(start_page, end_page + 1): cards = fetch_with_retry(page) if not cards: print(f"第 {page} 页无数据,可能触发了验证或页面改版") break all_cards.extend(cards) print(f"已抓取第 {page} 页,累计 {len(all_cards)} 条") # 频率控制核心参数:随机 sleep 1.5~3 秒 time.sleep(random.uniform(1.5, 3.0)) return all_cards参数说明:max_retries=3是失败重试次数的经验值,超过 3 次还在失败,说明不是网络抖动而是被限制或页面结构变了,再重试也是浪费时间。time.sleep(random.uniform(1.5, 3.0))是频率控制的关键,固定 sleep 会被对方统计出请求特征,随机区间更接近人工浏览;1.5 到 3 秒的区间保证单城市几万条数据能在合理时间内抓完,又不至于对目标站点造成压力。保底策略是:当连续两页返回空列表,立刻停止并打印当前 URL,把页面 HTML 保存到本地debug.html,再手动打开检查是验证码还是页面改版。不要对着黑匣子猜,这是我从多次爬虫“神秘翻车”里总结出的最值钱习惯。
3. 岗位数据清洗与薪资解析:把“15k-25k·14薪”变成可统计的数值区间
3.1 薪资文本解析:正则处理“K/k/千/面议”四种写法
招聘薪资文本是整个项目最脏的字段。常见写法有“15k-25k”“15K-25K·14薪”“15千-25千/月”“面议”四种,还有少数“底薪+提成”“30-60万/年”这类特殊格式。直接用float("15k")显然不行,必须先做文本标准化。下面是我常用的解析函数,处理高低区间和年薪、月薪单位。
import re def parse_salary(text: str) -> dict: """解析薪资文本,返回区间和月薪中位值""" if not text or "面议" in text: return {"薪资下限": None, "薪资上限": None, "月薪中位值": None, "薪资原始文本": text} text = text.lower() result = {"薪资原始文本": text} # 处理年薪描述:30-60万/年 -> 折算为月薪 /12 annual = re.search(r"(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)\s*万/年", text) if annual: result["薪资下限"] = float(annual.group(1)) * 10000 / 12 result["薪资上限"] = float(annual.group(2)) * 10000 / 12 result["月薪中位值"] = (result["薪资下限"] + result["薪资上限"]) / 2 return result # 月薪描述:15k-25k 或 15-25k,兼容小数如 15.5k numbers = re.findall(r"(\d+(?:\.\d+)?)\s*k", text) if len(numbers) >= 2: x = float(numbers[0]) * 1000 y = float(numbers[1]) * 1000 result["薪资下限"] = x result["薪资上限"] = y result["月薪中位值"] = (x + y) / 2 return result return {"薪资下限": None, "薪资上限": None, "月薪中位值": None, "薪资原始文本": text}逻辑说明:函数第一层先排除“面议”,这类样本无法量化,但不应直接丢弃,而是要保留薪资原始文本,后续统计时单独报告“面议占比”。第二层处理“30-60万/年”,这是资深岗位和大厂常见的表述,直接当成月薪会让薪资分布产生几千块的偏移。第三层处理常规月薪区间,正则里\d+(?:\.\d+)?兼容“15.5k”这种带小数的写法,否则15.5k-20k会被拆成“15”和“5”两个数字,解析结果直接崩。参数说明:最后统一转成元为单位,即数字*1000,这样后面画直方图和计算均值时不用再做单位换标。
3.2 经验、学历、区域的标准化映射
薪资解析完,剩下的经验、学历、区域字段是枚举型,但枚举值不统一。“1-3年”“三年以上”“经验不限”能直接看,但“本科及以上”“本科”“统招本科”语义是同一个;区域字段里“北京·朝阳”“北京朝阳”“朝阳区”也是同一个区。做分析前必须做归一,否则透视表里一个朝阳能拆成三行。常见的做法是维护映射字典。
import pandas as pd def normalize_exp(value: str) -> str: """把经验描述归类为 不限/应届/1-3年/3-5年/5-10年/10年以上""" if not value: return "未知" if "不限" in value: return "经验不限" if "应届" in value: return "应届" if "1年以下" in value or "在校" in value: return "1年以下" if "1-3" in value or "1-3年" in value: return "1-3年" if "3-5" in value or "3-5年" in value: return "3-5年" if "5-10" in value or "5-10年" in value: return "5-10年" if "10年" in value: return "10年以上" return value def normalize_edu(value: str) -> str: if not value: return "未知" if "大专" in value: return "大专" if "本科" in value: return "本科" if "硕士" in value or "研究生" in value: return "硕士及以上" if "博士" in value: return "博士" return value # 区域处理:去掉"北京·"前缀,保留具体区名 def normalize_district(value: str) -> str: if not value: return "未知区域" value = value.replace("北京·", "").replace("北京", "").strip() return value if value else "北京全域" # 应用映射 df["经验分层"] = df["经验要求"].apply(normalize_exp) df["学历分层"] = df["学历要求"].apply(normalize_edu) df["区域"] = df["工作地点"].apply(normalize_district)逻辑说明:三组映射函数都采用“关键词命中优先”的策略,因为招聘文本往往是复合写法,比如“本科及以上”同时含“本科”和“以上”,不能用相等判断。把“硕士及以上”里的“硕士”和“研究生”都归入同一类,是为了后续groupby时类别稳定。参数说明:normalize_district里连续两个 replace 是因为原始数据既有“北京·朝阳”也有“北京朝阳”,甚至还有不带“北京”的“朝阳区”,统一处理后才能聚合。这里我保留“北京全域”作为缺失或空区域的兜底值,统计时你可以选择剔除它或单独看。
3.3 清洗后的数据质量检查:九成项目翻在“没做质检”
清洗做完,不要直接画图。我见过不少项目,爬虫没翻车,清洗代码没报错,但最终图表里的“平均薪资 45k”一看就知道有问题——原因是没过滤实习岗,或者重复岗位把统计翻倍了。清洗完成后,我每次都会跑一遍质量检查,这是整个流程里最像“玄学”的部分,因为问题不出在语法层面,而出在业务理解层面。
# 去重:公司+岗位+薪资下限 三列作为复合主键 df_dedup = df.drop_duplicates(subset=["公司名称", "岗位名称", "薪资下限"], keep="first") print(f"原始数据 {len(df)} 条,去重后 {len(df_dedup)} 条") # 质检1:面议占比,超过 20% 说明薪资字段采集可能不完整 mouth_only = df_dedup["薪资原始文本"].str.contains("面议", na=False) print(f"面议占比: {mouth_only.mean():.2%}") # 质检2:关键字段缺失率 missing_rate = df_dedup[["岗位名称", "公司名称", "薪资下限", "经验分层"]].isna().mean() print(missing_rate) # 质检3:看极端值,正常的北京大数据岗位月薪中位值集中在 15k~40k extreme = df_dedup[df_dedup["月薪中位值"] > 100000] print(f"月薪中位值超过 10w 的异常样本: {len(extreme)} 条")逻辑说明:去重时把薪资下限纳入主键,而不是只用公司加岗位名,是因为同一家公司可能同时挂“15-25k”和“20-35k”两个薪资区间去测试市场,这两条数据是不同岗位,不该被合并。质检 1 的面议占比是最容易被忽视的指标,如果某个区域下面议特别多,说明样本不具备薪资可比性,画图时要单独剔除“面议”行并注明;质检 3 过滤月薪 10 万以上的记录,这类数据大多来自少数头部公司的高阶职位,个体量少但会把均值拉高,展示中位数比均值更稳。这三个检查做完,数据才算是“能用来回答问题”的状态。
4. 可视化看板怎么搭:用 pyecharts 回答求职者最关心的四个问题
4.1 先定义问题再选图表:从“画什么图”到“回答什么问题”
可视化最忌讳的是为了展示而展示,把柱状图、饼图、词云全堆一页。招聘数据做出来给谁看?给求职者或决策者看,他们只关心四个问题:哪些岗位需求量最大、薪资集中在什么区间、哪些技能被反复要求、学历和经验与薪资是什么关系。一个成熟的可视化方案应该反过来设计——先列问题,再选择匹配的图表。
| 业务问题 | 推荐图表 | 关键字段 |
|---|---|---|
| 哪些岗位方向需求量最大 | 横向条形图 Top20 | 岗位名称计数 |
| 薪资集中在什么范围 | 直方图 + 均值线 | 月薪中位值 |
| 哪些技能词高频出现 | 词云或条形图 | 技能标签 |
| 学历/经验与薪资关系 | 分组柱状图或箱线图 | 学历分层、经验分层、月薪中位值 |
选 pyecharts 而不是 matplotlib,核心原因是 pyecharts 生成的是交互式 HTML,图表自带鼠标悬浮数据提示,看板演示时可以逐条看数据点,求职者也能自己拖着看,这个体验差异对项目展示影响很大。配 Streamlit 做页面框架,能把多张图拼成一个可筛选的仪表盘,后面 4.3 会讲到。
4.2 用 pyecharts 画岗位需求 Top20 与薪资分布直方图
下面是两个最核心的图表,一个横向条形图看岗位热度,一个直方图看薪资分布。
from pyecharts.charts import Bar, Histogram from pyecharts import options as opts # 图表1:岗位需求 Top20 横向条形图 def get_top_jobs(df, top_n=20): job_counts = df["岗位名称"].value_counts().head(top_n) job_names = job_counts.index.tolist()[::-1] # 反转,让最大的在顶部 job_nums = job_counts.values.tolist()[::-1] return job_names, job_nums def render_top_jobs(df): job_names, job_nums = get_top_jobs(df) bar = ( Bar() .add_xaxis(job_names) .add_yaxis("岗位需求数", job_nums) .reversal_axis() # 横向条形图 .set_global_opts( title_opts=opts.TitleOpts(title="北京市大数据岗位需求 Top20"), xaxis_opts=opts.AxisOpts(name="岗位数量"), yaxis_opts=opts.AxisOpts(name="岗位名称"), ) ) bar.render("output/top_jobs.html") # 图表2:月薪中位值分布直方图 def render_salary_hist(df): salary_valid = df["月薪中位值"].dropna() salary_valid = salary_valid[salary_valid < 80000] # 剔除极端高值 hist = ( Histogram() .add_xaxis("月薪中位值", salary_valid.tolist()) .add_yaxis("频率", None, opacity=0.6) .set_global_opts( title_opts=opts.TitleOpts(title="北京市大数据岗位薪资分布"), xaxis_opts=opts.AxisOpts(name="月薪中位值(元)"), yaxis_opts=opts.AxisOpts(name="频数"), ) ) hist.render("output/salary_dist.html")逻辑说明:第一张图里[::-1]反转是因为 pyecharts 横向条形图默认从下往上排列,不反转则最大值出现在底部,视觉上不直观;reversal_axis()把笛卡尔坐标系的 x 轴和 y 轴对调,实现横向条形图效果。第二张直方图先剔除月薪 8 万以上的样本,目的是避免少量高阶岗位把 x 轴拉得过大,使大部分岗位的薪资区间在视觉上挤成一根柱子;直方图不直接接受原生数值数组,所以这里用了add_yaxis并传入 None,让 pyecharts 自动完成分箱计算。参数说明:output/目录需要提前建好,pyecharts 不会自动创建目录,否则render()会直接报 FileNotFoundError。
4.3 把图表拼成可交互看板:Streamlit 做筛选层
多张 HTML 图表单独放着,演示时得一个文件一个文件打开,说服力差很多。我的做法是用 Streamlit 做一层筛选和拼装:左侧选择区域或经验要求,右侧动态展示对应图表。Streamlit 的数据流模型适合这个场景,每次选择变更是重新跑一遍过滤再渲染,数据量在万级,完全扛得住。
import streamlit as st import pandas as pd from pyecharts.charts import Bar from pyecharts import options as opts import streamlit.components.v1 as components @st.cache_data def load_cleaned_data(): return pd.read_csv("data/cleaned_jobs.csv") df = load_cleaned_data() st.title("北京市大数据岗位招聘分析看板") exp_list = ["全部"] + list(df["经验分层"].unique()) exp_selected = st.sidebar.selectbox("选择经验要求", exp_list) region_list = ["全部"] + list(df["区域"].unique()) region_selected = st.sidebar.selectbox("选择区域", region_list) filtered = df.copy() if exp_selected != "全部": filtered = filtered[filtered["经验分层"] == exp_selected] if region_selected != "全部": filtered = filtered[filtered["区域"] == region_selected] job_counts = filtered["岗位名称"].value_counts().head(15) bar = ( Bar() .add_xaxis(job_counts.index.tolist()) .add_yaxis("岗位需求数", job_counts.values.tolist()) .set_global_opts(title_opts=opts.TitleOpts(title=f"{exp_selected} 岗位需求 Top15")) ) html = bar.render_embed() components.html(html, height=500)逻辑说明:@st.cache_data让清洗后的数据只在首次加载时读一次,之后的筛选操作不会反复读 CSV,这一点对交互流畅度影响很大;render_embed()把 pyecharts 图表嵌进 Streamlit 页面,不需要生成临时 HTML 文件,文件管理更干净。参数说明:侧边栏的两个selectbox作为筛选条件,筛选逻辑用布尔型 DataFrame 过滤代替query,避免用户输入引号造成语法错误。整个看板到这一步,已经从“有一堆数据”变成了“能回答问题”的独立交付物。
5. 避坑:招聘数据项目从爬虫到看板的 5 个常见翻车点
5.1 爬虫只返回空列表,但页面明明有岗位数据
现象:在浏览器里打开列表页能看到岗位卡片,但爬虫执行后cards列表为空。原因:目标页面是异步渲染,列表数据由前端 JavaScript 发起第二层接口请求后写入页面,requests 只拿得到初始 HTML,里面根本没有岗位卡片节点。解决:先用一个简单方法判断——把获取到的 HTML 字符串resp.text[:500]打印出来,看页面骨架里有没有岗位关键词。异步渲染的页面骨架通常是空的。解决办法有两个:一是找数据接口,打开浏览器开发者工具的网络面板,筛选 XHR 请求,找到返回岗位 JSON 的接口,直接用 requests 请求接口而不是页面;二是换渲染工具,比如用 Selenium 或 Playwright 驱动浏览器执行完 JS 再解析。实战中优先找接口,成本最低,灰度最稳。
5.2 薪资解析被“面议”大量污染,导致平均薪资严重失真
现象:清洗后算平均薪资,结果出现 3k 或 80k 这种极端值。原因:面议岗位的薪资被记成 0,或被其他特殊文本干扰;另外“底薪+提成”这种文本里没有数字区间,正则匹配失败返回空值。解决:清洗逻辑中把“面议”“底薪+提成”“薪资面议”统一归为“不可量化”类,不参与均值和中位数计算;最终统计时单独报告“面议占比”,如果某个岗位方向下面议占比超过 30%,这个方向的薪资分析结论就不该写进报告。我在 3.3 里已经加了这个质检逻辑,实际使用时要关注占比值的变化趋势。
5.3 关键词过滤误判:搜“大数据”混入不相干岗位
现象:岗位榜单里出现“移动端开发”“UI 设计”这类明显非大数据岗位。原因:搜索引擎是模糊匹配,JD 正文里提到“负责大数据平台对接”的移动端岗位也会被召回;如果只按岗位名称字段过滤,「大数据销售」「大数据产品经理」又会被误判为技术岗位。解决:清洗时增加岗位方向字段,按“岗位名称 + 职位描述”双重匹配来分类。例如技术类岗位要求描述里同时命中“Spark/Hadoop/Flink”中的一个;管理类岗位命中“团队/管理/带领”关键词。分类规则不用太复杂,四到五个方向就能把纯技术、产品、销售、运维大致分开。
5.4 图表中文乱码或中文方块字
现象:pyecharts 生成的 HTML 里中文标题正常,但保存成图片后标题变成方框。原因:pyecharts 渲染图片时依赖系统中文字体,部分服务器或精简版系统没有安装中文字体,字体渲染阶段找不到对应字形。解决:本地开发机大多没问题;如果要在服务器或 Docker 容器里跑,需要安装中文字体,常见做法是安装fonts-noto-cjk包,并把 pyecharts 的全局字体设置为 Noto Sans CJK SC。做项目演示前,先确认演示环境是否有中文字体,否则现场出方框会很尴尬。
5.5 重复岗位导致统计翻倍,榜单顺序完全失真
现象:某个公司同一个岗位在列表里出现多次,岗位需求 Top 榜里它排在第一位,但真实招聘量并没有那么大。原因:招聘网站会因为不同的招聘渠道、付费位、城市归属产生重复的岗位卡片,它们 URL 可能不同,但岗位名、公司名、薪资区间几乎一致。解决:在 3.3 的去重逻辑里加入“公司名称+岗位名称+薪资下限”复合键。如果同一公司同一岗位出现不同薪资区间,按照薪资区间不同视为两个岗位,因为企业确实在招不同职级的定位。另外抓取详情页 URL 后,可以用 URL 中的岗位 ID 做第二层去重,这个 ID 在站内是唯一的,往往比名称合并更准。
6. 把源码+数据跑成可复现工程:目录组织与数据新鲜度验证
6.1 代码目录怎么组织:爬虫、清洗、可视化三模块强制分离
拿到“源代码+数据+爬虫”类型的项目包,第一件事不是运行,而是先看目录结构。一套值得留下的工程,目录往往是按阶段拆分的。我自己的项目习惯是下面这种组织方式,每条数据从采集到展示,责任边界都很清楚。
project/ ├── requirements.txt # 依赖清单:requests, pandas, pyecharts, streamlit ├── README.md # 第一步运行说明,按顺序执行 ├── config/ │ └── settings.py # URL模板、请求头、采集关键词、翻页范围 ├── crawler/ │ ├── spider.py # 列表页抓取 + 详情页抓取 │ └── run_crawler.py # 入口脚本,产出去重后的原始 CSV ├── analysis/ │ ├── clean.py # 清洗 + 薪资解析 + 归一化 │ └── quality_check.py # 数据质量报告 ├── visualization/ │ ├── charts.py # 生成各图表的 pyecharts 代码 │ └── dashboard.py # Streamlit 仪表盘入口 ├── data/ │ ├── raw_jobs.csv # 爬虫原始输出,不做任何处理 │ └── cleaned_jobs.csv # 清洗后可直接分析的数据 └── output/ ├── top_jobs.html └── salary_dist.html参数说明:raw_jobs.csv和cleaned_jobs.csv分开放是我一直坚持的习惯。原始数据是后悔药——清洗规则改了一版,要重新解析时,只需要重新跑clean.py,不需要重新爬站。settings.py单独成文件,是因为爬虫的频率、UA、关键词这些参数经常要调,不要让爬虫脚本里到处散落 URL 和 User-Agent。
6.2 数据新鲜度验证:拿到项目数据后,先重新跑一遍再信结论
项目包里自带的数据集,发布时间是未知的,直接拿来做求职决策可能有风险。我拿到“源代码+数据+爬虫”这类包,不信任数据,只信任流程。验证数据新鲜度的方法是重新跑一遍爬虫,把新数据与旧数据做三个维度的对比:
import pandas as pd new_data = pd.read_csv("data/my_raw_jobs.csv") old_data = pd.read_csv("data/provided_raw_jobs.csv") def freshness_report(new_df, old_df): print(f"新数据 {len(new_df)} 条,旧数据 {len(old_df)} 条") # 对比1:总量变化 print(f"数量变化率: {(len(new_df)-len(old_df))/len(old_df)*100:.1f}%") # 对比2:薪资中位数是否漂移 for name, df in [("新", new_df), ("旧", old_df)]: salary_median = df["月薪中位值"].dropna().median() print(f"{name}数据月薪中位数: {salary_median/1000:.1f}k")逻辑说明:数量变化率和薪资中位数漂移是判断数据可信度的关键。如果新数据的中位数和旧数据差异在 10% 以内,说明旧数据集可信,可以放心用于分析;如果差异超过 30%,说明期间市场结构变了,或者旧数据的采集范围跟新爬虫不一致,这时候用旧数据得谨慎。对比完数据的下一步,才是开始清洗、画图、写报告。
这份工程做到这里,已经从“拿别人的数据”变成了“自己验证过的流水线”。我个人的教训是:数据项目的可信度,不取决于图表多漂亮,而取决于每一步能不能复现、数据能不能溯源。养成拿到项目先看原始数据、先跑一遍爬虫的习惯,能帮你避开大多数“图表好看但结论不可信”的坑。希望这篇总结能帮你把这个方向真正做透,也少走几步我走过的弯路。
本文还有配套的精品资源,点击获取