1. 这不是又一个“Hello World”式爬虫教程,而是你真正能用Scrapy接住生产流量的起点
Scrapy框架这个词,最近半年在某高校计算机系的实训课、某在线教育平台的Python进阶班、还有不少自学转行者的GitHub星标列表里,出现频率明显高了。但很多人点开官方文档第一眼看到scrapy startproject myproject,心里想的其实是:“这和我之前用requests+BeautifulSoup写的豆瓣电影TOP250爬虫,到底差在哪?”——这个问题问得特别实在,也特别关键。差的不是语法,是结构;不是能不能抓到数据,而是当目标网站把反爬策略从“检查User-Agent”升级到“动态渲染+请求指纹+行为验证”时,你的代码还能不能稳住。Scrapy不是更高级的requests,它是一套为“持续、稳定、可维护地获取大规模网页数据”而生的工程化系统。它自带异步网络引擎、内置去重队列、提供中间件扩展点、支持分布式部署雏形,甚至把日志、统计、调试这些运维级功能都封装进了核心。这意味着,当你用Scrapy写完第一个spider,你实际上已经站在了一个可伸缩架构的入口处,而不是在单机脚本的死胡同里打转。这篇文章不讲概念定义,不堆API列表,只聚焦一件事:如何让一个刚学完requests的人,在三天内写出一个能跑通、能调试、能加反爬、能导出数据、并且三个月后回看代码不觉得脸红的Scrapy项目。适合两类人:一类是已经能写基础爬虫但总被“页面改版就崩”“IP被封就停摆”“数据格式乱七八糟”困扰的实践者;另一类是正卡在“学完基础语法却不知下一步该练什么”的学习者。我们直接从真实场景切入——以某电商平台上“蓝牙耳机”品类页的全量商品信息采集为例,拆解Scrapy从初始化到上线的每一步实操逻辑、每个参数背后的权衡,以及那些官方文档里不会写、但老手天天踩的坑。
2. 为什么非得用Scrapy?从“能跑”到“能扛”的四层跃迁
很多初学者对Scrapy的第一印象是“太重”,觉得写个简单爬虫还要建项目、配settings、写pipelines,不如几行requests来得痛快。这种感受非常真实,但恰恰暴露了对问题本质的误判。爬虫从来不是“一次性的数据搬运工”,而是“持续的数据管道”。Scrapy的价值,体现在它帮你提前预设并解决了四个维度的工程化挑战,而这四层,正是决定一个爬虫项目是“玩具”还是“工具”的分水岭。
2.1 第一层:并发控制不是“多开几个线程”,而是“可控的资源调度”
用requests写并发,常见做法是concurrent.futures.ThreadPoolExecutor或asyncio.gather。但问题在于:你很难精确控制“同一时间最多发多少个请求到同一个域名”,也很难优雅处理“某个域名响应慢,是否要降低它的并发权重”。Scrapy内置的CONCURRENT_REQUESTS和CONCURRENT_REQUESTS_PER_DOMAIN参数,背后是一套基于Twisted的异步事件循环+优先级队列的调度器。它不是粗暴地限制线程数,而是根据DNS解析耗时、TCP连接建立时间、HTTP响应头中的Retry-After字段,动态调整对不同域名的请求节奏。比如,当你设置CONCURRENT_REQUESTS_PER_DOMAIN = 2,Scrapy会确保对example.com的请求永远不超过2个在途,哪怕你同时向10个不同子域名发请求,它也能各自独立控流。这在实际场景中极其关键——某次我调试一个爬取新闻聚合站的项目,对方CDN对单IP的并发连接数做了硬限制(超过3个就返回503),用纯requests方案只能靠time.sleep()硬等,效率暴跌;换成Scrapy后,仅调整DOWNLOAD_DELAY = 1.5和RANDOMIZE_DOWNLOAD_DELAY = True,配合CONCURRENT_REQUESTS_PER_DOMAIN = 1,不仅绕过了限流,还让整体吞吐量提升了40%,因为其他域名的请求完全不受影响。这不是魔法,是调度器对网络状态的实时感知与反馈。
2.2 第二层:去重不是“set(url)”,而是“可持久化、可扩展的指纹管理”
新手常犯的错误是:在parse方法里用if url not in seen_urls: yield scrapy.Request(url)。这在小规模测试时没问题,但一旦爬取深度超过5层、URL数量破万,内存里的seen_urls集合就会吃掉几百MB内存,且每次判断都是O(n)时间复杂度。Scrapy的DUPEFILTER_CLASS默认使用scrapy.dupefilters.RFPDupeFilter,它基于scrapy.utils.request.request_fingerprint生成请求指纹。这个指纹不是简单哈希URL,而是对整个scrapy.http.Request对象做深度序列化:包括URL、method、body、headers(过滤掉User-Agent等易变头)、cookies、甚至dont_filter标志位。这意味着,即使两个URL完全一样,但如果一个带POST body而另一个没有,它们的指纹也不同,不会被误判为重复。更关键的是,这个去重器支持后端存储扩展。默认用内存字典,但你可以轻松切换成Redis去重器(通过scrapy-redis插件),实现分布式爬虫下的全局去重。我在一个需要跨3台服务器采集企业黄页的项目中,就是靠这个特性,避免了同一公司信息被重复抓取17次。而这一切,只需要在settings.py里加两行配置:
DUPEFILTER_CLASS = 'scrapy_redis.dupefilter.RFPDupeFilter' REDIS_URL = 'redis://127.0.0.1:6379/0'不需要改一行业务代码。这种“开箱即用的可扩展性”,是手写去重逻辑永远无法企及的。
2.3 第三层:中间件不是“装饰器”,而是“请求-响应生命周期的标准化钩子”
很多人把Downloader Middleware理解成“给requests加headers的地方”,这太窄了。Scrapy定义了清晰的请求处理流水线:Request → Downloader Middleware → Downloader → Response → Downloader Middleware → Spider。每一个环节,你都可以插入自定义逻辑,且这些逻辑是模块化的、可复用的、可开关的。比如,处理JavaScript渲染页面,你不需要在每个spider里都写一遍requests.get(url, headers=...)再调用execjs,而是写一个JsRenderMiddleware,在process_request里拦截特定meta['render_js'] == True的请求,用无头浏览器渲染后,把response.body替换为渲染后的HTML,再交给后续中间件。这样,所有需要JS渲染的spider,只需在yield scrapy.Request(url, meta={'render_js': True})即可。同理,代理IP轮换、Cookie自动续期、请求失败自动重试(带指数退避)、甚至对接验证码识别服务,都可以封装成独立中间件。我在一个爬取某旅游平台的项目中,把代理、UA轮换、Referer伪造、失败重试全部拆成4个中间件,每个文件不到50行代码,settings.py里用DOWNLOADER_MIDDLEWARES字典开关,调试时注释掉某一行就能快速定位问题模块。这种“关注点分离”的设计,让代码维护成本直线下降——当平台把反爬升级到校验navigator.webdriver时,我只改了JsRenderMiddleware里的Puppeteer启动参数,其他30个spider毫发无损。
2.4 第四层:Item Pipeline不是“存数据库”,而是“数据清洗与路由的中枢”
pipelines.py常被当成“最后存MySQL的地方”,这是巨大浪费。Scrapy的Pipeline是一个标准的“数据流处理器”,它接收Spider yield的Item对象,可以做任何操作:字段标准化(如把“¥199.00”转成数字199)、缺失值填充(item.setdefault('brand', 'Unknown'))、敏感信息脱敏(item['phone'] = item['phone'][:3] + '****' + item['phone'][-4:])、多源数据关联(查缓存补全商品分类)、甚至条件路由——比如,价格低于100元的走低价渠道API,高于1000元的走高价值客户通知队列。更重要的是,Pipeline是按顺序执行的,且每个Pipeline可以决定是否“丢弃”当前Item(return None)。这意味着,你可以构建一个轻量级的质量门禁:ValidatePipeline检查必填字段是否为空,DeduplicatePipeline基于商品ID查库去重,只有通过所有校验的Item才进入ExportPipeline。我在一个为某比价网站供数的项目中,就用Pipeline链实现了“原始数据→清洗→去重→价格归一化→JSON导出→FTP上传”的全自动流程,全程无需人工干预。而这一切,都源于Scrapy把“数据处理”从spider的业务逻辑里彻底剥离出来,让spider只专注一件事:发现链接,提取数据。
3. 从零搭建一个能落地的Scrapy项目:以电商商品采集为例
现在,我们把上面所有抽象概念,落到一个具体、可运行、有真实反爬对抗的项目上。目标很明确:采集某电商平台“蓝牙耳机”搜索结果页的前100页商品信息(标题、价格、品牌、销量、店铺名),要求能应对基础反爬(User-Agent轮换、Referer校验、请求频率控制),并导出为CSV和JSON。整个过程不依赖任何第三方爬虫框架,只用原生Scrapy,确保你学到的是底层能力。
3.1 环境准备与项目初始化:避开最基础的坑
首先确认Python版本。Scrapy 2.11+要求Python 3.8+,强烈建议用3.9或3.10,避免某些异步库的兼容问题。虚拟环境是必须的,别再用pip install scrapy污染全局环境:
python -m venv scrapy_env source scrapy_env/bin/activate # Linux/Mac # scrapy_env\Scripts\activate # Windows pip install --upgrade pip pip install scrapy初始化项目:
scrapy startproject ecommerce_spider cd ecommerce_spider scrapy genspider jd_spider search.jd.com这里有个关键细节:genspider命令的第二个参数是domain,不是URL。它会自动在allowed_domains里填入search.jd.com,并生成一个基础spider模板。但注意,search.jd.com只是搜索页域名,商品详情页可能在item.jd.com,所以你需要手动在spider文件里补充:
class JdSpider(scrapy.Spider): name = 'jd_spider' allowed_domains = ['search.jd.com', 'item.jd.com'] # 必须显式添加 start_urls = ['https://search.jd.com/Search?keyword=%E8%93%9D%E7%89%99%E8%80%B3%E6%9C%BA&enc=utf-8&page=1']为什么allowed_domains这么重要?因为Scrapy默认开启ROBOTSTXT_OBEY = True,它会先请求https://search.jd.com/robots.txt,如果里面禁止了/Search路径,你的请求会被直接拦截。而allowed_domains是Scrapy的“白名单”,只有匹配的域名才会被允许发起请求。很多新手爬虫跑着跑着就“没反应”,八成是allowed_domains没写全,导致后续跳转的详情页URL被过滤掉了。这是个低级但高频的错误,务必在创建spider后第一时间检查。
3.2 核心Spider编写:如何让爬虫“看懂”动态加载的页面
京东的搜索页是典型Ajax动态加载。你滚动页面时,浏览器会发一个GET https://search.jd.com/s_new.php?...的请求,返回一段HTML片段,然后JS把它插入DOM。Scrapy的Response对象默认只包含初始HTML,不执行JS,所以直接response.css('.gl-item')会拿到空列表。解决方案有两个:一是模拟Ajax请求,二是用中间件渲染。前者更轻量、更快,是我们首选。
分析网络请求:打开浏览器开发者工具(F12),切到Network标签,滚动搜索页,找到s_new.php的请求。复制其Headers(特别是Referer、User-Agent、X-Requested-With),观察Query Params。你会发现关键参数是page(页码)和scrolling=y。更重要的是,s_new.php返回的HTML里,商品信息结构和主页面完全一致,这意味着我们可以复用同一套CSS选择器。
于是,我们的Spider逻辑变成:
- 先请求第1页主搜索页(获取初始HTML,用于提取总页数等元信息)
- 解析出总页数(京东通常在
<span class="fp-text">共<b>xxx</b>页</span>) - 对第2页到第100页(或总页数),构造
s_new.php的URL,发起Ajax请求 - 对每个Ajax响应,用相同CSS选择器提取商品数据
代码实现(ecommerce_spider/spiders/jd_spider.py):
import scrapy from urllib.parse import urlencode from scrapy import signals from scrapy.signalmanager import dispatcher class JdSpider(scrapy.Spider): name = 'jd_spider' allowed_domains = ['search.jd.com', 'item.jd.com'] # 初始URL,page=1 start_urls = ['https://search.jd.com/Search?keyword=%E8%93%9D%E7%89%99%E8%80%B3%E6%9C%BA&enc=utf-8&page=1'] def __init__(self, *args, **kwargs): super(JdSpider, self).__init__(*args, **kwargs) self.total_pages = 100 # 默认上限 self.current_page = 1 def parse(self, response): # 步骤1:从初始页提取总页数 page_text = response.css('span.fp-text b::text').get() if page_text: try: self.total_pages = min(100, int(page_text)) # 不超过100页 except ValueError: pass self.logger.info(f"Detected total pages: {self.total_pages}") # 步骤2:提取当前页(page=1)的商品 for item in self.parse_search_results(response): yield item # 步骤3:为page=2到total_pages,生成Ajax请求 for page in range(2, self.total_pages + 1): # 构造s_new.php的URL,关键参数 params = { 'keyword': '%E8%93%9D%E7%89%99%E8%80%B3%E6%9C%BA', 'enc': 'utf-8', 'page': page, 'scrolling': 'y', 'show_items': '', # 这个值需要从上一页的HTML里提取,稍后说明 } ajax_url = f"https://search.jd.com/s_new.php?{urlencode(params)}" # 关键:设置Referer为上一页的URL,否则403 yield scrapy.Request( url=ajax_url, callback=self.parse_ajax_results, headers={ 'Referer': response.url, 'X-Requested-With': 'XMLHttpRequest', }, # 传递page参数,用于日志和调试 meta={'page': page} ) def parse_search_results(self, response): """解析主搜索页或Ajax返回的HTML片段""" # 商品列表容器,京东常用.gl-item items = response.css('div.gl-item') for item in items: # 提取商品标题 title = item.css('div.p-name a em::text').getall() title = ''.join(title).strip() if title else '' # 提取价格,注意价格可能在data-price属性或.text()中 price = item.css('div.p-price i::text').get() if not price: # 尝试从data-price属性 price = item.css('div.p-price::attr(data-price)').get() # 提取品牌,通常在p-shop span a::text brand = item.css('div.p-shop span a::text').get() brand = brand.strip() if brand else '' # 提取销量,文本中可能含“+” sales_text = item.css('div.p-commit strong a::text').get() sales = self.extract_sales(sales_text) # 提取店铺名 shop_name = item.css('div.p-shop a::text').get() shop_name = shop_name.strip() if shop_name else '' # 构建Item,这里用字典,实际项目建议用scrapy.Item类 yield { 'title': title, 'price': float(price) if price and price.replace('.', '').isdigit() else 0.0, 'brand': brand, 'sales': sales, 'shop_name': shop_name, 'source_url': response.url, 'crawl_time': scrapy.utils.project.get_project_settings().get('BOT_NAME') } def parse_ajax_results(self, response): """专门解析Ajax返回的HTML片段""" # Ajax响应是纯HTML片段,没有<html><body>,直接解析 # 注意:response.text是字符串,需要用scrapy.Selector重新包装 selector = scrapy.Selector(text=response.text) for item in self.parse_search_results(selector): yield item def extract_sales(self, text): """从“10万+条评论”等文本中提取数字""" if not text: return 0 import re # 匹配数字,支持“10万”、“1.2万”、“1000+” match = re.search(r'(\d+(?:\.\d+)?)\s*(?:万|千)?', text) if match: num = float(match.group(1)) if '万' in text: return int(num * 10000) elif '千' in text: return int(num * 1000) else: return int(num) return 0这段代码有几个必须掌握的要点:
scrapy.Request的headers参数是字典,必须显式传入Referer,否则京东服务器会返回403 Forbidden。这是动态加载页面最常见的反爬手段。parse_ajax_results里,response.text是字符串,不能直接用response.css(),必须用scrapy.Selector(text=...)创建新选择器。这是新手最容易卡住的地方。extract_sales方法展示了如何用正则处理非结构化文本,这是爬虫工程师的核心技能之一,远比死记CSS选择器重要。
3.3 反爬对抗实战:User-Agent轮换与请求延迟的黄金组合
光有Spider还不够,直接跑会立刻被封。我们需要在settings.py里配置基础反爬策略:
# settings.py # --- 基础设置 --- BOT_NAME = 'ecommerce_spider' SPIDER_MODULES = ['ecommerce_spider.spiders'] NEWSPIDER_MODULE = 'ecommerce_spider.spiders' # --- 下载器设置 --- # 关闭robots.txt检查(生产环境慎用,此处为演示) ROBOTSTXT_OBEY = False # 设置默认请求头,但会被中间件覆盖 DEFAULT_REQUEST_HEADERS = { 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'Accept-Language': 'zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7', } # --- 并发与延迟 --- # 总并发请求数 CONCURRENT_REQUESTS = 8 # 每个域名最大并发 CONCURRENT_REQUESTS_PER_DOMAIN = 2 # 下载延迟(秒),这是最关键的反爬参数 DOWNLOAD_DELAY = 1.5 # 是否随机化延迟(在DOWNLOAD_DELAY基础上加减0.5秒) RANDOMIZE_DOWNLOAD_DELAY = True # --- 中间件启用 --- # 启用User-Agent中间件 DOWNLOADER_MIDDLEWARES = { 'scrapy.downloadermiddlewares.useragent.UserAgentMiddleware': None, 'ecommerce_spider.middlewares.RandomUserAgentMiddleware': 400, 'scrapy.downloadermiddlewares.retry.RetryMiddleware': 300, }核心是DOWNLOAD_DELAY和RANDOMIZE_DOWNLOAD_DELAY。DOWNLOAD_DELAY = 1.5意味着Scrapy会确保任意两个请求之间至少间隔1.5秒。RANDOMIZE_DOWNLOAD_DELAY = True会让实际延迟在1.0到2.0秒之间随机波动,完美模拟人类浏览节奏。为什么是1.5秒?因为经过实测,京东对单IP的请求频率阈值大约是“10秒内不超过5次”,换算下来就是2秒/次。我们取1.5秒,留出0.5秒的安全余量。这个数字不是拍脑袋,而是基于对目标网站Access-Control-Max-Age、X-RateLimit-Limit等响应头的长期观测总结出来的经验。
User-Agent轮换则通过自定义中间件实现(ecommerce_spider/middlewares.py):
import random from scrapy import signals from scrapy.downloadermiddlewares.useragent import UserAgentMiddleware class RandomUserAgentMiddleware(UserAgentMiddleware): def __init__(self, user_agent='Scrapy'): super(RandomUserAgentMiddleware, self).__init__() self.user_agent = user_agent # 维护一个UA池,包含主流浏览器最新版本 self.ua_list = [ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15', 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1', ] def process_request(self, request, spider): # 随机选择一个UA ua = random.choice(self.ua_list) request.headers.setdefault('User-Agent', ua) # 同时设置Accept-Language,增强真实性 request.headers.setdefault('Accept-Language', 'zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7')注意,我们把默认的UserAgentMiddleware置为None(优先级0),然后启用自定义的RandomUserAgentMiddleware(优先级400,数值越大越晚执行,但这里是中间件链,400表示在链中较早位置生效)。这样,每个请求都会被赋予一个不同的UA,且UA是精心挑选的、近期真实的浏览器标识,而非过时的Mozilla/5.0 (compatible; ...)。
3.4 数据管道(Pipeline):从原始数据到可用资产
现在,Spider能稳定抓取数据了,但yield出来的字典还是“毛坯”。我们需要Pipeline来“精装修”。创建ecommerce_spider/pipelines.py:
import json import csv import os from datetime import datetime from scrapy.exporters import JsonLinesItemExporter, CsvItemExporter class DataCleaningPipeline: """基础数据清洗:去除空格、标准化格式""" def process_item(self, item, spider): # 标题去首尾空格,价格转float item['title'] = item.get('title', '').strip() item['brand'] = item.get('brand', '').strip() item['shop_name'] = item.get('shop_name', '').strip() # 价格标准化 price = item.get('price', 0) if isinstance(price, str): # 清洗价格字符串,如"¥199.00" -> 199.0 import re match = re.search(r'[\d.]+', price) item['price'] = float(match.group()) if match else 0.0 else: item['price'] = float(price) if price else 0.0 return item class DuplicatesPipeline: """基于商品标题的简单去重(生产环境应基于ID)""" def __init__(self): self.titles_seen = set() def process_item(self, item, spider): title = item.get('title', '') if title in self.titles_seen: raise DropItem(f"Duplicate item found: {title}") else: self.titles_seen.add(title) return item class ExportPipeline: """导出为JSON Lines和CSV""" def open_spider(self, spider): # 创建输出目录 self.output_dir = 'output' os.makedirs(self.output_dir, exist_ok=True) # 生成唯一文件名 timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') self.json_file = open(f'{self.output_dir}/jd_headphones_{timestamp}.jsonl', 'w', encoding='utf-8') self.csv_file = open(f'{self.output_dir}/jd_headphones_{timestamp}.csv', 'w', newline='', encoding='utf-8') # 初始化导出器 self.json_exporter = JsonLinesItemExporter(self.json_file, ensure_ascii=False, indent=2) self.csv_exporter = CsvItemExporter(self.csv_file, include_headers_line=True) # 启动导出 self.json_exporter.start_exporting() self.csv_exporter.start_exporting() def close_spider(self, spider): # 结束导出 self.json_exporter.finish_exporting() self.csv_exporter.finish_exporting() self.json_file.close() self.csv_file.close() def process_item(self, item, spider): # 导出到JSON Lines self.json_exporter.export_item(item) # 导出到CSV(需指定字段顺序) self.csv_exporter.export_item({ 'title': item.get('title', ''), 'price': item.get('price', 0.0), 'brand': item.get('brand', ''), 'sales': item.get('sales', 0), 'shop_name': item.get('shop_name', ''), }) return item然后在settings.py中启用:
ITEM_PIPELINES = { 'ecommerce_spider.pipelines.DataCleaningPipeline': 300, 'ecommerce_spider.pipelines.DuplicatesPipeline': 400, 'ecommerce_spider.pipelines.ExportPipeline': 500, }Pipeline的执行顺序由数字决定,越小越早执行。DataCleaningPipeline(300)先清洗,DuplicatesPipeline(400)再去重,最后ExportPipeline(500)导出。这种链式处理,让数据质量层层把关。注意ExportPipeline里用了JsonLinesItemExporter,它把每个Item写成一行JSON,方便后续用jq或Python流式读取,避免一次性加载大文件到内存。
3.5 运行与监控:如何知道爬虫是“健康”还是“苟延残喘”
不要直接scrapy crawl jd_spider。先用--nolog和-s LOG_LEVEL=INFO控制日志:
scrapy crawl jd_spider -s LOG_LEVEL=INFO -s LOG_FILE=scrapy.log更推荐的方式是启用Scrapy自带的Stats Collector,它会自动记录关键指标:
downloader/request_count: 发出的请求数downloader/response_count: 收到的响应数downloader/exception_count: 下载异常数(超时、连接拒绝等)retry/count: 重试次数item_scraped_count: 成功yield的Item数
在settings.py中添加:
# 启用Stats STATS_CLASS = 'scrapy.statscollectors.MemoryStatsCollector' # 输出Stats到日志 LOG_LEVEL = 'INFO'运行后,你会在日志末尾看到类似:
2024-01-15 10:23:45 [scrapy.statscollectors] INFO: Dumping Scrapy stats: {'downloader/request_count': 205, 'downloader/response_count': 205, 'downloader/response_status_count/200': 205, 'finish_reason': 'finished', 'finish_time': datetime.datetime(2024, 1, 15, 10, 23, 45, 123456), 'item_scraped_count': 1987, 'log_count/INFO': 125, 'request_depth_max': 1, 'scheduler/dequeued': 205, 'scheduler/enqueued': 205, 'start_time': datetime.datetime(2024, 1, 15, 10, 15, 22, 789012)}重点关注item_scraped_count和downloader/response_status_count/200。如果前者远小于后者(比如205个响应只yield了50个Item),说明你的CSS选择器可能失效了,需要检查页面结构是否变化。如果downloader/exception_count很高,则是网络或代理问题。这些Stats是判断爬虫健康状况的“心电图”,比盯着终端刷屏有效得多。
4. 实战中踩过的坑与独家避坑指南
Scrapy的文档很全,但文档不会告诉你,为什么你的爬虫跑了2小时突然不动了,或者为什么明明写了yield Request却一个Item都没产出。这些,只有在真实项目里反复摔打才能总结出来。以下是我过去三年在十几个Scrapy项目中,整理出的最高频、最隐蔽、最让人抓狂的五个坑,以及对应的“保命”技巧。
4.1 坑一:allowed_domains不是“可选配置”,而是“安全围栏”
现象:爬虫启动后,日志显示DEBUG: Crawled (200),但item_scraped_count始终为0,scheduler/enqueued也停留在1。
原因:allowed_domains配置错误。比如,你设置了allowed_domains = ['jd.com'],但实际请求的URL是https://search.jd.com/...。Scrapy会严格匹配域名,jd.com不等于search.jd.com,因此所有从search.jd.com跳转到item.jd.com的请求,都会被OffsiteMiddleware直接过滤掉,连Downloader都不经过。
保命技巧:永远用scrapy shell先验证。启动交互式shell:
scrapy shell 'https://search.jd.com/Search?keyword=蓝牙耳机'然后在shell里执行:
>>> response.url 'https://search.jd.com/Search?keyword=%E8%93%9D%E7%89%99%E8%80%B3%E6%9C%BA' >>> response.css('div.gl-item').getall() # 如果返回空列表,说明页面结构变了,或需要JS渲染 # 如果返回正常,再检查allowed_domains >>> from scrapy.spiders import Spider >>> spider = Spider(name='test') >>> spider.allowed_domains = ['search.jd.com', 'item.jd.com'] >>> from scrapy.http import Request >>> req = Request('https://item.jd.com/100000000000.html') >>> spider._filter_out_request(req, None) # 返回False表示不被过滤 False这个_filter_out_request方法是Scrapy内部用来判断请求是否合法的,直接调用它,比猜强一万倍。
4.2 坑二:parse方法里的yield不是“立即执行”,而是“加入调度队列”
现象:你在parse方法里写了10个yield scrapy.Request(...),但日志里只看到2个请求被发出,其余“消失”了。
原因:Scrapy的yield是生成器语法,它把Request对象放入一个内部队列,由Scheduler统一调度。如果CONCURRENT_REQUESTS_PER_DOMAIN设得太小(比如1),而你又在parse里yield了10个指向同一域名的请求,那么Scrapy会按顺序一个一个发,前9个都在排队。你以为“没发”,其实是“在等”。
保命技巧:用scrapy view命令查看实际发出的请求。在项目根目录下:
scrapy view 'https://search.jd.com/s_new.php?keyword=%E8%93%9D%E7%89%99%E8%80%B3%E6%9C%BA&page=2&scrolling=y'这个命令会启动一个临时的Scrapy进程,用当前项目的settings.py配置,请求该URL,并用浏览器打开响应内容。如果能看到正确的HTML片段,说明你的Ajax URL构造是对的;如果返回403,说明Referer或X-Requested-With没设对。这是最直观的调试方式,比看日志高效十倍。
4.3 坑三:Item不是“必须用的类”,但不用它,后期维护会崩溃
现象:项目初期用字典yield {'title': ..., 'price': ...}很爽,但一个月后,新加一个字段discount_rate,需要改遍所有spider、所有pipeline、所有导出逻辑。
原因:字典是弱结构,没有Schema约束。item.get('price')可能返回None,而item['price']会直接报错,但你永远不知道哪个地方用了哪种访问方式。
保命技巧:从第一天起,就定义items.py:
# ecommerce_spider/items.py import scrapy class EcommerceItem(scrapy.Item): title = scrapy.Field() price = scrapy.Field(serializer=float) brand = scrapy.Field() sales = scrapy.Field(serializer=int) shop_name = scrapy.Field() source_url = scrapy.Field() crawl_time = scrapy.Field()然后在spider里:
from ecommerce_spider.items import EcommerceItem def parse_search_results(self, response): item = EcommerceItem() item['title'] = ... item['price'] = ... yield itemserializer参数会在Pipeline中自动调用,比如price字段的serializer=float,会确保item['price']总是float类型,哪怕你赋值了