1. 游戏库从第三十款开始失控:我为什么要写AnyPS5
说实话,我的PS5游戏库大概从第三十款开始就彻底失控了。当时我对着主机里的游戏列表想找某款回合制RPG,想了半天没想明白它到底是实体盘还是数字版、当时多少钱入的、还差几个奖杯能白金。群里朋友建议我拉个表格,但我想要的不只是一张静态Excel,而是一个能统计、能检索、能长期维护的个人资料库。于是就有了AnyPS5——一个完全跑在本地、不修改主机、也不依赖任何未公开接口的PS5游戏信息聚合工具。
这个项目从名字就能看出定位:任何一台PS5的玩家,都可以用一套简单的数据录入方式,把自己的游戏档案整理起来。它不是官方应用,也不碰系统底层,它做的是"读数据、存数据、算数据"这三件事。我在设计之初就给自己划了一条线:所有数据都由玩家自己维护或从公开途径录入,工具本身只做整理和呈现。这样既避免了各种授权问题,也让整个项目可以脱离网络独立运行。
1.1 三个最让我头疼的具体场景
第一个场景是"我到底买了什么"。数字商店、实体光盘、会免入库、好友送的兑换码,来源五花八门。主机上虽然有游戏库,但它只告诉你"有这个东西",不会自动帮你标清购买日期、价格、来源渠道这些信息。
第二个场景是"进度到底到哪了"。有些游戏我玩了一半搁置,半年后想回去又不知道还剩多少内容,更记不清奖杯拿到了什么程度。主机上的奖杯列表能看,但看一次要点半天,尤其游戏一多,逐个翻完全不现实。
第三个场景是"我应该买还是该等"。新游戏发售时总忍不住想冲首发,但翻翻历史记录就会发现,很多游戏半年后价格直接腰斩。如果有一个本地价格记录表,每次想冲动消费之前先看一眼上一个类似游戏的降价曲线,能省下不少冤枉钱。
这三个场景有一个共同点:主机本身不提供结构化输出,但玩家自己心里是有这些信息的,只是缺一个地方把它们记下来、算明白。AnyPS5就是冲着这个"记录+计算"的缺口去的。
1.2 项目边界:一个做"聚合与统计"的工具
这里必须说清楚AnyPS5不做什么,避免有人看完文章以为它能跟主机实时通信,或者更糟,以为它涉及什么系统层面的改动。
AnyPS5的数据源完全独立于主机内部存储。它的工作方式是:玩家通过手动录入、批量导入模板、或者从官方公开的界面手动摘录数据,把这些信息喂给工具,工具负责清洗、去重、统计和可视化。它不连接主机,不读取存档文件,不做任何绕过机制的操作。从技术角度看,它就相当于一个私人的、带统计能力的游戏账本。
我把边界画这么清楚,一方面是合规考虑,另一方面也是工程上的合理性。主机系统是一个封闭环境,与其绞尽脑汁去适配私有格式,不如把精力放在"数据建模、统计逻辑、使用体验"这些自己真正能控制的地方。工具的价值在于让玩家用最少的操作维护一份结构完整的资料库,而不是尝试去替代主机本身。
1.3 哪些人适合照着这个思路自己折腾
先说结论:如果你只有三五款游戏,完全不需要这个工具,打开主机翻两下就找到了。AnyPS5的适用人群是游戏数量超过二十款、有整理习惯、希望用数据回顾自己游戏历程的玩家;或者是对本地工具开发、数据建模感兴趣的开发者,想找一个练手场景。
对开发者的价值在于:这个项目麻雀虽小,但完整覆盖了数据采集、清洗、存储、统计、展示、备份这一整条链路。它不用高深的技术,却能把很多基本功练扎实,比如字符串编码处理、时区计算、去重逻辑、索引设计。后面章节我会把每一步的具体实现和踩坑过程都讲清楚。
2. AnyPS5的数据管道:不连主机也能把信息攒齐
最开始我天真地想过:能不能直接从主机系统里导出游戏列表?试了一圈发现不行,至少在不做任何额外操作的前提下不行。系统备份文件里确实有相关数据,但格式没有公开文档,吃了闭门羹之后我果断调整了方向——与其跟系统格式较劲,不如把录入这件事做得足够舒服。
2.1 三条数据采集路径的取舍
我最终敲定了三条可以混合使用的采集路径,每条路径适合不同场景。
第一条路径是手动录入。打开一个只有几个字段的表单,填游戏名、版本、状态、购买日期、价格、来源,二十秒搞定一款。这条路径适合那些不常玩、只需要建档的游戏,或者数字商店里"免费入库"的游戏——这类游戏往往没有实体盘的信息可查,只能手动标记。
第二条路径是批量导入。我先做了一个CSV模板,列头跟数据库字段一一对应。如果玩家以前用Excel维护过游戏清单,直接把旧表格整理成模板格式,一次性导入上百条记录。我在这步投入了不少精力,因为对老玩家来说,手里大概率已经有一份或多或少的"启蒙表格",能把它们无损迁移进来,这个工具才算真正立得住。
第三条路径是截图辅助记录。奖杯信息没法批量导入,我就按游戏逐个截屏,然后用本机的图像识别辅助读出来,识别结果进入确认界面,人工修正一遍再确认入库。这个功能开发时挺有意思,但坦白讲精度达不到百分之百,中文成语类奖杯经常识别出错。我的处理是把OCR结果控制在"候选值"层面,绝不直接写入数据库,必须人工确认。
2.2 三种路径混合使用的数据流
三种路径不是互斥的,实际使用时是流水线关系:
- 新游戏入库:直接手动录入游戏基本信息,状态默认"未开始"。
- 一次导入旧数据:CSV批量导入,自动匹配已有记录,匹配上就合并而不是重复插入。
- 每周维护:通关一款游戏后更新状态、补录时长;拿了新奖杯就进奖杯页勾选。
这套流程跑了一个月之后,我最大的体会是:工具能不能用起来,很大程度上取决于录入成本够不够低。如果每次记一款游戏要填十个字段,维护动力就没了;但如果把大部分字段都设成下拉选项和默认值,实际上每次只需要敲几个字,数据就能保持鲜活。
2.3 录入模板长什么样
我做的批量导入模板是这样的结构,列头和数据字段一一对应:
title,title_cn,region,genre,status,release_year,play_hours,purchase_date,purchase_price,source,rating,notes Baldur's Gate 3,博德之门3,asia,rpg,playing,2023,47.5,2023-09-06,398,digital,9,第一张地图探索中注意几个细节:region用的是asia、europe、america、japan这类通用发行区标识,不涉及具体国别;status固定枚举值,后面统计全靠它分组;rating是1-10的整数字段,可以用来算自己的平均评分。CSV模板的列顺序和数据库字段顺序一致,导入时就不需要单独做字段映射了。
3. 数据模型怎么设计:一张主表加两张附属表
数据采集想清楚了,接下来就是存储。AnyPS5用的是SQLite,这也是我反复比较后的选择,理由后面会展开。整个库的核心是三张表:games、trophies、price_history。
3.1 games表:游戏档案的核心字段
games表是所有功能的起点,字段设计决定了后续统计能玩出什么花样。下面是我最终定下来的结构:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | INTEGER | 主键,自增 |
| title | TEXT | 英文标题 |
| title_cn | TEXT | 中文标题 |
| search_key | TEXT | 清洗后的检索用字段 |
| region | TEXT | 发行区域标签 |
| genre | TEXT | 类型标签 |
| status | TEXT | 枚举值:未开始/进行中/已通关/已白金/搁置 |
| release_year | INTEGER | 发售年份 |
| play_hours | REAL | 累计游玩时长,小时 |
| purchase_date | TEXT | 购买日期,ISO格式 |
| purchase_price | REAL | 购买价格 |
| source | TEXT | 来源:实体/数字/会免/赠品 |
| rating | INTEGER | 个人评分,1-10 |
| notes | TEXT | 备注 |
search_key是我专门为检索加的冗余字段。它存储的是经过清洗的标题文本:把全角字符转成半角、去掉空格、统一大小写。查询时在search_key上做匹配,避免因为全角半角、中英文混排导致搜不到东西。这个字段占用不了多少空间,但对检索速度和应用体验的提升非常明显。
3.2 trophies和price_history:进度与花费的过账记录
trophies表记录每个游戏的奖杯获得情况。字段包括game_id外键、奖杯名称、描述、类型、是否获得、获得时间。设计上没有直接把所有奖杯拼在一个字段里,而是拆成独立记录行,因为后续要按"最近获得时间""未完成铜杯数量"做筛选,拆行才能用SQL直接算。
CREATE TABLE trophies ( id INTEGER PRIMARY KEY, game_id INTEGER NOT NULL, trophy_name TEXT NOT NULL, description TEXT, tier TEXT DEFAULT 'bronze', achieved INTEGER DEFAULT 0, achieved_date TEXT, FOREIGN KEY (game_id) REFERENCES games(id) );price_history表是给"等折扣"这个场景用的,每一行记录某款游戏在某一天的参考价格。表结构很简单:game_id、record_date、price。平时每周手动维护一次,看到商店调价就更新。有了这张表就能画价格曲线,也能在下次想买游戏时翻出来给自己泼盆冷水。
3.3 为什么选SQLite而不是其他方案
我经常被问:数据量这么小,为什么不直接存JSON文件?我的回答是:JSON适合保存快照,不适合做查询。一旦你想算"平均游玩时长""白金率""按年份分组的总花费",用JSON要么全量读入内存逐条算,要么自己写一堆过滤逻辑;而SQLite用一条SELECT就解决了。
SQLite的另一个优势是零配置。它不需要安装数据库服务,一个文件就是整个库,备份时直接复制文件即可。AnyPS5作为一个个人工具,我不想引入MySQL或PostgreSQL这种重量级组件,也不想操心服务进程的启动停止。Python标准库自带sqlite3模块,不用装第三方包就能跑通全链路,这对可复现性来说太重要了。
4. 核心功能的实现细节:清洗、统计与本地检索
这章写代码层面的具体实现。AnyPS5的后端逻辑用Python标准库完成,前端输出成静态HTML页面,全程没有引入重量级框架。
4.1 数据清洗:先让数据"长成同一个样子"
不管手动录入还是CSV导入,原始数据一定是脏的:标题前后有看不见的空格,有些用户输入了全角字符,有些英文标题大小写不一致。如果把这些数据直接存进库,后面检索和去重都会出问题。
我的清洗函数是这样设计的:
import re import unicodedata def clean_search_key(raw: str) -> str: # 全角转半角 normalized = unicodedata.normalize('NFKC', raw) # 统一转小写 normalized = normalized.lower() # 去掉所有空白字符 normalized = re.sub(r'\s+', '', normalized) return normalized这个函数对所有标题执行四步操作:全角转半角、转小写、去空白、保留字母数字。清洗后的结果存入search_key字段。录入新游戏时,先调用这个函数生成检索字段,再去库里比对是否重复;如果匹配已有记录,就弹出合并确认而不是新增一条。
这段代码看似简单,却解决了我最头疼的重复录入问题。比如"Baldur's Gate 3"和"博德之门3"这两个输入,经过清洗后一个变成"baldurlsgate3",另一个变成中文串,显然不同;但英文标题如果存在"Baldur's Gate 3"这种多余空格的情况,清洗后就能正常匹配。没有这层处理,去重逻辑完全是空中楼阁。
4.2 统计口径:白金率、时长中位数和年度费用
存储结构定了之后,统计就是SQL查询的事。但统计口径需要认真定义,直接用现成函数很容易算出没意义的数字。
以白金率为例。如果按"白金数量/全部游戏数量"算,把几十张会免入库的"玩过五分钟"也算进去,白金率会低得离谱。我的口径是:分母只算已开始游玩的游戏,也就是status不是"未开始"的记录。分子是白金数量。这个口径更真实地反映"我手上这些认真玩过的游戏,有多少真正通关了"。
SELECT COUNT(*) AS total_played, SUM(CASE WHEN status = '白金' THEN 1 ELSE 0 END) AS platinums, ROUND(100.0 * SUM(CASE WHEN status = '白金' THEN 1 ELSE 0 END) / COUNT(*), 1) AS plat_rate FROM games WHERE status != '未开始';游玩时长同理,平均值容易被个别几百小时的特殊情况拉偏,我改用中位数。SQLite没有内置中位数函数,需要借助子查询:
SELECT AVG(play_hours) FROM ( SELECT play_hours FROM games WHERE play_hours > 0 AND status != '未开始' ORDER BY play_hours LIMIT 1 OFFSET (SELECT COUNT(*) FROM games WHERE play_hours > 0 AND status != '未开始') / 2 );这个写法的思路是:排序后取正中间那一条记录的数值,对奇数条是拿中间值,对偶数条跳过一半取后一条,实际用下来足够准确。年度费用统计更简单,purchase_date字段用substr(purchase_date, 1, 4)提取年份,然后GROUP BY年份,累加purchase_price。
4.3 本地全文检索:先用LIKE,再考虑要不要上分词
AnyPS5的检索需求很明确:玩家输入一个游戏名(中文、英文、简称都行),快速定位到对应卡片。我的实现分两层。
第一层直接查search_key字段:
SELECT * FROM games WHERE search_key LIKE ? || '%';这个查询利用了SQLite的索引机制,配合前缀匹配,性能对于几千条记录来说绰绰有余。但它的局限在于不支持"关键词出现在中间"的情况。比如玩家只记得"Gate 3"这几个词,搜"gate"能匹配,搜"baldurs gate"也能匹配,因为两者都是前缀,但如果搜索词来自标题中部,就无法命中。
第二层做了一个简单的分词兜底:把搜索词按空格拆成多个词,每个词单独做前缀匹配,要求至少命中一个。这个方案并不复杂,但已经覆盖了我日常使用中绝大多数场景。中文标题的检索就更简单了——中文字节串直接做子串匹配,SQLite的LIKE '%关键词%'在中文环境下表现可以接受。真要再进一步,就得引入分词库了,但对几百到几千条游戏记录来说收益不大,我把这个留给将来扩展。
5. 可视化与日常使用:把本地页面当成游戏控制台
数据存好、能查能算,还差最后一步:怎么把这些结果呈现出来。我不喜欢每次查询都打开终端敲SQL,所以AnyPS5会生成一个静态HTML报告,放在本地目录里,浏览器打开就是整个仪表盘。
5.1 仪表盘上放的四组核心指标
首页顶部放四张数字卡片,分别是:游戏总数、已白金数量、平均通关时长中位数、累计花费。四张卡片的数据对应四条SQL,每次生成页面时自动计算,不写死任何数字。
接下来是按状态分组的条形分布,比如"进行中12款、未开始30款、已白金8款、搁置15款",这个分布能直观反映游戏库的"健康程度":如果"未开始"数量远大于"进行中",说明买游戏的速度又超过了玩游戏的进度,是时候收手了。
再往下是年度购买频率表:2023年买了多少款、花了多少钱,2024年对比上年是涨是跌。别小看这张表,它是我克制冲动消费最强有力的工具——看到前一年的数据,很多想下手的游戏就冷静了一半。
最后是"最近玩过的游戏"列表。这个列表依赖last_played_date字段,按最后游玩时间倒序排列。只要导入时顺手填了这个日期,这个列表就一直是最新的。
5.2 游戏详情页的设计细节
点击游戏卡片进入详情页,里面包含三块内容:基本属性、奖杯进度、价格历史。
基本属性直接展示games表的字段,配一个状态下拉框,改完状态后一键保存回数据库,方便"终于通关了"这种时刻快速更新。
奖杯进度用进度条显示,百分比用已获得奖杯数除以总奖杯数。往下是未完成奖杯的列表,会按"最近获得时间"从早到晚排序,这样一眼就能看出哪些奖杯搁置时间最久。
价格历史画成简单折线图。我第一版直接用Python生成图片太麻烦,后来换成了纯HTML+JavaScript的轻量绘图方案:价格数据存成JSON数组,页面里用几十行原生代码画折线。没有引入图表库,页面加载速度快,维护也简单。
5.3 多设备之间怎么同步
AnyPS5是纯本地工具,多设备同步的场景本来是"没有设计"的。但我实际使用中确实遇到了书房电脑和笔记本切换的需求,解决办法出乎意料地朴素:SQLite数据库文件本身就是可迁移的。
我每次维护结束,执行一次"备份"操作,工具会把数据库文件和当前HTML报告打包成一个带时间戳的压缩包。换设备时,把压缩包解开,打开启动脚本,工具会先检测数据库文件是否存在,存在就直接读取,不存在就自动建空库。整个过程不用配置任何服务器,我用网盘的常规文件夹同步功能把这几个备份文件同步到其他设备,然后手动解压最新包就行。
这个方案谈不上优雅,但它符合AnyPS5"本地优先、零依赖"的定位。在个人工具这个尺度上,文件即数据、数据即备份,是最不容易出错的做法。
6. 开发中踩过的坑:字符串、时区、版本区分
AnyPS5功能不算复杂,但开发过程中我遇到了不少跟"游戏数据特点"强相关的坑。挑三个最典型的展开讲,这几个问题如果没注意,后续维护成本会明显上升。
6.1 游戏标题里的隐形字符
第一个坑是游戏标题里的不可见字符。很多从网页复制的游戏名,看着没问题,实际字符串里夹着不换行空格、零宽空格这些字符。早期版本没有清洗逻辑时,我遇到过"同一款游戏录了两遍,但程序认为它们不同"的迷惑现象,排查了好久才发现是两个不可见字符在作怪。
解决办法就是前面写的清洗函数。对所有文本字段统一走NFKC规范化,这个处理会把全角符号转半角、把多种空格统一为普通空格,再配合正则彻底去掉空白,基本能消灭这类问题。建议所有做本地记录类工具的朋友,在数据入口加一道清洗,宁可多算一步也不能让脏数据进入库。
6.2 奖杯获得时间的时区陷阱
第二个坑跟时区有关。录奖杯时间时,一开始我直接记录了系统给出的时间,比如"2024-11-10 23:30:00"。后来有一次跨时区出差后打开工具,发现最近获得的奖杯时间看起来整整晚了好几个小时,怎么想都不对,最后发现原始时间被系统当作UTC时间处理了。
正确的做法是:任何时刻的录入都统一先转成UTC再存库,展示的时候再转回本地时区。所有日期时间字段,我都建议用ISO 8601格式加时区后缀来存储,例如2024-11-10T23:30:00+08:00。虽然SQLite的日期函数对带时区字符串的支持有限,但保证存储格式规范,至少不会出现数据本体不可追究的问题。
6.3 同一款游戏的多个版本怎么区分
第三个坑是"标准版、豪华版、年度版"的共存问题。如果库里同时有标准版和豪华版,直接按标题检索会出现多条记录,如果不做严格区分,统计"游戏数量"时就会重复计算,价格和时长也全混了。
我的处理方式是在数据模型中增加一个可选的edition字段,区分标准版、豪华版、实体铁盒版等;同时给标题加上更明确的槽位,比如"God of War Ragnarok"后面加括号标注版本。去重逻辑用search_key + edition两个字段拼接后的值判断,同一款基础游戏的不同版本就会显示为并行记录,既保留完整性,又不会被统计口径误伤。
这里再补充一个经验:任何去重逻辑都不要忽略"同款游戏不同代"的情况。比如同一系列名称的二代、三代,标题可能只差一个数字,如果不按完整标题精确匹配,很容易把两代混成一条。我在录入界面特意加了一行提示,要求边录边填发售年份,这样遇到争议记录,至少能用年份做二次判断。
7. 一点个人体会和相关经验
最后说几句实在话。AnyPS5做下来,我最大的收获不是代码量,而是想明白了一个道理:个人工具能不能长期用下去,核心不在功能多,而在录入成本和维护频率的平衡。如果每次维护要花十五分钟以上,用不了两周就会放弃;但如果把高频操作压到三秒内,它就会变成顺手记录的习惯。
现在我的日常流程很简单:买了新游戏顺手录一条,通关了改个状态,每周花五分钟刷新价格和奖杯,再点一下"生成报告"。整个过程不需要连主机,不需要依赖别人提供的服务,任何一步断了都不影响其他数据。这就是我想要的状态。
如果你也想做类似的项目,我的建议是:不要一开始就追求"自动同步、智能识别"这些花哨能力,先把基础的数据模型设计好,把录入体验做顺,跑起来之后再考虑要不要加OCR、要不要做更花哨的图表。工具是给后面两三个月的自己用的,记得留一条"就算半年没打开也能快速恢复"的后路。