ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python爬虫+法律文本挖掘+数据库设计:构建可检索的法律案例库

Python爬虫+法律文本挖掘+数据库设计:构建可检索的法律案例库 去年有段时间我一直想做一个法律案例检索的小工具输入案由能返回相关案例输入关键词能快速定位判决结果。市面上现成的法律数据库很多但要么要钱要么字段不够灵活于是干脆自己动手从爬虫抓取、文本清洗到数据库落库、基础文本挖掘走了一条完整的链路。这篇博客就是当时那次实践的技术复盘核心玩法是 Python 爬虫 法律文本挖掘 数据库设计目标读者是想用公开数据做课程设计、毕业设计或者打算入行数据分析、法律科技方向的开发者。整套流程说复杂也不复杂无外乎三件事把网页上的法律案例抓下来把非结构化的判决书文本整理成结构化字段再存进数据库方便查询。真正花时间的不是写代码而是搞清楚法律文本的规律和那些藏在细节里的坑。这篇内容会从项目需求拆解讲起一路讲到并发爬取、文本挖掘、表结构设计以及问题排查尽量把我踩过的坑和合理的做法都写清楚方便你照着复现。1. 项目定位与需求拆解1.1 先想清楚我要的是数据管道不是一个爬虫脚本很多人一上来就写爬虫爬到几万条数据后才发现不知道往哪放、不知道字段怎么设计、不知道重复数据怎么处理最后只能删掉重来。我建议第一步不要碰代码而是先把这个项目的定位想清楚你要的不是一个能跑通的爬虫而是一条能稳定运行的“数据管道”。所谓数据管道就是从目标网站抽取数据经过清洗转换最终进入存储系统并能够被检索的完整链路。这条链路要求你对每个阶段都有清晰的交付物抓取层能按列表页翻页并采集详情页 URL 与核心字段解析层能从 HTML 中抽取案号、法院、日期、当事人、判决结果等信息清洗层能处理编码、全半角、正则误匹配等问题存储层能设计合理的表结构支持去重、更新与条件查询应用层能按案由、法院、时间等维度做统计与分析。我在项目启动前会先写一个简单的字段清单。以法律案例为例至少要包含案号、法院名称、案由、审理程序、审结日期、原告、被告、判决结果文本、原文全文。有些字段在网页里可能没有比如案件标的额那就留空不要强行用正则硬抽否则会引入大量脏数据。1.2 数据源选择与合法性边界这一步非常关键且必须放在需求拆解中重点考虑。我选的案例源是公开的、允许下载的政府公开数据平台和部分法院官网的公告页面这类平台通常有明确的数据开放协议适合做个人学习研究也方便后续用于课程设计。法律案例数据本身是公共信息但具体到某个网站就要遵守该网站的 robots 协议和服务条款。不建议去搞那些需要登录、需要验证码、明确禁止爬取的平台。一方面法律风险高另一方面技术上要处理的事情会成倍增加比如验证码识别、登录态维护这些都不是个人项目该花时间的地方。我在实际项目中还会做两个约束第一控制抓取频率每两次请求之间至少间隔 1-2 秒不打扰目标站点第二只保存自己搭建检索系统所需的最小字段全文可以裁剪掉敏感信息只保留裁判理由、判决结果等主要段落。1.3 技术选型为什么是 requests BeautifulSoup而不是 Scrapy技术上我选了 requests BeautifulSoup lxml 这套组合而不是很多人推荐的 Scrapy。原因很简单法律案例页面结构不算太复杂数据量在几千到几万条规模用轻量库写出来的代码更容易理解和修改对初学者也友好。requests 负责网络请求BeautifulSoup 负责解析 HTML两者配合足够应付绝大多数公开案例页面。如果你要抓百万级页面再考虑 Scrapy 也不迟。Scrapy 自带并发、管道、中间件适合大规模分布式采集但对应的学习成本也高。在这个项目里我始终认为先跑通最小闭环更重要。环境准备命令如下:python -m venv law_crawler_env source law_crawler_env/bin/activate # Windows 下是 law_crawler_env\Scripts\activate pip install requests beautifulsoup4 lxml jieba数据库我选用 SQLite 起步。原因有两个零配置一个文件就是一个库适合个人项目支持标准 SQL方便后面迁移到 MySQL 或 PostgreSQL。如果你打算做成 web 服务再考虑换成 MySQL / PostgreSQL但前期用 SQLite 做原型完全够用。2. 爬虫开发从单页抓取到并发调度2.1 先手动把页面结构看明白写代码之前不要急着写请求。先用浏览器打开一个目标案例列表页按 F12 打开开发者工具把页面结构看清楚。主要观察三件事列表页的每一行是否包含了案号、案由、法院这些基础字段详情页的 URL 规律是路径参数还是查询参数详情页正文中判决书各部分是否有固定的 class 或 HTML 标签。这一步是在做“样本观察”决定了你后面的解析逻辑怎么写。经验是至少抽 10 个不同案由的案例看一眼因为刑事、民事、行政案件的文书结构差异很大。比如刑事案件会出现“公诉机关”“被告人”等字段民事案件则是“原告”“被告”“委托诉讼代理人”正则和选择器要兼顾这些差异。2.2 单页抓取与解析先跑通一个再谈批量单页抓取是整个项目的底座必须稳定。我当时先写了一个最小函数专门负责请求详情页并解析出所需字段。import requests import re from bs4 import BeautifulSoup def fetch_page(url, headersNone): if headers is None: headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 } resp requests.get(url, headersheaders, timeout10) resp.raise_for_status() return resp.text def parse_detail(html): soup BeautifulSoup(html, lxml) # 假设正文在 id 为 content 的 div 里 content soup.select_one(#content) text content.get_text(\n, stripTrue) if content else case_no re.search(r\d{4}[^\n]{0,10}?(民|刑|行)[^\n]{0,10}?号, text) case_no case_no.group(0) if case_no else court soup.select_one(td.courtName) court court.get_text(stripTrue) if court else return { case_no: case_no, court: court, full_text: text, }有个细节网页里的案号通常用的是中文全角括号“”而不是英文半角括号“()”所以正则要用和而不是(和)。法院名称和日期也是如此优先从 HTML 的固定标签里拿拿不到再去正文里用正则抽标签字段比全文抽取更稳定。2.3 列表页翻页与详情页 URL 收集单页解析通过后再处理列表页。法律案例列表页一般都有翻页参数常见的有page、pageNum、start等。我建议先把前 5 页的 URL 打印出来人工对比参数变化规律再写循环。注意有些站点对列表页做了异步加载直接请求页面看不到数据而是要请求一个 JSON 接口。这种情况就换一种思路在开发者工具的 Network 面板里过滤 XHR/Fetch 请求找到返回数据列表的接口直接请求它而不是去解析 HTML。我遇到过很多次列表页拿不到数据的情况最后都是靠找接口解决的。详情页 URL 收集完成后我习惯先把 URL 存起来再逐个请求详情而不是边抓列表边抓详情。这样做的目的是把“发现任务”和“执行任务”分离后面做断点续爬和并发调度都更方便。2.4 并发、限速与断点续爬单线程爬几万条数据会非常慢所以并发是必然要考虑的。Python 里最简单的并发方式是用ThreadPoolExecutor配合threading.Semaphore控制最大同时请求数。from concurrent.futures import ThreadPoolExecutor, as_completed import threading import time import random import sqlite3 MAX_WORKERS 4 semaphore threading.Semaphore(MAX_WORKERS) def crawl_detail_with_limit(url): with semaphore: try: html fetch_page(url) item parse_detail(html) item[url] url return item except Exception as exc: print(fFailed: {url}, error: {exc}) return None finally: time.sleep(random.uniform(1, 2))这里我特别强调两点第一线程数不是越大越好。对目标站点要保持克制4-8 个线程已经足够。线程开太多很可能被对方限流甚至封 IP得不偿失。如果你确实需要大规模采集应该通过合规渠道获取代理 IP并设计好随机延时而不是无限增加并发。第二必须记录“已完成”的 URL。我是建了一张crawled_urls表存已抓取 URL每次启动爬虫前先从表里读取 URL 集合跳过已经抓过的。这样就算程序中途崩溃重启后也能从断点继续不用从头爬。def save_crawled_url(url): conn sqlite3.connect(law.db) conn.execute(INSERT OR IGNORE INTO crawled_urls(url) VALUES (?), (url,)) conn.commit() conn.close()2.5 异常重试与容错网络请求不可能百分百成功超时、连接重置、解析空标签都很常见。我的经验是写一个简单的重试装饰器最多重试 3 次每次间隔递增。如果重试 3 次依然失败就跳过并记录日志不阻塞整体流程。def retry(func, times3, delay2): for i in range(times): try: return func() except Exception: if i times - 1: raise time.sleep(delay * (i 1))这里要小心一点不要在finally里继续抛异常否则本来想忽略的错误会变成新的崩溃点。日志里也要记录 URL 和具体异常类型方便事后人工检查。3. 法律文本挖掘把判决书拆成可查询的字段3.1 裁判文书的结构特征爬下来的全文是一个大文本字符串直接从里面搜索字段效率低也不利于数据库查询。要想把非结构化文本变成结构化数据得先了解裁判文书的基本结构。常见结构如下首部法院名称、文书类型、案号、诉讼参与人信息案件由来与审理经过诉辩意见原告诉称、被告辩称经审理查明的事实本院认为法院对争议焦点的分析判决结果法条依据 裁判主文。不同案由和审理程序会有调整但大体框架是稳定的。了解结构后做文本挖掘就有章法了可以从每个段落里按特征词定位关键信息。3.2 用正则提取案号、日期与判决结果案号是法律案例的唯一标识也是最核心的字段。常见格式如下2023京01民初1234号2022粤民终5678号2023沪刑终102号对应的正则我拆成了两部分括号年份和编号体。注意全角括号和中文括号混排的问题最终我用了这样一个兼容写法import re case_no_pattern re.compile(r[(]\s*(\d{4})\s*[)][^\s]{0,30}?号) def extract_case_no(text): m case_no_pattern.search(text) return m.group(0) if m else 日期提取比较简单常见格式有“2023年5月12日”“2023-05-12”用两个正则分别匹配然后统一转换成2023-05-12格式再入库。判决结果这一块通常是“依照《中华人民共和国合同法》第四十条……判决如下一、……二、……”。我的做法是定位“判决如下”或“裁定如下”后面的文本再用分号或句号切分保留包含“被告”“驳回”“支付”“赔偿”等关键词的句子。这部分不需要做到完美能提取出关键的动作词即可。def extract_result(text): idx text.find(判决如下) if idx -1: idx text.find(裁定如下) if idx -1: return return text[idx: idx 300]3.3 当事人角色提取与案由分类原告、被告、第三人的提取可以基于角色关键词定位。以民事判决书为例首部一般会出现“原告张三男1980年出生”“被告李四公司住所地……”。我的提取方式是在全文开头 2000 字内搜索“原告”“被告”“第三人”关键词截取关键词后的一段文本直到遇到下一个角色关键词或换行再用逗号或句号切分取第一段作为姓名/名称。这里有个容易踩的坑正文里也会反复出现“原告认为”“被告辩称”如果在全文搜索会把后面的内容都误判为首部的当事人。所以提取范围要限制在文书开头或者用“原告”第一次出现的位置作为锚点。案由分类是另一个有意思的问题。官方案由是“民间借贷纠纷”“买卖合同纠纷”“机动车交通事故责任纠纷”等直接匹配关键词即可。我先维护了一个案由关键词词典例如“民间借贷” - 民间借贷纠纷“买卖合同” - 买卖合同纠纷“交通事故” - 机动车交通事故责任纠纷“离婚” - 离婚纠纷然后遍历关键词如果在文首或案由字段中出现就归类。这个方案精度高、可解释性强缺点是词典需要人工维护。想要更复杂的案由归类可以在后续数据量足够大时用机器学习文本分类但对于个人项目和课程设计关键词词典已经足够。3.4 高频法条与关键词统计裁判文书中经常引用法条例如“依照《中华人民共和国民法典》第五百六十三条”“依据《中华人民共和国民事诉讼法》第六十七条”。用正则《[^》]》第[^条第、]条?可以抽取出引用的法律名称和条款然后统计频次。做法是先把“本院认为”之后的文本单独提取出来再做正则匹配因为法律法规依据基本都出现在裁判说理和判决结果部分。统计时用Counter记数按频次倒序排列就能直观看到不同类型案件中最常被引用的法律条文。关键词提取我用的是 jieba 分词 TF-IDF 权重。具体步骤是对清洗后的全文分词去掉停用词“的”“了”“本院”“认为”“原告”“被告”等再用 jieba.analyse.extract_tags 提取关键词。这部分对做摘要和快速了解案件语义很有用。import jieba.analyse text item[full_text] tags jieba.analyse.extract_tags(text, topK10, withWeightTrue) for word, weight in tags: print(word, weight)需要注意分词对法律术语的处理并不完美比如“法定代理人”“诉讼时效”可能会被切碎。我的做法是往 jieba 词典里补充自定义词例如“诉讼时效”“举证责任”“合同解除”等效果会好很多。4. 数据库落地表结构、入库与查询4.1 表结构设计先满足查询再考虑扩展数据库设计要面向查询去想。这个项目最常用的查询是“按案由查案例”“按法院查案例”“按关键词查判决结果”所以表结构按照“案例主表 当事人表 关键词表”来设计比较合理。CREATE TABLE IF NOT EXISTS cases ( id INTEGER PRIMARY KEY AUTOINCREMENT, case_no TEXT UNIQUE, court TEXT, cause TEXT, procedure TEXT, judgment_date TEXT, plaintiff TEXT, defendant TEXT, result_text TEXT, full_text TEXT, source_url TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS parties ( id INTEGER PRIMARY KEY AUTOINCREMENT, case_id INTEGER, role TEXT, name TEXT, FOREIGN KEY (case_id) REFERENCES cases(id) ); CREATE TABLE IF NOT EXISTS keywords ( id INTEGER PRIMARY KEY AUTOINCREMENT, case_id INTEGER, keyword TEXT, weight REAL, FOREIGN KEY (case_id) REFERENCES cases(id) ); CREATE TABLE IF NOT EXISTS crawled_urls ( url TEXT PRIMARY KEY, crawled_at TEXT DEFAULT CURRENT_TIMESTAMP );case_no设为唯一键是防止重复数据的最后一道闸门。parties表和keywords表用外键关联主表虽然查询时要关联但数据组织更规范后续做更复杂的分析也方便。4.2 批量入库与更新个人项目最容易出现的一个性能问题是一条一条执行 INSERT几千条之后程序会明显变慢。正确的做法是批量提交。Python 的 sqlite3 模块支持executemany也支持事务把多条数据放到一个事务里提交。def insert_case(conn, item): sql INSERT INTO cases( case_no, court, cause, procedure, judgment_date, plaintiff, defendant, result_text, full_text, source_url ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(case_no) DO UPDATE SET court excluded.court, cause excluded.cause, result_text excluded.result_text conn.execute(sql, ( item[case_no], item[court], item[cause], item[procedure], item[judgment_date], item[plaintiff], item[defendant], item[result_text], item[full_text], item[source_url], ))这里用了ON CONFLICT DO UPDATE的 upsert 语义如果案号已存在就更新法院、案由、判决结果这些可变字段而不是插入一条新记录。对于少量重复数据来说这样做比“先查再插”更高效也避免了重复数据污染统计结果。4.3 常用查询与索引优化入库完成后查询会变成主要操作。以下三个查询是这个项目最高频的用法-- 按案由统计案件数 SELECT cause, COUNT(*) AS cnt FROM cases WHERE cause ! GROUP BY cause ORDER BY cnt DESC; -- 按法院查询某案由案件列表 SELECT case_no, court, judgment_date, plaintiff, defendant FROM cases WHERE court 北京市朝阳区人民法院 AND cause 民间借贷纠纷 ORDER BY judgment_date DESC; -- 全文关键词搜索SQLite 内置 FTS5 可以代替 LIKE SELECT case_no, court, cause FROM cases WHERE full_text LIKE %违约金% LIMIT 50;数据量到达十万级以后LIKE %关键词%会全表扫描性能明显下降。我建议给法院、案由、审结日期加上普通索引如果要做全文搜索改用 SQLite FTS5 虚拟表。把案例文本灌进 FTS5 后查询速度能提升一个数量级而且支持中文分词表对法律文本搜索相当实用。CREATE VIRTUAL TABLE cases_fts USING fts5( case_no, full_text, contentcases, content_rowidid ); INSERT INTO cases_fts(case_no, full_text) SELECT case_no, full_text FROM cases;4.4 入库时的编码与特殊字符处理数据入库前一定要处理特殊字符。有些裁判文书原文里有“\u3000”全角空格、乱码字符、不可见字符入库后会影响查询和展示。我在清洗函数里固定做三件事替换全角空格为普通空格去除不可见控制字符把连续多个空白符压缩成一个。import re def clean_text(text): text text.replace(\u3000, ) text re.sub(r[\x00-\x1f\x7f], , text) text re.sub(r\s, , text) return text.strip()这一步看似不起眼但能省掉大量后面排查数据异常的时间。我最初偷懒没做清洗结果统计法院名称时出现了一堆带空格和换行符的脏值比如“北京市 朝阳区人民法院”浪费了整整一个下午。5. 常见问题与排查实录5.1 爬虫篇为什么拿到的是空页面最常见的问题是请求返回了 HTML但解析出来的列表是空的。排查思路按顺序来打印响应状态码确认不是 403 / 404检查返回内容长度如果只有几 KB 且内容里包含“验证”“访问过于频繁”说明被反爬策略拦截了在浏览器里打开同一 URL看是否正常展示数据检查请求头确认 User-Agent、Referer 等字段是否完整。我碰到过一种情况平时请求正常加了一个Referer之后反而被拦截查了半天发现是 Referer 的域名写错了。浏览器开发者工具里复制出来的 Referer 往往带有页面路径不要只写域名最好直接复制完整值。5.2 文本挖掘篇正则抽不到案号案号抽不到多半是括号或中文标点不统一。有的文书用全角“ ”有的用半角“( )”还有的是英文逗号后面带空格。我的建议是先用文本编辑器打开一条样本肉眼观察它长什么样再写正则。正则不要过度设计拆成“年份 法院简称 文书类型 编号”四段分别匹配每段都允许全角和半角混用这样比写一个长长的大正则稳健得多。5.3 数据库篇SQLite 并发写入报 database is lockedSQLite 同一时间只允许一个写事务。爬虫是并发的多个线程同时写库就会出现database is locked。我的解法是提供一个独立的写线程所有采集线程把解析好的数据放进queue.Queue写线程专门从队列里取数据并批量写入。这样可以保证同一时刻只有一个写者写入性能也更好。import queue import threading write_queue queue.Queue() def writer_worker(): conn sqlite3.connect(law.db, check_same_threadFalse) while True: item write_queue.get() if item is None: break try: insert_case(conn, item) conn.commit() except Exception as exc: print(exc) finally: write_queue.task_done()关于check_same_threadFalse要说明一下SQLite 默认要求连接只能由创建它的线程使用设为 False 意味着允许多线程共享连接但它不解决并发写冲突真正的保障还是“单写者模式”。如果数据量再上一个量级就应该换 PostgreSQL 这类支持连接池的数据库。5.4 常见异常速查表我整理了这张速查表方便你在项目里快速对照。现象可能原因处理建议列表页能打开内容为空异步加载数据在 JSON 接口中用 Network 面板找真实数据接口请求返回 403缺少 User-Agent 或触发反爬伪装合理请求头降低抓取频率案号全部为空全角半角括号混用正则兼容和()数据库报 locked多线程并发写入使用单写者线程或连接池法院名称带空格未清洗全角空格和换行入库前统一执行 clean_text统计数据偏少有重复 case_no 被 upsert检查案号唯一性约束jieba 分词结果很碎缺少法律术语自定义词典添加自定义词并加载用户词典重试一直失败目标站点已禁止访问暂停并检查站点规则不要硬抗写在最后几个让我后悔没早点知道的经验整套项目做完我的一个体会是法律文本挖掘的难点不在模型而在数据清洗和规则设计的细致程度。看起来很简单的一个“原告”提取实际会碰到各种格式变化比如“原告张三”“原告张三向本院提出诉讼请求”“原告委托诉讼代理人”等等。如果不先在样本上做足够的观察后面的规则就是空中楼阁。另一个经验是项目从第一天起就要有“断点续跑”的意识。我第一次爬数据时没有记录已抓 URL程序跑到一半崩了重启后又从头开始既浪费时间也增加对方服务器的压力。后来把 URL 去重表加上整个流程才稳下来。最后建议你从一个小范围开始先抓 500 条数据完成“入库 查询 统计”的闭环再考虑扩充到几千几万条。小数据量能让你更快发现问题也能让整个方案的结构更清晰。等这个闭环跑通再往里面加并发、加全文搜索、加分类模型就会顺很多。
返回列表