ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Django+协同过滤招聘推荐系统:从爬虫采集到推荐接口实战

2026/10/6 5:21:23 拓冰建站 浏览量
Django+协同过滤招聘推荐系统:从爬虫采集到推荐接口实战 简介这是一套面向计算机专业学生与初学者的信息管理系统实战项目采用Python3、Django、MySQL5.7、Vue与爬虫技术构建包含可运行源码、SQL文件与LW文档可直接用于毕业设计、课程设计、大作业或工程实训。系统前端以导航条串联各功能页面个人中心支持信息更新管理员端覆盖用户管理、留言板管理等模块并配套爬虫实现数据采集。压缩包共508个文件约23.35MB其中vue组件与js脚本构成前端交互py文件承载后端逻辑svg、jpg、png等图片资源用于界面展示另有sql建表脚本、html模板及woff、ttf字体文件目录结构完整。目前已有84人学习下载。对于缺少项目经验的学习者这份资源提供了从数据库设计到前后端联调的完整参考可帮助快速理解Django与Vue的协作方式并借助爬虫模块掌握数据获取思路适合作为入门到进阶的实践范本。1. 招聘推荐系统为什么用协同过滤而不是关键词匹配投过简历的人都有体会搜“Python 后端”返回的岗位里混着一堆“Python 讲师”“Python 运维”甚至还有“Python 少儿编程老师”。关键词匹配的问题在于它只看字面不理解“看过这个岗位的人还看了什么”。招聘场景里求职者的行为序列其实非常值钱——谁投了 A 岗又投了 B 岗谁浏览了 C 岗之后停留了 3 分钟这些信号比简历里的技能标签更能反映真实偏好。这个标题讲的就是用协同过滤算法做招聘信息推荐后端用 Django数据采集用 spider 爬虫最终打包成一个可运行的推荐系统。它解决的核心问题是把“人找岗位”变成“岗位找人”让求职者打开首页就能看到跟自己行为相似的人都在投什么。适合谁看有 Python 基础、想做一个完整 Django 项目练手的人或者手里有招聘数据、想快速搭一个推荐原型验证效果的人。整套东西不复杂但有几个地方容易翻车后面会逐个拆。2. 协同过滤在招聘场景的落地选型UserCF 还是 ItemCF2.1 招聘数据稀疏性对算法选择的影响招聘数据和电商数据有一个本质区别岗位的“生命周期”很短。一个岗位挂出来两周可能就招满了而用户的求职周期也就一两个月。这意味着用户-岗位评分矩阵比电影推荐、商品推荐稀疏得多。MovieLens 数据集稀疏度大概 6%招聘场景轻松超过 99.5%。在这种稀疏度下UserCF基于用户的协同过滤找“相似用户”会非常吃力——两个用户共同投递的岗位可能只有 1 个相似度计算出来全是噪声。ItemCF基于物品的协同过滤反而更稳因为岗位之间的关联可以通过“同一用户投递过”来建立哪怕只有一两个共同投递者也能算出岗位相似度。我一般会这样选如果用户量大于岗位量比如校园招聘几万学生对着几千个岗位用 ItemCF如果岗位量远大于用户量比如高端猎头场景UserCF 反而有优势。这个项目标题没限定场景但招聘系统默认按 ItemCF 来设计更稳妥。2.2 用 Django 模型定义用户-岗位行为矩阵先别急着写算法数据模型没设计好后面全是坑。招聘推荐系统至少需要三张核心表用户表、岗位表、行为表。行为表记录“谁对哪个岗位做了什么”这是协同过滤的输入。# models.py from django.db import models from django.contrib.auth.models import User class Job(models.Model): title models.CharField(max_length200) company models.CharField(max_length200) city models.CharField(max_length50) salary_min models.IntegerField(default0) salary_max models.IntegerField(default0) skills models.TextField(blankTrue) # 逗号分隔的技能标签 created_at models.DateTimeField(auto_now_addTrue) def __str__(self): return f{self.company} - {self.title} class UserBehavior(models.Model): BEHAVIOR_TYPES ( (view, 浏览), (apply, 投递), (collect, 收藏), ) user models.ForeignKey(User, on_deletemodels.CASCADE) job models.ForeignKey(Job, on_deletemodels.CASCADE) behavior_type models.CharField(max_length10, choicesBEHAVIOR_TYPES) weight models.FloatField(default1.0) # 不同行为给不同权重 created_at models.DateTimeField(auto_now_addTrue) class Meta: indexes [ models.Index(fields[user, job]), ]这里的关键设计是weight字段。浏览给 1 分收藏给 3 分投递给 5 分——这是招聘场景的常见做法。为什么不用 0/1 矩阵因为投递行为比浏览行为强得多如果不加权协同过滤会把“随便看看”和“认真投递”当成一回事推荐结果会偏。UserBehavior表上建了(user, job)联合索引因为后面算相似度时要频繁按用户查行为记录没索引的话数据量一上来查询就崩。2.3 构建评分矩阵与相似度计算Django 的 ORM 查出来的数据是对象列表协同过滤需要的是矩阵。中间要加一层转换把行为记录转成{user_id: {job_id: score}}的嵌套字典。# recommender.py from collections import defaultdict from .models import UserBehavior import math def build_user_item_matrix(): 从数据库构建用户-岗位评分矩阵 matrix defaultdict(dict) behaviors UserBehavior.objects.select_related(user, job).all() for b in behaviors: uid b.user_id jid b.job_id # 同一用户对同一岗位多次行为取最大权重避免重复累加 if jid in matrix[uid]: matrix[uid][jid] max(matrix[uid][jid], b.weight) else: matrix[uid][jid] b.weight return matrix def item_similarity(matrix): 计算岗位之间的余弦相似度 item_users defaultdict(set) for uid, items in matrix.items(): for jid in items: item_users[jid].add(uid) sim defaultdict(dict) for i in item_users: for j in item_users: if i j: continue co_users item_users[i] item_users[j] if not co_users: continue # 余弦相似度 dot len(co_users) norm_i math.sqrt(len(item_users[i])) norm_j math.sqrt(len(item_users[j])) sim[i][j] dot / (norm_i * norm_j) return simbuild_user_item_matrix里用max而不是累加是因为用户可能先浏览再投递同一个岗位累加会让这个岗位的分数虚高推荐时过度集中。item_similarity用的是集合交集算余弦比用评分向量算更抗稀疏——只要两个岗位有共同用户就能算出一个非零相似度。参数方面weight的取值需要根据实际数据调。如果投递行为很少冷启动阶段可以把浏览权重调高到 2投递调到 3避免矩阵太稀疏。相似度计算里没有设阈值实际部署时建议加一个if sim[i][j] 0.1: continue把弱关联过滤掉减少推荐噪声。3. spider 爬虫采集招聘数据从请求到入库的完整链路3.1 目标站点分析与请求头伪装招聘数据从哪来常见做法是爬公开的招聘信息聚合页。这里不指定具体站点讲通用套路。先分析列表页结构岗位标题、公司名、薪资、城市、技能标签分别在哪个 HTML 节点里。用浏览器开发者工具看一眼就能定位。请求头必须伪装否则大概率被拒。User-Agent 用常见浏览器的Referer 带上站点域名Accept-Language 设成zh-CN,zh;q0.9。频率控制是重点单 IP 每秒别超过 1 次请求否则容易被封。# spider/job_spider.py import requests import time import random from bs4 import BeautifulSoup HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Referer: https://example.com, Accept-Language: zh-CN,zh;q0.9, } def fetch_page(url, retry3): for i in range(retry): try: resp requests.get(url, headersHEADERS, timeout10) if resp.status_code 200: return resp.text except requests.RequestException: time.sleep(2 ** i) # 指数退避 return None def parse_job_list(html): 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) city card.select_one(.city) if not title or not company: continue jobs.append({ title: title.get_text(stripTrue), company: company.get_text(stripTrue), salary: salary.get_text(stripTrue) if salary else , city: city.get_text(stripTrue) if city else , }) return jobsfetch_page里的指数退避是血泪经验第一次失败等 1 秒第二次等 2 秒第三次等 4 秒。直接重试不等待只会加速被封。parse_job_list用 CSS 选择器定位比正则表达式好维护——正则写起来快但页面结构一变就全废而且调试困难。3.2 用 Django 管理命令跑爬虫并入库爬虫脚本不要单独跑集成到 Django 的 manage.py 命令里这样能直接用 ORM 入库不用手写 SQL。# management/commands/run_spider.py from django.core.management.base import BaseCommand from spider.job_spider import fetch_page, parse_job_list from jobs.models import Job import time class Command(BaseCommand): help 爬取招聘信息并入库 def add_arguments(self, parser): parser.add_argument(--pages, typeint, default5) def handle(self, *args, **options): base_url https://example.com/jobs?page{} total 0 for page in range(1, options[pages] 1): html fetch_page(base_url.format(page)) if not html: self.stderr.write(f第 {page} 页抓取失败) continue jobs parse_job_list(html) for j in jobs: # 用 get_or_create 避免重复入库 obj, created Job.objects.get_or_create( titlej[title], companyj[company], defaults{ city: j[city], salary_min: 0, salary_max: 0, } ) if created: total 1 time.sleep(random.uniform(1, 2)) # 随机间隔 self.stdout.write(f新增 {total} 条岗位)get_or_create是必须的因为爬虫可能重复跑没有去重逻辑的话数据库里全是重复岗位推荐结果也会重复。time.sleep(random.uniform(1, 2))比固定 sleep 更安全固定间隔容易被识别出机器人行为。薪资字段这里简化处理了实际爬下来是“15k-25k”这种字符串需要写个解析函数拆成salary_min和salary_max。解析时注意“面议”和“15k以上”这两种特殊情况前者跳过后者只设salary_min。4. 推荐接口与前端展示把算法结果塞进 Django 视图4.1 基于 ItemCF 的推荐函数实现有了相似度矩阵推荐逻辑就简单了对目标用户找出他有过行为的岗位然后找这些岗位的相似岗位按相似度加权排序。# recommender.py 续 def recommend_for_user(user_id, matrix, sim, top_n10): 给指定用户推荐岗位 user_items matrix.get(user_id, {}) if not user_items: return [] # 冷启动返回空 scores defaultdict(float) for jid, rating in user_items.items(): for similar_jid, similarity in sim.get(jid, {}).items(): if similar_jid in user_items: continue # 已经有过行为的岗位不再推荐 scores[similar_jid] similarity * rating # 按分数降序取 top_n ranked sorted(scores.items(), keylambda x: x[1], reverseTrue) return [jid for jid, _ in ranked[:top_n]]similarity * rating是加权核心用户对某个岗位评分越高相似岗位的推荐分数也越高。if similar_jid in user_items: continue这行是后悔药——不加的话用户已经投递过的岗位还会出现在推荐列表里体验极差。top_n默认 10实际可以调。招聘场景下推荐太多用户不会翻5-8 个比较合适。如果推荐结果不足 10 个说明相似度矩阵太稀疏需要回退到热门岗位兜底。4.2 Django 视图与缓存策略推荐计算是 CPU 密集型操作每次请求都算一遍会拖垮响应速度。常见做法是用 Django 的缓存框架把推荐结果缓存起来设置 1 小时过期。# views.py from django.core.cache import cache from django.shortcuts import render from django.contrib.auth.decorators import login_required from .recommender import build_user_item_matrix, item_similarity, recommend_for_user from .models import Job login_required def recommend_view(request): user_id request.user.id cache_key frecommend_{user_id} job_ids cache.get(cache_key) if job_ids is None: matrix build_user_item_matrix() sim item_similarity(matrix) job_ids recommend_for_user(user_id, matrix, sim, top_n8) cache.set(cache_key, job_ids, 3600) # 缓存 1 小时 jobs Job.objects.filter(id__injob_ids) # 保持推荐顺序 job_map {j.id: j for j in jobs} ordered_jobs [job_map[jid] for jid in job_ids if jid in job_map] return render(request, recommend.html, {jobs: ordered_jobs})缓存 key 带上user_id不同用户互不干扰。3600秒过期是权衡太短了缓存没意义太长了新行为不能及时反映。如果用户量不大可以在用户产生新行为时主动删缓存这样推荐实时性更好。Job.objects.filter(id__injob_ids)查出来的顺序是数据库默认顺序不是推荐顺序。所以后面用job_map重新按job_ids的顺序排列这个细节不注意的话推荐排序就白算了。5. 避坑与排查协同过滤推荐系统最常见的 5 个翻车点5.1 推荐结果全是热门岗位现象不管给谁推荐返回的都是那几个投递量最高的岗位。原因相似度计算时热门岗位和几乎所有岗位都有共同用户相似度虚高。这是协同过滤的经典问题叫“热门偏置”。解决在相似度公式里加一个惩罚项用1 / math.log(1 len(item_users[jid]))降低热门岗位的权重。或者在推荐排序后做一次多样性重排同一个公司的岗位最多出现 2 个。5.2 新用户打开首页推荐为空现象刚注册的用户推荐列表是空的页面一片空白。原因新用户没有任何行为记录matrix.get(user_id, {})返回空字典推荐函数直接返回空列表。解决冷启动兜底。新用户返回热门岗位或最新岗位同时在首页引导用户选择感兴趣的技能标签用标签匹配先填充一批行为数据。常见做法是注册后让用户勾选 3-5 个技能系统把这些当作隐式行为写入UserBehavior表。5.3 爬虫跑一次就被封 IP现象第一次跑爬虫正常第二次请求全部返回 403。原因请求频率太高或者 User-Agent 没换被目标站点识别为爬虫。解决降低频率到每秒 0.5 次以下每次请求间隔加随机抖动。User-Agent 准备 5-10 个轮换。如果还是被封检查是不是触发了站点的蜜罐链接——有些页面里藏着隐藏链接正常用户看不到爬虫跟着爬就会暴露。5.4 推荐接口响应超过 3 秒现象首页加载时推荐模块转圈很久用户体验差。原因每次请求都重新构建矩阵和计算相似度数据量一大就慢。解决把矩阵构建和相似度计算做成离线任务用 Django 的 management command 定时跑结果存到 Redis 或数据库表里。在线接口只读缓存结果不做实时计算。如果数据量在 10 万条行为以内缓存 1 小时基本够用。5.5 同一岗位在推荐列表重复出现现象推荐列表里同一个岗位出现两次。原因get_or_create去重时只按title和company匹配但不同公司可能发布相同标题的岗位或者同一岗位被爬虫从不同页面抓到字段有细微差异导致去重失败。解决给Job表加一个source_url字段用 URL 做唯一约束。爬虫入库前先按 URL 查重URL 相同直接跳过。如果 URL 拿不到用title company city做联合唯一索引。6. 推荐效果验证与离线评估的实操技巧推荐系统做完不是终点得知道它到底有没有用。招聘场景下最直接的指标是“推荐点击率”和“投递转化率”但这两个指标需要线上运行一段时间才能拿到。开发阶段更实用的是离线评估。我一般会这样做把UserBehavior表按时间切分前 80% 作为训练集后 20% 作为测试集。用训练集算相似度给每个用户推荐 10 个岗位看测试集里用户实际投递的岗位有多少出现在推荐列表里。这个指标叫 Hit Rate招聘场景下能到 15% 就算不错了。# evaluate.py def hit_rate(matrix_train, matrix_test, sim, top_n10): hits 0 total_users 0 for uid, test_items in matrix_test.items(): if uid not in matrix_train: continue total_users 1 recs set(recommend_for_user(uid, matrix_train, sim, top_n)) actual set(test_items.keys()) if recs actual: hits 1 return hits / total_users if total_users else 0跑完这个评估如果 Hit Rate 低于 5%说明相似度计算有问题优先检查weight设置和相似度阈值。如果高于 30%反而要警惕——可能是测试集和训练集有重叠数据泄漏了。还有一个技巧用 A/B 测试对比协同过滤和热门推荐的效果。把用户随机分成两组一组看协同过滤结果一组看热门岗位跑一周看投递转化率差异。这个比离线指标更有说服力但需要一定的用户量才能统计显著。我自己踩过的坑是离线指标好看上线后点击率反而降了。后来发现是推荐结果太集中用户翻两页就没了新鲜感。后来在推荐列表里混入 20% 的探索性岗位随机从非相似岗位里抽点击率才回升。推荐系统不是越准越好多样性和惊喜感同样重要。希望帮到你。本文还有配套的精品资源点击获取