资讯动态

Python爬虫实战:用Xpath爬取豆瓣读书Top250全流程

发布时间:2026/10/5 9:11:08 来源:尧图企业网站定制
说到Python爬虫练手项目豆瓣读书Top250绝对是个绕不开的经典。这周我正好重新整理爬虫工具箱就用Xpath语法把这个榜单完整爬了一遍从页面分析到数据落地全程不依赖任何重型框架只用了 requests lxml 两个库。这篇文章把整个过程的思路、代码和踩坑经历全部记录下来给正在学爬虫或者想快速入手Xpath的朋友做个参考。先说这个项目适合谁对Python基础语法有基本了解、想系统学习网页解析方式、或者纯粹想拿真实数据练手的同学都很对口。豆瓣读书Top250这个页面最大的优点是结构规整、全是静态HTML你不需要处理JS动态渲染也不用面对特别复杂的反爬机制可以把精力完全集中在Xpath选择器和数据提取这两件核心事情上。完工后的产出是一个CSV文件包含书名、作者及出版信息、评分、评价人数和一句话简介250条数据一次到位。后面的数据分析或者可视化练习都可以直接拿这个数据集开刀。1. 项目整体设计与思路拆解1.1 为什么拿豆瓣读书Top250当练手目标选练手项目这件事我一直觉得“难易适中、反馈明确”最重要。豆瓣读书Top250正好符合这两个条件。首先它是纯静态页面服务器返回的HTML里已经包含全部数据不需要模拟浏览器、不需要等待Ajax异步加载。你只要发一个GET请求把响应里的HTML拿去解析就行。相比那些需要Selenium或Playwright才能搞定的动态页面爬这个项目的技术难度集中在“解析”而不是“渲染”对初学者友好得多。其次是结构非常规整。每本书都放在一个tr classitem表格行里类名语义化做得很好——pl2是书名区pl是出版信息区star clearfix是评分区。这种页面简直是Xpath练习的天堂你随便写几个路径表达式就能把数据剥出来不用像某些电商网站那样靠层级硬猜。再一个好处是数据量合适。25本一页一共10页既能把分页逻辑跑通又不会让程序跑太久被网站盯上。250条数据作为后续分析样本也够用你可以做评分分布、出版社排行、作者国籍统计之类的可视化练习顺手还能学学pandas。1.2 为什么用Xpath而不是正则或BeautifulSoup很多新手一开始接触爬虫喜欢用正则表达式去抠数据。正则对付那种“夹在一堆文本里”的内容确实灵活但豆瓣读书这种层级清晰的HTML页面用正则去匹配书名的写法大概长这样div classpl2\s*a.*?title(.*?)。如果页面结构稍微变一下比如a标签的class变了、顺序换了这个正则立刻废掉。而且正则写出来可读性很差过两周你自己都未必看得懂。Xpath完全避免了这些问题。它把HTML当作一棵树来定位用/和//表示层级关系用[class...]做条件筛选语义接近自然语言。举个例子要拿评分就写.//span[classrating_nums]/text()一眼就能看出这是“当前节点下、class为rating_nums的span元素的文本”后期维护成本低得多。至于BeautifulSoup它本身是个好库API设计得很Pythonicfind_all、select_one这些方法读起来很顺。但对复杂定位往往要写多行嵌套查找代码逻辑一多就散。而Xpath是一条字符串搞定表达力更强而且lxml底层是C语言实现在大批量数据下速度优势明显。如果你尝试过用BS4解析几千条列表数据再换Xpath跑一遍性能差异是能感觉出来的。所以在我的爬虫工具栈里Xpath是解析静态HTML的首选。正则负责处理清洗侧的非结构化字段BS4偶尔用来做快速解析但面对“结构明确、字段多”的页面Xpath永远是性价比最高的选择。1.3 整体流程拆解整个项目可以拆成四步分析页面、构造请求、解析提取、保存数据。第一步是分析页面打开浏览器的开发者工具看清目标数据在哪个节点下、用什么class标识、分页参数怎么变化。这一步不做扎实后面全白搭。很多新手上来就写代码结果Xpath半天不对回头才发现连HTML结构都没看清。第二步是构造请求核心是处理请求头和编码。豆瓣对没有User-Agent的请求直接返回403所以UA必须伪装成一个正常浏览器的标识同时要把响应编码指定为UTF-8否则中文会变成乱码。第三步是解析提取这是Xpath的主战场。需要把每本书的书名、作者、出版社、评分、评价人数、简介逐一从HTML树里捞出来还要考虑字段缺失的情况——比如不是每本书都有一句话简介。第四步是保存数据我选择写CSV而不是存数据库原因很简单CSV格式通用、Excel能直接打开、后续用pandas读取也方便对练手项目来说是最少依赖的落地方案。这四个步骤环环相扣每一步做完都先用小样本验证再全量跑能节省不少排错时间。2. 环境准备与Xpath语法速成2.1 先搭环境requests lxml这个项目只需要两个第三方库requests负责发HTTP请求lxml负责把HTML解析成一棵可查询的树。pip install requests lxmlrequests的用法大家应该很熟了重点说一下lxml。lxml里解析HTML用的是etree模块核心就两个动作先用etree.HTML(html_str)把HTML字符串转成一棵树再在这棵树上调用xpath()方法执行查询。整个过程不需要额外配置上手成本很低。Python环境建议3.8以上我这边实测在3.10和3.11下都运行正常。另外提醒一句虚拟环境是个好习惯别嫌麻烦给每个爬虫项目单独建一个venv依赖隔离能避免很多莫名其妙的版本冲突。2.2 Xpath核心表达式速查Xpath的语法可以写成一本书但爬虫场景下你真正高频用到的就那几个。把下面这个表记住九成页面都能解表达式含义示例//在任意层级查找后代的节点//div摘出所有div/从根节点或当前节点的直接子节点查找/html/body/div.当前节点./span在当前节点下找span..当前节点的父节点..//a回到父级再往下找a属性选取属性值//a/href取出所有链接text()选取文本内容//span/text()[classx]谓语条件按属性筛选//tr[classitem][位置]取下标的元素//li[1]、//li[last()]contains(a,b)属性值包含判断//div[contains(class,star)]starts-with(a,b)属性值前缀判断//div[starts-with(id,book)]这里最常用的其实是//和[class...]的组合。比如//div[classpl2]/a/title意思是“在任意层级找class为pl2的div取它下面a元素的title属性”。这种写法在豆瓣这种class名明确的页面里无往不利。有两点容易踩坑提前说一下。第一点text()不会自动拼接子元素的文本如果标签里还有嵌套标签你可能需要分别取或者用string()来取完整文本。第二点Xpath下标从1开始不是0习惯了Python的0索引容易在这里翻车。2.3 用浏览器控制台提前验证Xpath写Xpath最怕的就是写完跑代码才发现路径不对。这里分享一个调试技巧Chrome或Firefox的开发者工具里自带$x()函数可以直接在Console面板执行Xpath并返回匹配结果无需写任何Python代码。打开豆瓣读书Top250页面按F12进入开发者工具切到Console标签页输入$x(//tr[classitem])回车后能看到一个包含25个元素的数组说明这个Xpath写对了。再试试$x(//tr[classitem])[0]回车后会展示第一本书的完整HTML结构你可以展开检查字段定位是否准确。比如想看书名取的对不对就输入$x(//tr[classitem])[0].querySelector(.pl2 a).getAttribute(title)确认无误后再把这套Xpath搬到Python里基本一次通过。这个习惯帮我省了无数调试时间强烈建议你也试一下。还有一种更快的方式是右键元素→Copy→Copy Xpath浏览器会帮你生成路径不过生成的可能又长又笨重我通常只是作为参考最终还是会自己精简。3. 核心代码实现一步步爬取250本书3.1 先看页面结构确定Xpath定位方案在写代码之前先花一分钟看懂目标页面。豆瓣读书Top250的URL是https://book.douban.com/top250书名下面的分页参数是start每一页25本第二页是?start25第三页是?start50依此类推。页面里每本书对应一个tr classitem里面的关键字段节点长这样书名位于div.pl2下第一个a的title属性副标题位于div.pl2下紧接着的span文本注意不是每本都有作者/出版社/年份/价格一整行文本位于p.pl用/分隔评分位于span.rating_nums的文本评价人数位于div.star.clearfix下的span.pl文本类似“(123456人评价)”一句话简介位于span.inq的文本同样不是每本都有有了这张地图Xpath就好写了。这里我用的都是相对路径从一个tr节点出发配合.//向下找好处是每一行独立定位互不干扰。3.2 写请求函数和信息解析函数先写请求函数。豆瓣对爬虫机器识别比较敏感请求头里必须带一个真实的User-Agent最好加上Accept和Accept-Language模拟真实浏览器的请求特征。我习惯把HEADERS定义成模块级常量避免每个函数重复声明。import requests from lxml import etree import csv import time import random 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,image/avif,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Connection: keep-alive } def fetch_page(url): try: resp requests.get(url, headersHEADERS, timeout10) resp.encoding utf-8 if resp.status_code 200: return resp.text print(f请求失败状态码{resp.status_code}) except Exception as e: print(f请求异常{e}) return None这里有个小细节resp.encoding utf-8必须手动指定。虽然豆瓣页面声明了UTF-8编码requests有时会根据响应头自动判断偶尔会判断错手动指定最保险。然后是解析函数。拿到HTML文本后先用etree.HTML()转成树对象再用Xpath把每一行tr.item摘出来遍历。每个字段都要考虑“取不到”的情况——比如简介缺失时Xpath返回空列表直接索引会报IndexError所以统一用list[0] if list else 的形式做兜底。def parse_page(html): tree etree.HTML(html) trs tree.xpath(//tr[classitem]) page_data [] for tr in trs: title tr.xpath(.//div[classpl2]/a/title) title title[0] if title else subtitle tr.xpath(.//div[classpl2]/span/text()) subtitle subtitle[0].strip() if subtitle else if subtitle: title f{title} {subtitle} info tr.xpath(.//p[classpl]/text()) info info[0].strip() if info else rating tr.xpath(.//span[classrating_nums]/text()) rating rating[0] if rating else rating_count tr.xpath(.//div[classstar clearfix]/span[classpl]/text()) rating_count rating_count[0].strip() if rating_count else quote tr.xpath(.//span[classinq]/text()) quote quote[0].strip() if quote else page_data.append({ title: title, info: info, rating: rating, rating_count: rating_count, quote: quote, }) return page_data注意这里我没有把info字符串硬拆成作者、出版社、年份、价格四个字段只保留了原始文本。原因是这个字段格式不完全统一有译者的书顺序是“作者 / 译者 / 出版社 / 年份 / 价格”没译者的书则是“作者 / 出版社 / 年份 / 价格”还有个别书缺价格甚至缺年份。如果写死按索引拆分一部分数据就会错位。更稳妥的做法是先保存原始串后面有精确需求再按格式二次处理。这也是我踩过一次坑之后总结出来的思路别在提取阶段做过度加工保留原始数据永远更安全。3.3 分页抓取和数据保存分页逻辑非常简单start从0开始步长25到225结束一共循环10次。每次抓完一页随机睡1到2秒避免请求节奏太规律被识别成机器。def main(): base_url https://book.douban.com/top250?start{} all_data [] for start in range(0, 250, 25): url base_url.format(start) print(f正在抓取{url}) html fetch_page(url) if html: page_data parse_page(html) all_data.extend(page_data) print(f本页获取 {len(page_data)} 条累计 {len(all_data)} 条) time.sleep(random.uniform(1, 2)) with open(douban_books_top250.csv, w, newline, encodingutf-8-sig) as f: fieldnames [title, info, rating, rating_count, quote] writer csv.DictWriter(f, fieldnamesfieldnames) writer.writeheader() writer.writerows(all_data) print(f全部完成共保存 {len(all_data)} 条数据)CSV保存这里要特别提一个坑编码一定要用utf-8-sig而不是普通的utf-8。如果你用utf-8写入然后用Excel打开CSV中文大概率会显示成一堆乱码。原因是Excel默认按ANSI编码解读CSV文件而utf-8-sig会在文件开头写入BOM头Excel识别到BOM就知道该用UTF-8来解码。另外用csv.DictWriter而不是手动拼接字符串也是为了避免书名或简介里的逗号、换行符破坏CSV结构。写爬虫时候的每一处细节都是在给后面省麻烦。3.4 完整代码把上面三段拼起来就是一个可运行的完整脚本。我这里再给一个补全版把if __name__入口加上import requests from lxml import etree import csv import time import random 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,image/avif,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Connection: keep-alive } def fetch_page(url): try: resp requests.get(url, headersHEADERS, timeout10) resp.encoding utf-8 if resp.status_code 200: return resp.text print(f请求失败状态码{resp.status_code}) except Exception as e: print(f请求异常{e}) return None def parse_page(html): tree etree.HTML(html) trs tree.xpath(//tr[classitem]) page_data [] for tr in trs: title tr.xpath(.//div[classpl2]/a/title) title title[0] if title else subtitle tr.xpath(.//div[classpl2]/span/text()) subtitle subtitle[0].strip() if subtitle else if subtitle: title f{title} {subtitle} info tr.xpath(.//p[classpl]/text()) info info[0].strip() if info else rating tr.xpath(.//span[classrating_nums]/text()) rating rating[0] if rating else rating_count tr.xpath(.//div[classstar clearfix]/span[classpl]/text()) rating_count rating_count[0].strip() if rating_count else quote tr.xpath(.//span[classinq]/text()) quote quote[0].strip() if quote else page_data.append({ title: title, info: info, rating: rating, rating_count: rating_count, quote: quote, }) return page_data def main(): base_url https://book.douban.com/top250?start{} all_data [] for start in range(0, 250, 25): url base_url.format(start) print(f正在抓取{url}) html fetch_page(url) if html: page_data parse_page(html) all_data.extend(page_data) print(f本页获取 {len(page_data)} 条累计 {len(all_data)} 条) time.sleep(random.uniform(1, 2)) with open(douban_books_top250.csv, w, newline, encodingutf-8-sig) as f: fieldnames [title, info, rating, rating_count, quote] writer csv.DictWriter(f, fieldnamesfieldnames) writer.writeheader() writer.writerows(all_data) print(f全部完成共保存 {len(all_data)} 条数据) if __name__ __main__: main()跑这个脚本之前最好像我刚才说的那样先用浏览器$x()验证一遍Xpath。如果页面结构在你运行那天发生了变化这类热门页面被改版也不是没可能根据F12里的实际结构微调选择器就行。4. 踩坑记录与常见问题排查4.1 请求被拒403怎么办我第一次写这个项目时直接用了裸的requests.get(url)结果返回的不是HTML而是一句“403 Forbidden”。原因很简单豆瓣服务器识别到了没有浏览器身份标识的请求直接拒绝处理。解决办法就是加请求头。User-Agent必须换成真实浏览器的UA字符串建议连Accept、Accept-Language一起带上伪装得更完整。如果加上UA之后还是403检查一下你是不是请求得太频繁了——连续快速翻页会在短时间内触发服务器的频控机制这时候除了加重试和延迟没有别的捷径。一个额外的建议是把headers里的UA单独抽出来做成常量如果哪天需要多UA轮换改起来也方便。我自己的工具里会放一个UA列表每次请求随机取一个但对于豆瓣这种反爬门槛不高的站点一个稳定的UA就足够。4.2 Xpath返回空列表/空字段这是新手最容易卡住的地方。Xpath返回空列表无非两种原因路径写错了或者页面结构和预期不一致。快速排查分两步。第一步在浏览器Console里执行$x(你的Xpath)看看是否能命中元素。如果浏览器里能命中Python里却取不到那就是请求返回的HTML和浏览器渲染出的HTML不一致——大概率是请求没有携带正确的请求头拿到了一个降级页面或验证页面。第二步在Python代码里打印出resp.text的前几百个字符直接确认拿到的到底是正常页面还是反爬提示。我见过不少同学在Xpath里少写一个点比如该写.//div[classpl2]却写成了//div[classpl2]。别小看这点区别前者是“当前节点下所有后代中找div”后者是“整个文档任意位置找div”。在遍历单行数据时用//开头可能会匹配到页面里其他区域的同类节点造成数据串位。4.3 字段缺失导致IndexError豆瓣Top250不是每本书都有一句话简介有些条目压根没有span.inq标签。如果直接用tr.xpath(.//span[classinq]/text())[0]遇到缺简介的书就会抛IndexError程序直接中断。稳妥的写法是先把Xpath返回的列表存下来判断非空再取第一个元素。这个思路对所有可能缺失的字段都适用我在解析函数里对每个字段都做了兜底。另外info字段虽然每个条目都有但内部元素数量不固定所以也不建议写死索引拆分先保留原始字符串最稳妥。还有一个小细节副标题不是每本都有但大部分书在div.pl2里都有一个span节点只是有的是空文本。所以我先strip()再判断非空避免把纯粹的空格拼进书名。4.4 编码乱码和其他小问题如果把CSV保存成普通utf-8编码Excel打开时中文会乱成一团这个坑我在前面已经说过解决方法是保存时用utf-8-sig。另一个常见问题是终端输出中文乱码这跟代码无关多半是Windows终端默认编码不是UTF-8导致的。如果在PyCharm或VS Code里跑一般不会有这个问题如果在命令行跑可以先把终端的代码页切到UTF-8chcp 65001或者干脆以输出英文日志为主中文都写进CSV里减少终端编码带来的干扰。关于时效性还有一句提醒豆瓣偶尔会对页面结构做微调比如某个class改名、某个标签层级变化。如果你运行代码的时间距离现在比较久但发现Xpath失效先别急着怀疑代码逻辑打开F12看眼当前页面结构选择器跟着改就行。爬虫项目维护成本最高的从来不是写代码而是跟随目标站点变化的适配成本。4.5 爬虫规范与频率控制代码能跑通只是第一步怎么跑得“体面”也很重要。我个人在写爬虫时会守住几条底线只爬取公开页面、不碰需要登录才能访问的私有数据、控制请求频率、有明确的数据用途绝不把爬到的数据用于任何商业变现或传播。实操层面每页之间用time.sleep(random.uniform(1, 2))随机休眠避免产生固定间隔的请求模式。一次抓取总量控制在250条左右对目标站点完全没有压力。如果你想把这个榜单的更多字段比如豆瓣链接、封面图地址、出版时间也一并抓下来Xpath的选择器扩展一下就行请求逻辑不用大改。爬虫技术本身是中性的关键在于怎么用。我把这个项目当作学习Xpath和网页解析的载体也建议你用同样的心态去练习——练完技术、跑通流程理解背后的原理比单纯拿到一份250条数据的CSV有价值得多。最后说一点我个人的体会。刚开始学爬虫的时候我也总想着找复杂网站练手结果被各种反爬机制按在地上摩擦。后来才明白把豆瓣这种“结构规整、反馈明确”的经典项目吃透比盲目挑战高难度目标有用得多。Xpath这个东西本质上就是对HTML树的查询语言熟练之后你会发现很多看似无从下手的页面只要把结构拆清楚提取数据也就是一两行选择器的事。另外爬虫写完之后别急着删我习惯把每次练手项目的请求模块、解析函数、重试逻辑都沉淀成自己的一套小工具箱。下次遇到类似页面直接复用这些基础能力把精力聚焦在页面结构和字段处理上。希望这篇文章能帮你在Xpath和爬虫实战上少走点弯路也欢迎你在评论区分享自己爬取过程中遇到的奇葩问题大家一起交流排坑经验。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑