爬虫练手项目里,多页面数据抓取几乎是绕不开的一道坎。单页爬取只能验证解析逻辑通不通,一旦要翻页、要控制频率、要把散落在多个页面里的字段拼成一条完整记录,问题就全冒出来了:请求被限速、翻页链接抓错、字段错位、数据重复。这次我拿一个电影信息采集的练手场景,用 Scrapy 框架配合 XPath 做多页面抓取,把整套流程从头到尾走一遍。选 Scrapy 而不是 requests + BeautifulSoup 手撸循环,核心原因是它自带调度器、去重队列、并发控制和管道机制,多页面场景下这些能力直接决定项目能不能稳定跑完;选 XPath 而不是 CSS 选择器,是因为电影列表页的 DOM 结构往往嵌套很深,XPath 的轴定位和文本节点处理在这种场景下更顺手。
这篇内容适合两类人看:一类是刚学完 Scrapy 基础、想找个多页面项目练手的;另一类是已经能爬单页、但一翻页就各种报错、数据对不上的。我会把项目结构、翻页策略、XPath 定位技巧、字段清洗、限速配置、常见报错排查都拆开讲,每个选择背后的理由都会说清楚,不给你一堆复制粘贴就能跑但不知道为什么这么写的代码。
1. 为什么这个场景值得用 Scrapy 而不是手写循环
1.1 多页面抓取的真实痛点不在解析而在调度
很多人对爬虫的认知停留在“发请求、拿 HTML、解析字段”这三步,单页确实就这么简单。但一旦页面数量上到几十上百,问题性质就变了。你需要考虑:哪些页面已经抓过、哪些失败了要重试、并发开多少不会被封、请求之间要不要加延迟、抓下来的原始数据怎么暂存。这些如果全用 requests 手写,等于自己实现一个迷你调度器,代码量翻倍不说,还容易在异常处理上留坑。
Scrapy 的价值就在这里。它的引擎、调度器、下载器、爬虫、管道是一条完整的流水线,调度器内部维护了一个去重集合,同一个 URL 不会重复请求;下载器有重试中间件,默认对失败请求重试两次;并发数通过CONCURRENT_REQUESTS控制;延迟通过DOWNLOAD_DELAY控制。这些配置项开箱即用,你只需要把精力放在“怎么翻页”和“怎么解析”上。我见过不少新手用 requests 写多页爬虫,写到后面自己维护一个 visited 集合和一个失败重试列表,最后代码比 Scrapy 版本还长,稳定性却差一截。
1.2 框架带来的隐性收益:中间件与管道
除了调度,Scrapy 的中间件机制在多页面场景下特别有用。下载中间件可以在请求发出前统一加请求头、统一处理代理切换(这里只讨论请求头层面的常规配置),爬虫中间件可以在响应返回后做统一过滤。管道则负责数据落盘,你可以同时挂多个管道,一个做字段清洗、一个做去重校验、一个写数据库,各司其职。
举个具体例子:电影信息里经常有“评分”字段,原始文本可能是“8.7”也可能是“暂无评分”,如果每个解析函数里都写一遍判断,代码会很脏。用管道统一处理,解析层只管把原始文本塞进 item,清洗逻辑集中在管道里,改起来只改一处。这种分层思维是手写脚本很难自然形成的,而 Scrapy 的项目结构会逼着你往这个方向走。
1.3 什么情况下反而不该用 Scrapy
说句实在话,Scrapy 不是万能的。如果你只抓三五个页面、字段结构还特别简单,上 Scrapy 属于杀鸡用牛刀,光建项目、配 settings 的时间就够你手写跑完了。另外,如果目标页面大量依赖 JavaScript 渲染、静态 HTML 里根本没有数据,Scrapy 默认的下载器拿不到内容,得额外接渲染方案,这时候项目复杂度会明显上升。本次的电影信息场景属于典型的服务端渲染列表页,静态 HTML 里字段齐全,正好是 Scrapy 的舒适区。
2. 项目骨架搭建与目录职责划分
2.1 创建项目与爬虫文件的标准动作
先建项目,命令很固定:
scrapy startproject movie_spider cd movie_spider scrapy genspider movie_list example.com这里有个细节值得说:genspider生成的爬虫文件里默认带一个allowed_domains和start_urls,很多人直接改start_urls就开跑,结果发现请求被过滤了。原因是allowed_domains限制了只能抓这个域名下的链接,如果你的翻页链接跳到了别的子域名,请求会被 OffsiteMiddleware 拦掉。我的习惯是先把allowed_domains写准确,确实需要跨子域名时再补,而不是图省事直接删掉——删掉等于放弃了这层保护,容易在调试时误抓一堆无关链接。
项目建好后目录结构大致是这样:
movie_spider/ scrapy.cfg movie_spider/ __init__.py items.py middlewares.py pipelines.py settings.py spiders/ __init__.py movie_list.pyitems.py定义字段结构,pipelines.py做数据清洗和落盘,settings.py管全局配置,spiders/下放具体爬虫。这个结构看着简单,但每个文件的职责边界要守住,否则项目一大就乱。
2.2 items.py 里字段怎么定才不返工
字段定义是最容易返工的地方。我一开始图快,想到什么字段加什么,结果爬到一半发现要按导演名做关联,item 里却没有这个字段,只能回头改解析、改管道、重跑。后来养成的习惯是:动手写解析之前,先把目标页面上所有想采集的字段列出来,按“列表页能拿到”和“详情页才能拿到”分两组。
本次场景的字段设计如下:
| 字段名 | 来源 | 类型 | 说明 |
|---|---|---|---|
| title | 列表页 | str | 电影名称 |
| rank | 列表页 | int | 榜单排名 |
| score | 列表页 | float | 评分,可能为空 |
| quote | 列表页 | str | 一句话短评 |
| detail_url | 列表页 | str | 详情页链接 |
| director | 详情页 | str | 导演 |
| year | 详情页 | str | 上映年份 |
列表页字段和详情页字段分开标注,是因为多页面抓取往往涉及“列表页拿链接、详情页补字段”的两级抓取。提前分好组,后面写parse和parse_detail两个方法时思路会清晰很多。items.py里用scrapy.Field()声明即可,不需要指定类型,类型约束放在管道里做。
2.3 settings.py 里必须提前改的几项
默认配置直接跑多页面,大概率会遇到两个问题:一是被目标站点限速,二是日志刷屏看不清。我一般开跑前先改这几项:
ROBOTSTXT_OBEY = True CONCURRENT_REQUESTS = 4 DOWNLOAD_DELAY = 1.5 AUTOTHROTTLE_ENABLED = True AUTOTHROTTLE_START_DELAY = 1 AUTOTHROTTLE_MAX_DELAY = 10 LOG_LEVEL = 'INFO'ROBOTSTXT_OBEY = True是底线,抓之前先看目标站点的 robots 协议允许抓哪些路径,这是基本规矩。CONCURRENT_REQUESTS设成 4 而不是默认的 16,是因为练手项目没必要压满并发,低并发加延迟更稳。DOWNLOAD_DELAY设 1.5 秒是经验值,太短容易触发限流,太长又拖慢整体进度。AUTOTHROTTLE开启后框架会根据响应时间动态调整延迟,比固定延迟更智能。LOG_LEVEL调到 INFO 是为了过滤掉 DEBUG 级别的海量日志,排查问题时再临时调回 DEBUG。
3. 翻页策略:三种思路的取舍与实现
3.1 翻页的本质是构造下一页请求
多页面抓取的核心动作就一句话:在当前页解析完之后,构造出下一页的请求并yield出去。Scrapy 的爬虫方法用生成器写法,yield一个scrapy.Request对象,调度器就会把它排进队列。翻页链接的获取方式决定了整个爬虫的健壮性,常见有三种思路。
第一种是拼接页码。如果目标 URL 形如example.com/movies?page=1,那翻页就是改 page 参数,逻辑最简单。第二种是从页面里提取“下一页”按钮的链接,用 XPath 定位。第三种是提取页面里所有页码链接,逐个请求。三种方式各有适用场景,下面分别说。
3.2 页码拼接法的边界处理
页码拼接法代码最短,但边界处理必须做。假设总页数未知,你怎么知道什么时候停?常见做法是判断当前页解析出的条目数,如果为 0 或者小于预期,就认为到底了,不再yield下一页请求。另一种做法是先抓第一页,从页面里解析出总页数,再循环构造。前者实现简单但会多发一次空请求,后者多一步解析但更干净。
我倾向后者,因为多发一次空请求在严格限流的站点上也可能被计数。解析总页数的 XPath 通常长这样:
total_pages = response.xpath('//a[contains(@class,"page")]/text()').re(r'\d+')拿到页码列表后取最大值,作为循环上界。这里要注意,页码文本里可能混有“上一页”“下一页”这类非数字文本,用正则re(r'\d+')过滤一遍最稳妥。
3.3 从“下一页”按钮提取链接的坑
用“下一页”按钮的 XPath 提取链接,坑主要在按钮的 DOM 结构上。很多站点在最后一页时,“下一页”按钮的href是空的或者指向javascript:void(0),如果你不判断直接yield,会构造出一个无效请求。正确做法是先取href属性,判断非空且不是javascript开头,再构造请求:
next_url = response.xpath('//a[contains(text(),"下一页")]/@href').get() if next_url and not next_url.startswith('javascript'): yield scrapy.Request(url=response.urljoin(next_url), callback=self.parse)response.urljoin这一步不能省。页面里的href经常是相对路径,比如/movies?page=2,直接拿去请求会缺域名。urljoin会基于当前响应 URL 补全成绝对路径,这是 Scrapy 提供的便利方法,比手动拼字符串可靠得多。
3.4 相对链接与绝对链接混用时的统一处理
实际页面里,翻页链接有时是相对路径、有时是绝对路径,甚至同一页里两种混着出现。统一用response.urljoin处理是最省心的,它对绝对路径会原样返回,对相对路径会补全,不用你写 if-else 判断。这个细节看着小,但我在早期项目里因为没统一处理,导致部分翻页请求 404,排查了半天才发现是相对路径没补全。
4. XPath 定位实战:从列表页到详情页的字段提取
4.1 列表页结构分析与选择器编写顺序
写 XPath 之前先干一件事:在浏览器开发者工具里把目标区块的 DOM 结构看一遍,找到列表项的重复单元。电影榜单页通常是ul下面一堆li,每个li里包含排名、标题、评分、短评。定位思路是先定位到所有li的集合,再在单个li内部用相对路径取字段。
movies = response.xpath('//ol[@class="grid_view"]/li') for movie in movies: title = movie.xpath('.//span[@class="title"]/text()').get() score = movie.xpath('.//span[@class="rating_num"]/text()').get() quote = movie.xpath('.//span[@class="inq"]/text()').get()注意movie.xpath里的路径以.开头,表示相对当前节点。这个点非常关键,漏掉的话 XPath 会从文档根节点重新开始匹配,取到的永远是第一条数据。我见过不止一个新手在这里栽跟头,数据全一样还以为是页面问题。
4.2 文本节点与属性节点的取值差异
XPath 里text()取的是文本节点,@href取的是属性节点,两者返回类型不同,取值方法也不同。文本节点用.get()拿字符串,属性节点同样用.get(),但如果你要拿多个值,用.getall()返回列表。电影标题有时会有多个span(中文名一个、外文名一个),这时候.getall()拿到列表后取第一个,或者用extract_first()的等价写法.get()。
还有一个容易忽略的点:文本里经常带多余空白和换行。XPath 取出来的字符串可能是"\n 肖申克的救赎\n ",直接入库很难看。清洗放在管道里做,解析层保持原样,这样职责清晰。
4.3 详情页字段提取与请求传递
列表页拿到的detail_url要用来发详情页请求,同时要把列表页已抓到的字段带过去,避免详情页重复抓。Scrapy 的meta参数就是干这个的:
yield scrapy.Request( url=response.urljoin(detail_url), callback=self.parse_detail, meta={'title': title, 'score': score, 'rank': rank} )详情页的parse_detail方法里通过response.meta.get('title')取回列表页字段,再补上导演、年份等详情页独有的字段,最后yield完整 item。meta是字典,可以塞任意数据,但别塞太大的对象,它会在请求队列里传递,塞大对象影响内存。
4.4 字段缺失时的容错写法
不是每部电影都有评分和短评,直接.get()拿到None,后续做类型转换会报错。容错写法有两种:解析层给默认值,或者管道层统一处理。我倾向解析层给默认值,因为这样 item 结构始终完整:
score = movie.xpath('.//span[@class="rating_num"]/text()').get(default='0') quote = movie.xpath('.//span[@class="inq"]/text()').get(default='').get(default=...)是 Scrapy 选择器提供的方法,取不到时返回默认值,比or运算更明确。评分默认给'0'而不是0,是因为后面管道里要统一做float()转换,保持字符串类型一致,转换逻辑不用分情况。
5. 数据清洗与管道设计:让脏数据止步于入库前
5.1 为什么清洗逻辑必须放管道
解析层只负责“把页面上的原始文本搬下来”,清洗层负责“把原始文本变成规范数据”,这两件事混在一起,代码会越来越难维护。举个真实场景:评分字段原始文本可能是"8.7"、"暂无评分"、""三种,如果解析层就做转换,每个解析方法里都得写一遍 try-except。放管道里,所有 item 流经同一个process_item,写一次判断就够了。
管道还有个好处是方便加校验。比如标题为空的数据直接丢弃,评分超出 0 到 10 范围的标记异常,这些逻辑集中在管道里,改起来只改一处,不会漏掉某个解析分支。
5.2 评分字段的类型转换与异常兜底
评分转换是典型的需要兜底的场景:
def process_item(self, item, spider): raw_score = item.get('score', '0') try: item['score'] = float(raw_score) except (ValueError, TypeError): item['score'] = 0.0 return itemfloat('暂无评分')会抛ValueError,float(None)会抛TypeError,两个都捕获,统一给0.0。这里不要用if raw_score.isdigit()判断,因为"8.7"带小数点,isdigit()返回False,会误判成无效数据。用 try-except 是最稳的写法。
5.3 去重策略:URL 去重与内容去重
Scrapy 调度器默认对请求 URL 去重,但内容去重它不管。多页面抓取时,同一部电影可能在不同榜单页重复出现,如果按标题去重,可能误杀同名电影;按详情页 URL 去重更准确,因为 URL 是唯一标识。去重集合可以放在管道里维护:
class DuplicateFilterPipeline: def __init__(self): self.seen_urls = set() def process_item(self, item, spider): url = item.get('detail_url') if url in self.seen_urls: raise DropItem(f'duplicate: {url}') self.seen_urls.add(url) return itemraise DropItem是 Scrapy 提供的机制,抛出后该 item 不会继续流向下一个管道,也不会入库。注意这个集合存在内存里,数据量特别大时要换成持久化方案,练手项目几十上百条数据用内存集合完全够。
5.4 落盘格式选择与编码问题
落盘用FEEDS配置最省事:
FEEDS = { 'movies.json': { 'format': 'json', 'encoding': 'utf8', 'overwrite': True, } }JSON 适合练手,结构直观好检查。如果数据量大、要后续分析,CSV 更合适,但 CSV 要注意字段里含逗号和换行的情况,Scrapy 的 CSV exporter 会自动处理引号转义,不用自己操心。编码统一用 utf8,避免中文乱码。overwrite设 True 是每次跑覆盖旧文件,调试阶段方便,正式采集时改成 False 或按时间戳命名,防止数据被覆盖。
6. 限速、重试与反爬边界的实操配置
6.1 延迟参数的组合逻辑
DOWNLOAD_DELAY和AUTOTHROTTLE的关系要理清。DOWNLOAD_DELAY是固定基础延迟,AUTOTHROTTLE是在此基础上根据响应情况动态调整。两者同时开启时,实际延迟是动态计算的结果,不是简单相加。我的配置习惯是基础延迟给 1 到 2 秒,自动限速的起始延迟给 1 秒、最大延迟给 10 秒,这样正常情况跑得快,遇到响应变慢时自动降速。
还有一个参数RANDOMIZE_DOWNLOAD_DELAY默认是 True,它会在DOWNLOAD_DELAY基础上加一个 0.5 到 1.5 倍的随机浮动,让请求间隔看起来不那么规律。这个默认值建议保留,固定间隔的请求模式太容易被识别。
6.2 重试中间件的默认行为与调整
Scrapy 默认开启RetryMiddleware,对 500 系列状态码和连接超时重试两次。多页面抓取时,偶尔的 502、504 很常见,默认重试基本够用。如果目标站点不稳定,可以把RETRY_TIMES调到 3,同时把RETRY_HTTP_CODES补上 408、429 这类状态码。429 是请求过多,遇到它说明延迟设小了,重试的同时应该考虑调大DOWNLOAD_DELAY,而不是一味重试。
6.3 请求头配置的合理范围
默认的User-Agent是Scrapy/x.x,目标站点一看就知道是爬虫。在settings.py里换一个常规浏览器的 UA 是基本操作:
USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'但要注意,换 UA 只是让请求看起来正常,不代表可以无视站点规则。ROBOTSTXT_OBEY保持 True,抓取频率控制在合理范围,这是底线。我个人的原则是:练手项目抓公开数据,延迟给足、并发压低,不追求速度,追求跑完不报错。
6.4 被封时的排查顺序
如果跑着跑着开始大量返回 403 或 429,排查顺序建议是:先看是不是延迟太小,调大DOWNLOAD_DELAY重跑;再看 UA 是否被识别,换一个常规 UA;然后看是不是并发太高,把CONCURRENT_REQUESTS降到 2 甚至 1;最后看是不是请求频率在某个时间段过于集中,加AUTOTHROTTLE的TARGET_CONCURRENCY限制。这个顺序是从成本最低的调整开始,逐层排查,不要一上来就上复杂方案。
7. 调试与常见报错:我踩过的几个典型坑
7.1 数据全一样:相对路径漏了点
这是最高频的坑。movie.xpath('//span[@class="title"]/text()')少了开头的.,结果每条数据都是页面第一条。排查方法很简单:在parse里打印len(movies)和第一条的title,如果len正常但title全一样,基本就是相对路径问题。修复就是给循环内的 XPath 全部加上.前缀。
7.2 翻页请求 404:相对链接没补全
yield scrapy.Request(url=next_url)直接用了页面里的相对路径,请求发出去变成GET /movies?page=2,缺域名直接 404。修复用response.urljoin(next_url)。这个坑的隐蔽之处在于,第一页能正常抓,翻页才报错,容易误以为是翻页逻辑写错了。
7.3 字段错位:列表与详情页 meta 传递串了
用meta传列表页字段时,如果多个请求并发,偶尔会出现字段对不上的情况。根因通常是meta字典在多个yield之间被复用或修改。修复方法是每次yield时新建一个字典,不要用外部可变对象。另外,meta里的键名要和parse_detail里取的键名严格一致,拼写错误不会报错,只会静默返回None,排查起来很费劲。
7.4 日志里大量 DEBUG 干扰排查
默认日志级别是 DEBUG,跑起来满屏都是请求和响应的调试信息,真正的错误反而被淹没。把LOG_LEVEL调到 INFO,只保留关键信息。需要看某个请求的详情时,临时在代码里加self.logger.debug(...),比全局开 DEBUG 更可控。
7.5 管道顺序导致的清洗失效
多个管道同时启用时,执行顺序由ITEM_PIPELINES里的数字决定,数字小的先执行。如果去重管道排在清洗管道前面,去重时用的还是原始脏数据,可能误判。正确顺序是先清洗、后去重、最后落盘。这个顺序问题不会报错,只会让结果悄悄出错,属于典型的“跑通了但数据不对”类问题。
8. 从练手到可用:这套骨架还能怎么扩展
这套项目跑通之后,最自然的扩展方向是把两级抓取改成三级,比如列表页到详情页再到演员页,meta逐级传递字段。另一个方向是换落盘目标,把 JSON 换成数据库,管道里改写入逻辑即可,解析层完全不用动,这就是分层设计的好处。还有一个实用扩展是加一个统计管道,记录抓取总数、成功数、丢弃数,跑完打印一份简报,比翻日志高效得多。
我个人在实际操作中的体会是:多页面爬虫的难点从来不在 XPath 写得多花哨,而在翻页边界、字段传递、异常兜底这三件事上。把这三件事的容错做扎实,代码量不大但跑得稳;反过来,解析写得再漂亮,翻页一断、字段一错位,整个项目就白跑。练手阶段宁可慢一点、延迟给足一点,把流程跑通跑稳,比追求抓取速度有价值得多。