如果你和我一样,把 PS5 买回来之后不只是通关,还想把每一款游戏到底玩了多久、停在哪个阶段、当时为什么弃坑都记录下来,多半会遇到一个问题:官方应用里看不到完整的个人游戏档案,第三方整理工具又往往要登入账号、抓取数据,操作有门槛,还有安全顾虑。于是我自己写了一个叫 AnyPS5 的本地游戏库管理工具。它的目标很简单:任何一台 PS5、任何区服的玩家,都可以用同一套方式,建立起自己的游戏档案馆。它不涉及任何主机系统底层的修改,也不去碰主机的内部数据,所有内容都来自玩家主动录入和本地整理的记录。这篇文章把我从设计数据库到实现界面的完整过程写下来,也把踩过的一些坑放在一起,希望能给同样有整理欲的朋友一点参考。
1. 为什么我要自建 AnyPS5:官方应用和现有工具都缺了点什么
1.1 官方应用的三个盲区
先说清楚,我没有去逆向什么接口,也没有做任何超出正常使用范围的事情。真正的痛点是:玩家的游戏记录其实并没有被系统性地“整理”。
第一,官方应用更关心你能买下一款游戏,而不是你已经拥有过的游戏。购买记录能翻到,但“通关率”“陪伴你多少小时”“哪款游戏在某个时期打动过你”这些私人维度,产品里几乎没有。第二,数据的归属感很弱。游戏列表、进度、成就都是云端的一串记录,一旦账号状态变化,你就拿不到一份属于自己的历史档案。第三,目前市面上的整理工具要么绑定特定平台,要么需要输入账号凭证,很多玩家不敢用,也懒得用。哪怕只是想留下一份自己的“游戏日记”,也找不到一个既安全又顺手的工具。
AnyPS5 要解决的,就是这三件事:让官方不关心的“私人游戏档案”变成可以被记录的东西;让记录存在玩家自己的本地文件里;让整理过程完全透明、可复制。这听起来不像一个酷炫的功能,但恰恰是个人项目最有价值的地方。
1.2 AnyPS5 的定位与边界
当时我给 AnyPS5 定了三条边界:
- 不碰主机的底层系统,不读内部文件,数据来源只靠玩家自己填写,或者从本地 CSV 导入。
- 本地优先。数据默认存在自己电脑的 SQLite 文件里,不上传服务器,不依赖云端。
- 可迁移。任何一个人拿到项目的数据库和文件目录,就能复现你的游戏档案。
这三条边界让工具做起来有些“笨拙”,但换来了安全与长期可用性。对于一个个人项目来说,这是最重要的。也是因为这种“不挑主机版本、不挑区服”的设计,我才把它叫 AnyPS5:不管你是哪个版本的主机,不管你在哪个区服买游戏,都能用同一套数据模型,把自己的游戏档案组织得明明白白。
2. 架构设计与数据模型:先想清楚怎么存,再动手写代码
2.1 技术选型:Python + Flask + SQLite 的理由
对比过几条路线。用现成的表格模板最省事,但字段被模板卡死,统计只能看一维表格,后期想画个时长分布图会很痛苦。用桌面应用框架做原生应用,界面漂亮,但打包体积和学习成本都不小。考虑到项目核心是数据处理和本地展示,而且需要方便大家在自己电脑上跑起来,最后选了 Python + Flask + SQLite。
这套组合的优点是:Python 处理 CSV 和正则清洗非常顺手,Flask 可以直接提供网页界面,SQLite 则省去了单独装数据库的麻烦。对不熟悉命令行的朋友,也可以把启动命令封装成脚本,双击就能运行。
AnyPS5 的运行结构很直白:一个 Flask 应用负责页面和数据接口,一个 SQLite 数据库负责存储,前端用原生 JavaScript 加轻量 CSS,不引入构建步骤。全部加起来依赖非常少,装好 Python 后只需要安装 Flask 和它的依赖组件,实测在常见的 PC 和 Mac 上都能直接跑。
2.2 核心数据表结构
动手前我先画了四张表:games、play_records、reviews、tags。看名字就知道,games 存游戏本体信息,play_records 存每次游玩时长记录,reviews 存玩后感和评分,tags 用来做自定义分类。
games 表至少要有这些字段:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | INTEGER PRIMARY KEY | 自增主键 |
| title | TEXT | 游戏名称 |
| platform | TEXT | 当前整理的是 PS5,但保留扩展字段 |
| region | TEXT | 港版、日版、美版等 |
| status | TEXT | 未开始/进行中/已通关/弃坑 |
| total_minutes | INTEGER | 累计游玩分钟数 |
| cover_path | TEXT | 本地封面图路径 |
| favorite | INTEGER | 是否收藏 |
play_records 表则记录每一次实际游玩开始和结束的时间,方便后面统计每日时长分布。reviews 表和 games 表是一对一关联,主要存评分、短评、通关日期。tags 表和 games 表用关系表 game_tags 连接。
2.3 数据关系设计
具体建表语句我贴在下面。用 SQLite 做外键约束,并在 title + region 字段上建唯一索引,这样可以避免同一款游戏的不同版本录成两条。
CREATE TABLE IF NOT EXISTS games ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, platform TEXT DEFAULT 'PS5', region TEXT, status TEXT DEFAULT '未开始', total_minutes INTEGER DEFAULT 0, cover_path TEXT, favorite INTEGER DEFAULT 0, created_at TEXT DEFAULT (datetime('now', 'localtime')) ); CREATE TABLE IF NOT EXISTS play_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, game_id INTEGER NOT NULL, started_at TEXT NOT NULL, ended_at TEXT, minutes INTEGER DEFAULT 0, FOREIGN KEY (game_id) REFERENCES games(id) ); CREATE TABLE IF NOT EXISTS reviews ( id INTEGER PRIMARY KEY AUTOINCREMENT, game_id INTEGER NOT NULL UNIQUE, rating REAL, comment TEXT, finished_at TEXT, FOREIGN KEY (game_id) REFERENCES games(id) ); CREATE TABLE IF NOT EXISTS tags ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE );这里有个小设计:total_minutes 虽然是冗余字段,但它能大幅减少列表页统计的开销。查询时不需要每次都把 play_records 全表扫一遍。这个字段会在每次添加游玩记录时通过事务更新。同理,favorite 单独一个字段也比单独建收藏表更简单,因为这个项目不需要收藏历史。
3. 让游戏记录进入系统:从手动录入到批量导入
3.1 手动录入表单设计
对刚接触项目的人来说,第一步永远是从手动录入开始。我把录入表单做成一列式,字段顺序是“游戏名称 -> 版本区服 -> 状态 -> 时长 -> 封面”。名称最优先,因为后面所有去重逻辑都靠它。
这里有个容易被忽略的细节:游戏名称一定要让用户自己决定,不要在下拉框里内置固定的游戏列表。原因很简单,任何列表都覆盖不全,而且不同语言的官方译名不一样,用户自己填反而最准确。如果以后想要更省事,可以做一个“最近输入过的名称”联想,让重复录入变得更快。
手动录入的时候,时长字段我统一用分钟数。但玩家日常习惯说“3小时 20 分钟”,所以表单里做了两个输入框:小时和分钟,提交时再合并成分钟数。这样看起来是笨办法,却能避免用户在脑海里做心算,也减少填错概率。
3.2 CSV 批量导入与清洗
玩得多了之后,一个一个录就不现实。所以我又加了一个 CSV 导入功能,让用户可以从自己维护的表格文件里一次性导入几十条记录。模板字段定为:title, region, status, minutes, cover_path。
导入代码里最重要的一步是清洗,不是直接插入。下面这段 Python 做了几件事:去掉首尾空格、把中文括号统一成半角、跳过空行、自动识别标题列。
import csv import re def clean_title(title): title = title.strip() title = re.sub(r'[((]\s*[))]', '', title) return re.sub(r'\s+', ' ', title) def load_csv(file_path): records = [] with open(file_path, encoding='utf-8-sig') as f: reader = csv.DictReader(f) for row in reader: title = clean_title(row.get('title', '')) if not title: continue records.append({ 'title': title, 'region': row.get('region', '').strip(), 'status': row.get('status', '未开始').strip(), 'minutes': int(row.get('minutes', 0) or 0), 'cover_path': row.get('cover_path', '').strip() }) return records注意我打开文件时用了encoding='utf-8-sig',这是因为很多场景下用户导出的 CSV 是带 BOM 的 UTF-8,不用这个参数,第一列字段名会多出一个看不见的\ufeff,导致后面字段匹配不上。这个小问题我后面还会在踩坑章节里详细说。
3.3 去重逻辑与执行
导入的时候最怕重复。同一个游戏,CSV 里叫“传说之刃:黄昏回响”,数据库里叫“传说之刃黄昏回响”,如果在清洗阶段不统一规则,会造成大量重复条目。
我的去重规则分三层:
- 第一层:完全匹配。title 和 region 完全相同,直接跳过。
- 第二层:忽略标点。去掉空格、冒号、括号后匹配,匹配到则更新原记录。
- 第三层:关键词相似。归一化后用 Python 的相似度算法计算,超过一定阈值就认为是同一款。
下面这段代码实现了前两层,第三层需要引入额外的文本相似度模块,我放在导入函数的注释里简单说明。
def normalize_key(text): return re.sub(r'[\s::()()\'"《》\-_]+', '', text.lower()) def merge_records(existing, new_records): for rec in new_records: key = normalize_key(rec['title']) matched = False for old in existing: if normalize_key(old['title']) == key and old['region'] == rec['region']: update_game(old['id'], rec) matched = True break if not matched: insert_game(rec)实际测试下来,第三层相似度匹配对“副标题”这种场景很有效,但误伤率也高,所以我把它做成了导入预览里的“建议合并”选项,而不是自动执行。这算是经验之谈:宁可让用户多确认一次,也不要让系统自动把两个可能不是同一款的游戏合在一起。特别是那些“决定版”“年度版”“豪华版”,看起来相似,实际是不同版本,自动合并会让人非常头疼。
4. 游戏库界面与核心功能实现
4.1 总览页的筛选与排序
数据录进去之后,最重要的就是展示。我把总览页做成了“卡片墙 + 筛选器”的形式。卡片显示封面、标题、状态和累计时长,筛选器则提供状态、区服、标签和收藏四个维度。
排序逻辑我放在后端,因为数据量大了之后前端排序性能会有问题。默认排序按“最近更新”倒序,玩家刚录完一条记录,卡片应该出现在最前面,这样会带来即时反馈。也可以切换到“游玩时长”排序,这时 SQL 会直接用 total_minutes 字段:
if sort_by == 'hours': games = db.execute('SELECT * FROM games ORDER BY total_minutes DESC').fetchall() else: games = db.execute('SELECT * FROM games ORDER BY updated_at DESC').fetchall()这里有一个体验细节:筛选器是即时刷新的,但我不做防抖处理,而是每次筛选后直接重新加载页面。原因很简单,本地数据库响应足够快,不需要像远程接口那样拼命省请求,反而是“每点一次就刷新”给用户的反馈更明确。
4.2 游戏详情页与状态流转
点进任意一张卡片,能看到更完整的记录:评分、短评、所有游玩历史、标签。这里我特意设计了一个状态流转的按钮组,让状态切换只允许按规则走:未开始 -> 进行中 -> 已通关,或者未开始 -> 进行中 -> 弃坑。规则写在代码里,其实是为了防止误操作把“已通关”直接改成“未开始”。
很多工具都会忽略这种状态约束,以为越自由越好。但游戏进度是有时间方向的,你很难从“通关”退回到“没开始”,除非你自己意识到录错了。加了规则之后,误操作的概率大大降低,而且数据库里的状态值更干净。
一个很实用的细节是:每次把状态改为“已通关”时,系统会弹出一个表单,让你顺手填通关日期和评分。这样就不会出现游戏早就通关,但库里评分一直空着的情况。填完评分后,游戏卡片上会多出一颗星的标记,看起来很有成就感。
4.3 一个简单但有用的统计页面
玩到一定数量后,只做列表是不够的,人会产生“我到底玩了多少小时”的好奇心。所以我加了一个统计页,用最朴素的 HTML 表格展示三个数字:游戏总数、已通关数量、总游玩时长。还做了两个趋势图,一个是每月的游玩时长柱状图,一个是最近七天的记录条数。
图表我没有用任何前端图表库,直接拿 JavaScript 的 canvas 画,因为统计项很简单,画两张图不到 50 行代码,引入一个库反而增加维护成本。如果未来要做更复杂的分布图,再考虑换专业轮子。这里的思路是:个人项目的第一版,能少一个依赖就少一个依赖,以后需要了再补。
统计页还放了一张最近 30 天游玩热力图,每天一个格子,颜色从浅到深代表游玩分钟数。它不像专业统计工具那样精良,但每次打开都能很直观地看到,哪些天自己“整个人都泡在游戏里”,哪些天完全没碰主机。这种东西不需要精确到分钟,反而让人更有动力去保持记录。
5. 折腾过程中踩过的坑:编码、时区和无效数据
5.1 CSV 里的中文乱码问题
我记得第一次拿一个从表格软件导出的 CSV 来测 AnyPS5,很多行记录的中文标题全部乱码,唯独第一列是对的。排查了很久,最后发现问题不在解析,而在文件编码。很多场景下,表格软件默认用 GBK 编码保存 CSV,而 Python 的默认文本编码是 UTF-8,两边对不上,自然乱码。
解决方法是打开文件时做编码嗅探。我写了一个小函数,先尝试 UTF-8,失败时回退到 GBK:
def open_csv_with_fallback(path): for enc in ('utf-8-sig', 'gbk', 'utf-8'): try: f = open(path, encoding=enc) f.read(1) f.seek(0) return f except UnicodeDecodeError: continue raise ValueError('无法识别文件编码')这个函数之后被我复用到了所有文件导入场景,因为用户手里的文件来源太杂了,不能默认任何编码。以前的我可能会图省事只支持 UTF-8,现在绝对不会这么干。
5.2 游戏时长单位不统一
第二个坑来自导入模板。有的用户会在“minutes”列填“3小时20分”,有的填“200分钟”,还有的填“3h20m”。解析函数必须兼容这些写法,否则导入会直接报错。
我的做法是先把字符串全部转成小写,再用正则把数字和单位拆开:
import re def parse_minutes(text): if isinstance(text, int): return text text = text.lower().strip() hours = re.search(r'(\d+)\s*h(?:ours|rs|r)?', text) mins = re.search(r'(\d+)\s*m(?:in(?:ute)?s?)?', text) total = 0 if hours: total += int(hours.group(1)) * 60 if mins: total += int(mins.group(1)) return total if total else int(text)解析完之后还要判断范围。单次游玩记录超过 24 小时,基本就是填错了,我直接拒绝写入。这个限制避免了很多手滑导致的脏数据,比如把“2小时”写成“20小时”的误输入,至少会被拦下来一次。
5.3 时区偏移导致统计偏差
一开始在 play_records 表里存时间用datetime('now'),这个是 UTC 时间。我在东八区,晚上八点打的游戏,记录会被存到当天中午十二点,如果拿“日期”做分组统计,游戏时间会跑到错误的一天。
这个坑非常隐蔽,因为列表页显示的时候可能看起来是正常时间,但统计图里就少了晚上一段。最后我把所有时间字段统一改成datetime('now', 'localtime'),并且在读取时统一使用本地时区,问题才彻底解决。如果你也在做类似的记录工具,建议从一开始就固定时区约定,不要混用 UTC 和本地时间。
这个问题也提醒了我:任何涉及时间字段的工具,都要在一开始写下时区约定。哪怕只是本地单机工具,也会因为“数据库函数默认使用 UTC”而踩坑,更不用说以后如果做多设备部署。
5.4 封面前缀不统一怎么办
最后一个是资源管理的问题。很多游戏封面图片文件名来自不同来源,有的比较规范,有的则是随机命名。导入到系统后,卡片墙会变得混乱,因为根本没有封面图。
我没有做复杂的图片识别,而是用了一个笨办法:如果 cover_path 字段为空,就在详情页显示一个大字符样式的占位图,字符来自游戏名称的前两个汉字。这个方案解决不了真正的资源管理,但至少让界面看起来整齐,而且能避免因为图片缺失导致的视觉噪音。
如果未来要做完整方案,我倾向于把图片统一重命名为 game_id 开头,并存到一个有日期分层的目录里。这样既能减少重复文件,也让备份更清晰。资源管理和数据管理一样,规则越早定好,后面越省心。
6. 下一步计划:局域网部署、多用户与移动端适配
6.1 局域网部署思路
现在 AnyPS5 跑在单机上,所有数据都在本机 SQLite 文件里。如果想在家庭网络里多台设备访问,只需要让 Flask 监听0.0.0.0,再在启动脚本里给出一行命令。不过要注意,SQLite 不适合多进程同时写,所以真要部署到局域网,我计划先加一个简单的文件锁,保证同一时刻只有一个写事务。
页面方面,我也在做基础的响应式适配,让手机浏览器打开时卡片竖排、字号变大。毕竟整理游戏库这件事,很多时候是躺在沙发上想起来某个数据,顺手就改了。
6.2 多用户与家庭共享
下一步更有意思的是多用户。现在的数据表都是围绕“一个人的游戏库”设计的,缺少 owner_id 字段。如果要支持全家共用一台主机、各记各的进度,我得把 games、play_records、reviews 三张表都加上 owner_id 外键,并在所有查询入口传入当前用户标识。
这个改动涉及面很广,但也让项目真正配得上“AnyPS5”这个名字:不是只有一个人能用,而是任何成员都可以有自己的视图。首页要有“选择使用者”的入口,统计数据也要按人过滤。相比最初版本,这更像一个真正长期维护的私人工具。
6.3 一点个人体会
做 AnyPS5 最大的收获不是代码本身,而是明白了“整理游戏记录”这件事的价值。每一次手动录入,都是在建立一种更主动的记录习惯。工具越做越顺,我的游戏库从一开始的几十条到现在几百条,反而每一条都能快速定位。这个项目也让我重新审视“数据所有权”这个词:真正属于自己的游戏档案,不是云端给你展示的那几行字,而是你能导出、能复制、能解释的每一串记录。
如果你也想做类似的项目,我给你的建议是从一张纸和一支笔开始,先记录十条游戏,想清楚你最想追踪哪些字段,再开始写代码。数据结构是这类项目的地基,地基稳了,后面的功能怎么加都不会乱。