news 2026/10/5 5:58:41

Xpath实战:完整爬取豆瓣读书Top250数据

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Xpath实战:完整爬取豆瓣读书Top250数据

把豆瓣读书Top250完整爬下来,是我心里最适合练Xpath的项目,没有之一。页面结构规整、分页规律明显、字段固定,不用处理复杂的JavaScript渲染,不用跟验证码纠缠,你只需要把Xpath语法吃透,就能把这250本书的书名、作者、评分、评价人数、简介全部拿到手。这篇博文我就拿这个项目当例子,从页面结构分析、Xpath核心语法、代码实现到排错技巧一条龙讲清楚,尤其是那些文档里不会写的经验和坑,我都会讲到。适合刚学完Python基础、想正经练一次爬虫的读者,也适合已经会写requests但Xpath总用不利索的朋友。

1. 项目整体思路与页面结构分析

1.1 为什么拿豆瓣读书Top250练手

爬虫练手项目我见过太多了,什么爬天气、爬新闻、爬股票,但这些项目往往有一个问题:要么页面结构太简单,练不到解析技巧;要么数据藏在接口里,新手根本分不清是直接抓HTML还是调用API。豆瓣读书Top250恰好卡在一个非常舒服的难度区间。

先说页面本身。豆瓣读书Top250是一个纯静态页面,数据直接写在HTML源码里,不需要像某些网站那样等JavaScript加载完了才能看到内容。这就意味着你只需要一个requests.get()就能把整页拿下来,然后专心练解析。这是练Xpath最理想的环境,因为你拿到的HTML是完整且确定的,出错只能是你自己写错了表达式,而不是被页面渲染环节干扰。

再说数据量。250本书、10页、每页25本,这个规模很适合做翻页训练。你有10次机会去验证翻页逻辑是否正确,又不会因为数据量太大导致运行时间过长。加上豆瓣读书页面多年未大改结构,网上能找到的参考资料多,遇到问题也好排查,对新手非常友好。

最后说字段。一本书的信息包含书名、作者、出版社、出版日期、价格、评分、评价人数、简介,这些字段形态各不相同,有的在超链接属性里,有的在文本节点里,有的在span标签里。处理这些不同形态的数据,正好能把Xpath的节点选择、属性获取、文本提取、条件过滤这些常用操作全部过一遍。练完这一个项目,你基本就能应对工作中七八成的HTML解析需求。

1.2 页面结构分析与URL规律

在写任何一行代码之前,先花十分钟把页面结构看清楚。这一步省掉的不是十分钟,是后面一小时的调试时间。

打开豆瓣读书Top250页面,按F12打开开发者工具,点左上角的箭头图标,然后鼠标悬停在任意一本书的标题上。你会看到一个半透明的蓝色区域,那个区域对应的就是该条数据的HTML父节点。点击后,Elements面板会跳到对应的<tr>标签上。

豆瓣读书Top250的每一条数据都是一个<tr class="item">,这个tr里面有两个<td>。第一个td放封面图,第二个td放详细信息。我在实际操作中发现,大多数有价值的字段都集中在第二个td里:

  • 书名:td[2]/div[1]/a,书名同时存在于这个<a>标签的title属性和文本节点里
  • 作者、出版社、出版日期、价格:td[2]/p[1],是一整段文本,需要用split()做二次清洗
  • 评分:td[2]/div[2]/span[2],class="rating_nums"的span
  • 评价人数:td[2]/div[2]/span[3],文本形如(12345人评价),注意带括号
  • 一句话简介:td[2]/p[2]/span,class="inq"的span,有的书没有简介,提取时要有空值兜底

再来看翻页规律。第1页是https://book.douban.com/top250?start=0,第2页是https://book.douban.com/top250?start=25,第3页是https://book.douban.com/top250?start=50。规律一目了然,start参数按照25递增,一共10页,到start=225结束。这个规律意味着你不需要去解析下一页的链接,直接用一个循环拼接URL就能搞定。

1.3 请求头与访问节奏

请求头这一块是新手最常踩坑的地方。豆瓣对未带User-Agent的请求会直接拒绝,返回418或在页面上显示“检测到有异常请求”。我第一次爬的时候就因为偷懒没带请求头,结果拿回来的页面连书名都没有,排查了半天才发现是请求头的问题。

最核心的三个请求头是:

  • User-Agent:标识你的客户端类型,不能是Python默认的python-requests/x.x.x,要伪装成一个正常浏览器
  • Referer:告诉服务器你是从哪个页面跳转过来的,访问https://book.douban.com/top250?start=25时可以带上https://book.douban.com/top250
  • Cookie:如果你只是想爬Top250榜单,不带Cookie通常也能成功,但带着Cookie更接近正常用户行为

另外要特别说一句访问节奏的问题。我在实际操作中的习惯是每请求一页强制休眠1到2秒,用time.sleep(random.uniform(1, 2))来控制。很多新手觉得休眠浪费时间,恨不得一次循环把10页全部抓完,结果就是IP被临时限制。慢一点,反而更稳。这也是爬虫的基本礼仪。

2. Xpath语法核心知识

2.1 Xpath到底是什么

Xpath的全称是XML Path Language,翻译过来就是“XML路径语言”。它的核心思想很简单:把HTML文档看成一颗树,你要做的就是用一条路径表达式,从根节点走到你想要的节点。这个过程就像你在文件系统里定位一个文件,/usr/local/bin/python3是在找路径,//div[@class="item"]是在定位节点,本质是一回事。

我一直觉得Xpath是所有HTML解析方式里最符合人类直觉的。用正则表达式提取HTML是“用一把刀在绳子上找结”,你得小心翼翼考虑各种字符组合;用BeautifulSoup虽然直观,但写多了会发现soup.find('div', class_='item').find('a').get('title')这样的链式调用又长又啰嗦;而Xpath用一条表达式就能搞定,比如//tr[@class='item']//a/@title,语义清晰,写起来快,维护起来也不费劲。

2.2 常用表达式与谓词速查

我把Xpath里最常用的语法整理成了一张表,练这个项目期间把这些记住就足够了。

表达式含义示例
/从根节点开始选取(绝对路径)/html/body/div
//从任意位置选取(相对路径)//div
.当前节点./td[2]
..父节点../a
@选取属性//a/@href、//img/@src
text()选取文本节点//span/text()
[n]第n个节点,从1开始//tr[2]
[last()]最后一个节点//tr[last()]
[@class='x']按class属性过滤//span[@class='rating_nums']
contains()包含匹配//div[contains(@class, 'item')]
starts-with()前缀匹配//a[starts-with(@href, '/book')]
提取多个同时取属性与文本//a/@title
``并集

这里要特别提醒两个新手容易犯迷糊的点。第一,Xpath的索引从1开始,不是从0开始,//tr[1]取的是第一个tr,写惯Python的很容易在这里栽跟头。第二,text()取的是当前节点的直接文本,如果标签里还嵌套了子标签,text()是拿不到子标签里的文字的,这时候要么用string(),要么就相应地去定位到子标签那一层。

2.3 从HTML片段到Xpath的实战映射

光看语法记不住,我拿豆瓣读书的一条数据来演示一下怎么把HTML变成Xpath表达式。

假设页面里有一条数据长这样(简化版):

<tr class="item"> <td> <a href="https://book.douban.com/subject/1005005/"> <img src="..." class="nbg" /> </a> </td> <td> <div> <a href="https://book.douban.com/subject/1005005/" title="红楼梦"> 红楼梦 </a> <span class="pl">(人民文学出版社)</span> </div> <p>曹雪芹 / 人民文学出版社 / 1996-12 / 59.70元</p> <div class="star clearfix"> <span class="allstar45"></span> <span class="rating_nums">9.6</span> <span class="pl">(234567人评价)</span> </div> <p class="quote"> <span class="inq">一部中国末期封建社会的百科全书。</span> </p> </td> </tr>

要提取这本书的书名,你会下意识想://a/@title,但这样会把这个页面上所有a标签的title都抓出来,范围太大。正确的做法是先锁定tr这个数据容器,再在它的范围内找字段:

//tr[@class='item'][1]/td[2]/div/a/@title

这个表达式的意思是:先找所有class为item的tr,取第一个,然后往下走td[2]、div、a,最后取title属性。在实际代码里,一般还会更简洁一点,先定位到tr节点,再基于这个节点写相对路径。举个例子,我在代码里会这样写:

items = html.xpath('//tr[@class="item"]') for item in items: title = item.xpath('.//a/@title')[0]

注意这个相对路径开头的.//,它表示“从当前节点往下找”,如果不加这个点,//a/@title会从文档根节点开始找,结果就全乱了。这个坑我踩过不止一次,每次都是因为漏了一个点导致拿回来的数据张冠李戴。

3. 环境准备与完整代码实现

3.1 环境准备

这个项目只需要三个东西:Python 3.6以上版本、requests库、lxml库。其中lxml是提供Xpath解析能力的核心库,它会自动帮我们把HTML字符串解析成可以进行Xpath查询的节点树。

安装命令很简单:

pip install requests lxml

如果你想把结果顺便存成Excel,再来一个pandas:

pip install pandas

有读者可能会问,为什么不用自带的urllib?我的回答是:requests封装得太好了,会话、请求头、编码处理都省心,既然有成熟的工具,没必要自讨苦吃。还有人会问,为什么解析用lxml而不是soup?这个前面说过,Xpath写起来更紧凑,而且lxml的解析速度在纯Python方案里是最快的之一,处理几百本书根本感觉不到延迟。

3.2 请求页面与解析节点

请求页面的逻辑核心是“带合理的请求头、控制访问频率、检查响应状态”。我的做法是把请求封装成一个函数,返回一个lxml的HtmlElement对象。

import requests from lxml import etree 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', 'Referer': 'https://book.douban.com/top250', } def fetch_page(url): resp = requests.get(url, headers=HEADERS, timeout=10) resp.raise_for_status() resp.encoding = 'utf-8' return etree.HTML(resp.text)

这里有三点值得说明。第一,resp.encoding = 'utf-8'是主动告诉requests按UTF-8解码,防止某些情况下requests根据响应头猜错编码导致中文乱码。第二,etree.HTML(resp.text)会把字符串解析成一棵可查询的节点树,返回的是HtmlElement对象,后续所有的Xpath查询都是在这个对象上进行的。第三,raise_for_status()是一个好习惯,只要状态码不是200,程序直接抛异常,避免你拿着一个404的页面在那里疑惑“为什么什么都提取不到”。

3.3 数据提取、清洗与保存

拿到一棵节点树之后,核心的提取逻辑就来了。我按“先定位容器,再逐字段提取”的顺序来处理。

def parse_page(html): items = html.xpath('//tr[@class="item"]') page_data = [] for item in items: title = item.xpath('.//a/@title') title = title[0] if title else '' detail = item.xpath('.//p[1]/text()') detail = detail[0].strip() if detail else '' rating = item.xpath('.//span[@class="rating_nums"]/text()') rating = rating[0] if rating else '' people = item.xpath('.//span[@class="pl"]/text()') people = people[0].strip() if people else '' quote = item.xpath('.//span[@class="inq"]/text()') quote = quote[0] if quote else '' page_data.append({ 'title': title, 'detail': detail, 'rating': rating, 'people': people, 'quote': quote, }) return page_data

这里面的细节值得逐条说。

关于标题,我取的是a标签的title属性而不是文本节点。原因是豆瓣读书的书名在a标签里既出现在文本节点又出现在title属性,两个值一样。但取属性有一个额外好处:有些书名字特别长,HTML里可能因为布局被换行截断,属性值反而更干净。

关于detail字段,也就是“作者 / 出版社 / 出版日期 / 价格”这一段,text()取回来的是原始文本,里面可能有换行和空格。我处理的方式是.strip()去掉首尾空白。如果后续你想把作者和出版社分开,可以再按/做一次split(),这个看你的具体需要。

关于提取结果为空的问题,item.xpath('.//a/@title')返回的是列表,如果表达式没有匹配到任何节点,返回的是空列表而不是None。所以我的代码里用了title[0] if title else ''这种写法,保证没有数据时也能赋一个空字符串。千万不能直接写title[0],否则遇到没有简介的书(quote为空)程序会直接报IndexError。

保存数据我用的是pandas,一行代码就能把列表转成表格并写进CSV或Excel:

import pandas as pd def save_data(all_data): df = pd.DataFrame(all_data) df.to_csv('douban_top250.csv', index=False, encoding='utf-8-sig')

这里要重点说一下encoding='utf-8-sig'。如果你用的是utf-8,生成的CSV用Excel打开时会乱码,因为Excel默认用ANSI编码去解码CSV。utf-8-sig会在文件开头加一个BOM头,Excel就能正确识别了。这个小细节,是我自己乱码了好几次才测出来的。

3.4 完整代码总览

把上面的函数拼起来,再加上翻页逻辑,就是一个完整的爬虫:

import requests from lxml import etree import time import random import pandas as pd 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', 'Referer': 'https://book.douban.com/top250', } def fetch_page(url): resp = requests.get(url, headers=HEADERS, timeout=10) resp.raise_for_status() resp.encoding = 'utf-8' return etree.HTML(resp.text) def parse_page(html): items = html.xpath('//tr[@class="item"]') page_data = [] for item in items: title = item.xpath('.//a/@title') title = title[0] if title else '' detail = item.xpath('.//p[1]/text()') detail = detail[0].strip() if detail else '' rating = item.xpath('.//span[@class="rating_nums"]/text()') rating = rating[0] if rating else '' people = item.xpath('.//span[@class="pl"]/text()') people = people[0].strip() if people else '' quote = item.xpath('.//span[@class="inq"]/text()') quote = quote[0] if quote else '' page_data.append({ 'title': title, 'detail': detail, 'rating': rating, 'people': people, 'quote': quote, }) return page_data def main(): all_data = [] for page in range(10): start = page * 25 url = f'https://book.douban.com/top250?start={start}' print(f'正在抓取第{page + 1}页:{url}') html = fetch_page(url) all_data.extend(parse_page(html)) time.sleep(random.uniform(1, 2)) df = pd.DataFrame(all_data) df.to_csv('douban_top250.csv', index=False, encoding='utf-8-sig') print(f'抓取完成,共{len(df)}条数据') if __name__ == '__main__': main()

运行完这段代码,你应该能看到一个250行的CSV文件。我在本地实测时,加了休眠之后整个流程耗时30秒左右,数据量250条,字段完整。这个结果对练手项目来说完全够用了。

4. 常见问题与排查技巧实录

4.1 请求被拒绝或返回418/403

这个问题出现的频率最高。正常访问豆瓣页面没问题,但用脚本去请求就容易得到418状态码或者一个写着“检测到有异常请求”的页面。

我排查这个问题的固定流程是:先在fetch_page里打印状态码和响应前500个字符,看看服务器返回的到底是什么。如果是418,基本可以断定是请求头的问题。这时候检查你的User-Agent,确认它的格式像一个真实浏览器,而不是python-requests。另外还可以加上Accept、Accept-Language这两个请求头,让请求更接近浏览器行为。

还有一种情况是你之前请求太频繁,IP被临时限制。这时候不要继续尝试暴力刷新,老老实实等几分钟,再把请求间隔拉长到2到3秒。我在实际运行中,1到2秒的随机间隔基本不会触发限制,但如果你开了多线程并发,那就要格外小心了。

4.2 中文乱码

乱码的原因九成是编码判断错误。requests在拿到响应后,会尝试从响应头里的charset字段判断编码,但有些页面的charset字段没声明或者声明错误,requests就只能猜,猜错了就乱码。

解决方法是强制指定编码。豆瓣读书页面是标准的UTF-8,所以直接在fetch_page里把resp.encoding = 'utf-8'写死。如果你的爬虫将来要换目标网站,先看一下页面的<meta charset="...">标签,把编码设置成一致的值就可以了。

另外,如果你保存的CSV在Excel里打开乱码、但在记事本里正常,那就是CSV文件编码的问题,按前面说的,把to_csv的encoding参数改成utf-8-sig就能解决。

4.3 提取结果为空或字段对不上

提取不到数据,绝大多数时候不是网页变了,而是你的Xpath表达式没写对。这里我提供一个非常实用的调试方法,可以让你在写代码之前就先验证表达式是否正确。

打开浏览器的开发者工具,切到Console标签页,输入以下内容:

$x('//tr[@class="item"]')

这行代码会在控制台里返回所有匹配的tr节点。如果返回空数组,说明你的表达式写错了,或者页面上根本没有这个class。接着你可以用$x('//tr')看看总数是不是25条,一点点缩小范围。我个人强烈建议在写爬虫代码之前,先在浏览器控制台把Xpath验证一遍,这比写完代码再DEBUG快得多。

实际写代码时,还有一个初学者容易忽略的坑:xpath()方法返回的永远是一个列表。就算你写了一个只会匹配到一个节点的表达式,返回的也是长度为1的列表,而不是单纯的一个节点。列表为空时直接按下标[0]取值就会报IndexError。所以在解析时请务必按我前面代码里那种方式做判空处理。

4.4 翻页重复或漏页

如果你发现抓回来的数据量只有25条或者50条,多半是翻页循环写错了。豆瓣读书Top250的规律是start从0开始,每次加25,一共10次。要注意的是第2页是start=25而不是start=1,每一页的25本书是完全不重复的。

还有一种错误是页面抓成功了但每页解析出来的数量不足25条,这通常是某个字段解析异常导致try...except跳过了循环。我在写代码时倾向于不用跳过的写法,而是用title[0] if title else ''来兜底,这样即使某个书没有简介,也不影响整条记录进入结果集,最多是那个字段为空。保持数据的完整性,比一个字段都不丢更重要。250条数据里如果少了1条,排查起来很费劲。

5. 这个爬虫还能怎么扩展

5.1 代码如何迁移到其他网站

爬虫的框架是通用的,换一个目标网站时,你只需要做三件事:第一,分析新页面的URL规律,搞清楚翻页参数是什么;第二,用开发者工具定位数据容器节点,确定哪一层是“一条记录”;第三,把字段提取的Xpath表达式改成新页面的结构。

你会发现绝大多数列表类页面都有类似的规律。比如新闻网站的列表页,通常每条新闻是一个<div class="news-item">,里面包含标题链接、发布时间、摘要段落;电商站点的搜索结果列表,每个商品也都是一个固定的容器节点。换汤不换药,把你的//tr[@class="item"]换成新的容器表达式,把字段路径换成新页面的路径,整个爬虫就能跑了。市面上的热词里提到的懂车帝、淘宝评论、招聘职位等信息采集,底层也是这个思路。

但这里一定要提醒一句。改成能跑和可以合法地跑是两回事。每个网站都有自己明示或暗示的访问规则,上线前先去查看对方的robots协议,控制请求频率,不要抓取需要登录才能看到的个人数据,更不要用分布式或者高并发去冲击别人的服务器。我们练技术的目的是提升能力、解决工作问题,而不是给目标站点制造压力。

5.2 数据处理层面的优化

250条数据拿到手,只是第一步。如果你愿意往下做,这个项目可以延伸出不少值得玩味的分析点。比如用pandas统计评分排名前50的书都在哪些出版社;分析不同出版社的评分均值差异;看评价人数和评分之间有没有相关性;甚至把所有书名和简介拿去做一个简单的词频分析,看看Top250里出现最多的书评关键词是什么。

数据处理这一块,我常做的一个优化是“去重和增量”。虽然Top250理论上不会出现重复数据,但如果你把代码改成了每天定时跑一次,就需要考虑页面数据变化带来的重复问题。最简单的方案是以书名+作者作为联合主键,存到CSV前先检查是否已经存在。更规范的方案是改用SQLite存储,对唯一键加约束,插入时用INSERT OR IGNORE。

5.3 代码健壮性的增强

最后说代码本身的优化。练手项目跑通了之后,你可以试着从这几个方向给它“加加固”。

第一,加异常重试。requests.get可能因为网络抖动抛出异常,一个简单的try...except加循环重试就能让程序稳定很多。我的习惯是最多重试3次,每次等待2秒,重试次数太多反而浪费时间。

第二,加日志。不要只用print打印状态,用logging模块记录每页抓取的状态、耗时、异常信息。日志的作用在脚本运行失败时特别明显,你能直接看到是第几页出了问题,而不是对着屏幕干瞪眼。

第三,随机延迟。前面说过time.sleep(random.uniform(1, 2)),这比固定睡1.5秒更自然。固定间隔虽然也能用,但随机延迟更像真实用户的行为,也更不容易触发服务端的频率限制。

第四,把解析写成独立的函数,保持请求和解析分离。这样做的好处是,当页面结构变化时,你只需要修改解析函数,请求逻辑完全不用动。代码的可维护性,往往就体现在这种小细节上。

最后分享一个调试小技巧

开发这个爬虫的过程中,我最受益的一个习惯是“先在浏览器里验证表达式,再写进代码”。每次要写一个比较复杂的Xpath时,我都会先在开发者工具的Console里用$x()验证一遍路径能不能命中,确认没问题再复制到Python代码里。调试时间至少缩短一半。

另一个想分享的经验是:初次运行不要急着抓全部10页,先把range(10)改成range(1),跑通第1页,确认数据全都正确后再放开翻页。这么做的好处是,如果Xpath写错了,你只需要对着第1页的25条数据排查,不至于被250条混乱的结果淹没。磨刀不误砍柴工,慢下来,反而更快。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/5 5:57:43

UART串口通信协议详解:从STM32到Linux的BAVA字节传输方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/5 5:57:32

VSCode手动搭建STM32编译调试环境:makefile与debug配置详解

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/5 5:57:17

DHT11+继电器:手把手教你制作Arduino智能孵化器

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/5 5:56:51

S32K3双核CANFD配置实战:EB tresos中断与轮询方案详解

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/5 5:56:36

ROS2扫地机器人开发实战:从SLAM建图到Nav2导航全栈指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/5 5:56:06

SOEM主站控制伺服驱动器:PDO配置与状态机切换高频误区详解

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华