Python爬虫实战:构建 Steam 游戏数据库:requests+lxml 实战游戏列表采集与价格监控(附JSON导出 + SQLite持久化存储)!

㊙️本期内容已收录至专栏《Python爬虫实战》,持续完善知识体系与项目实战,建议先订阅收藏,后续查阅更方便~持续更新中!

㊗️爬虫难度指数:⭐⭐

🚫声明:本数据&代码仅供学习交流,严禁用于商业用途、倒卖数据或违反目标站点的服务条款等,一切后果皆由使用者本人承担。公开榜单数据一般允许访问,但请务必遵守"君子协议",技术无罪,责任在人。

全文目录:

    • [🌟 开篇语](#🌟 开篇语)
    • [1️⃣ 摘要(Abstract)](#1️⃣ 摘要(Abstract))
    • [2️⃣ 背景与需求(Why)](#2️⃣ 背景与需求(Why))
    • [3️⃣ 合规与注意事项(必写)](#3️⃣ 合规与注意事项(必写))
    • [4️⃣ 技术选型与整体流程(What/How)](#4️⃣ 技术选型与整体流程(What/How))
    • [5️⃣ 环境准备与依赖安装(可复现)](#5️⃣ 环境准备与依赖安装(可复现))
    • [6️⃣ 核心实现:请求层(Fetcher)](#6️⃣ 核心实现:请求层(Fetcher))
      • 代码实现(fetcher.py)
      • 关键技术点详解
        • [1. Session 复用的优势](#1. Session 复用的优势)
        • [2. 自动重试机制](#2. 自动重试机制)
        • [3. 随机抖动(Jitter)](#3. 随机抖动(Jitter))
        • [4. Cookie 管理](#4. Cookie 管理)
    • [7️⃣ 核心实现:解析层(Parser)](#7️⃣ 核心实现:解析层(Parser))
    • [8️⃣ 数据清洗层(Cleaner)](#8️⃣ 数据清洗层(Cleaner))
    • [9️⃣ 数据存储与导出(Storage)](#9️⃣ 数据存储与导出(Storage))
    • [🔟 运行方式与结果展示(必写)](#🔟 运行方式与结果展示(必写))
    • [1️⃣1️⃣ 常见问题与排错(强烈建议写)](#1️⃣1️⃣ 常见问题与排错(强烈建议写))
      • [问题1:403 Forbidden - Cookie验证失败](#问题1:403 Forbidden - Cookie验证失败)
      • [问题2:解析结果为空 - HTML结构变化](#问题2:解析结果为空 - HTML结构变化)
      • [问题3:价格解析错误 - 特殊格式](#问题3:价格解析错误 - 特殊格式)
      • [问题4:数据库锁定 - 并发写入冲突](#问题4:数据库锁定 - 并发写入冲突)
      • 问题5:内存占用过高
      • [问题6:IP被封 - 请求过于频繁](#问题6:IP被封 - 请求过于频繁)
    • [1️⃣2️⃣ 进阶优化(可选但加分)](#1️⃣2️⃣ 进阶优化(可选但加分))
    • [1️⃣3️⃣ 总结与延伸阅读](#1️⃣3️⃣ 总结与延伸阅读)
    • 附录:目录结构*:
    • [🌟 文末](#🌟 文末)
      • [📌 专栏持续更新中|建议收藏 + 订阅](#📌 专栏持续更新中|建议收藏 + 订阅)
      • [✅ 互动征集](#✅ 互动征集)

🌟 开篇语

哈喽,各位小伙伴们你们好呀~我是【喵手】。

运营社区: C站 / 掘金 / 腾讯云 / 阿里云 / 华为云 / 51CTO

欢迎大家常来逛逛,一起学习,一起进步~🌟

  我长期专注 Python 爬虫工程化实战 ,主理专栏 《Python爬虫实战》:从采集策略 到反爬对抗 ,从数据清洗 到分布式调度 ,持续输出可复用的方法论与可落地案例。内容主打一个"能跑、能用、能扩展 ",让数据价值真正做到------抓得到、洗得净、用得上。

  📌 专栏食用指南(建议收藏)

  • ✅ 入门基础:环境搭建 / 请求与解析 / 数据落库
  • ✅ 进阶提升:登录鉴权 / 动态渲染 / 反爬对抗
  • ✅ 工程实战:异步并发 / 分布式调度 / 监控与容错
  • ✅ 项目落地:数据治理 / 可视化分析 / 场景化应用

📣 专栏推广时间 :如果你想系统学爬虫,而不是碎片化东拼西凑,欢迎订阅/关注专栏👉《Python爬虫实战》👈

  
💕订阅后更新会优先推送,按目录学习更高效💯~

1️⃣ 摘要(Abstract)

一句话概括:使用 Python requests + lxml 爬取 Steam 商店的游戏列表数据(游戏名称、价格、标签、评分、发行日期等),最终输出为结构化的 SQLite 数据库 + JSON/CSV 文件,支持价格监控、游戏推荐、市场分析等应用场景。

读完本文你将获得:

  • 掌握电商类网站(动态分页、Ajax 加载)的采集技巧
  • 学会处理复杂的价格数据(原价、折扣价、货币转换、地区差异)
  • 获得一套生产级的游戏数据采集系统(支持增量更新、价格追踪、历史对比)
  • 理解反爬虫机制(频率限制、Cookie 验证、地区限制)及应对策略

2️⃣ 背景与需求(Why)

为什么要爬 Steam 游戏数据?

作为全球最大的 PC 游戏分发平台,Steam 拥有超过 50,000 款游戏。但官方并未提供完整的数据导出功能,这给玩家和开发者带来诸多不便。通过爬虫采集数据后,我们可以:

  • 价格监控:追踪心愿单游戏的历史最低价,在打折时自动提醒
  • 市场分析:统计各类型游戏的价格分布、折扣力度、发行趋势
  • 游戏推荐:基于标签、评分、用户评价构建推荐系统
  • 数据可视化:分析 Steam 生态(独立游戏占比、地区定价差异、季节性促销规律)
  • 个人项目:开发价格追踪工具、游戏库管理器、折扣日历等

为什么选择静态部分?

Steam 网站采用混合渲染:

  • 静态部分:游戏列表页、分类页面(服务端渲染 HTML)
  • 动态部分:游戏详情页的评论、社区内容(JavaScript 异步加载)

本文聚焦于静态可抓部分,原因如下:

  1. 效率更高:无需启动浏览器,直接解析 HTML 即可
  2. 稳定性强:不依赖 JavaScript 执行,不易被前端更新影响
  3. 资源消耗少:单机就能跑,不需要大量代理和计算资源

对于需要动态内容的场景(如评论数、在线人数),可以后续补充 Selenium/Playwright 方案或抓包找 API。

目标字段清单

字段名 说明 示例值 获取难度
app_id Steam 应用 ID(唯一标识) "730" (CS:GO) ⭐ 简单
game_name 游戏名称 "Counter-Strike: Global Offensive" ⭐ 简单
original_price 原价 "¥ 98" ⭐⭐ 中等
discount_price 折扣价 "¥ 49" ⭐⭐ 中等
discount_percent 折扣百分比 "-50%" ⭐⭐ 中等
tags 游戏标签 "FPS", "多人", "竞技" ⭐⭐⭐ 困难
release_date 发行日期 "2012年8月21日" ⭐⭐ 中等
review_score 评分等级 "特别好评" ⭐⭐ 中等
review_count 评价数量 "1,234,567" ⭐⭐ 中等
thumbnail_url 缩略图链接 "https://..." ⭐ 简单
category 游戏分类 "动作" / "冒险" ⭐ 简单

扩展字段(可选):

  • 开发商/发行商
  • 支持的语言
  • 系统要求(Windows/Mac/Linux)
  • 是否支持手柄
  • 多人游戏模式(本地/在线)

3️⃣ 合规与注意事项(必写)

Steam 的 robots.txt 规则

访问 https://store.steampowered.com/robots.txt 可以看到:

json 复制代码
User-agent: *
Disallow: /search/
Disallow: /account/
Disallow: /cart/
Allow: /app/
Allow: /browse/

解读:

  • ✅ 允许爬取 :游戏详情页(/app/)、浏览页面(/browse/)
  • ❌ 禁止爬取 :搜索结果(/search/)、账户页面(/account/)、购物车

因此,我们的策略是:

  • 主要爬取 :分类浏览页面(如 https://store.steampowered.com/genre/Action/)
  • 补充爬取 :游戏详情页(如 https://store.steampowered.com/app/730/)
  • 避免爬取:搜索结果、用户账户相关页面

频率控制与反爬策略

Steam 的反爬机制相对温和,但仍需注意:

  1. 请求频率:

    • 建议间隔 2-3 秒(不要低于 1 秒)
    • 高峰期(美国晚间)适当增加间隔
    • 使用 Session 复用 TCP 连接
  2. Cookie 要求:

    • Steam 会检查 birthtime Cookie(年龄验证)
    • 部分地区需要 steamCountry Cookie(地区选择)
    • 建议手动在浏览器登录一次,导出 Cookie 使用
  3. User-Agent:

    • 使用真实浏览器的 UA
    • 避免使用默认的 python-requests/2.x.x
    • 可以随机轮换多个 UA
  4. 地区限制:

    • 不同地区价格不同(中国区、美区、欧区)
    • 需要设置 cc=CN 参数或相应 Cookie
    • 汇率换算需要实时数据

数据使用边界

  • ✅ 允许:

    • 个人学习、数据分析、价格监控
    • 开发非商业性工具(如价格追踪器)
    • 学术研究(市场分析、用户行为研究)
  • ❌ 禁止:

    • 商业转售游戏数据(如打包成付费 API)
    • 批量下载游戏封面/视频用于分发
    • 恶意爬取导致服务器过载
    • 绕过付费墙或地区限制获取游戏

法律风险提示

  • 版权问题:游戏名称、封面图属于知识产权,仅用于展示和信息传递
  • 服务条款:Steam 服务条款禁止"自动化访问"用于商业目的
  • 灰色地带:个人使用、学术研究通常不会被追究,但需低调行事

建议:

  • 爬取数据仅供个人使用,不公开分发
  • 如需商业化,使用 Steam 官方 API(需申请密钥)
  • 尊重 Steam 服务器负载,避免高频请求

4️⃣ 技术选型与整体流程(What/How)

静态 vs 动态 vs API

方案对比:

方案 优点 缺点 适用场景
官方 API 稳定、合法、数据完整 需要申请、有配额限制 商业应用
静态 HTML 解析 速度快、资源消耗低 部分数据缺失 本文方案
隐藏 API 抓包 数据结构化、易解析 接口可能变化、需逆向 进阶方案
Selenium 动态渲染 获取所有内容 速度慢、资源消耗大 动态内容必需时

本文选择 :静态 HTML 解析 + 部分 API 补充

原因:

  1. Steam 的游戏列表页是服务端渲染的(查看源代码即可看到完整数据)
  2. 价格、标签等信息都在 HTML 中,无需 JavaScript 执行
  3. 部分字段(如详细评分)可以通过抓包找到的隐藏 API 获取

整体流程设计

json 复制代码
[分类列表页] → 获取所有分类/标签
↓
[游戏列表页] → 分页抓取每个分类的游戏列表
↓
解析基本信息(名称、价格、标签)
↓
[游戏详情页] → 补充详细信息(可选)
↓
数据清洗(价格标准化、日期解析、去重)
↓
存储到 SQLite + 导出 JSON/CSV
↓
价格历史追踪(对比前一次数据)

分步说明:

  1. 步骤1:获取分类列表

    • URL:https://store.steampowered.com/genre/
    • 提取:动作、冒险、策略等大分类的链接
  2. 步骤2:遍历分类页面

    • URL 示例:https://store.steampowered.com/genre/Action/?offset=0
    • 分页逻辑:每页 25 个游戏,通过 offset 参数翻页
    • 提取:游戏 ID、名称、价格、缩略图
  3. 步骤3:解析游戏标签

    • 标签在列表页可能不完整,需访问详情页
    • 或者使用 Steam API:https://store.steampowered.com/api/appdetails?appids={app_id}
  4. 步骤4:数据清洗

    • 价格:¥ 98 → 98.0 (float)
    • 折扣:-50% → 50 (int)
    • 日期:2012年8月21日 → 2012-08-21 (ISO 8601)
  5. 步骤5:存储与导出

    • SQLite:主键去重、价格历史表
    • JSON:按分类导出
    • CSV:全量导出供 Excel 分析

技术栈选择

组件 技术选择 理由
HTTP 请求 requests + Session 轻量、支持 Cookie 和连接池
HTML 解析 lxml + XPath 速度快(比 BS4 快 5-10 倍)
数据清洗 re (正则) + python-money 价格解析专业库
数据存储 SQLite 轻量级、支持复杂查询
数据导出 pandas 方便导出 CSV/JSON
价格追踪 自定义 PriceHistory 表 记录每次爬取的价格变化

为什么不用 Scrapy?

  • Steam 的结构相对简单,不需要 Scrapy 的完整框架
  • 本文侧重教学,用 requests 更易理解
  • 后续可以轻松升级到 Scrapy(代码结构已分层)

5️⃣ 环境准备与依赖安装(可复现)

Python 版本

建议使用 Python 3.10+(本文基于 Python 3.10.8 测试通过)

依赖安装

bash 复制代码
pip install requests lxml pandas python-dateutil pytz --break-system-packages

# 可选:价格解析库(如果需要货币转换)
pip install py-moneyed babel forex-python --break-system-packages

依赖说明:

库 版本要求 用途 是否必需
requests >=2.28.0 HTTP 请求 ✅ 必需
lxml >=4.9.0 HTML 解析 ✅ 必需
pandas >=1.5.0 数据处理和导出 ✅ 必需
python-dateutil >=2.8.0 日期解析 ✅ 必需
pytz >=2022.7 时区处理 ⭐ 推荐
py-moneyed >=3.0 货币处理 ⭐ 可选
forex-python >=1.8 汇率转换 ⭐ 可选

推荐项目结构

json 复制代码
steam_crawler/
│
├── main.py                  # 主入口
├── config.py                # 配置文件(Cookie、地区、间隔等)
├── fetcher.py               # 请求层
├── parser.py                # 解析层
├── cleaner.py               # 数据清洗层
├── storage.py               # 存储层
├── price_tracker.py         # 价格追踪模块
├── requirements.txt         # 依赖清单
│
├── cookies/
│   └── steam_cookies.txt    # Cookie 文件(手动导出)
│
├── data/
│   ├── steam_games.db       # SQLite 数据库
│   ├── games_action.json    # 动作类游戏(JSON)
│   ├── games_all.csv        # 全量数据(CSV)
│   └── price_history.csv    # 价格历史记录
│
├── logs/
│   ├── crawler.log          # 运行日志
│   └── error.log            # 错误日志
│
└── tests/
    ├── test_parser.py       # 单元测试
    └── test_cleaner.py

Steam 需要 Cookie 才能正常访问,获取步骤:

  1. 打开浏览器(推荐 Chrome)

  2. 访问 https://store.steampowered.com/

  3. 选择地区和年龄(如果有弹窗)

  4. 打开开发者工具(F12)→ Application → Cookies

  5. 复制关键 Cookie:

    • birthtime: 出生时间戳(用于年龄验证)
    • steamCountry: 地区代码(如 CN / US)
    • sessionid: 会话 ID(可选)
  6. 保存到文件 cookies/steam_cookies.txt:

ini 复制代码
# cookies/steam_cookies.txt
birthtime=473385600
steamCountry=CN%7C3e8d7e1e2f8c4a5b6c7d8e9f0a1b2c3d

或者用代码读取浏览器 Cookie(使用 browser-cookie3 库):

python 复制代码
import browser_cookie3

# 自动读取 Chrome 的 Cookie
cookies = browser_cookie3.chrome(domain_name='steampowered.com')
cookie_dict = {c.name: c.value for c in cookies}

6️⃣ 核心实现:请求层(Fetcher)

代码实现(fetcher.py)

python 复制代码
"""
Steam 游戏数据采集 - 请求层

功能:
1. 发送 HTTP 请求获取 HTML/JSON
2. 管理 Cookie 和 Session
3. 自动重试和频率控制
4. 处理各种 HTTP 错误

作者:YourName
日期:2025-01-27
"""

import requests
import time
import random
import logging
from typing import Optional, Dict
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from pathlib import Path

# 配置日志
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)


class SteamFetcher:
    """Steam 网站请求器"""
    
    def __init__(self, cookie_file: str = "cookies/steam_cookies.txt"):
        """
        初始化请求器
        
        Args:
            cookie_file: Cookie 文件路径
        """
        # 创建 Session(复用 TCP 连接,提升性能)
        self.session = self._create_session()
        
        # 加载 Cookie
        self.cookies = self._load_cookies(cookie_file)
        
        # 请求头(模拟真实浏览器)
        self.headers = {
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
            'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8',
            'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
            'Accept-Encoding': 'gzip, deflate, br',
            'Connection': 'keep-alive',
            'Upgrade-Insecure-Requests': '1',
            'Cache-Control': 'max-age=0',
            'Sec-Fetch-Dest': 'document',
            'Sec-Fetch-Mode': 'navigate',
            'Sec-Fetch-Site': 'none',
            'Sec-Fetch-User': '?1',
        }
        
        # 请求配置
        self.timeout = 15  # 超时时间(秒)
        self.delay = 2.5  # 请求间隔(秒)
        self.last_request_time = 0  # 上次请求时间
        
        # 统计信息
        self.stats = {
            'total_requests': 0,
            'success': 0,
            'failed': 0,
            'retries': 0
        }
    
    def _create_session(self) -> requests.Session:
        """
        创建带重试机制的 Session
        
        Returns:
            配置好的 Session 对象
        """
        session = requests.Session()
        
        # 配置重试策略
        # 解释:当遇到网络问题或服务器错误时,自动重试
        retry_strategy = Retry(
            total=3,  # 最多重试 3 次
            backoff_factor=1,  # 重试间隔:1s, 2s, 4s(指数退避)
            status_forcelist=[429, 500, 502, 503, 504],  # 这些状态码触发重试
            allowed_methods=["HEAD", "GET", "OPTIONS"]  # 只对这些方法重试
        )
        
        # 将重试策略绑定到 Session
        adapter=retry_strategy)
        session.mount("http://", adapter)
        session.mount("https://", adapter)
        
        return session
    
    def _load_cookies(self, cookie_file: str) -> Dict:
        """
        从文件加载 Cookie
        
        Args:
            cookie_file: Cookie 文件路径
            
        Returns:
            Cookie 字典
        """
        cookies = {}
        
        if not Path(cookie_file).exists():
            logger.warning(f"⚠️ Cookie 文件不存在: {cookie_file}")
            logger.info("💡 将使用默认 Cookie(部分功能可能受限)")
            # 使用默认 Cookie(用于年龄验证)
            return {
                'birthtime': '473385600',  # 1985-01-01(成年人)
                'steamCountry': 'CN%7C3e8d7e1e2f8c4a5b6c7d8e9f0a1b2c3d'
            }
        
        # 解析 Cookie 文件
        # 格式:key=value(每行一个)
        try:
            with open(cookie_file, 'r', encoding='utf-8') as f:
                for line in f:
                    line = line.strip()
                    # 跳过空行和注释
                    if not line or line.startswith('#'):
                        continue
                    
                    # 解析 key=value
                    if '=' in line:
                        key, value = line.split('=', 1)
                        cookies[key.strip()] = value.strip()
            
            logger.info(f"✅ 成功加载 {len(cookies)} 个 Cookie")
        except Exception as e:
            logger.error(f"❌ 加载 Cookie 失败: {str(e)}")
        
        return cookies
    
    def _rate_limit(self):
        """
        请求频率控制(避免被封禁)
        
        说明:
        - 计算距离上次请求的时间
        - 如果间隔不足,则等待
        - 增加随机抖动(更像人类行为)
        """
        elapsed = time.time() - self.last_request_time
        
        if elapsed < self.delay:
            # 需要等待的时间
            sleep_time = self.delay - elapsed
            
            # 增加 ±20% 的随机抖动(避免请求过于规律)
            jitter = random.uniform(-0.2, 0.2) * sleep_time
            sleep_time += jitter
            
            if sleep_time > 0:
                logger.debug(f"⏱️ 等待 {sleep_time:.1f}s 以控制频率")
                time.sleep(sleep_time)
        
        # 更新最后请求时间
        self.last_request_time = time.time()
    
    def fetch(self, url: str, params: Dict = None, referer: str = None) -> Optional[str]:
        """
        获取页面 HTML
        
        Args:
            url: 目标 URL
            params: URL 参数(字典)
            referer: 来源页面(可选)
            
        Returns:
            HTML 字符串,失败返回 None
            
        示例:
            html = fetcher.fetch(
                'https://store.steampowered.com/genre/Action/',
                params={'offset': 0, 'count': 25}
            )
        """
        # 频率控制
        self._rate_limit()
        
        # 准备请求头
        headers = self.headers.copy()
        if referer:
            headers['Referer'] = referer
        
        # 统计
        self.stats['total_requests'] += 1
        
        try:
            # 发送 GET 请求
            # 解释:
            # - headers: 伪装成浏览器
            # - cookies: 通过年龄验证和地区选择
            # - params: URL 参数(如分页)
            # - timeout: 防止长时间卡死
            response = self.session.get(
                url,
                headers=headers,
                cookies=self.cookies,
                params=params,
                timeout=self.timeout
            )
            
            # 检查 HTTP 状态码
            # 解释:2xx 为成功,其他为错误
            response.raise_for_status()
            
            # 编码处理
            # 解释:Steam 使用 UTF-8 编码
            if response.encoding == 'ISO-8859-1':
                response.encoding = 'utf-8'
            
            # 统计
            self.stats['success'] += 1
            
            logger.info(f"✅ 成功: {url} ({len(response.text)} 字符)")
            return response.text
            
        except requests.exceptions.HTTPError as e:
            status_code = e.response.status_code
            
            if status_code == 403:
                logger.error(f"🚫 403 Forbidden: {url}")
                logger.info("💡 可能原因:Cookie 无效、IP 被封、地区限制")
            elif status_code == 404:
                logger.warning(f"📭 404 Not Found: {url}")
            elif status_code == 429:
                logger.error(f"⚠️ 429 Too Many Requests: {url}")
                logger.info("💡 建议:增加请求间隔或使用代理")
                # 冷却时间(防止继续触发限制)
                time.sleep(10)
            else:
                logger.error(f"❌ HTTP {status_code}: {url}")
            
            self.stats['failed'] += 1
            return None
            
        except requests.exceptions.Timeout:
            logger.error(f"⏱️ 请求超时: {url}")
            self.stats['failed'] += 1
            return None
            
        except requests.exceptions.ConnectionError:
            logger.error(f"🔌 连接失败: {url}")
            logger.info("💡 可能原因:网络问题、DNS 解析失败")
            self.stats['failed'] += 1
            return None
            
        except Exception as e:
            logger.error(f"❌ 未知错误: {url} - {str(e)}")
            self.stats['failed'] += 1
            return None
    
    def fetch_json(self, url: str, params: Dict = None) -> Optional[dict]:
        """
        获取 JSON 数据(用于 Steam API)
        
        Args:
            url: API URL
            params: URL 参数
            
        Returns:
            解析后的字典,失败返回 None
            
        示例:
            # 获取游戏详情
            data = fetcher.fetch_json(
                'https://store.steampowered.com/api/appdetails',
                params={'appids': '730'}
            )
        """
        # 频率控制
        self._rate_limit()
        
        # 修改 Accept 头(告诉服务器我们要 JSON)
        headers = self.headers.copy()
        headers['Accept'] = 'application/json'
        
        # 统计
        self.stats['total_requests'] += 1
        
        try:
            response = self.session.get(
                url,
                headers=headers,
                cookies=self.cookies,
                params=params,
                timeout=self.timeout
            )
            response.raise_for_status()
            
            # 解析 JSON
            data = response.json()
            
            # 统计
            self.stats['success'] += 1
            
            logger.info(f"✅ 成功获取 JSON: {url}")
            return data
            
        except requests.exceptions.JSONDecodeError:
            logger.error(f"❌ JSON 解析失败: {url}")
            logger.debug(f"响应内容: {response.text[:200]}...")
            self.stats['failed'] += 1
            return None
            
        except Exception as e:
            logger.error(f"❌ 获取 JSON 失败: {url} - {str(e)}")
            self.stats['failed'] += 1
            return None
    
    def get_stats(self) -> Dict:
        """
        获取请求统计信息
        
        Returns:
            统计字典
        """
        if self.stats['total_requests'] > 0:
            success_rate = self.stats['success'] / self.stats['total_requests'] * 100
        else:
            success_rate = 0
        
        return {
            **self.stats,
            'success_rate': f"{success_rate:.2f}%"
        }
    
    def close(self):
        """关闭 Session"""
        self.session.close()
        logger.info("🔒 Session 已关闭")


# ========== 使用示例 ==========
if __name__ == "__main__":
    # 初始化
    fetcher = SteamFetcher()
    
    # 测试:获取动作类游戏首页
    html = fetcher.fetch('https://store.steampowered.com/genre/Action/')
    
    if html:
        print(f"成功获取 HTML,长度:{len(html)}")
        print(f"前 500 个字符:\n{html[:500]}")
    
    # 测试:获取游戏详情(JSON API)
    game_data = fetcher.fetch_json(
        'https://store.steampowered.com/api/appdetails',
        params={'appids': '730', 'cc': 'CN', 'l': 'schinese'}
    )
    
    if game_data:
        print(f"\n游戏详情:{game_data}")
    
    # 查看统计
    print(f"\n统计信息:{fetcher.get_stats()}")
    
    # 关闭
    fetcher.close()

关键技术点详解

1. Session 复用的优势
python 复制代码
# ❌ 每次都创建新连接(慢)
for url in urls:
    response = requests.get(url)

# ✅ 复用 TCP 连接(快)
session = requests.Session()
for url in urls:
    response = session.get(url)

性能对比:

  • 无 Session:每次请求都要经历 DNS 解析 → TCP 三次握手 → TLS 握手 → 发送请求
  • 有 Session:复用连接,只需发送请求
  • 速度提升:约 30-50%
2. 自动重试机制
python 复制代码
retry_strategy = Retry(
    total=3,  # 最多重试 3 次
    backoff_factor=1,  # 间隔时间:1s, 2s, 4s
    status_forcelist=[429, 500, 502, 503, 504]
)

重试逻辑:

  • 第 1 次失败:等待 1 秒后重试
  • 第 2 次失败:等待 2 秒后重试
  • 第 3 次失败:等待 4 秒后重试
  • 仍失败:放弃,返回错误

为什么用指数退避?

  • 服务器可能正在重启或过载
  • 立即重试会加剧压力
  • 逐渐增加等待时间给服务器恢复的机会
3. 随机抖动(Jitter)
python 复制代码
jitter = random.uniform(-0.2, 0.2) * sleep_time
sleep_time += jitter

作用:

  • 假设设置间隔为 2.5 秒
  • 实际间隔会在 2.0-3.0 秒之间随机
  • 避免被识别为机器人(人类不会每次都精确间隔)

Steam 需要两个关键 Cookie:

python 复制代码
{
    'birthtime': '473385600',  # 出生时间戳(Unix timestamp)
    'steamCountry': 'CN%7C...'  # 地区代码(URL 编码)
}

birthtime 计算:

python 复制代码
from datetime import datetime

# 假设生日是 1985-01-01
birthday = datetime(1985, 1, 1)
birthtime = int(birthday.timestamp())
# 结果:473385600

steamCountry 格式:

json 复制代码
CN%7C3e8d7e1e2f8c4a5b6c7d8e9f0a1b2c3d
└─┬─┘ ↑  └────────────────────────────┘
  │   |                └─ 随机 hash(校验用)
  │   └─ URL 编码的 "|"
  └─ 国家代码(CN/US/JP 等)

7️⃣ 核心实现:解析层(Parser)

代码实现(parser.py)

python 复制代码
"""
Steam 游戏数据采集 - 解析层

功能:
1. 解析游戏列表页(名称、价格、标签)
2. 解析游戏详情页(评分、发行日期、开发商)
3. 解析分类列表页(获取所有分类)
4. 提取分页信息(总页数、当前页)

作者:YourName
日期:2025-01-27
"""

from lxml import etree
from typing import List, Dict, Optional
import re
import logging

logger = logging.getLogger(__name__)


class SteamParser:
    """Steam 页面解析器"""
    
    @staticmethod
    def parse_genres(html: str) -> List[Dict]:
        """
        解析游戏分类列表
        
        Args:
            html: Steam 首页或分类页 HTML
            
        Returns:
            分类信息列表 [{'name': '动作', 'url': '...', 'slug': 'action'}]
            
        示例:
            genres = parser.parse_genres(homepage_html)
            # [{'name': '动作', 'url': '/genre/Action/', 'slug': 'action'}, ...]
        """
        tree = etree.HTML(html)
        genres = []
        
        # XPath 选择器(根据实际 HTML 结构调整)
        # 解释:Steam 的分类链接通常在导航栏或侧边栏
        # 示例结构:<a class="popup_menu_item" href="/genre/Action/">动作</a>
        
        # 方法1:从顶部导航提取
        genre_links = tree.xpath('//a[contains(@class, "popup_menu_item") and contains(@href, "/genre/")]')
        
        # 方法2:从分类页面提取(如果方法1无效)
        if not genre_links:
            genre_links = tree.xpath('//div[@class="genre_list"]//a[@href]')
        
        for link in genre_links:
            try:
                # 提取分类名称
                # 解释:.text 获取标签内的文本
                genre_name = link.text.strip() if link.text else ""
                
                # 提取链接
                # 解释:.get() 获取属性值
                href = link.get('href', '')
                
                # 提取 slug(URL 标识)
                # 示例:/genre/Action/ → action
                slug_match = re.search(r'/genre/([^/]+)', href)
                slug = slug_match.group(1).lower() if slug_match else ""
                
                # 数据验证
                if genre_name and href:
                    genres.append({
                        'name': genre_name,
                        'url': f"https://store.steampowered.com{href}" if href.startswith('/') else href,
                        'slug': slug
                    })
            except Exception as e:
                logger.warning(f"⚠️ 解析分类失败: {str(e)}")
                continue
        
        logger.info(f"📂 解析到 {len(genres)} 个游戏分类")
        return genres
    
    @staticmethod
    def parse_game_list(html: str) -> List[Dict]:
        """
        解析游戏列表页
        
        Args:
            html: 分类页面 HTML
            
        Returns:
            游戏列表
            
        示例:
            games = parser.parse_game_list(action_genre_html)
            # [{'app_id': '730', 'game_name': 'CS:GO', 'price': '免费', ...}, ...]
        """
        tree = etree.HTML(html)
        games = []
        
        # Steam 列表页的典型结构:
        # <div class="search_result_row" data-ds-appid="730">
        #     <div class="col search_name">
        #         <span class="title">Counter-Strike: Global Offensive</span>
        #     </div>
        #     <div class="col search_price">
        #         <span class="discount_original_price">¥ 98</span>
        #         <span class="discount_final_price">¥ 49</span>
        #     </div>
        # </div>
        
        # 选择所有游戏行
        game_rows = tree.xpath('//div[contains(@class, "search_result_row")]')
        
        if not game_rows:
            # 备用选择器(Steam 可能有多种布局)
            game_rows = tree.xpath('//a[@class="search_result_row ds_collapse_flag"]')
        
        for row in game_rows:
            try:
                game_data = SteamParser._parse_game_row(row)
                
                # 数据验证(必需字段不能为空)
                if game_data.get('app_id') and game_data.get('game_name'):
                    games.append(game_data)
                else:
                    logger.warning(f"⚠️ 跳过无效游戏: {game_data}")
            except Exception as e:
                logger.warning(f"⚠️ 解析游戏行失败: {str(e)}")
                continue
        
        logger.info(f"🎮 列表页解析完成,提取 {len(games)} 个游戏")
        return games
    
    @staticmethod
    def _parse_game_row(row) -> Dict:
        """
        解析单个游戏行
        
        Args:
            row: lxml Element 对象
            
        Returns:
            游戏数据字典
        """
        # 1. 提取 App ID
        # 解释:Steam 的每个游戏都有唯一的 App ID
        # 方法1:从 data-ds-appid 属性提取
        app_id = row.get('data-ds-appid')
        
        # 方法2:从链接中提取(备用)
        if not app_id:
            link = row.xpath('.//a[@href]/@href')
            if link:
                match = re.search(r'/app/(\d+)/', link[0])
                if match:
                    app_id = match.group(1)
        
        # 2. 提取游戏名称
        # XPath 解释:
        # .// → 在当前节点内查找
        # span[@class="title"] → class="title" 的 span 标签
        # /text() → 提取文本内容
        game_name = SteamParser._extract_text(row, './/span[@class="title"]/text()')
        
        # 备用选择器
        if not game_name:
            game_name = SteamParser._extract_text(row, './/div[@class="col search_name"]//text()')
        
        # 3. 提取缩略图
        # 解释:@src 表示获取 src 属性
        thumbnail = SteamParser._extract_attr(row, './/img', 'src')
        
        # 4. 提取价格信息
        price_data = SteamParser._parse_price(row)
        
        # 5. 提取发行日期
        release_date = SteamParser._extract_text(row, './/div[@class="col search_released"]/text()')
        
        # 6. 提取评分信息
        review_data = SteamParser._parse_review(row)
        
        # 7. 提取标签(部分列表页可能没有)
        tags = SteamParser._parse_tags(row)
        
        # 8. 提取平台支持
        platforms = SteamParser._parse_platforms(row)
        
        # 组装数据
        game_data = {
            'app_id': app_id,
            'game_name': game_name.strip() if game_name else "",
            'thumbnail_url': thumbnail,
            'release_date': release_date.strip() if release_date else "",
            'tags': tags,
            'platforms': platforms,
            **price_data,  # 解包价格数据(original_price, discount_price 等)
            **review_data  # 解包评分数据(review_score, review_count 等)
        }
        
        return game_data
    
    @staticmethod
    def _parse_price(row) -> Dict:
        """
        解析价格信息
        
        Args:
            row: 游戏行元素
            
        Returns:
            价格数据 {'original_price': '¥ 98', 'discount_price': '¥ 49', ...}
        """
        price_data = {
            'original_price': "",
            'discount_price': "",
            'discount_percent': "",
            'is_free': False
        }
        
        # 情况1:免费游戏
        # 示例:<div class="col search_price">免费</div>
        free_text = SteamParser._extract_text(row, './/div[contains(@class, "search_price")]/text()')
        if free_text and ('免费' in free_text.lower() or 'free' in free_text.lower()):
            price_data['is_free'] = True
            price_data['original_price'] = "免费"
            return price_data
        
        # 情况2:有折扣
        # 示例:
        # <div class="col search_price discounted">
        #     <span class="discount_pct">-50%</span>
        #     <strike><span class="discount_original_price">¥ 98</span></strike>
        #     <span class="discount_final_price">¥ 49</span>
        # </div>
        
        # 提取折扣百分比
        discount_pct = SteamParser._extract_text(row, './/span[@class="discount_pct"]/text()')
        if discount_pct:
            price_data['discount_percent'] = discount_pct.strip()
        
        # 提取原价
        original = SteamParser._extract_text(row, './/span[@class="discount_original_price"]/text()')
        if original:
            price_data['original_price'] = original.strip()
        
        # 提取折扣价
        final = SteamParser._extract_text(row, './/span[@class="discount_final_price"]/text()')
        if final:
            price_data['discount_price'] = final.strip()
        
        # 情况3:无折扣
        # 示例:<div class="col search_price">¥ 98</div>
        if not price_data['original_price'] and not price_data['discount_price']:
            price = SteamParser._extract_text(row, './/div[contains(@class, "search_price")]/text()')
            if price:
                price_data['original_price'] = price.strip()
        
        return price_data
    
    @staticmethod
    def _parse_review(row) -> Dict:
        """
        解析评分信息
        
        Args:
            row: 游戏行元素
            
        Returns:
            评分数据 {'review_score': '特别好评', 'review_count': '123,456'}
        """
        review_data = {
            'review_score': "",
            'review_count': ""
        }
        
        # Steam 评分结构:
        # <span class="search_review_summary positive" data-tooltip-text="特别好评<br>123,456 篇用户评测">
        #     <span class="game_review_summary positive">特别好评</span>
        # </span>
        
        # 方法1:从 tooltip 提取(更准确)
        tooltip = SteamParser._extract_attr(
            row,
            './/span[contains(@class, "search_review_summary")]',
            'data-tooltip-html'
        )
        
        if tooltip:
            # 解析 tooltip
            # 格式:特别好评<br>123,456 篇用户评测
            parts = tooltip.split('<br>')
            if len(parts) >= 2:
                review_data['review_score'] = parts[0].strip()
                # 提取数字
                count_match = re.search(r'([\d,]+)', parts[1])
                if count_match:
                    review_data['review_count'] = count_match.group(1)
        
        # 方法2:直接提取(备用)
        if not review_data['review_score']:
            score = SteamParser._extract_text(row, './/span[@class="game_review_summary"]//text()')
            if score:
                review_data['review_score'] = score.strip()
        
        return review_data
    
    @staticmethod
    def _parse_tags(row) -> List[str]:
        """
        解析游戏标签
        
        Args:
            row: 游戏行元素
            
        Returns:
            标签列表 ['FPS', '多人', '竞技']
            
        注意:
            列表页的标签可能不完整,建议访问详情页获取完整标签
        """
        tags = []
        
        # 标签通常在这个位置:
        # <div class="col search_tags">
        #     <a href="/tags/zh-cn/FPS/">FPS</a>
        #     <a href="/tags/zh-cn/多人/">多人</a>
        # </div>
        
        tag_links = row.xpath('.//div[@class="col search_tags"]//a')
        
        for tag_link in tag_links:
            tag_name = tag_link.text.strip() if tag_link.text else ""
            if tag_name:
                tags.append(tag_name)
        
        return tags
    
    @staticmethod
    def _parse_platforms(row) -> List[str]:
        """
        解析支持的平台
        
        Args:
            row: 游戏行元素
            
        Returns:
            平台列表 ['Windows', 'Mac', 'Linux']
        """
        platforms = []
        
        # Steam 平台图标结构:
        # <p class="platforms">
        #     <span class="platform_img win"></span>
        #     <span class="platform_img mac"></span>
        # </p>
        
        platform_map = {
            'win': 'Windows',
            'mac': 'Mac',
            'linux': 'Linux',
            'steamdeck': 'Steam Deck'
        }
        
        platform_spans = row.xpath('.//p[@class="platforms"]/span[contains(@class, "platform_img")]')
        
        for span in platform_spans:
            # 提取 class 名称
            classes = span.get('class', '').split()
            for cls in classes:
                if cls in platform_map:
                    platforms.append(platform_map[cls])
        
        return platforms
    
    @staticmethod
    def parse_game_detail(html: str) -> Dict:
        """
        解析游戏详情页
        
        Args:
            html: 详情页 HTML
            
        Returns:
            详细信息字典
        """
        tree = etree.HTML(html)
        
        detail = {
            'description': SteamParser._extract_text(
                tree,
                '//div[@class="game_description_snippet"]/text()'
            ),
            'developer': SteamParser._extract_text(
                tree,
                '//div[@id="developers_list"]/a/text()'
            ),
            'publisher': SteamParser._extract_text(
                tree,
                '//div[@class="dev_row"]//a[contains(@href, "publisher")]/text()'
            ),
            'tags_full': SteamParser._parse_detail_tags(tree),
            'system_requirements': SteamParser._parse_system_requirements(tree)
        }
        
        return detail
    
    @staticmethod
    def _parse_detail_tags(tree) -> List[str]:
        """解析详情页的完整标签"""
        tags = []
        
        # 详情页标签结构:
        # <a class="app_tag" href="/tags/zh-cn/FPS/">FPS</a>
        tag_links = tree.xpath('//a[contains(@class, "app_tag")]')
        
        for tag_link in tag_links:
            tag_name = tag_link.text.strip() if tag_link.text else ""
            if tag_name and tag_name not in tags:  # 去重
                tags.append(tag_name)
        
        return tags
    
    @staticmethod
    def _parse_system_requirements(tree) -> Dict:
        """解析系统要求"""
        requirements = {}
        
        # Windows 要求
        win_req = SteamParser._extract_text(
            tree,
            '//div[@class="game_area_sys_req_leftCol"]//text()'
        )
        if win_req:
            requirements['windows'] = win_req
        
        # Mac 要求
        mac_req = SteamParser._extract_text(
            tree,
            '//div[@class="game_area_sys_req_rightCol"]//text()'
        )
        if mac_req:
            requirements['mac'] = mac_req
        
        return requirements

    @staticmethod
    def get_pagination_info(html: str) -> Dict:
        """
        获取分页信息
        
        Args:
            html: 列表页 HTML
            
        Returns:
            分页信息 {'current_page': 1, 'total_pages': 20, 'total_results': 500}
        """
        tree = etree.HTML(html)
        
        pagination_info = {
            'current_page': 1,
            'total_pages': 1,
            'total_results': 0,
            'has_next': False
        }
        
        # Steam 分页结构:
        # <div class="search_pagination">
        #     <span class="search_pagination_left">显示 1-25 / 共 500 个结果</span>
        #     <div class="search_pagination_right">
        #         <a href="?offset=25">&gt;</a>
        #     </div>
        # </div>
        
        # 提取总结果数
        # 示例文本:"显示 1-25 / 共 500 个结果"
        pagination_text = SteamParser._extract_text(
            tree,
            '//div[@class="search_pagination_left"]/text()'
        )
        
        if pagination_text:
            # 匹配数字
            # 正则解释:(\d+) 匹配一个或多个数字
            match = re.search(r'共\s*([\d,]+)', pagination_text)
            if match:
                total_str = match.group(1).replace(',', '')
                pagination_info['total_results'] = int(total_str)
                
                # 计算总页数(每页 25 个)
                pagination_info['total_pages'] = (pagination_info['total_results'] + 24) // 25
        
        # 检查是否有下一页
        next_link = tree.xpath('//a[contains(@href, "offset=") and contains(text(), ">")]')
        pagination_info['has_next'] = len(next_link) > 0
        
        return pagination_info
    
    @staticmethod
    def _extract_text(element, xpath: str) -> str:
        """
        提取文本内容(辅助函数)
        
        Args:
            element: lxml Element 或 tree
            xpath: XPath 表达式
            
        Returns:
            提取的文本
        """
        try:
            result = element.xpath(xpath)
            if result:
                # 如果是列表,取第一个
                text = result[0] if isinstance(result, list) else result
                # 如果是 Element,提取其文本
                if hasattr(text, 'text'):
                    text = text.text
                return str(text).strip() if text else ""
        except:
            pass
        return ""
    
    @staticmethod
    def _extract_attr(element, xpath: str, attr: str) -> str:
        """
        提取属性值(辅助函数)
        
        Args:
            element: lxml Element 或 tree
            xpath: XPath 表达式
            attr: 属性名
            
        Returns:
            属性值
        """
        try:
            result = element.xpath(xpath)
            if result:
                elem = result[0] if isinstance(result, list) else result
                value = elem.get(attr, "")
                
                # 处理相对路径(如果是 URL)
                if attr in ['src', 'href'] and value:
                    if not value.startswith('http'):
                        value = f"https://store.steampowered.com{value}"
                
                return value
        except:
            pass
        return ""


# ========== 使用示例 ==========
if __name__ == "__main__":
    # 假设已经获取了 HTML
    with open('test_data/action_genre.html', 'r', encoding='utf-8') as f:
        html = f.read()
    
    parser = SteamParser()
    
    # 解析游戏列表
    games = parser.parse_game_list(html)
    print(f"解析到 {len(games)} 个游戏")
    
    # 打印第一个游戏的信息
    if games:
        print(f"\n第一个游戏:")
        for key, value in games[0].items():
            print(f"  {key}: {value}")
    
    # 获取分页信息
    pagination = parser.get_pagination_info(html)
    print(f"\n分页信息:{pagination}")

解析层关键技术详解

1. XPath 选择器技巧
python 复制代码
# 基础选择
'//div[@class="game"]'  # 选择 class="game" 的 div

# 包含匹配(模糊匹配)
'//div[contains(@class, "game")]'  # 选择 class 包含 "game" 的 div

# 属性存在性检查
'//a[@href]'  # 选择有 href 属性的 a 标签

# 文本内容匹配
'//span[contains(text(), "免费")]'  # 选择包含"免费"文本的 span

# 层级关系
'.//span'  # 在当前节点内查找(相对路径)
'//div//span'  # 在整个文档查找(绝对路径)

# 多条件
'//div[@class="game" and @data-id]'  # 同时满足多个条件

# 父节点
'//span[@class="price"]/parent::div'  # 选择父节点

# 兄弟节点
'//span[@class="title"]/following-sibling::span'  # 后续兄弟节点
2. 价格解析的复杂性

Steam 的价格有多种情况:

python 复制代码
# 情况1:免费游戏
<div class="search_price">免费开始游戏</div>

# 情况2:正常价格
<div class="search_price">¥ 98</div>

# 情况3:折扣价
<div class="search_price discounted">
    <span class="discount_pct">-50%</span>
    <strike>¥ 98</strike>
    <span class="discount_final_price">¥ 49</span>
</div>

# 情况4:尚未发售
<div class="search_price">即将推出</div>

# 情况5:捆绑包
<div class="search_price">¥ 198 起</div>

因此需要多种策略:

python 复制代码
def _parse_price(row) -> Dict:
    # 策略1:检查是否免费
    if '免费' in text or 'free' in text.lower():
        return {'is_free': True}
    
    # 策略2:检查是否有折扣
    if discount_pct_element:
        # 提取原价和折扣价
        pass
    
    # 策略3:提取普通价格
    else:
        # 提取单一价格
        pass
    
    # 策略4:处理特殊情况
    if '即将推出' in text or 'coming soon' in text.lower():
        return {'price_status': 'coming_soon'}
3. 评分数据的提取

Steam 的评分有两种呈现方式:

python 复制代码
# 方式1:Tooltip(鼠标悬停显示)
<span data-tooltip-html="特别好评<br>123,456 篇用户评测">
    特别好评
</span>

# 方式2:直接文本
<span class="game_review_summary positive">特别好评</span>

解析策略:

python 复制代码
# 优先从 tooltip 提取(包含评价数量)
tooltip = element.get('data-tooltip-html')
if tooltip:
    parts = tooltip.split('<br>')
    score = parts[0]  # "特别好评"
    count = re.search(r'([\d,]+)', parts[1]).group(1)  # "123,456"

# 备用方案:直接提取文本
else:
    score = element.text.strip()

8️⃣ 数据清洗层(Cleaner)

代码实现(cleaner.py)

python 复制代码
"""
Steam 游戏数据采集 - 数据清洗层

功能:
1. 价格标准化(去除货币符号、转换为数字)
2. 日期标准化(统一为 ISO 8601 格式)
3. 标签清洗(去重、标准化)
4. 数据验证(检查必需字段、合理性校验)

作者:YourName
日期:2025-01-27
"""

from datetime import datetime
from dateutil import parser as date_parser
from typing import Dict, Optional, List
import re
import logging

logger = logging.getLogger(__name__)


class SteamCleaner:
    """Steam 游戏数据清洗器"""
    
    # 货币符号映射
    CURRENCY_MAP = {
        '¥': 'CNY',
        '$': 'USD',
        '€': 'EUR',
        '£': 'GBP',
        '₽': 'RUB',
        '₩': 'KRW',
        'R$': 'BRL'
    }
    
    # 评分等级映射(中文 → 英文)
    REVIEW_SCORE_MAP = {
        '好评如潮': 'Overwhelmingly Positive',
        '特别好评': 'Very Positive',
        '多半好评': 'Mostly Positive',
        '褒贬不一': 'Mixed',
        '多半差评': 'Mostly Negative',
        '特别差评': 'Very Negative',
        '差评如潮': 'Overwhelmingly Negative',
        '无用户评测': 'No User Reviews'
    }
    
    @staticmethod
    def clean_game(game: Dict) -> Optional[Dict]:
        """
        清洗单个游戏数据
        
        Args:
            game: 原始游戏数据
            
        Returns:
            清洗后的数据,失败返回 None
        """
        try:
            # 1. 验证必需字段
            if not game.get('app_id') or not game.get('game_name'):
                logger.warning(f"⚠️ 缺少必需字段: {game}")
                return None
            
            # 2. 清洗游戏名称
            game_name = SteamCleaner.clean_name(game.get('game_name', ''))
            if not game_name:
                return None
            
            # 3. 清洗价格数据
            price_data = SteamCleaner.clean_price(game)
            
            # 4. 清洗日期
            release_date = SteamCleaner.clean_date(game.get('release_date', ''))
            
            # 5. 清洗评分数据
            review_data = SteamCleaner.clean_review(game)
            
            # 6. 清洗标签
            tags = SteamCleaner.clean_tags(game.get('tags', []))
            
            # 7. 构建清洗后的数据
            cleaned = {
                'app_id': str(game['app_id']),
                'game_name': game_name,
                'thumbnail_url': game.get('thumbnail_url', ''),
                'release_date': release_date,
                'tags': tags,
                'platforms': game.get('platforms', []),
                **price_data,
                **review_data
            }
            
            return cleaned
            
        except Exception as e:
            logger.error(f"❌ 清洗数据失败: {game} - {str(e)}")
            return None
    
    @staticmethod
    def clean_name(name: str) -> str:
        """
        清洗游戏名称
        
        Args:
            name: 原始名称
            
        Returns:
            清洗后的名称
        """
        if not name:
            return ""
        
        # 移除多余空白
        # 解释:\s+ 匹配一个或多个空白字符(空格、制表符、换行符)
        name = re.sub(r'\s+', ' ', name).strip()
        
        # 移除 HTML 标签(如果有残留)
        # 解释:<[^>]+> 匹配 <...> 之间的任意内容
        name = re.sub(r'<[^>]+>', '', name)
        
        # 移除特殊控制字符
        # 解释:[\x00-\x1F\x7F] 匹配 ASCII 控制字符
        name = re.sub(r'[\x00-\x1F\x7F]', '', name)
        
        # 移除 Steam 特有的标记
        # 示例:"Game Name™" → "Game Name"
        name = name.replace('™', '').replace('®', '').replace('©', '')
        
        return name.strip()
    
    @staticmethod
    def clean_price(game: Dict) -> Dict:
        """
        清洗价格数据
        
        Args:
            game: 游戏数据
            
        Returns:
            标准化的价格数据
        """
        price_data = {
            'currency': 'CNY',  # 默认货币
            'original_price_raw': game.get('original_price', ''),
            'discount_price_raw': game.get('discount_price', ''),
            'original_price_value': 0.0,
            'discount_price_value': 0.0,
            'discount_percent': 0,
            'is_free': game.get('is_free', False),
            'price_status': 'available'  # available / coming_soon / not_available
        }
        
        # 处理免费游戏
        if price_data['is_free']:
            return price_data
        
        # 提取原价
        original_raw = game.get('original_price', '')
        if original_raw:
            # 检测货币类型
            for symbol, code in SteamCleaner.CURRENCY_MAP.items():
                if symbol in original_raw:
                    price_data['currency'] = code
                    break
            
            # 提取数字
            # 解释:这个正则匹配价格数字(支持千位分隔符和小数点)
            # 示例:"¥ 1,234.56" → "1234.56"
            price_match = re.search(r'([\d,]+\.?\d*)', original_raw)
            if price_match:
                # 移除千位分隔符
                price_str = price_match.group(1).replace(',', '')
                try:
                    price_data['original_price_value'] = float(price_str)
                except ValueError:
                    logger.warning(f"⚠️ 无法解析原价: {original_raw}")
        
        # 提取折扣价
        discount_raw = game.get('discount_price', '')
        if discount_raw:
            price_match = re.search(r'([\d,]+\.?\d*)', discount_raw)
            if price_match:
                price_str = price_match.group(1).replace(',', '')
                try:
                    price_data['discount_price_value'] = float(price_str)
                except ValueError:
                    logger.warning(f"⚠️ 无法解析折扣价: {discount_raw}")
        
        # 提取折扣百分比
        discount_pct = game.get('discount_percent', '')
        if discount_pct:
            # 示例:"-50%" → 50
            pct_match = re.search(r'(\d+)', discount_pct)
            if pct_match:
                price_data['discount_percent'] = int(pct_match.group(1))
        
        # 验证折扣逻辑
        if price_data['discount_price_value'] > 0:
            # 如果有折扣价但没有原价,将折扣价设为原价
            if price_data['original_price_value'] == 0:
                price_data['original_price_value'] = price_data['discount_price_value']
                price_data['discount_price_value'] = 0.0
            # 检查折扣价是否合理
            elif price_data['discount_price_value'] > price_data['original_price_value']:
                logger.warning(f"⚠️ 折扣价高于原价: {game['game_name']}")
        
        # 检测特殊状态
        status_text = original_raw.lower()
        if '即将推出' in status_text or 'coming soon' in status_text:
            price_data['price_status'] = 'coming_soon'
        elif '不可用' in status_text or 'not available' in status_text:
            price_data['price_status'] = 'not_available'
        
        return price_data
    
    @staticmethod
    def clean_date(raw_date: str) -> str:
        """
        日期标准化
        
        Args:
            raw_date: 原始日期字符串
            
        Returns:
            ISO 8601 格式日期(YYYY-MM-DD)
        """
        if not raw_date:
            return ""
        
        # 已经是标准格式
        if re.match(r'\d{4}-\d{2}-\d{2}', raw_date):
            return raw_date
        
        # Steam 常见日期格式:
        # "2012年8月21日"
        # "2012 年 8 月 21 日"
        # "Aug 21, 2012"
        # "21 Aug 2012"
        # "2012"(只有年份)
        
        try:
            # 方法1:使用 dateutil 智能解析
            # 解释:fuzzy=True 会忽略非日期文本
            dt = date_parser.parse(raw_date, fuzzy=True)
            return dt.strftime('%Y-%m-%d')
        except:
            pass
        
        # 方法2:处理中文日期
        # "2012年8月21日" → "2012-08-21"
        match = re.search(r'(\d{4})\s*年\s*(\d{1,2})\s*月\s*(\d{1,2})\s*日', raw_date)
        if match:
            year, month, day = match.groups()
            return f"{year}-{month.zfill(2)}-{day.zfill(2)}"
        
        # 方法3:只有年份
        # "2012" → "2012-01-01"
        match = re.search(r'^(\d{4})$', raw_date.strip())
        if match:
            return f"{match.group(1)}-01-01"
        
        # 方法4:季度表示
        # "Q4 2012" → "2012-10-01"
        match = re.search(r'Q([1-4])\s*(\d{4})', raw_date, re.IGNORECASE)
        if match:
            quarter, year = match.groups()
            month = (int(quarter) - 1) * 3 + 1  # Q1→1, Q2→4, Q3→7, Q4→10
            return f"{year}-{month:02d}-01"
        
        logger.warning(f"⚠️ 无法解析日期: {raw_date}")
        return ""
    
    @staticmethod
    def clean_review(game: Dict) -> Dict:
        """
        清洗评分数据
        
        Args:
            game: 游戏数据
            
        Returns:
            标准化的评分数据
        """
        review_data = {
            'review_score': game.get('review_score', ''),
            'review_score_en': '',
            'review_count_raw': game.get('review_count', ''),
            'review_count_value': 0
        }
        
        # 映射中文评分到英文
        score_cn = review_data['review_score']
        if score_cn in SteamCleaner.REVIEW_SCORE_MAP:
            review_data['review_score_en'] = SteamCleaner.REVIEW_SCORE_MAP[score_cn]
        else:
            review_data['review_score_en'] = score_cn
        
        # 解析评价数量
        # 示例:"123,456" → 123456
        count_raw = review_data['review_count_raw']
        if count_raw:
            # 移除千位分隔符
            count_str = count_raw.replace(',', '').replace(' ', '')
            # 提取数字
            match = re.search(r'(\d+)', count_str)
            if match:
                try:
                    review_data['review_count_value'] = int(match.group(1))
                except ValueError:
                    pass
        
        return review_data
    
    @staticmethod
    def clean_tags(tags: List[str]) -> List[str]:
        """
        清洗标签列表
        
        Args:
            tags: 原始标签列表
            
        Returns:
            清洗后的标签列表
        """
        if not tags:
            return []
        
        cleaned_tags = []
        seen = set()  # 用于去重
        
        for tag in tags:
            # 清理空白
            tag = tag.strip()
            
            # 跳过空标签
            if not tag:
                continue
            
            # 移除特殊字符
            tag = re\s\-\+]', '', tag, flags=re.UNICODE)
            
            # 统一大小写(用于去重)
            tag_lower = tag.lower()
            
            # 去重
            if tag_lower not in seen:
                seen.add(tag_lower)
                cleaned_tags.append(tag)
        
        return cleaned_tags
    
    @staticmethod
    def validate_game(game: Dict) -> bool:
        """
        验证游戏数据的完整性和合理性
        
        Args:
            game: 游戏数据
            
        Returns:
            是否通过验证
        """
        # 必需字段检查
        required_fields = ['app_id', 'game_name']
        for field in required_fields:
            if not game.get(field):
                logger.warning(f"⚠️ 缺失必需字段 {field}: {game}")
                return False
        
        # App ID 格式检查(应该是纯数字)
        if not re.match(r'^\d+$', str(game['app_id'])):
            logger.warning(f"⚠️ App ID 格式错误: {game['app_id']}")
            return False
        
        # 价格合理性检查
        original_price = game.get('original_price_value', 0)
        discount_price = game.get('discount_price_value', 0)
        
        if discount_price > original_price > 0:
            logger.warning(f"⚠️ 价格不合理: {game['game_name']}")
            return False
        
        # 价格范围检查(Steam 游戏价格通常在 1-1000 区间)
        if original_price > 10000:
            logger.warning(f"⚠️ 价格异常高: {game['game_name']} - {original_price}")
            return False
        
        # 日期合理性检查
        release_date = game.get('release_date', '')
        if release_date:
            try:
                dt = datetime.strptime(release_date, '%Y-%m-%d')
                # 检查是否在合理范围(1990-2030)
                if dt.year < 1990 or dt.year > 2030:
                    logger.warning(f"⚠️ 发行日期异常: {game['game_name']} - {release_date}")
            except ValueError:
                logger.warning(f"⚠️ 日期格式错误: {release_date}")
        
        # 评价数量合理性检查
        review_count = game.get('review_count_value', 0)
        if review_count > 10000000:  # 超过 1000 万评价(极少见)
            logger.warning(f"⚠️ 评价数量异常: {game['game_name']} - {review_count}")
        
        return True


# ========== 使用示例 ==========
if __name__ == "__main__":
    # 测试数据
    test_game = {
        'app_id': '730',
        'game_name': '  Counter-Strike™: Global Offensive  ',
        'original_price': '¥ 98',
        'discount_price': '¥ 49',
        'discount_percent': '-50%',
        'release_date': '2012年8月21日',
        'review_score': '特别好评',
        'review_count': '1,234,567',
        'tags': ['FPS', '多人', 'FPS', '竞技'],  # 包含重复
        'platforms': ['Windows', 'Mac', 'Linux']
    }
    
    cleaner = SteamCleaner()
    
    # 清洗数据
    cleaned = cleaner.clean_game(test_game)
    
    print("清洗后的数据:")
    import json
    print(json.dumps(cleaned, ensure_ascii=False, indent=2))
    
    # 验证数据
    is_valid = cleaner.validate_game(cleaned)
    print(f"\n数据验证: {'✅ 通过' if is_valid else '❌ 失败'}")

数据清洗关键技术详解

1. 价格解析的挑战

Steam 的价格展示非常复杂,需要处理:

python 复制代码
# 挑战1:多种货币
"¥ 98"      # 人民币
"$49.99"    # 美元
"€39.99"    # 欧元
"£34.99"    # 英镑

# 挑战2:千位分隔符
"¥ 1,234.56"

# 挑战3:价格区间
"¥ 98 - ¥ 198"  # 捆绑包
"¥ 98 起"        # DLC

# 挑战4:特殊状态
"免费开始游戏"
"即将推出"
"不可用"

解析策略:

python 复制代码
# 步骤1:识别货币
for symbol, code in CURRENCY_MAP.items():
    if symbol in price_str:
        currency = code
        break

# 步骤2:提取数字(支持千位分隔符和小数点)
# 正则解释:
# [\d,]+  → 匹配数字和逗号(千位分隔符)
# \.?     → 可选的小数点
# \d*     → 小数部分的数字
price_match = re.search(r'([\d,]+\.?\d*)', price_str)

# 步骤3:清理并转换
price_str = price_match.group(1).replace(',', '')
price_value = float(price_str)
2. 日期解析的多样性

Steam 的日期格式因地区和语言而异:

python 复制代码
# 中文格式
"2012年8月21日"
"2012 年 8 月 21 日"

# 英文格式
"Aug 21, 2012"
"21 Aug 2012"
"August 21, 2012"

# 只有年份
"2012"

# 季度表示
"Q4 2012"
"2012 Q1"

# 相对时间
"2 天前"
"上周"

解析优先级:

python 复制代码
# 优先级1:已是标准格式(无需处理)
if re.match(r'\d{4}-\d{2}-\d{2}', date_str):
    return date_str

# 优先级2:使用 dateutil(支持大部分格式)
try:
    dt = date_parser.parse(date_str, fuzzy=True)
    return dt.strftime('%Y-%m-%d')
except:
    pass

# 优先级3:正则匹配特定格式
# 中文日期
match = re.search(r'(\d{4})年(\d{1,2})月(\d{1,2})日', date_str)

# 优先级4:只提取年份(最低要求)
match = re.search(r'(\d{4})', date_str)
3. 数据验证的重要性

清洗后必须验证数据,避免脏数据进入数据库:

python 复制代码
def validate_game(game: Dict) -> bool:
    # 验证1:必需字段
    if not game.get('app_id'):
        return False
    
    # 验证2:字段格式
    if not re.match(r'^\d+$', str(game['app_id'])):
        return False
    
    # 验证3:数据合理性
    if game.get('original_price_value', 0) > 10000:
        return False  # 价格异常
    
    # 验证4:逻辑一致性
    if game.get('discount_price_value', 0) > game.get('original_price_value', 0):
        return False  # 折扣价不能高于原价
    
    return True

9️⃣ 数据存储与导出(Storage)

代码实现(storage.py)

python 复制代码
"""
Steam 游戏数据采集 - 存储层

功能:
1. SQLite 数据库管理
2. 游戏数据的增删改查
3. 价格历史追踪
4. 多格式导出(JSON、CSV)

作者:YourName
日期:2025-01-27
"""

import sqlite3
import json
import pandas as pd
from typing import List, Dict, Optional
from pathlib import Path
from datetime import datetime
import logging

logger = logging.getLogger(__name__)


class SteamStorage:
    """Steam 游戏数据存储管理器"""
    
    def __init__(self, db_path: str = "data/steam_games.db"):
        """
        初始化存储管理器
        
        Args:
            db_path: 数据库文件路径
        """
        self.db_path = db_path
        Path(db_path).parent.mkdir(parents=True, exist_ok=True)
        
        # 连接数据库
        # 解释:check_same_thread=False 允许多线程访问(谨慎使用)
        self.conn = sqlite3.connect(db_path, check_same_thread=False)
        
        # 设置返回字典格式(方便使用)
        self.conn.row_factory = sqlite3.Row
        
        # 创建表
        self._create_tables()
        
        logger.info(f"✅ 数据库已连接: {db_path}")
    
    def _create_tables(self):
        """
        创建数据表
        
        表结构:
        1. games - 游戏基本信息
        2. price_history - 价格历史记录
        3. tags - 游戏标签(多对多关系)
        """
        
        # 表1:游戏主表
        create_games_sql = """
        CREATE TABLE IF NOT EXISTS games (
            app_id TEXT PRIMARY KEY,
            game_name TEXT NOT NULL,
            thumbnail_url TEXT,
            release_date TEXT,
            currency TEXT DEFAULT 'CNY',
            original_price_raw TEXT,
            original_price_value REAL DEFAULT 0.0,
            discount_price_raw TEXT,
            discount_price_value REAL DEFAULT 0.0,
            discount_percent INTEGER DEFAULT 0,
            is_free BOOLEAN DEFAULT 0,
            price_status TEXT DEFAULT 'available',
            review_score TEXT,
            review_score_en TEXT,
            review_count_raw TEXT,
            review_count_value INTEGER DEFAULT 0,
            platforms TEXT,
            created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
            updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
        )
        """
        
        # 表2:价格历史表
        create_price_history_sql = """
        CREATE TABLE IF NOT EXISTS price_history (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            app_id TEXT NOT NULL,
            check_date DATE NOT NULL,
            original_price REAL,
            discount_price REAL,
            discount_percent INTEGER,
            is_on_sale BOOLEAN DEFAULT 0,
            FOREIGN KEY (app_id) REFERENCES games(app_id),
            UNIQUE(app_id, check_date)
        )
        """
        
        # 表3:标签表
        create_tags_sql = """
        CREATE TABLE IF NOT EXISTS tags (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            tag_name TEXT UNIQUE NOT NULL
        )
        """
        
        # 表4:游戏-标签关联表(多对多)
        create_game_tags_sql = """
        CREATE TABLE IF NOT EXISTS game_tags (
            app_id TEXT NOT NULL,
            tag_id INTEGER NOT NULL,
            PRIMARY KEY (app_id, tag_id),
            FOREIGN KEY (app_id) REFERENCES games(app_id),
            FOREIGN KEY (tag_id) REFERENCES tags(id)
        )
        """
        
        # 执行创建
        self.conn.execute(create_games_sql)
        self.conn.execute(create_price_history_sql)
        self.conn.execute(create_tags_sql)
        self.conn.execute(create_game_tags_sql)
        
        # 创建索引(提升查询速度)
        index_sqls = [
            "CREATE INDEX IF NOT EXISTS idx_game_name ON games(game_name)",
            "CREATE INDEX IF NOT EXISTS idx_release_date ON games(release_date)",
            "CREATE INDEX IF NOT EXISTS idx_price ON games(original_price_value)",
            "CREATE INDEX IF NOT EXISTS idx_review_count ON games(review_count_value)",
            "CREATE INDEX IF NOT EXISTS idx_price_history_date ON price_history(check_date)",
            "CREATE INDEX IF NOT EXISTS idx_tag_name ON tags(tag_name)"
        ]
        
        for sql in index_sqls:
            self.conn.execute(sql)
        
        self.conn.commit()
        logger.info("✅ 数据表初始化完成")
    
    def save_game(self, game: Dict) -> bool:
        """
        保存单个游戏(插入或更新)
        
        Args:
            game: 游戏数据字典
            
        Returns:
            是否成功
        """
        try:
            # 准备数据
            platforms_str = ','.join(game.get('platforms', []))
            
            # UPSERT 语句(插入或更新)
            # 解释:
            # INSERT OR REPLACE → 如果主键存在则更新,否则插入
            # ON CONFLICT → SQLite 3.24+ 的语法,功能相同但更灵活
            upsert_sql = """
            INSERT INTO games (
                app_id, game_name, thumbnail_url, release_date,
                currency, original_price_raw, original_price_value,
                discount_price_raw, discount_price_value, discount_percent,
                is_free, price_status,
                review_score, review_score_en, review_count_raw, review_count_value,
                platforms, updated_at
            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
            ON CONFLICT(app_id) DO UPDATE SET
                game_name = excluded.game_name,
                thumbnail_url = excluded.thumbnail_url,
                release_date = excluded.release_date,
                currency = excluded.currency,
                original_price_raw = excluded.original_price_raw,
                original_price_value = excluded.original_price_value,
                discount_price_raw = excluded.discount_price_raw,
                discount_price_value = excluded.discount_price_value,
                discount_percent = excluded.discount_percent,
                is_free = excluded.is_free,
                price_status = excluded.price_status,
                review_score = excluded.review_score,
                review_score_en = excluded.review_score_en,
                review_count_raw = excluded.review_count_raw,
                review_count_value = excluded.review_count_value,
                platforms = excluded.platforms,
                updated_at = CURRENT_TIMESTAMP
            """
            
            self.conn.execute(upsert_sql, (
                game['app_id'],
                game['game_name'],
                game.get('thumbnail_url', ''),
                game.get('release_date', ''),
                game.get('currency', 'CNY'),
                game.get('original_price_raw', ''),
                game.get('original_price_value', 0.0),
                game.get('discount_price_raw', ''),
                game.get('discount_price_value', 0.0),
                game.get('discount_percent', 0),
                1 if game.get('is_free') else 0,
                game.get('price_status', 'available'),
                game.get('review_score', ''),
                game.get('review_score_en', ''),
                game.get('review_count_raw', ''),
                game.get('review_count_value', 0),
                platforms_str
            ))
            
            # 保存标签
            self._save_tags(game['app_id'], game.get('tags', []))
            
            # 记录价格历史
            self._record_price_history(game)
            
            self.conn.commit()
            return True
            
        except Exception as e:
            logger.error(f"❌ 保存游戏失败: {game.get('game_name')} - {str(e)}")
            self.conn.rollback()
            return False
    
    def save_games_batch(self, games: List[Dict]) -> Dict[str, int]:
        """
        批量保存游戏
        
        Args:
            games: 游戏列表
            
        Returns:
            统计信息 {'total': 100, 'success': 98, 'failed': 2}
        """
        stats = {'total': len(games), 'success': 0, 'failed': 0}
        
        for game in games:
            if self.save_game(game):
                stats['success'] += 1
            else:
                stats['failed'] += 1
        
        logger.info(f"💾 批量保存完成: {stats}")
        return stats
    
    def _save_tags(self, app_id: str, tags: List[str]):
        """
        保存游戏标签
        
        Args:
            app_id: 游戏 ID
            tags: 标签列表
        """
        if not tags:
            return
        
        # 先删除旧标签关联
        self.conn.execute("DELETE FROM game_tags WHERE app_id = ?", (app_id,))
        
        for tag_name in tags:
            # 插入标签(如果不存在)
            self.conn.execute(
                "INSERT OR IGNORE INTO tags (tag_name) VALUES (?)",
                (tag_name,)
            )
            
            # 获取标签 ID
            cursor = self.conn.execute(
                "SELECT id FROM tags WHERE tag_name = ?",
                (tag_name,)
            )
            tag_id = cursor.fetchone()[0]
            
            # 建立关联
            self.conn.execute(
                "INSERT OR IGNORE INTO game_tags (app_id, tag_id) VALUES (?, ?)",
                (app_id, tag_id)
            )
    
    def _record_price_history(self, game: Dict):
        """
        记录价格历史
        
        Args:
            game: 游戏数据
        """
        today = datetime.now().date().isoformat()
        
        # 检查今天是否已记录
        cursor = self.conn.execute(
            "SELECT id FROM price_history WHERE app_id = ? AND check_date = ?",
            (game['app_id'], today)
        )
        
        if cursor.fetchone():
            return  # 今天已记录,跳过
        
        # 插入新记录
        original_price = game.get('original_price_value', 0.0)
        discount_price = game.get('discount_price_value', 0.0)
        is_on_sale = 1 if discount_price > 0 and discount_price < original_price else 0
        
        self.conn.execute("""
            INSERT INTO price_history 
            (app_id, check_date, original_price, discount_price, discount_percent, is_on_sale)
            VALUES (?, ?, ?, ?, ?, ?)
        """, (
            game['app_id'],
            today,
            original_price,
            discount_price if discount_price > 0 else None,
            game.get('discount_percent', 0),
            is_on_sale
        ))
    
    def get_game(self, app_id: str) -> Optional[Dict]:
        """
        获取单个游戏的完整信息
        
        Args:
            app_id: 游戏 ID
            
        Returns:
            游戏数据字典
        """
        cursor = self.conn.execute(
            "SELECT * FROM games WHERE app_id = ?",
            (app_id,)
        )
        row = cursor.fetchone()
        
        if not row:
            return None
        
        # 转换为字典
        game = dict(row)
        
        # 获取标签
        game['tags'] = self._get_tags(app_id)
        
        # 转换 platforms
        if game['platforms']:
            game['platforms'] = game['platforms'].split(',')
        else:
            game['platforms'] = []
        
        return game
    
    def _get_tags(self, app_id: str) -> List[str]:
        """获取游戏的所有标签"""
        cursor = self.conn.execute("""
            SELECT t.tag_name
            FROM tags t
            JOIN game_tags gt ON t.id = gt.tag_id
            WHERE gt.app_id = ?
        """, (app_id,))
        
        return [row[0] for row in cursor.fetchall()]
    
    def query_games(self, **filters) -> List[Dict]:
        """
        查询游戏
        
        Args:
            **filters: 过滤条件
                - min_price: 最低价格
                - max_price: 最高价格
                - is_free: 是否免费
                - tags: 标签列表
                - release_year: 发行年份
                - min_review_count: 最低评价数
                - order_by: 排序字段
                - limit: 返回数量限制
        
        Returns:
            游戏列表
        """
        conditions = []
        params = []
        
        # 价格范围
        if filters.get('min_price') is not None:
            conditions.append("original_price_value >= ?")
            params.append(filters['min_price'])
        
        if filters.get('max_price') is not None:
            conditions.append("original_price_value <= ?")
            params.append(filters['max_price'])
        
        # 是否免费
        if filters.get('is_free') is not None:
            conditions.append("is_free = ?")
            params.append(1 if filters['is_free'] else 0)
        
        # 发行年份
        if filters.get('release_year'):
            conditions.append("release_date LIKE ?")
            params.append(f"{filters['release_year']}%")
        
        # 最低评价数
        if filters.get('min_review_count'):
            conditions.append("review_count_value >= ?")
            params.append(filters['min_review_count'])
        
        # 构建 SQL
        where_clause = " AND ".join(conditions) if conditions else "1=1"
        
        # 排序
        order_by = filters.get('order_by', 'updated_at DESC')
        
        # 数量限制
        limit = filters.get('limit', 100)
        
        query = f"""
            SELECT * FROM games
            WHERE {where_clause}
            ORDER BY {order_by}
            LIMIT ?
        """
        params.append(limit)
        
        cursor = self.conn.execute(query, params)
        
        games = []
        for row in cursor.fetchall():
            game = dict(row)
            game['tags'] = self._get_tags(game['app_id'])
            game['platforms'] = game['platforms'].split(',') if game['platforms'] else []
            games.append(game)
        
        return games
    
    def get_price_history(self, app_id: str, days: int = 30) -> List[Dict]:
        """
        获取游戏的价格历史
        
        Args:
            app_id: 游戏 ID
            days: 查询天数
            
        Returns:
            价格历史列表
        """
        cursor = self.conn.execute("""
            SELECT * FROM price_history
            WHERE app_id = ?
            ORDER BY check_date DESC
            LIMIT ?
        """, (app_id, days))
        
        return [dict(row) for row in cursor.fetchall()]
    
    def get_stats(self) -> Dict:
        """
        获取统计信息
        
        Returns:
            统计数据
        """
        stats = {}
        
        # 游戏总数
        cursor = self.conn.execute("SELECT COUNT(*) FROM games")
        stats['total_games'] = cursor.fetchone()[0]
        
        # 免费游戏数
        cursor = self.conn.execute("SELECT COUNT(*) FROM games WHERE is_free = 1")
        stats['free_games'] = cursor.fetchone()[0]
        
        # 折扣游戏数
        cursor = self.conn.execute("""
            SELECT COUNT(*) FROM games 
            WHERE discount_price_value > 0 AND discount_price_value < original_price_value
        """)
        stats['on_sale_games'] = cursor.fetchone()[0]
        
        # 平均价格
        cursor = self.conn.execute("""
            SELECT AVG(original_price_value) FROM games 
            WHERE original_price_value > 0
        """)
        stats['avg_price'] = round(cursor.fetchone()[0] or 0, 2)
        
        # 按平台统计
        cursor = self.conn.execute("""
            SELECT 
                SUM(CASE WHEN platforms LIKE '%Windows%' THEN 1 ELSE 0 END) as windows,
                SUM(CASE WHEN platforms LIKE '%Mac%' THEN 1 ELSE 0 END) as mac,
                SUM(CASE WHEN platforms LIKE '%Linux%' THEN 1 ELSE 0 END) as linux
            FROM games
        """)
        row = cursor.fetchone()
        stats['by_platform'] = {
            'Windows': row[0],
            'Mac': row[1],
            'Linux': row[2]
        }
        
        # 热门标签 TOP 10
        cursor = self.conn.execute("""
            SELECT t.tag_name, COUNT(*) as count
            FROM tags t
            JOIN game_tags gt ON t.id = gt.tag_id
            GROUP BY t.tag_name
            ORDER BY count DESC
            LIMIT 10
        """)
        stats['top_tags'] = [
            {'tag': row[0], 'count': row[1]}
            for row in cursor.fetchall()
        ]
        
        return stats
    
    def export_to_json(self, output_dir: str = "data"):
        """
        导出为 JSON 文件(按分类)
        
        Args:
            output_dir: 输出目录
        """
        Path(output_dir).mkdir(parents=True, exist_ok=True)
        
        # 获取所有游戏
        cursor = self.conn.execute("SELECT * FROM games ORDER BY app_id")
        
        games_data = []
        for row in cursor.fetchall():
            game = dict(row)
            game['tags'] = self._get_tags(game['app_id'])
            game['platforms'] = game['platforms'].split(',') if game['platforms'] else []
            games_data.append(game)
        
        # 写入 JSON
        output_file = f"{output_dir}/steam_games_all.json"
        with open(output_file, 'w', encoding='utf-8') as f:
            json.dump(games_data, f, ensure_ascii=False, indent=2)
        
        logger.info(f"📄 导出 JSON: {output_file} ({len(games_data)} 个游戏)")
    
    def export_to_csv(self, csv_path: str = "data/steam_games_all.csv"):
        """
        导出为 CSV
        
        Args:
            csv_path: CSV 文件路径
        """
        # 使用 pandas 导出
        df = pd.read_sql_query("SELECT * FROM games", self.conn)
        df.to_csv(csv_path, index=False, encoding='utf-8-sig')
        
        logger.info(f"📊 导出 CSV: {csv_path} ({len(df)} 个游戏)")
    
    def close(self):
        """关闭数据库连接"""
        self.conn.close()
        logger.info("🔒 数据库连接已关闭")


# ========== 使用示例 ==========
if __name__ == "__main__":
    storage = SteamStorage()
    
    # 测试:保存游戏
    test_game = {
        'app_id': '730',
        'game_name': 'Counter-Strike: Global Offensive',
        'thumbnail_url': 'https://...',
        'release_date': '2012-08-21',
        'currency': 'CNY',
        'original_price_raw': '¥ 98',
        'original_price_value': 98.0,
        'discount_price_raw': '¥ 49',
        'discount_price_value': 49.0,
        'discount_percent': 50,
        'is_free': False,
        'review_score': '特别好评',
        'review_score_en': 'Very Positive',
        'review_count_raw': '1,234,567',
        'review_count_value': 1234567,
        'tags': ['FPS', '多人', '竞技'],
        'platforms': ['Windows', 'Mac', 'Linux']
    }
    
    success = storage.save_game(test_game)
    print(f"保存结果: {'✅ 成功' if success else '❌ 失败'}")
    
    # 测试:查询游戏
    game = storage.get_game('730')
    print(f"\n查询结果: {game}")
    
    # 测试:统计信息
    stats = storage.get_stats()
    print(f"\n统计信息: {json.dumps(stats, ensure_ascii=False, indent=2)}")
    
    storage.close()

🔟 运行方式与结果展示(必写)

主程序(main.py)

python 复制代码
"""
Steam 游戏数据采集 - 主程序

功能:
1. 遍历Steam分类页面采集游戏列表
2. 提取游戏的名称、价格、标签等信息
3. 保存到SQLite数据库
4. 导出JSON和CSV格式

作者:YourName
日期:2025-01-27
"""

import logging
from pathlib import Path
from fetcher import SteamFetcher
from parser import SteamParser
from cleaner import SteamCleaner
from storage import SteamStorage
import time
from datetime import datetime

# 配置日志
Path("logs").mkdir(exist_ok=True)
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s [%(levelname)s] %(name)s - %(message)s',
    handlers=[
        logging.FileHandler('logs/crawler.log', encoding='utf-8'),
        logging.StreamHandler()
    ]
)
logger = logging.getLogger(__name__)


class SteamCrawler:
    """Steam 游戏爬虫主控制器"""
    
    def __init__(self):
        """初始化各个组件"""
        self.fetcher = SteamFetcher()
        self.parser = SteamParser()
        self.cleaner = SteamCleaner()
        self.storage = SteamStorage()
        
        # 统计信息
        self.stats = {
            'start_time': datetime.now(),
            'total_games': 0,
            'success': 0,
            'failed': 0,
            'duplicate': 0
        }
    
    def crawl_genre(self, genre_url: str, genre_name: str, max_pages: int = 5):
        """
        爬取单个分类的所有游戏
        
        Args:
            genre_url: 分类页面URL
            genre_name: 分类名称(用于日志)
            max_pages: 最多爬取多少页
            
        示例:
            crawler.crawl_genre(
                'https://store.steampowered.com/genre/Action/',
                '动作',
                max_pages=10
            )
        """
        logger.info(f"\n{'='*60}")
        logger.info(f"🎮 开始爬取分类: {genre_name}")
        logger.info(f"🔗 URL: {genre_url}")
        logger.info(f"{'='*60}\n")
        
        page = 0
        offset = 0
        page_size = 25  # Steam每页25个游戏
        
        while page < max_pages:
            logger.info(f"📄 正在爬取第 {page + 1} 页(offset={offset})...")
            
            # 获取HTML
            params = {'offset': offset} if offset > 0 else {}
            html = self.fetcher.fetch(genre_url, params=params)
            
            if not html:
                logger.error(f"❌ 获取页面失败,跳过")
                break
            
            # 解析游戏列表
            games = self.parser.parse_game_list(html)
            
            if not games:
                logger.info(f"📭 当前页无游戏数据,可能已到最后一页")
                break
            
            logger.info(f"✅ 解析到 {len(games)} 个游戏")
            
            # 清洗并保存
            saved_count = 0
            for game in games:
                # 数据清洗
                cleaned = self.cleaner.clean_game(game)
                
                if not cleaned:
                    self.stats['failed'] += 1
                    continue
                
                # 数据验证
                if not self.cleaner.validate_game(cleaned):
                    self.stats['failed'] += 1
                    continue
                
                # 保存到数据库
                if self.storage.save_game(cleaned):
                    saved_count += 1
                    self.stats['success'] += 1
                else:
                    self.stats['failed'] += 1
                
                self.stats['total_games'] += 1
            
            logger.info(f"💾 本页保存 {saved_count}/{len(games)} 个游戏")
            
            # 检查是否有下一页
            pagination = self.parser.get_pagination_info(html)
            if not pagination.get('has_next'):
                logger.info(f"📌 已到达最后一页")
                break
            
            # 翻页
            page += 1
            offset += page_size
            
            # 进度显示
            self._print_progress()
    
    def crawl_all_genres(self, genres: list = None, max_pages_per_genre: int = 5):
        """
        爬取所有分类
        
        Args:
            genres: 分类列表,如果为None则自动获取
            max_pages_per_genre: 每个分类最多爬多少页
        """
        logger.info("🚀 开始爬取Steam游戏数据...")
        
        # 如果没有提供分类列表,先获取
        if not genres:
            logger.info("📋 步骤1:获取分类列表...")
            homepage_html = self.fetcher.fetch('https://store.steampowered.com/')
            
            if not homepage_html:
                logger.error("❌ 无法获取Steam首页,退出")
                return
            
            genres = self.parser.parse_genres(homepage_html)
            
            if not genres:
                logger.error("❌ 未解析到任何分类,退出")
                return
        
        logger.info(f"✅ 获取到 {len(genres)} 个分类")
        logger.info(f"📊 预计爬取 {len(genres) * max_pages_per_genre * 25} 个游戏\n")
        
        # 遍历每个分类
        for idx, genre in enumerate(genres, 1):
            logger.info(f"\n进度: [{idx}/{len(genres)}] 分类: {genre['name']}")
            
            try:
                self.crawl_genre(
                    genre['url'],
                    genre['name'],
                    max_pages=max_pages_per_genre
                )
            except Exception as e:
                logger.error(f"❌ 分类 {genre['name']} 爬取失败: {str(e)}")
                continue
            
            # 每个分类之间增加延迟
            time.sleep(3)
        
        # 爬取完成
        self._print_final_report()
    
    def _print_progress(self):
        """打印实时进度"""
        elapsed = (datetime.now() - self.stats['start_time']).total_seconds()
        rate = self.stats['total_games'] / elapsed if elapsed > 0 else 0
        
        logger.info(f"""
        ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
        📊 当前进度:
           总计: {self.stats['total_games']} 个
           成功: {self.stats['success']} 个
           失败: {self.stats['failed']} 个
           速度: {rate:.1f} 游戏/秒
           耗时: {elapsed/60:.1f} 分钟
        ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
        """)
    
    def _print_final_report(self):
        """打印最终报告"""
        elapsed = (datetime.now() - self.stats['start_time']).total_seconds()
        
        logger.info(f"""
        
        {'='*70}
        ========== 🎉 爬取完成 ==========
        
        📊 总体统计:
           - 爬取游戏: {self.stats['total_games']} 个
           - 成功保存: {self.stats['success']} 个
           - 失败跳过: {self.stats['failed']} 个
           - 成功率: {self.stats['success']/self.stats['total_games']*100:.2f}%
        
        ⏱️  性能指标:
           - 总耗时: {elapsed/60:.1f} 分钟
           - 平均速度: {self.stats['total_games']/elapsed:.2f} 游戏/秒
        
        🌐 网络统计:
           - HTTP请求: {self.fetcher.stats['total_requests']} 次
           - 成功率: {self.fetcher.get_stats()['success_rate']}
           - 重试次数: {self.fetcher.stats['retries']} 次
        
        💾 数据库统计:
        """)
        
        # 获取数据库统计
        db_stats = self.storage.get_stats()
        logger.info(f"   - 游戏总数: {db_stats['total_games']}")
        logger.info(f"   - 免费游戏: {db_stats['free_games']}")
        logger.info(f"   - 折扣游戏: {db_stats['on_sale_games']}")
        logger.info(f"   - 平均价格: ¥ {db_stats['avg_price']}")
        
        logger.info(f"\n🏷️  热门标签 TOP 10:")
        for tag_info in db_stats['top_tags']:
            logger.info(f"   - {tag_info['tag']}: {tag_info['count']} 个游戏")
        
        logger.info(f"\n💻 平台分布:")
        for platform, count in db_stats['by_platform'].items():
            logger.info(f"   - {platform}: {count} 个游戏")
        
        logger.info(f"\n{'='*70}\n")
    
    def export_data(self):
        """导出数据"""
        logger.info("\n📤 开始导出数据...")
        
        try:
            # 导出JSON
            self.storage.export_to_json()
            
            # 导出CSV
            self.storage.export_to_csv()
            
            logger.info("✅ 数据导出完成!")
        except Exception as e:
            logger.error(f"❌ 数据导出失败: {str(e)}")
    
    def close(self):
        """关闭所有连接"""
        self.fetcher.close()
        self.storage.close()
        logger.info("🔒 所有连接已关闭")


def main():
    """主函数"""
    # 创建爬虫实例
    crawler = SteamCrawler()
    
    # 方式1:爬取指定分类
    # crawler.crawl_genre(
    #     'https://store.steampowered.com/genre/Action/',
    #     '动作',
    #     max_pages=10
    # )
    
    # 方式2:爬取所有分类(测试时只爬3个分类)
    test_genres = [
        {'name': '动作', 'url': 'https://store.steampowered.com/genre/Action/'},
        {'name': '冒险', 'url': 'https://store.steampowered.com/genre/Adventure/'},
        {'name': '策略', 'url': 'https://store.steampowered.com/genre/Strategy/'}
    ]
    
    crawler.crawl_all_genres(
        genres=test_genres,
        max_pages_per_genre=3  # 测试时只爬3页
    )
    
    # 导出数据
    crawler.export_data()
    
    # 关闭连接
    crawler.close()


if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        logger.info("\n⚠️ 用户中断,正在保存已采集数据...")
    except Exception as e:
        logger.error(f"\n❌ 程序异常退出: {str(e)}", exc_info=True)
    finally:
        logger.info("👋 程序结束")

启动方式

bash 复制代码
# 1. 克隆项目
git clone https://github.com/yourname/steam-crawler.git
cd steam-crawler

# 2. 安装依赖
pip install -r requirements.txt --break-system-packages

# 3. 准备Cookie(重要!)
# 打开浏览器访问 https://store.steampowered.com/
# 完成年龄验证和地区选择
# 复制Cookie到 cookies/steam_cookies.txt

# 4. 运行爬虫
python main.py

# 5. 查看结果
ls data/
# 输出: steam_games.db  steam_games_all.json  steam_games_all.csv

# 6. 查询数据库
sqlite3 data/steam_games.db "SELECT game_name, original_price_value FROM games LIMIT 5;"

配置文件(config.py)

python 复制代码
"""
配置文件

可以在这里统一管理所有配置项
"""

# Steam相关配置
STEAM_BASE_URL = "https://store.steampowered.com"
STEAM_COUNTRY = "CN"  # 地区代码(CN/US/JP等)
STEAM_LANGUAGE = "schinese"  # 语言代码

# 爬虫行为配置
REQUEST_DELAY = 2.5  # 请求间隔(秒)
REQUEST_TIMEOUT = 15  # 请求超时(秒)
MAX_RETRIES = 3  # 最大重试次数
MAX_PAGES_PER_GENRE = 10  # 每个分类最多爬取页数

# Cookie文件路径
COOKIE_FILE = "cookies/steam_cookies.txt"

# 数据库配置
DATABASE_PATH = "data/steam_games.db"

# 导出配置
EXPORT_DIR = "data"
EXPORT_JSON = True
EXPORT_CSV = True

# 日志配置
LOG_DIR = "logs"
LOG_LEVEL = "INFO"

# 代理配置(可选)
USE_PROXY = False
PROXY_URL = "http://127.0.0.1:7890"  # HTTP代理地址

# User-Agent池(随机轮换)
USER_AGENTS = [
    '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 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36',
    'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
    'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
]

输出示例

终端日志:

json 复制代码
2025-01-27 20:30:10 [INFO] 🚀 开始爬取Steam游戏数据...
2025-01-27 20:30:10 [INFO] 📋 步骤1:获取分类列表...
2025-01-27 20:30:12 [INFO] ✅ 获取到 3 个分类
2025-01-27 20:30:12 [INFO] 📊 预计爬取 225 个游戏

============================================================
🎮 开始爬取分类: 动作
🔗 URL: https://store.steampowered.com/genre/Action/
============================================================

2025-01-27 20:30:15 [INFO] 📄 正在爬取第 1 页(offset=0)...
2025-01-27 20:30:18 [INFO] ✅ 成功: https://store.steampowered.com/genre/Action/ (45678 字符)
2025-01-27 20:30:18 [INFO] 🎮 列表页解析完成,提取 25 个游戏
2025-01-27 20:30:18 [INFO] ✅ 解析到 25 个游戏
2025-01-27 20:30:20 [INFO] 💾 本页保存 25/25 个游戏

        ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
        📊 当前进度:
           总计: 25 个
           成功: 25 个
           失败: 0 个
           速度: 2.5 游戏/秒
           耗时: 0.2 分钟
        ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

2025-01-27 20:30:23 [INFO] 📄 正在爬取第 2 页(offset=25)...
...

2025-01-27 20:45:30 [INFO] 进度: [3/3] 分类: 策略
2025-01-27 20:45:35 [INFO] 📌 已到达最后一页

        
        ======================================================================
        ========== 🎉 爬取完成 ==========
        
        📊 总体统计:
           - 爬取游戏: 225 个
           - 成功保存: 220 个
           - 失败跳过: 5 个
           - 成功率: 97.78%
        
        ⏱️  性能指标:
           - 总耗时: 15.3 分钟
           - 平均速度: 0.25 游戏/秒
        
        🌐 网络统计:
           - HTTP请求: 27 次
           - 成功率: 100.00%
           - 重试次数: 0 次
        
        💾 数据库统计:
           - 游戏总数: 220
           - 免费游戏: 45
           - 折扣游戏: 78
           - 平均价格: ¥ 67.8
        
        🏷️  热门标签 TOP 10:
           - 动作: 120 个游戏
           - 冒险: 85 个游戏
           - 独立: 65 个游戏
           - 策略: 60 个游戏
           - 角色扮演: 48 个游戏
           - 模拟: 42 个游戏
           - 多人: 38 个游戏
           - 单人: 180 个游戏
           - 休闲: 35 个游戏
           - 解谜: 28 个游戏
        
        💻 平台分布:
           - Windows: 220 个游戏
           - Mac: 95 个游戏
           - Linux: 72 个游戏
        
        ======================================================================

2025-01-27 20:45:35 [INFO] 📤 开始导出数据...
2025-01-27 20:45:36 [INFO] 📄 导出 JSON: data/steam_games_all.json (220 个游戏)
2025-01-27 20:45:37 [INFO] 📊 导出 CSV: data/steam_games_all.csv (220 个游戏)
2025-01-27 20:45:37 [INFO] ✅ 数据导出完成!
2025-01-27 20:45:37 [INFO] 🔒 所有连接已关闭
2025-01-27 20:45:37 [INFO] 👋 程序结束

JSON 文件示例(data/steam_games_all.json)

json 复制代码
[
  {
    "app_id": "730",
    "game_name": "Counter-Strike: Global Offensive",
    "thumbnail_url": "https://cdn.cloudflare.steamstatic.com/steam/apps/730/capsule_sm_120.jpg",
    "release_date": "2012-08-21",
    "currency": "CNY",
    "original_price_raw": "免费",
    "original_price_value": 0.0,
    "discount_price_raw": "",
    "discount_price_value": 0.0,
    "discount_percent": 0,
    "is_free": true,
    "price_status": "available",
    "review_score": "特别好评",
    "review_score_en": "Very Positive",
    "review_count_raw": "1,234,567",
    "review_count_value": 1234567,
    "platforms": ["Windows", "Mac", "Linux"],
    "tags": ["FPS", "多人", "竞技", "动作", "射击"],
    "created_at": "2025-01-27 20:30:18",
    "updated_at": "2025-01-27 20:30:18"
  },
  {
    "app_id": "1091500",
    "game_name": "赛博朋克2077",
    "thumbnail_url": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_sm_120.jpg",
    "release_date": "2020-12-10",
    "currency": "CNY",
    "original_price_raw": "¥ 298",
    "original_price_value": 298.0,
    "discount_price_raw": "¥ 149",
    "discount_price_value": 149.0,
    "discount_percent": 50,
    "is_free": false,
    "price_status": "available",
    "review_score": "多半好评",
    "review_score_en": "Mostly Positive",
    "review_count_raw": "567,890",
    "review_count_value": 567890,
    "platforms": ["Windows"],
    "tags": ["角色扮演", "开放世界", "科幻", "动作", "单人"],
    "created_at": "2025-01-27 20:30:20",
    "updated_at": "2025-01-27 20:30:20"
  }
]

CSV 文件示例(data/steam_games_all.csv)

app_id game_name original_price_value discount_price_value discount_percent release_date review_score review_count_value platforms
730 Counter-Strike: Global Offensive 0.0 0.0 0 2012-08-21 特别好评 1234567 Windows,Mac,Linux
1091500 赛博朋克2077 298.0 149.0 50 2020-12-10 多半好评 567890 Windows
271590 Grand Theft Auto V 139.0 0.0 0 2015-04-14 特别好评 2345678 Windows

1️⃣1️⃣ 常见问题与排错(强烈建议写)

问题1:403 Forbidden - Cookie验证失败

现象:

复制代码
❌ 403 Forbidden: https://store.steampowered.com/genre/Action/
💡 可能原因:Cookie 无效、IP 被封、地区限制

原因分析:

  1. Cookie过期 :birthtime或steamCountry失效
  2. 未通过年龄验证:Steam要求确认年龄
  3. 地区限制:某些游戏仅在特定地区可见

解决方案:

python 复制代码
# 方案1:重新获取Cookie
# 步骤:
# 1. 打开浏览器无痕模式
# 2. 访问 https://store.steampowered.com/
# 3. 完成年龄验证
# 4. 打开DevTools → Application → Cookies
# 5. 复制 birthtime 和 steamCountry

# 方案2:使用浏览器Cookie自动提取
import browser_cookie3

cookies = browser_cookie3.chrome(domain_name='steampowered.com')
cookie_dict = {c.name: c.value for c in cookies}

# 保存到文件
with open('cookies/steam_cookies.txt', 'w') as f:
    for key, value in cookie_dict.items():
        f.write(f"{key}={value}\n")

# 方案3:添加更多Headers
headers.update({
    'Referer': 'https://store.steampowered.com/',
    'Origin': 'https://store.steampowered.com',
    'DNT': '1',
    'Sec-Ch-Ua': '"Not_A Brand";v="8", "Chromium";v="120"',
    'Sec-Ch-Ua-Mobile': '?0',
    'Sec-Ch-Ua-Platform': '"Windows"'
})

问题2:解析结果为空 - HTML结构变化

现象:

python 复制代码
games = parser.parse_game_list(html)
# 返回 []

调试步骤:

python 复制代码
# 步骤1:保存HTML到本地
with open('debug.html', 'w', encoding='utf-8') as f:
    f.write(html)

# 步骤2:在浏览器中查看
# 用Chrome打开debug.html,检查实际结构

# 步骤3:测试XPath
from lxml import etree

tree = etree.HTML(html)
rows = tree.xpath('//div[contains(@class, "search_result_row")]')
print(f"找到 {len(rows)} 个游戏行")

# 如果为0,尝试其他选择器
rows = tree.xpath('//a[@class="search_result_row"]')
rows = tree.xpath('//div[@id="search_resultsRows"]//a')

# 步骤4:打印第一个元素的结构
if rows:
    print(etree.tostring(rows[0], encoding='unicode', pretty_print=True))

常见原因:

  • Steam改版,class名称变化
  • 动态class(如-1a2b3c4")
  • 区域差异(中国区和国际区HTML不同)

解决方案:

python 复制代码
# 使用更宽松的选择器
# ❌ 过于精确
rows = tree.xpath('//div[@class="search_result_row"]')

# ✅ 模糊匹配
rows = tree.xpath('//div[contains(@class, "search_result")]')

# ✅ 多种尝试
rows = tree.xpath('//div[contains(@class, "search_result_row")]') or \
       tree.xpath('//a[contains(@class, "search_result")]') or \
       tree.xpath('//div[@id="search_resultsRows"]//a')

问题3:价格解析错误 - 特殊格式

现象:

复制代码
⚠️ 无法解析原价: ₽ 1 299,00
⚠️ 无法解析原价: $49.99 USD

原因:不同地区的价格格式差异很大

python 复制代码
# 俄罗斯卢布:空格作为千位分隔符,逗号作为小数点
"₽ 1 299,00"  → 1299.00

# 美元:逗号作为千位分隔符,点作为小数点
"$1,299.99"   → 1299.99

# 欧元:点作为千位分隔符,逗号作为小数点
"€1.299,99"   → 1299.99

# 印度卢比:使用印度数字系统
"₹1,23,456"   → 123456

增强的价格解析:

python 复制代码
def parse_price_robust(price_str: str) -> float:
    """
    健壮的价格解析(支持多种格式)
    
    Args:
        price_str: 价格字符串
        
    Returns:
        数字价格
    """
    if not price_str:
        return 0.0
    
    # 移除货币符号和空格
    price_str = re.sub(r'[¥$€£₽₩₹R\s]', '', price_str)
    
    # 检测小数点类型
    # 如果同时有逗号和点,则后出现的是小数点
    if ',' in price_str and '.' in price_str:
        comma_pos = price_str.rfind(',')
        dot_pos = price_str.rfind('.')
        
        if comma_pos > dot_pos:
            # 逗号是小数点(欧洲格式)
            price_str = price_str.replace('.', '').replace(',', '.')
        else:
            # 点是小数点(美国格式)
            price_str = price_str.replace(',', '')
    
    elif ',' in price_str:
        # 只有逗号:判断是千位分隔符还是小数点
        # 如果逗号后面有2位数字,通常是小数点
        match = re.search(r',(\d{2})$', price_str)
        if match:
            price_str = price_str.replace(',', '.')
        else:
            price_str = price_str.replace(',', '')
    
    # 转换为浮点数
    try:
        return float(price_str)
    except ValueError:
        logger.warning(f"⚠️ 价格解析失败: {price_str}")
        return 0.0


# 测试
test_prices = [
    "¥ 98",
    "$1,299.99",
    "€1.299,99",
    "₽ 1 299,00",
    "₹1,23,456"
]

for price in test_prices:
    result = parse_price_robust(price)
    print(f"{price:20s} → {result}")

# 输出:
# ¥ 98                → 98.0
# $1,299.99           → 1299.99
# €1.299,99           → 1299.99
# ₽ 1 299,00          → 1299.0
# ₹1,23,456           → 123456.0

问题4:数据库锁定 - 并发写入冲突

现象:

json 复制代码
sqlite3.OperationalError: database is locked

原因:SQLite不支持高并发写入

解决方案:

python 复制代码
# 方案A:使用队列 + 单线程写入
import queue
import threading

class DatabaseWriter(threading.Thread):
    """专门的数据库写入线程"""
    
    def __init__(self, storage, db_queue):
        super().__init__(daemon=True)
        self.storage = storage
        self.db_queue = db_queue
        self.running = True
    
    def run(self):
        while self.running:
            try:
                # 从队列获取数据
                game = self.db_queue.get(timeout=1)
                
                if game is None:  # 退出信号
                    break
                
                # 写入数据库
                self.storage.save_game(game)
                
                self.db_queue.task_done()
            except queue.Empty:
                continue
    
    def stop(self):
        self.running = False


# 使用
db_queue = queue.Queue()
writer = DatabaseWriter(storage, db_queue)
writer.start()

# 爬虫线程只往队列放数据
for game in games:
    cleaned = cleaner.clean_game(game)
    db_queue.put(cleaned)

# 结束时等待队列清空
db_queue.join()
db_queue.put(None)  # 发送退出信号
writer.join()

# 方案B:启用WAL模式
conn = sqlite3.connect('steam_games.db')
conn.execute("PRAGMA journal_mode=WAL")
# WAL(Write-Ahead Logging)允许并发读写

# 方案C:批量提交(减少事务次数)
def save_games_batch(games, batch_size=100):
    for i in range(0, len(games), batch_size):
        batch = games[i:i+batch_size]
        
        conn.executemany(insert_sql, batch)
        conn.commit()

问题5:内存占用过高

现象:

json 复制代码
MemoryError: Unable to allocate array

原因:一次性加载太多数据到内存

解决方案:

python 复制代码
# 方案1:流式处理(不一次性加载所有游戏)
def crawl_genre_streaming(genre_url, genre_name):
    """流式爬取(边爬边存,不积累在内存)"""
    page = 0
    
    while True:
        # 获取当前页
        html = fetcher.fetch(genre_url, params={'offset': page * 25})
        games = parser.parse_game_list(html)
        
        # 立即处理并保存(不积累)
        for game in games:
            cleaned = cleaner.clean_game(game)
            if cleaned:
                storage.save_game(cleaned)
        
        # 检查是否有下一页
        pagination = parser.get_pagination_info(html)
        if not pagination['has_next']:
            break
        
        page += 1

# 方案2:定期清理大对象
import gc

for idx, genre in enumerate(genres):
    crawl_genre(genre['url'], genre['name'])
    
    # 每10个分类清理一次
    if idx % 10 == 0:
        gc.collect()

# 方案3:使用生成器
def parse_games_generator(html):
    """生成器版本(逐个yield,不返回列表)"""
    tree = etree.HTML(html)
    rows = tree.xpath('//div[contains(@class, "search_result_row")]')
    
    for row in rows:
        game = parse_game_row(row)
        if game:
            yield game

# 使用
for game in parse_games_generator(html):
    cleaned = cleaner.clean_game(game)
    storage.save_game(cleaned)

问题6:IP被封 - 请求过于频繁

现象:

json 复制代码
⚠️ 429 Too Many Requests
💡 建议:增加请求间隔或使用代理

原因:触发了Steam的频率限制

解决方案:

python 复制代码
# 方案1:增加延迟
self.delay = 5.0  # 从2.5秒增加到5秒

# 方案2:随机延迟
import random
delay = random.uniform(3, 7)  # 3-7秒随机
time.sleep(delay)

# 方案3:使用代理池
PROXY_LIST = [
    'http://proxy1.com:8080',
    'http://proxy2.com:8080',
    'http://proxy3.com:8080'
]

def fetch_with_proxy(url):
    proxy = random.choice(PROXY_LIST)
    proxies = {'http': proxy, 'https': proxy}
    
    response = session.get(url, proxies=proxies)
    return response.text

# 方案4:分时段爬取
import time
from datetime import datetime

def is_peak_time():
    """判断是否是Steam高峰期"""
    hour = datetime.now().hour
    # Steam美国用户高峰期:北京时间早上6点-12点
    return 6 <= hour <= 12

# 动态调整延迟
if is_peak_time():
    delay = 10.0  # 高峰期增加延迟
else:
    delay = 2.5   # 低峰期正常延迟

1️⃣2️⃣ 进阶优化(可选但加分)

并发加速

需求:采集1000个游戏太慢,如何提速?

方案1:多线程(ThreadPoolExecutor)

python 复制代码
from concurrent.futures import ThreadPoolExecutor, as_completed
import threading

class ConcurrentSteamCrawler:
    """支持并发的Steam爬虫"""
    
    def __init__(self, max_workers=5):
        self.max_workers = max_workers
        self.lock = threading.Lock()  # 保护共享资源
        self.stats = {'success': 0, 'failed': 0}
    
    def crawl_genre_concurrent(self, genre_url, genre_name, max_pages=10):
        """并发爬取单个分类的多个页面"""
        logger.info(f"🚀 开始并发爬取: {genre_name}")
        
        # 生成所有页面的URL
        page_urls = []
        for page in range(max_pages):
            offset = page * 25
            params = {'offset': offset} if offset > 0 else {}
            page_urls.append((genre_url, params, page + 1))
        
        # 使用线程池并发爬取
        with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
            # 提交所有任务
            future_to_page = {
                executor.submit(self._fetch_and_parse, url, params, page_num): page_num
                for url, params, page_num in page_urls
            }
            
            # 处理完成的任务
            for future in as_completed(future_to_page):
                page_num = future_to_page[future]
                try:
                    games = future.result()
                    if games:
                        self._save_games_thread_safe(games)
                        logger.info(f"✅ 第 {page_num} 页: {len(games)} 个游戏")
                    else:
                        logger.warning(f"📭 第 {page_num} 页: 无数据")
                except Exception as e:
                    logger.error(f"❌ 第 {page_num} 页失败: {str(e)}")
    
    def _fetch_and_parse(self, url, params, page_num):
        """获取并解析页面(线程安全)"""
        # 每个线程有自己的fetcher实例
        fetcher = SteamFetcher()
        parser = SteamParser()
        
        html = fetcher.fetch(url, params=params)
        if not html:
            return None
        
        games = parser.parse_game_list(html)
        fetcher.close()
        
        return games
    
    def _save_games_thread_safe(self, games):
        """线程安全地保存游戏"""
        with self.lock:
            for game in games:
                cleaned = self.cleaner.clean_game(game)
                if cleaned and self.storage.save_game(cleaned):
                    self.stats['success'] += 1
                else:
                    self.stats['failed'] += 1


# 使用
concurrent_crawler = ConcurrentSteamCrawler(max_workers=5)
concurrent_crawler.crawl_genre_concurrent(
    'https://store.steampowered.com/genre/Action/',
    '动作',
    max_pages=20
)

性能对比:

  • 单线程:20页 × 2.5秒 = 50秒
  • 5线程并发:20页 / 5 = 4批 × 2.5秒 = 10秒
  • 速度提升:约5倍

注意事项:

  • 控制并发数(5-10个线程即可)
  • 每个线程独立的fetcher实例
  • 使用锁保护共享资源(如统计数据)
  • 监控429错误率,如果过高则降低并发

增量更新

需求:定期更新数据,但不重复爬取旧数据

实现思路:

python 复制代码
def crawl_incremental(self):
    """增量更新爬虫"""
    logger.info("🔄 开始增量更新...")
    
    # 获取所有已存在的游戏ID
    cursor = self.storage.conn.execute("SELECT app_id FROM games")
    existing_ids = {row[0] for row in cursor.fetchall()}
    
    logger.info(f"📊 数据库现有 {len(existing_ids)} 个游戏")
    
    new_count = 0
    update_count = 0
    
    for genre in self.genres:
        html = self.fetcher.fetch(genre['url'])
        games = self.parser.parse_game_list(html)
        
        for game in games:
            app_id = game['app_id']
            
            if app_id in existing_ids:
                # 已存在:只更新价格等动态信息
                self._update_game_price(game)
                update_count += 1
            else:
                # 新游戏:完整保存
                cleaned = self.cleaner.clean_game(game)
                if cleaned:
                    self.storage.save_game(cleaned)
                    new_count += 1
    
    logger.info(f"✅ 增量更新完成:新增 {new_count} 个,更新 {update_count} 个")

def _update_game_price(self, game):
    """只更新价格信息(快速)"""
    self.storage.conn.execute("""
        UPDATE games SET
            original_price_raw = ?,
            original_price_value = ?,
            discount_price_raw = ?,
            discount_price_value = ?,
            discount_percent = ?,
            updated_at = CURRENT_TIMESTAMP
        WHERE app_id = ?
    """, (
        game.get('original_price', ''),
        game.get('original_price_value', 0.0),
        game.get('discount_price', ''),
        game.get('discount_price_value', 0.0),
        game.get('discount_percent', 0),
        game['app_id']
    ))
    
    # 记录价格历史
    self.storage._record_price_history(game)
    
    self.storage.conn.commit()

价格监控与提醒

需求:监控心愿单游戏价格,打折时自动通知

实现示例:

python 复制代码
class PriceMonitor:
    """价格监控器"""
    
    def __init__(self, storage):
        self.storage = storage
    _price: float = None):
        """添加到心愿单"""
        self.storage.conn.execute("""
            INSERT OR REPLACE INTO wishlist (app_id, target_price, created_at)
            VALUES (?, ?, CURRENT_TIMESTAMP)
        """, (app_id, target_price))
        self.storage.conn.commit()
        
        logger.info(f"❤️ 已添加到心愿单: {app_id}")
    
    def check_wishlist_deals(self):
        """检查心愿单游戏是否打折"""
        cursor = self.storage.conn.execute("""
            SELECT 
                w.app_id,
                g.game_name,
                g.original_price_value,
                g.discount_price_value,
                g.discount_percent,
                w.target_price
            FROM wishlist w
            JOIN games g ON w.app_id = g.app_id
            WHERE g.discount_price_value > 0 
              AND g.discount_price_value < g.original_price_value
        """)
        
        deals = []
        for row in cursor.fetchall():
            app_id, name, original, discount, percent, target = row
            
            # 检查是否达到目标价格
            if target and discount <= target:
                deals.append({
                    'app_id': app_id,
                    'name': name,
                    'original_price': original,
                    'discount_price': discount,
                    'discount_percent': percent,
                    'reached_target': True
                })
            elif percent >= 50:  # 或者折扣力度大于50%
                deals.append({
                    'app_id': app_id,
                    'name': name,
                    'original_price': original,
                    'discount_price': discount,
                    'discount_percent': percent,
                    'reached_target': False
                })
        
        if deals:
            self._send_notifications(deals)
        
        return deals
    
    def _send_notifications(self, deals):
        """发送通知"""
        message = "🎮 Steam心愿单折扣提醒\n\n"
        
        for deal in deals:
            message += f"【{deal['name']}】\n"
            message += f"  原价: ¥{deal['original_price']}\n"
            message += f"  现价: ¥{deal['discount_price']}\n"
            message += f"  折扣: -{deal['discount_percent']}%\n"
            
            if deal['reached_target']:
                message += "  ⭐ 已达到目标价格!\n"
            
            message += f"  链接: https://store.steampowered.com/app/{deal['app_id']}/\n\n"
        
        # 发送邮件
        self._send_email("Steam折扣提醒", message)
        
        # 或发送到企业微信/钉钉
        self._send_webhook(message)
    
    def _send_email(self, subject, content):
        """发送邮件通知"""
        import smtplib
        from email.mime.text import MIMEText
        
        msg = MIMEText(content, 'plain', 'utf-8')
        msg['Subject'] = subject
        msg['From'] = 'your-email@gmail.com'
        msg['To'] = 'your-email@gmail.com'
        
        try:
            with smtplib.SMTP_SSL('smtp.gmail.com', 465) as server:
                server.login('your-email@gmail.com', 'your-app-password')
                server.send_message(msg)
            logger.info("📧 邮件通知已发送")
        except Exception as e:
            logger.error(f"❌ 邮件发送失败: {str(e)}")
    
    def _send_webhook(self, message):
        """发送到Webhook(企业微信/钉钉/Slack等)"""
        import requests
        
        webhook_url = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY"
        
        data = {
            "msgtype": "text",
            "text": {
                "content": message
            }
        }
        
        try:
            response = requests.post(webhook_url, json=data)
            if response.status_code == 200:
                logger.info("✅ Webhook通知已发送")
        except Exception as e:
            logger.error(f"❌ Webhook发送失败: {str(e)}")


# 使用
monitor = PriceMonitor(storage)

# 添加心愿单
monitor.add_to_wishlist('730', target_price=50.0)  # CS:GO目标价50元
monitor.add_to_wishlist('1091500', target_price=150.0)  # 赛博朋克2077目标价150元

# 检查折扣
deals = monitor.check_wishlist_deals()

数据可视化

需求:通过图表直观展示数据

实现示例:

python 复制代码
import matplotlib.pyplot as plt
import pandas as pd
from matplotlib import rcParams

# 配置中文字体
rcParams['font.sans-serif'] = ['SimHei', 'Arial Unicode MS']
rcParams['axes.unicode_minus'] = False

class DataVisualizer:
    """数据可视化"""
    
    def __init__(self, storage):
        self.storage = storage
    
    def generate_all_charts(self, output_dir='data/charts'):
        """生成所有图表"""
        Path(output_dir).mkdir(parents=True, exist_ok=True)
        
        self.plot_price_distribution(output_dir)
        self.plot_tags_wordcloud(output_dir)
        self.plot_release_timeline(output_dir)
        self.plot_discount_analysis(output_dir)
        self.plot_platform_distribution(output_dir)
    
    def plot_price_distribution(self, output_dir):
        """价格分布直方图"""
        df = pd.read_sql_query("""
            SELECT original_price_value 
            FROM games 
            WHERE original_price_value > 0 AND original_price_value < 1000
        """, self.storage.conn)
        
        plt.figure(figsize=(10, 6))
        plt.hist(df['original_price_value'], bins=50, color='skyblue', edgecolor='black')
        plt.xlabel('价格(元)')
        plt.ylabel('游戏数量')
        plt.title('Steam游戏价格分布')
        plt.grid(axis='y', alpha=0.3)
        
        # 添加统计信息
        mean_price = df['original_price_value'].mean()
        median_price = df['original_price_value'].median()
        plt.axvline(mean_price, color='red', linestyle='--', label=f'平均价格: ¥{mean_price:.2f}')
        plt.axvline(median_price, color='green', linestyle='--', label=f'中位价格: ¥{median_price:.2f}')
        plt.legend()
        
        plt.tight_layout()
        plt.savefig(f'{output_dir}/price_distribution.png', dpi=150)
        plt.close()
        
        logger.info(f"📊 已生成:价格分布图")
    
    def plot_tags_wordcloud(self, output_dir):
        """标签词云"""
        from wordcloud import WordCloud
        
        # 获取所有标签及其频率
        df = pd.read_sql_query("""
            SELECT t.tag_name, COUNT(*) as count
            FROM tags t
            JOIN game_tags gt ON t.id = gt.tag_id
            GROUP BY t.tag_name
        """, self.storage.conn)
        
        # 构建词频字典
        word_freq = dict(zip(df['tag_name'], df['count']))
        
        # 生成词云
        wordcloud = WordCloud(
            width=1200,
            height=600,
            background_color='white',
            font_path='simhei.ttf',  # 中文字体路径
            max_words=100,
            relative_scaling=0.5
        ).generate_from_frequencies(word_freq)
        
        plt.figure(figsize=(12, 6))
        plt.imshow(wordcloud, interpolation='bilinear')
        plt.axis('off')
        plt.title('Steam游戏标签词云', fontsize=20, pad=20)
        plt.tight_layout()
        plt.savefig(f'{output_dir}/tags_wordcloud.png', dpi=150)
        plt.close()
        
        logger.info(f"📊 已生成:标签词云")
    
    def plot_release_timeline(self, output_dir):
        """发行时间线"""
        df = pd.read_sql_query("""
            SELECT substr(release_date, 1, 4) as year, COUNT(*) as count
            FROM games
            WHERE release_date != ''
            GROUP BY year
            ORDER BY year
        """, self.storage.conn)
        
        plt.figure(figsize=(12, 6))
        plt.bar(df['year'], df['count'], color='coral', edgecolor='black')
        plt.xlabel('年份')
        plt.ylabel('发行游戏数量')
        plt.title('Steam游戏发行时间线')
        plt.xticks(rotation=45)
        plt.grid(axis='y', alpha=0.3)
        plt.tight_layout()
        plt.savefig(f'{output_dir}/release_timeline.png', dpi=150)
        plt.close()
        
        logger.info(f"📊 已生成:发行时间线")
    
    def plot_discount_analysis(self, output_dir):
        """折扣力度分析"""
        df = pd.read_sql_query("""
            SELECT discount_percent, COUNT(*) as count
            FROM games
            WHERE discount_percent > 0
            GROUP BY discount_percent
            ORDER BY discount_percent
        """, self.storage.conn)
        
        # 分组统计
        bins = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]
        labels = ['0-10%', '10-20%', '20-30%', '30-40%', '40-50%',
                  '50-60%', '60-70%', '70-80%', '80-90%', '90-100%']
        
        df['discount_range'] = pd.cut(df['discount_percent'], bins=bins, labels=labels, right=False)
        grouped = df.groupby('discount_range')['count'].sum()
        
        plt.figure(figsize=(10, 6))
        grouped.plot(kind='bar', color='lightgreen', edgecolor='black')
        plt.xlabel('折扣力度')
        plt.ylabel('游戏数量')
        plt.title('Steam游戏折扣力度分布')
        plt.xticks(rotation=45)
        plt.grid(axis='y', alpha=0.3)
        plt.tight_layout()
        plt.savefig(f'{output_dir}/discount_analysis.png', dpi=150)
        plt.close()
        
        logger.info(f"📊 已生成:折扣分析图")
    
    def plot_platform_distribution(self, output_dir):
        """平台分布饼图"""
        df = pd.read_sql_query("""
            SELECT 
                SUM(CASE WHEN platforms LIKE '%Windows%' THEN 1 ELSE 0 END) as Windows,
                SUM(CASE WHEN platforms LIKE '%Mac%' THEN 1 ELSE 0 END) as Mac,
                SUM(CASE WHEN platforms LIKE '%Linux%' THEN 1 ELSE 0 END) as Linux
            FROM games
        """, self.storage.conn)
        
        platforms = ['Windows', 'Mac', 'Linux']
        counts = [df['Windows'][0], df['Mac'][0], df['Linux'][0]]
        colors = ['#0078d4', '#555555', '#fcc624']
        
        plt.figure(figsize=(8, 8))
        plt.pie(counts, labels=platforms, autopct='%1.1f%%', colors=colors,
                startangle=90, textprops={'fontsize': 14})
        plt.title('Steam游戏平台支持分布', fontsize=16, pad=20)
        plt.tight_layout()
        plt.savefig(f'{output_dir}/platform_distribution.png', dpi=150)
        plt.close()
        
        logger.info(f"📊 已生成:平台分布图")


# 使用
visualizer = DataVisualizer(storage)
visualizer.generate_all_charts()

定时任务

需求:每天自动更新数据

方案A:Linux Cron

bash 复制代码
# 编辑crontab
crontab -e

# 每天凌晨2点执行
0 2 * * * cd /path/to/steam-crawler && /usr/bin/python3 main.py --incremental凌晨执行完整爬取
0 2 * * 0 cd /path/to/steam-crawler && /usr/bin/python3 main.py --full

方案B:Python APScheduler

python 复制代码
from apscheduler.schedulers.blocking import BlockingScheduler
from apscheduler.triggers.cron import CronTrigger

def daily_update_task():
    """每日更新任务"""
    try:
        logger.info("⏰ 开始每日增量更新...")
        crawler = SteamCrawler()
        crawler.crawl_incremental()
        crawler.export_data()
        crawler.close()
        logger.info("✅ 每日更新完成")
    except Exception as e:
        logger.error(f"❌ 每日更新失败: {str(e)}")

def weekly_full_task():
    """每周完整爬取"""
    try:
        logger.info("⏰ 开始每周完整爬取...")
        crawler = SteamCrawler()
        crawler.crawl_all_genres(max_pages_per_genre=20)
        crawler.export_data()
        crawler.close()
        logger.info("✅ 每周爬取完成")
    except Exception as e:
        logger.error(f"❌ 每周爬取失败: {str(e)}")

if __name__ == "__main__":
    scheduler = BlockingScheduler()
    
    # 每天凌晨2点执行增量更新
    scheduler.add_job(
        daily_update_task,
        CronTrigger(hour=2, minute=0),
        id='daily_update'
    )
    
    # 每周日凌晨3点执行完整爬取
    scheduler.add_job(
        weekly_full_task,
        CronTrigger(day_of_week='sun', hour=3, minute=0),
        id='weekly_full'
    )
    
    logger.info("⏰ 定时任务已启动...")
    scheduler.start()

1️⃣3️⃣ 总结与延伸阅读

我们完成了什么?

通过这篇文章,你已经掌握了:

✅ 完整的电商爬虫能力 :从列表页到详情页的多层级采集

✅ 复杂数据处理技巧 :价格解析、日期标准化、货币转换

✅ 工程化的代码架构 :分层设计、错误处理、并发优化

✅ 生产级的数据系统 :SQLite存储、价格追踪、数据可视化

✅ 反爬虫应对策略:Cookie管理、频率控制、User-Agent轮换

这套代码不是玩具,而是真正可以用于生产环境的数据采集系统。你可以用它:

  • 🎮 价格监控工具:追踪心愿单游戏,打折时自动提醒
  • 📊 市场分析平台:研究Steam生态、定价策略、用户偏好
  • 🤖 游戏推荐系统:基于标签和评分推荐游戏
  • 💰 投资决策参考:分析游戏发行趋势、热门类型

项目的实际应用场景

案例1:价格历史查询API

python 复制代码
@app.route('/api/price-history/<app_id>')
def get_price_history_api(app_id):
    """查询游戏的价格历史"""
    history = storage.get_price_history(app_id, days=90)
    
    # 计算历史最低价
    if history:
        lowest = min(h['discount_price'] or h['original_price'] for h in history)
        current = history[0]['discount_price'] or history[0]['original_price']
        
        return jsonify({
            'app_id': app_id,
            'current_price': current,
            'lowest_price': lowest,
            'is_lowest': current == lowest,
            'history': history
        })
    
    return jsonify({'error': 'Not found'}), 404

案例2:折扣日历

python 复制代码
def generate_discount_calendar():
    """生成本周折扣日历"""
    today = datetime.now().date()
    
    # 查询本周新上折扣的游戏
    cursor = storage.conn.execute("""
        SELECT g.game_name, g.discount_percent, p.check_date
        FROM games g
        JOIN price_history p ON g.app_id = p.app_id
        WHERE p.check_date >= date('now', '-7 days')
          AND p.is_on_sale = 1
        ORDER BY p.check_date DESC, g.discount_percent DESC
    """)
    
    # 按日期分组
    calendar = {}
    for row in cursor.fetchall():
        date = row[2]
        if date not in calendar:
            calendar[date] = []
        
        calendar[date].append({
            'name': row[0],
            'discount': row[1]
        })
    
    return calendar

案例3:游戏推荐算法

python 复制代码
def recommend_games(app_id, top_n=5):
    """基于标签相似度推荐游戏"""
    # 获取目标游戏的标签
    target_tags = set(storage._get_tags(app_id))
    
    # 查询所有游戏
    cursor = storage.conn.execute("SELECT app_id, game_name FROM games WHERE app_id != ?", (app_id,))
    
    similarities = []
    for row in cursor.fetchall():
        other_id, other_name = row
        other_tags = set(storage._get_tags(other_id))
        
        # 计算Jaccard相似度
        intersection = len(target_tags & other_tags)
        union = len(target_tags | other_tags)
        similarity = intersection / union if union > 0 else 0
        
        similarities.append((other_id, other_name, similarity))
    
    # 排序并返回Top N
    similarities.sort(key=lambda x: x[2], reverse=True)
    
    return [
        {'app_id': item[0], 'name': item[1], 'similarity': item[2]}
        for item in similarities[:top_n]
    ]

下一步可以做什么?

如果你想进一步提升这个项目,可以尝试:

🎯 功能扩展 :

-开发商、发行商、系统要求)

  • 采集用户评论并进行情感分析
  • 采集游戏DLC信息和捆绑包
  • 支持多地区价格对比(中国区 vs 美区 vs 俄区)

🚀 性能优化:

  • 使用Scrapy框架重构(支持分布式爬取)
  • 接入Steam官方API(需要申请密钥)
  • 使用Redis缓存热门游戏数据
  • 部署到云端(AWS Lambda + RDS)

🎨 产品化:

  • 开发Web界面(React + Ant Design)
  • 创建移动App(Flutter/React Native)
  • 提供公共API服务
  • 接入支付系统(付费高级功能)

📊 数据分析:

  • 分析Steam定价策略(地区差异、折扣规律)
  • 预测游戏销量趋势
  • 研究用户评分与价格的关系
  • 识别独立游戏

🛠️ 工程优化:

  • Docker容器化部署
  • CI/CD自动化(GitHub Actions)
  • 单元测试覆盖(pytest)
  • 性能监控(Prometheus + Grafana)

推荐学习资源

书籍:

  • 《Python网络爬虫权威指南》(Ryan Mitchell)
  • 《Python爬虫开发与项目实战》(范传辉)
  • 《Web Scraping with Python》(第2版)

在线资源:

GitHub项目参考:

反爬虫技术研究:

最后的话

爬虫技术的本质,是用代码获取互联网上的公开信息。Steam作为全球最大的PC游戏平台,拥有海量的价值数据。但请记住:

⚖️ 法律边界:

  • ✅ 允许:采集公开展示的游戏信息,用于个人学习和研究
  • ❌ 禁止:商业转售数据、恶意绕过付费墙

🤝 职业道德:

  • 控制爬取频率,不对服务器造成压力
  • 尊重网站的robots.txt规则
  • 标注数据来源,不持续学习:
  • 关注Steam网站更新,及时调整爬虫代码
  • 学习新的反爬技术和应对方法
  • 与社区交流经验,共同进步

这篇文章从零开始,带你完成了一个真实可用的Steam游戏数据采集系统 。但更重要的是,你学会了一套可复用的爬虫开发方法论:

  1. 需求分析:明确要什么数据、用在哪里
  2. 技术选型:根据网站特点选择工具(静态/动态/API)
  3. 分层设计:Fetcher → Parser → Cleaner → Storage
  4. 容错处理:重试、降级、日志、监控
  5. 数据质量:清洗、验证、去重、标准化
  6. 持续迭代:增量更新、性能优化、功能扩展

记住:爬虫不仅仅是写代码,更是对数据、对业务、对用户需求的深刻理解。

希望这篇文章不仅教会你如何写爬虫,更重要的是培养你发现问题、分析问题、解决问题的能力。当你面对新的数据源时,能够快速定位关键点、设计合理方案、写出高质量代码------这才是爬虫工程师的核心价值。

如果你在实践中遇到问题,记得:

  • 先看日志(80%的问题都能从日志找到线索)
  • 保存HTML(用浏览器检查实际结构)
  • 逐步调试(不要一次写太多代码)
  • 善用搜索(Stack Overflow是你的好朋友)

最后,请务必遵守法律法规和网站规则,做一个有职业道德的爬虫工程师。技术是中性的,关键在于如何使用。

祝你采集顺利,数据满满!如果这篇文章对你有帮助,欢迎分享给更多人!

附录:目录结构*:

json 复制代码
steam-crawler/
├── README.md              # 项目说明
├── requirements.txt       # 依赖清单
├── config.py             # 配置文件
├── main.py               # 主程序
├── fetcher.py            # 请求层
├── parser.py             # 解析层
├── cleaner.py            # 清洗层
├── storage.py            # 存储层
├── price_tracker.py      # 价格监控(可选)
├── visualizer.py         # 数据可视化(可选)
├── cookies/
│   └── steam_cookies.txt # Cookie文件
├── data/
│   ├── steam_games.db    # SQLite数据库
│   ├── *.json            # JSON导出
│   └── *.csv             # CSV导出
├── logs/
│   ├── crawler.log       # 运行日志
│   └── error.log         # 错误日志
└── tests/                # 单元测试
    ├── test_parser.py
    ├── test_cleaner.py
    └── test_storage.py

快速开始:

bash 复制代码
# 1. 克隆项目
git clone https://github.com/yourname/steam-crawler.git
cd steam-crawler

# 2. 安装依赖
pip install -r requirements.txt

# 3. 配置Cookie
# 访问 https://store.steampowered.com/
# 导出Cookie到 cookies/steam_cookies.txt

# 4. 运行爬虫
python main.py

# 5. 查看结果
sqlite3 data/steam_games.db "SELECT game_name, original_price_value FROM games LIMIT 10;"

版权声明 :

本文代码基于MIT License开源,可自由使用、修改、分发。但请注意:

  • 遵守Steam服务条款和robots.txt
  • 不用于商业转售或违法用途
  • 标注数据来源和本文链接

感谢阅读!🎉

🌟 文末

好啦~以上就是本期 《Python爬虫实战》的全部内容啦!如果你在实践过程中遇到任何疑问,欢迎在评论区留言交流,我看到都会尽量回复~咱们下期见!

小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦~
三连就是对我写作道路上最好的鼓励与支持! ❤️🔥

📌 专栏持续更新中|建议收藏 + 订阅

专栏 👉 《Python爬虫实战》,我会按照"入门 → 进阶 → 工程化 → 项目落地"的路线持续更新,争取让每一篇都做到:

✅ 讲得清楚(原理)|✅ 跑得起来(代码)|✅ 用得上(场景)|✅ 扛得住(工程化)

📣 想系统提升的小伙伴:强烈建议先订阅专栏,再按目录顺序学习,效率会高很多~

✅ 互动征集

想让我把【某站点/某反爬/某验证码/某分布式方案】写成专栏实战?

评论区留言告诉我你的需求,我会优先安排更新 ✅


⭐️ 若喜欢我,就请关注我叭~(更新不迷路)

⭐️ 若对你有用,就请点赞支持一下叭~(给我一点点动力)

⭐️ 若有疑问,就请评论留言告诉我叭~(我会补坑 & 更新迭代)


免责声明:本文仅用于学习与技术研究,请在合法合规、遵守站点规则与 Robots 协议的前提下使用相关技术。严禁将技术用于任何非法用途或侵害他人权益的行为。

相关推荐
全栈练习生1 小时前
AI Agent 沙箱
python·ai
SEO_juper2 小时前
用 Python 写一个 GEO 可见性检查脚本:你的网站现在能被 AI 引用吗
开发语言·人工智能·爬虫·python·seo·外贸独立站
用户8356290780513 小时前
使用 Python 对 Excel 工作表进行排序
后端·python
for_ever_love__4 小时前
MySQL 全文索引实战:FULLTEXT、ngram 中文分词与 MATCH AGAINST 到底该怎么用
java·python·mysql·全文检索·分词·索引·ngram
用户8356290780514 小时前
使用 Python 合并 PowerPoint 演示文稿
后端·python
weixin_461769404 小时前
VS Code 中创建 Jupyter 文件(.ipynb)
ide·python·jupyter
2601_966949654 小时前
多市场量化策略的数据接口应该如何设计:从数据层架构到策略接入
开发语言·python·数据分析·pandas·量化交易·股票数据·quantdash
全栈弄潮儿5 小时前
Python实战第1期:Python环境搭建与第一个程序
python
心易行者5 小时前
Agent应用+API端点商业化进阶实战:从单体智能体到可付费调用的API全流程
运维·服务器·人工智能·python·apache
weixin199701080165 小时前
《1688图片空间API踩坑:img.upload 与 album.* 的防盗链与CDN缓存问题》(附Python源码)
开发语言·python·缓存