1. 从"手动刷招聘"到"自动筛岗":这个项目的出发点
又到一年毕业季,我在某高校的信息中心帮忙做就业数据相关的技术支持。一位负责就业指导的A老师跟我聊起一个很现实的问题:他们学校每年毕业生几千人,就业信息网每天更新的岗位上万条,学生自己刷——费时间;老师人工筛选推荐——费人力;更麻烦的是,很多岗位藏在不同的招聘平台、学校就业网、企业官网里,谁也没法打包看全。
聊完之后我们定了个需求:能不能做一个系统,把零散招聘信息统一抓下来,洗干净,按专业、地域、薪资、学历要求做分析,再给学生做智能推荐,最后用一块可视化大屏把就业市场的整体情况展示出来?于是就有了这个基于大数据的高校毕业生招聘信息推荐系统。整个项目包括四块核心内容:多源爬虫采集、数据清洗与分析、个性化推荐引擎、可视化大屏展示。
前后花了大约六周做完,目前已经在某高校的就业指导中心试运行,效果比较稳定。这篇文章把我整个项目的设计思路、技术选型、踩坑过程、具体实现步骤都拆开聊一聊,给正打算做类似系统的朋友一些参考。项目里涉及的技术栈不复杂:Python爬虫、MySQL存储、Pandas清洗、协同过滤推荐、ECharts大屏,都是比较成熟的东西,难点不在某单个环节,而在整体链路怎么串起来。
2. 系统整体架构设计:先画出数据流动的方向再动手
2.1 为什么先定数据流而不是先写爬虫
我见过不少同学一上来就写爬虫,结果爬到一半发现字段不够用、数据格式乱、或者存储设计不合理,又回头重来。这个项目我第一周啥代码都没写,先画了一张数据流图,把从原始网页到最终大屏展示的完整链路理清楚。
数据的流转方向是:多源采集层 → 原始数据层 → 清洗加工层 → 分析计算层 → 应用展示层。
采集层负责从若干招聘网站、学校就业网、企业校招页抓取原始HTML页面;原始数据层把抓到的内容按原样存下来,保留一个"后悔池";清洗加工层做去重、补全、格式统一、字段标准化;分析计算层做专业匹配、薪资分布、地域热度、行业需求统计;应用展示层就是给就业指导老师和学生用的前端界面和大屏。
这条链路看起来简单,但对后续开发非常重要——每层只依赖上一层的数据结构,不会互相污染。比如爬虫层今天改了页面解析逻辑,清洗层不用动,因为清洗层只看数据库表结构,不关心网页长什么样。
2.2 数据表结构设计:把"岗位"这个核心实体拆清楚
数据库表是整个项目的地基,MySQL里我设计了六张核心表,反复调了两版才确定。
岗位信息表是主表,字段包括岗位ID、岗位名称、公司名称、公司行业、工作地点(省/市/区)、学历要求、经验要求、薪资下限、薪资上限、招聘人数、岗位描述、发布日期、抓取来源、抓取时间。主表之外还有专业要求表、技能要求表、公司信息表、用户表、用户行为表。
这里特别想说的是专业要求表和技能要求表为什么要单独拆出来。招聘信息里岗位描述往往是整段文本,比如"计算机相关专业,熟练使用Python,有数据分析经验优先",如果把这段文字当成一个字段存着,后面做推荐和统计时会非常痛苦——你没法直接算出"哪些公司要了多少计算机专业的学生"。单独拆成两张关联表之后,清洗时做NLP分词+规则匹配,把结构化信息提取出来,后面所有分析都方便。
用户行为表则是为推荐算法准备的。表里记录学生ID、岗位ID、行为类型(浏览、收藏、投递、忽略四类)、行为时间。推荐算法不靠学生填写的意向问卷,而是靠真实行为数据,效果会好很多。
2.3 技术选型有哪些?为什么选Python全家桶
技术栈这部分我直接给结论,都是经过实际项目验证的选择:
| 环节 | 工具 | 选型理由 |
|---|---|---|
| 爬虫采集 | Python + Requests + BeautifulSoup + Scrapy | Requests处理单页够用,Scrapy做批量采集效率高,BeautifulSoup做页面解析很顺手 |
| 数据存储 | MySQL 8.0 | 数据量级在几十万条,MySQL完全胜任,查询方便,配套工具多 |
| 数据清洗 | Pandas + jieba | Pandas处理表格类数据效率极高,jieba做中文分词,用于专业和技能提取 |
| 推荐引擎 | Python自写协同过滤 + 基于规则的Freemarker模板 | 不引入太重的外部框架,逻辑透明,便于调整 |
| 可视化 | ECharts + Vue | ECharts大屏方案成熟,图表生态齐全,Vue做页面框架开发快 |
| 定时调度 | APScheduler | 写爬虫任务和清洗任务非常简单,支持cron表达式 |
| 服务器 | Linux + Nginx + uWSGI | 部署Flask应用比较稳定,Nginx做反向代理和静态文件服务 |
有些朋友可能会问,为什么不直接用现成的招聘数据服务或者对方平台提供的开放接口,省掉爬虫这一步?我也考虑过,但实际调研发现,各大招聘平台的数据接口权限限制非常多,尤其是针对校园招聘的细分数据,开放程度很低。学校就业信息网有自己的发布渠道,但企业岗位数据也有滞后。自建爬虫虽然工作量大一点,胜在数据源完全可控,想怎么抓就怎么抓。
3. 爬虫模块:多源采集的设计细节与反爬应对
3.1 目标站点分析:先看清楚对手再动手
我给这个项目选了四类数据源,每类的页面结构、更新频率、反爬强度都不一样,必须分开设计爬虫策略:
第一类是某综合招聘平台的校园招聘频道,页面是静态HTML加少量AJAX渲染,更新频率高、反爬中等——有简单的请求频率限制,但页面结构规整,解析难度低。第二类是该平台的社会招聘频道,用于扩展数据量,页面结构与校招频道类似,但字段更杂。第三类是学校自己的就业信息网,页面是纯静态HTML,发布速度最快,几乎没有反爬,是数据的"基本盘"。第四类是若干目标企业的官网校招页,结构各不相同,有的接口直接返回JSON,有的需要渲染JS。
很多人在这一步喜欢直接写一个通用爬虫,试图覆盖所有网站。我的经验是:通用爬虫在中小规模采集项目中是个美丽的陷阱。每个网站都有自己的特殊逻辑,硬套统一框架反而要写大量if-else分支。我的做法是先按网站分模块写独立爬虫,每个模块只负责一个网站的解析,最后统一入库模块对接。代码量是稍微多了一点,但后期维护成本极低——某个网站改版了,只需修改对应的那个文件。
3.2 爬虫的具体实现:Requests + BeautifulSoup怎么配合
以学校就业信息网为例,它的页面结构比较简单:列表页是一个table,每行一个岗位,岗位详情页是独立URL。我用的方案是Requests发请求,BeautifulSoup做解析,循环遍历列表页的所有分页。
列表页解析的代码大概是这个思路:
import requests from bs4 import BeautifulSoup def parse_list_page(page_url): """解析列表页,返回所有详情页链接和基础字段""" headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" } resp = requests.get(page_url, headers=headers, timeout=10) resp.encoding = "utf-8" soup = BeautifulSoup(resp.text, "html.parser") results = [] rows = soup.select("table tr") for row in rows: cells = row.find_all("td") if len(cells) < 4: continue link = cells[1].find("a") if not link: continue item = { "post_name": cells[1].text.strip(), "company_name": cells[2].text.strip(), "detail_url": link.get("href"), "publish_date": cells[3].text.strip(), } # 把相对链接补全成绝对链接 if item["detail_url"].startswith("/"): item["detail_url"] = "https://example.edu.cn" + item["detail_url"] results.append(item) return results列表页拿到之后,还需要挨个请求详情页,提取正文中的岗位描述、薪资、学历要求这些更细的字段。这里我有一个实际经验:不要对列表页和详情页分开请求,最好把列表页解析结果直接放进任务队列,然后启动一个线程池并发抓详情页。学校就业网并发开8个线程就够了,开太多会把学校服务器搞挂,不好交代。企业官网则可以开16个线程,招聘平台只开4个,因为对方反爬严。
3.3 反爬处理:我把踩过的坑一个个排掉了
反爬是这个项目里最容易让人崩溃的环节。我遇到的坑和解决办法整理如下:
第一个坑:招聘平台会校验User-Agent和Referer,不带上这两个字段直接返回403。后来在请求头里伪装成浏览器访问,同时加上随机的请求间隔(2到5秒),问题解决。
第二个坑:部分平台对访问频率做了限制,单IP超过每分钟30次请求就会被封一段时间,表现是返回一个验证码页面。我的对策是给每类网站设置不同的请求频率上限,同时用APScheduler做定时调度,把抓取任务分散在凌晨和下午执行,避开高峰。
第三个坑:某企业官网的校招数据是通过接口返回JSON的,直接在网页源码里看不到内容。这种其实倒好办——用浏览器开发者工具抓XHR请求,找到真实接口的URL和参数,直接用Requests请求这个接口拿JSON,反而比解析HTML更省事。
def fetch_company_api(): """通过JSON接口直接拿数据""" api_url = "https://example-company.com/api/campus/positions" params = { "page": 1, "size": 50, "keyword": "", "city": "" } headers = { "User-Agent": "Mozilla/5.0", "X-Requested-With": "XMLHttpRequest", "Referer": "https://example-company.com/campus" } resp = requests.get(api_url, params=params, headers=headers, timeout=10) data = resp.json() # 在分页循环里递增page参数,直到拿完所有数据 return data还有一个比较隐蔽的问题:招聘平台夜间和白天返回的页面结构不完全一致,夜里返回的可能缺少部分字段。我的对策是把采集任务固定安排在每天早上9点和下午3点各跑一次,避开站点后台批量更新数据的时间窗口。虽说不太严谨,但实际效果很好,数据完整率从78%提到了95%以上。
3.4 采集链路里容易被忽略的"后悔池"设计
刚开始做这个项目时,我图省事,爬虫解析完直接就往MySQL里写,字段拼错了或者来源页面改了结构,数据就没法追溯。后来我加了一层原始数据存储:每次爬虫请求到的原始HTML文本,按天和来源站点分目录存成JSON文件,文件名带上时间戳。
这样设计的好处是:解析逻辑写错了,可以回过头用原始文件重新解析,不用重新爬一遍网站;被反爬封了,有原始数据做参照,方便判断是请求被拦截还是页面结构变化。缺点是磁盘占用多一些,但一天也就几百MB,完全可接受。这个"后悔池"在项目调试阶段帮了大忙,至少帮我省下了三次重新爬数据的时间。
4. 数据清洗与分析:让几十万条脏数据变成可信情报
4.1 数据质量审计:先搞清楚脏在哪,再谈清洗
把四个数据源的原始数据全部入库之后,我先做了一次数据质量审计。不看不知道,一看吓一跳,数据脏的程度远超预期。我统计了一下,主要问题集中在四类:
第一类是字段缺失。岗位描述缺失约12%、薪资字段缺失约18%、学历要求缺失约9%。第二类是格式不统一。薪资有的写"8k-12k",有的写"8000-12000元/月",有的写"面议",还有的写"8K~13K",不统一没法计算。第三类是重复数据。同一个岗位被不同数据源重复采集,或者同一公司同一岗位在不同时间被多次抓取,需要去重。第四类是内容噪声。岗位描述里混杂着公司介绍、员工福利、面试地址等信息,对推荐和分析没有直接用处。
做数据清洗之前,一定要先做审计,别急着开写代码。审计的结果直接决定清洗规则的优先级。比如重复数据占比太高,去重逻辑就是第一优先级;如果薪资缺失太严重,就得考虑是否用同类岗位的均值做填补,还是直接把"面议"作为一个单独档次处理。
4.2 清洗Pipeline的实现:从原始字段到标准字段
清洗模块我写了一个Pipeline,按顺序走:去重、字段补全、薪资标准化、学历与专业提取。每一步都输出中间结果,方便检查在哪一步丢了数据。
去重我用的方案是"联合键+哈希":对来源站点、岗位名称、公司名称、发布日期这四个字段做组合判断。因为同一岗位在不同平台发布时,标题和公司名往往一模一样,只是发布时间可能有几天误差,我允许日期差在3天以内视为同一条记录,取最早发布时间的那条作为主记录。
薪资标准化是清洗模块里最磨人的部分。我写了专门的解析函数,先把各种写法统一成数字档位,然后拆出下限和上限。
import re def parse_salary(raw): """把各种薪资写法解析成(下限, 上限)元组,单位统一为K""" if not raw or "面议" in raw: return None raw = raw.replace("元/月", "").replace("元", "") # 统一成数字+k的单位 raw = raw.upper().replace("K", "K").replace("千", "K") pattern = re.compile(r"(\d+(?:\.\d+)?)\s*[-~—–]\s*(\d+(?:\.\d+)?)K?") match = pattern.search(raw) if match: low = float(match.group(1)) high = float(match.group(2)) # 统一成K为单位,方便比较 return (low, high) match_single = re.compile(r"(\d+(?:\.\d+)?)\s*K").search(raw) if match_single: val = float(match_single.group(1)) return (val, val) return None学历要求这块我也是用规则匹配,把"本科、硕士、博士、大专"等关键词从文本中抽出,没有明确写学历要求的数据统一标记为"不限"。地方有争议,就做一个优先级判断:文本里提到"本科及以上",优先算本科。
4.3 专业相关度分析:用jieba分词加行业词典
推荐系统要做专业匹配,就得先算岗位和专业的相关度。传统做法是人工打标签——每个岗位标出对口专业,比如Java开发岗对计算机、软件工程。但面对几十万条岗位数据,人工标注不现实,我用的是"行业专业词典+jieba分词"的自动化方案。
先整理了一份行业专业词典,大概一千多组映射关系,比如"软件工程""计算机科学与技术""物联网工程"归入计算机大类,"市场营销""工商管理""电子商务"归入经管大类。再对岗位描述做jieba分词,筛掉停用词,看命中了哪些专业关键词,按命中数量和权重打分,得到专业相关度分数。
这个方案简单有效,但也有个明显的局限:词典覆盖不到新兴交叉岗位,比如"数据标注工程师"对应什么专业,词典里就没有。我的补充方案是:建立一个"专业未识别岗位"列表,每周人工过一遍,把新出现的专业词补充进词典。系统跑了两周之后,字典覆盖的岗位比例从76%提高到了89%。
4.4 核心分析指标:到底拿这些数据算什么
数据洗干净了,接下来做分析。我根据就业指导老师的使用需求,定了五类核心统计指标:
第一类是按专业的需求规模统计,算的是"某个专业大类能匹配的岗位总量",给老师看哪些专业是需求旺盛型。第二类是薪资分布统计,按城市和学历两个维度交叉分析,比如"北京+本科的平均薪资""成都+硕士的薪资中位数"。第三类是行业热度分析,按公司所属行业聚合岗位数量和平均薪资。第四类是地域流向分析,统计各城市岗位占比,同时对比该城市岗位的学历要求结构。第五类是岗位发布时间趋势,按周统计新增岗位的数量,帮助老师判断校招旺季的节奏。
这些指标用SQL就能查出来,但要在后台定时算好并写入统计表,前端展示时直接查统计结果,避免实时聚合拖慢渲染。统计任务和爬虫任务一样,用APScheduler统一调度,每天凌晨2点跑一次全量重算,白天有增量数据只做局部更新。
5. 推荐引擎设计:协同过滤之外还要加规则约束
5.1 为什么不能用纯协同过滤给应届生推荐岗位
推荐系统是项目的核心亮点之一。最开始我试了纯协同过滤——就是经典的"跟你类似的人喜欢的岗位,也推荐给你"。但这种方案在应届生就业场景里有一个致命的逻辑问题:应届生没有足够的历史行为数据,冷启动问题非常严重。一个刚注册的学生,只浏览了两三个岗位,协同过滤根本没法找到"相似用户"。
于是我把推荐策略改成"混合推荐":先用规则推荐解决冷启动,再用协同过滤做进一步个性化。规则推荐的基本逻辑是:根据学生的专业、期望城市、学历层次、毕业时间,从岗位库里筛出匹配的岗位集合。这一步相当于漏斗过滤,先把完全不对口的岗位滤掉,缩小候选集。协同过滤则在候选集内部做排序优化——根据其他相似学生的行为,把最可能投递的岗位排到前面。
5.2 推荐排序核心代码的拆解
规则过滤和协同过滤的完整流程我贴一段核心代码。输入是学生基本信息和历史行为序列,输出是排序好的岗位ID列表。
import numpy as np from collections import defaultdict def hybrid_recommend(student, positions_df, behavior_matrix): """ 混合推荐:规则过滤 + 协同过滤排序 student: {major, city, education, grad_year} positions_df: 清洗后的岗位DataFrame behavior_matrix: 用户-岗位行为矩阵,行为类型: view=1, favorite=2, apply=3, ignore=-1 """ # 第一步:规则过滤 filtered = positions_df[ (positions_df["major_tag"] == student["major"]) & (positions_df["city"].str.contains(student["city"])) & (positions_df["education_level"] <= student["education"]) ].copy() if len(filtered) == 0: # 放宽城市限制再试一次 filtered = positions_df[ (positions_df["major_tag"] == student["major"]) & (positions_df["education_level"] <= student["education"]) ].copy() # 第二步:协同过滤打分 # 实际项目里用相似用户公式,这里给出简化版本 user_id = student["user_id"] if user_id in behavior_matrix.index: sim_users = find_similar_users(user_id, behavior_matrix, top_k=20) score = defaultdict(float) for other_user, sim_score in sim_users: # 该相似用户对岗位的行为权重 for pos_id, behavior_val in behavior_matrix.loc[other_user].items(): if pd.notna(behavior_val) and pos_id in filtered["post_id"].values: score[pos_id] += sim_score * behavior_val filtered["rec_score"] = filtered["post_id"].map(score).fillna(0) else: # 冷启动:用规则打分作为初始分 filtered["rec_score"] = filtered["salary_avg"] / 10000.0 + \ filtered["post_publish_days"].apply(days_to_score) result = filtered.sort_values("rec_score", ascending=False) return result.head(20)[["post_id", "post_name", "company_name", "salary_avg", "rec_score"]]规则过滤部分解释一下"education_level"这个字段。清洗时我把学历要求做了等级化处理:大专=1、本科=2、硕士=3、博士=4。学生学历取自身等级,岗位要求取最高等级。过滤条件是"岗位要求最高等级小于等于学生等级",也就是本科学生可以投本科和大专要求的岗位,但硕士岗位不能投。
5.3 推荐效果怎么评价:不能只看点击率
推荐效果评估我用了三类指标,而不是单一的点击率。第一是覆盖率,推荐列表里出现的不同岗位数量占总岗位池的比例,太低了说明推荐结果单一化;第二是投递转化率,推荐位带来的投递行为占全部投递的百分比;第三是三天回访率,看看学生看了推荐之后还会不会再回来看下一个推荐批次。
测试阶段我用了一组真实行为日志做回测:随机选取50名学生,把他们的历史行为切分成训练集和测试集,用训练集计算推荐结果,再比对测试集里实际情况。A/B对照时,混合推荐组的投递转化率比纯规则组高了约23%,点击率高了约17%,证明协同过滤阶段确实贡献了价值。但也要说清楚,这批数据量不大,效果只能算初步验证,等系统积累更多行为数据之后,指标还有优化空间。
6. 可视化大屏设计:把数据变成老师能看懂的信息
6.1 大屏的布局逻辑:不是堆图表,是讲故事
可视化大屏是项目的门面,就业指导中心的老师、学校领导都可能看这块屏幕,所以它的设计不能是花架子。我参考了大屏设计的一些常见布局,按阅读优先级分成三个层次:顶部核心指标区、中部趋势分析区、底部细分数据区。
顶部放了三个核心数字:本周新增岗位数、累计在招岗位数、匹配推荐成功人数。这三个数回答的是"整体态势怎么样"的问题。中部是一张全国地图加一条折线图,地图显示各城市岗位分布热度(用散点大小代表岗位量),折线图显示近30天岗位发布趋势。最底部是三张辅助图表:各专业大类需求占比(柱状图)、各行业平均薪资排行(横向柱状图)、学历与薪资交叉分析(堆叠柱状图)。
整个大屏色调我用了深蓝色背景加亮色图表,深色背景对LED大屏的显示效果更友好,亮色数据点也更突出。顶部大数字用红色或金色强调,其他图表用统一的蓝色系和绿色系,视觉上干净统一,不会乱。
其实大屏这步不太需要我写太复杂的交互逻辑,核心报表已经在后台算好,前端只需要定时轮询接口更新数据。大屏页面每5分钟自动刷新一次数据,脚本用Vue管理界面状态,图表渲染用ECharts。
6.2 ECharts图表实现的几个关键配置
写ECharts配置时,有几个容易出错的点分享一下。首先是地图组件,大屏里用中国地图展示城市岗位分布,需要额外加载地图GeoJSON数据,ECharts 5开始默认不内置中国地图,要自己在项目里放入地图JSON文件。我用的方案是把GeoJSON手动注册到echarts.registerMap,然后配置series的map类型。
// 注册中国地图数据 fetch('/static/china.geo.json').then(res => res.json()).then(geoJson => { echarts.registerMap('china', geoJson); const myChart = echarts.init(document.getElementById('map-container')); myChart.setOption({ series: [{ type: 'map', map: 'china', roam: true, itemStyle: { areaColor: '#1e2a4a', borderColor: '#3a6ea5' }, data: cityHeatData }], visualMap: { min: 0, max: 5000, text: ['高', '低'], seriesIndex: 0, inRange: { color: ['#0e1a35', '#2a5ca8', '#4a90d9', '#7ec3ff'] } } }); });第二个容易踩的坑是大屏实时刷新时要先setOption并清掉旧数据,直接用setOption里的notMerge参数:
// 每隔5分钟刷新一次大屏数据 setInterval(() => { fetch('/api/dashboard/stats').then(r => r.json()).then(data => { updateTopNumbers(data.top_stats); updateChartOptions(data.chart_data, true); // notMerge为true,覆盖旧配置 }); }, 300000);换了notMerge参数之后,滚动刷新时图表不会再出现图标残留或数据错乱,这个细节值得提一下。
6.3 大屏后端API怎么设计:一个接口对应一个图表
后端我用的Flask框架,API设计原则是"一个图表一个接口":顶部数字对应一个接口,地图对应一个接口,折线图对应一个接口,三张底部图表各对应一个接口。接口返回的都是JSON,前端直接setOption用。
设计时还有一个细节:对接口做"归一化"处理。比如薪资分布接口返回的数据,后端已经按"学历-城市-平均薪资"组织好了,前端不用再写计算逻辑,只负责渲染。这样不仅减少了前端工作,也避免了一处改动导致前后端逻辑不同步的问题。遇到数据没算好的情况,宁可后端返回空数组,也不要返回null,不然前端处理起来容易报错。
6.4 实际部署和调试时遇到的小问题
大屏在这台专用Windows机器上跑的时候,遇到两个比较意外的问题。第一个是分辨率适配问题。大屏是竖屏展示,但页面默认布局是横向16:9,第一次投上去布局全乱。后来我在CSS里用了vw和vh单位做自适应布局,再用flex布局分区,效果就正常了。第二个是浏览器缓存问题。大屏页面在设备上长时间挂着,旧图表数据一直不更新,后来给API请求地址加了一个时间戳参数,强制绕过缓存:
const fetchUrl = '/api/dashboard/map_data?ts=' + Date.now();这两个问题都不难解决,但如果在开发环境测试时不用大屏真机验证,很容易漏掉。
7. 部署与运维:从开发机到服务器遇到的一串坑
7.1 三层部署:爬虫、Web应用、数据库分开跑
项目部署我分成了三台机器:爬虫服务器、Web应用服务器、数据库服务器。一个很朴素的理由:爬虫任务IO密集且容易出异常,Web应用对外提供服务,数据库读写频繁,三个角色混在一起部署,一个出问题容易影响另外两个。实际环境里可能只需要一台机器,但即便只用一台,也要用systemd或者supervisor把不同进程隔离管理。
部署细节方面,爬虫和Web应用各用了一个Python虚拟环境,依赖清单全量记录在requirements.txt里。数据库使用MySQL 8.0跑在独立的分区,每天凌晨通过crontab自动备份,备份保留最近7天。
7.2 定时任务调度踩坑:APScheduler的misfire问题
APScheduler跑定时任务时,我遇到了misfire(错失触发)的问题。场景是这样:爬虫任务原本设定每天9:00执行,但因为某次网络异常导致任务卡了半小时,9:00的任务被漏掉了,而misfire_grace_time默认是1秒,超过1秒就直接跳过,数据当天就没抓到。
解决办法是把misfire_grace_time调到3600秒,同时把任务改成max_instances=1,避免前一个实例还没跑完,后一个实例就启动,导致数据重复。调完之后,这个问题基本没再复现过。
还有个坑是时区问题。APScheduler默认使用本地时区,但服务器时区如果设置成UTC,任务执行时间就会差8小时。项目上线第一天我就踩了这个坑,9点的任务在凌晨1点就跑了,数据抓了个寂寞。统一把服务器时区设成Asia/Shanghai,然后在APScheduler的配置里显式指定timezone。
7.3 日志监控与告警:没有日志的系统等于裸奔
项目上线第一周,我一直觉得系统跑得很安稳。直到有一天老师反馈当天大屏数据没更新,查了半天才发现爬虫进程因为一个页面解析异常直接退出了,而APScheduler不会自动拉起已退出的进程。那之后我给三个核心进程都加了supervisor托管,同时给核心节点的异常写入了日志文件,定时扫描日志关键词,匹配到"ERROR""CRITICAL"就发送邮件告警。
日志这块我养成了习惯:爬虫每次执行写一行摘要日志(采集了多少条、成功多少、失败多少、耗时多少秒),清洗任务也写摘要日志(输入条数、输出条数、去重丢弃多少条)。运维阶段查问题,靠的就是这些摘要日志快速定位发生在哪一步。
8. 复盘总结:几个真正影响成败的经验
8.1 先和业务使用方对齐"推荐成功的标准"
项目做到推荐模块时,我和A老师确认过一个很关键的问题:什么叫"推荐成功"?我的第一反应是推荐了岗位,学生点击了就算成功。但老师给的答案不一样:学生投递了简历、并且收到了面试通知,才算真正的成功。
这个定义一变,整个推荐系统的评价标准就变了。光看点击率不够,要看投递后的面试转化率。但问题来了,面试结果这个数据不会自动产生,需要学生使用系统时主动回传状态。于是我在系统里加了一个简单的"求职反馈"功能,学生投递后可以标记"已面试"或"已录用"。这批数据目前还在积累阶段,但至少评价标准是对的了。
这件事给我最大的触动是:技术项目最容易犯的错误,是自己定义"好"的标准,而不去问业务方"好"的标准。数据采集再全、算法再漂亮,评价口径错了,系统做得再炫也不解决实际问题。
8.2 数据质量比算法重要得多
推荐算法写得再复杂,如果基础数据本身是脏的,结果一定是错的。比如某平台上同一家公司把同一个岗位发布了5次,薪资分别写的是"面议"和"15k-20k",清洗时如果没有去重和标准化,推荐系统就会把这个岗位当成5个不同岗位推荐给学生,而且薪资差异极大。
这个项目做完之后,我在数据清洗上花的时间大概占了30%以上。现在回头想,这是最值钱的部分。推荐算法用的是简单协同过滤加规则,效果已经很好;如果数据清洗不到位,换再复杂的深度模型也救不回来。
8.3 关于扩展:下一步可以做岗位趋势预测
系统目前基本稳定,后续可扩展的方向我也想了两个。一是岗位需求趋势预测,现在数据量已经积累到可以按周、按月做时序分析了,用简单的移动平均或者指数平滑,预测下月哪些专业热门程度上升,对指导学生选方向很有参考价值。二是和学生就业管理系统打通,把推荐结果自动同步到学生的个人中心,甚至对接投递状态回传,形成完整的闭环。
这两个方向暂时还没动手,但数据表结构在设计时已经预留了扩展字段,到时候不用大改。项目做到这个阶段,"能用、好用、有人用"比"做得特别大"更有价值。