2026/10/30 16:21:53

Django招聘系统实战:爬虫+搜索+PostgreSQL闭环

Django招聘系统实战:爬虫+搜索+PostgreSQL闭环 简介这是一套基于Django框架开发的云招聘系统实战项目资源面向Python Web开发初学者与求职类应用实践者解决招聘信息采集、存储、可视化展示与Web服务搭建的一站式学习需求。资源包共2000个文件主体为362个JavaScript前端交互脚本、156个CSS样式文件、53个Python后端逻辑代码含Django模型、爬虫模块及视图、20个HTML页面模板辅以少量sample数据样例与配置文件完整覆盖从网络爬虫抓取招聘数据、Django ORM建模存储、Ajax无刷新加载到ECharts动态图表渲染的全流程技术栈。压缩包大小27.25MB结构清晰包含可直接运行的yun_job Django项目及原始爬取数据文件。目前已有94人下载学习适合希望掌握Web全栈开发闭环、理解真实招聘平台技术实现逻辑的开发者快速上手与二次开发。1. 云招聘系统设计.zip一个能跑通的 Django 招聘信息聚合原型不是 Demo是带爬虫后台搜索的最小可行闭环你花三天搭了个 Django 后台结果发现前端连个真实岗位数据都填不满手动录入 50 条岗位信息后页面还没热就过时了——这根本不是“招聘系统”是静态简历墙。这个云招聘系统设计.zip不是教学模板而是一个从零跑通的实战压缩包它用 Django 4.2Python 3.10做骨架内置可配置的招聘信息爬虫模块基于 requests BeautifulSoup自动抓取前程无忧、智联招聘等平台的公开职位页非登录态、非反爬强站点清洗后存入 PostgreSQL并通过 Django Admin 自定义搜索视图暴露给 HR 或求职者查看。它不解决高并发或分布式调度但解决了 90% 新手卡在“数据哪来”这个死结上的问题。适合正在做课程设计、毕设、或想快速验证招聘类 MVP 的 Python 工程师——你解压、改两行配置、python manage.py runserver就能看到实时更新的岗位列表而不是对着空 admin 页面发呆。它不是玩具是能让你在答辩现场当场演示“数据自动进来、搜索立刻响应”的黑匣子。2. 项目结构与核心模块拆解看清 zip 里到底塞了什么为什么这样组织这个压缩包不是把django-admin startproject的默认结构扔进去就完事。它刻意做了三层隔离Django Web 层、独立爬虫层、数据桥接层。这种分法不是炫技而是为了让你后续能单独升级爬虫逻辑而不动后台路由也能把爬虫抽出来部署到另一台机器上只留 Django 做纯展示。我拆开后数了下共 4 个一级 appcore基础模型与工具、jobs岗位主业务、crawler爬虫调度与解析、search全文检索增强。下面逐块说清每个模块的职责和不可删减性。2.1jobsapp岗位模型设计与 Admin 配置不是简单字段堆砌jobs/models.py里定义的JobPost模型表面看就是 title、company、salary 这些字段但关键在三个隐藏设计source_url字段类型为URLField且加了db_indexTrue—— 这不是为了前端展示而是为后续去重服务每次爬虫入库前先查JobPost.objects.filter(source_urlxxx).exists()避免同一条职位被反复插入publish_date是DateField而非DateTimeField因为主流招聘网站只显示“发布日期”精确到小时反而引入噪声且影响按日统计的聚合查询效率tags字段用TextField存逗号分隔字符串如python,web,上海没上django-taggit—— 这是权衡taggit 依赖多、迁移复杂而本项目搜索场景中__icontains就够用且导出 CSV 时无需额外序列化。# jobs/models.py 关键片段 class JobPost(models.Model): title models.CharField(max_length200) company models.CharField(max_length100) salary_min models.PositiveIntegerField(nullTrue, blankTrue) # 单位元/月 salary_max models.PositiveIntegerField(nullTrue, blankTrue) location models.CharField(max_length50) publish_date models.DateField() source_url models.URLField(db_indexTrue) # 索引加速去重 tags models.TextField(blankTrue) # 简单 tag 存储避免引入 heavy dependency created_at models.DateTimeField(auto_now_addTrue) class Meta: ordering [-publish_date, -created_at] # 复合索引按发布时间倒序查最新岗再按创建时间保序 indexes [ models.Index(fields[-publish_date, -created_at]), ]提示ordering和indexes必须同时存在。只设ordering会让 Django 在每次JobPost.objects.all()时都加ORDER BY但没索引支撑就会全表扫描只建索引不设orderingAdmin 列表页默认排序会失效。这是新手翻车最多的地方之一。2.2crawlerapp爬虫不是写死 URL而是可配置的任务队列很多人以为爬虫就是requests.get(url)然后soup.find_all()。这个项目把爬虫做成“任务驱动”所有待爬目标存在crawler/models.py的CrawlTask模型里字段包括target_site枚举值qiancheng, zhilian, liepin、base_url如https://search.51job.com/list/000000,000000,0000,00,9,99,Python,2,1.html、page_range如1-5、statuspending, running, done, failed。执行时manage.py crawl_jobs命令会轮询pending任务调用对应site_parser.py中的解析器而不是硬编码一堆 if-elif。# 执行爬虫的正确姿势不是直接运行爬虫脚本 python manage.py crawl_jobs --task-id3 # 或批量执行所有 pending 任务 python manage.py crawl_jobs --allcrawler/parsers/目录下有三个文件qiancheng.py、zhilian.py、liepin.py。每个都实现统一接口# crawler/parsers/qiancheng.py def parse_job_list(html_content: str) - List[Dict]: 解析前程无忧职位列表页 HTML返回字典列表 soup BeautifulSoup(html_content, lxml) job_cards soup.select(div.el) # 前程无忧列表项选择器 results [] for card in job_cards: title_elem card.select_one(p.t1 span a) if not title_elem: continue results.append({ title: title_elem.get_text(stripTrue), company: card.select_one(span.t2 a).get_text(stripTrue), location: card.select_one(span.t3).get_text(stripTrue), salary: card.select_one(span.t4).get_text(stripTrue), publish_date: card.select_one(span.t5).get_text(stripTrue), detail_url: urljoin(https://www.51job.com, title_elem[href]), }) return results注意urljoin是必须的因为很多招聘网站用相对路径a href/job/123456.html不补全域名会导致source_url存错后续去重失效。2.3searchapp不用 Elasticsearch靠 Django ORM 实现准实时搜索项目没引入任何外部搜索服务但搜索体验不差。核心是search/views.py中的JobSearchView它用Q对象组合多个字段的icontains查询并对结果做权重排序# search/views.py from django.db.models import Q, F from django.contrib.postgres.search import SearchQuery, SearchRank, SearchVector def get_search_results(query: str): vector SearchVector(title, weightA) SearchVector(company, weightB) SearchVector(tags, weightC) query_obj SearchQuery(query) return JobPost.objects.annotate( rankSearchRank(vector, query_obj) ).filter(rank__gte0.1).order_by(-rank, -publish_date)注意这段代码只在 PostgreSQL 后端生效django.contrib.postgres.search是 PostgreSQL 专属。如果你用 SQLite 或 MySQLSearchVector会报错。项目settings.py里明确写了DATABASES[default][ENGINE] django.db.backends.postgresql这不是可选项是硬性前提。别想着换数据库省事换就等于重写搜索模块。2.4coreapp提供跨模块的通用工具比如防重复插入的原子操作core/utils.py里有个upsert_job函数这才是整个数据流稳定的关键# core/utils.py from django.db import transaction def upsert_job(job_data: dict) - JobPost: 基于 source_url 去重插入岗位失败则更新如薪资变动 使用 select_for_update 避免并发插入重复 with transaction.atomic(): try: # 先尝试获取加行锁 job JobPost.objects.select_for_update().get(source_urljob_data[source_url]) # 更新非关键字段薪资、发布时间可能变动 for field in [salary_min, salary_max, publish_date]: if field in job_data and job_data[field] is not None: setattr(job, field, job_data[field]) job.save() return job except JobPost.DoesNotExist: # 不存在则新建 return JobPost.objects.create(**job_data)这个函数被crawler/parsers/中所有解析器调用确保即使两个爬虫线程同时处理同一条 URL也只会生成一条记录。没有它你跑一次crawl_jobs可能收获 30% 的重复数据。3. 本地环境搭建与首次运行从解压到看到真实岗位数据只需 7 步别被“云招聘”名字吓住它不依赖云服务纯本地可跑。但每一步都有坑尤其在 Python 环境和数据库初始化上。我按自己实操顺序整理跳过所有“安装 Python”这种废话直击关键动作。3.1 解压与依赖安装确认 Python 版本别用 conda 混淆 pipunzip 云招聘系统设计.zip -d cloud-recruit cd cloud-recruit # 必须用 Python 3.10检查版本 python --version # 输出应为 Python 3.10.x 或 3.11.x # 创建干净虚拟环境不要用系统自带 python也不要混 conda python -m venv venv source venv/bin/activate # Linux/macOS # venv\Scripts\activate.bat # Windows # 安装依赖requirements.txt 已包含 psycopg2-binary无需额外编译 pip install -r requirements.txt提示requirements.txt里psycopg2-binary2.9.7是经过测试的稳定版本。如果你用psycopg2非-binary在 macOS 或某些 Linux 发行版上会因缺少pg_config编译失败。binary版本自带二进制直接可用。3.2 数据库配置PostgreSQL 不是可选是必须且密码不能含特殊字符项目默认连接localhost:5432上名为cloud_recruit的数据库。你需要提前创建# 终端执行需已安装 PostgreSQL sudo -u postgres psql # 进入 psql 后执行 CREATE DATABASE cloud_recruit; CREATE USER recruit_user WITH PASSWORD recruit123; -- 密码不要用 / $ / ! 等符号 GRANT ALL PRIVILEGES ON DATABASE cloud_recruit TO recruit_user; \q然后修改cloud-recruit/settings.py中的DATABASES配置DATABASES { default: { ENGINE: django.db.backends.postgresql, NAME: cloud_recruit, USER: recruit_user, PASSWORD: recruit123, # 与上面创建的密码一致 HOST: localhost, PORT: 5432, } }注意HOST写127.0.0.1而非localhost可能导致连接失败取决于 PostgreSQL 的pg_hba.conf认证方式。坚持用localhost并确保pg_hba.conf中有host all all 127.0.0.1/32 md5这一行。3.3 迁移与超级用户创建makemigrations不要跳过哪怕模型没变python manage.py makemigrations python manage.py migrate python manage.py createsuperuser # 按提示输入用户名、邮箱、密码密码至少 8 位含大小写字母数字makemigrations这步不能省。即使你没改模型项目自带的0001_initial.py迁移文件可能因 Django 版本差异需要重新生成。跳过会导致migrate报No migrations to apply但实际表未创建。3.4 启动开发服务器并验证后台访问/admin前先确认服务真在跑python manage.py runserver # 终端输出应包含 Django version 4.2.x 和 Starting development server at http://127.0.0.1:8000/打开浏览器访问http://127.0.0.1:8000/admin。如果看到登录页输入刚才创建的 superuser 账号密码能进入管理后台说明 Django 层 OK。此时jobs_jobpost表是空的正常。3.5 配置爬虫任务并执行别直接crawl_jobs先造一条任务登录 Admin 后左侧菜单找到Crawler→Crawl tasks→ADD CRAWL TASK。填写Target site: 选qiancheng前程无忧Base url:https://search.51job.com/list/000000,000000,0000,00,9,99,Python,2,1.htmlPage range:1-2先爬 2 页验证流程Status:pending保存后在终端执行python manage.py crawl_jobs --task-id1 # 观察终端输出应有类似 # Crawling page 1 of task 1... # Parsed 25 jobs from qiancheng page 1 # Inserted/updated 25 jobs # Crawling page 2 of task 1... # ...提示如果卡在Crawling page 1...不动大概率是网络问题。前程无忧有时会返回 403 或验证码页。此时crawler/logs/crawl_error.log会记录HTTP 403错误。解决方案不是换 UA而是暂停 30 秒后重试——项目crawler/management/commands/crawl_jobs.py中已内置time.sleep(1)但若连续失败需手动加长。3.6 验证数据入库用 Django Shell 直接查比刷新 Admin 更快python manage.py shell在交互式环境中执行 from jobs.models import JobPost JobPost.objects.count() 25 # 应该是你刚爬的页数 × 每页条数前程无忧默认 25 条/页 JobPost.objects.first().title Python 开发工程师 JobPost.objects.filter(company__icontains腾讯).count() 0 # 如果没爬到腾讯说明数据真实不是 demo 填充3.7 访问搜索页面/search/?qpython是你的第一个 MVP 成果在浏览器打开http://127.0.0.1:8000/search/?qpython。你应该看到一个带分页的岗位列表标题含 “Python” 的岗位排在前面且publish_date是今天或昨天的日期。点击任意一条进入详情页/job/123/能看到完整字段。至此从爬虫→存储→搜索→展示的闭环完成。4. 爬虫模块避坑指南5 个血泪经验全是线上翻车后加的防御逻辑爬虫是这个项目最不稳定的环节但也是最容易被当成“黑盒”忽略的部分。下面这些坑我都在凌晨三点 debug 过每一条都附带现象、根因和修复动作不是泛泛而谈。4.1 现象爬虫跑完JobPost.objects.count()没变日志显示Parsed 0 jobs原因目标网站 HTML 结构更新soup.select(div.el)选择器失效返回空列表。前程无忧在 2024 年 3 月将职位卡片 class 从el改为j_joblist但项目内置的qiancheng.py还在用旧选择器。解决打开crawler/parsers/qiancheng.py找到job_cards soup.select(div.el)改为job_cards soup.select(div.j_joblist)。更健壮的做法是加 fallbackjob_cards soup.select(div.j_joblist) or soup.select(div.el) or soup.select(div.job-card)4.2 现象crawl_jobs执行后数据库里出现大量company查看公司介绍的脏数据原因爬虫解析时card.select_one(span.t2 a)没取到文本返回None.get_text(stripTrue)报错但代码里没try-except导致company字段被赋值为默认空字符串而前端模板又把空字符串渲染成占位符文字。解决在parse_job_list函数内所有.get_text(stripTrue)前加安全提取def safe_text(elem): return elem.get_text(stripTrue) if elem else # 然后替换所有类似 # company: card.select_one(span.t2 a).get_text(stripTrue), # 为 company: safe_text(card.select_one(span.t2 a)),4.3 现象source_url字段存的是/job/123456.html导致去重失效同一岗位插入 N 次原因detail_url没用urljoin拼接直接用了相对路径。JobPost.objects.filter(source_url/job/123456.html).exists()永远为 False因为数据库里存的是绝对 URL。解决qiancheng.py中detail_url构建必须用urljoinfrom urllib.parse import urljoin # ... detail_url: urljoin(https://www.51job.com, title_elem[href]),4.4 现象爬取智联招聘时crawl_jobs报UnicodeDecodeError: utf-8 codec cant decode byte 0xe9原因智联招聘部分页面用gbk编码但requests.get()默认按utf-8解码导致解析失败。解决在crawler/management/commands/crawl_jobs.py的handle方法中获取响应后强制指定编码response requests.get(url, headersheaders) response.encoding gbk # 关键智联招聘用 gbk html_content response.text4.5 现象crawl_jobs --all执行时某个任务失败后后续所有任务都不执行原因crawl_jobs命令的for task in pending_tasks:循环里没对单个任务做异常捕获一个Exception就中断整个流程。解决给每个任务执行加try-except记录错误但继续下一个# crawler/management/commands/crawl_jobs.py for task in pending_tasks: try: self.crawl_single_task(task) except Exception as e: logger.error(fTask {task.id} failed: {e}) task.status failed task.save()5. 进阶技巧把爬虫变成定时任务让系统真正“云”起来“云招聘”不是指部署在云服务器而是指数据能自动更新、无需人工干预。下面这个技巧能让你的系统从“手动触发”升级为“每天凌晨自动拉新”且不依赖第三方服务。5.1 用 Django-Q 替代 Celery轻量、无 Redis 依赖专为小项目设计Celery 配置太重还要装 Redis。Django-Q 用 Django 自带数据库做消息队列5 分钟配好。先安装pip install django-q在settings.py中添加INSTALLED_APPS [django_q] Q_CLUSTER { name: DjangORM, workers: 4, recycle: 500, timeout: 60, retry: 120, queue_limit: 50, bulk: 10, orm: default, }运行迁移python manage.py migrate5.2 编写定时爬虫任务每天 2:00 AM 执行全部 pending 任务创建crawler/tasks.py# crawler/tasks.py from django_q.tasks import schedule from django_q.models import Schedule def schedule_daily_crawl(): 每天凌晨 2 点执行所有 pending 爬虫任务 schedule( crawler.management.commands.crawl_jobs.Command.handle, funccrawler.management.commands.crawl_jobs.Command.handle, schedule_typeSchedule.DAILY, next_rundatetime.now().replace(hour2, minute0, second0, microsecond0), repeats-1, # 永久重复 )在cloud-recruit/__init__.py或jobs/apps.py的ready()方法中注册# jobs/apps.py from django.apps import AppConfig class JobsConfig(AppConfig): default_auto_field django.db.models.BigAutoField name jobs def ready(self): import crawler.tasks # 避免重复注册 if not hasattr(self, _scheduled): crawler.tasks.schedule_daily_crawl() self._scheduled True5.3 启动 Django-Q 集群一个命令后台常驻# 新开一个终端窗口 python manage.py qcluster你会看到类似输出Q Cluster 1234567890 starting. Process-1:1234567890: Ready Process-2:1234567890: Ready Process-3:1234567890: Ready Process-4:1234567890: Ready此时Django-Q 已在后台监听数据库中的定时任务表。每天凌晨 2:00它会自动执行crawl_jobs --all无需你守着电脑。5.4 监控与告警当爬虫连续失败 3 次邮件通知你Django-Q 自带失败重试但没人告诉你它挂了。我们在crawler/tasks.py加个失败钩子from django_q.hooks import post_fail from django.core.mail import send_mail def notify_on_failure(task): if task.attempts 3: send_mail( subjectf[云招聘] 爬虫任务 {task.name} 连续失败, messagef任务 ID: {task.id}\n失败原因: {task.result}, from_emailadmincloud-recruit.local, recipient_list[your-emailexample.com], ) # 注册钩子 post_fail.connect(notify_on_failure)注意邮件发送需配置 SMTPsettings.py中加EMAIL_BACKEND django.core.mail.backends.smtp.EmailBackend EMAIL_HOST smtp.gmail.com EMAIL_PORT 587 EMAIL_USE_TLS True EMAIL_HOST_USER yourgmail.com EMAIL_HOST_PASSWORD your-app-password # Gmail 需用应用专用密码5.5 数据保鲜策略自动清理 30 天前的过期岗位招聘岗位生命周期短存太久浪费空间还影响搜索性能。在jobs/management/commands/clean_old_jobs.py写个清理命令# jobs/management/commands/clean_old_jobs.py from django.core.management.base import BaseCommand from django.utils import timezone from jobs.models import JobPost class Command(BaseCommand): def handle(self, *args, **options): cutoff timezone.now().date() - timezone.timedelta(days30) deleted_count, _ JobPost.objects.filter(publish_date__ltcutoff).delete() self.stdout.write(fDeleted {deleted_count} old jobs)然后用 Django-Q 定时执行schedule( jobs.management.commands.clean_old_jobs.Command.handle, schedule_typeSchedule.DAILY, next_rundatetime.now().replace(hour3, minute0), # 比爬虫晚 1 小时 )从那以后我每次上线新爬虫规则都强制走一遍python manage.py crawl_jobs --task-idX --dry-run项目虽没实现 dry-run但我在crawl_jobs.py里加了--dry-run参数打印 SQL 而不执行确认解析逻辑无误再正式跑。这招省下至少三次凌晨重启服务器的时间。希望帮到你。本文还有配套的精品资源点击获取