ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

北京招聘数据分析实战:从爬虫采集到可视化大屏

北京招聘数据分析实战:从爬虫采集到可视化大屏 简介这是一份基于Python的北京市大数据岗位招聘数据全流程实践项目覆盖爬虫采集、数据清洗、数据分析与可视化展示。项目以Boss直聘为目标站点演示Scrapy/BeautifulSoup请求解析、pandas预处理与统计聚合并结合matplotlib、seaborn等库输出地域分布、薪资区间、职位需求变化等图表适合正在学习爬虫与数据分析的Python开发者作为综合练手案例。压缩包共111个文件总大小6.58MB其中包含4个Python爬虫脚本、1个CSV数据文件、1个Excel数据文件另有大量前端展示文件html/css/js、地图组件及数据库文件方便直接运行或二次开发。目前已有727人学习下载。内含完整源代码、采集数据及可视化页面目录结构清晰能够帮助读者快速理解从网络爬虫到数据落库、从统计分析到图表呈现的完整链路在真实业务场景中提升数据获取与解读能力。1. 北京招聘数据分析项目到底值不值得做先看清这条链路再说把「大数据」三个字先放一边这个项目最实在的价值是把 Python 爬虫、Pandas 数据清洗、SQLite 存储、可视化大屏这四段技能用一条完整的链路串起来。北京市大数据岗位的招聘信息每天都在更新爬下来之后整理成结构化表格算出薪资分布、技能需求、学历门槛再做成能交互的可视化大屏——这套流程做完你对「数据分析师拿到原始数据之后发生了什么」就有了完整的概念。这个项目适合两类人一类是正在准备数据分析相关岗位面试的求职者需要一份能讲清楚细节的项目经历另一类是毕业设计选了数据可视化方向的学生需要一套能跑通、能截图展示、能写进论文的完整实现。数据量级没有想象中大一个月能采集一万多条有效岗位记录就够用真正的工程量在清洗和口径统一上。接下来我把每个环节的落地方式和参数选择逐个拆开讲照着做能少走大半弯路。2. 招聘数据爬虫字段设计、requests 与 Selenium 双方案2.1 先定表结构再写爬虫岗位数据的 8 个必备字段写爬虫之前先把目标表结构定下来这是最容易省事的一步。很多新手上来就写requests.get()拿到 HTML 之后发现想要的字段散落在五六个地方最后清洗阶段被迫反复回去补爬非常被动。我一般会先想清楚最终分析需要哪些维度再决定页面里提取什么。北京招聘数据分析至少需要这 8 个字段岗位名称、公司名称、薪资区间、学历要求、经验要求、工作地点、技能标签、发布日期。如果后续想分析公司规模对薪资的影响再加一个公司规模字段。不要把整段 JD 存进去——JD 文本太大而且后续技能抽取是单独的逻辑存原始文本反而拖慢读写。CREATE TABLE job_posts ( id INTEGER PRIMARY KEY AUTOINCREMENT, job_title TEXT NOT NULL, company_name TEXT, salary_raw TEXT, salary_min INTEGER, salary_max INTEGER, education TEXT, experience TEXT, location TEXT, skills TEXT, publish_date TEXT, source_url TEXT UNIQUE, crawl_time TEXT, UNIQUE(company_name, job_title, salary_raw, publish_date) );这个建表语句里有几个关键设计source_url加 UNIQUE 约束保证同一链接不会重复入库最后的复合 UNIQUE 作为兜底防止同一 URL 内容更新后产生重复记录薪资字段同时保留salary_raw原始字符串和salary_min/max整数方便后续直接聚合计算。工作地点单独存为可视化那一步按区域聚合做准备。2.2 requests 抓静态页面UA 池和随机延时是底线招聘平台的首页和搜索结果页大部分内容可以直接用 requests 拿到前提是请求头够友好。UA 池和随机延时是底线我自己吃过教训固定间隔 1 秒跑 200 个请求到第 150 个左右开始返回 403连验证码页面都不给。后来改成随机延时同样的请求量再没触发过风控。import random import time import requests from fake_useragent import UserAgent ua UserAgent() def fetch_page(url: str, retries: int 3): headers { User-Agent: ua.random, Accept: text/html,application/xhtmlxml, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Referer: https://www.zhipin.com/ } for attempt in range(retries): try: resp requests.get(url, headersheaders, timeout10) if resp.status_code 200: return resp.text elif resp.status_code 403: wait 30 * (attempt 1) # 指数退避 print(fHTTP 403等待 {wait}s 后重试) time.sleep(wait) else: time.sleep(5) except requests.RequestException as e: print(f请求异常: {e}重试 {attempt 1}/{retries}) time.sleep(3) return None这里的核心参数有两个timeout10防止某个慢接口拖住整个任务retries3配合指数退避给临时风控留出恢复时间。指数退避里30 * (attempt 1)意味着第一次失败等 30 秒第二次 60 秒第三次 90 秒。用ua.random每次换 UA比固定写一个 Chrome UA 靠谱得多fake_useragent库会随机生成各版本浏览器标识。拿到 HTML 之后用 BeautifulSoup 解析按标签定位字段from bs4 import BeautifulSoup def parse_job_card(html: str): soup BeautifulSoup(html, html.parser) jobs [] for card in soup.select(.job-card): title card.select_one(.job-title) company card.select_one(.company-name) salary card.select_one(.salary) if not title or not salary: continue jobs.append({ job_title: title.get_text(stripTrue), company_name: company.get_text(stripTrue) if company else , salary_raw: salary.get_text(stripTrue), source_url: card.get(href, ) }) return jobsselect_one返回第一个匹配项get_text(stripTrue)把标签内部文本提取出来并去掉首尾空格。这里有个细节没有工资信息的卡片直接continue跳过——这种一般是外包岗或「薪资面议」留到后面拆薪资逻辑反而容易出错。选择器./ job-card只是示例实际平台需要你打开浏览器开发者工具找到岗位卡片对应的真实 class 名再替换。2.3 Selenium 处理动态渲染无头浏览器与显式等待部分招聘页面是 JavaScript 动态渲染的requests 拿到的是空壳 HTML这时候换成 Selenium。常见的做法是启动 headless Chrome模拟滚动加载分页数据等页面关键元素出现后再提取。from selenium import webdriver from selenium.webdriver.chrome.options import Options from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC options Options() options.add_argument(--headlessnew) options.add_argument(--no-sandbox) options.add_argument(--disable-dev-shm-usage) options.add_argument(--window-size1920,1080) driver webdriver.Chrome(optionsoptions) driver.get(https://www.example.com/jobs) wait WebDriverWait(driver, 10) cards wait.until( EC.presence_of_all_elements_located((By.CSS_SELECTOR, .job-card)) ) for card in cards: title card.find_element(By.CSS_SELECTOR, .job-title).text salary card.find_element(By.CSS_SELECTOR, .salary).text print(title, salary) driver.quit()--headlessnew是 Chrome 109 之后的推荐写法旧的--headless在新版上可能直接失效。--window-size1920,1080必须带上很多前端框架在窄屏下会切换成移动端布局导致 class 名完全不同。WebDriverWait是显式等待最多等 10 秒直到.job-card元素出现——这里不要用time.sleep(3)这种固定等待页面加载速度波动很大固定等待要么浪费要么不够。Selenium 方案的定位是补 requests 的缺口不是全部替代。我的建议是先用 requests 跑一部分页面把能拿到的数据量统计一下确认哪些页面需要 Selenium 再开浏览器能省下大量采集时间。另外无头模式被网站识别的问题确实存在普通学习项目不用过度纠结后面避坑章节会展开讲。2.4 增量去重与采集频率控制总量比追求数量重要招聘数据爬虫有一个常见误区总想把目标平台所有岗位都爬下来。实际上北京一日新增的大数据相关岗位可能有几百条连续采集一个月也就一万多条这个量级对分析足够。更重要的是维护一个去重逻辑避免同一条岗位在数据里出现三次。import hashlib import sqlite3 def gen_content_hash(record: dict) - str: raw f{record[company_name]}|{record[job_title]}|{record[salary_raw]} return hashlib.md5(raw.encode()).hexdigest() def save_if_new(conn: sqlite3.Connection, record: dict): content_hash gen_content_hash(record) exists conn.execute( SELECT id FROM job_posts WHERE content_hash ?, (content_hash,) ).fetchone() if exists: return False conn.execute( INSERT INTO job_posts (job_title, company_name, salary_raw, content_hash) VALUES (?, ?, ?, ?), (record[job_title], record[company_name], record[salary_raw], content_hash) ) conn.commit() return True用 MD5 生成内容指纹比直接比对多个字段效率更高。company_name job_title salary_raw三者拼接作为指纹同一个公司在同一时间段发布的同名岗位大概率是重复职位。采集频率这块我的建议是每天只跑一次增量任务固定在北京时间凌晨防御性较强避开白天用户活跃高峰期。单次任务最多请求 300 个页面超过就直接停下来第二天继续。爬虫要做的是「可持续更新数据」不是「一次性榨干站点」尤其招聘数据这种按天更新的业务今天没爬到明天还有没必要冒险。3. 数据清洗与存储薪资拆解、技能抽取与 SQLite 落库3.1 薪资字段拆解正则处理「15-25K·14薪」招聘网站的薪资展示格式五花八门常见的有「20-40K·14薪」「8k-12k」「25-50K·15薪」「10K」这几种。分析前必须统一拆成三个数字下限、上限、中位数。正则表达式是最稳的解法一次性把区间两端和月薪单位提取出来。import re def parse_salary(salary_raw: str) - tuple: 返回 (salary_min, salary_max, salary_mid) 输入: 20-40K·14薪 或 8k-12k if not salary_raw: return (0, 0, 0) pattern r(\d(?:\.\d)?)\s*[-~—]?\s*(\d(?:\.\d)?)?\s*[Kk] match re.search(pattern, salary_raw) if not match: return (0, 0, 0) low float(match.group(1)) high float(match.group(2)) if match.group(2) else low return (int(low), int(high), int((low high) / 2))\d(?:\.\d)?表示匹配整数或小数[-~—]?兼容了连字符、波浪线、中文破折号三种分隔写法。中位数取区间两端平均值后续聚合就按salary_mid算。这里有个经验不要把「14薪」「16薪」直接算进月薪分析不同公司的薪酬结构差异太大统一按月薪中位数比较才有意义。想分析年薪就把salary_mid * 薪数但要在清洗时单独存年薪字段别覆盖月薪。3.2 技能标签抽取关键词库匹配与词频归一化岗位描述里技能词的写法也不统一「Python」和「python」大小写不同「C」和「C/C」算同一个技能「Hadoop/Hive/Spark」经常连在一起写。技能抽取的策略是维护一份技能关键词表对文本做大小写归一化后逐一匹配。SKILL_KEYWORDS [ Python, Java, SQL, Spark, Hadoop, Hive, Flink, Kafka, Redis, MySQL, Pandas, NumPy, Docker, K8s, Linux, Spring, TensorFlow, PyTorch, Selenium, Airflow, Linux, Shell, HBase, Elasticsearch, FineBI, Tableau ] def extract_skills(text: str) - list: text_lower text.lower() matched [] for skill in SKILL_KEYWORDS: if skill.lower() in text_lower: matched.append(skill) return list(set(matched)) # 同一岗位内技能去重匹配之后要把结果用逗号拼成一个字段存进skills列后续分析时用FIND_IN_SET或在 Python 里str.split(,)展开统计。大小写统一转小写再匹配避免「Python」和「python」被当成两个技能。K8s 和 Kubernetes 这种同义不同写的问题靠词表里同时收录并做别名映射解决更简单的方案是统一替换成规范名。技能抽取这块不需要做得太深够分析就行。几轮做下来你会看到 Python、SQL、Java 稳居前三这个结论本身就能支撑论文里「北京大数据岗位技术栈分布」的分析章节。3.3 SQLAlchemy 定义 ORM 模型并落库清洗完成的数据用 SQLAlchemy 写入 SQLite比直接拼 SQL 更安全也方便后续切换到 MySQL。SQLAlchemy 的好处是模型定义即文档字段类型一目了然。from sqlalchemy import create_engine, Column, Integer, String from sqlalchemy.orm import declarative_base, sessionmaker Base declarative_base() class JobPost(Base): __tablename__ job_posts id Column(Integer, primary_keyTrue) job_title Column(String(100)) company_name Column(String(100)) salary_raw Column(String(50)) salary_min Column(Integer) salary_max Column(Integer) salary_mid Column(Integer) education Column(String(30)) experience Column(String(50)) location Column(String(50)) skills Column(String(500)) publish_date Column(String(20)) source_url Column(String(500), uniqueTrue) crawl_time Column(String(20)) engine create_engine(sqlite:///beijing_jobs.db) Base.metadata.create_all(engine) Session sessionmaker(bindengine) session Session()uniqueTrue在数据库层面挡住了重复 URL即使清洗环节漏掉了重复记录入库时也会报错而不是静默写入。SQLite 文件单文件存储整个项目可以塞进一个文件夹交给别人复现时拷走.db文件就够了。单机分析场景下 SQLite 性能完全够用只有数据量超过几百万行才需要考虑 MySQL。3.4 清洗质量校验空值率、异常值和重复率三张表清洗完先别急着分析花十分钟跑一遍质量校验。招聘数据最容易出的问题是薪资解析失败返回 0、部分字段大量为空、重复率异常高。我会用一条 SQL 快速看全局。SELECT COUNT(*) AS total, SUM(CASE WHEN salary_mid 0 THEN 1 ELSE 0 END) AS zero_salary, SUM(CASE WHEN skills IS NULL OR skills THEN 1 ELSE 0 END) AS no_skills, SUM(CASE WHEN location IS NULL OR location THEN 1 ELSE 0 END) AS no_location, COUNT(DISTINCT source_url) AS distinct_url, COUNT(*) - COUNT(DISTINCT source_url) AS dup_count FROM job_posts;zero_salary超过总数 3% 就要回看正则说明有规模不小的薪资格式没被覆盖。no_skills超过 10% 也要查说明关键词词表覆盖面不足。dup_count超过 5% 的时候优先检查内容指纹生成逻辑而不是直接改数据——真想连同名岗位一起清理得对比发布时间字段确认是否真的重复。提示质量校验的输出结果存成quality_report.csv做可视化之前先看这份报告能避免「图表做完了才发现数据是脏的」这种返工。4. 北京招聘数据分析与可视化从指标计算到 pyecharts 大屏4.1 市场综合指标平均薪资、中位薪资与岗位总量分析环节先算三个全局指标岗位总量、平均薪资、薪资中位数。平均薪资会被少数高管岗拉高中位数更能代表市场常态两个都算、图表里都展示是最稳妥的做法。import pandas as pd df pd.read_sql(SELECT * FROM job_posts, engine) total_jobs len(df) avg_salary df[salary_mid].mean().round(1) median_salary df[salary_mid].median().round(1) print(f岗位总量: {total_jobs}) print(f平均月薪(中位值): {avg_salary}K) print(f月薪中位数: {median_salary}K)这三个数直接放进可视化大屏顶部的 KPI 卡片里。额外可以算一个「薪资区间分布」把月薪分成 10K 以下、10-20K、20-30K、30-40K、40K 以上五档统计每档岗位数占比。分档后的柱状图比原始薪资分布直方图更容易读出结论。4.2 技能薪资溢价Python/Java/Spark 谁更值钱技能分析是招聘数据最有价值的部分。把skills字段展开成一行一技能的长表再按技能分组计算平均薪资就能看到不同技术方向的身价差异。skills_df df.dropna(subset[skills]).copy() skills_df[skill_list] skills_df[skills].str.split(,) rows [] for _, row in skills_df.iterrows(): for skill in row[skill_list]: rows.append({skill: skill.strip(), salary_mid: row[salary_mid]}) skill_salary pd.DataFrame(rows) skill_stats ( skill_salary.groupby(skill) .agg(avg_salary(salary_mid, mean), job_count(salary_mid, count)) .sort_values(job_count, ascendingFalse) .head(20) ) print(skill_stats)groupby之后按job_count排序取前 20避免只出现一两次但薪资奇高的冷门技能干扰判断。展示时做双轴图柱状图是岗位数量、折线是平均薪资一眼能看出「需求量大的技能薪资不一定最高」这类反直觉结论。4.3 学历与经验结构分布饼图与堆叠图的数据口径学历分布直接按清洗后的education字段分组计数经验要求同理。需要注意口径有些平台把「大专」和「本科」混在一起写「大专/本科」清洗时要把这种合并项里更低的学历作为主判断否则饼图会出现重叠类别。edu_order [大专, 本科, 硕士, 博士, 不限] edu_dist ( df.groupby(education)[id] .count() .reindex(edu_order, fill_value0) .reset_index() ) edu_dist.columns [education, count] print(edu_dist)reindex保证图表里类别的排列顺序固定不会每次运行都变。经验字段同理拆成「应届/1-3年/3-5年/5-10年/10年以上」五档学历和经验做成交叉堆叠图能看出「硕士学历集中出现在哪些经验段」这类更细的结构特征。4.4 可视化大屏pyecharts 组件布局与数据对接可视化部分我选用 pyecharts它支持链式调用、生成独立 HTML不需要额外部署前端服务。大屏默认布局是一行三列顶部 KPI 卡片、左侧技能榜单、中间学历饼图、右侧薪资分布柱状图、底部词云。from pyecharts.charts import Bar, Pie, WordCloud from pyecharts import options as opts salary_hist Bar() salary_hist.add_xaxis([0-10K, 10-20K, 20-30K, 30-40K, 40K]) salary_hist.add_yaxis(岗位数, salary_dist_list) salary_hist.set_global_opts( title_optsopts.TitleOpts(title北京大数据岗位薪资分布), yaxis_optsopts.AxisOpts(name岗位数), ) salary_hist.render(charts/salary_dist.html)链式调用的核心是set_global_opts里的title_opts与axis_opts标题和坐标轴名称必须写清楚否则看图表的人不知道横轴是薪资档位还是经验年限。每个render()生成独立 HTML 文件多个图表放在同一个charts/目录下最后用 iframe 拼到一张总览页里。词云图用岗位名字段生成能直观反映当前市场需求量最大的岗位关键词。WordCloud 组件默认支持中文分词但是要保证数据里没有乱码——清洗阶段所有文本统一utf-8编码从源头上避免这个问题。4.5 数据看板整体串联与手动刷新所有图表生成后写一个run_analysis.py统一调度读库 → 算指标 → 生成图表 HTML。改成一个带手动刷新的大屏页面用 Flask 提供本地服务每次访问时重跑一次分析脚本保证看板数据跟上最新的增量抓取结果。这样整个项目从爬虫到展示形成了一个闭环今天凌晨爬的新数据中午打开看板就能看到更新后的统计口径。Flask 版大屏不需要复杂模板一个路由直接拼 iframe 就好from flask import Flask, render_template_string app Flask(__name__) app.route(/) def dashboard(): html div styledisplay: grid; grid-template-columns: 1fr 1fr; iframe src/static/salary_dist.html/iframe iframe src/static/skill_bar.html/iframe /div return render_template_string(html) app.run(host127.0.0.1, port5000, debugFalse)grid-template-columns: 1fr 1fr把两个图表并列排布iframe直接引用 pyecharts 生成的静态文件。没有用复杂的前端框架整个看板就是纯 HTML 拼装新人也能看懂结构。5. 避坑招聘数据项目最常见的 5 个翻车现场5.1 薪资中位数算出来翻倍正则的贪婪匹配现象清洗后统计平均薪资达到 40K明显高于招聘网站肉眼看到的水平。原因正则\d在匹配「20-40K·14薪」时把14薪前面的14也当成了薪资下限。原始正则没有限定只在K前面取数字导致14被当成一个新的薪资档位。解决正则改成从数字开始强制要求以K/k结尾且只取第一个匹配段。用re.findall输出所有匹配结果逐一检查确认只有「20」「40」两个数字被提取。加一条校验规则兜底——salary_max小于salary_min的记录直接置 0 并在日志里打印原始字符串。5.2 请求频率没控制好封 IP 之后只能干等现象前 200 个请求正常第 250 个开始持续返回 403浏览器手动访问也出现验证码说明 IP 级访问被限制。原因请求间隔用了固定time.sleep(1)同一 IP 的请求节奏太规律被风控识别为脚本行为。解决间隔改成random.uniform(1.5, 3.5)每次请求之间随机休息 1.5 到 3.5 秒。同时加请求前延时和失败后的指数退避——这个方案能应对绝大多数反爬策略。如果已经封了就停一天让限制自动解除赢了对抗也丢了数据不划算。5.3 数据重复率超过 30%来源 URL 和内容指纹两个约束都要有现象洗完数据用source_url查重重复率不高但按「公司岗位薪资」查重复率超过 30%。同一公司连续几天发布同一批岗位URL 不同内容几乎一样。原因单独靠 URL 去重挡不住「同岗位重新上架」的情况原 URL 和重新上架的 URL 不同内容却完全相同。解决内容指纹去重逻辑里加入publish_date超过 30 天的同内容记录允许重新入库新发布的看做新岗位。同时job_posts表保留复合唯一约束(company_name, job_title, salary_raw, publish_date)数据库层面兜底。5.4 Selenium 无头模式加载不出内容等待条件写错位置现象无头模式打开页面后find_elements返回空列表但去掉--headless后正常。原因页面内容是滚动懒加载的初始化时只渲染首屏后续内容需要滚动触发。无头模式窗口高度默认 800px首屏内容太少等待条件presence_of_element_located已经满足往下找卡片就找不到了。解决等待条件改成presence_of_all_elements_located并且循环执行滚动直到页面高度不再变化。滚动代码加上之后还要配合WebDriverWait等待特定数量的卡片出现。另一个细节是--window-size必须设置大尺寸首屏渲染的内容越多越稳。5.5 词云全是「岗位职责」「任职要求」停用词表不过关现象词云图生成后占据视觉中心的全是「岗位职责」「任职要求」「具有良好的」这些套话真正的技术词被挤到边缘。原因岗位描述里高频出现的 JD 模板语言词频远高于真实技能词词云按词频布局套话自然占据核心位置。解决建一个业务停用词表把「岗位职责」「任职要求」「具备」「优先」「良好」「相关」「以上学历」这类词全部过滤掉再跑词频统计。停用词表要按招聘场景专门维护通用中文停用词表覆盖不到「优先」「熟悉」这类招聘特有词。提示这五个坑不是全部但覆盖了从采集到展示的最常见事故点。项目做到后面你会发现维护成本最大的不是写代码而是持续调清洗规则和停用词表。6. 进阶数据质量自查清单与增量看板自动化项目跑通之后我给自己定的验收标准是三句话数据能自圆其说、图表能回答业务问题、整套流程两周后还能跑。前两条靠分析逻辑最后一条靠自动化。每天凌晨跑一次爬虫脚本已经是基本功但很多人的脚本跑两周就开始报错——不是代码坏了是数据质量下滑导致清洗逻辑崩了。我习惯每次爬虫任务结束后自动跑一套数据质量自查把结果写进日志当天空值率有没有超过 5%、薪资解析失败率有没有超过 3%、重复率有没有超过阈值、新增岗位数是不是接近 0。这四个数任何一个异常就发一封提醒邮件等白天再排查。新增岗位数接近 0 这种情况大概率是页面结构变了HTML 选择器匹配不到内容这是周期性任务最隐蔽的失败模式不跑质量自查根本发现不了。看板侧可以做两个小优化。第一个是把凌晨爬完的数据直接写入一个data_processed.csv可视化脚本只读取这个 CSV不直接碰数据库数据链路变成「爬虫 → 库 → CSV → 图表」每层职责清晰。第二个是给 KPI 卡片加环比今天的岗位总量和昨天比涨了还是跌了用df[crawl_time].dt.date分组就能算出来。环比数字放进大屏后看板从「展示现状」升级为「展示变化」给面试官讲的时候也更有话可说——「我不仅分析了静态分布还跟踪了趋势」。最后说一个我自己的习惯每次改完清洗或分析脚本先把原始数据备份一份再跑全流程最后对比新旧两份图表输出的差异。这个动作让我避开过好几次「改完正则前两周的数据全部解析失败」这种自己给自己挖的坑。先备份、再修改、后对比这套流程已经成了我做数据项目的肌肉记忆希望帮到你。本文还有配套的精品资源点击获取
返回列表