简介:这是一套面向Python学习者的综合实战资源,聚焦北京市大数据岗位招聘数据的爬取、清洗、分析与可视化。资源包含完整项目源码、爬虫脚本、数据文件及图表展示,覆盖requests/Scrapy网页抓取、pandas数据预处理、matplotlib/seaborn可视化等核心流程,适合正在学习数据分析或想了解招聘市场行情的人群。压缩包共111个文件,容量6.58MB,主要包含Python代码、CSV/XLS数据文件、HTML页面与CSS/JS前端资源、可视化图表图片等,便于直接运行和二次开发。目前已有727人学习下载。通过该资源可掌握从网络爬虫到数据落地的完整链路,理解真实招聘数据中的薪资分布、岗位需求等规律,并复用其目录结构与代码片段,是入门大数据分析的有力参考。
1. 北京市大数据岗位招聘数据项目:爬虫、清洗到可视化看板全过程
第一次跑完北京市大数据岗位招聘数据的爬虫,我发现最扎眼的不是哪家公司开薪最高,而是大量岗位描述里反复出现同一组词:实时计算、数据仓库、特征工程。基于Python实现的北京市大数据岗位招聘数据分析及可视化展示项目,就是围绕这条链路搭的:爬虫从公开页面抓原始数据,清洗成结构化二维表,再通过可视化把岗位分布、技能要求、薪资区间摆到一张看板上。它不是某份现成文档的复述,而是一套可以照抄、改写、替换数据源的工程样板。适合三类人:准备进入大数据行业的求职者、想摸底市场行情的研发同学、以及需要帮团队做招聘决策的HR或后端工程师。全程不改一行也能出图,但更推荐你替换接口和字段后当脚手架用。
2. 爬虫部分:用 Requests + BeautifulSoup 稳妥抓取招聘列表与详情
这套项目里的爬虫,核心目标只有一个:在尽量轻量的前提下,把北京市大数据岗位招聘的索引和正文拿回来。别指望一眼看到全部,数据量到万级以后,速度不是主要矛盾,不中断、可续跑才是。这个阶段我常用 Requests + BeautifulSoup 组合,原因是不需要启动浏览器,内存占用小,且能把网络请求和页面解析拆开单独测试。
2.1 字段设计:先定表结构再写爬虫
岗位数据里真正决定分析质量的不是薪资,而是岗位描述和技能标签。没有描述的分析是做不出技能词频的,所以我在开头就定了这么一张字段表。
| 字段 | 含义 | 抓取位置 | 备注 |
|---|---|---|---|
| job_title | 岗位名称 | 列表页 | 用来归类大数据方向 |
| salary_raw | 原始薪资文本 | 列表页 | 必须存原文,清洗层再解析 |
| experience | 经验要求 | 列表页/详情页 | 用规则抽数值 |
| education | 学历要求 | 详情页 | 归一化到有限枚举 |
| work_area | 工作区域 | 列表页 | 可做区域分布 |
| company_name | 公司名称 | 列表页 | 注意同名公司问题 |
| industry | 公司行业 | 详情页 | 做行业交叉分析 |
| financing | 融资阶段 | 详情页 | 过滤初创群体 |
| company_size | 公司规模 | 详情页 | 交叉分析用 |
| description | 岗位职责与要求 | 详情页 | 技能词频主要来源 |
| job_tags | 岗位标签 | 详情页 | 快速验证技能词频 |
| detail_url | 详情页链接 | 列表页 | 做唯一索引和去重 |
这张表里最容易忽视的是 description 和 job_tags。只存结构化字段,后续想分析“到底要求哪些技术栈”就完全抓瞎。我一般会额外把每一页的原始 HTML 或接口 JSON 落一份快照,这样就算清洗规则写错了,也能回炉重造。
2.2 列表页与详情页的两级请求与解析
一级请求先拿列表页索引,二级请求再进详情页补全描述。下面的代码是我在静态页面场景下的常用骨架,选择器按目标站点的实际 HTML 调整即可。
import requests import random import time from bs4 import BeautifulSoup BASE_HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", } def fetch_or_none(url, params=None, timeout=10, retry=3): """带重试和超时的 GET,避免单次网络抖动拖垮整个爬虫""" for i in range(retry): try: resp = requests.get(url, params=params, headers=BASE_HEADERS, timeout=timeout) if resp.status_code == 200: return resp.text except requests.RequestException as e: print(f"[retry {i+1}] {e}") time.sleep(random.uniform(1, 3)) return None def parse_list(html): soup = BeautifulSoup(html, "html.parser") job_list = [] for item in soup.select("div.job-list-item"): title_node = item.select_one(".job-title") url_node = item.select_one("a.job-link") if not title_node or not url_node: continue salary_node = item.select_one(".salary") company_node = item.select_one(".company-name") area_node = item.select_one(".area") job_list.append({ "job_title": title_node.text.strip(), "salary_raw": salary_node.text.strip() if salary_node else "", "company_name": company_node.text.strip() if company_node else "", "work_area": area_node.text.strip() if area_node else "", "detail_url": url_node.get("href"), }) return job_list这里fetch_or_none的核心价值是把网络异常和页面解析彻底隔离。retry=3只处理偶发超时,不处理连续封禁;timeout=10必须写,否则某个详情页卡住后整条线程会一直停在那里。parse_list里所有select_one都做了空值判断,因为招聘网站上最常见的不是缺字段,而是某个职位卡片没有薪资节点。
列表页拿到的是简版数据,详情页才是描述文本所在地。继续写二级解析:
def parse_detail(html): soup = BeautifulSoup(html, "html.parser") education_node = soup.select_one(".education") experience_node = soup.select_one(".experience") industry_node = soup.select_one(".industry") desc_node = soup.select_one(".job-description") return { "education": education_node.text.strip() if education_node else "", "experience": experience_node.text.strip() if experience_node else "", "industry": industry_node.text.strip() if industry_node else "", "description": desc_node.get_text("\n", strip=True) if desc_node else "", "job_tags": [t.text.strip() for t in soup.select(".tag-item")], } def crawl_all(keyword="大数据", city="北京", max_pages=10): all_jobs = [] for page in range(1, max_pages + 1): html = fetch_or_none("https://example.cn/search", params={"keyword": keyword, "city": city, "page": page}) if not html: continue for item in parse_list(html): detail_html = fetch_or_none(item["detail_url"]) if not detail_html: item["detail_fail"] = True all_jobs.append(item) continue detail = parse_detail(detail_html) item.update(detail) all_jobs.append(item) time.sleep(random.uniform(1.2, 2.5)) time.sleep(random.uniform(2, 4)) return all_jobs详情页解析我用get_text("\n", strip=True)而不是每个<p>单独取,这样能保留段落间距,又不至于把文本全挤成一行。detail_fail是故意留的标记,它告诉后面的清洗层:“这条数据描述缺失,不是没抓,而是抓失败”,避免你把失败和“本来就无描述”混为一谈。
如果目标页面改成动态渲染,常见做法是到浏览器开发者工具里找 XHR 接口,优先抓返回 JSON 的接口,再用 JSON 字段映射上面的结构。拦截到接口后,代码里只要把fetch_or_none的返回从 HTML 换成 JSON 即可,两级请求的骨架不用变。
2.3 断点续爬:用哈希队列记住已抓取详情页
爬虫跑一半崩溃是最常见的事。没有断点保护,重跑一次就又重新抓一遍,既浪费流量又增加对方服务器压力。我一般会把详情页 URL 做 SHA256 哈希后存到一个文本文件里,每次抓之前先查这个集合。
import hashlib import os def url_mark(url: str) -> str: return hashlib.sha256(url.encode("utf-8")).hexdigest() def load_done(path="done_urls.txt"): if not os.path.exists(path): return set() with open(path, "r", encoding="utf-8") as f: return {line.strip() for line in f if line.strip()} def append_done(path, marks): with open(path, "a", encoding="utf-8") as f: for mark in marks: f.write(mark + "\n")在实际抓取循环里,每次解析完一个详情页,就把url_mark(detail_url)加进一个临时集合,达到一定数量后再批量写入文件。批量写入比每条都打开文件快得多,还能避免脚本被Ctrl+C后只写了一半。
提示:用哈希而不是原始 URL 是为了避免换行符和超长 URL 污染文本文件。哈希本身不可逆,不需要反解,只需要判断“抓过没有”。
2.4 请求节奏与反爬底线:慢,就是快
招聘网站的常规反爬手段是请求频率检测、User-Agent 校验、验证码。作为正常数据采集,我的原则是:只抓公开可见内容,不碰需要登录的字段,不短时间高频轰炸。常见做法是准备三五个 UA 随机切换,再把详情页请求间隔控制在 1.2 到 2.5 秒之间。
UA_LIST = [ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124.0 Safari/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/120.0 Safari/537.36", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/118.0 Safari/537.36", ] headers = {"User-Agent": random.choice(UA_LIST)}这里没有玄学,就是给服务器一个合理的访问节奏。真正容易被忽略的是:网页改版后选择器失效,爬虫不会报错,只会默默返回空列表。所以我对每个解析函数都会做“抽样校验”,抓完前 20 条先看一遍字段,确认能解析出 content 再放量跑。
3. 数据清洗与存储:把杂乱招聘文本变成可分析的二维表
爬虫拿回来的数据是脏的:薪资是“25K-40K·14薪”这种字符串,学历里有“本科及以上”也有“本科”,描述里夹杂大量“岗位职责”“任职要求”。数据清洗的目标不是把所有格式统一成一种,而是让模型和图表能吃下去。这一层我建议写成独立函数,不跟爬虫耦合,后续每次改清洗规则都能立刻重跑。
3.1 薪资字符串转可计算区间
图里最常出现的是平均月薪,所以第一步是把字符串转成数值。我习惯统一单位为“千元/月”,也就是 K。这样后续画图时,20 就代表月薪 2 万的岗位。
import re def parse_salary(raw): if not isinstance(raw, str): return None, None # 去掉“13薪”“14薪”这类补充说明 s = re.sub(r"\d+薪", "", raw).strip() if not s or "面议" in s: return None, None nums = re.findall(r"(\d+(?:\.\d+)?)\s*([Kk万])", s) if not nums: return None, None vals = [] for num, unit in nums: v = float(num) if unit in ("K", "k"): vals.append(v) elif "年" in s: vals.append(v * 10000 / 12 / 1000) else: vals.append(v * 10000 / 1000) # 补“20-30K”这种范围中缺失的单位格式 m = re.search(r"(\d+(?:\.\d+)?)\s*[-~—]\s*(\d+(?:\.\d+)?)", s) if m and len(vals) == 1: low = float(m.group(1)) if "年" in s and nums[0][1] == "万": vals.append(low * 10000 / 12 / 1000) elif nums[0][1] == "万": vals.append(low * 10000 / 1000) else: vals.append(low) if not vals: return None, None return min(vals), max(vals)这个函数保留原始salary_raw字段,新生成min_salary和max_salary两列,再算avg_salary。参数上有个血泪经验:"年" in s判断不能写在re.findall外层,因为“老员工”“年终奖”里的“年”也会误伤。真正可靠的是看薪资数字后面是否紧跟“万/年”“万·年薪”这类格式,所以我上面的分支只处理“万”单位场景下的年份折算。
日薪场景我也补了一版,用22天折算月薪。它的误判率比“年薪转月薪”高,所以我会在清洗后做一层范围校验,把低于1K或高于500K的标记为异常,而不是直接删除。异常值在画图前再人工决定去留,是这个项目里性价比最高的操作。
3.2 学历、经验字段的规则归一
经验字段常见值有“经验不限”“3-5年”“5-10年”“应届生”。学历字段常见值有“本科”“本科及以上”“硕士”“大专”。这些文本直接用 group by 会得到十几个类别,必须映射到少数几个档位。
def normalize_education(text): if not text: return "学历不限" for keyword in ["博士", "硕士", "本科", "大专", "中专", "高中"]: if keyword in text: return keyword return "学历不限" def normalize_experience(text): if not text: return "经验不限" pattern = re.search(r"(\d+)-(\d+)年", text) if pattern: return "-".join(pattern.groups()) + "年" if "不限" in text or "应届" in text: return "经验不限" return text.strip() def extract_exp_min(text): pattern = re.search(r"(\d+)-(\d+)年", text) if pattern: return int(pattern.group(1)) if "应届" in text: return 0 return Nonenormalize_education的匹配顺序很关键,必须从“博士”往“大专”降序匹配。因为“本科及以上”同时包含“本科”,顺序不对就会被低学历档位抢先。extract_exp_min是为后续画“经验要求与薪资关系”准备的数值字段,它比“3-5年”这种文本更适合做散点图的横轴。
3.3 职位描述技能词频:预设词典 + 停用词过滤
对职位描述做词频分析,最怕的是分出“岗位职责”“任职要求”“优先考虑”这些非技能词。我在这个项目里不只用 jieba 裸分词,而是先加载自定义技能词典,再用停用词表过滤,最后只统计命中技能词的文本。
import jieba from collections import Counter jieba.load_userdict("userdict.txt") stop_words = set("岗位职责 任职要求 优先 考虑 熟悉 掌握 具备 经验 能力 工作 负责".split()) def extract_skill_freq(description): words = jieba.lcut(description) hit = set() for w in words: w = w.strip() if len(w) < 2: continue if w in stop_words: continue if not re.search(r"[\u4e00-\u9fa5a-zA-Z]", w): continue hit.add(w) return hit这里我有一个比较实用的参数设置:自定义词典userdict.txt里放“Flink”“ClickHouse”“数据仓库”“实时计算”这类专业名词,停用词表里放通用动词和职位套话。每个岗位描述只保留命中的技能词集合,再和job_tags字段取并集,这样词频统计更贴近真实技术栈。
如果单纯用 Counter 统计全部分词结果,Top 20 里一定是“能力”“优先”“岗位”这种词,图表做出来对求职者毫无参考价值。这个坑基本每个做文本分析的人都会踩一次。
3.4 SQLite 落库:唯一索引是数据不会膨胀的保证
清洗完的数据我会存进 SQLite,库文件放在data/jobs.db。表结构里最重要的是detail_url的唯一索引,它能从数据库层面拦住重复数据。
CREATE TABLE IF NOT EXISTS job ( id INTEGER PRIMARY KEY, job_title TEXT, salary_raw TEXT, min_salary REAL, max_salary REAL, avg_salary REAL, experience TEXT, exp_min INTEGER, education TEXT, work_area TEXT, publish_time TEXT, description TEXT, job_tags TEXT, detail_url TEXT UNIQUE, crawl_time TEXT DEFAULT (datetime('now','localtime')) );写入时用INSERT OR IGNORE,这样重复抓到的详情页不会产生第二行记录。
import sqlite3 def append_jobs(conn, df): cols = ["job_title", "salary_raw", "min_salary", "max_salary", "avg_salary", "experience", "exp_min", "education", "work_area", "publish_time", "description", "job_tags", "detail_url"] sql = f""" INSERT OR IGNORE INTO job ({', '.join(cols)}) VALUES ({', '.join(['?'] * len(cols))}) """ rows = [tuple(r) for r in df[cols].values] conn.executemany(sql, rows) conn.commit()这里有个容易翻车的点:to_sql是 pandas 提供的最简单写入方式,但它默认不处理唯一索引冲突,重复运行会把整个表再 append 一遍。自己拼executemany虽然代码量多一点,却能让增量更新变成真正的“增量”。我一般还会把原始 JSONL 文件保留在data/raw/目录,SQLite 只是分析用的副本,数据库删了随时能从原始文件重建。
4. 分析与可视化:从技能、薪资、学历三个维度看北京市大数据岗位
可视化不是把所有图表堆在一起,而是要回答三个具体问题:北京市大数据岗位集中在哪些方向、这些岗位要求什么技能、学历和经验对薪资影响有多大。我用 pyecharts 生成 HTML 图表,好处是自带中文界面和交互,不做前端也能出图。
4.1 岗位方向归类和 Top N 柱状图
“大数据”本身是个笼统关键词,抓回来的岗位标题五花八门。第一步是写一个归类函数,把标题映射到方向。
def map_position(title): if not isinstance(title, str): return "其他" if "架构" in title: return "数据架构" if "算法" in title: return "算法" if "数仓" in title or "仓库" in title: return "数据仓库" if "开发" in title and ("实时" in title or "大数据" in title): return "大数据开发" if "分析" in title or "挖掘" in title: return "数据分析/挖掘" return "其他" df["position_type"] = df["job_title"].map(map_position)归类之后直接画 Top 10 柱状图。
from pyecharts.charts import Bar from pyecharts import options as opts def draw_position_top(df, path="charts/job_top.html"): counts = df["position_type"].value_counts().head(10) bar = ( Bar() .add_xaxis(counts.index.tolist()) .add_yaxis("岗位数量", counts.values.tolist()) .set_global_opts( title_opts=opts.TitleOpts(title="北京市大数据岗位方向分布 Top10"), xaxis_opts=opts.AxisOpts(name="岗位方向"), yaxis_opts=opts.AxisOpts(name="数量"), ) ) bar.render(path)value_counts()默认按数量降序,所以直接head(10)就能得到 Top 10。Bar的 x 轴是方向名称,长度超过六个字时,要在AxisOpts里加axislabel_opts=opts.LabelOpts(rotate=30),不然标签叠在一起没法看。这是图表参数里最不起眼但最影响阅读体验的一个。
4.2 技能词频可视化:先筛词再出图
技能词频不需要把所有词都在图里画一遍,只画预设技能词的命中次数,反而更能反映岗位真实要求。
SKILL_LIST = ["Hadoop", "Spark", "Flink", "Kafka", "Hive", "SQL", "Python", "Java", "MySQL", "Redis", "ClickHouse", "Doris", "数据仓库", "ETL", "实时计算", "数据治理", "Scala", "Pandas", "Airflow"] def count_skills(df): skill_counter = Counter() for desc in df["description"].dropna(): desc_lower = desc.lower() for skill in SKILL_LIST: if skill.lower() in desc_lower: skill_counter[skill] += 1 return skill_counter skill_counter = count_skills(df) skill_df = pd.DataFrame(skill_counter.most_common(15), columns=["技能", "命中岗位数"])这里我用的是“包含匹配”而不是分词。原因是像“Spark SQL”和“Spark Streaming”都会被 jieba 切成多个词,单独统计“Spark”时会漏掉大量上下文。用in做包含匹配虽然会多统计一些无关语境,但对岗位技能这种粗粒度分析足够用。skill_counter.most_common(15)控制显示数量,超过 15 个词会让横向柱状图高度失衡。
4.3 学历与薪资交叉分析:箱线图要看样本量
学历与薪资用箱线图展示很直观,但必须先过滤异常值,再看每个学历组的样本量。
from pyecharts.charts import Boxplot def draw_edu_salary(df, path="charts/edu_salary.html"): edu_order = ["大专", "本科", "硕士", "博士"] df = df[df["education"].isin(edu_order)] df = df[df["avg_salary"].between(3, 150)] # K 为单位,异常值先剔除 data = [] sample_n = [] for edu in edu_order: values = df[df["education"] == edu]["avg_salary"].dropna().tolist() data.append(values) sample_n.append(len(values)) box = ( Boxplot() .add_xaxis([f"{edu}\n{n}条" for edu, n in zip(edu_order, sample_n)]) .add_yaxis("平均月薪(K)", data) .set_global_opts(title_opts=opts.TitleOpts(title="北京市大数据岗位学历与平均月薪分布"), yaxis_opts=opts.AxisOpts(name="月薪(K)")) ) box.render(path)between(3, 150)过滤的是月薪 3K 到 150K 的范围,防止“日薪未折算”或“年薪折算错误”造成的极端值污染箱体。把样本量直接拼进 x 轴标签,是因为“博士”样本如果只有几条,箱体的参考价值就很弱,必须让读者一眼看到样本量。这个细节比任何分布计算都更能避免误判。
4.4 Flask 本地看板:把多张图表放进同一页面
所有图表都独立成 HTML 之后,再用 Flask 组合看板。这样数据更新后只需要重新执行分析脚本,刷新页面就能看到新图。
from flask import Flask, render_template from pyecharts import options as opts from pyecharts.charts import Bar app = Flask(__name__) @app.route("/") def index(): bar = ( Bar() .add_xaxis(["大数据开发", "数据分析", "数据仓库", "数据架构"]) .add_yaxis("岗位数", [120, 80, 45, 30]) .set_global_opts(title_opts=opts.TitleOpts(title="北京市大数据岗位看板")) ) return render_template("dashboard.html", chart=bar.render_embed()) if __name__ == "__main__": app.run(host="0.0.0.0", port=8080, debug=False)对应的模板文件templates/dashboard.html里只需要一个占位:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>北京市大数据岗位招聘看板</title> </head> <body> {{ chart|safe }} </body> </html>render_embed()会把 pyecharts 的初始化和图表配置全部渲染成 HTML 字符串,不需要额外引 echarts.min.js,部署起来最简单。但每个图表独立一个路由会更好维护,比如/chart/edu_salary只返回某个图表,首页再用 iframe 拼接。这里我只写了一个路由,目的是把“出图到展示”的最短路径先跑通。
5. 避坑:招聘数据项目的 5 个常见翻车点与排查办法
这个项目看起来简单,真正从爬虫到出图跑通,中间一定会有几个固定剧情。下面五条是我认为新手最容易踩、老手也容易疏忽的坑。
5.1 相同岗位越抓越多:重复数据问题
现象:日志记录的行数肉眼可见地涨,但看明细时发现同一个岗位标题、同一家公司、同一条薪资出现十几遍。原因:爬虫重跑时没有去重,数据库里也没有唯一索引。尤其招聘页面改版后,详情页的 URL 会带上随机参数,每次访问看起来都是“新链接”。解决:对detail_url做哈希,并在 SQLite 表上建UNIQUE索引。不要只靠标题和公司去重,同一家公司可能同时挂多个不同岗位。
5.2 薪资解析出现极值和负数
现象:图表里出现-100这种月薪,或者“3.5K”和“80K”同时出现在同一学历组。原因:手写正则没区分“年薪”和“月薪”,把“20-30万/年”折成了一个巨大的月薪数;另一部分“面议”被强制转换成0参与计算。解决:清洗层保留salary_raw原始值,解析结果只写入min_salary/max_salary;写完以后立刻跑范围校验,将落点不在1K~200K全量打上异常标记。宁可放弃这几条异常数据,也不能让它们把箱线图和柱状图带偏。
5.3 分词结果被“岗位职责、任职要求”淹没
现象:技能词频 Top 20 里全是“能力”“优先”“工作”“负责”,真正的 Spark、Flink 排在二十名开外。原因:直接用 jieba 默认词典对职位描述做全身扫描,没有加停用词表,也没有技能词白名单。解决:先维护一个userdict.txt自定义词典,把“实时计算”“数据仓库”这类复合名词加入;再维护停用词表,过滤职位套话;最后只统计命中技能词库的词。这个方法比任何高级算法都管用。
5.4 详情页突然返回登录页,解析不到描述
现象:请求状态码是 200,但description和job_tags全是空字符串。原因:目标站点检测到短时间高频请求后,把详情页重定向到了统一登录页。这个页面解析不出来,但状态码依然是 200。解决:在parse_detail里校验描述文本长度,低于 50 个字符就视为“疑似反爬页面”,跳过本次详情请求并延长等待时间。不要用登录账号去绕规则,只抓公开内容才是这个项目能长期跑下去的前提。
5.5 可视化图表中文变方块
现象:生成 HTML 后用浏览器打开,x 轴上的“大数据开发”“数据仓库”全部变成小方块。原因:HTML 页面声明了 UTF-8,但 pyecharts 渲染时某些操作系统缺少对应中文字体,或者模板里没有<meta charset="UTF-8">。解决:在模板<head>里显式写<meta charset="UTF-8">,图表标题里的中文不要从文件里二次读取,避免编码转换。如果是保存服务器静态文件,还要确认系统中文字体已安装,否则图表嵌入图片时会缺字。
6. 最后一个技巧:给数据管道加“自检”,让每日增量更新不再靠人盯
数据管道最怕的不是出错,而是出错之后没人发现。我习惯把爬虫、清洗、出图串成一个入口脚本,再让调度器每天跑一次,最后必须经过自检函数才算通过。
6.1 用调度器把抓取、清洗、出图串成一条命令
把前面的逻辑收进pipeline.py:
from apscheduler.schedulers.blocking import BlockingScheduler def pipeline(): crawl_and_save_raw() # 1. 增量抓取,写 JSONL 和 SQLite clean_and_check() # 2. 清洗、去重、解析薪资 render_all_charts() # 3. 重跑所有图表和看板 scheduler = BlockingScheduler() scheduler.add_job(pipeline, "cron", hour=9, minute=0, id="daily_job") scheduler.start()这里没有指定timezone,默认使用系统本地时区。如果你把脚本部署在 UTC 的容器里,就必须在BlockingScheduler()里传入服务器本地时区,否则“每天早上 9 点”会变成一个诡异的时间点。跑一次不是终点,连续监控一周不出问题才算真的落地。
6.2 数据质量自检函数:先确认再出图
出图前先做一次数据自检,能挡住绝大多数“看起来在跑,实际在产废数据”的情况。
def self_check(db_path="data/jobs.db"): with sqlite3.connect(db_path) as conn: total = conn.execute("SELECT COUNT(*) FROM job").fetchone()[0] no_desc = conn.execute( "SELECT COUNT(*) FROM job WHERE length(trim(description)) < 50" ).fetchone()[0] bad_salary = conn.execute( "SELECT COUNT(*) FROM job WHERE min_salary > max_salary" ).fetchone()[0] assert total > 1000, f"数据量过少: {total}" assert no_desc / max(total, 1) < 0.05, f"描述缺失过多: {no_desc / max(total,1):.2%}" assert bad_salary == 0, f"薪资字段异常 {bad_salary} 条" print(f"自检通过: {total} 条岗位, 描述缺失 {no_desc} 条, 薪资异常 {bad_salary} 条")这个函数我一般放在pipeline()的最后,也就是渲染图表之前。如果某天爬虫被反爬挡住,描述缺失率会突然飙升,自检会直接抛异常,看板不会被自动更新成一份“半成品”。我现在养成的习惯是:每天上午看一次自检日志,而不是反复打开图表页面肉眼找问题。看到“自检通过”四个字,再刷新看板,才敢确认今天的岗位数据是干净可用的。希望这个习惯也能帮到你。
本文还有配套的精品资源,点击获取