先说一个背景。大概半年前,我打开自己的游戏库,看着那几百个数字版游戏发呆:哪些是通关的、哪些烂尾了、哪些是打折时脑子一热买的,完全说不清楚。更烦的是每次看商店促销,邮件、推送、横幅一起轰炸,我根本判断不了眼前这个"限时折扣"到底是不是历史最低价。
我当时想做一个小工具来解决这件事:把官方商店公开的游戏数据拉下来,存到本地,自己算史低、自己画价格曲线、自己管理整个库。项目名叫AnyPS5,和我用过的很多小工具一样,定位就是"本地优先、自给自足"。我本来只打算随便写写,结果越做越顺,最后变成了一个带网页面板的完整工具箱。
这篇东西不是教程文档,就是一次真实项目的完整复盘。我把选型思路、抓取清洗逻辑、史低算法、页面实现,以及踩过的五个坑都摊开讲。如果你也是游戏库庞大、长期被打折信息困扰的人,或者单纯喜欢折腾本地小工具,这篇应该能给你不少可直接抄作业的参考。
1. 为什么游戏库越来越乱,官方面板却帮不上忙
1.1 我的实际痛点
先把话说明白:不是官方界面垃圾,是它的设计逻辑和玩家真实需求根本不匹配。
官方应用的主逻辑围绕"商店"和"购买"展开,游戏库只是一个按字母排序的列表。我真正想要的是几个问题的高效回答:
- 我库里到底有多少个游戏?按类型、按地区分布如何?
- 哪些游戏我已经通关,哪些买来就没打开过?
- 我现在心愿单里这些游戏,现在的价格是不是史低?降到过多少?
- 昨天新打折的一批游戏里,哪些在我库里已经有了?
这些问题官方界面一个都答不了。列表只能一页一页翻,筛选条件有限,更别提历史价格数据了——官方根本不提供。
1.2 AnyPS5 要解决的三件事
当时我给这个小工具定下了三个核心目标:
一本账:把散落在各渠道的游戏信息统一拉到一个本地数据库里,按自己的规则打标签、分组、统计。
一把尺:持续记录每款游戏的价格变化,自动标出"历史最低价""首次打折""降回史低"等状态,告别被促销文案牵着走。
一张图:用一个浏览器就能打开的本地面板,把所有数据可视化。不用装额外App,不用注册账号,打开就是总览。
后来整个项目的所有功能,都是从这三句话长出来的。
1.3 为什么坚持"本地优先"
我见过很多同类工具做成了网页服务或者插件,数据全在别人服务器上。我的顾虑很简单:
- 数据是你的还是我的?游戏库、价格历史、个人打分标签订阅,这些数据我不想放在一个不知名的云服务里。
- 稳定性:本地跑,接口挂了顶多暂时拉不到新数据,但历史数据永远可用。云端服务一旦停止维护,数据就全没了。
- 隐私干净:不需要登录任何账号,不需要授权读取个人资料,只请求公开的商品信息,整个流程清爽得多。
所以从第一天起,技术路线就是:数据存在本地 SQLite,服务跑在本地 Flask,页面只在localhost访问。这个定位我后来一次都没动摇过。
2. AnyPS5 的架构与选型:小项目也要有章法
2.1 模块划分
很多人写个人工具最容易犯的毛病是"想到哪写到哪",最后所有逻辑堆在一个脚本里。AnyPS5 我一开始就拆成了四个模块,各自只干一件事:
| 模块 | 职责 | 对应文件 | 关键产出 |
|---|---|---|---|
| collector | 抓取官方商店公开数据 | collector.py | 原始 JSON 记录 |
| cleaner | 清洗、归一化、去重 | cleaner.py | 结构化游戏条目 |
| store | SQLite 读写、版本管理 | store.py | ps5lib.db数据库 |
| web | 本地 Dashboard | app.py+templates/ | 浏览器页面 |
拆分之后最大的好处是调试思路清晰了。数据抓坏了不用碰清洗逻辑,价格算错不用翻页面代码,各改各的,互不污染。对一个小项目来说,这种分层带来的收益远大于那一点代码量。
2.2 为什么选 Python + SQLite + Flask
选型这事没有标准答案,但我的理由很朴素:
- Python:我日常做数据分析都靠它,处理 JSON、正则清洗、日期计算这些活生态太成熟,写起来快,出问题也好查。
- SQLite:完全本地、单文件、零配置。一个
ps5lib.db文件搞定所有数据,备份就是复制文件,不用装数据库服务。 - Flask:轻量到可以用一句话启动一个 Web 服务。我不需要后端框架的庞杂功能,只要能把数据渲染成页面就行。
有人问过我为啥不用 Node、不用 Mongo 或者直接用现成的 BI 工具。不是它们不好,是这个场景下 Python + SQLite + Flask 的组合能让"最小可跑版本"在半小时内跑通。工具这种东西,能让你立刻开工的就是最好的。
2.3 目录结构与快速启动
项目目录长这样:
anyps5/ ├── collector.py ├── cleaner.py ├── store.py ├── app.py ├── requirements.txt ├── data/ │ └── ps5lib.db └── templates/ └── index.htmlrequirements.txt 只有三行:
requests==2.31.0 flask==3.0.0首次拉全量数据只需要两行命令:
python collector.py --init # 拉取游戏列表与当前价格 python app.py # 启动本地面板然后浏览器打开http://localhost:5000就能看到 Dashboard。这个"极简起步"的设计帮助很大——我在第一天就看到了可用原型,之后所有优化都是在这个骨架上做增量,而不是推倒重来。
3. 商店数据抓取与清洗:决定好用与否的第一道关
3.1 基于公开搜索接口的数据获取
游戏库工具的地基是数据,而数据的唯一来源是官方商店公开接口。这个接口不需要登录,请求后返回一段 JSON,里面包含标题、封面图、价格、折扣信息等字段。
我的请求逻辑非常简单粗暴:
import time import requests SEARCH_URL = "https://api.example-store.com/catalog/search" def search_games(keyword: str, region: str = "hk", limit: int = 20): session = requests.Session() params = { "query": keyword, "region": region, "limit": limit, } resp = session.get(SEARCH_URL, params=params, timeout=10) resp.raise_for_status() return resp.json().get("results", [])这里有几个实操细节,是纯看文档学不到的:
- 用
Session()复用连接,不要每次请求都新建一个会话。一次任务动辄几百次请求,复用连接能明显减少握手开销。 - 每次请求之间强制 sleep,我设置的间隔是 0.5 到 1 秒之间随机。这是对接口的礼貌,也是保护自己不被临时限流。
- 加超时和重试。网络抖动是常态,我的策略是失败后指数退避:第一次等 1 秒,第二次等 2 秒,最多重试 4 次。
def fetch_with_retry(params, max_retries=4): for attempt in range(max_retries): try: resp = requests.get(SEARCH_URL, params=params, timeout=10) resp.raise_for_status() return resp.json() except Exception as e: wait = (2 ** attempt) * 0.5 print(f"请求失败: {e}, {wait}s 后重试") time.sleep(wait) return None提示:这里有一个容易忽略的原则——只处理公开数据、降低请求频率、不要做任何绕过限制的操作。个人学习工具的使用方式,和商业爬虫完全不同,别把两者混为一谈。
3.2 标题归一化和去重
光把数据拉下来是不够的,真正的坑在清洗阶段。官方商店的标题书写非常随意:有的带副标题,有的带版本号,有的用全角冒号,有的中英混排。
比如同一个游戏,可能出现在这几个条目里:
GAME (Standard Edition)GAME - Deluxe EditionGAME(标准版)
如果按完全匹配去重,这三个会被当成三个游戏。我的解法是先做归一化,再做匹配:
import re def normalize_title(title: str) -> str: t = title.lower() # 去掉括号及括号内内容 t = re.sub(r"[((][^))]*[))]", "", t) # 把各种连接符统一成空格 t = re.sub(r"[-_–—::]", " ", t) # 压缩连续空格 t = re.sub(r"\s+", " ", t) return t.strip()归一化之后,GAME (Standard Edition)和GAME - Standard Edition都会变成game standard edition,这样再做匹配就稳定得多。
但这还没完。更复杂的是"版本家族"问题:标准版、豪华版、终极版其实是同一款游戏的不同商品,价格完全不同。如果只做标题归一化,它们仍然是三条独立记录,可在我眼里它们是"同一个游戏"。我的做法是在库里加一个family_id字段,通过标题去掉版本关键词后合并:
VERSION_WORDS = ["standard edition", "deluxe edition", "ultimate edition", "豪华版", "标准版", "终极版", "特别版", "年度版"] def family_key(title: str) -> str: k = normalize_title(title) for word in VERSION_WORDS: k = k.replace(word, "") return k.strip()同一family_key的条目归入一个游戏家族。价格追踪时以家族为单位计算"史低",而不是看单个商品——这个问题我在第 6 章还会详细展开。
3.3 各区域商店的差异处理
官方商店在不同地区的 API 字段名、价格币种、折扣规则不完全一样。我主要跟踪三个区域:港区、日区、美区。它们的差异集中在这几方面:
| 差异点 | 港区 | 日区 | 美区 |
|---|---|---|---|
| 货币字段 | HKD | JPY | USD |
| 标题语言 | 繁中/英文混合 | 日文/英文混合 | 主要是英文 |
| 折扣字段 | discount_percent | 有时候在promotion里 | 直接在price中 |
| 上架日期格式 | 带时区的 ISO 字符串 | 本地时间无时区 | UTC |
处理原则很简单:所有数据统一先转成内部标准结构,再进数据库。抓到原始 JSON 后,立刻映射成这样的字典:
def to_standard(raw: dict, region: str) -> dict: price_raw = raw.get("price") return { "game_id": f"{region}_{raw.get('id')}", "title": raw.get("title"), "region": region, "price": price_raw.get("final_price"), "original_price": price_raw.get("original_price"), "discount_percent": price_raw.get("discount", 0), "currency": price_raw.get("currency"), "release_date": raw.get("release_date"), }宁可在这里多写几行映射代码,也不要让后续逻辑去兼容各种不同的原始格式。数据标准化的价值,在写史低算法时你会体会得非常深。
4. 价格历史与史低判断:打折焦虑的终结者
4.1 价格历史表的设计
市面上很多比价工具只显示"当前价"和"原价",这种数据其实没什么用。打折时标个原价再划个横线,这是商店自己玩的把戏,我要的是独立的第三方记录:某个游戏在过去 30 天、90 天、一年内,价格到底是怎么走的。
所以价格存储必须按时间落库,只存当前快照是没法画曲线的。核心表设计如下:
CREATE TABLE price_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, game_id TEXT NOT NULL, family_id TEXT NOT NULL, title TEXT NOT NULL, region TEXT NOT NULL, price REAL NOT NULL, original_price REAL, discount_percent INTEGER DEFAULT 0, currency TEXT NOT NULL, recorded_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE(game_id, recorded_at) );这个表的核心是UNIQUE(game_id, recorded_at)。它保证了同一款游戏在同一天只会有一条记录,重复执行不会造成脏数据。
为什么记录粒度是一天而不是每次抓取?因为对我来说,一天一次完全够用。打折周期通常以天为单位,更频繁的采样只会让数据库膨胀而信息量不增加——这其实是一个工程上的取舍:你记录的粒度越细,存储成本和数据处理成本越高,决策价值却未必同步上升。
4.2 史低算法与边界情况
有了历史价格表,"史低"的定义就变得很清晰了:当前可见价格不超过该游戏家族历史记录中的最低价。
def is_historical_low(family_id: str, current_price: float) -> bool: row = db.execute( """SELECT MIN(price) FROM price_history WHERE family_id = ? AND price > 0""", (family_id,) ).fetchone() min_price = row[0] if row and row[0] is not None else float("inf") return current_price <= min_price注意这里的price > 0过滤条件。商店偶尔会出现价格为 0 的条目,通常是占位数据或临时异常,不参与史低计算,否则会把所有历史记录都带偏。
算法本身上没有什么玄学,真正的坑在于边界情况:
- 预购价不算。有些游戏预购阶段会挂一个比较低的价格,发售前才会修正。预购价参与历史计算会形成假史低。
- 临时特价不算。某些短期活动价(比如周末闪促)持续不到 24 小时,如果它被记录为"历史最低",会永久影响后续判断。
- 会员专属价要独立标记。会员价和普通价是两套体系,我直接把会员价存到另一个字段,不参与普适史低判断。
所以我的写入逻辑里加了一个price_type字段,取值为regular、promo、preorder、member。只有regular和promo会参与史低计算,另外两种只保留记录但不参与算法判断。这个细节花了我不少时间,因为第一次跑历史数据时,我发现一堆游戏被误标成"历史新低",查了半天才发现是预购价在捣乱。
4.3 心愿单降价提醒
价格追踪的最后一步是提醒。我不可能天天盯页面,所以写了一个很朴素的定时检查:
# check_wishlist.py WISHLIST_PATH = "wishlist.txt" # 每行一个 game_id 或 family_id def check_wishlist(): for family_id in load_wishlist(): current = get_current_price(family_id) if is_historical_low(family_id, current["price"]): notify(f"{family_id} 已降至史低 {current['price']} {current['currency']}")提醒方式我用的是本地通知脚本,发送到系统通知中心。整个流程可以挂 cron(或 Windows 的任务计划程序),每天早上 10 点自动跑一次。这个组合非常轻量,不需要任何外部服务。
后来我发现,这种"宁可错过不错买"的心态改变是最有价值的。以前看到促销就心痒,现在瞄一眼 Dash 上的历史曲线,90% 的冲动基本就消退了——因为你知道它半年前就卖过这个价,现在根本不是史低。
5. 用 Flask 搭一个本地 Dashboard
5.1 有价值的信息页面有哪些模块
数据全在库里躺着还不够,得给人看。Dashboard 我第一版只放了三块内容,后来证明这三块就是最有价值的三块:
- 总览卡片:游戏总数、家族数、心愿单待降价的品数、历史最低价均值。
- 今日降价榜:当天折扣力度最大的游戏列表,自动把"已在库"和"未入库"分开。
- 价格曲线图:点进任意游戏家族,可以看到它的价格走势和当前史低价位置。
很多同类工具会放一堆图表,其实没什么人看。真实使用习惯是:我每天打开 Dashboard,先扫一眼降价榜,再点进一两个感兴趣的游戏看看曲线,结束。所以页面设计首位是"少",宁可牺牲炫酷,保证信息密度合适。
5.2 Flask 路由与模板设计要点
Flask 部分的逻辑非常朴素。两个路由就够:
from flask import Flask, render_template, jsonify app = Flask(__name__) @app.route("/") def index(): stats = db.get_overview_stats() deals = db.get_today_deals() return render_template("index.html", stats=stats, deals=deals) @app.route("/game/<family_id>") def game_detail(family_id): info = db.get_family_info(family_id) price_history = db.get_price_history(family_id) return render_template("detail.html", info=info, history=price_history)模板方面我踩过一个明显坑:开始把所有逻辑堆在 Jinja2 模板里,日期格式化、价格比较、史低判断全写在 HTML 里,结果模板变成一锅粥。
后来我强制自己执行一条规则:模板只负责展示,所有计算在 Python 侧完成。页面需要的所有字段,都在视图函数里提前算好,模板只做循环和填值。这条规则让项目后期的维护成本下降了一个量级。
5.3 展示细节:价格曲线的数据准备
价格曲线我用的是最轻量方案:后端按时间排序输出 JSON,前端用一个简单的图表库绘制。关键点在于输出数据的结构一定要简单,前端拿到就能直接用:
@app.route("/api/price_history/<family_id>") def api_price_history(family_id): rows = db.get_price_history(family_id) points = [ { "date": row["recorded_at"].split(" ")[0], "price": row["price"], "discount": row["discount_percent"], } for row in rows ] return jsonify(points)为什么不直接在服务端生成图表?因为后端处理图表会非常笨重。让后端只输出干净的数据结构,让前端图表库承担渲染,这是各司其职。
曲线图上我加了一条延伸的平行线表示史低价,这样一眼就能看出当前价格离史低还有多远。这个小改动很受欢迎——玩家看到曲线被史低线"压住"的时候,就是最好的出手时机。
6. 实测中的五个坑与修复方式
这里真的是全文最值钱的部分。AnyPS5 跑了大半年,遇到的绝大多数坑都不是"不会跑",而是"跑起来之后在各种边界条件下露出原形"。按踩坑顺序来。
6.1 请求过快导致被临时限流
第一次全量拉数据时,我图省事没加 sleep,连续快速请求了大概两百多次,很快返回 429 状态码。不是被屏蔽,就是限流提示。
修复很简单:每请求之间随机 sleep 0.5 到 1 秒,再加指数退避重试。速度虽然慢了一些,但稳定多了。我的体会是:拿公开数据做个人工具,尽量当个"文明访客",频率低一点、重试温和一点,对自己对对方都好。
6.2 中文标题匹配失败
清洗逻辑刚写好时,对英文游戏效果很好,一碰到港区的繁体中文标题就匹配失败。原因在于官方商店同一款游戏在港区可能同时存在中文名和英文名两条记录,两者的family_key完全对不上。
我的解法是维护一个"别名映射表":手动把一批知名游戏的中英文标题映射到同一个family_id,比如{中文名, 英文名, 日文名} -> family_id。第一批手动映射了大概 30 个常玩的游戏,就覆盖了 80% 的实际场景。剩下的边角情况,日常使用中随见随补。
提示:不要试图做一个全自动的 100% 匹配方案。游戏命名水很深,半自动维护反而更实际——自动匹配处理大部分,定期人工扫一眼异常记录,补漏就行。
6.3 时区导致史低错判
这个问题最阴险。最初我把recorded_at用 Python 的本地时间写入,而接口返回的上架时间、折扣时间用的又是国际标准时间。导致当我上午 10 点跑检查时,昨天下午抓的数据在数据库里被当成"两天前"——历史价格曲线出现断层,一些游戏的史低判断也跟着出偏差。
修复非常简单但需要决心:数据库里所有时间字段统一存标准化时间字符串,展示时再转换成本地时区。凡是涉及时间的读写,全部经过工具函数,不允许在业务代码里直接调datetime.now()。这个规矩立下来之后,时间相关的 bug 就再没出现过。
6.4 同一游戏多版本价格归属混乱
我前面提到的"版本家族"归并问题,最开始没有解决彻底。标准版 200、豪华版 350、终极版 500,三个版本各有各的价格曲线。如果各自独立判断史低,会出现"豪华版降到 320,低于历史最低"这种假信号,实际上绝对价格还是比标准版高一大截。
后来我引入了family_id的概念,以"家族最低可见价"作为史低判断的基础。比如某个游戏家族的三个版本中,标准版史低 160、豪华版史低 280、终极版史低 400,那这个家族的史低就是 160。任何版本降价到"它自身历史上的最低点"时,我会额外标注"这是该版本自身史低";而当某个非标准版的价格已经逼近家族史低时,面板会推荐"直接买标准版更划算"。这套逻辑把"游戏"和"商品"彻底分开了——对玩家来说,我们要管理的是游戏,不是一个又一个商品 tags。
6.5 重复运行时数据重复
早期版本没有唯一约束,定时任务跑了几次之后,价格历史表里出现大量完全相同的重复记录。查询结果变得混乱,画出来的曲线也出现"平台期"(同一天有四五条记录)。
修复其实就是把建表语句里的UNIQUE(game_id, recorded_at)加上,写入数据时改用:
INSERT INTO price_history (game_id, family_id, title, region, price, original_price, discount_percent, currency, recorded_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(game_id, recorded_at) DO NOTHING;这样同一天内重复抓取的数据会被直接忽略,幂等性从数据库层面保证了。做完这个修复之后,定时任务随便跑,数据源始终干净有序。
7. 后续想做的事与现在的使用感受
AnyPS5 目前的形态已经稳定跑了大半年,每天定时抓一次数据,早上看一眼 Dashboard 已经变成我的固定习惯。接下来我想做三件事:
- 接入游戏时长统计:把玩过的游戏按游玩时长做一个热力视图,看看这一年到底把时间花在了哪些游戏上。
- 批量导入导出:支持从商店页面批量复制游戏链接,一键导入到愿望单做比价,省去手动输入的麻烦。
- 分享清单页:生成一个可以分享的"我的游戏库清单"静态页,朋友们点开就能看到我推荐了什么,不涉及任何隐私数据。
就我个人的实际体验而言,这个小工具给我最大的收获不是省了多少钱,而是彻底改变了我面对促销信息的心态。以前看到"限时 -50%"就手痒,现在我会打开 AnyPS5 看一眼价格曲线:如果半年前就打过这个折扣,那这个"限时"就毫无稀缺性。这种对消费冲动的克制,大概才是坚持做这个项目最大的回报。
如果你也想做一个类似的本地工具,我的建议是:第一,先想清楚你要回答哪几个问题,而不是一上来追求功能大而全;第二,数据清洗和去重一开始就让规则清晰起来,否则后面会返工;第三,保持小步快跑,每天能用、能看,比做到完美再上线有价值得多。