尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Python爬虫实战:手把手教你采集司法解释目录分页

Python爬虫实战:手把手教你采集司法解释目录分页 我最早开始写爬虫就是因为一个特别“笨”的需求把某个司法数据库里的司法解释目录手动一页页复制出来。当时翻了不到十页就崩溃了——目录项少说几百条每页20条光翻页加复制粘贴就得折腾一个多小时更别提中间接电话手一抖就不知道翻到哪一页了。后来花了一个午饭时间写了个Python脚本requests加parsel不到80行代码三分多钟跑完全部目录还顺手按发布时间排好了序。从此我对爬虫的态度变成了凡是超过5分钟就能做完的重复手工操作都应该用脚本去做。这篇文章就把这套采集司法解释目录分页的完整流程拆开讲。文章的标题是《Python爬虫实战手把手教你如何采集司法解释目录分页》所以我不会只给一个“能跑就行”的脚本而是会把每一步为什么要这么做、遇到报错怎么查、翻页规律是怎么发现的都讲透。不管是刚入门Python的读者还是已经写过一些基础爬虫但没系统整理过分页逻辑的读者这篇文章都会让你对“分页采集”这件事有一个完整的认知。1. 司法解释目录这个项目为什么值得当成爬虫案例来练很多Python爬虫教程喜欢拿新闻列表、商品列表当例子但司法解释目录这种数据其实更适合新手入门分页采集。原因有三点。第一它的分页结构极度规整。司法数据库的目录查询页面翻页参数通常就是页码数字加每页条数这两个变量没有复杂的异步加载也不涉及JavaScript渲染。这意味着用最基础的requests请求就能拿到HTML再用parsel提取数据整个链路清晰好理解。学一次以后碰到任何规整的列表型页面都能复用这套思路。第二它的字段结构有代表性。一条司法解释目录通常包含名称、发布文号、发布日期、效力级别这几个字段。这些信息在页面结构里都有明确的HTML标签包裹用XPath或CSS选择器提取起来很有代入感。你把司法解释当成“商品名、价格、销量”来看爬虫逻辑完全一致。第三也是最实际的一点这数据真的有用。法律从业者、法学生、做法律产品的人经常需要整理司法解释的完整目录来做检索、比对、统计。人工整理效率低不说还容易抄错文号。爬虫一次跑完生成CSV或Excel后续怎么用都方便。拿到这个项目之后第一步不是写代码而是先想清楚采集流程。我习惯把整个任务拆成三个问题数据在哪目标页面的URL长什么样子翻页时URL怎么变。要什么数据目录页里哪些字段需要保留哪些字段是噪音。存成什么样最终是要CSV、Excel还是直接进数据库。这三个问题想清楚代码只是把它们翻译成Python而已。2. 开工前的环境准备与合规检查别跳过这步2.1 安装必要依赖库这个项目只需要三个库都是爬虫领域的标配pip install requests parsel pandas依次说明用途。requests负责发HTTP请求拿HTML是整个爬虫的地基。parsel负责解析HTML、提取目标数据它底层用的是lxml性能足够快API设计也比直接用lxml友好。pandas严格来说不是必须的但你要把数据存成Excelpandas的DataFrame.to_excel一句代码就能搞定省去手动操作csv模块的麻烦。建议在虚拟环境里装这些依赖不要直接往全局Python环境里塞。用venv或者conda都可以防止各个项目之间的依赖互相打架。2.2 先查网站是否允许爬取这一步很多新手会跳过去但恰恰是最能体现专业素养的环节。拿到目标网站之后先用浏览器访问该域名下的robots.txt文件看看目录采集相关的规则是怎么写的。robots.txt里面会明确告诉爬虫哪些路径允许访问、哪些路径禁止抓取。另外还要克制请求频率。哪怕是公开数据也不要用并发去怼对方的服务器那既不礼貌也容易被封IP。我个人的习惯是每页请求之间sleep 0.5到1秒整套目录跑下来也就三四分钟对方服务器几乎无感。2.3 构造最小可验证的请求头请求头里最重要的字段是User-Agent。很多网站对没有UA标识的请求会直接拒绝因为正常浏览器不可能不带上UA。你可以在浏览器里按F12在Network面板里复制自己浏览器的User-Agent字符串。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, Accept: text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.5, }这里有个小细节不要只带UAAccept和Accept-Language也建议带上。有些WAF会校验请求头里的Accept字段格式缺失会触发异常行为。3. 先学会“看”页面单页目录的解析思路3.1 从真实页面里分析HTML结构在写任何爬虫逻辑之前动手打开一个目标目录页按F12打开开发者工具先把页面里的一条目录记录的结构找出来。这个动作看似简单实则是整个爬虫项目里最容易出错的地方。司法解释目录列表页的结构通常长这样div classlist-item h3 a href/interpretation/detail/12345最高人民法院关于适用《中华人民共和国刑事诉讼法》的解释/a /h3 div classmeta span classdoc-no法释〔2021〕1号/span span classpublish-date2021-01-26/span span classeff-level司法解释/span /div /div当然这只是示意结构不同网站的真实结构会有差异关键是要学会看懂规律每一条目录记录在HTML里是不是都对应同一个重复块字段分别放在哪个class里。3.2 XPath为什么比正则更合适新手经常有个误区提取信息第一反应是用正则表达式。但对于这种重复块的HTML结构XPath显然更合适。原因很直观XPath直接操作节点树能沿着HTML的层级关系去定位元素不需要关心标签里有没有换行空格这些细节。打个比方的话正则表达式像握着镊子从干草堆里挑针XPath则像直接按分类抽屉去拿东西——前提是抽屉的标签class名你认识。提取上面示意结构的核心XPath写法from parsel import Selector selector Selector(texthtml) items selector.xpath(//div[contains(class, list-item)]) for item in items: title item.xpath(.//h3/a/text()).get() doc_no item.xpath(.//span[contains(class, doc-no)]/text()).get() publish_date item.xpath(.//span[contains(class, publish-date)]/text()).get() eff_level item.xpath(.//span[contains(class, eff-level)]/text()).get() print(title, doc_no, publish_date, eff_level)这里有一个非常重要的点item变量已经是单个列表项的选择器了内部的xpath一定要用.//开头这个点代表从当前节点往下找。漏掉这个点XPath会从整个文档根节点重新找结果就是每一条循环都提取出第一项的标题字段全部错位。3.3 把解析逻辑封装成函数解析代码不要直接写在循环里建议封装成一个函数输入是HTML文本输出是一个字典列表。这样每页的解析逻辑相互独立后续排查问题也方便。def parse_directory_page(html): selector Selector(texthtml) records [] for item in selector.xpath(//div[contains(class, list-item)]): record { title: item.xpath(.//h3/a/text()).get(), doc_no: item.xpath(.//span[contains(class, doc-no)]/text()).get(), publish_date: item.xpath(.//span[contains(class, publish-date)]/text()).get(), eff_level: item.xpath(.//span[contains(class, eff-level)]/text()).get(), detail_url: item.xpath(.//h3/a/href).get(), } records.append(record) return records顺手把detail_url也取出来这个字段后续要扩展详情页采集时直接用得上现在多留一手以后少改一遍代码。4. 翻页规律的探索与抓取策略设计4.1 找到分页参数的核心规律单页解析做通了接下来就是分页。这是整个采集任务的核心难点。打开第二页对比第一页的URL通常有三种情况第一种是查询参数分页URL形如/type/jieshi?page2size20page参数控制页码这种最好办。第二种是路径分页形如/type/jieshi/page/2/页码嵌在路径里。第三种是POST请求加Form Data翻页页面刷新不改变URL这种相对麻烦需要构造POST请求体但逻辑也不复杂。金融、司法、政府网站里最常用的还是第一种。拿到规律后可以先手动在浏览器里改页码看看URL变化是否符合预期确认无误再写代码。这一步千万别省我见过太多人写完了循环才发现第二页和第一页返回的是同一份数据。4.2 总页数的三种获取方式循环抓取需要一个终止条件也就是总页数。常用的方案有三种解析页脚的分页器找到“最后一页”的链接提取其中的页码数字。这是最推荐的方式前提是分页器不是纯JavaScript渲染。解析分页器里的页码列表取最大值作为总页数。不提前获取总页数循环里发请求直到返回的列表为空或状态码异常时终止。三种方案里前两种需要在解析函数里额外处理分页器节点。为了代码简洁我更推荐写一个单独的函数get_total_pageshtmldef get_total_pages(html): selector Selector(texthtml) page_nums selector.xpath(//div[contains(class, pagination)]//a/text()).getall() numbers [] for p in page_nums: p p.strip() if p.isdigit(): numbers.append(int(p)) return max(numbers) if numbers else 1这里过滤掉“下一页”“最后一页”这类非数字文本只保留数字页码取最大值。如果页面只有一页分页器里没有数字就默认返回1。4.3 编写稳健的抓取循环循环的主体逻辑其实不复杂从第1页开始依次请求到最后一页每页解析完追加到结果列表。关键在两点一是请求异常时的处理二是频率控制。import time import requests def fetch_page(session, url, headers): try: resp session.get(url, headersheaders, timeout10) resp.raise_for_status() resp.encoding resp.apparent_encoding return resp.text except requests.RequestException as e: print(f请求失败URL: {url}错误: {e}) return None def crawl_all_pages(base_url, total_pages, headers, delay0.8): all_records [] with requests.Session() as session: for page in range(1, total_pages 1): url base_url.format(pagepage) html fetch_page(session, url, headers) if not html: print(f第{page}页请求失败跳过) continue records parse_directory_page(html) if not records: print(f第{page}页解析结果为空提前终止) break print(f第{page}页采集到{len(records)}条记录) all_records.extend(records) time.sleep(delay) return all_records这里有几个细节值得说。第一用requests.Session()保持会话Session会自动管理cookie很多网站的目录查询第一次访问会种cookie不接的话后续请求容易异常。第二resp.encoding resp.apparent_encoding这一步。司法类网站很多老系统用的是GBK编码requests从headers里拿到的编码不一定准。用apparent_encoding根据页面内容自动识别编码比死写utf-8稳妥得多。但要注意这种自动判断也不是100%正确如果遇到页面乱码优先考虑手动指定编码。第三records为空时直接break。这相当于一个退路防止总页数判断错误时脚本把最后几个重复页也抓进来。5. 数据落地从CSV到Excel的保存姿势5.1 CSV保存与utf-8-sig绕坑数据抓下来了保存这一步也有讲究。最简单的保存方式是CSVimport pandas as pd df pd.DataFrame(all_records) df.to_csv(sifa_interpretation_directory.csv, indexFalse, encodingutf-8-sig)注意这里的编码必须用utf-8-sig而不是普通的utf-8。原因很实际utf-8编码的CSV虽然记事本和Linux下都正常但用Excel直接打开时中文会乱码。utf-8-sig会在文件开头加一个BOM头Excel识别了BOM就知道是UTF-8编码中文显示正常。这个坑几乎每个爬虫新手都会踩一次区别只是踩完能不能记住。你现在记住了后面就少踩一次。5.2 Excel保存与列宽优化想要更直观的表格体验可以直接存成Excelwith pd.ExcelWriter(sifa_interpretation_directory.xlsx, engineopenpyxl) as writer: df.to_excel(writer, sheet_name司法解释目录, indexFalse) ws writer.sheets[司法解释目录] for column_cells in ws.columns: max_length max(len(str(cell.value)) for cell in column_cells if cell.value) adjusted_width min(max_length * 1.8, 60) ws.column_dimensions[column_cells[0].column_letter].width adjusted_width列宽自动调整这段代码值得留着因为很多标题特别长默认列宽下显示不完整手动拖又拖不到合适的宽度。去查openpyxl源码在ExcelWriter对象里可以通过sheets拿到Worksheet对象再操作column_dimensions设置列宽这招在导出带长字段名的内容时特别有用。5.3 SQLite方案为了后续增量采集如果你的目标不是一次性采集而是持续维护建议直接用SQLite。Python内置sqlite3模块不需要装额外依赖建表、插入、查询都很方便。import sqlite3 conn sqlite3.connect(legal_data.db) cursor conn.cursor() cursor.execute( CREATE TABLE IF NOT EXISTS interpretation ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, doc_no TEXT, publish_date TEXT, eff_level TEXT, detail_url TEXT UNIQUE ) ) cursor.executemany( INSERT OR IGNORE INTO interpretation (title, doc_no, publish_date, eff_level, detail_url) VALUES (?, ?, ?, ?, ?), [(r[title], r[doc_no], r[publish_date], r[eff_level], r[detail_url]) for r in all_records] ) conn.commit() conn.close()这个方案的厉害之处在于detail_url设了UNIQUE约束配合INSERT OR IGNORE下次再跑脚本时重复数据自动跳过新数据自动插入天然支持增量更新。以后目录有更新重新跑一遍脚本就行不会生成重复记录。6. 实测中最常踩的四个坑按排查链路讲把这套代码应用到真实站点时大概率会遇到下面这些问题。我按实际排查的顺序逐个讲建议收藏这一段报错的时候回来对照。6.1 采集结果全部为空先定位“壳”还是“数据”最让人懵的情况是脚本跑完不报错但records列表是空的。这时候第一反应应该是是每一页HTML都没抓下来还是HTML抓了但解析选择器不对排查链路是随便拿一页程序里把resp.text保存成html文件用浏览器打开这个文件看内容。如果浏览器打开后页面内容正常说明选择器写错了如果浏览器打开后页面是空的或者壳子说明目标数据是JavaScript异步加载的requests拿到的是没有数据的空壳。司法类网站大多数是服务端渲染但仍有一部分老系统使用iframe嵌套页面或者前台展示用Vue/React做了接口异步渲染。如果真遇到后者就不能直接解析HTML了得去Network面板里找真实的JSON接口。这里不展开讲但排查思路都是通用的。6.2 字段错位几乎全是xpath里少了“.”导致的前面提到过的经典问题每个item内部的xpath必须用.//开头。如果出现这种情况通常表现为每一行的标题都是第一条的名称或者字段顺序乱。排查方法是print单条item的xpath结果看看是不是取到了第一条。防止这个问题的另一招是定位单条item后先extract一两个字段看看是否符合预期再继续写后面的字段。一次写完所有字段然后整体跑出错了定位反而慢。6.3 HTTP 403或418反爬识别别急着上代理状态码403表示请求被拒绝418表示被识别的“茶壶”请求这两种都是目标服务识别到异常请求特征后拒绝服务。处理逻辑遵循一个原则从轻到重逐步升级。第一个排查点是请求头把浏览器F12里的完整请求头全部复制过来包括Referer、Origin这些字段拼到headers里。第二个排查点是访问频率把delay调到2秒甚至更长观察是否恢复正常。第三个排查点才是考虑代理IP。对单机周期性的目录采集来说前两步基本能解决90%以上的问题。6.4 存储时编码报错在抓取源头就统一编码UnicodeEncodeError是保存阶段最常见的报错。如果是写CSV或Excel时报错一般是字段里有特殊字符比如引号、换行符。解决方法是抓取阶段对字段做strip清理title title.strip() if title else 如果是控制台print时报错Windows终端默认编码是GBK遇到生僻字可能打印失败。不用慌这个是环境问题把环境变量PYTHONIOENCODING设为utf-8即可。6.5 有反爬风险的一个极端情况这里额外提醒一种非常少见但确实会发生的情况目标网站对单个IP的请求频率做了严格限制哪怕控制了睡眠时间跑到几百页之后还是被限流。这时候不要无限降速也不要硬闯合理的做法是把任务拆小分时段分批采集每次只跑前几十页错峰完成。目录类数据更新频率不高不需要实时性分几天跑完全合法合理。7. 完整代码组装一条命令跑完整套目录把前面所有代码组装起来就是一个完整的可运行脚本。我自己在用的版本大概长这样。import time import sqlite3 import requests from parsel import Selector 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, Accept: text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.5, } BASE_URL https://example.com/interpretation?page{page}size20 DELAY 0.8 def fetch_page(session, url): try: resp session.get(url, headersHEADERS, timeout10) resp.raise_for_status() resp.encoding resp.apparent_encoding return resp.text except requests.RequestException as e: print(f请求失败: {e}) return None def parse_directory_page(html): selector Selector(texthtml) records [] for item in selector.xpath(//div[contains(class, list-item)]): title item.xpath(.//h3/a/text()).get() if not title: continue records.append({ title: title.strip(), doc_no: item.xpath(.//span[contains(class, doc-no)]/text()).get(), publish_date: item.xpath(.//span[contains(class, publish-date)]/text()).get(), eff_level: item.xpath(.//span[contains(class, eff-level)]/text()).get(), detail_url: item.xpath(.//h3/a/href).get(), }) return records def get_total_pages(html): selector Selector(texthtml) page_nums selector.xpath(//div[contains(class, pagination)]//a/text()).getall() numbers [] for p in page_nums: p p.strip() if p.isdigit(): numbers.append(int(p)) return max(numbers) if numbers else 1 def main(): with requests.Session() as session: first_html fetch_page(session, BASE_URL.format(page1)) if not first_html: return total_pages get_total_pages(first_html) print(f共 {total_pages} 页) first_records parse_directory_page(first_html) all_records first_records for page in range(2, total_pages 1): html fetch_page(session, BASE_URL.format(pagepage)) if not html: continue records parse_directory_page(html) if not records: break all_records.extend(records) print(f第 {page}/{total_pages} 页已累计 {len(all_records)} 条) time.sleep(DELAY) conn sqlite3.connect(legal_data.db) cursor conn.cursor() cursor.execute( CREATE TABLE IF NOT EXISTS interpretation ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, doc_no TEXT, publish_date TEXT, eff_level TEXT, detail_url TEXT UNIQUE ) ) cursor.executemany( INSERT OR IGNORE INTO interpretation (title, doc_no, publish_date, eff_level, detail_url) VALUES (?, ?, ?, ?, ?), [(r[title], r[doc_no], r[publish_date], r[eff_level], r[detail_url]) for r in all_records] ) conn.commit() total cursor.execute(SELECT COUNT(*) FROM interpretation).fetchone()[0] print(f采集完成数据库中共 {total} 条记录) conn.close() if __name__ __main__: main()这个脚本达到的状态是跑一次全站目录落地SQLite再次运行自动增量更新。整体没有任何无用的花活每段代码都有明确的职责。把它存成crawl_directory.py在命令行执行python crawl_directory.py就能跑。遇到限流就把DELAY调大遇到页面报错就检查选择器排查思路都在这篇文章前面几节里。8. 后续扩展从目录到详情页从单机到分布式一套目录采集跑通之后你会发现这个项目其实还藏着好几个进阶方向。第一个方向是详情页采集。目录页里的detail_url字段如果抓到了就可以顺着它继续抓每一条司法解释的正文内容。正文页的解析逻辑和目录页完全不同但思路一样先分析单页结构写解析函数再写批量循环。把目录和正文都抓下来等于拥有了一份完整的司法解释全文数据库。第二个方向是增量监控。司法解释不断有新发而出的内容把采集脚本挂上计划任务每周或每月跑一次配合SQLite的UNIQUE约束直接拿到新增条目。不用写复杂的增量逻辑数据库层面就过滤掉了重复项。第三个方向是并发优化。目录类站点的请求压力通常不大requests加多线程完全够用不必一上来就上crawley、scrapy这类重型框架。把页面请求交给ThreadPoolExecutor去并发执行注意控制线程数量不要超过5个就能明显提速且不触发反爬。如果你对分布式爬虫感兴趣后续可以了解scrapy加scrapy-redis的方式但这属于另外一个主题了。度还是掌握在采集者自己手里的。拿到数据之后尊重数据源的使用规则不把采集成果用于违法用途这是这个行业的基本底线。
返回列表