
简介这份PDF文档系统讲解了开源Python网络爬虫框架Scrapy的核心知识面向希望高效抓取网页数据的Python开发者帮助读者理解框架的架构设计与异步网络库Twisted的运用并能在数据挖掘、监测和自动化测试等场景中落地。资源为单个PDF文件压缩包大小仅401KB内容紧凑便于随时查阅。文档重点拆解了Scrapy引擎、调度器、下载器、Spider、项目管道以及下载器/蜘蛛中间件等核心组件的职责结合数据流向图梳理了从初始URL到请求调度、下载解析、数据清洗存储的完整工作流程同时对项目管道中清洗HTML数据、校验字段、去重落库等典型处理步骤以及通过下载器中间件应对反爬虫策略的实践方法都做了说明还给出了Windows环境下的安装方法。已有748人学习/下载适合想系统掌握Scrapy原理并搭建大规模爬虫项目的开发者参考。1. 异步框架与“全站递归”的起点所谓网络爬虫本质上是给定一个入口 URL不断解析页面、发现新 URL、再递归抓取的自动化程序。第一次写爬虫时我也用 requests 加 BeautifulSoup 写循环页面到几千个时速度立刻变成灾难中断后还不知道抓到哪条更别提并发和去重。这也是我转向 Scrapy 的原因——它是一个开源 Python 网络爬虫框架用 Twisted 异步网络库驱动把调度、下载、解析、清洗、存储拆成独立组件。这篇文档就从异步架构切入拆解数据流、中间件、XPath 递归爬取以及动态 iframe 和去重这几个绕不开的实战点。适合已经能写简单爬虫脚本、想把自己的爬虫工程化或准备应对“爬全站”需求的 Python 开发者。2. 数据流模型Scrapy 引擎、调度器、下载器、Spider、Pipeline 如何协作2.1 核心组件分工一览Scrapy 整个框架最容易被忽略的一点它不是“一个爬虫类”而是一组对象协作的结果。掌握数据流之前先分清五个组件的职责。我把常见对应关系列成表格组件职责对应模块Scrapy Engine 引擎控制数据流向触发请求、响应、Item 的事件scrapy.core.engineScheduler 调度器接收 Request去重后排队再按顺序分发scrapy.core.schedulerDownloader 下载器真正发送 HTTP 请求获取 Responsescrapy.core.downloaderSpider 蜘蛛用户定义解析规则产出 Item 和新的 Requestscrapy.spidersItem Pipeline 项目管道对 Item 做清洗、验证、去重、持久化scrapy.pipelines除了这五个对象还有三类中间件Downloader middlewares 挂在引擎与下载器之间Spider middlewares 挂在引擎与蜘蛛之间Scheduler middlewares 早期版本用于引擎与调度器之间的扩展。中间件本质上是一组钩子函数让你能在请求发出前改 Header、在响应进入 Spider 前做拦截、或者把异常响应直接重试。2.2 从 start_urls 到 Item Pipeline一次请求的完整旅程我习惯把一次抓取拆成九个步骤Spider 提供初始 URL引擎拿到后交给调度器排队接着引擎从调度器取出下一个待爬请求请求经过下载中间件进入下载器下载完成后 Response 原路返回引擎引擎把 Response 交给 Spider 的回调方法Spider 解析后产出 Item以及新的 RequestItem 被送入 Item Pipeline新请求重新进入调度器直到调度器队列为空引擎断开连接。这个流程和早期版本文档里的架构图完全一致只是不同版本在内部实现上做了抽象。如果用现代 Scrapy 2.x 写一个最小爬虫你会更直观地看到这条数据流。比如抓取一个书店站点的标题和价格import scrapy class BookSpider(scrapy.Spider): name books start_urls [http://books.toscrape.com/] def parse(self, response): for book in response.css(article.product_pod): yield { title: book.css(h3 a::attr(title)).get(), price: book.css(p.price_color::text).get(), } next_page response.css(li.next a::attr(href)).get() if next_page: yield response.follow(next_page, callbackself.parse)这段代码里没有一行网络请求逻辑。start_urls 定义入口引擎会自动为每个 URL 构造 Requestparse 是响应后的回调它返回一个生成器字典对象会被引擎识别为 Item 的替身直接送到 Pipelineresponse.follow 生成的 Request 会被重新交给调度器。需要特别说明的是callbackself.parse 表示下一页仍然用同一个解析函数这就是最基础的递归爬取形态。在 Scrapy 0.15 那个时代这里用的是 HtmlXPathSelector解析语法和现在略有不同但数据流骨架没有变。name 是 Spider 唯一标识scrapy crawl 命令用它来定位爬虫start_urls 是入口列表callback 是处理响应的函数缺了它响应就会被默认的 start_requests 逻辑忽略。2.3 中间件在数据流中的钩子点下载中间件是新手最容易用起来的扩展点。它的 process_request 会在请求发给下载器之前被调用process_response 在响应返回引擎之后触发。最常见的场景是给每个请求换 User-Agent。我一般会写一个随机 UA 类import random class RandomUserAgentMiddleware: def __init__(self, ua_list): self.ua_list ua_list classmethod def from_crawler(cls, crawler): return cls(crawler.settings.getlist(UA_LIST)) def process_request(self, request, spider): request.headers[User-Agent] random.choice(self.ua_list) return None然后在 settings.py 里注册并配置DOWNLOADER_MIDDLEWARES { myproject.middlewares.RandomUserAgentMiddleware: 543, } UA_LIST [ Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15, ]process_request 的返回值有讲究返回 None 表示继续走下载流程返回 Response 对象会直接跳过下载器相当于做了一层本地缓存返回 Request 则会立即中断当前请求把这个新请求重新调度。理解这三个分支中间件才算入门。设置里的 543 是执行优先级数字越小越靠近引擎数字越大越靠近下载器同类中间件按这个数字排序执行。3. 环境准备与项目搭建从 scrapy startproject 到 Pipeline 落库3.1 现代安装方式与历史依赖Scrapy 依赖一堆底层库Twisted 负责异步 IOlxml 做 HTML 解析parsel 封装 XPath/CSS 选择器w3lib 处理 URL 和编码。在老版本里Windows 用户要手动下载 Twisted、zope.interface、w3lib 的安装包顺序错了还会报依赖缺失。今天的安装简单很多只需要一个命令pip install scrapy2.11.2等安装结束后打开终端执行scrapy version能输出版本号说明环境正常。如果安装过程中报Microsoft Visual C 14.0 is required这是 Windows 上编译 lxml 和 Twisted 扩展的常见坑去装对应版本的 Visual C Build Tools 即可。Python 版本建议 3.9 以上新版本 Scrapy 已放弃对 Python 3.8 以下的支持。3.2 项目目录结构与文件职责进入存放代码的目录运行scrapy startproject article_spider这个命令会生成一个完整的工程骨架包括配置文件、爬虫目录、管道和中间件模板。刚创建出来的目录结构是这样的article_spider/ ├── scrapy.cfg └── article_spider/ ├── __init__.py ├── items.py ├── middlewares.py ├── pipelines.py ├── settings.py └── spiders/ └── __init__.py用表格说明核心文件职责文件职责scrapy.cfg项目配置入口指向 settings.pyitems.py定义要抓取的数据结构middlewares.py存放自定义中间件pipelines.pyItem 的后处理清洗、验证、存储settings.py爬虫全局配置包括并发数、Pipeline、中间件spiders/每个爬虫类独立成文件scrapy.cfg 在部署或外部脚本调用项目时才会被读取本地开发时直接执行 scrapy 命令它会根据这个文件自动找到项目配置。3.3 定义 Item 并编写第一个 Spideritems.py 里的字段定义和 Django 的 models 有些像但 Scrapy 的 Field 不做类型约束只作为字段声明的载体。一个典型的数据结构长这样import scrapy class ArticleItem(scrapy.Item): title scrapy.Field() link scrapy.Field() desc scrapy.Field()接着在 spiders 目录下新建一个 article_spider.py。这里模拟抓取一个分类列表页解析出标题、链接和描述import scrapy from article_spider.items import ArticleItem class ArticleListSpider(scrapy.Spider): name articles start_urls [https://example.com/archive] def parse(self, response): for article in response.xpath(//ul/li): item ArticleItem() item[title] article.xpath(./a/text()).get() item[link] article.xpath(./a/href).get() item[desc] article.xpath(./text()).get() yield item这里用到了 XPath//ul/li选出所有列表项./a/text()取当前节点下 a 标签的文本./a/href取属性。get() 方法在 Scrapy 1.1 之后加入如果选不到节点会返回 None适合直接赋值给 Item而 extract() 返回列表适合配合循环批量取值。这个区别很重要很多“为什么我解析出来是空列表”的问题都出在混用了这两种返回值。3.4 Item Pipeline清洗、去重和落盘Pipeline 的作用是接收 Spider 产出的 Item按注册顺序依次处理。每个 Pipeline 类只需要实现 process_item返回 Item 或抛出 DropItem。下面这个例子把缺标题的脏数据丢弃并把合法数据写入 JSON Linesimport json from scrapy.exceptions import DropItem class JsonWriterPipeline: def open_spider(self, spider): self.file open(articles.jsonl, w, encodingutf-8) def process_item(self, item, spider): if not item.get(title): raise DropItem(missing title) self.file.write(json.dumps(dict(item), ensure_asciiFalse) \n) return item def close_spider(self, spider): self.file.close()在 settings.py 中激活它ITEM_PIPELINES { article_spider.pipelines.JsonWriterPipeline: 300, } FEEDS { articles.csv: {format: csv, encoding: utf-8}, }ITEM_PIPELINES 的值同样是执行顺序数字小的先执行。如果流水线中有多个步骤前面完成清洗后面做入库最后返回的 item 会继续传给下一个 Pipeline。上述配置里我同时用了 FEEDS这是 Scrapy 2.x 提供的导出功能即使不写 Pipeline 也能把 Item 导出成 CSV、JSON 或 XML。open_spider 和 close_spider 是 Pipeline 的生命周期钩子分别在整个爬虫启动和关闭时调用适合做资源初始化与释放。4. XPath 提取、链接发现与递归爬取不要再用循环拼接 URL4.1 用 scrapy shell 验证 XPath 表达式写解析规则时我建议不要靠肉眼猜页面结构而是先用 Scrapy 自带的 shell 交互式调试。运行scrapy shell https://example.com/archive进入交互器后response 对象可以直接调用 xpath 或 css 方法。下面这张表列出最常见的 XPath 写法表达式含义//div[classtitle]选取所有包含 classtitle 的 div//a/text()取所有 a 标签内的文本//a/href取所有 a 标签的 href 属性//li[contains(class, item)]选取 class 中包含 item 的 li//p[2]选取同层级下第二个 p 标签在 shell 里可以先执行sel response.xpath(...)然后sel.getall()查看结果确认路径能选中目标节点后再写回 spider效率会高很多。XPath 选不中时优先检查页面是否在 iframe 或动态渲染里这两种情况后续会单独处理。4.2 提取链接并过滤外站 URL递归爬取的第一步是从页面中拿到所有链接但直接抓//a/href会带回外站地址、javascript:伪协议和相对路径。常见的做法是过滤掉不以 http 开头的链接用 urljoin 补全相对地址再判断域名是否属于目标站点。这段代码我一般放在 parse 的开头from urllib.parse import urljoin, urlparse def extract_internal_links(response, allowed_domains): links set() for href in response.xpath(//a/href).getall(): if href.startswith(javascript:) or href.startswith(#): continue abs_url urljoin(response.url, href) domain urlparse(abs_url).netloc if any(domain d or domain.endswith(. d) for d in allowed_domains): links.add(abs_url) return linksurljoin 把相对路径拼成完整 URLdomain 用 netloc 取出来allowed_domains 可以来自 spider 属性。这里用 set 去重避免同一个 URL 多次入队。需要注意的是Scrapy 的 Spider 本身有 allowed_domains 属性但 OffsiteMiddleware 只在请求发往调度器时做检查并不阻止你在 parse 里构造请求所以提前过滤能省一点调度开销。4.3 使用 response.follow 递归下一个页面拿到内链之后不要手动scrapy.Request(url, callbackself.parse)我建议优先使用response.follow因为它能自动处理相对 URL并保留当前响应的 meta。改造后的递归爬虫大概长这样import scrapy class RecursiveSpider(scrapy.Spider): name recursive_articles allowed_domains [example.com] start_urls [https://example.com/archive] def parse(self, response): links extract_internal_links(response, self.allowed_domains) for url in links: yield scrapy.Request(url, callbackself.parse, meta{depth: 1})meta 参数可以把自定义数据捆绑在请求上响应回来后通过 response.meta 读取。如果想控制爬取深度可以在回调开头检查 depth超过阈值就停止继续发请求。更重要的是Scrapy 调度器默认使用请求指纹去重相同 URL、相同方法、相同请求体的 Request 第二次出现会被直接丢弃。所以只要 URL 是规范化的即使多个页面指向同一 URL整个抓取过程中它只会被真正下载一次。4.4 递归爬取的三个隐蔽坑第一个坑是 URL 片段。同一个页面?fromhome#comment和?fromhome在浏览器里是同一个页面但调度器认为它们是两个 URL会产生重复抓取。规范化的做法是去掉 fragment甚至按需求裁剪 query 参数。第二个坑是循环链接比如列表页第一页指向第二页第二页底部又有“上一页”回到第一页。Scrapy 默认指纹去重能挡住这种回环前提是 URL 完全一致如果 URL 带了动态参数如page1ts1600000000就必须先做参数裁剪否则每刷新一次参数就多一个重复请求。第三个坑是动态 iframe。不少页面把真正内容放在 iframe 的子文档里父页面里只留下一个iframe src...标签。这种场景下我通常会在 parse 里直接提取 iframe 的 src然后当成普通 Request 继续爬yield scrapy.Request(response.urljoin(iframe_src), callbackself.parse_detail)这个方案的优点是比集成浏览器轻量得多如果 iframe 内部又由 JavaScript 渲染再考虑引入 scrapy-playwright 等方案具体做法在下一章展开。5. 让爬虫更稳的三个实用技巧动态 iframe、URL 规范化与深度控制5.1 动态 iframe先抓 src再决定集成 playwright遇到 iframe不要急着上浏览器自动化。先在浏览器开发者工具里确认 iframe 的 src 是不是静态地址绝大多数第三方组件如分享、评论、地图的 src 都能直接访问。只要 src 存在就在 parse 里取出并交给单独的回调。只有当 src 本身也是 JavaScript 动态生成的才考虑scrapy-playwright这类方案。集成后需要在 settings 中声明下载处理器# settings.py DOWNLOAD_HANDLERS { http: scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler, https: scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler, }然后给需要动态渲染的请求加 metayield scrapy.Request(url, callbackself.parse_detail, meta{playwright: True})注意playwright 会明显降低并发能力建议只在确实需要渲染的页面开启普通静态列表页仍然走默认下载器。5.2 统一 URL 规范化函数写去重逻辑前先定义一个 normalize_url把所有 URL 转成同一种格式。我常用的做法是去掉 fragment、把 query 参数按 key 排序、并去掉名单内的跟踪参数from urllib.parse import urlsplit, urlunsplit, parse_qsl, urlencode def normalize_url(url, drop_params(utm_source, utm_medium, ts)): parts urlsplit(url) query [(k, v) for k, v in parse_qsl(parts.query, keep_blank_valuesTrue) if k not in drop_params] query.sort(keylambda x: x[0]) return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), ))这个函数放在 utils.py 里然后在 spider 的 start_requests 或 parse 中统一处理可以大幅降低重复抓取。注意端口的归一化也可以在这里处理比如把默认的 80 和 443 端口去掉避免example.com:80和example.com被当成两个地址。5.3 用 meta 控制深度防止爬虫失控递归爬取最怕无限制扩展。给初始 Request 设置meta[depth]0每次 yield 新请求时加一并在回调开头做检查def parse_page(self, response): depth response.meta.get(depth, 0) if depth 3: return for url in extract_internal_links(response, self.allowed_domains): yield scrapy.Request(url, callbackself.parse_page, meta{depth: depth 1})这样既能控制规模又能在日志里看到当前请求的层级。输出结果时用scrapy crawl articles -o result.json再配合上面的规范化函数抓下来的数据重复率会明显下降如果还要跨运行去重就把请求指纹存到 Redis 或数据库中下一次启动时直接过滤已抓过的 URL。本文还有配套的精品资源点击获取