简介:面向法律研究与司法数据分析场景,这套基于Python的分布式爬虫系统,可对中国裁判文书网案件信息进行自动化采集、清洗与存储,并支持文书详情页解析和文本下载。系统自带反反爬机制,适合有一定Python基础的爬虫开发者与法律数据研究者使用。
资源包共17个文件,体积仅42KB。核心为13个Python脚本,覆盖生产者、日志、检查、CLI入口、配置等模块;另含说明txt、项目说明docx、运行日志log及README,便于快速了解工程结构和启动方式。目前已有109人浏览学习。
借助这套系统可快速了解分布式爬虫的模块拆分、反爬策略、站点字段解析与清洗后数据的组织思路,对搭建法律文本数据Pipeline或类似政务数据采集项目有直接借鉴意义。
1. 裁判文书网爬虫项目:谁会用、能做什么、风险在哪
法律实证研究、类案检索、量刑分析,这些工作都绕不开一份数据来源:裁判文书。手点网页逐条复制,几百份还能忍,几万份甚至几十万份就是不可能完成的任务。基于 Python 的分布式爬虫,把「采集、清洗、存储、解析、下载」串成一条自动化流水线,正是这个项目标题想解决的痛点。它可以服务于高校法学院、律所智库、司法数据公司做统计分析或案件画像,也能支撑个人的法律数据小实验。
但先泼一盆冷水:裁判文书网的反爬强度在同类站点里属于第一梯队,WAF 规则更新快,且官方近年对文书公开口径做过多轮调整。指望解压一个 zip 就能跑出全网文书,不现实。这篇文章会把这个系统拆开讲清楚——从架构设计、反反爬策略到数据清洗和踩坑记录,让你能从中取出真正可复用的部分,搭建一套属于自己的、合规克制的采集方案。
2. 分布式爬虫架构:为什么单机跑不动,以及队列怎么拆
2.1 裁判文书网的数据规模与单机瓶颈
裁判文书网的文书总量在亿级上下浮动(存在下线、撤回和调整),日增量峰值也常到数万份。单机爬虫的最大问题不是 CPU,而是「等待」——一次请求从发出到收到响应普遍要 1 到 3 秒,加上限速要求,单线程一小时最多抓一两千页。即便开了线程池把并发拉到 32,单机出口 IP 的请求频率也很快触达站点的反爬阈值,结果就是验证码频出、IP 被临时封禁。
分布式解决的不是「快」的单一问题,而是把「抓取、解析、清洗、存储」拆成多个可以独立扩缩的环节。常见做法是按照生产者-消费者模型组织:一个调度节点负责任务切分,多个抓取节点从消息队列取任务执行,再把结果抛给清洗和落库环节。这样即便一台机器被封,其他机器仍能继续工作,而且可以随时加节点横向扩容。项目标题里的「分布式」不是噱头,在这个场景下,它真实解决的是 IP 分散和失败隔离的问题。
2.2 基于 Redis 的任务队列:最省事的分布式落地方式
真正的分布式框架(比如 Scrapy-Cluster、Celery)对这个场景来说偏重。我一般会先用 Redis 的 List 类型做任务队列,配合阻塞弹出命令实现多节点消费。这套方案的优点是:依赖少、代码直观、出问题好排查。调度节点负责向 Redis 推入待抓取 URL,每个抓取节点 BRPOP 取出 URL 执行抓取,完成后再把解析结果推入另一个队列供清洗节点消费。
下面是一个最小可跑的生产者-消费者骨架。先看生产者:
# producer.py import redis REDIS_HOST = "localhost" REDIS_PORT = 6379 TASK_QUEUE = "wenshu:task:list" # 待抓取URL队列 DONE_SET = "wenshu:task:done" # 已完成URL指纹集合,用于去重 r = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, db=0) def push_url(url: str) -> None: fingerprint = hashlib.md5(url.encode("utf-8")).hexdigest() if r.sadd(DONE_SET, fingerprint) == 0: # 已经抓过,直接跳过 return r.rpush(TASK_QUEUE, url)这里sadd的返回值是关键:1表示集合中原本不存在该指纹,说明这是一个新任务;0表示已存在,跳过即可。使用主键去重,避免同一个 URL 被推进队列多次。
再看消费者:
# worker.py import redis, requests, json, time r = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, db=0, decode_responses=True) TASK_QUEUE = "wenshu:task:list" CLEAN_QUEUE = "wenshu:clean:list" def fetch(url: str) -> dict: # 实际抓取逻辑:请求头、代理、超时见后文 resp = requests.get(url, timeout=30) return {"url": url, "html": resp.text, "status": resp.status_code} while True: item = r.blpop(TASK_QUEUE, timeout=30) # 阻塞等待任务 if item is None: continue _, task_url = item try: result = fetch(task_url) r.rpush(CLEAN_QUEUE, json.dumps(result)) except Exception as e: # 抓取失败重新入队,限定重试次数避免死循环 r.rpush(TASK_QUEUE, task_url) time.sleep(5)用blpop而不是lpop,可以让多个 worker 同时阻塞等待而不会空转消耗 CPU。timeout=30表示队列为空时最多阻塞 30 秒左右。抓取异常时把 URL 重新推回队尾,配合一个重试计数器字段(比如 URL 前面拼接retry:1),防止永久循环。
参数选择上,Redis 队列本身不需要特殊配置,但要注意两点:给 List 设置合适的长度上限提示(用LTRIM或定期清理失败任务);开持久化(AOF)防止节点崩溃丢任务。
2.3 节点间如何避免重复抓取:分布式锁与去重
分布式环境下最常见的坑是多个 worker 同时拿到了同一个 URL。上面示例用sadd做去重,但如果两个 worker 同时在sadd之前检查,还是可能重复入队。更稳妥的做法是给「抓取中」状态加一个分布式锁,锁的 key 可以设计成lock:url:{fingerprint},使用 Redis 的SET NX EX原子命令:
# 加锁,TTL 60秒,防止持锁节点崩溃导致死锁 lock_key = f"lock:url:{fingerprint}" got = r.set(lock_key, "1", nx=True, ex=60) if got == 0: # 其他节点正在处理,跳过 return在极端情况下请求耗时超过 60 秒,锁自动过期,其他节点就会重新处理。解决思路是:合理评估单条 URL 的最大处理时间,设成它的 2 到 3 倍;同时在时效敏感的场景下引入续租机制——在一个后台线程里每 15 秒刷新一次 TTL。实际抓取场景中,单个请求很少持续超过 30 秒,所以简单的固定过期时间够用。
Redis 分布式锁的选型理由也在这里说明白:这个项目里不需要 Zookeeper 或 etcd,理由是没有复杂的一致性诉求,Redis 的SET NX EX在绝大多数并发场景下足够可靠,部署运维成本最低。
3. 反反爬机制:从请求头伪装到代理池与频率控制
3.1 为什么裁判文书网这么难爬:WAF 的判别逻辑
裁判文书网使用的 WAF 会从多个维度评估请求是否为爬虫:请求频率、User-Agent 与浏览器指纹、Cookie 合法性、HTTP 协议特征、TCP 连接行为。普通爬虫最容易暴露的是「规律性」——固定间隔、固定请求头、无鼠标轨迹、单 IP 高频访问。反反爬的本质就是让每个请求的「行为特征」和真实用户接近,并且把失败后的降级策略想清楚。
很多从零开始写代码的人只改了 User-Agent 就以为完成了反反爬,这是典型认知误区。只改 UA 会在第一轮被识别:因为浏览器指纹(比如 HTTP/2 指纹、TLS 指纹、字体列表)对不上。Python 的 requests 库默认使用 HTTP/1.1,和主流浏览器的网络指纹存在明显差异。如果项目要追求高成功率,建议直接改用curl_cffi或playwright这类能模拟真实浏览器指纹的库。
3.2 请求头随机化与浏览器指纹模拟
请求头随机化是最廉价的一道防线。不要只准备一两个 UA,要准备一个包含移动端和桌面端的池子,每次请求随机取用。同时 Header 里的Accept、Accept-Language、Sec-Fetch-*、Upgrade-Insecure-Requests等字段要遵循浏览器的顺序和写法,不要随意省略。
我用curl_cffi做请求层的示例,因为它在网络指纹层面能模拟 Chrome 的 TLS 和 HTTP/2 特征:
# request_sender.py from curl_cffi import requests as cffi_requests import random UA_POOL = [ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", ] def build_headers(): ua = random.choice(UA_POOL) return { "User-Agent": ua, "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,en;q=0.8", "Connection": "keep-alive", "Referer": "https://example.com/", # 按实际入口站点调整 } def fetch_page(url: str, proxy: str | None = None, timeout: int = 30): headers = build_headers() kwargs = {"headers": headers, "timeout": timeout, "verify": False} if proxy: kwargs["proxies"] = {"http": proxy, "https": proxy} resp = cffi_requests.get(url, **kwargs) return respcurl_cffi的参数里,impersonate="chrome"可以启用浏览器指纹模拟,我示例代码里没有写全,实际日常使用我会加上:
resp = cffi_requests.get(url, impersonate="chrome", headers=headers, timeout=30)这里的impersonate参数会接管 TLS 指纹、HTTP/2 指纹、Header 顺序等底层细节。注意代理参数的标准写法:proxies={"http": "http://ip:port", "https": "http://ip:port"},不要在 URL 里手动拼接代理地址。
3.3 代理池:构造、验证与轮换策略
代理池的质量直接决定这套方案能跑多久。免费代理池的问题在于可用率低、生命周期短,必须做两件事:抓取后立即验证、定时剔除失效代理。下面这个验证脚本用最小成本测出代理真正可用的比例:
# proxy_validate.py import threading, requests, queue TEST_URL = "https://example.com" # 换成你实际要访问的站点 test_queue = queue.Queue() result_queue = queue.Queue() def validator(threshold: float = 5.0): while True: proxy = test_queue.get() if proxy is None: break try: requests.get(TEST_URL, proxies={"http": proxy, "https": proxy}, timeout=threshold) result_queue.put((proxy, True)) except Exception: result_queue.put((proxy, False)) finally: test_queue.task_done() # 用法:把代理列表放入 test_queue,开十几个线程并发验证 # 最后从 result_queue 取回结果,可用率过低的代理直接丢弃代理轮换策略上,优先尝试「每个请求换一个代理」,如果发现代理质量不稳定,就改为「每个代理最多连续使用 N 次」(N 取 5 到 20),降低换代理带来的连接耗时。遇到 403 时要能自动标记当前代理失效并在短时间内不再使用。
这里要强调:代理池是反反爬中最容易翻车的环节。付费代理也有被 WAF 标记的风险,因为出口 IP 的段可能已经被识别。一个务实的做法是混用住宅代理和数据中心代理,并保留本地日志,记录每个代理的成功率,后期根据统计结果动态调整代理池策略。
3.4 频率控制与验证码的风控降级
无论请求头伪装得多好,频率都是最终的红线。真实用户再怎么快,也不可能持续 10 分钟每秒 5 次请求。限速要放在「每次请求之前」而不是「之后」,并且要有随机抖动。令牌桶是一个成熟的限速模型,但对 Python 脚本来说,简单的随机 sleep 通常足够:
import time, random MIN_INTERVAL = 2.0 MAX_INTERVAL = 5.0 def rate_limit(): delay = random.uniform(MIN_INTERVAL, MAX_INTERVAL) time.sleep(delay)两个参数按站点实际风控强度调整。如果触发验证码,不要立刻「硬撞」,应当暂时降低MAX_INTERVAL,或者切换到备用 IP。验证码出现时页面结构往往有明显标记(比如出现滑块组件或校验字段),通过响应内容的关键词来判断,一旦命中就触发降级策略:
if "verify" in resp.text or "captcha" in resp.text: # 该IP触发验证码,标记代理不可用,休息一段时间 mark_proxy_unavailable(proxy) time.sleep(60 * 5)这套降级策略的唯一目的是让整个采集流程更稳,不意味着能绕过更高级的人机校验。合规边界在后文还会展开。
4. 文书详情页解析与数据清洗:把 HTML 变成结构化数据
4.1 详情页结构拆解与解析方案选型
裁判文书详情页的典型字段包括:标题、案号(如「(2024)京0105民初12345号」)、案件类型、文书类型、法院名称、审判程序、当事人信息、判决日期、正文文本。页面既有 HTML 实体,也有经转义的嵌套内容,解析时需要先定位到正文容器,再做文本归一化。
解析方案上,我用 BeautifulSoup + lxml 的组合。理由是:BeautifulSoup 的find方法链写起来直观,遇到不规范的 HTML 也能容错;lxml 的解析速度比 html.parser 快数倍,适合批量场景。不要用正则表达式提取正文——HTML 结构的细微变化就会让正则全面崩溃,维护成本高到没法接受。
4.2 一个字段抽取函数的完整实现
下面是详情页解析的示例代码,能覆盖大部分页面结构。注意这里的选择器需要以实际页面为准,它是模板而非终版:
# parser.py from bs4 import BeautifulSoup def parse_detail(html: str) -> dict: soup = BeautifulSoup(html, "lxml") # 标题:通常位于 h1 或特定 class 的容器 title_tag = soup.find("h1") or soup.find("div", id=lambda x: x and "tit" in x) title = title_tag.get_text().strip() if title_tag else "" # 案号:常包含在 .case-number 或类似 class 中,也常出现在标题内 number_tag = soup.find("span", class_=lambda c: c and "number" in c) case_no = "" if number_tag: case_no = number_tag.get_text().strip() if not case_no: # 兜底:从标题里提取形如 (2024)京0105民初12345号 的内容 import re m = re.search(r"[\((]\d{4}[\))][^,。]*?号", title) case_no = m.group(0) if m else "" # 正文:最常见的是一个 id 含 content/body/text 的区域 body_container = soup.find("div", id=lambda x: x and ("content" in x or "text" in x)) body_text = "" if body_container: body_text = body_container.get_text(separator="\n", strip=True) return { "title": title, "case_no": case_no, "body": body_text, "raw_length": len(html), # 供排错使用,看解析前后长度对比 }这段代码体现了解析的三个要点。第一层是「精确查找优先」,优先选择具有语义化的 class 或 id。第二层是「兜底正则」,案号这类格式稳定的字段正则很可靠。第三层是让解析器对结构变化有所感知——raw_length这个字段在后续排查「为什么某条数据正文为空」时非常有用。
需要注意,get_text(separator="\n", strip=True)不只是简单拼接,还会在块级元素之间插入换行,这对判决书的段落结构还原很有帮助。去掉这个参数,所有段落会被挤成一行,给后续的自然语言处理增加不必要的麻烦。
4.3 数据清洗:编码统一、空白压缩与日期标准化
从网页抓下来的数据有三个普遍问题:编码乱码(gb2312 与 utf-8 混用)、全角半角混杂、日期格式多样。数据清洗环节要先把这些问题收口,避免脏数据进入数据库。
# cleaner.py import re from datetime import datetime def normalize_encoding(text: str) -> str: # 统一转为 utf-8,浏览器实测页面声明常有错误 return text.replace("\u3000", "\u0020") # 全角空格转半角 def compress_whitespace(text: str) -> str: # 把多个空白符压成单个换行或空格 text = re.sub(r"[ \t]+", " ", text) text = re.sub(r"\n{3,}", "\n\n", text) return text.strip() def normalize_date(date_str: str) -> str: # 处理 2024年1月5日 / 2024-01-05 / 2024.1.5 三种常见格式 date_str = date_str.strip() for fmt in ("%Y年%m月%d日", "%Y-%m-%d", "%Y.%m.%d"): try: return datetime.strptime(date_str, fmt).strftime("%Y-%m-%d") except ValueError: continue return date_str # 解析不了就原样返回,由人工兜底清洗不是越激进的替换越好。normalize_date返回原值而不是抛异常,是刻意为之——一条脏数据比一条丢失的数据更容易追溯和修正。清洗环节的设计原则是「保留原始值 + 输出规范值」,而不是清洗完就丢弃原始版本。建议在存储层保留一个raw_html字段或者 HTML 文件,便于后续重新解析。
在 pandas 的应用场景中,清洗封装成函数之后用DataFrame.apply批量跑。清洗逻辑尽量做成纯函数不动全局状态,这样方便并行处理。pandas+数据清洗的场景在这个项目里主要是对落库后的批量结果做二次统计,比如统计按年的文书数量、按法院的地域分布,这些不需要在抓取流水线里实时做。
4.4 存储方案与文件下载的设计取舍
这个项目的存储落点取决于后续用途。做法律研究分析,我会推荐 JSON Lines 文件 + SQLite 的组合。JSON Lines 方便增量写入、便于用各种工具快速查看;SQLite 适合结构化查询,而且不需要单独部署数据库服务。每次抓取解析完,json.dumps一行一个对象追加写入。
文书下载功能不仅仅是把 HTML 存下来。建议按「案号/法院/日期」组织目录结构,避免几十万个文件堆在同一个目录里:
# storage.py import os, json def save_parse_result(item: dict, base_dir: str) -> str: case_no = item.get("case_no", "unknown") # 用案号后两位做二级目录,防止单目录文件数过大 sub_dir = os.path.join(base_dir, case_no[:2]) os.makedirs(sub_dir, exist_ok=True) file_path = os.path.join(sub_dir, f"{case_no}.json") with open(file_path, "w", encoding="utf-8") as f: json.dump(item, f, ensure_ascii=False, indent=2) return file_path目录分片是实操里很有用的习惯。单目录超过几万个文件后,文件系统性能会明显下降,检索也麻烦。分片让问题可定位,也方便后续做数据版本管理。
5. 避坑与排查:这五个问题最容易让项目翻车
5.1 抓取几十页后突然 403
- 现象:连续正常抓取一段时间,某一刻起几乎所有请求都返回 403,即使换代理也一样。
- 原因:站点已经识别出当前 IP 段或指纹特征,触发了临时封禁;也可能是请求频率超过了 WAF 的阈值,与代理池无关。
- 解决:暂停 10 到 15 分钟让封禁自动解除;把单 IP 的请求频率降到原来的 1/3;检查是否为
curl_cffi的impersonate参数失效,改用最新版 Chrome 指纹。遇到这种问题先看日志里封禁前最后 10 个请求的间隔,通常是限速参数写死导致的。
5.2 Redis 任务堆积但消费者全部空闲
- 现象:队列长度持续上涨,worker 进程没有报错但也不消费。
- 原因:消费者阻塞在
blpop上且等待超时设得太短;或者代理池无可用代理导致 fetch 函数每次都抛异常、重试又失败,反复循环造成假性空闲。
5.3 详情页字段大面积缺失
- 现象:解析完成的数据里,
title和body有一半为空。 - 原因:站点改版导致原有的选择器失效,或列表页与详情页用的是不同的 HTML 模板。
- 解决:对比新页面的 DOM 结构,更新选择器。同时在解析函数里加「空值统计」:每次解析完把空字段情况写入日志,如果空值率超过 10%,立即告警——沉到 1000 条数据再发现字段全空,要重新清洗,非常耗时。
5.4 重复数据比预想的多
- 现象:同一案号在数据库里出现多次,且批次不同。
- 原因:列表页存在同一案件多条文书(一审、二审、再审),指纹去重只针对 URL 而非案件本身;断点续爬时已完成任务集合丢失。
- 解决:去重键从 URL 换成
(case_no, 文书类型),在解析入库阶段再做一次幂等校验。对于 Redis 持久化,需要开启 AOF 并定期备份 RDB,防止重启丢集合数据。
5.5 乱码和繁体混排
- 现象:正文出现「锟斤拷」或「�」类型的替换字符,还有一部分字符看似是繁体实际是简体被错误解码。
- 原因:页面声明 utf-8 但实际为 gb2312,或者请求时返回的压缩流被错误解码。
requests有时会依赖响应头猜测编码,猜测错误就会出现这种问题。 - 解决:强制性按「响应头编码 → 页面声明的 meta charset → 试探性解码」顺序处理。更稳妥的方法是直接用
resp.content传给解析器,让 BeautifulSoup 的UnicodeDammit自动探测,而不是用resp.text。这个改动虽小,能解决掉大部分编码坑。
6. 增量抓取与验证:把项目收尾做成可持续的习惯
增量抓取比全量重要得多。全量这个概念对于裁判文书网这类亿级规模而且还在动态调整的站点,不应该是第一版的目标。合理的路径是以「近 N 天变更」作为起点,跑通流程后再逐步回溯历史。增量逻辑的核心是维护一个时间游标:记录上次抓取的结束时间,下次只取这个时间之后发布的文书,避免每天重复拉取昨天之前已经入库的数据。
验证全链路是否健康的办法是写一个冒烟检查脚本。我会把验证分成三层:网络层(抓取成功率不低于某阈值)、解析层(字段非空率不低于某阈值)、存储层(文件可读且能统计出合理分布)。跑完一组数据,输出这三组数字,哪一层掉了立即定位。
# smoke_test.py def run_smoke(sample_size: int = 200): urls = load_sample_urls(sample_size) # 从最近增量数据中随机抽样 success = 0 parsed = 0 for url in urls: resp = fetch_page(url) if resp.status_code == 200: success += 1 item = parse_detail(resp.text) if item["title"] and item["body"]: parsed += 1 return { "fetch_rate": success / len(urls), "parse_rate": parsed / success, }我会给自己定一个 95% 的抓取成功率和 90% 的解析成功率作为底线,低于这个值不做大规模增量。这套方法救过我太多次:在大规模运行前先小样本验证,永远比事后清洗几万条垃圾数据轻松。这也是我想分享给每一个做爬虫方向研究的人的习惯——把验证和监控做成强制环节,甚至自动化。希望这个方案能帮你少踩一些坑,在裁判文书网数据采集这条路上走得稳一点。
本文还有配套的精品资源,点击获取