用XPath爬取豆瓣Top250电影,是我给刚接触Python爬虫的人推荐的第一个练手项目:数据量固定250条,页面结构规整,字段丰富,翻页逻辑也直观。这篇文章就把这件事从头到尾拆开,写清楚为什么要这样设计请求、XPath到底怎么写才稳,以及真正落地会遇到哪些坑。
1. 目标页的DOM骨架:把Top250里的li节点变成可重复定位的XPath单元
刚开始学XPath的人,最容易犯的错是一上来就写表达式,而不先搞清楚页面长什么样。XPath本质上是写给文档树看的导航地图,你必须先知道目标内容在这棵树里的位置,才能写出能命中的路径。
以Top250列表页为例,全部的250条电影记录分布在25页里,每页显示25条。这25条记录并不是平铺在页面上,而是放在一个<ol class="grid_view">容器里,每条记录都是一个<li>。打开开发者工具,在Elements面板里随便点一个电影条目,你能看到li内部还有<div class="item">、<div class="pic">、<div class="info">这样的嵌套结构。
1.1 URL的翻页规律和start参数的计算方式
Top250没有单独的接口,它就是一组带参数的静态页面。第一页的地址是https://movie.douban.com/top250?start=0,打开第二页时,URL会变成start=25。这个start参数是记录的起始偏移量,它决定你从第几条开始取,而不是页码。
翻页规律是这样的:第一页start=0,第二页start=25,第三页start=50,一直到最后第二十五页start=225。换算公式很简单:start = (页码 - 1) * 25。这个规律意味着你不需要手动构造25个URL,只需要在循环里生成range(0, 250, 25)就能得到所有偏移量列表。
这个设计其实很常见。很多分页系统用page参数表示第几页,但偏移量分页在数据量少的页面里更直接,后端不用额外计算起始位置。理解了这个机制,后面的循环逻辑就非常简单——每次请求一个start值对应的页面,解析完25条,再请求下一个start值,连贯起来就是完整数据。
1.2 从ol到li的XPath定位:集合式取数思想
定位电影条目列表,我建议把XPath的起点定在//ol[@class="grid_view"]/li。这条表达式会返回当前页面上全部25个li节点,每个节点对应一部电影。
这里的关键点是“先拿集合,再取字段”。你要先通过一个XPath拿到所有电影节点,然后遍历这个节点列表,在每次遍历中用相对路径去提取该电影的标题、评分、链接等信息。千万不要一上来就写一个超长的XPath试图一网打尽所有字段,这样既难调试,又容易在前端改版之后全面失效。
为什么强调//ol[@class="grid_view"]/li而不是直接写//li?因为一个页面里可能有很多li,有的是导航菜单的li,有的是侧边栏推荐位的li,直接选//li会把无关内容也抓进来。加上祖先节点ol[class="grid_view"]这个限定条件,等于告诉XPath“只要这个列表容器里的li”。
1.3 相对路径和绝对路径:为什么不要从浏览器复制XPath
很多初学者在开发者工具里右键一个元素,选择“Copy”再选“Copy XPath”,复制出来的路径往往长这样:/html/body/div[3]/div[1]/div/div[1]/ol/li[1]/div[1]/div[2]/div[1]/a。这种绝对路径包含了从html根节点到目标元素的完整链路,中间每一个层级都写死了序号。
你复制出来的那一刻,它可能是对的,但问题迟早会来。页面头部只要多插入一个div,序号就会整体往后推;页面结构微调,这条XPath就彻底失效。更麻烦的是,li[1]指的是“当前层级下第一个li”,它表达的是文档中的位置,而不是某个固定条目的语义。换句话说,它不是“排名第一的电影”,而只是“页面结构上的第一个li”,一旦排序方式改变,抓取结果就会错位。
所以我的建议永远是:起点用//ol[@class="grid_view"]/li这样的语义型定位,字段提取用每个li内部的相对路径。相对路径从当前li节点出发,只关心li内部的嵌套关系,外层页面再变也不会影响到它。这种写法比绝对路径稳定得多,也更容易阅读和维护。
1.4 浏览器的XPath测试技巧
顺手分享一个排查XPath的小技巧。在开发者工具的Elements面板里按Ctrl+F,焦点会落到一个搜索框,你可以直接在搜索框里输入XPath表达式。输入//ol[@class="grid_view"]/li,页面上会高亮显示匹配到的节点,还会显示匹配数量。这个功能在写字段表达式的时候非常有用,可以先在浏览器里验证表达式能命中,再把表达式粘回Python代码,能省掉很多来回调试的时间。
这个技巧看起来不起眼,但在实际抓取里几乎是必备技能。因为代码里XPath报错的时候,你很难判断是页面结构变了还是表达式写错了,先用浏览器的搜索框验证一遍,问题很快就能定位。
2. 请求层设计:从403状态码看起,先让页面肯把HTML还给你
写解析逻辑之前,先确保请求能拿到200状态码和完整的HTML。Top250的反爬强度不算高,但也不是裸用requests.get()就能稳定通过的。我见过太多人在这步卡住,看到403或者418就以为要上复杂的反爬手段,其实大多数情况下只是几个请求头的问题。
2.1 User-Agent和其他几个容易被忽略的请求头
第一次写代码的人,最容易犯的错误是直接这样写:requests.get(url),不带任何headers。这时候服务端收到的User-Agent可能是Python的默认标识,一眼就能看出是脚本在访问,直接返回403或者418。
解决方法是模拟真实浏览器的请求头。第一个要设置的必然是User-Agent,用一个常见的浏览器UA字符串。我自己常用的写法是把整个请求头封装成一个字典,放进Session里,因为一旦你把headers挂在Session上,后续所有请求都会自动带上,不用每次请求都重复写。
{ "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/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Connection": "keep-alive" }Accept-Language这个头容易被忽略,但对中文站点的请求挺重要。它告诉服务端你期望接收中文内容,某些情况下服务端会依据这个头返回不同的页面版本。Connection: keep-alive的作用是让TCP连接可以复用,减少频繁建立连接的开销,配合Session使用,批量翻页时会顺畅一点。
2.2 为什么Session比裸requests更好用
requests的Session对象会帮你保存这次会话过程中的状态,包括服务端通过Set-Cookie下发的Cookie。Top250页面本身不需要登录,Cookie不是强制要求,但带上它会让请求看起来更像正常浏览。
Session还有一层好处是连接复用。网络请求从建立TCP连接、完成TLS握手再到发送数据,整个链路有一定开销,如果每一页都重新建连,25页请求会多出不少耗时。用Session时,多个请求可以复用同一个连接,整体时间更短,也更不容易触发频率控制。我把Session挂上headers之后,从第一页到最后一页,全程共用一个会话,稳定性和速度都比裸requests好。
2.3 状态码排查:403、418和超时分别说明什么
遇到不同状态码,处理思路完全不同:
| 状态码 | 含义 | 应对方式 |
|---|---|---|
| 403 | 请求被拒绝,服务端不让你拿页面 | 检查User-Agent、Accept等请求头是否齐全,必要时加上Cookie |
| 418 | 被识别为自动化脚本,反爬拦截 | 降低请求频率,检查请求头是否与真实浏览器一致 |
| 500 | 服务端内部错误 | 多数是网络波动或服务端临时问题,配合重试等待后重新请求 |
| 超时 | 请求在指定时间内没拿到响应 | 增加超时时间,或者使用重试机制间隔几秒再来一次 |
这里想强调一个理念:做采集不要追求“永远不失败”,而是要有“失败了怎么处理”的方案。网络环境的偶发失败是常态,遇到403先分析请求头,遇到超时先考虑延迟重试,把处理逻辑写进框架里,而不是出了问题就手动改代码。
3. 字段级XPath推演:标题、评分、引言与链接的提取表达式
接下来是整个项目最核心的部分:从单个li节点里把字段全部提取出来。这一步做扎实了,后面的翻页、存储都是水到渠成的事。
3.1 一个电影条目里值得抓取的字段清单
我建议至少提取七个字段:片名、电影链接、评分、评价人数、经典引言、排名、导演与主演信息。页面里每个li的内部结构都很规整,我用下面的表格整理出常用的XPath表达式,后续你可以在parse_item函数里直接使用:
| 字段 | XPath表达式 |
|---|---|
| 排名 | .//div[@class="pic"]/em/text() |
| 电影链接 | .//div[@class="hd"]/a/@href |
| 中文片名 | .//span[@class="title"][1]/text() |
| 英文片名 | .//span[@class="title"][2]/text() |
| 导演与主演 | .//div[@class="bd"]/p[1]/text()[1] |
| 年份、地区、类型 | .//div[@class="bd"]/p[1]/text()[2] |
| 评分 | .//span[@class="rating_num"]/text() |
| 评价人数 | .//div[@class="star"]/span[4]/@title |
| 经典引言 | .//span[@class="inq"]/text() |
注意,.//和./在前面的表达式中混用了。.//span[@class="title"]表示从当前li节点往下找所有符合条件的span,不管中间嵌套了几层;而./div[@class="item"]表示只从li的直接子节点里找div。字段提取多用.//更安全,因为能够承受更深的嵌套。
3.2 为什么span[4]会取到评价人数
很多人第一次写评价人数时容易懵:为什么是span[4]?这是因为在div[class="star"]这个容器里,通常有5个span:前4个是五角星图标,占位前4个span,其中第4个可能是最后半颗星的图形,第5个span才是评价人数。而XPath的索引从1开始,不是从0开始,所以span[1]到span[4]是星形图标部分,span[5]才是包含评价人数的文本节点。
实际操作中可能因页面改版略有差异,所以建议在浏览器里先验证,看看span[4]和span[5]哪个命中你需要的文本。验证的方法是前面提到的Ctrl+F搜索框,输入表达式回车,看哪个节点被高亮。
3.3 空值处理:没有引言和缺失导演信息的场景
页面里不是每部电影都有经典引言。有些电影条目中根本没有span[class="inq"]节点,这时直接取text()会返回空列表,如果你不管不顾地往索引[0]上取,就会抛IndexError。正确做法是先取列表,再判断列表是否非空,非空才取第一个元素,否则填空字符串。
同样的问题也出现在导演与主演信息里。这段信息存在于div[class="bd"]下的第一个p标签中,但它内部包含多行文本,第一行是导演与主演,第二行是年份、地区、类型。用text()[1]取第一行、text()[2]取第二行,这里的下标是文本节点在p标签内部的顺序,不是标签的嵌套顺序。返回的文本里还会夹杂换行符和空格,建议在Python端用strip()清理,或者直接在XPath里套normalize-space()函数。
这里我贴一个parse_item的通用写法:
def parse_item(li): title = li.xpath('.//span[@class="title"][1]/text()') title = title[0] if title else "" rating = li.xpath('.//span[@class="rating_num"]/text()') rating = rating[0] if rating else "" inq = li.xpath('.//span[@class="inq"]/text()') quote = inq[0] if inq else "" href = li.xpath('.//div[@class="hd"]/a/@href') href = href[0] if href else "" people = li.xpath('.//div[@class="star"]/span[5]/text()') people = people[0] if people else "" info_lines = li.xpath('.//div[@class="bd"]/p[1]/text()') if info_lines: info_clean = "|".join(x.strip() for x in info_lines if x.strip()) else: info_clean = "" return { "title": title, "href": href, "rating": rating, "people": people, "quote": quote, "info": info_clean, }每个字段都做了空列表判断,这能避免一半以上的运行时崩溃。尤其当你要批量抓几千条数据时,一条脏数据导致整个脚本退出,损失远大于在提取函数里多写两行判断。
3.4 用text()和@属性的区分
看上面的表达式会发现,有的地方用text(),有的地方用@href,有的是@title。规则很简单:text()是取某节点的文本内容,@href是取a标签的href属性值,@title是取节点的title属性值。在div[class="star"]/span[5]/@title里,评价人数其实存在span的title属性中,例如“XXXXXX人评价”,所以要用@title而不是text()。这个细节不搞清楚,很容易取回来一堆空值。
另一个细节是链接字段取的是@href,拿到的是相对路径还是完整URL取决于页面源码。Top250页面里的href一般是完整URL,但有些页面给的是类似/top250/12345/的相对路径,你就需要自己拼接域名。建议先打印一条数据看看,确认URL形式再做处理。
4. 用start参数驱动翻页:25页循环和主采集进程的完整写法
前面的解析逻辑只覆盖单页,现在要把25页全部串起来。这个过程不复杂,但有几个地方要设计好,否则很容易出现重复或者漏抓。
4.1 主循环结构:start参数的生成和请求节奏
主循环的核心是for start in range(0, 250, 25)。每次循环生成一个URL,请求页面,解析页面,累积结果到movies列表。下面是我常用的结构:
import time import random import requests from lxml import etree session = requests.Session() session.headers.update({ "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-Language": "zh-CN,zh;q=0.9", }) movies = [] for start in range(0, 250, 25): url = f"https://movie.douban.com/top250?start={start}" try: resp = session.get(url, timeout=10) except requests.RequestException as e: print(f"start={start} 请求异常: {e}") time.sleep(5) continue if resp.status_code != 200: print(f"start={start} 请求失败,状态码: {resp.status_code}") time.sleep(5) continue html = etree.HTML(resp.text) items = html.xpath('//ol[@class="grid_view"]/li') for li in items: movies.append(parse_item(li)) print(f"start={start} 已抓取,当前累计 {len(movies)} 条") time.sleep(random.uniform(1, 3)) print(f"完成,共抓取 {len(movies)} 条")这段代码有一个值得注意的设计点:解析逻辑被抽成了parse_item函数,主循环只负责翻页、请求和结果累积。以后要改字段,只需要改函数的内部实现,不用动循环;要保持代码可读性,也要坚持这种分工思路。
4.2 成功准则:每页25条不多不少
每次解析完一页,建议检查一下len(items)是不是25。如果某页只拿到20条,说明页面结构可能变了,或者存在反爬拦截,后面就算继续翻页也只是在累计残缺数据。更稳妥的做法是:如果某页条目数不等于25,打印警告日志,提示该页可能有问题,后续再单独核查。
这是我在实际采集时总结的一个习惯:不要等全部跑完再检查数据,而是每页都做即时校验。因为Top250每页固定25条,任何偏离都意味着异常,越早发现越省事。
4.3 失败重试与断点续抓的思路
25页跑下来大概需要几十秒到两三分钟,但中途可能因为网络抖动抛出异常。最粗糙的恢复办法是,如果某一页失败,记录当前start值,脚本异常退出后手动修改start再重新跑剩余页。但更省心的方法是做一个简单重试循环。
把单个请求封装成一个带重试的函数,最多试3次,每次失败后等2到5秒再重试。3次都失败就跳过当前页,记录到失败列表,最后统一处理缺失页。这样整个采集流程不会因为一次网络波动而前功尽弃,最终数据也会比较完整。
具体可以参考这种写法:
def fetch_page(session, url, retries=3): for attempt in range(retries): try: resp = session.get(url, timeout=10) if resp.status_code == 200: return resp except requests.RequestException as e: print(f"第{attempt + 1}次请求异常: {e}") time.sleep(2 + attempt * 2) return None要说明的是,重试不是万能药。如果连续3次都返回403,说明请求头或者频率需要调整,那时候再重试也是白费力气,应该停下来观察原因。
5. 数据清洗与落盘:从List到CSV和JSON的结构化过程
数据解析完成后,它只是内存里的一组字典。如果不落盘,脚本一退出就什么都没了。所以最后一步是把数据变成可长期使用的文件。
5.1 字段规范化:评分、评价人数和年份的处理
实际抓回来的字段基本都是字符串,直接写文件也能用,但对后续分析不友好。我的建议是把字段规范化做在parse_item阶段,这样后续程序读取时不用再处理。
评分字段抓下来是"9.7"这种字符串,可以转换成float;评价人数抓下来是"XXXXXX人评价",需要把"人评价"去掉再转int;年份、地区、类型这一段在info字段里混在一起,可以继续用正则或split拆出年份。如果抓回来的字段缺失,评分可以填空字符串,评价人数可以填0,但不要填None,因为在CSV里None会被序列化成空字段,不同软件打开后显示效果不一致。
5.2 CSV写入的编码坑:utf-8-sig和newline参数
CSV是最常用的落盘格式,尤其适合用Excel打开查看。但用Python原生的csv模块写入时,有个老坑:文件以默认utf-8编码写入,Excel直接打开中文会乱码。解决办法是在打开文件时指定编码为utf-8-sig,这样Excel能正确识别BOM头部并显示中文。
另外打开文件时还要设置newline="",否则csv模块在Windows平台写入时会自动在行尾额外加一个回车,导致文件里出现空白行。这个细节很多人会忽略,等看到CSV里每两行之间多一行空行时才反应过来。
import csv fieldnames = ["title", "href", "rating", "people", "quote", "info"] with open("top250.csv", "w", encoding="utf-8-sig", newline="") as f: writer = csv.DictWriter(f, fieldnames=fieldnames) writer.writeheader() writer.writerows(movies)JSON这边相对省心,只需要注意ensure_ascii=False,否则中文会被转成\uXXXX的转义序列,文件可读性很差,其他语言读取时也要多一层转换。
import json with open("top250.json", "w", encoding="utf-8") as f: json.dump(movies, f, ensure_ascii=False, indent=2)5.3 去重与校验:如何确认250条数据是完整的
抓完之后至少要跑两个校验:一是总数是不是250条,二是链接是不是唯一。Top250本身不会重复,如果发现有重复链接,多半是翻页逻辑或者解析逻辑出问题,比如start参数重复生成,或者某页被解析了两次。
去重用Python内置的set就能完成。把所有href放进set,如果set的长度小于记录总数,就说明有重复项。这个校验虽然简单,但它是决定数据质量的关键一步。
还有一个小技巧是校验排名字段。因为Top250的排名从1到250是连续的,解析完成后你可以在Excel或程序里单独打开排名列,看看1到250是否完整。如果某个数字缺失,说明这一条数据没抓到;如果某个数字出现两次,则说明解析有重复。这两步结合,基本能保证数据没有明显缺失或重复。
6. 采集稳定性的几个实操结论:延迟、重试与自检机制
很多人在第一次跑通之后就急着加大频率,把25页一口气跑完,结果跑到第8页突然403,后面全部白跑。原因很简单,服务端会根据请求频率识别出自动化行为。所以最后单独讲一下稳定性问题,这是爬虫能不能长期跑起来的根本。
6.1 请求间隔的随机化策略
请求间隔不建议写成固定值,比如每次都time.sleep(2)。固定间隔容易暴露机器特征,因为正常浏览时,人类的阅读节奏本身有波动。比较实用的做法是在1到3秒之间取一个随机值,每次循环之间随机睡一下。这个幅度对25页的数据量来说,总耗时只增加几十秒,但对稳定性的提升非常明显。
时间久了你会发现,频率控制的核心不是“慢”,而是“不要规整得像算法”。随机化的本质就是让请求节奏更接近人的行为。这只是对单次抓取的建议,且前提是控制好采集频率,不给目标服务端造成压力,只做个人学习用途。
6.2 日志记录的价值:失败的页码比错误本身更重要
在批量采集里,日志比代码本身更值钱。每次请求记住时间、页码、状态码,出现问题的时候一查日志,立刻知道是哪一页、什么情况下挂的。很多人不写日志,出错了就在代码里加print重跑,既浪费时间,又定位不了根本原因。
我习惯在每次请求之后打印一条简短日志,包括起始偏移量、状态码、当前累计条数、耗时。采集完成后再单独打印一个汇总,比如“成功页数、失败页数、总条数”。这样即使某个页失败了,你也能从日志里直观看到失败集中在哪个区域,再有针对性地重抓那个区间。
6.3 页面结构变化的提前自检
页面结构不会永远不变。网站在某些时候可能改版,class名变化、标签层级调整都可能导致之前的XPath全部失效。所以我在每次运行脚本前,会先请求一页,用html.xpath('//ol[@class="grid_view"]/li')测试一下,确认能取到25条数据再开始全量抓取。
这种自检逻辑只需要几行代码,但相当于给整个采集流程加了一道保险。脚本从“盲目跑”变成了“先确认环境正常再跑”,遇到页面改版时,它能帮你省掉一次全量失败的成本。
6.4 关于单次数据量的最后说明
Top250一共250条数据,用Request加lxml的方式抓完,耗时只有一两分钟。这个项目适合作为XPath技术的起点,把它跑通了,你对网页解析的理解会扎实很多。后续如果还想进阶,可以尝试抓其他类型的列表页、处理登录态、把数据写入数据库,或者用多线程提升速度。不过在那之前,先把单线程流程跑稳,比什么都重要。