
简介面向法律研究与司法数据分析场景这套基于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(hostREDIS_HOST, portREDIS_PORT, db0) 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(hostREDIS_HOST, portREDIS_PORT, db0, decode_responsesTrue) TASK_QUEUE wenshu:task:list CLEAN_QUEUE wenshu:clean:list def fetch(url: str) - dict: # 实际抓取逻辑请求头、代理、超时见后文 resp requests.get(url, timeout30) return {url: url, html: resp.text, status: resp.status_code} while True: item r.blpop(TASK_QUEUE, timeout30) # 阻塞等待任务 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。timeout30表示队列为空时最多阻塞 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 flock:url:{fingerprint} got r.set(lock_key, 1, nxTrue, ex60) 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/xhtmlxml,application/xml;q0.9,image/avif,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.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的参数里impersonatechrome可以启用浏览器指纹模拟我示例代码里没有写全实际日常使用我会加上resp cffi_requests.get(url, impersonatechrome, headersheaders, timeout30)这里的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}, timeoutthreshold) 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, idlambda 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, idlambda x: x and (content in x or text in x)) body_text if body_container: body_text body_container.get_text(separator\n, stripTrue) return { title: title, case_no: case_no, body: body_text, raw_length: len(html), # 供排错使用看解析前后长度对比 }这段代码体现了解析的三个要点。第一层是「精确查找优先」优先选择具有语义化的 class 或 id。第二层是「兜底正则」案号这类格式稳定的字段正则很可靠。第三层是让解析器对结构变化有所感知——raw_length这个字段在后续排查「为什么某条数据正文为空」时非常有用。需要注意get_text(separator\n, stripTrue)不只是简单拼接还会在块级元素之间插入换行这对判决书的段落结构还原很有帮助。去掉这个参数所有段落会被挤成一行给后续的自然语言处理增加不必要的麻烦。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_okTrue) file_path os.path.join(sub_dir, f{case_no}.json) with open(file_path, w, encodingutf-8) as f: json.dump(item, f, ensure_asciiFalse, indent2) 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% 的解析成功率作为底线低于这个值不做大规模增量。这套方法救过我太多次在大规模运行前先小样本验证永远比事后清洗几万条垃圾数据轻松。这也是我想分享给每一个做爬虫方向研究的人的习惯——把验证和监控做成强制环节甚至自动化。希望这个方案能帮你少踩一些坑在裁判文书网数据采集这条路上走得稳一点。本文还有配套的精品资源点击获取