Scrapy电商全站爬取实战项目 - 从零构建多层级电商数据抓取系统

📂 所属阶段:第四阶段 — 实战演练(项目开发篇)
🔗 相关章节:Spider实战 · Selector选择器 · Pipeline管道实战 · 反爬对抗实战

目录

项目背景与目标

电商数据是许多业务决策的“燃料”——比价、选品、竞品监控都离不开它。可现实中,电商网站往往结构复杂:分类层层嵌套,商品列表一页接一页,还有各种反爬手段在暗中阻拦。全站爬取就是让爬虫像顾客一样从首页一路逛到详情页,把有价值的信息全部摘下来的过程。

本项目将带你搭建一个生产级的数据抓取系统,核心能力包括:

  • 多级分类处理:自动识别并递归爬取网站的分类树,从一级类目深入到几百个子分类。
  • 深度翻页:智能判断翻页链接或生成URL参数,确保不“漏页”,也能防止死循环。
  • 精准数据提取:使用CSS选择器与正则表达式,从结构复杂的详情页中抽取出标题、价格、规格、图片等关键字段。
  • 反爬虫对抗:集成随机User‑Agent、请求头伪装、代理轮换和延迟策略,让爬虫更“像人”。
  • 数据质量保证:内置去重、清洗、有效性校验,产出的数据可以直接用于分析。

项目技术栈

  • 爬虫框架:Scrapy 2.x,久经考验的异步爬虫引擎。
  • 数据处理:Pydantic 或 Item 定义结构化数据,配合 ItemLoader 自动清洗转换。
  • 存储方案:可选择 MongoDB、MySQL,甚至导出 CSV/JSON 文件。
  • 代理管理:自建代理中间件,简单可控;也可对接 Scrapy‑Proxy‑Pool 等扩展。

准备好之后,我们就从分析目标网站的结构开始。

电商网站架构分析

无论目标网站长得多么花哨,它的内部结构通常都符合一套经典的四层模型:

  1. 首页:顶部导航栏、侧边分类菜单。所有子分类的入口都集中在这里。
  2. 分类页:要么展示下一级子分类,要么直接展示该分类下的商品列表,或者两者兼有。
  3. 列表页:由几十个商品卡片组成,通常带有分页器和排序/筛选控件。
  4. 详情页:单个商品的全部信息,包含标题、价格、属性、图片、评价等。

爬取流程就是沿着这个结构依次展开:

首页 → 获取分类链接 → 进入分类页 → 提取商品链接 → 进入详情页 → 抓取数据

真正的难点往往不在逻辑本身,而在于:

  • 分类的层级不固定(可能2层,也可能4层),需要设计通用的递归解析器。
  • 翻页的实现五花八门:可能是?page=2的查询参数,也可能是/page/3/的路径形式,甚至是“点击加载更多”的按钮。
  • 网站会悄悄检测爬虫:常见的反爬手段包括检查User‑Agent、限制请求频率、封禁IP等。

在动手编码前,我们先规划好项目的整体结构。

项目架构设计

清晰的目录划分会让后续开发更顺畅。下面这个结构是我们推荐的布局:

ecommerce_spider/
├── ecommerce_spider/
   ├── spiders/         # 爬虫文件(核心逻辑)
   ├── items/           # 数据模型定义
   ├── pipelines/       # 管道(清洗、去重、存储)
   ├── middlewares/     # 中间件(反爬、代理)
   ├── utils/           # 工具类(翻页处理、分类解析等)
   └── settings.py      # 全局配置
├── scrapy.cfg           # 项目配置文件
└── requirements.txt     # 依赖列表

核心组件关系

下面的流程图概括了各组件是如何串联工作的:

graph TD
    A[起始URL] --> B[分类解析模块]
    B --> C[列表页解析模块]
    C --> D[详情页解析模块]
    D --> E[数据清洗管道]
    E --> F[数据存储]
    G[反爬中间件] -.-> B
    H[代理中间件] -.-> C

说明:实线箭头表示数据/请求的流转,虚线表示中间件在背后“拦截”并处理请求。

接下来,我们从数据模型开始,一步步实现各个模块。

数据模型定义

在Scrapy中,通常用 Item 类描述我们要抓取的数据结构。配合 ItemLoader,可以很方便地在填充数据时完成清洗和格式转换。

# ecommerce_spider/items/product_item.py
import scrapy
from itemloaders.processors import TakeFirst, MapCompose, Join
from w3lib.html import remove_tags
import re

def clean_price(value):
    """清洗价格字符串,转为浮点数。例如 '¥1,299.00' -> 1299.0"""
    if value:
        cleaned = re.sub(r'[^\d.,]', '', value.strip())
        try:
            return float(cleaned.replace(',', ''))
        except ValueError:
            return None
    return None

class ProductItem(scrapy.Item):
    product_id = scrapy.Field(output_processor=TakeFirst())
    title = scrapy.Field(output_processor=TakeFirst())
    brand = scrapy.Field(output_processor=TakeFirst())
    # 分类路径会用 ' > ' 连接多个层级
    category_path = scrapy.Field(output_processor=Join(' > '))
    current_price = scrapy.Field(
        input_processor=MapCompose(clean_price),
        output_processor=TakeFirst()
    )
    original_price = scrapy.Field(
        input_processor=MapCompose(clean_price),
        output_processor=TakeFirst()
    )
    main_image = scrapy.Field(output_processor=TakeFirst())
    gallery_images = scrapy.Field()              # 可能有多张图
    rating = scrapy.Field(output_processor=TakeFirst())
    review_count = scrapy.Field(output_processor=TakeFirst())
    specifications = scrapy.Field()              # 商品规格,通常为字典列表
    url = scrapy.Field(output_processor=TakeFirst())
    crawled_at = scrapy.Field()

解读

  • clean_price 函数负责把“¥1,299.00”这类字符串变成可计算的浮点数。
  • input_processoroutput_processor 定义了数据进出字段时的自动处理规则。
  • category_path 那样,多个同名字段的值会被自动拼接成字符串,方便后续存储。

有了数据蓝图,我们就可以开始编写爬虫逻辑了。

核心功能实现

主爬虫框架

主爬虫是整个系统的“调度中心”,它负责:

  1. 从起始URL拿到所有分类链接;
  2. 对每个分类,请求其商品列表页;
  3. 在列表页中抓取商品详情链接,并交给详情解析方法;
  4. 控制翻页,确保不会无限循环。
# ecommerce_spider/spiders/ecommerce_spider.py
import scrapy
from urllib.parse import urljoin
from ecommerce_spider.items import ProductItem
from datetime import datetime

class EcommerceSpider(scrapy.Spider):
    name = 'ecommerce'
    custom_settings = {
        'DOWNLOAD_DELAY': 2,       # 基础下载延迟,视网站情况调整
        'CONCURRENT_REQUESTS': 8,  # 全局并发请求数
    }

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        # 允许通过命令行传入起始URL和最多翻页数
        self.start_urls = kwargs.get('start_urls', ['https://example.com/categories'])
        self.max_pages = int(kwargs.get('max_pages', 100))

    def start_requests(self):
        for url in self.start_urls:
            yield scrapy.Request(url, callback=self.parse_categories)

    def parse_categories(self, response):
        """解析分类页,提取所有子分类的链接"""
        category_links = response.css('a.category-link::attr(href)').getall()
        for link in category_links:
            category_url = urljoin(response.url, link)
            yield scrapy.Request(category_url, callback=self.parse_product_list)

    def parse_product_list(self, response):
        """解析商品列表页:提取商品链接,并找到下一页"""
        product_links = response.css('a.product-link::attr(href)').getall()
        for link in product_links:
            product_url = urljoin(response.url, link)
            yield scrapy.Request(
                product_url,
                callback=self.parse_product_detail,
                # 可以传递一些上下文信息,比如分类名
                meta={'category': response.meta.get('category')}
            )

        # 处理翻页
        current_page = response.meta.get('page', 1)
        next_page = response.css('a.next::attr(href)').get()
        if next_page and current_page < self.max_pages:
            next_url = urljoin(response.url, next_page)
            yield scrapy.Request(
                next_url,
                callback=self.parse_product_list,
                meta={'page': current_page + 1}
            )

    def parse_product_detail(self, response):
        """解析商品详情页,构造 ProductItem"""
        item = ProductItem()
        item['product_id'] = self._extract_product_id(response)
        item['title'] = response.css('h1::text').get().strip()
        item['current_price'] = response.css('.price::text').get()
        item['main_image'] = urljoin(
            response.url,
            response.css('.main-image img::attr(src)').get()
        )
        item['url'] = response.url
        item['crawled_at'] = datetime.now().isoformat()
        yield item

    def _extract_product_id(self, response):
        """从URL或页面元素解析商品ID"""
        match = re.search(r'/(\d+)/?$', response.url)
        return match.group(1) if match else 'unknown'

💡 提示:如果在某个步骤中需要灵活传递上下文(比如分类名称),可以在meta字典中添加相应字段,后续方法通过response.meta取出即可。

多级分类解析

电商网站经常会用“面包屑导航”显示用户当前所在的分类层级。我们可以从这些元素中提取出完整的分类路径,用于后续数据分析。

# ecommerce_spider/utils/category_parser.py
from urllib.parse import urljoin

class CategoryParser:
    def parse_hierarchy(self, response):
        """从面包屑导航中解析分类层级"""
        breadcrumbs = response.css('.breadcrumb a')
        hierarchy = []
        for i, crumb in enumerate(breadcrumbs):
            name = crumb.css('::text').get().strip()
            url = urljoin(response.url, crumb.css('::attr(href)').get())
            hierarchy.append({
                'name': name,
                'url': url,
                'level': i      # 0表示顶级分类
            })
        return hierarchy

用法:在parse_product_detail中调用该工具,将返回的路径信息存入item['category_path'](或单独字段),方便后续分组分析。

智能翻页处理

翻页逻辑不能只依赖一个“下一页”按钮。有些网站没有明确的下一页链接,就需要从当前URL中解析页码并构建下一页URL。下面的工具类兼顾了这两种情况:

# ecommerce_spider/utils/pagination_handler.py
import re
from urllib.parse import urlparse, parse_qs, urlencode

class PaginationHandler:
    def handle(self, response, max_pages):
        """返回下一页URL,若无则返回None"""
        current_page = self._extract_current_page(response.url)
        if current_page >= max_pages:
            return None

        # 方式一:从页面中寻找 rel="next" 或 .next 的链接
        next_link = response.css('a[rel="next"]::attr(href), a.next::attr(href)').get()
        if next_link:
            return urljoin(response.url, next_link)

        # 方式二:通过URL参数自己生成
        return self._generate_next_url(response.url, current_page)

    def _extract_current_page(self, url):
        parsed = urlparse(url)
        query = parse_qs(parsed.query)
        if 'page' in query:
            return int(query['page'][0])
        # 也可能页码在路径中,比如 /page/3/
        match = re.search(r'/page/(\d+)', url)
        return int(match.group(1)) if match else 1

    def _generate_next_url(self, url, current_page):
        parsed = urlparse(url)
        query = parse_qs(parsed.query)
        query['page'] = [current_page + 1]
        new_query = urlencode(query, doseq=True)
        return parsed._replace(query=new_query).geturl()

将这个工具集成到parse_product_list中,替换原来简单的.next查找,可以大幅提高翻页的健壮性。

反爬虫对抗策略

前面说到的所有逻辑,只有在请求能够成功返回的前提下才有意义。因此我们需要通过中间件武装我们的爬虫。

反爬虫中间件

这个中间件会在每个请求发出之前,随机更换 User‑Agent 和一些常见的请求头,同时加入一个随机延迟,让爬虫的行为更像普通浏览器。

# ecommerce_spider/middlewares/anti_crawler_middleware.py
import random
import time
from fake_useragent import UserAgent

class AntiCrawlerMiddleware:
    def __init__(self):
        self.ua = UserAgent()

    def process_request(self, request, spider):
        # 随机User-Agent
        request.headers['User-Agent'] = self.ua.random
        # 模拟浏览器的 Accept 头部
        request.headers['Accept'] = (
            'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
        )
        request.headers['Accept-Language'] = 'zh-CN,zh;q=0.9,en;q=0.8'
        # 微小随机延迟(注意:这里会阻塞请求,生产环境建议用 Downloader 中间件延迟)
        time.sleep(random.uniform(1, 3))
        return None

代理中间件

对于IP容易被封的网站,更换代理是最直接的应对手段。下面是一个极简的轮询代理实现:

# ecommerce_spider/middlewares/proxy_middleware.py
import random

class ProxyMiddleware:
    def __init__(self):
        self.proxies = [
            'http://proxy1:port',
            'http://proxy2:port',
        ]

    def process_request(self, request, spider):
        if self.proxies:
            request.meta['proxy'] = random.choice(self.proxies)
        return None

注意

  • 实际项目中,代理列表可能来自文件或付费代理API,你可以动态更新self.proxies
  • 该中间件在请求发出前设置request.meta['proxy'],Scrapy 会自动使用该代理进行下载。
  • 需要配合DOWNLOADER_MIDDLEWARESsettings.py中启用这些中间件。

数据去重与清洗

爬取过程中难免会出现重复数据,或者某次抓取返回了空白/无效信息。通过管道(Pipeline)可以集中处理这些问题。

数据清洗管道

利用之前定义的 ItemLoaderProductItem,管道可以自动应用价格、标题等字段的清洗规则,同时剔除只有部分字段的“垃圾”Item。

# ecommerce_spider/pipelines/cleaning_pipeline.py
from ecommerce_spider.items import ProductItem
from itemloaders import ItemLoader

class CleaningPipeline:
    def process_item(self, item, spider):
        # 如果是用 ItemLoader 生成的 item 可以直接返回
        # 这里演示手动校验
        loader = ItemLoader(item=ProductItem(), response=item.get('response'))
        title = item.get('title', '')
        loader.add_value('title', title.strip())

        price = item.get('current_price')
        loader.add_value('current_price', price)

        # 使用 loader 加载完成后,获取处理过的字段
        cleaned_item = loader.load_item()

        # 验证必需字段是否存在且有效
        if not cleaned_item.get('title') or not cleaned_item.get('current_price'):
            spider.logger.warning(
                f"Missing required fields for item: {item.get('url')}"
            )
            # 返回 None 表示丢弃这个 item
            return None
        return cleaned_item

去重管道

最简单的去重就是在管道里维护一个已处理标识的集合。对于商品,product_id+url通常能唯一确定一条记录。

# ecommerce_spider/pipelines/deduplication_pipeline.py
class DeduplicationPipeline:
    def __init__(self):
        self.seen = set()

    def process_item(self, item, spider):
        identifier = f"{item.get('product_id')}_{item.get('url')}"
        if identifier in self.seen:
            spider.logger.info(f"Duplicate item: {item.get('title')}")
            return None
        self.seen.add(identifier)
        return item

性能优化与部署

性能优化配置

settings.py中,合理调整并发与延迟可以成倍提升爬取速度,同时避免触发反爬。

# ecommerce_spider/settings.py
# 全局并发请求数
CONCURRENT_REQUESTS = 32
# 对单个域名的并发请求数
CONCURRENT_REQUESTS_PER_DOMAIN = 8
# 下载延迟(秒),建议设为 1 或稍大
DOWNLOAD_DELAY = 1
# 在延迟基础上加入随机浮动 (0.5 * DOWNLOAD_DELAY)
RANDOMIZE_DOWNLOAD_DELAY = 0.5
# 启用自动限速,根据延迟和并发自动调整
AUTOTHROTTLE_ENABLED = True
AUTOTHROTTLE_TARGET_CONCURRENCY = 4.0

含义AUTOTHROTTLE 会根据实际的响应时间动态调整请求速率,让你的爬虫既能跑得快,又不会把网站压垮。

Docker 部署

将整个项目打包成Docker镜像,可以省去环境配置的麻烦,也方便在服务器上横向扩展。

# Dockerfile
FROM python:3.9-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["scrapy", "crawl", "ecommerce"]
# docker-compose.yml
version: '3.8'
services:
  spider:
    build: .
    volumes:
      - ./output:/app/output   # 将输出目录挂载出来
  mongodb:
    image: mongo:4.4
    ports:
      - "27017:27017"
    volumes:
      - mongodb_data:/data/db
volumes:
  mongodb_data:

部署时只需在目录下执行:

docker-compose up -d

爬取的数据既可以存入MongoDB,也可以通过修改pipelines保存为文件。


至此,你已完整构建了一个可用的电商全站爬取系统。从理解网站结构,到设计数据模型,再到编写爬虫逻辑、反爬对抗、数据清洗和最终部署——每个环节都经过了实战打磨。把这个框架套用到不同的电商网站上,只需调整CSS选择器和部分规则,就能快速完成数据采集任务。开始你的爬虫之旅吧!