Django实战:构建云招聘系统爬虫与数据闭环的完整指南 简介云招聘系统设计.zip是一套基于Django框架开发的招聘信息采集与可视化项目面向Python Web开发者及爬虫初学者可用于课程设计、毕业设计或项目实战参考。压缩包共2000个文件大小27.25MB主要包含53个Python文件实现URL路由、视图逻辑及ORM数据库操作、20个HTML模板、156个CSS样式、362个JS脚本以及若干XML配置、文本记录和版本控制相关文件前端交互、后端服务、配置说明等一应俱全目录结构清晰。目前已有94人浏览学习。项目完整覆盖Django框架搭建、招聘信息爬取、数据库存储、Ajax异步加载和ECharts数据可视化等关键环节爬虫模块能够抓取职位名称、公司、地点、薪资等字段并通过ECharts呈现招聘趋势与热门职位统计从数据采集到前端展示形成闭环附带配置文件与项目说明便于读者理解整体架构并在其基础上扩展或二次开发。1. 云招聘系统设计.zip 不是一份文档而是一条招聘数据闭环你拿到的云招聘系统设计.zip解压后往往不是一本说明书而是一个能直接跑的 Django 工程加一个爬虫模块Django 负责把公司、职位、薪资、发布时间变成可查询的页面爬虫负责把外部招聘信息抓下来灌进数据库。整条链路是“爬虫采集 → 清洗入库 → Django 展示”这也是 Django 实战里最常被拿去做毕业设计和求职作品的组合。适合谁想用一套完整代码同时搞定数据采集和 Web 开发的从业者或者要给团队搭内部招聘数据看板的人。往下我会按搭建顺序把模型、爬虫、接入方式和那些绕不开的坑讲清楚。2. 从 Django 项目骨架到招聘数据模型先把实体关系想清楚2.1 项目结构怎么摆django-admin startproject 之后别急着写爬虫很多新手一上来就写爬虫爬完没地方放。正确顺序是先建工程和 appdjango-admin startproject cloud_job cd cloud_job python manage.py startapp jobs python manage.py startapp crawler先解释一下为什么拆两个 appjobs 是业务域放模型、视图、后台管理crawler 是采集任务放抓取、解析、清洗和 management command。如果只建一个 app后面定时任务、权限控制、日志归属迟早会纠缠在一起。目录结构应该是cloud_job/ ├── cloud_job/ │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── jobs/ │ ├── models.py │ ├── views.py │ └── admin.py └── crawler/ ├── fetcher.py ├── parser.py ├── cleaners.py └── management/commands/crawl_jobs.py建好之后把两个 app 加进INSTALLED_APPSINSTALLED_APPS [ django.contrib.admin, django.contrib.auth, django.contrib.contenttypes, django.contrib.sessions, django.contrib.messages, django.contrib.staticfiles, jobs, crawler, ]这一步漏掉的后果是makemigrations不识别你的模型后面所有 migrate 都不生效页面也会直接 500。2.2 Job 与 Company 模型唯一约束和索引比字段数量更重要设计表时把“公司”和“职位”拆成两张表因为一家公司可能发多条职位且职位页面一般带有公司名称拆开后可以统一关联也能避免重复存公司名。下面是一套可以直接落地的模型# jobs/models.py from django.db import models class Company(models.Model): name models.CharField(公司名, max_length128, uniqueTrue) website models.URLField(官网, blankTrue) city models.CharField(所在城市, max_length64, blankTrue) created_at models.DateTimeField(创建时间, auto_now_addTrue) def __str__(self): return self.name class Job(models.Model): title models.CharField(职位名称, max_length128) company models.ForeignKey(Company, on_deletemodels.CASCADE, related_namejobs) city models.CharField(城市, max_length64, db_indexTrue) salary_min models.PositiveIntegerField(最低月薪, nullTrue, blankTrue) salary_max models.PositiveIntegerField(最高月薪, nullTrue, blankTrue) degree_required models.CharField(学历要求, max_length32, blankTrue) experience_required models.CharField(经验要求, max_length32, blankTrue) description models.TextField(职位描述) source_url models.URLField(来源链接, max_length500, uniqueTrue) published_at models.DateTimeField(发布时间, nullTrue, blankTrue) created_at models.DateTimeField(auto_now_addTrue) updated_at models.DateTimeField(auto_nowTrue) class Meta: ordering [-created_at] indexes [ models.Index(fields[city, title]), ]这里有几个参数值得单独说。source_url加uniqueTrue是防重第一道防线。同一个招聘页面被爬两次时数据库会拒绝第二次写入。city加db_indexTrue是因为列表页和筛选页永远按城市查。salary_min和salary_max用PositiveIntegerField单位是元爬虫层会把“20K-30K”转成整数后再入库不要在模型里用CharField存薪资文本否则后面没法按薪资排序。ForeignKey的on_deletemodels.CASCADE也表示删公司时会连带删职位这在招聘系统里符合预期公司不存在了职位也没有保留价值。related_namejobs让你能通过company.jobs.all()反查职位列表写模板和 API 时少写一段查询逻辑。2.3 为什么用 ORM 而不是直接写 SQL对招聘系统来说直接写 SQL 当然可以跑但 Django 的 ORM 能在同一条路上带来三个直接收益迁移管理、admin 后台、和查询接口天然安全。执行迁移python manage.py makemigrations jobs python manage.py migrate如果模型有改动重复执行这两句就行。不要手动改数据库表结构来“匹配”模型Django 会认为迁移记录失效后面所有变更都会报错。接着注册 admin# jobs/admin.py from django.contrib import admin from .models import Company, Job admin.register(Job) class JobAdmin(admin.ModelAdmin): list_display (title, company, city, salary_min, salary_max, source_url) search_fields (title, company__name) list_filter (city, degree_required) admin.site.register(Company)不要小看 admin。爬虫入库后在后台搜一条记录验证字段比写十行 SQL 快。很多人习惯跳过 admin最后排查数据时才发现没有可视化入口只能反复进 shell 查效率极低。2.4 数据表落地后先造一条测试数据迁移完先别接爬虫用 Django shell 手动造一条数据确认模型字段没有低级错误python manage.py shell -c from jobs.models import *; cCompany.objects.create(name测试公司); Job.objects.create(titlePython开发, companyc, city上海, salary_min15000, salary_max25000, description测试, source_urlhttps://example.com/job/1)这条能成功写入说明表结构、外键、约束都没问题。如果第二条用相同 source_url 再插会报IntegrityError这正好是后面爬虫避免重复的基础。注意这条测试数据后面要清掉爬虫跑起来后它没有真实来源 URL会和正式数据混在一起admin 后台看起来特别刺眼。2.5 数据库选型开发用 SQLite部署换 PostgreSQLsettings.py 默认是 SQLite本地验证没问题。但爬虫写库是典型的多写场景并发上来后 SQLite 会锁库。部署的时候换成 PostgreSQL配置如下DATABASES { default: { ENGINE: django.db.backends.postgresql, NAME: cloud_job, USER: cloud_job, PASSWORD: your_password, HOST: 127.0.0.1, PORT: 5432, CONN_MAX_AGE: 60, } }CONN_MAX_AGE是连接复用时间爬虫频繁增删改查时复用连接能明显减少握手开销。但要注意这个参数对 SQLite 没意义反而是 PostgreSQL 上的常用优化项。3. 招聘信息爬虫robots 是底线请求头是基本礼貌3.1 先看目标站的 robots.txt再决定爬什么任何招聘信息源第一步不是打开浏览器看网页而是先看 robots.txtcurl -s https://example.com/robots.txt | head -50如果目标站点写明Disallow: /那就不要硬碰。常见做法是爬公开聚合页并且只拿职位标题、公司、城市、薪资、描述这些基础字段不做用户数据采集。很多从业者把这一步当作“黑匣子”以为看 robots 是玄学实际上它是你项目是否合规的分水岭。看不到 robots.txt 的站点至少也要看页面底部有没有版权声明。凡是明确声明禁止采集的内容就不要写进交付包里这会直接决定这个 zip 能不能被别人放心使用。3.2 请求头、超时、重试三个参数决定你的爬虫能活多久这里要强调“不加请求头等于裸奔”。很多新手请求招聘列表页时被秒拒原因就是 User-Agent 暴露了默认的 python-requests。给一个最小可跑的请求封装# crawler/fetcher.py import time import requests from fake_useragent import UserAgent DEFAULT_HEADERS { Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/avif,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9, Referer: https://example.com/, } def make_session(): session requests.Session() session.headers.update(DEFAULT_HEADERS) session.headers[User-Agent] UserAgent().random return session def fetch_page(session, url, max_retries3, timeout10): for attempt in range(1, max_retries 1): try: resp session.get(url, timeouttimeout) resp.raise_for_status() resp.encoding resp.apparent_encoding return resp except (requests.ConnectionError, requests.Timeout, requests.HTTPError) as e: print(f第 {attempt} 次请求失败: {e}) if attempt max_retries: return None time.sleep(attempt * 2) return None参数说明timeout10要同时给连接和读取不要默认无限等否则爬虫进程可能卡死在某个坏请求上max_retries3是常见折中再多就说明站点已经对你动手了。fake_useragent每次会话随机一个 UA能避免同一 UA 高频访问。注意不要对每个请求都切换 UA那样反而异常一个会话固定一个 UA 就好。这里还用了requests.Session()而不是每次直接requests.get。Session 会自动保留 Cookies招聘网站经常在前几次请求中下发会话标识没有它后面请求列表页可能拿到一个跳转页。3.3 解析招聘列表页不要用正则用 BeautifulSoup 的 select招聘站点结构通常“列表 详情”。列表页最稳定的解析方式是通过 CSS 选择器# crawler/parser.py from bs4 import BeautifulSoup def parse_job_list(html): soup BeautifulSoup(html, html.parser) items soup.select(ul.job-list li.job-item) for item in items: title_el item.select_one(h3.job-title a) company_el item.select_one(span.company-name) city_el item.select_one(span.location) salary_el item.select_one(span.salary) if not title_el or not company_el: continue yield { title: title_el.get_text(stripTrue), company: company_el.get_text(stripTrue), city: city_el.get_text(stripTrue) if city_el else , salary_text: salary_el.get_text(stripTrue) if salary_el else , url: title_el.get(href, ), }说明为什么用 select 而不是正则因为招聘列表页的 HTML 经常带换行和多余空白正则写起来很容易翻车CSS 选择器能直接按结构和类名取节点。如果目标页面是前端渲染的列表页 HTML 里拿不到数据那要去找 XHR 接口这属于爬虫进阶话题后面避坑章再展开。3.4 数据清洗把“20K-30K”变成两个整数数据库里薪资是PositiveIntegerField但页面爬下来是“20K-30K·14薪”。不转换就入库数据库会拒绝或者你用CharField存了又没法排序。写一个清洗函数# crawler/cleaners.py import re def clean_salary(text): text text or text text.replace(k, K) match re.search(r(\d(?:\.\d)?)K-(\d(?:\.\d)?)K, text) if match: low int(float(match.group(1)) * 1000) high int(float(match.group(2)) * 1000) return min(low, high), max(low, high) match re.search(r(\d(?:\.\d)?)-(\d(?:\.\d)?)K, text) if match: low int(float(match.group(1)) * 1000) high int(float(match.group(2)) * 1000) return low, high return None, None这里的坑在“20-30K”和“20K-30K”两种写法都存在所以先用第一个正则匹配带 K 前缀的再用第二个匹配不带前缀的。如果两种都匹配不到返回None, None爬虫侧就跳过这条记录而不是报错。3.5 明细页要不要爬列表页一般只能拿到标题、公司和城市职位描述往往在详情页里。如果需求只要求“有多少岗位、在哪个城市、薪资范围”列表页就够了如果要求“能看职位描述和任职要求”就必须再请求详情页。常见做法是在解析列表页时把 URL 拿出来存到待抓队列然后用同一个 Session 逐个请求。注意每访问一个详情页都要加 sleep否则很容易触发频率限制。4. 把爬虫接进 Django别用独立脚本用 management command4.1 两种姿势对比django.setup() 脚本 vs management command很多人习惯写一个spider.py然后手动python spider.py但这样每次都要处理DJANGO_SETTINGS_MODULE和数据库驱动很累。在 Django 工程下最靠谱的做法是把它封装成 management command也就是crawler/management/commands/crawl_jobs.pycrawler/ ├── management/ │ ├── __init__.py │ └── commands/ │ ├── __init__.py │ └── crawl_jobs.py这样就能直接python manage.py crawl_jobs --pages 5触发也方便后面用 cron 调度。management command 会自己加载 settings、连接数据库、初始化日志你不需要写任何环境变量。4.2 一个能落地的 crawl_jobs 命令先看完整实现再解释里面的关键点# crawler/management/commands/crawl_jobs.py import time import logging from django.core.management.base import BaseCommand from crawler.fetcher import make_session, fetch_page from crawler.parser import parse_job_list from crawler.cleaners import clean_salary from jobs.models import Company, Job logger logging.getLogger(__name__) class Command(BaseCommand): help 爬取招聘信息并入库 def add_arguments(self, parser): parser.add_argument(--pages, typeint, default3, help抓取页数) parser.add_argument(--sleep, typefloat, default2.0, help每页间隔秒数) parser.add_argument(--dry-run, actionstore_true, help只解析不写入) def handle(self, *args, **options): pages options[pages] sleep options[sleep] dry_run options[dry_run] session make_session() stats {seen: 0, created: 0, updated: 0, skipped: 0} for page in range(1, pages 1): url fhttps://example.com/jobs?page{page} self.stdout.write(f抓取第 {page} 页: {url}) resp fetch_page(session, url) if resp is None: continue for raw_item in parse_job_list(resp.text): stats[seen] 1 item self._normalize(raw_item) if item is None: stats[skipped] 1 continue if dry_run: self.stdout.write(f[dry-run] {item[title]} | {item[company]}) continue saved self._save(item) if saved created: stats[created] 1 elif saved updated: stats[updated] 1 time.sleep(sleep) self.stdout.write(self.style.SUCCESS( f完成: 看到 {stats[seen]} 条, 新增 {stats[created]}, f更新 {stats[updated]}, 跳过 {stats[skipped]} )) def _normalize(self, raw): salary_min, salary_max clean_salary(raw.get(salary_text, )) if not raw.get(title) or not raw.get(company) or not raw.get(url): return None return { title: raw[title], company: raw[company], city: raw.get(city, ), salary_min: salary_min, salary_max: salary_max, description: raw.get(description) or , url: raw[url], } def _save(self, item): company, _ Company.objects.get_or_create(nameitem[company]) job, created Job.objects.update_or_create( source_urlitem[url], defaults{ title: item[title], company: company, city: item[city], salary_min: item[salary_min], salary_max: item[salary_max], description: item[description], }, ) return created if created else updated逻辑说明核心是update_or_create它先按source_url查查到就更新查不到就新增。相比先查再建它把“查 写”放在一个事务里避免并发重复插入。_normalize里把解析出来的脏数据转成模型字段如果标题、公司、URL 缺任何一个直接跳过不污染表。get_or_create用来建公司公司名重复时直接用已有记录。参数说明--pages控制抓多少页--sleep是每页间隔默认 2 秒调成 0.5 可能被封--dry-run很有用第一次跑通之前先打印解析结果确认字段没写错再真正写库。如果你在本地试--dry-run --pages 1是第一个命令我强烈建议不要跳过这一步。4.3 增量更新不是删除重建有些方案会“先清空表再爬全量”这在招聘系统里是大忌。第一清空时如果正在展示页面用户会看到数据瞬间消失第二爬虫中途断了会导致表里只剩半批数据。update_or_create的语义是增量更新已有职位更新薪资和描述新职位插入已下架的职位保留但不更新。若要实现真正的“下架检测”可以额外维护一个 crawl_id记录每次爬虫批次最后批量把updated_at 本批次开始时间的职位标记为过期这一招在最后进阶章再展开。4.4 日志与失败兜底爬虫不是跑一次就完定时跑的时候失败很常见。给 command 加日志文件是最低成本兜底。在handle开头加logger logging.getLogger(__name__) # 在异常分支里写 logger.warning(第 %s 页抓取失败: %s, page, e)在 settings.py 里用 dictConfig 或简单配置输出到logs/crawler.log。不要只靠 print因为 cron 定时执行的时候 print 的内容不会自动进入文件出问题时你连原因都找不到。django.core.management.base.BaseCommand自带的self.stdout.write适合给人看的进度给机器看的日志还是交给 logging 模块。5. 避坑排查爬虫被拦、数据库锁死、时区错乱每一条都有人翻车5.1 现象跑第二次爬虫时直接报 “database is locked”原因默认是 SQLite而update_or_create的并发写和第一次的爬虫长事务互相等待。有人会去反复执行 migrate有人会直接把数据库文件删了重来这些都是治标不治本。解决先确认没有残留的 Python 进程再把生产库切到 PostgreSQL或者至少在本地把CONN_MAX_AGE调低。我的建议是开发期用 SQLite部署时换成 PostgreSQL不要在这上面省钱。SQLite 做毕设演示没问题一旦定时任务跑起来几乎必然遇到锁。5.2 现象爬回来几十条数据但 job.description 都是空字符串原因目标职位描述不是静态 HTML而是页面加载后用 JavaScript 渲染的直接用 requests 抓不到详情。解决去浏览器开发者工具里找详情接口。常见做法是https://example.com/api/job/12345这类 XHR 接口会返回 JSON里面字段名更规整解析起来反而比 HTML 简单。另外也可能是我方 select 选择器写错先用--dry-run把它打印出来对照页面 DOM。不要上来就怀疑站点有反爬很多时候只是选择器没匹配上。5.3 现象抓了几页之后就收到 403 或验证码原因同一 IP 请求频率过高或者 User-Agent 太单一。解决把--sleep调大到 3 秒以上并把重试退避从time.sleep(attempt * 2)改成指数退避例如 2 秒、4 秒、8 秒。如果还不行就需要更换出口 IP 的服务但注意一定要选正规合规的渠道并且确认目标站点允许这么做。这里最重要的不是反爬技术而是别把项目做成一个“只会跑三天”的定时任务。降低频率和增加重试指数退避是性价比最高的方案。5.4 现象入库时间比招聘页面上显示的时间晚了 8 小时原因Django 默认USE_TZTrue数据库里存的是 UTC而爬虫解析出来的本地时间被当成了 UTC。解决在 settings.py 中设置TIME_ZONE Asia/Shanghai USE_TZ True并且保存前用timezone.make_aware(dt, timezone.get_current_timezone())把本地时间转成带时区的时间。如果你在_save里直接存 naive datetimeDjango 会警告且行为不可控。建议在_normalize阶段就统一转好from django.utils import timezone def _normalize(self, raw): published_raw raw.get(published_at) if published_raw: published_at timezone.make_aware( published_raw, timezone.get_current_timezone() ) else: published_at None5.5 现象职位列表页结构改了一点整个爬虫就静默失败原因parse_job_list里只要title_el为 None 就 continue没有报错也没有统计。结果是你以为还在跑实际上每分钟都在空转。解决加一个失败计数如果在某一页一条数据都没解析出来直接打印告警并停止爬虫。用我前面的stats统计就能在日志里看到“第 2 页看到 0 条”说明页面结构可能变了要立刻人工介入而不是继续空跑。更进一步在 command 里加一个判断if stats[seen] 0 and page 1: self.stderr.write(首页都没有解析到数据请检查页面结构或选择器) return这种硬性兜底看着简单但能避免你睡一觉起来发现数据库里一条新数据都没有。5.6 现象抓下来的薪资变成 20 和 30而不是 20000 和 30000原因clean_salary只匹配了带 K 的文本有些页面把薪资写成“20-30万/年”或者“20000-30000元/月”。解决在清洗函数里加单位分支。如果是“万/年”换算时要除以 12因为模型里存的是月薪。这个坑非常隐蔽表面看有薪资实际数值比真实低 10 倍。建议清洗后在_normalize里顺手打印一条样本确认数值量级。6. 让这个 zip 真正值钱并发、调度与数据质量验证6.1 并发抓取和限速怎么平衡前面是按页串行抓取太慢。如果你已经确认目标站点能承受一定压力可以用ThreadPoolExecutor并发抓列表页但注意不要只快不控。给出一个更稳妥的并发版本from concurrent.futures import ThreadPoolExecutor, as_completed import threading def fetch_with_limit(session, url, semaphore): with semaphore: return fetch_page(session, url) # 在 handle 内部 semaphore threading.Semaphore(options[workers]) with ThreadPoolExecutor(max_workersoptions[workers]) as pool: futures [ pool.submit( fetch_with_limit, session, fhttps://example.com/jobs?page{p}, semaphore, ) for p in range(1, pages 1) ] for future in as_completed(futures): resp future.result() if resp is not None: # 这里仍然串行写入 for item in parse_job_list(resp.text): self.stdout.write(item[title])参数说明workers4是常见起始值太高会让请求频率像洪水反而更容易触发反爬。即便并发抓取写入数据库这一步仍然建议串行避免 SQLite 锁。而且requests.Session在多个线程里复用是安全的但如果你用了fake_useragent每个线程写同一个 session 的 headers 可能导致竞争建议并发时不要动态改 session 头。6.2 定时调度先想最简单的 cronDjango 项目里的爬虫定时任务最可靠的其实是系统 cron# 每天凌晨 2 点抓 10 页 0 2 * * * cd /path/to/cloud_job /usr/bin/python3 manage.py crawl_jobs --pages 10 /var/log/cloud_job/crawler.log 21不需要一开始就上 Celery。等 cron 不够用了再说。Celery 会引入消息队列、worker 进程和结果后端对一个小项目来说运维成本太大。这个 zip 如果是要交付给别人用的越少外部依赖越好。cron 最大的坑是环境变量Django 在manage.py里会自动加载 settings但你必须在 cron 里写绝对路径的 python不要指望 PATH 里有你开发机的同一个 Python。6.3 验证数据质量的三个检查项跑完爬虫后别急着关终端用 Django shell 或直接写一条检查命令看三个数from django.db.models import Count from jobs.models import Job total Job.objects.count() city_count Job.objects.exclude(city).values(city).annotate( nCount(id) ).count() salary_count Job.objects.filter(salary_min__isnullFalse).count() print(f总职位数: {total}, 覆盖城市: {city_count}, 含薪资职位: {salary_count})这三条能最快看出爬虫是否偏科如果总职位数很高但含薪资的很少说明清洗函数对薪资文本的匹配有问题如果覆盖城市只有 1 个多半是列表页的城市字段没解析对。我一般还会顺手查一下重复率dup_count ( Job.objects.values(source_url) .annotate(cntCount(id)) .filter(cnt__gt1) .count() ) print(f重复 source_url 数量: {dup_count})正常应该是 0。如果查出大于 0说明uniqueTrue没建上或者你用的是旧库没执行 migrate需要马上处理。6.4 再多想一步职位下架标记怎么加招聘系统的时间一长很多职位其实已经下架但数据库里还显示着。最朴素的做法是给 Job 模型加一个is_active布尔字段爬虫每次跑完后把updated_at早于本批次开始时间的记录统一置为 False。这样列表页只展示is_activeTrue的职位历史数据也不丢。代码放在 command 的末尾from django.utils import timezone batch_start timezone.now() # 在 handle 最后执行 Job.objects.filter(updated_at__ltbatch_start).update(is_activeFalse)我的习惯是每个 management command 都带--dry-run并且在第一次跑通之前从不用真实数据写库。这批代码交付出去之后别人至少不用花一个下午来猜你的清洗逻辑。希望帮到你。本文还有配套的精品资源点击获取