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:数据清洗

    • 价格:¥ 9898.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过期birthtimesteamCountry失效
  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 协议的前提下使用相关技术。严禁将技术用于任何非法用途或侵害他人权益的行为。

相关推荐
老蒋每日coding2 小时前
LangGraph:从入门到Multi-Agent超级智能体系统进阶开发
开发语言·python
岚天start2 小时前
Python HTTP服务器添加简单用户名密码认证的三种方案
服务器·python·http
zhengfei6112 小时前
高级网络安全爬虫/蜘蛛
爬虫
cuber膜拜2 小时前
Weaviate 简介与基本使用
数据库·python·docker·向量数据库·weaviate
HealthScience2 小时前
DNA具体怎么转为蛋白质的?
python
PacosonSWJTU3 小时前
mac-python解释器理解与python安装
开发语言·python
urkay-3 小时前
Android 中实现 HMAC-SHA256
android·开发语言·python
DN20203 小时前
AI销售机器人的隐私痛点与破解之道
人工智能·python·机器学习·机器人·节日
恬淡如雪3 小时前
Excel接口测试自动化实战
爬虫·python·excel