破解分页与反爬:淘宝商品详情 API 采集开发高级实战

190 阅读9分钟

在电商数据采集领域,淘宝作为国内最大的电商平台之一,其数据采集面临着复杂的反爬机制和分页限制。本文将深入探讨如何通过技术手段突破这些限制,实现淘宝商品详情数据的高效采集。

1. 淘宝反爬机制分析

淘宝的反爬体系主要包括以下几个方面:

  1. 请求签名验证:所有 API 请求需要通过特定算法生成签名
  2. 频率限制:单 IP 请求频率超过阈值会触发验证码或封禁
  3. 设备指纹:分析请求头信息识别异常请求
  4. Cookie/Session 管理:需要维护有效的会话状态
  5. 动态页面渲染:部分数据通过 JavaScript 动态加载

2. 分页采集策略

淘宝商品列表通常采用分页展示,每页限制 20-40 条记录。高级分页策略包括:

  1. 多维度分页:结合时间、分类、销量等维度
  2. 智能断点续传:记录已采集页码,支持中断后继续
  3. 自适应分页大小:根据 API 限制动态调整每页请求量
  4. 异步并发采集:使用协程或多线程提高采集效率

3. 核心代码实现

以下是一个完整的淘宝商品详情采集解决方案,包含反爬处理和分页策略:

import requests
import time
import random
import hashlib
import json
import logging
import base64
import re
from urllib.parse import urlencode
from concurrent.futures import ThreadPoolExecutor, as_completed
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type

# 配置日志
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)

class TaobaoSpider:
    def __init__(self):
        # 基础配置
        self.session = requests.Session()
        self.ua_list = [
            "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
            "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36",
            "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:90.0) Gecko/20100101 Firefox/90.0",
            "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.2 Safari/605.1.15"
        ]
        self.headers = {
            "User-Agent": random.choice(self.ua_list),
            "Accept": "application/json, text/plain, */*",
            "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
            "Connection": "keep-alive",
        }
        
        # 反爬配置
        self.cookies = {}
        self.token = None
        self.x_csrf_token = None
        self.proxy_pool = []  # 代理IP池
        self.current_proxy = None
        
        # 分页配置
        self.page_size = 20  # 默认每页20条
        self.max_retries = 3  # 最大重试次数
        self.delay = (1, 3)  # 请求间隔范围(秒)
        
    def load_proxy_pool(self, proxy_file="proxies.txt"):
        """加载代理IP池"""
        try:
            with open(proxy_file, "r") as f:
                self.proxy_pool = [line.strip() for line in f.readlines() if line.strip()]
            logger.info(f"成功加载 {len(self.proxy_pool)} 个代理IP")
        except Exception as e:
            logger.error(f"加载代理失败: {e}")
    
    def get_random_proxy(self):
        """获取随机代理"""
        if not self.proxy_pool:
            return None
        return random.choice(self.proxy_pool)
    
    def rotate_proxy(self):
        """切换代理"""
        self.current_proxy = self.get_random_proxy()
        logger.info(f"切换代理: {self.current_proxy}")
        return self.current_proxy
    
    def generate_sign(self, data, timestamp, token):
        """生成请求签名"""
        # 淘宝签名算法通常涉及token、时间戳和请求参数
        # 这里是简化示例,实际需要逆向分析淘宝JS代码
        sign_str = f"{token}{timestamp}{json.dumps(data)}"
        return hashlib.md5(sign_str.encode()).hexdigest()
    
    def update_headers(self):
        """更新请求头,添加必要信息"""
        self.headers["User-Agent"] = random.choice(self.ua_list)
        if self.x_csrf_token:
            self.headers["x-csrf-token"] = self.x_csrf_token
        if self.cookies:
            self.session.cookies.update(self.cookies)
    
    def handle_captcha(self, response):
        """处理验证码"""
        # 检测是否需要验证码
        if "verify" in response.url or "captcha" in response.text:
            logger.warning("触发验证码,需要人工处理或使用打码平台")
            # 这里可以集成打码平台API
            return False
        return True
    
    @retry(
        stop=stop_after_attempt(3),
        wait=wait_exponential(multiplier=1, min=2, max=10),
        retry=retry_if_exception_type((requests.exceptions.RequestException,))
    )
    def send_request(self, method, url, params=None, data=None, headers=None):
        """发送请求,带重试和代理机制"""
        if not headers:
            headers = self.headers
        
        # 使用代理
        proxies = None
        if self.current_proxy:
            proxies = {
                "http": f"http://{self.current_proxy}",
                "https": f"https://{self.current_proxy}"
            }
        
        # 添加随机延迟,避免请求过于频繁
        delay = random.uniform(*self.delay)
        logger.debug(f"等待 {delay:.2f} 秒后发送请求")
        time.sleep(delay)
        
        try:
            response = self.session.request(
                method, url, params=params, data=data, 
                headers=headers, proxies=proxies, timeout=15
            )
            
            # 检查状态码
            response.raise_for_status()
            
            # 处理验证码
            if not self.handle_captcha(response):
                # 切换代理并重试
                self.rotate_proxy()
                raise requests.exceptions.RequestException("需要验证码")
            
            # 更新cookies
            self.cookies.update(dict(response.cookies))
            
            return response
        
        except requests.exceptions.RequestException as e:
            logger.error(f"请求异常: {e},尝试切换代理")
            self.rotate_proxy()
            raise  # 重新抛出异常,触发重试
    
    def login(self, username, password):
        """模拟登录获取有效会话"""
        logger.info("开始登录淘宝...")
        
        # 访问登录页面,获取初始cookies
        login_page_url = "https://login.taobao.com/member/login.jhtml"
        self.send_request("GET", login_page_url)
        
        # 登录API
        login_api = "https://login.taobao.com/member/loginByPassword.jhtml"
        data = {
            "TPL_username": username,
            "TPL_password": password,
            # 实际登录需要处理加密和验证码
        }
        
        response = self.send_request("POST", login_api, data=data)
        
        if "登录成功" in response.text:
            logger.info("登录成功")
            # 提取token等信息
            self.token = self.extract_token(response.text)
            self.x_csrf_token = self.extract_csrf_token(response.text)
            return True
        else:
            logger.error("登录失败")
            return False
    
    def extract_token(self, html):
        """从HTML中提取token"""
        # 实际需要根据淘宝页面结构调整
        match = re.search(r'"token":"(.*?)"', html)
        return match.group(1) if match else None
    
    def extract_csrf_token(self, html):
        """从HTML中提取CSRF token"""
        match = re.search(r'name="_csrf_token" value="(.*?)"', html)
        return match.group(1) if match else None
    
    def get_item_detail(self, item_id):
        """获取商品详情"""
        logger.info(f"获取商品详情: {item_id}")
        
        # 构建商品详情API URL
        url = f"https://h5api.m.taobao.com/h5/mtop.taobao.detail.getdetail/6.0/"
        
        # 构建请求参数
        data = {
            "itemNumId": item_id,
            "detail_v": "3.1.1",
            "ut_sk": "xx",  # 用户token
            "type": "jsonp",
            "callback": "mtopjsonp1"
        }
        
        # 生成时间戳和签名
        timestamp = int(time.time() * 1000)
        sign = self.generate_sign(data, timestamp, self.token)
        
        # 构建请求参数
        params = {
            "jsv": "2.6.1",
            "appKey": "12574478",
            "t": timestamp,
            "sign": sign,
            "api": "mtop.taobao.detail.getdetail",
            "v": "6.0",
            "dataType": "jsonp",
            "callback": "mtopjsonp1",
            "data": json.dumps(data)
        }
        
        # 发送请求
        response = self.send_request("GET", url, params=params)
        
        # 解析JSONP响应
        json_str = response.text
        json_str = re.search(r'mtopjsonp1((.*))', json_str).group(1)
        try:
            result = json.loads(json_str)
            return result.get("data", {})
        except json.JSONDecodeError as e:
            logger.error(f"解析商品详情失败: {e}")
            return None
    
    def get_item_list(self, keyword, page=1):
        """获取商品列表(分页)"""
        logger.info(f"获取商品列表: 关键词={keyword}, 页码={page}")
        
        # 淘宝搜索API
        url = "https://s.taobao.com/search"
        
        # 构建请求参数
        params = {
            "q": keyword,
            "s": (page - 1) * self.page_size,  # 偏移量
            "ie": "utf8",
            "ajax": "true"
        }
        
        # 发送请求
        response = self.send_request("GET", url, params=params)
        
        try:
            result = response.json()
            items = result.get("mods", {}).get("itemlist", {}).get("data", {}).get("auctions", [])
            return items
        except json.JSONDecodeError:
            # 可能需要处理HTML格式响应
            items = self.parse_item_list_html(response.text)
            return items
    
    def parse_item_list_html(self, html):
        """解析HTML格式的商品列表"""
        # 使用正则表达式或BeautifulSoup解析商品信息
        items = []
        # 这里是简化示例,实际需要根据淘宝页面结构调整
        item_pattern = re.compile(r'"nid":"(.*?)","title":"(.*?)","price":"(.*?)"')
        for match in item_pattern.finditer(html):
            item_id, title, price = match.groups()
            items.append({
                "item_id": item_id,
                "title": title,
                "price": price
            })
        return items
    
    def crawl_items_by_keyword(self, keyword, max_pages=10):
        """根据关键词采集商品信息"""
        all_items = []
        
        for page in range(1, max_pages + 1):
            try:
                items = self.get_item_list(keyword, page)
                if not items:
                    logger.info(f"第 {page} 页没有商品,结束采集")
                    break
                
                all_items.extend(items)
                logger.info(f"成功获取第 {page} 页,共 {len(items)} 个商品")
                
                # 处理每个商品详情(异步)
                self.process_items_concurrently(items)
                
                # 随机延迟,避免被反爬
                time.sleep(random.uniform(2, 5))
                
            except Exception as e:
                logger.error(f"采集第 {page} 页失败: {e}")
                # 发生异常时适当延长等待时间
                time.sleep(random.uniform(5, 10))
        
        return all_items
    
    def process_items_concurrently(self, items, max_workers=5):
        """并发处理商品详情"""
        with ThreadPoolExecutor(max_workers=max_workers) as executor:
            futures = []
            
            for item in items:
                item_id = item.get("nid") or item.get("item_id")
                if item_id:
                    futures.append(executor.submit(self.process_item_detail, item_id))
            
            # 等待所有任务完成
            for future in as_completed(futures):
                try:
                    future.result()
                except Exception as e:
                    logger.error(f"处理商品详情失败: {e}")
    
    def process_item_detail(self, item_id):
        """处理单个商品详情"""
        detail = self.get_item_detail(item_id)
        if detail:
            # 保存商品详情到数据库
            self.save_item_detail(detail)
            return True
        return False
    
    def save_item_detail(self, detail):
        """保存商品详情到数据库"""
        # 这里应该实现数据库存储逻辑
        # 简化示例,打印商品标题和价格
        item_info = detail.get("itemInfoModel", {})
        logger.info(f"保存商品: {item_info.get('title')} - {item_info.get('price')}")
        
        # 实际应用中,这里应该将数据存入数据库
        # 例如:
        # with self.db_connection.cursor() as cursor:
        #     sql = "INSERT INTO items VALUES (%s, %s, %s)"
        #     cursor.execute(sql, (item_id, title, price))
        # self.db_connection.commit()


# 使用示例
if __name__ == "__main__":
    spider = TaobaoSpider()
    
    # 加载代理池(如果有)
    spider.load_proxy_pool()
    
    # 可选:登录获取更完整的数据
    # spider.login("your_username", "your_password")
    
    # 开始采集
    keyword = "手机"
    spider.crawl_items_by_keyword(keyword, max_pages=5)

 

4. 高级反爬突破技巧

  1. Cookie/Session 管理

    • 维护长期有效的会话状态
    • 定期刷新 Cookie,避免过期
    • 分析 Cookie 生成规则,模拟生成
  2. 请求头定制

    • 随机更换 User-Agent,模拟不同浏览器
    • 添加必要的请求头字段(如 Referer、Accept 等)
    • 分析请求头指纹,确保一致性
  3. 动态内容处理

    • 使用 Selenium/WebDriver 处理 JavaScript 渲染内容
    • 逆向分析 JS 代码,提取数据加载逻辑
    • 直接调用 AJAX 接口获取数据
  4. 分布式采集

    • 使用代理 IP 池分散请求源
    • 部署多节点采集系统,分担压力
    • 实现任务队列,均衡负载

5. 分页优化策略

  1. 智能分页控制
def smart_pagination(self, keyword):
    """智能分页策略,自动检测结束条件"""
    page = 1
    while True:
        items = self.get_item_list(keyword, page)
        if not items:
            break
            
        # 处理商品
        self.process_items_concurrently(items)
        
        # 检查是否有下一页
        if not self.has_next_page(items):
            break
            
        page += 1
        # 控制采集频率
        time.sleep(random.uniform(2, 5))

 2.断点续传实现

def resume_crawling(self, keyword, last_page=1):
    """从指定页码开始继续采集"""
    logger.info(f"从第 {last_page} 页开始继续采集")
    for page in range(last_page, 100):  # 设置最大页数限制
        items = self.get_item_list(keyword, page)
        if not items:
            break
            
        # 处理商品
        self.process_items_concurrently(items)
        
        # 记录当前页码
        self.save_crawler_state(keyword, page)
        
        # 控制采集频率
        time.sleep(random.uniform(2, 5))

 

6. 数据存储与分析

  1. 数据库设计
CREATE TABLE `taobao_items` (
    `id` BIGINT PRIMARY KEY,
    `title` TEXT NOT NULL,
    `price` DECIMAL(10, 2) NOT NULL,
    `original_price` DECIMAL(10, 2),
    `sales_volume` INT,
    `seller_id` VARCHAR(50),
    `category_id` VARCHAR(50),
    `brand` VARCHAR(100),
    `keywords` VARCHAR(255),
    `crawl_time` DATETIME DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE `taobao_item_details` (
    `item_id` BIGINT PRIMARY KEY,
    `description` TEXT,
    `specifications` JSON,
    `images` JSON,
    `reviews_count` INT,
    `good_review_rate` DECIMAL(5, 2),
    `attributes` JSON,
    `crawl_time` DATETIME DEFAULT CURRENT_TIMESTAMP
);

 2.数据分析示例

def analyze_prices(self, items):
    """分析商品价格分布"""
    prices = [float(item.get("price", 0)) for item in items if item.get("price")]
    if not prices:
        return
        
    avg_price = sum(prices) / len(prices)
    min_price = min(prices)
    max_price = max(prices)
    
    logger.info(f"价格分析: 平均={avg_price:.2f}, 最低={min_price:.2f}, 最高={max_price:.2f}")
    
    # 可以进一步分析价格区间分布
    price_ranges = {
        "0-100": 0, "100-500": 0, "500-1000": 0, 
        "1000-2000": 0, "2000+": 0
    }
    
    for price in prices:
        if price < 100:
            price_ranges["0-100"] += 1
        elif price < 500:
            price_ranges["100-500"] += 1
        elif price < 1000:
            price_ranges["500-1000"] += 1
        elif price < 2000:
            price_ranges["1000-2000"] += 1
        else:
            price_ranges["2000+"] += 1
            
    logger.info(f"价格区间分布: {price_ranges}")

 

7. 部署与运维建议

  1. 配置环境变量
# .env 文件示例
PROXY_ENABLED=True
PROXY_FILE=proxies.txt
LOG_LEVEL=INFO
MAX_WORKERS=5
DELAY_MIN=1
DELAY_MAX=3

 2.异常监控与告警

def setup_monitoring(self):
    """设置监控和告警"""
    # 集成Sentry监控异常
    import sentry_sdk
    sentry_sdk.init(
        dsn="https://your-sentry-dsn@example.com/1",
        traces_sample_rate=1.0,
    )
    
    # 设置邮件告警
    self.alert_email = "admin@example.com"

 

3.性能优化

  • 使用异步请求库(如 aiohttp)替代 requests
  • 实现连接池复用 HTTP 连接
  • 优化数据库插入操作,使用批量插入

8. 合规与伦理考量

  1. 遵守 robots.txt 规则
  2. 控制采集频率,避免影响目标网站性能
  3. 仅用于合法的数据分析和研究目的
  4. 妥善保护采集到的数据,避免泄露

总结

通过本文介绍的技术和策略,开发者可以有效突破淘宝的反爬机制和分页限制,实现高效、稳定的商品数据采集。关键要点包括:

  1. 深入分析目标网站的反爬机制
  2. 实现智能的分页采集策略
  3. 构建健壮的反爬应对体系
  4. 合理设计数据存储与分析方案
  5. 确保采集行为的合规性

实际应用中,应根据淘宝网站的更新及时调整采集策略,保持代码的灵活性和可维护性。通过持续优化,可以构建出一套稳定、高效的电商数据采集系统。