简介这套基于协同过滤算法的招聘信息推荐系统采用Python 3.8DjangoMySQL 5.7Vue爬虫技术栈实现适用于毕业设计、课程设计、大作业或工程实训也适合不同技术阶段的学习者深入拆解与二次开发。系统前台提供导航式页面与个人中心可查看招聘信息并更新个人信息管理员后台覆盖用户管理、招聘信息管理、留言板管理、系统管理等功能模块。压缩包共508个文件约23.35MB以vue前端组件、py后端源码、js逻辑脚本、svg及png图标素材为主另含SQL数据脚本和LW设计文档并附安装、运行一键bat脚本部署与启动门槛较低。已有84人学习下载。包内包含可运行完整源码、数据库初始化文件与配套文档前后端目录划分清晰便于模块化二次开发适合想完整掌握从爬虫采集到协同过滤推荐全流程的进阶学习者。1. 招聘信息推荐系统难的不是协同过滤而是数据如果你下载过“5p125基于协同过滤算法的招聘信息推荐系统_djangospider.zip”这类项目大概率是为了交毕设或者快速搭一个推荐系统 demo。这个标题其实已经把技术栈说清楚了django 做 Web 服务spider 爬招聘数据协同过滤算法做推荐。很多 django 项目实战新手拿到压缩包第一反应是配环境跑起来但真正让这个系统“像样”的是把爬虫、数据清洗、推荐算法、Web 展示这四段串成一个闭环。这个方向能解决什么问题对求职者来说推荐系统解决的是“海量职位里挑出匹配自己技能和兴趣的那几个”对做毕设或练手的人来说它解决的是“如何不靠大厂数据、用一份爬下来的真实数据集把推荐系统完整落地”。适合谁看准备做推荐系统方向毕设的学生、想从 CRUD 往算法方向转的后端工程师以及想快速验证协同过滤效果的好奇者。接下来我按“算法怎么选、数据怎么来、系统怎么搭、坑在哪、怎么验证”的顺序把整个方案拆开讲。2. 协同过滤在招聘场景的落地选 UserCF 还是 ItemCF2.1 招聘推荐为什么适合用协同过滤协同过滤的核心假设是“相似的人有相似的偏好相似的商品会被同一类人喜欢”。招聘场景天然满足这个假设同是 Java 后端方向的求职者浏览和投递的职位高度重合同是“三年经验、本科、深圳”的职位吸引的简历也高度相似。而且招聘数据不像电商那么稀疏一个求职者通常会在短时间内浏览几十个职位行为数据密度足够支撑协同过滤计算。但招聘场景有一个特殊问题职位会过期。一个岗位招满下线之后它留下的历史行为数据仍然存在于表里却不再具有推荐价值。我个人的经验是在用协同过滤之前先对职位做一次“下架过滤”——把状态为关闭、过期时间早于当天的职位从候选集里剔除只对有效职位计算相似度。这件事比调相似度公式里的参数重要得多。2.2 UserCF 和 ItemCF 的选型逻辑UserCF基于用户的协同过滤找与当前用户行为最相似的 K 个用户把这 K 个用户喜欢的职位集合推给当前用户。适合用户粘性低、物品更新快的场景。招聘环境里职位数量变化快但用户求职者相对稳定照理说 UserCF 更合适但它有个致命问题新用户没有行为找不出相似用户而且在用户量大的时候两两计算相似度代价很高。ItemCF基于物品的协同过滤先算职位和职位之间的相似度再根据当前用户历史行为过的职位推荐相似的职位。它的计算复杂度取决于职位数量独立于用户数。招聘网站职位量级通常在万级用户量可能在十万级这种情况下 ItemCF 的实时计算压力远小于 UserCF。更重要的是ItemCF 的推荐结果有“因为你收藏了 A所以推荐相似的 B”这种可解释性用户在求职场景里更信任这种推荐。我一般会直接选 ItemCF原因很简单实验数据集只有几万条交互记录UserCF 的用户相似度矩阵算出来巨稀疏而 ItemCF 只要把职位相似度矩阵算好线上推荐就是“查表 TopK 排序”响应速度快一个量级。2.3 相似度计算的代码实现与参数说明协同过滤的相似度计算一般用余弦相似度。下面给一个纯 Python 的实现不依赖任何框架方便你理解后再移植到 Django 的 view 里。import math from collections import defaultdict # 模拟数据key 是用户 idvalue 是用户投递过的职位 id 列表 # 真实项目中这份数据应从 django ORM 查到后组装成同样结构 user_items { u1: [j1, j2, j5], u2: [j1, j2, j3], u3: [j2, j3, j4], u4: [j5], } def build_item_popularity(user_items): 统计每个职位被多少人行为过用于计算相似度时做分母 返回 dict: {item_id: 行为人数} popularity defaultdict(int) for user, items in user_items.items(): for item in set(items): # 同一个用户对同一职位多次行为只算一次 popularity[item] 1 return popularity def calc_item_similarity(user_items): 基于物品的协同过滤相似度余弦相似度 返回 dict: {item_i: {item_j: sim_score}} popularity build_item_popularity(user_items) co_occur defaultdict(lambda: defaultdict(int)) # 第一步统计两个职位被同一用户共同行为的次数 for user, items in user_items.items(): items list(set(items)) # 去重避免同一用户同一职位的重复行为拉高相似度 for i in range(len(items)): for j in range(i 1, len(items)): item_i, item_j items[i], items[j] co_occur[item_i][item_j] 1 # 第二步余弦相似度公式 similarity defaultdict(dict) for item_i, related_items in co_occur.items(): for item_j, count in related_items.items(): denominator math.sqrt(popularity[item_i] * popularity[item_j]) if denominator 0: similarity[item_i][item_j] 0.0 else: similarity[item_i][item_j] count / denominator # count 是共现次数popularity 是单个职位的被行为总数 # 两个职位都被很多人行为过共现次数容易被放大做归一化才有可比性 return similarity def recommend(user_id, user_items, similarity, top_k3, min_sim0.1): 给指定用户做 Top-N 推荐 top_k: 最终返回的推荐职位数量 min_sim: 相似度低于这个阈值的职位直接过滤用于去噪 user_history set(user_items.get(user_id, [])) if not user_history: return [] scores defaultdict(float) # 对用户行为过的每个职位找出与其相似且未被用户行为过的职位 for item in user_history: for related_item, sim in similarity.get(item, {}).items(): if related_item in user_history: continue # 用户已经投递/浏览过不再推荐 if sim min_sim: continue scores[related_item] sim # 按累加相似度排序取 top_k sorted_scores sorted(scores.items(), keylambda x: x[1], reverseTrue) return [item for item, score in sorted_scores[:top_k]] if __name__ __main__: sim calc_item_similarity(user_items) result recommend(u1, user_items, sim, top_k3, min_sim0.1) print(u1 的推荐结果:, result)代码里有两个参数值得细说top_k控制最终推几个职位招聘场景我一般设置 10 到 20推太少用户觉得没用推太多用户懒得翻min_sim是相似度过滤阈值0.1 到 0.3 之间比较合理太低了把弱相关的职位也推上来太高了推荐列表经常为空。注意这里为了演示用的是全量计算。真实项目里职位相似度矩阵不会实时重算而是用 Django management command 定期离线更新比如每天凌晨重跑一次把结果写进 Redis 或者数据库表线上推荐直接读缓存。3. Django Spider 的数据管线爬虫只是开始3.1 数据表设计与 ORM 建模推荐系统需要三类核心数据用户求职者、职位招聘信息、行为浏览/投递/收藏。Django 项目里我一般建三张核心表外加一张扩展表用于存用户画像标签。下面这个 models.py 是精简过的结构够跑通整个推荐链路from django.db import models from django.contrib.auth.models import User from datetime import datetime class JobListing(models.Model): 职位表来源是爬虫写入字段尽量贴合原始页面 title models.CharField(max_length200, verbose_name职位名称) company models.CharField(max_length200, verbose_name公司名称) salary_min models.IntegerField(default0, verbose_name最低薪资(K)) salary_max models.IntegerField(default0, verbose_name最高薪资(K)) city models.CharField(max_length50, verbose_name城市) experience_required models.CharField(max_length20, verbose_name经验要求) skill_tags models.CharField(max_length500, blankTrue, verbose_name技能标签) url models.URLField(uniqueTrue, verbose_name原始链接) is_active models.BooleanField(defaultTrue, verbose_name是否有效) created_at models.DateTimeField(auto_now_addTrue, verbose_name入库时间) class Meta: index_together [(city, is_active)] class UserProfile(models.Model): 用户画像表OneToOne 关联 django 自带的 User 存求职者的技能、期望城市、期望薪资区间 user models.OneToOneField(User, on_deletemodels.CASCADE) skills models.CharField(max_length500, blankTrue, verbose_name技能标签) expect_city models.CharField(max_length50, blankTrue, verbose_name期望城市) expect_salary_min models.IntegerField(default0) expect_salary_max models.IntegerField(default0) class UserPreference(models.Model): 用户行为表所有浏览、投递、收藏、放弃的动作都记在这里 这是协同过滤算法的输入数据源 user models.ForeignKey(User, on_deletemodels.CASCADE) job models.ForeignKey(JobListing, on_deletemodels.CASCADE) behavior_type models.CharField( max_length20, choices[(view, 浏览), (apply, 投递), (collect, 收藏), (reject, 不感兴趣)], defaultview ) created_at models.DateTimeField(auto_now_addTrue) class Meta: unique_together [(user, job, behavior_type)]unique_together这个约束很重要它保证同一个用户对同一个职位不会重复写入同类型的行为记录。爬虫或者埋点数据重复推送的时候数据库层面就直接挡住了不需要每次在代码里查重。JobListing.url加了uniqueTrue同样是为了防重真实爬虫跑起来同一个招聘页可能被多个入口抓到url 去重是最可靠的唯一性判断。还有一个细节不直接把爬虫字段全部塞进一张表。原因很简单招聘网站上的字段非常脏比如薪资有“15K-25K·13薪”“面议”“8千-1.2万”各种写法直接存原始字符串不如拆出salary_min和salary_max两个数字字段方便后续做协同过滤之外的规则过滤。3.2 Scrapy 爬虫写入 Django 的 Pipeline爬虫部分常见做法是 Scrapy 负责抓取解析输出结构化 itemDjango 通过 ORM 写库。不少毕设项目把爬虫代码写在 Django 的 management command 里直接用 requests BeautifulSoup这么做不是不行但并发抓取和断点续爬都得自己造轮子用 Scrapy 自带的能力更省事。下面是一个精简的 Scrapy pipeline用于把爬到的职位写入 Django 数据库# pipelines.py import django import os os.environ.setdefault(DJANGO_SETTINGS_MODULE, recsys.settings) django.setup() from jobs.models import JobListing class DjangoPipeline: Scrapy 的 item 经过这个 pipeline 时写入 Django 数据库 必须在 spider 里配置 ITEM_PIPELINES 并把这个类注册进去 def process_item(self, item, spider): # 先按 url 查重没有才创建记录 # 这个操作依赖 JobListing.url 字段的 uniqueTrue 约束 obj, created JobListing.objects.get_or_create( urlitem[url], defaults{ title: item[title], company: item[company], salary_min: self.parse_salary(item[salary])[0], salary_max: self.parse_salary(item[salary])[1], city: item[city], experience_required: item[experience], skill_tags: |.join(item[skills]), is_active: True, } ) return item staticmethod def parse_salary(salary_str): 把 15K-25K·13薪 这类字符串解析成 (15000, 25000) 解析失败时返回默认值 (0, 0)避免异常中断整个 pipeline import re if not salary_str or salary_str 面议: return 0, 0 nums re.findall(r(\d(?:\.\d)?), salary_str.replace(K, ).replace(k, )) if len(nums) 2: return int(float(nums[0]) * 1000), int(float(nums[1]) * 1000) elif len(nums) 1: return int(float(nums[0]) * 1000), int(float(nums[0]) * 1000) return 0, 0get_or_create是 Django 里非常实用的方法它执行一次 SELECT查不到再 INSERT不是字面上理解的“先查再创建”而是带数据库锁的原子操作。高并发爬虫同时跑到同一条 url 时不会产生重复记录。parse_salary这种小函数很多人忽略实际上推荐效果不好有一半原因是薪资数据没解析干净导致最终推荐结果里推了“薪资面议”的空壳职位。Pipeline 写完之后记得在 Scrapy 的settings.py里做两件事注册 pipeline 类设置ITEM_PIPELINES优先级关闭默认的去重机制换成 Django 层去重。Scrapy 自带的 dupefilter 是基于 request 指纹的你改了 url 带的不同参数可能绕过但 Django 层的uniqueTrue是最终防线。3.3 行为数据的采集协同过滤要吃用户行为数据这个数据从哪来毕设项目里最现实的来源有两个一是系统自己的埋点用户在 Django 页面上的浏览、收藏、投递动作都记入UserPreference二是模拟数据如果真实用户太少就写脚本生成一批“模拟求职者”的行为记录。我在实际操作中会把UserPreference的写入封装成 Django signal用户在页面点击职位详情、点击投递按钮时自动触发这样推荐模块的输入数据不需要手工维护。下面是一个极简的 signal 写入示例# signals.py from django.db.models.signals import post_save from django.dispatch import receiver from .models import UserPreference, JobListing # 在 apps.py 的 ready() 里 import 这个模块来注册信号 receiver(post_save, senderJobListing) def track_job_view(sender, instance, created, **kwargs): 这个信号是示例实际上行为数据通常由 view 函数里显式写入 不建议用 post_save 这种粗粒度信号做行为追踪容易误伤 pass def record_behavior(user, job, behavior_typeview): 在业务 view 里调用这个方法而不是在 signal 里写 原因是行为追踪需要带上 request 里的上下文来源页、停留时长 UserPreference.objects.update_or_create( useruser, jobjob, behavior_typebehavior_type, defaults{created_at: timezone.now()} )注意我在代码里特意留了个负面示例不要用post_save信号去追踪职位浏览行为。因为职位被爬虫写入、后台修改、删除都可能触发post_save这些都不是用户行为。正确的做法是在职位详情 view 里主动调用record_behavior。4. 把算法接到 Django 上推荐 view 与模板渲染4.1 推荐接口的实现思路协同过滤算法算完之后Django 的 view 要做的事情很简单查相似度缓存取出 Top-N 职位 ID再按 ID 把职位详情查出来渲染给前端。常见做法是用 Redis 存相似度矩阵key 设计成item_sim:{job_id}value 是 JSON 数组线上推荐时一次redis.get就拿到结果不需要再跑一遍相似度计算。如果没有 Redis退而求其次可以在 Django 里用数据库表存相似度字段就是item_i, item_j, similarity再加一个update_time。查询的时候filter(item_icurrent_job_id).order_by(-similarity)[:top_k]对数据量在万级以下的系统也够用。下面是一个完整的推荐 view# views.py from django.shortcuts import render from django.http import JsonResponse from .models import JobListing, UserPreference from .recommend_utils import recommend_by_itemcf # 上一章实现的计算逻辑导入 def job_recommend(request): 基于 ItemCF 的职位推荐接口 支持登录用户个性化推荐未登录用户走热门兜底 user request.user top_k int(request.GET.get(top_k, 10)) if not user.is_authenticated: # 未登录用户给热门职位兜底保证页面不空 job_list JobListing.objects.filter(is_activeTrue).order_by(-created_at)[:top_k] context {job_list: job_list, source: hot} return render(request, jobs/recommend.html, context) # 取出该用户最近 30 天的行为记录用来计算推荐 user_history UserPreference.objects.filter( useruser, created_at__gtetimezone.now() - timedelta(days30) ).values_list(job_id, flatTrue) if not user_history: job_list JobListing.objects.filter(is_activeTrue).order_by(-created_at)[:top_k] context {job_list: job_list, source: cold_start} return render(request, jobs/recommend.html, context) # 调用协同过滤计算函数返回推荐职位 id 列表 rec_job_ids recommend_by_itemcf(list(set(user_history)), top_ktop_k) job_list JobListing.objects.filter(id__inrec_job_ids, is_activeTrue) # 保持推荐顺序不乱ORM 的 id__in 是按主键排序而不是按列表顺序 job_map {job.id: job for job in job_list} ordered_jobs [job_map[jid] for jid in rec_job_ids if jid in job_map] context {job_list: ordered_jobs, source: itemcf} return render(request, jobs/recommend.html, context)这里有一个特别容易被新手忽略的坑filter(id__inrec_job_ids)返回的 QuerySet 顺序和rec_job_ids列表顺序不一致数据库默认按主键排序。推荐结果的顺序本身就是算法的输出顺序乱了推荐质量直接崩。所以我做了job_map 列表推导来重建顺序。这个问题在面试时也经常被问到算是一个隐藏考点。4.2 模板渲染与推荐理由展示推荐结果页面我一般会展示“推荐理由”比如“因为你收藏了 Java 后端开发工程师为你推荐相似岗位”。这个可解释性来自 ItemCF 的中间结果——记录下每个推荐职位是跟用户的哪个历史职位相似度最高。在 view 里需要把这个信息一并传进模板# 接上面的 view在 context 里增加 reason 字段 rec_reasons {} for jid in rec_job_ids: # 找到与用户行为记录里相似度最高的那个职位作为推荐理由 # 这里依赖 recommend_by_itemcf 返回值附带 reason 信息 rec_reasons[jid] recommend_by_itemcf.high_sim_item(jid, user_history) context[rec_reasons] rec_reasons模板里展示的时候每张职位卡片下面加一行灰色小字推荐理由与您收藏的 {reason_job_title} 相似。这个交互细节对提升用户信任度很有帮助。很多推荐系统毕设项目只把职位列表堆出来没有任何解释用户觉得“这不就是按时间排序吗”体验就不一样了。模板渲染用 Django 自带的模板系统就够不需要前端框架。注意静态资源的问题如果你在模板里写了img src{% static images/logo.png %}但图片显示不出来大概率是STATICFILES_DIRS配置不对或者static模板标签没加载。这属于 Django 日常问题后面避坑章我再细讲。4.3 异步更新相似度矩阵的定时任务职位相似度矩阵不是实时算的我一般用 Django management command crontab 做每天凌晨的定时更新。管理命令的好处是不需要额外引入 Celery服务器上配置一条 crontab 就行# management/commands/update_similarity.py from django.core.management.base import BaseCommand from jobs.recommend_utils import calc_and_store_similarity class Command(BaseCommand): help 重新计算职位相似度矩阵并写入缓存/数据库 def add_arguments(self, parser): parser.add_argument(--batch-size, typeint, default5000) def handle(self, *args, **options): # 分批处理避免一次性 loading 全部数据把内存打满 # 计算完成后写入 redis 或 similarity 表 calc_and_store_similarity(batch_sizeoptions[batch_size]) self.stdout.write(self.style.SUCCESS(相似度矩阵更新完成))crontab 配置# crontab -e 添加以下行每天凌晨 2 点运行 0 2 * * * cd /path/to/your/project /path/to/python manage.py update_similarity --batch-size 5000 logs/similarity.log 21管理命令配合 crontab 是 Django 项目里最轻量的定时任务方案。Celery 当然更专业但毕设项目引入 Celery 意味着要配 Redis broker、配 worker、配 beat复杂度翻倍。数据量几千到几万条时crontab 完全够用。等以后真实用户量上来了再迁移到 Celery 也不迟。5. 招聘推荐系统避坑爬虫、静态资源与数据稀疏的五个常见问题5.1 爬虫入库后中文字段乱码现象Scrapy 爬到数据库里的中文变成â\x80\x99之类乱码页面显示一片问号。原因请求头里的编码声明和页面实际编码不一致。招聘网站有的用 UTF-8有的用 GBKScrapy 默认按 UTF-8 解码遇到 GBK 页面就乱了。解决在 Scrapy 的settings.py里设置FEED_EXPORT_ENCODINGutf-8同时在 spider 的start_requests里根据页面 meta 动态设置response.encoding。我一般会在parse方法开头加if response.encoding ! utf-8: response.encoding response.encoding强制让 Scrapy 按页面声明的编码重新解码。如果是 GBK 页面且没有声明手动指定response.encoding gbk再取 xpath 结果。5.2 python manage.py migrate 不生效表一直没建出来现象migrate命令执行成功但进数据库看没有表或者表建了和 models 不一致。原因最常见是 app 没有注册进INSTALLED_APPSmigrate 不知道要建哪张表。还有一种是多个 app 同名 model 冲突makemigrations 时生成了多个迁移文件但执行顺序错乱。解决先检查settings.py里INSTALLED_APPS包含当前 app 名。然后执行python manage.py makemigrations jobs重新生成迁移文件再python manage.py migrate。如果表已经存在但结构对不上不要直接删表用python manage.py migrate jobs zero回滚该 app 的所有迁移再重新迁移。5.3 vscode 写的 img 标签在 Django static 文件中显示不了现象模板渲染正常也不报错就是图片区域空白。浏览器 F12 看到GET /static/images/logo.png 404。原因Django 开发环境跑runserver时静态文件由django.contrib.staticfiles处理需要在settings.py里正确配置STATIC_URL和STATICFILES_DIRS。很多人只配了STATIC_URL/static/没把静态文件目录告诉 Django导致找不到文件。另一个常见原因是路径写成了相对路径而 Django 模板里静态资源必须用{% static %}标签。解决在settings.py里加STATICFILES_DIRS [os.path.join(BASE_DIR, static)]确认项目根目录下确实有static文件夹。模板最顶部加{% load static %}然后图片写{% static images/logo.png %}。如果还不行检查有没有把 static 文件夹建在某个 app 目录下而STATICFILES_DIRS指到了别处。5.4 协同过滤矩阵稀疏导致推荐结果为空现象用户历史行为明明有但推荐结果返回空列表页面上一个推荐都没有。原因用户行为过的职位和职位相似度矩阵中没有交集。比如用户最近 30 天的行为全是收藏而看过职位相似度矩阵里的is_active过滤已经把这些职位下线清掉了导致related_item全被过滤掉。解决第一层不要只取最近 30 天行为改为最近 90 天给冷启动留出更多缓冲。第二层推荐结果为空时做热门兜底返回平台点击量最高的 N 个职位保证页面不是空的。第三层也是最关键的相似度计算时要限制职位必须是is_activeTrue且在有效期内否则大量分数计算浪费在过期职位上用户行为数据也污染了矩阵。5.5 django 执行查询删除对象时外键关联报错现象想删除一条职位记录job.delete()结果报ProtectedError或者把UserPreference里的相关记录也删掉了推荐数据跟着丢失。原因UserPreference里的job外键设置了on_deletemodels.CASCADE删掉职位会连带删掉所有行为记录。这符合 Django 默认行为但在推荐系统里是灾难——用户行为数据是不能被级联删除的。解决把UserPreference.job的外键改为on_deletemodels.SET_NULL, nullTrue这样职位删除后行为记录保留只是job字段置空。做推荐计算时过滤掉空值即可。同理UserProfile关联的User也不应该用 CASCADE改为SET_NULL更加安全。这个细节如果等到跑上线数据才发现用户行为表已经被清空了没有后悔药可吃。6. 冷启动兜底与效果评估用命中率和覆盖率验证推荐质量推荐系统的冷启动问题在招聘场景特别突出新用户上来没有行为数据协同过滤直接“罢工”。我建议做一个三层兜底策略按用户数据量自动降级有足够行为走 ItemCF 个性化推荐行为少比如只有 1-2 条走“同 city 技能标签匹配”的规则推荐完全没行为走热门职位加“为你推荐”运营位。每一层都要保证页面不空这也是推荐系统的基本素养。冷启动之外验证协同过滤效果不能只看“跑起来了”要有量化指标。毕设答辩时老师问你“推荐效果怎么评估”你不能只回答“感觉还可以”。我常用的两个指标是命中率和覆盖率。命中率指在测试集里用户实际点击/投递的职位是否出现在推荐列表里做法是把行为数据按时间切一刀前 80% 做训练后 20% 做验证推荐列表和用户真实行为做交集覆盖率指推荐结果覆盖了多少职位覆盖太少说明算法只在热门职位里打转求职者会审美疲劳。一个可执行的小脚本思路遍历测试用户统计 Top-10 推荐命中数除以总行为数得到整体命中率统计推荐结果里不同职位 id 数量除以总职位数得到覆盖率。最后一件事养成一个习惯每次改完相似度公式参数、换了相似度度量余弦改皮尔逊、调了min_sim阈值都把训练验证结果记录成一个表格包括命中率、覆盖率、平均推荐数量三个值再和上一次对比。我自己的经验是皮尔逊系数在招聘数据上往往略优于余弦但差距在 1% 以内没有统计意义不要盲目迷信网上说的“某个算法更好”在自己数据上实测才有结论。这个习惯花不了多少时间但能帮你在答辩或者技术评审时拿出真实数据而不是一句“效果还行”带过。希望帮到你。本文还有配套的精品资源点击获取