裁判文书网生产级爬虫:多线程+代理池+SQLAlchemy工程实践

发布时间:2026/10/3 4:24:21
裁判文书网生产级爬虫:多线程+代理池+SQLAlchemy工程实践
简介这是一套面向法律研究者、法学专业学生及数据分析师的Python爬虫工具专为突破中国裁判文书网反爬机制、批量获取案件docid并下载完整文书内容而设计解决法律数据采集效率低、人工整理成本高的核心痛点。资源包共20个文件含9个核心Python脚本如GetWenshu.py、Analyticak_wenshu.py实现多线程抓取与法律依据解析、7个配置类txt文件涵盖地域、页码、参数等关键控制项、1个详细说明文档.docx和1个README.md整体仅266KB轻量易部署。已有246人学习下载用户可直接运行源码完成docid批量采集、文书正文概要自动生成、法律条文引用定位与解析等全流程操作代码结构清晰模块分工明确登录、请求头管理、区域筛选、结果分析支持代理IP动态切换与参数灵活配置便于二次开发与场景适配。1. 裁判文书网爬虫系统不是“一键下载”而是能稳定跑通、带法律依据解析、可存进数据库的生产级 Python 爬虫工具你试过在裁判文书网手动翻页、点开每份文书、复制案号、再粘贴到搜索框里查下一页吗我干过3 小时只扒了 47 份其中 12 份因页面跳转失败直接丢数据。这不是效率问题是根本不可持续——而这个「裁判文书网爬虫系统」就是为解决这个真实痛点设计的它不只抓 docid更把文书正文、法院认定事实、裁判理由、法律依据条款比如《刑法》第236条第1款、甚至判决结果类型驳回/支持/部分支持都结构化提取出来用多线程动态代理IP池扛住反爬用 SQLAlchemy 写入 MySQL/SQLite支持断点续爬和去重校验。它不是教学 Demo而是我在三个律所合规审查项目中实际部署过的版本平均单日稳定采集 8000 份有效文书含完整正文失败率压在 1.7% 以下。适合法律科技团队、法学研究者、合规风控工程师——如果你需要的是可审计、可复现、能进分析 pipeline 的原始数据源而不是“能跑就行”的脚本这份资源就是为你准备的。2. 系统架构与核心模块拆解为什么必须用多线程代理IP池SQLAlchemy而不是 requests for 循环这个爬虫不是“requests BeautifulSoup 拼凑起来的玩具”它是一套有明确分层、可运维、可扩展的工程化方案。下面从选型逻辑和代码实现两个维度讲清楚每个模块为什么这么设计、怎么协同工作。2.1 为什么必须用多线程而非 asyncio——裁判文书网的请求特征决定的裁判文书网的反爬机制对并发请求并不敏感但对单 IP 的请求频率极其苛刻实测发现同一 IP 连续请求间隔低于 1.8 秒大概率触发验证码而单次请求响应时间波动极大300ms4.2s主要卡在服务端渲染和 CDN 缓存穿透上。在这种场景下asyncio 的协程调度优势被严重稀释——大量 await 在等网络 IOCPU 利用率反而不如多线程。更重要的是多线程能天然隔离 session 和 cookies避免不同线程间 cookie 冲突导致的登录态失效这是裁判文书网最常翻车的点。我们实测对比过方案单 IP 日均采集量验证码触发率线程/协程崩溃率数据完整性requestsfor循环串行≈ 320 份5%0%100%但太慢asyncioaiohttp≈ 1100 份38%22%session 错乱76%部分文书正文为空threadingrequests.Session≈ 8200 份1.7%0.3%仅因代理失效99.2%经 checksum 校验提示不要迷信“asyncio 一定比 threading 快”。在裁判文书网这种高延迟、低连接数、强状态依赖的场景下多线程才是更稳的选择。我一般会设max_workers12配合time.sleep(2.1)基础间隔再叠加代理轮换效果远超异步方案。2.2 代理 IP 池不是“加个 proxies 参数”那么简单——它必须支持自动检测、失效剔除和权重调度很多新手以为“买个代理套餐写死proxies{http: xxx}就完事”结果跑两小时就全挂。裁判文书网对代理质量极其挑剔要求 HTTP 支持 POST 表单提交、支持 Referer 伪造、支持 Cookie 持久化、且 DNS 解析必须稳定否则requests.get()直接 timeout。我们的代理池模块做了三件事自动探测启动时并发测试所有代理用requests.head(http://wenshu.court.gov.cn, timeout3)验证连通性并记录响应时间动态剔除每个 worker 线程在请求前从池中按权重随机取一个代理若该代理连续 3 次返回503或timeout则标记为unavailable并降权至 0.11 小时后自动重检Referer 绑定代理池返回的 proxy 字典强制包含headers: {Referer: http://wenshu.court.gov.cn}因为裁判文书网校验 Referer 是硬性反爬规则。# proxy_manager.py 核心逻辑节选 class ProxyPool: def __init__(self, proxy_list: List[str]): self.proxies [] for p in proxy_list: # 标准化代理格式http://user:passip:port → dict parsed urlparse(p) self.proxies.append({ http: fhttp://{parsed.username}:{parsed.password}{parsed.hostname}:{parsed.port}, https: fhttp://{parsed.username}:{parsed.password}{parsed.hostname}:{parsed.port}, headers: {Referer: http://wenshu.court.gov.cn} }) self.weights [1.0] * len(self.proxies) # 初始权重全为1 def get_proxy(self) - dict: idx random.choices(range(len(self.proxies)), weightsself.weights)[0] return self.proxies[idx].copy() # 返回副本避免线程间污染 def mark_unavailable(self, idx: int): self.weights[idx] * 0.1 # 权重衰减非清零这段代码的关键在于不追求“永远在线”而追求“快速失效感知”。代理挂了不可怕可怕的是继续用它浪费请求配额。我们通过权重衰减定时重检让坏代理自然沉底好代理持续获得更高调度概率——这才是生产环境该有的弹性。2.3 SQLAlchemy 不是“为了用而用”而是解决法律文书数据建模的刚性需求裁判文书不是纯文本它有强结构案号含年份、法院代字、序号、审理法院需映射到最高法四级法院编码、当事人信息原告/被告/第三人含身份类型、法律依据精确到条款项、判决结果支持/驳回/调解/撤诉。如果用 CSV 或 JSON 存后续做“检索某省高院近3年引用《民法典》第1024条的名誉权案件”就得全文扫描性能崩盘。SQLAlchemy 让我们定义清晰的 ORM 模型# models.py from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Index from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import relationship Base declarative_base() class Court(Base): __tablename__ courts id Column(Integer, primary_keyTrue) code Column(String(20), uniqueTrue, indexTrue) # 如 court_010100 name Column(String(100)) level Column(Integer) # 1:最高法, 2:高院, 3:中院, 4:基层院 class Document(Base): __tablename__ documents id Column(Integer, primary_keyTrue) docid Column(String(64), uniqueTrue, indexTrue) # 裁判文书网唯一标识 case_number Column(String(100), indexTrue) # 案号如 (2023)京0101民初1234号 court_id Column(Integer, ForeignKey(courts.id)) court relationship(Court) title Column(String(200)) # 文书标题 publish_date Column(DateTime) content Column(Text) # 完整HTML正文已清洗 summary Column(Text) # 正文概要由NLP模块生成 legal_basis Column(Text) # 法律依据解析结果JSON格式[{law: 民法典, article: 1024, paragraph: 1, content: 民事主体享有名誉权...}] # 建立复合索引加速法律依据检索 Index(ix_legal_basis_law_article, Document.legal_basis, postgresql_usinggin)这个模型直接支撑两类刚需查询SELECT * FROM documents WHERE legal_basis LIKE %民法典% AND legal_basis LIKE %1024%;SELECT c.name, COUNT(*) FROM documents d JOIN courts c ON d.court_id c.id GROUP BY c.name ORDER BY COUNT(*) DESC;没有 SQLAlchemy 的 schema 管理和 migration 支持这种数据规模百万级文书下字段变更、索引优化、跨库同步都会变成噩梦。3. 核心功能实现从 docid 批量获取、正文解析到法律依据结构化提取这个系统最值钱的部分不是“能爬”而是“爬下来之后能干什么”。下面三步每一步都对应一个独立模块且全部开源可复现。3.1 docid 批量获取绕过前端分页直击后端搜索接口的 POST 请求构造裁判文书网的搜索页看似是 GET实则是隐藏 form 提交。关键不是抓 URL而是逆向出它的Param加密参数。我们不用 Selenium 模拟点击太慢且易被识别而是复现其前端 JS 加密逻辑前端用RSA公钥加密搜索关键词公钥固定硬编码在 JS 中Param字段是base64(encrypt(rsa_pubkey, json.dumps({keyword: ..., page: 1})))vl5x字段是md5(docid timestamp salt)用于防重放salt 从首页 HTML 中正则提取。# search_engine.py import base64 import json import hashlib import re from Crypto.PublicKey import RSA from Crypto.Cipher import PKCS1_v1_5 RSA_PUBLIC_KEY -----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu... -----END PUBLIC KEY----- def build_search_param(keyword: str, page: int 1) - str: data {keyword: keyword, page: page} key RSA.import_key(RSA_PUBLIC_KEY) cipher PKCS1_v1_5.new(key) encrypted cipher.encrypt(json.dumps(data).encode()) return base64.b64encode(encrypted).decode() def extract_vl5x(html: str, docid: str) - str: # 从首页HTML中提取 salt形如 scriptvar saltabc123;/script salt_match re.search(rvar\ssalt\s*\s*([^]), html) if not salt_match: raise ValueError(Cannot find salt in HTML) salt salt_match.group(1) timestamp str(int(time.time() * 1000)) raw docid timestamp salt return hashlib.md5(raw.encode()).hexdigest() # 实际请求示例 session requests.Session() home_html session.get(http://wenshu.court.gov.cn).text vl5x extract_vl5x(home_html, dummy_docid) # 实际用真实docid param build_search_param(合同纠纷, page1) resp session.post( http://wenshu.court.gov.cn/website/wenshu/181107ANFZ0BXSK4/index.html, data{ Param: param, vl5x: vl5x, number: , # 空字符串非None guid: str(uuid.uuid4()).replace(-, ) # 随机guid } )参数说明Param是加密后的搜索条件vl5x是防重放签名guid是会话标识。这三个字段缺一不可且vl5x必须和当前时间戳强绑定——这就是为什么不能缓存vl5x必须每次请求前重新计算。3.2 文书正文解析不是简单soup.find(div.content)而是基于 DOM 结构的语义块切分裁判文书 HTML 极其混乱p标签嵌套无规律、br滥用、表格与段落混排、甚至存在span styledisplay:none隐藏干扰文本。我们放弃通用清洗采用“模板匹配 规则修正”双策略模板匹配预定义 7 类文书结构刑事判决书、民事判决书、行政裁定书等每类有专属 XPath 规则定位“本院认为”、“经审理查明”、“判决如下”等关键锚点规则修正对锚点间文本做三步清洗① 合并连续br为段落分隔② 删除nbsp;和\xa0③ 用正则归一化标点如。→。避免 NLP 分词错位。# parser.py def parse_civil_judgment(html: str) - dict: soup BeautifulSoup(html, lxml) # 定位核心区块以“本院认为”为起点到“审判人员”或“书记员”结束 start_tag soup.find(lambda t: t.name p and 本院认为 in t.get_text()) if not start_tag: return {summary: , legal_basis: [], content: clean_html(html)} # 向下遍历直到遇到结束标识 blocks [] for sibling in start_tag.next_siblings: if sibling.name p and any(kw in sibling.get_text() for kw in [审判人员, 书记员, 二〇]): break if sibling.name in [p, div, span]: text sibling.get_text(stripTrue) if text and len(text) 10: # 过滤短文本和空行 blocks.append(text) full_content \n.join(blocks) summary generate_summary(full_content) # 调用本地 LLM 摘要 legal_basis extract_legal_clauses(full_content) # 正则词典匹配 return { summary: summary, legal_basis: legal_basis, content: full_content } def extract_legal_clauses(text: str) - List[Dict]: # 匹配模式《中华人民共和国刑法》第二百三十六条第一款 patterns [ r《([^》])》(?:(?:第|条)(\d)(?:条|款|项|目)?(?:第|款|项|目)?(\d)?), r《([^》])》(?:(?:第|条)(\d)(?:条|款|项|目)?), ] results [] for pattern in patterns: for match in re.finditer(pattern, text): law, article, paragraph match.groups() if not paragraph: paragraph results.append({ law: law.strip(), article: article.strip(), paragraph: paragraph.strip(), content: get_law_clause(law, article, paragraph) # 从本地法规库查原文 }) return results这个解析器的价值在于它输出的legal_basis是结构化 JSON不是字符串。后续做“统计《刑法》第236条在强奸罪判决中的援引频次”直接 SQL 查询即可无需再做 NLP 实体识别。3.3 法律依据解析功能不只是“找到法条”而是关联到权威法规库并提取上下文光匹配到“《刑法》第236条”没用用户真正需要的是这条法条原文是什么它在司法解释中如何细化同类案件中法官怎么说理我们的做法是本地法规库内置laws.dbSQLite 数据库含《刑法》《民法典》《刑诉法》等 23 部核心法律全文按“法律-章节-条-款-项”四级索引上下文提取当匹配到article236时不仅返回该条全文还自动提取其前后两条即 235、237 条构成“适用语境”司法解释关联在法规库中建立law_to_interpretation关系表例如《刑法》第236条 → 《关于办理强奸、猥亵未成年人刑事案件适用法律若干问题的解释》第3条。# law_database.py def get_law_clause(law_name: str, article: str, paragraph: str ) - str: conn sqlite3.connect(laws.db) cursor conn.cursor() # 主查询找精确匹配 query SELECT content FROM clauses WHERE law_name ? AND article ? ORDER BY level DESC LIMIT 1 cursor.execute(query, (law_name, article)) result cursor.fetchone() if result: base_content result[0] else: base_content f【未收录】{law_name} 第{article}条 # 补充上下文前一条、后一条 prev_art str(int(article) - 1) if article.isdigit() else next_art str(int(article) 1) if article.isdigit() else context [] for art in [prev_art, next_art]: if art: cursor.execute(SELECT content FROM clauses WHERE law_name ? AND article ?, (law_name, art)) ctx cursor.fetchone() if ctx: context.append(f【{law_name}第{art}条】{ctx[0][:80]}...) conn.close() return base_content \n\n【上下文参考】\n \n.join(context) if context else base_content这个设计让“法律依据解析”真正落地研究员输入一个案号系统返回的不只是“引用了哪条法”而是“这条法怎么写的、法官为什么选它、同类案件怎么用它”——这才是法律研究需要的深度。4. 避坑指南那些让你凌晨三点还在 debug 的真实翻车现场这套系统我部署过 5 次每次上线前都得重踩一遍坑。下面这 5 条全是血泪经验不是教科书理论。4.1 现象爬虫跑着跑着突然全量 403代理池里所有 IP 都显示“unavailable”原因裁判文书网后端会记录User-AgentIPCookie三元组的请求指纹。即使你换了代理如果User-Agent固定比如一直用Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36且 Cookie 未及时更新JSESSIONID过期服务器会判定为“同一用户高频切换 IP”直接封禁整个 UA 池。解决在requests.Session初始化时动态生成 UA并每 50 次请求强制刷新 Cookie# utils.py def get_random_ua() - str: uas [ Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15, Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 ] return random.choice(uas) # 在 worker 线程中 session.headers.update({User-Agent: get_random_ua()}) if counter % 50 0: session.cookies.clear() # 强制清空触发下次请求重建 session4.2 现象legal_basis字段里出现大量【未收录】但明明法规库文件存在原因法规库laws.db是用sqlite3工具生成的但 Python 的sqlite3模块默认使用utf-8编码打开 DB 文件。如果法规库导出时用了gbk中文 Windows 默认Python 读取就会乱码WHERE law_name ?查询永远不命中。解决统一用 UTF-8 重建法规库并在连接时显式指定编码# 重建法规库命令行 iconv -f gbk -t utf-8 laws_gbk.sql | sqlite3 laws.db # Python 连接时 conn sqlite3.connect(laws.db) conn.text_factory str # 强制文本为 str非 bytes4.3 现象多线程跑着跑着MySQL 报OperationalError: (1205, Deadlock found when trying to get lock)原因所有线程都在往documents表插入数据且docid是唯一索引。当两个线程几乎同时插入相同docid因去重逻辑未生效MySQL 会触发死锁——一个线程回滚另一个成功但回滚线程的session.commit()抛异常导致整个 worker 崩溃。解决改用INSERT ... ON CONFLICT DO NOTHINGPostgreSQL或INSERT IGNOREMySQL并在 ORM 层封装重试逻辑# database.py def safe_insert_document(session, doc_data): try: session.add(Document(**doc_data)) session.commit() except IntegrityError as e: if Duplicate entry in str(e): session.rollback() # 主动回滚不抛异常 logging.info(fDocid {doc_data[docid]} already exists, skipped.) else: raise e except Exception as e: session.rollback() raise e4.4 现象summary字段内容全是乱码或者长度只有 20 字原因generate_summary()函数调用本地 LLM如 Qwen-7B时输入文本超过模型最大上下文4096 token被截断。而裁判文书正文平均 8000 字符直接喂进去LLM 只看到开头几百字摘要自然失真。解决先用规则抽取关键段落“本院认为”“判决如下”再送入 LLMdef generate_summary(content: str) - str: # 优先提取法律说理和判决主文 key_parts [] for marker in [本院认为, 综上所述, 判决如下, 裁定如下]: idx content.find(marker) if idx ! -1: end_idx content.find(\n, idx len(marker)) if end_idx -1: end_idx len(content) key_parts.append(content[idx:end_idx200]) # 截取 marker 后 200 字 input_text \n.join(key_parts)[:3000] # 严格限制输入长度 return llm_client.generate(input_text)4.5 现象docid获取成功但下载正文时返回{code: 1, msg: 访问过于频繁}原因裁判文书网的 docid 接口/website/wenshu/181107ANFZ0BXSK4/index.html和正文接口/website/wenshu/181107ANFZ0BXSK4/detail.html?DocIDxxx是两个独立反爬系统。你 docid 拿得再稳正文接口照样可能限流——尤其当DocID是批量获取的服务器会认为你在“预加载”。解决正文请求必须带Referer且必须是 docid 对应的详情页 URL不能直接GET /detail.html?DocIDxxx# 正确方式先构造 Referer URL再发请求 referer_url fhttp://wenshu.court.gov.cn/website/wenshu/181107ANFZ0BXSK4/detail.html?DocID{docid} session.headers.update({Referer: referer_url}) resp session.get(fhttp://wenshu.court.gov.cn/website/wenshu/181107ANFZ0BXSK4/detail.html?DocID{docid})5. 部署与验证从本地调试到 Docker 化生产环境的完整链路这套系统最终不是跑在你笔记本上而是要部署到服务器7×24 小时采集。下面是我验证过的最小可行部署方案不依赖云厂商纯 Linux Docker。5.1 本地开发环境用 conda 隔离依赖避免 Python 版本冲突裁判文书网爬虫对requests、lxml、cryptography版本极其敏感。我用 conda 创建专用环境而非 pip# 创建环境指定 Python 3.9兼容性最好 conda create -n wenshu-crawler python3.9 conda activate wenshu-crawler # 安装核心包注意 cryptography 必须 40.0.0否则 RSA 加密失败 pip install requests2.31.0 \ beautifulsoup44.12.2 \ lxml4.9.3 \ cryptography39.0.2 \ sqlalchemy1.4.49 \ pysqlite30.5.0 \ schedule1.2.0 # 安装本地 LLMQwen-7B-Chat量化版 pip install transformers4.36.2 \ accelerate0.25.1 \ bitsandbytes0.43.1注意cryptography39.0.2是关键。新版cryptography40.0.0移除了PKCS1_v1_5的某些旧签名方式会导致Param加密失败返回{code: 2, msg: 参数错误}。5.2 Docker 部署用 multi-stage 构建镜像体积压缩到 1.2GB我们不用FROM python:3.9-slim而是用FROM continuumio/miniconda3:4.12.0直接复用 conda 环境避免 pip 编译耗时# Dockerfile FROM continuumio/miniconda3:4.12.0 # 复制 conda 环境文件 COPY environment.yml /tmp/environment.yml RUN conda env create -f /tmp/environment.yml \ conda clean --all -f -y # 复制源码 COPY . /app WORKDIR /app # 激活环境并设为默认 SHELL [conda, run, -n, wenshu-crawler, bash, -c] CMD [python, main.py, --mode, daemon]构建命令docker build -t wenshu-crawler . docker run -d \ --name wenshu-prod \ -v /data/wenshu:/app/data \ -v /data/laws.db:/app/laws.db \ -e PROXY_LISThttp://user:pass1.1.1.1:8000,http://user:pass2.2.2.2:8000 \ wenshu-crawler5.3 数据验证三道防线确保每份文书“可查、可读、可分析”部署后不能只看日志“Success”必须用三道自动化检查存在性检查每天凌晨执行 SQL确认昨日采集量 ≥ 预期值如 8000且docid无重复完整性检查随机抽 100 份content IS NOT NULL AND LENGTH(content) 500结构化检查验证legal_basis字段是否为合法 JSON且至少含 1 条有效法条law和article非空。# validate_daily.py def daily_validation(): engine create_engine(sqlite:///data/crawler.db) with engine.connect() as conn: # 检查总量 count conn.execute(SELECT COUNT(*) FROM documents WHERE DATE(publish_date) DATE(now, -1 day)).scalar() assert count 8000, fDaily count {count} 8000 # 检查完整性 valid_content conn.execute( SELECT COUNT(*) FROM documents WHERE DATE(publish_date) DATE(now, -1 day) AND content IS NOT NULL AND LENGTH(content) 500 ).scalar() assert valid_content 95, Content completeness 95% # 检查结构化 valid_json conn.execute( SELECT COUNT(*) FROM documents WHERE DATE(publish_date) DATE(now, -1 day) AND json_valid(legal_basis) 1 AND json_length(legal_basis) 0 ).scalar() assert valid_json 90, Legal basis JSON validity 90%这个验证脚本每天自动运行失败则发邮件告警。它让我彻底告别“以为在跑其实早挂了”的玄学运维。5.4 进阶技巧用schedulelogging实现“自愈式”爬虫守护真正的生产级爬虫必须能自己从故障中恢复。我们用schedule做心跳logging做状态追踪# main.py import schedule import logging from datetime import datetime # 配置日志按天滚动保留30天 logging.basicConfig( levellogging.INFO, format%(asctime)s - %(levelname)s - %(message)s, handlers[ logging.handlers.TimedRotatingFileHandler( logs/crawler.log, whenmidnight, interval1, backupCount30 ), logging.StreamHandler() ] ) def health_check(): # 检查代理池存活率 pool ProxyPool.load_from_env() alive_rate sum(1 for w in pool.weights if w 0.5) / len(pool.weights) if alive_rate 0.7: logging.warning(fProxy pool alive rate {alive_rate:.2f} 0.7, triggering reload) pool.reload() # 重新加载代理列表 # 检查数据库连接 try: engine create_engine(sqlite:///data/crawler.db) engine.execute(SELECT 1) except Exception as e: logging.error(fDatabase connection failed: {e}) # 发送企业微信告警此处省略具体实现 # 每10分钟执行一次健康检查 schedule.every(10).minutes.do(health_check) # 主爬取任务每小时执行一次 schedule.every().hour.at(:00).do(run_crawler_job) # 启动调度器 while True: schedule.run_pending() time.sleep(30)这个设计让爬虫具备“自愈”能力代理挂了自动 reloadDB 断了自动告警连不上就等下次重试——我不用半夜被电话叫醒它自己就能扛住大部分抖动。从那以后我每次上线新版本都强制走一遍docker-compose down docker-compose up -d sleep 60 python validate_daily.py——不是信它是信验证。希望帮到你。本文还有配套的精品资源点击获取