1. 项目需求与爬虫方案选型
1.1 动手前先把三件事问清楚
我在带人做爬虫项目时,一向要求先把需求写成交付明确的条目。很多人一上来就打开一个爬虫直接跑,跑完发现字段不全、数据重复、性能拉胯,返工比写代码还累。第一步就是问清楚:抓哪个站点、抓哪些字段、抓到之后放哪里、多久更新一次。比如我这里设计了一个模拟项目X,目标是抓取某个资讯类网站的公开新闻列表,需要标题、发布时间、摘要、正文、原文章URL和来源栏目。别小看“来源栏目”这个字段,它后面要做数据分析和推荐时可能是主维度。所以Item设计这事不要图省事,宁可多设计两个字段,也不要在后面再改数据结构。
第二个要问清楚的问题是存储。是一口气导出几条JSON文件交给下游,还是进来一条就写一条数据库?这决定了Pipeline怎么写。如果数据量很大而且需要去重关联,那肯定得落到关系型数据库或文档型数据库里,而不是攒到最后用Scrapy的FEEDS导出。第三个问题是运行频率。新闻站可能一个小时更新一次,电商价格监控可能五分钟一次,频率不同,调度策略和延时设置完全不同。把这三件事想明白,后面的框架搭建就事半功倍。
1.2 为什么选Scrapy而不是靠Requests手写
很多刚入门的同学会用Requests配合BeautifulSoup写抓取脚本,这种方式对几百条数据、单页面抓取完全没问题,但它没有任何工程化结构。你需要在业务代码里手工管理多线程、请求状态、异常处理、数据入库,整个脚本不到一千行就开始发臭,后面想加一个防重、加一个管道清洗,改起来全是坑。Scrapy恰好把这层工程化做到框架里。
我列一个对比表格,容易看得清楚:
| 能力 | Requests手写方案 | Scrapy方案 |
|---|---|---|
| 并发请求 | 需要用线程池或协程手写 | 内置异步调度器,配置即可 |
| 请求调度与去重 | 需自己维护队列与集合 | 内置调度器和去重过滤器 |
| 扩展机制 | 无,代码穿插在业务里 | 中间件、管道、扩展清晰分离 |
| 项目可维护性 | 脚本式,越写越乱 | 目录结构固定,改一处即可全局生效 |
| 分布式支持 | 基本没有 | 可搭配组件实现分布式队列 |
| 调试与命令行工具 | 无 | shell、命令行等多种调试方式 |
所以当项目涉及多个页面类型、数据量上万、需要长期维护时,我建议直接用Scrapy。毕竟那些看起来“很快”的手写方案,到了真实环境里其实一点都不快——光是各种意外异常就够你调一整天的。
1.3 整体架构流:从调度到数据入库的逻辑
Scrapy最核心的一套逻辑并不复杂:调度器(Scheduler)从请求队列里取出URL,交给下载器(Downloader)发起请求;拿到响应后交给Spider去解析,Spider把提取到的数据生成Item,Item再依次经过Pipeline做清洗和存储。中间任何一个环节都可以插入中间件(Middleware)做定制,比如修改请求头、处理重试逻辑。
在真实项目里,我还习惯在这条主链之外多设计一层“任务分层”:列表页抓取和详情页抓取分开写解析函数,用meta参数传递上下文。这样列表页挂了不会连带把详情页的逻辑也搞乱。简单说就是让一个Spider处理多个页面层级,但是用不同的方法隔开,每个方法只做一件清晰的事。这部分在第三节我会给出完整代码。
2. 环境搭建与项目工程化
2.1 虚拟环境与Scrapy安装
爬虫项目依赖版本很敏感,尤其Scrapy解析HTML时会用到底层HTML解析库,不同Python环境的兼容情况略有差异。所以第一步永远是建虚拟环境。我用的是完全通用的做法:
python3 -m venv venv source venv/bin/activate pip install scrapy注意不要直接依赖系统Python,否则有一天你把环境搞乱了,你会非常后悔。装好以后验证一下:
scrapy version能看到版本号就表示环境正常。如果你在的项目里还有其他数据相关库,比如数据库驱动,也要在虚拟环境里一次性装上,避免后续再装时遗忘导致各种ImportError。
2.2 创建项目骨架:每个文件都是干什么的
用Scrapy自带的命令就能生成标准骨架:
scrapy startproject myspider cd myspider scrapy genspider news demo.example.com我从实战经验说,先别急着给生成命令传真实域名,后面写代码时再改URL也不迟。生成出来的结构大概是这样:
- scrapy.cfg:项目配置文件,发布时用到,日常基本不用动。
- myspider/items.py:定义你要抓取的数据结构。
- myspider/pipelines.py:写数据清洗、去重、入库的管道类。
- myspider/middlewares.py:存放下载中间件和Spider中间件。
- myspider/settings.py:所有全局配置,比如并发数、延时、是否遵守robot协议、管道是否启用。
- myspider/spiders/:放实际抓取逻辑,一个网站一个爬虫类。
新手最容易犯的错误是不知道每个文件该写什么逻辑,把一堆代码堆在Spider文件里。我建议你从一开始就建立习惯:Spider只管“提取数据”,Pipeline只管“处理数据”,Middleware只管“参与请求与响应的预处理”。职责分清楚,后期排查问题会舒服非常多。
2.3 settings.py 里必须调的基本参数
刚生成的settings.py有一堆默认值,但直接用默认值跑真实项目往往会出事。这里给出我在模拟项目X里最常用的基准配置:
ROBOTSTXT_OBEY = True CONCURRENT_REQUESTS = 16 DOWNLOAD_DELAY = 1.0 RANDOMIZE_DOWNLOAD_DELAY = True DEFAULT_REQUEST_HEADERS = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Accept': 'text/html,application/xhtml+xml', } ITEM_PIPELINES = { 'myspider.pipelines.NewsPipeline': 300, }ROBOTSTXT_OBEY = True是底线,它让你的爬虫先去读取目标站点的robots.txt,符合规则的页面才去抓,这是正规爬虫项目最基本的态度。DOWNLOAD_DELAY设置到1.0可以避免因为请求过快被临时限制访问。至于CONCURRENT_REQUESTS,16是一个不算高也不算低的值,如果你发现目标站点响应很慢,建议调到8。
你可能会问,延时不是拖慢速度了吗?这里要特别给你一个反直觉的经验:爬虫的稳定性和数据完整性,远比那几分钟的速度差重要。与其冲太快被限制以后等十几分钟冷却,不如从一开始就按友好节奏跑。后面需要提速再做分布式调度,而不是靠压延时硬抢。
3. 核心实战:写一个完整Spider抓取列表页与详情页
3.1 定义Item:数据结构先于代码
我曾经在一个项目里因为一开始没定义好Item,后面重构字段时要改的东西比想象的还多。所以这一节我建议你严格跟着做。在items.py里写:
import scrapy class NewsItem(scrapy.Item): title = scrapy.Field() url = scrapy.Field() publish_time = scrapy.Field() summary = scrapy.Field() content = scrapy.Field() category = scrapy.Field()为什么要把字段定得这么细?因为Scrapy的Field并没有强制类型,它就相当于一个字典的key,但提前定义能让你在Pipeline里做统一处理时更清晰。比如你可以写一段通用代码,给所有未提取到的字段填入默认值,这样下游不会因为缺字段挂掉。
3.2 用CSS选择器与XPath写提取规则
解析是爬虫最耗时、也最容易翻车的地方。我自己更习惯先用CSS选择器快速定位,再在个别单个字段上补XPath。原因是CSS的语法简单好理解,比如:
title = response.css('div.news-title h2 a::text').get()如果想抓列表页里所有文章的链接,可以用getall()拿到列表:
links = response.css('ul.list-box li a::attr(href)').getall()但CSS拿属性值的写法有时候不够直白,这时可以用XPath补:
link = response.xpath('//div[@class="news-item"]/a/@href').get()无论你使用哪种方式,我强烈建议在写完选择器后用scrapy shell调试一下再写死。命令行直接输入:
scrapy shell <目标页面URL>然后反复执行选择器,看到符合预期的结果再复制回代码。这样能省下大量反复调试的时间。
3.3 编写Spider:列表页与详情页分层解析
下面这是模拟项目X里新闻爬虫的完整骨架。我先在Spider里写了start_requests来初始化抓取入口,再在parse里做列表页解析,并通过meta把栏目信息传给parse_detail。
import scrapy from myspider.items import NewsItem class NewsSpider(scrapy.Spider): name = 'news' allowed_domains = ['demo.example.com'] start_urls = ['https://demo.example.com/news'] def parse(self, response): # 从列表页提取详情页链接 article_links = response.css('div.news-item a::attr(href)').getall() for link in article_links: full_url = response.urljoin(link) yield scrapy.Request(full_url, callback=self.parse_detail) # 处理下一页(简单示例,只取前5页) if 'page=5' not in response.url: next_page = response.css('a.next::attr(href)').get() if next_page: yield response.follow(next_page, callback=self.parse) def parse_detail(self, response): item = NewsItem() item['url'] = response.url item['title'] = response.css('h1.article-title::text').get() item['publish_time'] = response.css('span.date::text').get() item['summary'] = response.css('div.summary::text').get().strip() content_nodes = response.css('div.article-content p::text').getall() item['content'] = ''.join(content_nodes) item['category'] = response.request.meta.get('category', '未分类') yield item注意细节:response.follow会自动处理相对链接,省去手动拼接。response.urljoin也能实现,但follow对字符串更友好。如果模板类页面有多个分页,我通常会限制一下翻页深度,避免爬虫失控。上面的代码用page=5做了个简单的终止条件,真实项目可以把页码和关键URL做进配置里管理。
3.4 Pipeline:清洗、校准与去重
数据从Spider出来,如果不做处理就直接丢给下游,你会在一周后收到一堆包含空白符、空字段、乱码的数据。我通常会在Pipeline里做三件事:去除首尾空白、统一时间格式、过滤重复数据。下面是一个典型的Pipeline,记得从scrapy.exceptions导入DropItem:
import re from itemadapter import ItemAdapter from scrapy.exceptions import DropItem class NewsPipeline: def open_spider(self, spider): self.seen = set() def process_item(self, item, spider): adapter = ItemAdapter(item) # 字段清洗 for field in adapter.field_names(): value = adapter.get(field) if isinstance(value, str): adapter[field] = value.strip() # 时间格式统一(例如把日期分隔符换成横线) raw_time = adapter.get('publish_time') if raw_time: adapter['publish_time'] = raw_time.replace('/', '-') # 简单的URL去重 url = adapter.get('url') if url in self.seen: raise DropItem(f'重复数据: {url}') self.seen.add(url) return item写Pipeline时还要注意数字编号。ITEM_PIPELINES里的字典value值决定执行顺序,值越小越靠前执行。如果你有多个管道,比如一个清洗、一个入库,就分别用100、200这样的值设置。顺序一旦写反,先入库后清洗,那数据就彻底脏了。
4. 反爬信号识别与访问节奏控制
4.1 先看状态码和响应体,别急着改代码
每次爬虫抓了几百条突然中断,第一个反应不应该是满世界找“破解反爬”的偏方,而是先看HTTP响应。403表示请求被服务端拒绝,503通常是对方服务繁忙或我们请求太密,404则多见于拼接错误。抓下来的响应体内容也很关键,如果发现返回的是验证页或者一个空的HTML,通常意味着请求头被识别了。
这时候我调试的标准动作是:把response保存到本地看看返回了什么。可以直接在Spider里临时打印response.text[:500],或者用一个下载中间件把异常响应写进日志。这个习惯能让你从“瞎猜”变成“看证据”。
4.2 用限速与自动调节保证双方稳定
Scrapy自带一个自动限速扩展(AutoThrottle),它会根据服务器的响应时间自动调节请求速率。简单说,响应越快,它可能会稍微放松一点速度,响应慢就自动放慢。真实项目中我经常开启AutoThrottle,再配合基础的DOWNLOAD_DELAY,两者一起用效果很好。
AUTOTHROTTLE_ENABLED = True AUTOTHROTTLE_START_DELAY = 1.0 AUTOTHROTTLE_MAX_DELAY = 10.0 AUTOTHROTTLE_TARGET_CONCURRENCY = 1.0如果说要给你一个最值得记住的经验,我觉得是:爬虫开发中真正的核心竞争不是速度,而是稳定。一个能从下午四点稳定跑到凌晨十二点不带中断的爬虫,比一个看似飞快但十分钟就挂的爬虫有价值得多。
4.3 请求头与Cookie的正确处理方法
正常处理Cookie不是用来做任何灰色操作,而是为了模拟普通浏览器的行为。有些站点在未登录时给的页面只有摘要,登录后才给全文。如果爬的是必须登录的内容,就应该走正常账号登录流程获取Cookie,然后通过请求参数传递。但前提永远是合法抓取,尊重站点的服务条款。
设置全局请求头可以写在DEFAULT_REQUEST_HEADERS里,如果你需要在中间件里动态更换UA,也可以写一个简单的下载中间件,每个请求随机更换UA。随机UA能降低服务器把你识别成“同一个工具”的概率,这同时是对双方都友好的做法。
5. 常见问题与排查技巧实录
5.1 爬到一半不跑了:看队列、看日志、看延迟
最常见的问题是:日志不再输出新请求,进程还在那里,但什么也不干。这种情况通常是请求全部卡在网络等待里,或者被下载器限流了。先看日志最后几行的状态,如果是正常的“Crawled (200)”之后没有新日志,那就看看当前并发数和延时。如果并发数设得太大,对方服务器迟迟不回包,调度器会一直等着,看起来就像卡死。解决办法是把CONCURRENT_REQUESTS降下来,把DOWNLOAD_TIMEOUT调短一些。
DOWNLOAD_TIMEOUT = 15设一个合理的超时非常重要,宁可跳过几条慢请求,也不要把整个爬虫拖进死等。
5.2 学会读Scrapy日志:DEBUG和INFO之间藏着的信号
Scrapy日志密密麻麻,但真正有用的就那么几类。运行正式任务时我会把日志级别设为INFO,过滤掉烦人的DEBUG输出。关注两个字段:Crawled (200)表示成功拿到页面,Scraped from <...>表示从Spider生成了Item,ERROR则代表有异常。如果一段时间内Crawled的数目不再增长,说明调度队列可能空了或者全部请求超时。还有一条很容易忽略的Retrying日志——它表示第一次请求失败,正在重试。重试次数一多,执行时间就会拖长,这时候你该去优化请求头或者延时,而不是干等着重试成功。
5.3 Pipeline的异常处理与顺序坑
Pipeline里如果抛了一个未捕获的异常,整个爬虫会因为这个异常直接停止。所以凡是写入库操作、清洗操作,必须用try/except包住,并把错误记进日志。我自己的标准写法是这样:
def process_item(self, item, spider): try: # 入库逻辑 pass except Exception as e: spider.logger.error(f'清洗失败: {e}') raise DropItem(f'异常数据: {item.get("url")}')这里的坑在于,如果DropItem没有正确导入,异常会把这条Item留在半路,看似成功其实丢了数据。另外就是Pipeline排序问题——你定义了两个管道,第一个做清洗,第二个做入库,结果字典里的顺序反了,清洗还没执行就入库了,数据脏到没法看。每次改完ITEM_PIPELINES顺序,我都会用小数据量重跑一遍确认清洗结果。
5.4 数据完整性与重跑策略:用幂等避免重复积累
爬虫程序跑了几天以后,最尴尬的不是抓不到数据,而是重复数据。Scrapy本身会基于URL做请求去重,但如果你用多个入口爬同一个页面,依然会有URL层面去重失效的情况。所以我在Pipeline里通常还会加一层“业务去重”,比如用URL或者标题哈希做唯一键。这样即使爬虫中途断掉重启,也不怕积累重复数据。重启时也不需要把历史数据清掉,直接重新跑一遍抓取任务,有主键约束和去重逻辑在,结果依然干净。
5.5 最后再分享一个我个人的实用小技巧
这个技巧看着简单,但真能帮很多人省时间:写Spider时,不要把生命周期、延迟这些参数硬编码在代码里,统一放到settings.py里管理。我第一次做项目时把所有延时写死在Spider里,后来想调整全局节奏,要翻好几个文件才能改完。现在我把所有跟目标站点相关的配置信息都集中到一个配置文件里,甚至包括列表页深度、是否允许抓取图片等等。这样当上级让你临时换个站点跑数据的时候,你只要复制一个配置文件,再改一小段Spider的解析规则,整个项目就能复用,效率提升非常明显。
这个专栏还有不少关于数据治理与采集调优的内容可以继续写,比如如何对接消息队列,或者把多个爬虫统一管理。不过在我个人实际操作中,先把上面这套基础流程跑扎实,比盲目堆功能要有用得多。