Scrapy工程化实战:从爬虫脚本到生产级数据管道
1. 这不是又一个“Hello World”式爬虫教程而是你真正能用Scrapy接住生产流量的起点Scrapy框架这个词最近半年在某高校计算机系的实训课、某在线教育平台的Python进阶班、还有不少自学转行者的GitHub星标列表里出现频率明显高了。但很多人点开官方文档第一眼看到scrapy startproject myproject心里想的其实是“这和我之前用requestsBeautifulSoup写的豆瓣电影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 2Scrapy会确保对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可以决定是否“丢弃”当前Itemreturn 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%BAencutf-8page1]为什么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页码和scrollingy。更重要的是s_new.php返回的HTML里商品信息结构和主页面完全一致这意味着我们可以复用同一套CSS选择器。于是我们的Spider逻辑变成先请求第1页主搜索页获取初始HTML用于提取总页数等元信息解析出总页数京东通常在span classfp-text共bxxx/b页/span对第2页到第100页或总页数构造s_new.php的URL发起Ajax请求对每个Ajax响应用相同CSS选择器提取商品数据代码实现ecommerce_spider/spiders/jd_spider.pyimport 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] # 初始URLpage1 start_urls [https://search.jd.com/Search?keyword%E8%93%9D%E7%89%99%E8%80%B3%E6%9C%BAencutf-8page1] 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(fDetected total pages: {self.total_pages}) # 步骤2提取当前页page1的商品 for item in self.parse_search_results(response): yield item # 步骤3为page2到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 fhttps://search.jd.com/s_new.php?{urlencode(params)} # 关键设置Referer为上一页的URL否则403 yield scrapy.Request( urlajax_url, callbackself.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片段没有htmlbody直接解析 # 注意response.text是字符串需要用scrapy.Selector重新包装 selector scrapy.Selector(textresponse.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/xhtmlxml,application/xml;q0.9,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en-US;q0.8,en;q0.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.pyimport random from scrapy import signals from scrapy.downloadermiddlewares.useragent import UserAgentMiddleware class RandomUserAgentMiddleware(UserAgentMiddleware): def __init__(self, user_agentScrapy): 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;q0.9,en-US;q0.8,en;q0.7)注意我们把默认的UserAgentMiddleware置为None优先级0然后启用自定义的RandomUserAgentMiddleware优先级400数值越大越晚执行但这里是中间件链400表示在链中较早位置生效。这样每个请求都会被赋予一个不同的UA且UA是精心挑选的、近期真实的浏览器标识而非过时的Mozilla/5.0 (compatible; ...)。3.4 数据管道Pipeline从原始数据到可用资产现在Spider能稳定抓取数据了但yield出来的字典还是“毛坯”。我们需要Pipeline来“精装修”。创建ecommerce_spider/pipelines.pyimport 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(fDuplicate 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_okTrue) # 生成唯一文件名 timestamp datetime.now().strftime(%Y%m%d_%H%M%S) self.json_file open(f{self.output_dir}/jd_headphones_{timestamp}.jsonl, w, encodingutf-8) self.csv_file open(f{self.output_dir}/jd_headphones_{timestamp}.csv, w, newline, encodingutf-8) # 初始化导出器 self.json_exporter JsonLinesItemExporter(self.json_file, ensure_asciiFalse, indent2) self.csv_exporter CsvItemExporter(self.csv_file, include_headers_lineTrue) # 启动导出 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的执行顺序由数字决定越小越早执行。DataCleaningPipeline300先清洗DuplicatesPipeline400再去重最后ExportPipeline500导出。这种链式处理让数据质量层层把关。注意ExportPipeline里用了JsonLinesItemExporter它把每个Item写成一行JSON方便后续用jq或Python流式读取避免一次性加载大文件到内存。3.5 运行与监控如何知道爬虫是“健康”还是“苟延残喘”不要直接scrapy crawl jd_spider。先用--nolog和-s LOG_LEVELINFO控制日志scrapy crawl jd_spider -s LOG_LEVELINFO -s LOG_FILEscrapy.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始终为0scheduler/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先验证。启动交互式shellscrapy 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(nametest) 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%BApage2scrollingy这个命令会启动一个临时的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(serializerfloat) brand scrapy.Field() sales scrapy.Field(serializerint) 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字段的serializerfloat会确保item[price]总是float类型哪怕你赋值了