㊙️本期内容已收录至专栏《Python爬虫实战》,持续完善知识体系与项目实战,建议先订阅收藏,后续查阅更方便~持续更新中!
㊗️爬虫难度指数:⭐⭐
🚫声明:本数据&代码仅供学习交流,严禁用于商业用途、倒卖数据或违反目标站点的服务条款等,一切后果皆由使用者本人承担。公开榜单数据一般允许访问,但请务必遵守"君子协议",技术无罪,责任在人。

全文目录:
-
- [🌟 开篇语](#🌟 开篇语)
- [1️⃣ 摘要(Abstract)](#1️⃣ 摘要(Abstract))
- [2️⃣ 背景与需求(Why)](#2️⃣ 背景与需求(Why))
-
- [为什么要爬 Steam 游戏数据?](#为什么要爬 Steam 游戏数据?)
- 为什么选择静态部分?
- 目标字段清单
- [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))
-
- 代码实现(parser.py)
- 解析层关键技术详解
-
- [1. XPath 选择器技巧](#1. XPath 选择器技巧)
- [2. 价格解析的复杂性](#2. 价格解析的复杂性)
- [3. 评分数据的提取](#3. 评分数据的提取)
- [8️⃣ 数据清洗层(Cleaner)](#8️⃣ 数据清洗层(Cleaner))
-
- 代码实现(cleaner.py)
- 数据清洗关键技术详解
-
- [1. 价格解析的挑战](#1. 价格解析的挑战)
- [2. 日期解析的多样性](#2. 日期解析的多样性)
- [3. 数据验证的重要性](#3. 数据验证的重要性)
- [9️⃣ 数据存储与导出(Storage)](#9️⃣ 数据存储与导出(Storage))
- [🔟 运行方式与结果展示(必写)](#🔟 运行方式与结果展示(必写))
-
- 主程序(main.py)
- 启动方式
- 配置文件(config.py)
- 输出示例
- [JSON 文件示例(data/steam_games_all.json)](#JSON 文件示例(data/steam_games_all.json))
- [CSV 文件示例(data/steam_games_all.csv)](#CSV 文件示例(data/steam_games_all.csv))
- [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 异步加载)
本文聚焦于静态可抓部分,原因如下:
- 效率更高:无需启动浏览器,直接解析 HTML 即可
- 稳定性强:不依赖 JavaScript 执行,不易被前端更新影响
- 资源消耗少:单机就能跑,不需要大量代理和计算资源
对于需要动态内容的场景(如评论数、在线人数),可以后续补充 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 的反爬机制相对温和,但仍需注意:
-
请求频率:
- 建议间隔 2-3 秒(不要低于 1 秒)
- 高峰期(美国晚间)适当增加间隔
- 使用 Session 复用 TCP 连接
-
Cookie 要求:
- Steam 会检查
birthtimeCookie(年龄验证) - 部分地区需要
steamCountryCookie(地区选择) - 建议手动在浏览器登录一次,导出 Cookie 使用
- Steam 会检查
-
User-Agent:
- 使用真实浏览器的 UA
- 避免使用默认的
python-requests/2.x.x - 可以随机轮换多个 UA
-
地区限制:
- 不同地区价格不同(中国区、美区、欧区)
- 需要设置
cc=CN参数或相应 Cookie - 汇率换算需要实时数据
数据使用边界
-
✅ 允许:
- 个人学习、数据分析、价格监控
- 开发非商业性工具(如价格追踪器)
- 学术研究(市场分析、用户行为研究)
-
❌ 禁止:
- 商业转售游戏数据(如打包成付费 API)
- 批量下载游戏封面/视频用于分发
- 恶意爬取导致服务器过载
- 绕过付费墙或地区限制获取游戏
法律风险提示
- 版权问题:游戏名称、封面图属于知识产权,仅用于展示和信息传递
- 服务条款:Steam 服务条款禁止"自动化访问"用于商业目的
- 灰色地带:个人使用、学术研究通常不会被追究,但需低调行事
建议:
- 爬取数据仅供个人使用,不公开分发
- 如需商业化,使用 Steam 官方 API(需申请密钥)
- 尊重 Steam 服务器负载,避免高频请求
4️⃣ 技术选型与整体流程(What/How)
静态 vs 动态 vs API
方案对比:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 官方 API | 稳定、合法、数据完整 | 需要申请、有配额限制 | 商业应用 |
| 静态 HTML 解析 | 速度快、资源消耗低 | 部分数据缺失 | 本文方案 |
| 隐藏 API 抓包 | 数据结构化、易解析 | 接口可能变化、需逆向 | 进阶方案 |
| Selenium 动态渲染 | 获取所有内容 | 速度慢、资源消耗大 | 动态内容必需时 |
本文选择 :静态 HTML 解析 + 部分 API 补充
原因:
- Steam 的游戏列表页是服务端渲染的(查看源代码即可看到完整数据)
- 价格、标签等信息都在 HTML 中,无需 JavaScript 执行
- 部分字段(如详细评分)可以通过抓包找到的隐藏 API 获取
整体流程设计
json
[分类列表页] → 获取所有分类/标签
↓
[游戏列表页] → 分页抓取每个分类的游戏列表
↓
解析基本信息(名称、价格、标签)
↓
[游戏详情页] → 补充详细信息(可选)
↓
数据清洗(价格标准化、日期解析、去重)
↓
存储到 SQLite + 导出 JSON/CSV
↓
价格历史追踪(对比前一次数据)
分步说明:
-
步骤1:获取分类列表
- URL:
https://store.steampowered.com/genre/ - 提取:动作、冒险、策略等大分类的链接
- URL:
-
步骤2:遍历分类页面
- URL 示例:
https://store.steampowered.com/genre/Action/?offset=0 - 分页逻辑:每页 25 个游戏,通过
offset参数翻页 - 提取:游戏 ID、名称、价格、缩略图
- URL 示例:
-
步骤3:解析游戏标签
- 标签在列表页可能不完整,需访问详情页
- 或者使用 Steam API:
https://store.steampowered.com/api/appdetails?appids={app_id}
-
步骤4:数据清洗
- 价格:
¥ 98→98.0(float) - 折扣:
-50%→50(int) - 日期:
2012年8月21日→2012-08-21(ISO 8601)
- 价格:
-
步骤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
Cookie 获取方法
Steam 需要 Cookie 才能正常访问,获取步骤:
-
打开浏览器(推荐 Chrome)
-
访问
https://store.steampowered.com/ -
选择地区和年龄(如果有弹窗)
-
打开开发者工具(F12)→ Application → Cookies
-
复制关键 Cookie:
birthtime: 出生时间戳(用于年龄验证)steamCountry: 地区代码(如CN/US)sessionid: 会话 ID(可选)
-
保存到文件
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 秒之间随机
- 避免被识别为机器人(人类不会每次都精确间隔)
4. Cookie 管理
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">></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 被封、地区限制
原因分析:
- Cookie过期 :
birthtime或steamCountry失效 - 未通过年龄验证:Steam要求确认年龄
- 地区限制:某些游戏仅在特定地区可见
解决方案:
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版)
在线资源:
- Scrapy官方文档:https://docs.scrapy.org
- lxml教程:https://lxml.de/tutorial.html
- XPath速查表:https://devhints.io/xpath
- Steam Web API文档:https://steamcommunity.com/dev
GitHub项目参考:
SteamSpyAPI封装:https://github.com/topics/steamspysteam-apiPython库:https://github.com/ValvePython/steam- 价格追踪工具:https://github.com/jshackles/Enhanced_Steam
反爬虫技术研究:
- 《反爬虫实战案例集》:https://github.com/luyishisi/Anti-Anti-Spider
- Cloudflare绕过:使用
cloudscraper库 - 验证码识别:
ddddocr(OCR)+ 打码平台
最后的话
爬虫技术的本质,是用代码获取互联网上的公开信息。Steam作为全球最大的PC游戏平台,拥有海量的价值数据。但请记住:
⚖️ 法律边界:
- ✅ 允许:采集公开展示的游戏信息,用于个人学习和研究
- ❌ 禁止:商业转售数据、恶意绕过付费墙
🤝 职业道德:
- 控制爬取频率,不对服务器造成压力
- 尊重网站的robots.txt规则
- 标注数据来源,不持续学习:
- 关注Steam网站更新,及时调整爬虫代码
- 学习新的反爬技术和应对方法
- 与社区交流经验,共同进步
这篇文章从零开始,带你完成了一个真实可用的Steam游戏数据采集系统 。但更重要的是,你学会了一套可复用的爬虫开发方法论:
- 需求分析:明确要什么数据、用在哪里
- 技术选型:根据网站特点选择工具(静态/动态/API)
- 分层设计:Fetcher → Parser → Cleaner → Storage
- 容错处理:重试、降级、日志、监控
- 数据质量:清洗、验证、去重、标准化
- 持续迭代:增量更新、性能优化、功能扩展
记住:爬虫不仅仅是写代码,更是对数据、对业务、对用户需求的深刻理解。
希望这篇文章不仅教会你如何写爬虫,更重要的是培养你发现问题、分析问题、解决问题的能力。当你面对新的数据源时,能够快速定位关键点、设计合理方案、写出高质量代码------这才是爬虫工程师的核心价值。
如果你在实践中遇到问题,记得:
- 先看日志(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 协议的前提下使用相关技术。严禁将技术用于任何非法用途或侵害他人权益的行为。