Python后端爬虫专题10:重复运行不能重复入库——内容指纹、去重与幂等更新
上一篇练习完整答案
缓存测试应期望请求顺序为 robots、job1、job2;若出现两个 robots,缓存没有生效。两个迷惑 URL 的 hostname 都是evil.example或其子域,而不是 target.test:一个利用后缀相似,一个利用 URL 用户名。内部范围清单必须写负责人和过期时间,否则“一次授权”容易被误当永久授权。
三种重复不要混为一谈
队列重复:同一规范 URL 在一次任务中被发现多次;数据库重复:两次任务把同一来源插入两行;内容未变化:同一职位再次出现但业务字段相同。它们发生在不同层,需要不同机制。
URL set 解决本次队列重复,数据库唯一约束解决并发和跨任务重复,内容指纹判断已有行是否需要更新。只做任意一层都不完整。
指纹里应该放什么
job_fingerprint对 JobItem 的业务字段做 canonical JSON,再计算 SHA-256。source_url 被排除,因为它已经用于身份唯一键,跟踪规则变化不应让正文误报更新。skills 统一 casefold 并排序,避免展示顺序变化产生新指纹。
标题、公司、城市、描述、薪资和发布时间都影响职位事实,因此应进入指纹。last_seen_at、快照 ID、采集时间不能进入,否则每次运行必然变化。指纹不是加密密码,也不能证明内容来自某个法律主体;它只是快速比较业务快照。
URL 身份与 external_id
TargetLab 有 external_id,但真实来源可能重复使用编号或不同栏目使用相同 id。本项目用(tenant_id, source_url)做唯一身份,external_id 作为来源字段保留。若站点承诺全局稳定编号,可改为(tenant, source, external_id),但迁移前要验证历史数据。
租户必须进入唯一键。两个客户被授权采集同一来源时,各自拥有任务、保留策略和下游结果,不能让 tenant-a 的更新覆盖 tenant-b。
幂等不等于什么都不做
第一次 upsert 返回 created;相同指纹返回 unchanged,但仍更新 last_seen_at、ETag 和快照引用;内容变化返回 updated 并替换业务字段。调用者根据 action 累计 CrawlReport。
并发环境只靠“先 select 后 insert”仍可能竞态:两个事务都看见不存在,然后同时插入。数据库唯一约束是最终防线,生产 repository 可用 PostgreSQLINSERT ... ON CONFLICT或捕获 IntegrityError 重读。课程当前顺序逻辑便于学习,约束保证错误不会静默生成两行。
为什么不用 Python 的 hash()
内置 hash 默认会跨进程随机化,也可能因版本变化,不适合作持久标识。SHA-256 输出稳定、碰撞风险足够低、易于跨语言复算。canonical JSON 必须固定字段名排序、分隔符和 Unicode 处理,否则相同对象可能产生不同字节。
运行三阶段验收
.\.venv\Scripts\python.exe-m pytest tests\test_repository.py::test_repository_creates_then_ignores_duplicate_then_updates_changed_job-q测试在同一内存数据库依次插入、重复、修改描述,断言 action 为 created/unchanged/updated,三个结果 job_id 相同,最终只有一行且 ETag 更新为 v2。这比只测fingerprint字符串更接近用户可见行为。
变更历史是否要保存
当前 jobs 表保存最新事实,原始快照保存每次有正文的证据。如果产品需要展示历史薪资,应另建 job_versions,以 job_id、fingerprint 和有效时间记录版本,不能指望日志永久保存。是否建历史表取决于业务,不为了“架构完整”提前增加。
本篇完整指纹模块
函数很短,但存在明确调用者:JobRepository.upsert。它不打印展示结果、不维护全局列表,也不为测试硬编码职位顺序。短函数是否有意义看它是否封装稳定业务合同,而不是看行数。
"""业务内容指纹;网络缓存标识与业务变化判断各司其职。"""fromhashlibimportsha256importjsonfrom.modelsimportJobItemdefjob_fingerprint(item:JobItem)->str:"""生成与排版和技能顺序无关、与职位事实有关的 SHA-256。"""payload=item.model_dump(mode="json",exclude={"source_url"})payload["skills"]=sorted((skill.casefold()forskillinitem.skills),key=str.casefold)canonical=json.dumps(payload,ensure_ascii=False,sort_keys=True,separators=(",",":"))returnsha256(canonical.encode("utf-8")).hexdigest()本篇课后练习
- 修改 skills 顺序和大小写,验证指纹不变;修改 description,验证指纹改变。
- 写出为什么
last_seen_at不能进入内容指纹。 - 模拟两个 Session 同时插入相同 tenant/source,观察唯一约束如何阻止重复,并思考 ON CONFLICT。下一篇会把 jobs 与 crawl_tasks 表结构完整落地。