1. 项目缘起与整体设计思路拆解
做数据采集这行的朋友大概都有个共识:单页爬取只能算练手,真正能拿去干活的东西,必须能稳定地翻页、去重、断点续爬,还得把数据结构化地落盘。我最早接触这类需求,是帮一个做影视内容分析的小团队整理公开影片资料,当时第一反应就是用requests加BeautifulSoup手撸,结果页面一多,代码里全是for循环套try-except,维护起来头大。后来换成 Scrapy 框架配合 XPath 解析,整个项目的骨架一下子就清爽了。这篇就围绕“用 Scrapy 框架和 XPath 爬取多页面电影信息”这个实例,把我从零搭到跑通、再到踩坑填坑的全过程摊开讲一遍。
先说清楚这个项目到底在干什么。目标站点是一个公开的电影信息展示页面,列表页按分页组织,每页展示若干部电影,点进详情页能看到片名、评分、导演、主演、年份、地区、类型等字段。我们要做的就是:从列表页出发,自动翻页,抓取每部电影的详情链接,再进入详情页抽取结构化字段,最后统一导出成 CSV 或 JSON。听起来简单,但“多页面”三个字背后藏着翻页逻辑、请求调度、去重、限速、异常重试这一整套工程问题,这正是 Scrapy 存在的意义。
为什么选 Scrapy 而不是手写脚本?我用生活化的比喻解释一下。手写requests循环就像你自己开车送快递,每一单都得你亲自规划路线、亲自搬货、亲自处理路上抛锚;而 Scrapy 更像一个已经搭好流水线的物流中心,调度器负责排单,下载器负责运输,管道负责分拣入库,你只需要告诉它“货从哪取、长什么样、往哪放”。对于多页面这种需要并发、需要队列管理、需要失败重试的场景,框架带来的稳定性提升是数量级的。
XPath 的选择也是同样的逻辑。相比正则表达式,XPath 直接基于 HTML 的树形结构定位节点,可读性强、容错性好。比如取详情页标题,正则得写一长串匹配规则,页面结构稍微一变就崩;XPath 写//h1/span/text(),语义一目了然,调试时还能在浏览器开发者工具里直接验证表达式结果。对于电影信息这种字段多、结构相对规整的页面,XPath 是性价比最高的解析方案。
整个项目的设计思路可以概括为四层:入口层负责定义起始 URL 和翻页规则;解析层用 XPath 从列表页提取详情链接、从详情页提取字段;调度层交给 Scrapy 的引擎和调度器自动管理请求队列与去重;存储层通过 Item Pipeline 把数据清洗后写入文件或数据库。这四层各司其职,任何一层出问题都能单独定位,不会牵一发动全身。下面我就按这个骨架,把每一层的实现细节和背后的取舍讲透。
2. 环境搭建与项目骨架初始化
2.1 依赖安装与版本选择
动手之前先把环境理顺。Scrapy 对 Python 版本有要求,我实测下来 Python 3.8 到 3.11 都比较稳,3.12 在某些老版本 Scrapy 上偶有兼容问题,建议用 3.10 这个“甜点版本”。安装命令很直接:
pip install scrapy如果你还想把数据存进数据库或者做更复杂的数据清洗,可以顺手装上pandas和openpyxl,但核心项目其实只依赖 Scrapy 本体。这里有个小提醒:不要在一个已经装了几百个包的老环境里直接装,容易和已有的Twisted、lxml版本打架。我习惯用虚拟环境隔离:
python -m venv spider_env source spider_env/bin/activate # Windows 下用 spider_env\Scripts\activate pip install scrapy装完之后用scrapy version验证一下,能正常输出版本号就说明环境通了。这一步看似废话,但我见过太多人卡在“命令找不到”上,多半是虚拟环境没激活或者 PATH 没配好。
2.2 创建项目与目录结构认知
环境就绪后,用脚手架命令生成项目骨架:
scrapy startproject movie_spider cd movie_spider scrapy genspider douban_movie example.com生成的目录结构是这样的:
movie_spider/ ├── scrapy.cfg └── movie_spider/ ├── __init__.py ├── items.py ├── middlewares.py ├── pipelines.py ├── settings.py └── spiders/ └── douban_movie.py每个文件的分工要心里有数:items.py定义数据结构,相当于给每部电影建一个“档案模板”;pipelines.py负责数据落盘和清洗;settings.py是全局配置中心,限速、并发、请求头都在这里调;spiders/目录下放具体的爬虫逻辑。新手最容易犯的错是把所有逻辑都塞进 spider 文件里,结果几百行代码搅在一起,改一个字段要翻半天。我的建议是:spider 只负责“怎么抓”,item 负责“抓什么”,pipeline 负责“抓完怎么处理”,职责分清,后期扩展才不痛苦。
2.3 settings.py 的关键配置项
settings.py里有几个参数直接决定爬虫能不能跑稳,我逐个说清楚为什么这么设。
ROBOTSTXT_OBEY:默认是True,意思是遵守站点的 robots 协议。做公开数据采集时,我一般会先看一眼目标站点的 robots 规则,确认允许抓取的路径再动手。这个开关不是让你无脑关掉,而是提醒你:采集行为要有边界意识,只抓公开、允许抓的内容,控制频率,别给人家服务器添堵。
DOWNLOAD_DELAY:请求间隔,单位秒。默认是 0,意味着全速跑。多页面爬取时我通常设成 1 到 2 秒,配合RANDOMIZE_DOWNLOAD_DELAY = True,让间隔在一个区间内随机浮动。为什么?固定间隔的请求节奏太“机器”,随机化之后更像正常用户的访问行为,对站点更友好,也能降低被临时限制的概率。
CONCURRENT_REQUESTS:并发请求数。默认 16,对于小规模采集其实偏高。我一般调到 4 到 8,配合延迟使用。并发太高,单位时间请求量上去了,但失败率也会上升,反而拖慢整体进度。这就像超市开收银台,开太多收银员但顾客没那么多,反而增加管理成本。
DEFAULT_REQUEST_HEADERS:请求头。至少要带上User-Agent,让请求看起来像正常浏览器发出的。我习惯准备几个常见的 UA 轮换,但注意别用夸张的伪造信息,保持朴素真实即可。
ITEM_PIPELINES:管道注册。把自定义的 pipeline 类写进去,后面的数字是优先级,越小越先执行。数据清洗和存储通常分两个管道,清洗在前、存储在后。
FEED_EXPORT_ENCODING:导出编码,设成utf-8,避免中文乱码。这个坑我踩过,导出的 CSV 用 Excel 打开全是问号,排查半天才发现是编码问题。
3. 核心实现:从列表页到详情页的完整链路
3.1 Item 定义:先想清楚要什么字段
写爬虫之前,先在一张纸上把要抓的字段列出来,这步不能省。我定义的 Item 大概是这样:
import scrapy class MovieItem(scrapy.Item): title = scrapy.Field() # 片名 rating = scrapy.Field() # 评分 director = scrapy.Field() # 导演 actors = scrapy.Field() # 主演 year = scrapy.Field() # 年份 region = scrapy.Field() # 地区 genre = scrapy.Field() # 类型 detail_url = scrapy.Field() # 详情页链接字段设计有两个原则:一是够用就好,别一上来就想抓几十个字段,先跑通核心字段再逐步加;二是保留原始链接,detail_url这个字段看似冗余,但后期做数据核对、补抓漏抓时非常有用,相当于给每条数据留了个“溯源凭证”。
3.2 列表页解析与翻页逻辑
列表页的任务有两个:提取每部电影的详情链接,以及找到下一页的入口。XPath 在这里的写法要结合页面实际结构来定,我以常见的列表结构举例:
def parse(self, response): # 提取当前页所有电影的详情链接 movie_links = response.xpath('//div[@class="item"]/div[@class="info"]/div[@class="hd"]/a/@href').getall() for link in movie_links: yield scrapy.Request(url=link, callback=self.parse_detail) # 翻页:找到下一页链接 next_page = response.xpath('//span[@class="next"]/a/@href').get() if next_page: yield scrapy.Request(url=next_page, callback=self.parse)这段代码的精髓在于递归回调:parse方法既处理当前页,又通过yield一个新的 Request 指向下一页,并把callback设回parse自己。Scrapy 的调度器会自动把这个请求排进队列,抓完一页自动抓下一页,直到next_page为空为止。这就是框架帮你省掉的手动循环。
XPath 表达式怎么写才稳?我的经验是尽量用语义化的 class 或属性定位,少用绝对路径。像/html/body/div[3]/div[2]/a这种写法,页面加个广告位就全错位了;而//div[@class="item"]//a/@href这种基于特征属性的写法,抗结构变动能力强得多。调试时在浏览器控制台用$x('你的xpath')直接验证,能返回预期节点再往代码里写,效率高很多。
3.3 详情页字段抽取与 XPath 实战
进入详情页后,逐个字段用 XPath 抽取。这里的关键是处理缺失值。电影信息页面经常有字段为空的情况,比如某部片子没有评分、没有标注地区,如果直接.get()拿到None再去做字符串处理,程序就崩了。我的做法是统一加兜底:
def parse_detail(self, response): item = MovieItem() item['title'] = response.xpath('//h1/span[@property="v:itemreviewed"]/text()').get(default='').strip() item['rating'] = response.xpath('//strong[@class="ll rating_num"]/text()').get(default='').strip() item['director'] = response.xpath('//a[@rel="v:directedBy"]/text()').get(default='').strip() item['actors'] = ','.join(response.xpath('//a[@rel="v:starring"]/text()').getall()) item['year'] = response.xpath('//span[@class="year"]/text()').get(default='').strip('()') item['detail_url'] = response.url yield item注意几个细节。get(default='')是 Scrapy 提供的兜底参数,节点不存在时返回空字符串而不是None,省去大量判空代码。getall()用于取多个节点,比如主演有多个,用join拼成一个字符串,方便后续存储。年份字段外面常带括号,用strip('()')去掉。这些看似琐碎的处理,恰恰是数据质量的保证——我见过太多采集结果里混着None、空括号、多余空格的脏数据,后期清洗的成本远高于在源头处理好。
地区、类型这类字段往往藏在信息列表里,结构是“标签 + 值”的形式,XPath 要写得更有针对性:
info_text = response.xpath('//div[@id="info"]//text()').getall() info_str = ''.join(info_text) # 从拼接后的文本里按标签切分提取这种“先取整块文本再二次切分”的思路,适合字段位置不固定、但标签文字固定的场景。相比给每个字段写一条独立 XPath,维护成本更低,页面小改版也不容易全崩。
3.4 数据管道:清洗与落盘
抓到数据只是第一步,落盘前还得过一遍管道。我通常写两个 pipeline:一个做清洗,一个做存储。
清洗管道负责去空格、统一分隔符、过滤明显异常值(比如评分不在 0 到 10 之间的记录)。存储管道用 Scrapy 内置的FeedExport或者自己写文件写入逻辑。最省事的做法是在settings.py里配置导出:
FEEDS = { 'movies.csv': { 'format': 'csv', 'encoding': 'utf-8', 'overwrite': True, }, }跑完scrapy crawl douban_movie,项目根目录下就多出一个movies.csv。如果想存 JSON,把format改成json即可。数据量大的话,建议按时间戳命名文件,避免多次运行互相覆盖。
4. 稳定性优化与常见问题排查实录
4.1 反爬应对与请求节奏控制
多页面爬取绕不开一个现实问题:请求太密集,站点会给你返回异常状态码或者空内容。我的处理原则是主动降速、礼貌采集,而不是硬碰硬。具体手段有三个:一是前面说的DOWNLOAD_DELAY加随机化;二是开启AUTOTHROTTLE自动限速扩展,让 Scrapy 根据响应延迟动态调整请求速度;三是设置合理的RETRY_TIMES和RETRY_HTTP_CODES,遇到临时性失败自动重试,而不是直接丢弃。
AUTOTHROTTLE_ENABLED = True AUTOTHROTTLE_START_DELAY = 1 AUTOTHROTTLE_MAX_DELAY = 10 RETRY_TIMES = 3 RETRY_HTTP_CODES = [500, 502, 503, 504, 408]自动限速的好处是“自适应”:站点响应快时它适当提速,响应慢时自动放缓,比固定延迟更聪明。我实测下来,开启之后整体成功率明显提升,虽然总耗时略增,但省去了大量手动补抓的麻烦。
4.2 常见问题速查表
下面这张表是我在实际项目中反复遇到的坑和对应解法,直接抄作业就行:
| 问题现象 | 可能原因 | 排查与解决 |
|---|---|---|
| 抓到的字段全是空 | XPath 表达式与页面结构不匹配 | 用浏览器$x()验证表达式,检查是否被动态渲染 |
| 翻页到第二页就停 | 下一页链接 XPath 写错或页面用 JS 加载 | 检查next_page是否为None,必要时分析请求接口 |
| 中文导出乱码 | 编码未统一 | 设置FEED_EXPORT_ENCODING = 'utf-8' |
| 请求被拒返回 403 | 缺少请求头或频率过高 | 补全 UA,降低并发,开启自动限速 |
| 数据重复 | 同一链接被多次请求 | 确认DUPEFILTER开启,检查链接是否带随机参数 |
| 程序中途崩溃丢数据 | 未做异常捕获 | 在 pipeline 中加 try-except,关键步骤记录日志 |
4.3 独家避坑心得
说几个文档里不会写、但实际会咬人的细节。第一,详情页链接可能是相对路径,比如/subject/12345/,直接拿去请求会失败,得用response.urljoin(link)拼成绝对地址。这个坑我早期踩过,抓了一堆 404 还以为是站点封了。第二,列表页的“下一页”有时是 JavaScript 触发的,XPath 抓不到href,这时候要么分析背后的数据接口,要么用支持渲染的方案,别死磕静态解析。第三,Item 字段名不要用 Python 关键字,比如class、type,虽然 Scrapy 不报错,但后续处理时各种别扭。第四,跑大批量之前先用小样本试跑,把CLOSESPIDER_PAGECOUNT设成 2 或 3,确认字段抽取正确、翻页正常,再放开全量,能省下大量返工时间。
CLOSESPIDER_PAGECOUNT = 3 # 试跑阶段限制页数这个参数特别适合调试期,跑三页就停,快速验证逻辑,确认无误再注释掉。
5. 数据校验与项目扩展方向
5.1 抓完之后的校验动作
数据落盘不等于项目结束,我习惯做一轮快速校验。最直接的办法是统计字段完整率:总记录数多少、标题非空多少、评分非空多少、详情链接是否唯一。用 pandas 几行代码就能出结果:
import pandas as pd df = pd.read_csv('movies.csv') print(df.shape) print(df['title'].notna().sum()) print(df['detail_url'].duplicated().sum())如果发现某字段完整率异常低,多半是 XPath 写错了或者页面结构和预期不符,回头针对性修。重复链接数量不为零,说明去重没生效,检查DUPEFILTER配置或者链接是否带了时间戳之类的动态参数。这一步花不了几分钟,但能避免把脏数据交给下游。
5.2 可扩展的几个方向
这个实例跑通之后,扩展空间其实很大。往数据量方向走,可以把存储从 CSV 换成数据库,MySQL 或 SQLite 都行,pipeline 里改写入逻辑即可,Scrapy 的异步机制配合连接池能扛住更大规模。往字段丰富度方向走,可以增加评论数、片长、别名等字段,XPath 逐条补上,Item 同步扩展。往调度方向走,可以接入分布式方案,把请求队列外置,多台机器协同采集,不过这属于另一个量级的工程了,建议先把单机版跑稳再考虑。
我个人在实际操作中的体会是:Scrapy 加 XPath 这套组合,真正的门槛不在语法,而在对目标页面结构的理解和对异常情况的预判。语法查文档半小时能会,但“为什么这个字段抓不到”“为什么翻页断了”“为什么数据重复了”这类问题,靠的是对请求链路和页面渲染机制的熟悉。所以我的建议是,每写一条 XPath 之前,先在浏览器里把页面结构摸清楚,把表达式验证通过再往代码里搬;每加一个配置项,先想清楚它解决什么问题、可能带来什么副作用。慢就是快,前期多花十分钟验证,后期少花两小时排查。最后再分享一个小技巧:给爬虫加详细的日志输出,每个关键节点打一行 log,出问题时看日志比断点调试快得多,尤其是跑长时间任务的时候,日志就是你的“黑匣子”。