中国裁判文书网全量爬取实战:分布式爬虫、反反爬与数据清洗
发布时间:2026/10/28 7:45:30 锦皓数字建站

简介一套基于Python的分布式网络爬虫系统专门面向中国裁判文书网的全量案件信息采集场景集成反反爬机制与数据清洗流程实现自动化爬取、文书详情页解析与结构化存储满足法律研究、司法数据分析及批量案例检索等需求适合具备Python基础的数据采集开发者和法律信息化研究者使用。压缩包体积仅42KB包含17个文件其中13个Python脚本构成项目主体分工覆盖爬虫调度、页面解析、反爬策略、数据清洗与存储等环节另有说明txt、使用文档docx、运行日志log及README.md便于对照阅读和二次开发。当前已有109人学习下载。除完整可运行的爬虫源码外还附带项目架构说明与附赠资源文档有助于理解反反爬机制实现、分布式任务分配及数据清洗思路可直接支撑裁判文书网案件信息的自动化采集与分析工作。1. 中国裁判文书网全量爬取分布式爬虫、反反爬与数据清洗为什么缺一不可很多人第一次接触中国裁判文书网爬虫时以为难点在“爬”本身——菜鸟写个 requests 循环就能跑起来跑到第几百页突然收到验证码才意识到事情没那么简单。我见过太多法律研究项目死在同一个地方页面能打开、请求能返回但数据采集到一半被安全机制掐断或者采下来的文书字段错位、编码乱码、重复率惊人。中国裁判文书网的全量数据爬取核心目标不是“能爬到一条数据”而是在不触发反爬机制的前提下用分布式架构把千万级文书稳定采集下来再做一层彻底的数据清洗让数据真正能用于法律研究。这套基于 Python 的分布式网络爬虫系统解决的正是从“请求一个网页”到“拿到一张干净的案件信息表”之间的所有中间环节。本文按实操顺序拆解反反爬、分布式调度、清洗存储三条主线适合司法数据分析团队、法律实证研究者和想系统做采集工程的爬虫工程师。2. 反反爬机制拆解请求头、Cookie 与频控的三层对抗2.1 裁判文书网的反爬体系与爬虫应对思路裁判文书网的反爬设计不是一道墙而是三层叠加。第一层是请求校验服务器会校验请求头里的 User-Agent、Referer、Cookie 链缺失或矛盾直接判定为异常请求。第二层是行为频控同一个会话在短时间内请求次数超过阈值触发验证码严重时直接封禁会话。第三层是页面结构混淆部分列表页和详情页的字段并不是静态 HTML而是经过编码的数据块直接解析 dom 会得到一堆难以处理的文本。这决定了爬虫不能像爬普通博客那样写死一个 UA 就无脑请求。常见做法是把“会话管理”和“请求节流”做成基础设施层跑批逻辑只负责生产任务不关心网络细节。很多开源代码在反爬上翻车是因为只处理了 User-Agent 轮换把频控忽略了。实际上频控才是命门——同样的请求头配置一秒一次和一秒五次的结果完全是两回事。2.2 用 requests.Session 构造可信会话Cookie 池与指纹轮换我一般会把采集端的网络层封装成一个独立的CourtSession类。它做的事情有三件维护一个可轮换的请求头池、自动处理 Set-Cookie、用令牌桶控制请求速率。核心是用requests.Session复用 TCP 连接和 Cookie避免每次请求都重新握手。import requests import time import random from collections import deque class CourtSession: 裁判文书网采集会话整合 UA 轮换与令牌桶限速 def __init__(self, cookie_strNone, qps0.8): self.session requests.Session() # 随机 User-Agent避免某个指纹被长期盯上 self.ua_pool [ Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0, Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Gecko/20100101 Firefox/119.0, ] self.session.headers.update({ Accept: text/html,application/xhtmlxml,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9, Referer: https://wenshu.court.gov.cn/, }) if cookie_str: self.session.headers[Cookie] cookie_str # 令牌桶qps0.8 表示每 1.25 秒允许一次请求 self.qps qps self.tokens deque() self._refill_time 0 def _wait_for_token(self): now time.time() # 清掉超过时间窗口的旧令牌 while self.tokens and now - self.tokens[0] 2: self.tokens.popleft() if len(self.tokens) self.qps * 2: time.sleep(0.3 random.random() * 0.2) return self._wait_for_token() self.tokens.append(now) def get(self, url, retry3): self.session.headers[User-Agent] random.choice(self.ua_pool) for attempt in range(retry): self._wait_for_token() try: resp self.session.get(url, timeout15) if resp.status_code 200 and 验证 not in resp.text[:500]: return resp except requests.RequestException: pass time.sleep(2 attempt * 2) return None这段代码里值得关注的参数有三个。qps0.8是我在长期采集里试出来的一个比较安全的起点单节点每秒不到一个请求几乎不会触发频控如果你有多个出口 IP 资源可以把这个值调大但要记住频控是按会话维度统计的不是按全局统计。tokens队列实现了令牌桶的简化版本窗口是 2 秒令牌容量是qps * 2实际操作中不需要引入复杂队列中间件就能达到限流效果。retry3配合指数退避应对偶发的网络抖动和 5xx 响应。有人说直接用requests裸请求也行但实践中你会发现 Session 的自动 Cookie 累积能力非常有用。裁判文书网的很多校验逻辑依赖 Cookie 链的完整性单个请求单独发容易漏掉某些中间 Cookie。要让 Cookie 池化——比如提前用脚本登录几次把 Cookie 序列化后按批次分配给不同采集线程这个后面讲分布式时会展开。2.3 验证码识别与请求节奏能绕就不解硬解走打码平台验证码是跑全量数据时最磨人的环节。常见的处理思路有三条按优先级排第一优先级是“绕”也就是把请求频率控制在验证码出现阈值以下并且避免被单会话连续探测第二优先级是“解”接打码平台做识别第三优先级是“缓”真遇到高频验证时把当前任务回收到队列尾部过几分钟再试。def fetch_with_captcha_escape(session, url): 优先降频重试二次失败再走打码平台。 返回 HTML 文本或 None。 for i in range(3): resp session.get(url) if resp is None: time.sleep(20) continue # 通过特征文本判断是否被验证码拦截 if 滑动验证 in resp.text or 安全验证 in resp.text: # 方案一放弃当前页回队列延迟重试 if i 2: time.sleep(15 i * 10) continue # 方案二调用打码平台接口此处省略具体接入代码 captcha_id submit_captcha_task(resp.text) token poll_captcha_result(captcha_id) session.update_captcha_token(token) else: return resp.text return None这段逻辑演示的是“先绕后解”的节奏控制。真实场景里验证码出现后立刻打码往往成功率不高因为服务器已经怀疑这个会话了即使答案正确也可能继续弹验证码。更可靠的做法是降低全局频控阈值或者切换出口 IP。submit_captcha_task和poll_captcha_result是两个占位函数接入任何一家打码平台时核心参数只有三个验证码图片的 base64、超时时间、结果轮询间隔。切记打码只是兜底手段不是常规武器。一个架构健康的采集系统90% 的请求都不应该走到验证码分支——如果验证码率超过 5%第一件事是检查频控配置是否合理。3. 分布式采集框架落地Redis 任务队列、分布式锁与断点续爬3.1 为什么单机跑不动全量数据任务拆分的粒度设计说一个大家都懂但经常被忽略的事实把全量数据爬完瓶颈不在网络而在任务调度。单机单线程爬裁判文书网按每秒 0.8 个请求算一小时是 2880 个请求一天不到 7 万。文书详情页的平均大小在几十 KB 到几百 KB 不等带宽不是问题但任务的组织方式决定了你能不能横向扩展。常见的做法是把“待采集文书 ID 列表”做成一个独立的任务队列各采集节点只认任务不认全局。我习惯用 Redis 做三件事任务队列List 或 Stream、已采集集合Set、节点心跳Hash。任务队列里的每一个元素要么是一个列表页 URL要么是一个详情页 URLRedis 的SADD天然具备去重能力一个文书被重复推入时直接忽略。3.2 用 Redis 实现生产消费模型LPUSH、BRPOP 与 SADD 去重下面这部分代码是整个分布式系统的骨架。生产者负责把文书链接灌进队列消费者分布式节点从队列取任务、执行采集、把结果写回存储最后把已处理的 ID 记入集合。这套模型不需要引入重量级消息队列成本低、易排错。import redis import json class TaskQueue: 基于 Redis List Set 的分布式任务队列 def __init__(self, hostlocalhost, port6379, db0): self.r redis.Redis(hosthost, portport, dbdb, decode_responsesTrue) self.pending_key court:tasks:pending # List self.done_key court:tasks:done # Set def push(self, task: dict): 推送任务task 必须包含唯一 id 字段 task_id task.get(id) if not task_id: raise ValueError(task 缺少 id 字段) # SISMEMBER 判重避免重复入队 if self.r.sismember(self.done_key, task_id): return False self.r.lpush(self.pending_key, json.dumps(task)) return True def pop(self, timeout30): 阻塞式取任务BRPOP 避免空轮询 item self.r.brpop(self.pending_key, timeouttimeout) if not item: return None return json.loads(item[1]) def mark_done(self, task_id: str): 采集完成后记入已处理集合 self.r.sadd(self.done_key, task_id)这段代码有三个关键设计。第一个是用BRPOP而不是LPOP——前者在队列为空时阻塞等待不会产生空轮询的 CPU 消耗多个消费者同时BRPOP时Redis 会公平地将任务分发给不同客户端天然实现负载均衡。第二个是入队前的SISMEMBER判断这保证了任务不会被重复推入真正的分布式锁其实隐藏在SADD mark_done这一步里同一任务只允许一个节点把它标记为完成。第三个是decode_responsesTrue这个参数很多人会忽略但如果不设置所有读出来的字符串都是 bytes 类型在 Python 里做 Json 解析时极易踩编码坑。3.3 分布式锁的正确打开方式SETNX 与看门狗续期任务队列解决了“谁来干”的问题分布式锁解决的是“同一件事不能同时干两遍”的问题。最典型的场景是多个节点同时处理同一个详情页 URL。裁判文书网存在同一个案件出现在多个列表页的情况如果不去重一个案件会被多个节点重复下载。Redis 分布式锁的标准写法是SET key value NX EX seconds但是单纯的 SETNX 有个隐患锁超时时间设短了任务没跑完锁就过期设长了节点挂掉后锁要等很久才能释放。def acquire_lock(r, lock_key, owner_id, expire60): 获取分布式锁owner_id 是节点唯一标记 ok r.set(lock_key, owner_id, nxTrue, exexpire) if ok: return True # 锁已存在时检查持锁者是否还活着心跳续期 owner r.get(lock_key) if owner and not r.hexists(fnode:{owner}, alive): # 持有者失联强制抢锁 r.delete(lock_key) return r.set(lock_key, owner_id, nxTrue, exexpire) return False def release_lock(r, lock_key, owner_id): 只释放自己持有的锁防止误删别人的锁 if r.get(lock_key) owner_id: r.delete(lock_key)这里最值得学习的是“看门狗”思想每个节点启动时维护一个心跳 Hash每秒写入当前时间戳。acquire_lock在锁冲突时先检查持锁节点是否还有心跳如果心跳已经超过阈值说明持锁者已经死了直接抢锁。这种设计比固定超时时间可靠得多基本不会出现锁永久卡死。我见过不少爬虫项目在这里翻车——两个节点同时处理一个任务数据量翻倍清洗阶段怎么去重都去不干净。锁的粒度也不要太细按“文书 ID”级别加锁就够了不需要给一个列表页加锁。3.4 断点续爬的三种姿势进度表、任务重试、定时快照跑全量数据最怕的不是慢而是中途故障后从头来过。断点续爬的常见做法有三种我一般会结合使用。第一种是进度表在 MongoDB 或 MySQL 里维护一个crawl_progress表记录每个列表页的页码和对应的采集状态重启后从“未完成”的页码继续。第二种是任务重试Redis 队列里的任务自带retry_count字段失败超过 5 次就丢进死信队列人工介入。第三种最实用——定时快照每隔 10 分钟把 Redis 里的待采集队列导出到本地文件。Redis 的RDB快照本身有持久化但进程级故障往往伴随 Docker 容器重启卷没了数据就没了定时导出到外部存储才是真正的后悔药。4. 数据清洗与存储从原始 HTML 到结构化的法律数据集4.1 为什么原始数据不能直接用于研究HTML 里的脏数据陷阱刚从网上下载的 HTML 文档不能直接进入分析流程原因很简单采集下来的页面里至少有一半信息是爬虫不需要的——导航栏、页脚、推荐阅读、JS 变量、版权声明这些噪声会直接污染后续的自然语言处理。更棘手的是字段结构在不同历史时期是不一样的。裁判文书网的页面改版过不止一次老文书的页面结构和新文书存在明显差异抽取起来字段会错位。用 pandas 做清洗前我会先做一个 HTML 解析层把页面转成 JSON 或 DataFrame。4.2 pandas 清洗管道去重、空值处理与日期字段标准化import pandas as pd import re def clean_court_data(df: pd.DataFrame) - pd.DataFrame: 清洗裁判文书原始数据返回可入库的标准 DataFrame # 1. 去重案件唯一标识 case_id 是判重的关键 df df.drop_duplicates(subset[case_id], keepfirst) print(f去重后剩余 {len(df)} 条) # 2. 剔除明显无效的文本正文长度过短说明解析失败 df df[df[content_len] 500] # 3. 日期标准化兼容 2023-12-05 / 2023年12月5日 / 二〇二三年 def parse_dates(x): if x is None or x : return pd.NaT x str(x).strip() # 中文日期转数字把年月日替换成 - x x.replace(年, -).replace(月, -).replace(日, ) x x.replace(〇, 0).replace(一, 1).replace(二, 2) x x.replace(三, 3).replace(四, 4).replace(五, 5) x x.replace(六, 6).replace(七, 7).replace(八, 8).replace(九, 9) return pd.to_datetime(x, errorscoerce) df[judge_date] df[judge_date].apply(parse_dates) # 4. 案号清理去掉空格和全角字符 df[case_no] df[case_no].apply( lambda x: re.sub(r[\s\u3000], , str(x)) ) # 5. 空值统计输出到日志供人工确认 null_report df.isnull().sum() print(空值统计:\n, null_report[null_report 0]) return df这段代码里有几个参数值得单独说。keepfirst在去重时保留了先采集到的版本——后采集的版本往往因为网络原因字段不完整先到先得是安全的。content_len 500这个阈值不是拍脑袋定的裁判文书最短的简易程序文书正文也在三四百字以上低于这个长度的基本是解析失败或反爬拦截页直接剔除。日期解析里的中文字符替换最容易被忽略的是“〇”——它是零的异体写法很多爬虫只处理了阿拉伯数字导致一批老文书日期全部丢失。清洗完成后导出一份null_report这是你判断解析质量最直接的依据。4.3 存储方案选型MySQL、MongoDB 还是 Parquet 文件存储方案适合场景优势劣势MySQL结构化字段完整、需要按案号/法院/日期频繁查询事务可靠、查询语法通用扩展成本高超大文本字段拖性能MongoDB文书详情页解析出的嵌套字段多、字段结构不稳定无固定 Schema方便字段增减聚合查询在大数据量下变慢Parquet 文件离线研究、Pandas 分析、按批次处理压缩率高读取速度快不适合并发写入和实时查询我在法律研究项目里的习惯是“双写”MySQL 存结构化摘要字段案号、法院、日期、案件类型Parquet 存全量正文。研究者在做统计回归时只查 MySQL需要做文本分析时直接用 pandas 读取 Parquet两边互不干扰。parquet格式的实际压缩率能做到原始 JSON 的 1/5 到 1/10千万级文书也只需要几百 GB 的磁盘空间这是比较经济的方案。4.4 增量更新与全量对账用案件唯一键做增量合并裁判文书网的数据是持续增长的不是爬完一次就结束了。常见的增量策略是每天爬取当天的更新列表然后和存量数据合并。合并时不能直接 append需要按case_id做全量对账def upsert_by_case_id(old_df, new_df): 以 case_id 为唯一键合并增量数据旧数据保留新数据覆盖 combined pd.concat([old_df.set_index(case_id), new_df.set_index(case_id)]) # 同一 case_id 保留 index 位置靠后的数据即新采集的 combined combined[~combined.index.duplicated(keeplast)] return combined.reset_index()这个函数看着简单但它解决了一个非常容易翻车的细节同一案件增量数据有时候比存量数据更完整有时候反而更残缺。直接覆盖会丢失旧数据不覆盖新采集到的修正字段又进不来。用keeplast加索引去重逻辑上等价于“后到的覆盖先到的”配合上一节清洗管道可以保证数据版本是可回溯的。对账报告我一般每周跑一次统计新增数量、变更数量和失效文书数量这三项能直接反映采集任务的健康度。5. 常见问题与排查全量采集中最容易翻车的 5 个故障5.1 高频请求触发会话封禁列表页变成空响应现象运行良好的采集系统突然连续返回空列表或者页面内容变成“暂无数据”。原因这不是网站挂了而是当前会话的请求频率累计值触发了阈值。裁判文书网的频控是滑动窗口式的短时间降速并不会立刻恢复要等一个完整的统计周期过去。解决把采集端降速到原有 1/3 以下并强制切换会话 Cookie。如果是分布式多节点协同采集检查是否有多个节点共用同一个出口 IP——很多分布式框架默认每个节点随机路由但出口 IP 的复用情况只能在网络层看到。5.2 解析字段错位刑事案件的被告人被装进了原告席位现象清洗时发现某批次数据的法院、案由、当事人字段大面积错位。原因页面结构变了。裁判文书网对部分旧文书的 HTML 结构做了兼容性处理但新发布的文书用了新的属性名解析器还是老的find到的节点就串位了。解决解析层要预留“结构版本”字段。我在解析函数里会让每个字段同时尝试 3 个 CSS 选择器按优先级命中并在 DataFrame 里记录命中的版本号。运行前先抽样 50 篇人工比对一次不要拿全量直接跑。5.3 中文乱码正文里一半是问号和成对的反斜杠现象采集下来的 HTML 在浏览器里打开正常用 pandas 读出来全是乱码。原因最常见的是压缩和编码双重问题——响应是 gzip 压缩的但resp.text猜测的编码不对另外一种是页面里嵌了\uXXXX形式的 Unicode 转义序列直接输出了原始转义。解决请求时显式声明resp.encoding utf-8或者用requests.get(..., headers{Accept-Encoding: gzip})让 requests 自动处理。Unicode 转义用bytes(text, utf-8).decode(unicode_escape)还原。5.4 分布式锁失效同一案件被采集两遍现象清洗时case_id去重后数据量依然比预期多出一截而且重复项不是完全一样有些字段是旧的。原因锁过期时间设得太短任务还没跑完锁就释放了或者锁已存在且持锁节点有心跳但实际上采集线程已经卡死心跳是另一个线程在维持。解决把锁过期时间设为 120 秒以上并为锁增加“任务级心跳”——每个子任务开始和结束都会刷新一次心跳让锁的生命周期与任务生命周期对齐。5.5 死信任务堆积队列永远跑不完现象Redis 队列长度长期不变但done集合的增长趋近于零后台日志里全是同一个 URL 的重试记录。原因某个详情页在页面层面上已经下线或者该文书被从公开列表中移除但列表页链接还挂在缓存里。每次重试都走到验证码流程浪费了频控配额。解决每类任务设置“最大重试次数”超过 3 次直接进死信队列一个独立的 List后续人工批量确认是删除还是回放。死信队列不是用来恢复的而是用来发现问题页面的——经常检查死信队列里的 URL 模式能提前发现网站结构调整的端倪。6. 验证采集结果抽样比对、数据质量评分与增量对账全量数据跑完后第一件事不是激动地进建模而是做一次严格的验证。我自己的习惯是三个步骤走完才敢把数据放给研究人员用。第一步是抽样比对。从清洗后的数据里随机抽 300 条用计算机打开对应文书的官网链接人工核对案号、审判法院、判决日期和当事人姓名四个字段任何一项不一致都算解析错误。核对结果用accuracy 1 - 错误条数 / 300作为本批次数据的质量基线。我个人的可接受底线是单字段错误率低于 1%四字段全对的文书占比不低于 97%。一旦低于这个线说明解析器或清洗规则需要修正全量数据要重新跑一遍清洗——这个教训我吃过亏曾经因为日期解析漏了朝鲜语字符集导致一批数据里的日期全是空值研究团队跑回归时才发现。第二步是质量评分。用 pandas 对全量数据生成一个质量报告内容包括空值字段的占比、案号正则匹配失败的条数、日期超出合理范围的条数、正文长度异常分布的条数。报告的价值不在报告本身而在趋势判断——增量跑了一周后这些指标如果持续恶化说明网站可能在调整页面结构或反爬策略需要提前介入而不是等到数据烂透了才返工。第三步是增量对账。我每周一跑一次对账脚本对比本周新增条数和官网公告的“本周新增文书数量”允许误差在 5% 以内超过这个误差就检查是不是漏了某个分类下的列表页。全量爬取是一个长期维护的系统工程不是跑一次就完事。我见过太多项目把“爬完”当成终点结果两周后数据就没法更新了。这套系统的真正价值是让你在任何一天醒来时都知道昨天的数据有没有正常入库有没有该采而未采的文书躺在死信队列里。希望这些从实际采集中磨出来的路径和习惯能帮你在做法律数据采集时少走弯路。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。