ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Django实战:构建云招聘系统爬虫与数据闭环的完整指南

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 之后别急着写爬虫

很多新手一上来就写爬虫,爬完没地方放。正确顺序是先建工程和 app:

django-admin startproject cloud_job cd cloud_job python manage.py startapp jobs python manage.py startapp crawler

先解释一下为什么拆两个 app:jobs 是业务域,放模型、视图、后台管理;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_APPS:

INSTALLED_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_length=128, unique=True) website = models.URLField("官网", blank=True) city = models.CharField("所在城市", max_length=64, blank=True) created_at = models.DateTimeField("创建时间", auto_now_add=True) def __str__(self): return self.name class Job(models.Model): title = models.CharField("职位名称", max_length=128) company = models.ForeignKey(Company, on_delete=models.CASCADE, related_name="jobs") city = models.CharField("城市", max_length=64, db_index=True) salary_min = models.PositiveIntegerField("最低月薪", null=True, blank=True) salary_max = models.PositiveIntegerField("最高月薪", null=True, blank=True) degree_required = models.CharField("学历要求", max_length=32, blank=True) experience_required = models.CharField("经验要求", max_length=32, blank=True) description = models.TextField("职位描述") source_url = models.URLField("来源链接", max_length=500, unique=True) published_at = models.DateTimeField("发布时间", null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: ordering = ["-created_at"] indexes = [ models.Index(fields=["city", "title"]), ]

这里有几个参数值得单独说。source_url加unique=True,是防重第一道防线。同一个招聘页面被爬两次时,数据库会拒绝第二次写入。city加db_index=True是因为列表页和筛选页永远按城市查。salary_min和salary_max用PositiveIntegerField,单位是元,爬虫层会把“20K-30K”转成整数后再入库,不要在模型里用CharField存薪资文本,否则后面没法按薪资排序。

ForeignKey的on_delete=models.CASCADE也表示删公司时会连带删职位,这在招聘系统里符合预期:公司不存在了,职位也没有保留价值。related_name="jobs"让你能通过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 *; c=Company.objects.create(name='测试公司'); Job.objects.create(title='Python开发', company=c, city='上海', salary_min=15000, salary_max=25000, description='测试', source_url='https://example.com/job/1')"

这条能成功写入,说明表结构、外键、约束都没问题。如果第二条用相同 source_url 再插,会报IntegrityError,这正好是后面爬虫避免重复的基础。注意:这条测试数据后面要清掉,爬虫跑起来后它没有真实来源 URL,会和正式数据混在一起,admin 后台看起来特别刺眼。

2.5 数据库选型:开发用 SQLite,部署换 PostgreSQL

settings.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.txt:

curl -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/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.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_retries=3, timeout=10): for attempt in range(1, max_retries + 1): try: resp = session.get(url, timeout=timeout) 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

参数说明:timeout=10要同时给连接和读取,不要默认无限等,否则爬虫进程可能卡死在某个坏请求上;max_retries=3是常见折中,再多就说明站点已经对你动手了。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(strip=True), "company": company_el.get_text(strip=True), "city": city_el.get_text(strip=True) if city_el else "", "salary_text": salary_el.get_text(strip=True) 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 command

4.1 两种姿势对比:django.setup() 脚本 vs management command

很多人习惯写一个spider.py然后手动python spider.py,但这样每次都要处理DJANGO_SETTINGS_MODULE和数据库驱动,很累。在 Django 工程下最靠谱的做法是把它封装成 management command,也就是crawler/management/commands/crawl_jobs.py:

crawler/ ├── 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", type=int, default=3, help="抓取页数") parser.add_argument("--sleep", type=float, default=2.0, help="每页间隔秒数") parser.add_argument("--dry-run", action="store_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 = f"https://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(name=item["company"]) job, created = Job.objects.update_or_create( source_url=item["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_TZ=True,数据库里存的是 UTC,而爬虫解析出来的本地时间被当成了 UTC。解决:在 settings.py 中设置:

TIME_ZONE = "Asia/Shanghai" USE_TZ = True

并且保存前用timezone.make_aware(dt, timezone.get_current_timezone())把本地时间转成带时区的时间。如果你在_save里直接存 naive datetime,Django 会警告且行为不可控。建议在_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 = None

5.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_workers=options["workers"]) as pool: futures = [ pool.submit( fetch_with_limit, session, f"https://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"])

参数说明:workers=4是常见起始值,太高会让请求频率像洪水,反而更容易触发反爬。即便并发抓取,写入数据库这一步仍然建议串行,避免 SQLite 锁。而且requests.Session在多个线程里复用是安全的,但如果你用了fake_useragent,每个线程写同一个 session 的 headers 可能导致竞争,建议并发时不要动态改 session 头。

6.2 定时调度:先想最简单的 cron

Django 项目里的爬虫定时任务,最可靠的其实是系统 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 2>&1

不需要一开始就上 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( n=Count("id") ).count() salary_count = Job.objects.filter(salary_min__isnull=False).count() print(f"总职位数: {total}, 覆盖城市: {city_count}, 含薪资职位: {salary_count}")

这三条能最快看出爬虫是否偏科:如果总职位数很高但含薪资的很少,说明清洗函数对薪资文本的匹配有问题;如果覆盖城市只有 1 个,多半是列表页的城市字段没解析对。我一般还会顺手查一下重复率:

dup_count = ( Job.objects.values("source_url") .annotate(cnt=Count("id")) .filter(cnt__gt=1) .count() ) print(f"重复 source_url 数量: {dup_count}")

正常应该是 0。如果查出大于 0,说明unique=True没建上,或者你用的是旧库没执行 migrate,需要马上处理。

6.4 再多想一步:职位下架标记怎么加

招聘系统的时间一长,很多职位其实已经下架,但数据库里还显示着。最朴素的做法是给 Job 模型加一个is_active布尔字段,爬虫每次跑完后,把updated_at早于本批次开始时间的记录统一置为 False。这样列表页只展示is_active=True的职位,历史数据也不丢。代码放在 command 的末尾:

from django.utils import timezone batch_start = timezone.now() # 在 handle 最后执行 Job.objects.filter(updated_at__lt=batch_start).update(is_active=False)

我的习惯是每个 management command 都带--dry-run,并且在第一次跑通之前从不用真实数据写库。这批代码交付出去之后,别人至少不用花一个下午来猜你的清洗逻辑。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进