去年年底我把自己的游戏库清点了一遍,发现情况比想象中乱得多:数字版、实体盘、订阅库里的会免、偶尔打折冲动买的跨区小游戏,全部加起来,我自己都说不清到底有多少款,更说不清哪款在哪个区买更划算。于是就有了这个叫AnyPS5的本地工具项目——它不是那种"帮你在商店页面上点两下就能拿到低价"的抢券助手,而是把自己游戏资产管起来、把跨区价格差异摊开看、把奖杯数据落地的个人管理台。这篇文章就从项目动机、数据模型、技术选型到实际踩坑,完整讲一遍。
如果你手头有 PS5,又是一个喜欢跨区比价、会去整理奖杯、想清楚管理自己游戏库的人,那这篇文章里很多东西可以直接落地到你的工作流里。如果你是开发者,想参考一个"本地优先 + 公开数据解析 + 渐进式Web技术"的小工具项目,也会找到不少有价值的细节。
1. 项目缘起:三个让PS5玩家头疼的真实场景
1.1 跨区商店的价格差异到底有多大
索尼官方商店对数字版游戏采取的是各区域独立定价,加上汇率、税率、区域促销节奏不同,同一款游戏在不同区的价格差往往比很多人想象中大得多。
举个例子,某款在港区定价折合人民币约 390 元的 3A 游戏,在美区打折时可能只要 30 美元,折合人民币约 215 元;而日区打折力度大的时候,甚至能到 2200 日元左右,折合人民币不到 110 元。同一个游戏,只因为你在哪个区付款,差价能到三倍。问题是,玩家一般不会为一个游戏专门去三个区反复比较,最多看看朋友推荐的区,或者刷社交平台时偶然看到"某某区打折了"的消息,很容易错过最优解。
我在做 AnyPS5 之前,用的是笨办法:每个区的商店网页开一个浏览器标签页,挨个搜索,手动记录价格。这种办法效率极低,而且商店页面经常会因为地区调整、登录态变化而变化,记录的准确性也没法保证。最难受的是,当你游戏库里有几十款游戏想等着促销时,光维护这张"愿望单"就已经耗光了耐心。
后来我想明白一件事:比价这件事,本质不是"现在立刻买到最低价",而是"对自己的目标游戏建立一个长期的价格观察基线"。一旦有了基线,打折有没有诚意一眼就能看出来。这个认知直接决定了 AnyPS5 里价格模块的设计思路。
1.2 游戏资产散落在不同介质里
PS5 玩家的游戏库构成,通常远不是"我买了哪款游戏"这么简单,它至少包括这几个来源:
- 数字商店直接购买的游戏
- 实体光盘(如果做成数字版迁移,还要考虑码是否兑换)
- 二档订阅库里入库/退库的游戏
- 每月会免领取的游戏
- 限时试玩、活动赠送、跨区账号领取的版本
试想你某天突然想起"我好像有一款叫某某的独立游戏是当年会免领的",这时候你打算去哪儿找?打开主机商店翻已购买列表,翻半天可能还翻不到;网页端的购买记录列表又比较长。更麻烦的是,如果当年是美服账号领的,你还得切区去看。
我试过用 Excel 维护游戏库,但维护成本太高:每次入手新游戏都要手动补一条,想备注"什么时候打折入的""这游戏通了吗"也挺麻烦。后来痛定思痛,决定给自己写一个真正的、带数据库管理的本地工具,这就是 AnyPS5 的第一个雏形——游戏库管理。
1.3 奖杯数据其实可以更"本地化"
很多玩家喜欢看奖杯统计,但官方 App 主要做社交和推送,并没有特别友好的本地理财式统计视图。我更想看到这样一些维度的数据:
- 我所有游戏的白金率是多少
- 最近一个月拿了多少杯
- 哪些游戏烂尾了
- 每个游戏我玩了多少小时(这个数据其实不好拿,但可以通过奖杯时间做一个近似估算)
官方公开奖杯页面是一个被很多人忽略的数据源。玩家只要愿意公开自己的奖杯卡,就会生成一个可访问的页面,这个页面包含你已经获得的奖杯信息。由于这些页面本来就是公开的、可分享的,为它专门写一个本地解析工具,既不侵犯隐私,也是社区里常见的玩法。
于是 AnyPS5 的目标逐渐清晰:它不做破解、不做越狱、不碰任何灰色操作,只是把你自己公开的游戏数据、奖杯数据、商店价格数据从各处捞到一个本地数据库里,变成你自己的资产。这个定位让我可以放心分享它的设计思路,也让这个项目在后续很长一段时间里都保持了很干净的架构——不需要依赖任何非公开接口。
2. 功能拆解与数据模型:先想清楚"要存什么"
2.1 五大核心模块
在动手写代码之前,我先把需求拆成了五个模块,后来也基本没有改动过这个划分:
- 游戏库登记:手动录入或批量导入游戏基本信息,包括名称、平台、来源、购买时间、游玩状态。
- 价格跟踪:按游戏名去各区域商店搜索历史价格,维护价格基线,支持打折提醒。
- 奖杯统计:导入公开奖杯页数据,生成本地统计视图。
- 数据导出:一键导出 CSV 或 JSON,方便备份与其他工具联动。
- 本地检索:几万条数据存本地后,搜索响应要足够快,要支持中文模糊检索。
这五个模块之间通过一个共同的游戏 ID 关联起来。这个 ID 可以是我自己生成的一个哈希值,也可以是官方商店某款游戏页面自带的公开 ID。用公开 ID 做主键的好处是后续跨区合并时不容易产生重复记录,坏处是它本身不一定稳定——某些老游戏的 ID 在商店改版后可能就变了。因此我在实际实现中,采用了"本地自增 ID + 公开 ID 双键"的策略。
2.2 数据库表结构设计
AnyPS5 用的是 SQLite,一共设计了六张核心表。我挑重点说一下。
第一张是games表,存的是游戏的主数据。字段包括:local_id(本地自增主键)、title(游戏标题)、title_zh(中文名,可选)、region(默认区域代码)、source(来源:digital、disc、extra、essential、other)、status(游玩状态:backlog、playing、completed、platinum)、purchase_date、purchase_price、notes。这里有个容易被忽略的点:source字段一定要设计成可扩展的。比如后续某个游戏被踢出了二档订阅,你还要能在对应记录里标记"已退库",如果一开始只设置成四个固定值,后面扩展就要迁库。
第二张是prices表,存的是某游戏在某区域某时刻的价格快照。字段包括:game_id、region_code、price_value_cny(以人民币为基准的统一换算值)、original_value_cny、currency_code、discounted、discount_rate、captured_at。这张表会有大量写入,索引设计上我踩过坑,后面会专门说。
第三张是trophies表,存奖杯快照。字段包括:game_id、trophy_id、trophy_name、trophy_type(bronze、silver、gold、platinum)、earned_at、rarity。这里的rarity是奖杯稀有度,官方公开页面能看到,虽然精度不高,但用于个人统计已经足够。
其它还有wishlist、settings、sync_log三张辅助表。sync_log尤其重要,它记录每次价格刷新和奖杯同步的状态、耗时、失败原因,没有它你很难排查"为什么这次同步失败了"。
2.3 数据来源与合法边界
这是整个项目里最需要克制的地方。我的原则很简单:只用官方公开可访问的页面和接口,不碰任何需要登录态、需要破解、或者明显违反服务条款的数据通道。
具体来说,价格数据我从商店的公开搜索页面拿,这些页面在无登录状态下也可以直接访问,里面会返回游戏名、价格、折扣状态等基本信息。奖杯数据则完全依赖玩家自己提供公开链接,链接指向官方公开奖杯页面,应用只负责解析页面内容,不做任何侵入式请求。所有请求都做限速、做缓存,并尽可能降低对目标服务器的压力。
这个边界划清楚之后,项目推进得非常顺畅。因为不需要维护高风险的请求逻辑,后续几乎没出现过"接口失效就要重写"这类问题。
3. 技术选型:为什么最终选了 Electron + Vue + SQLite
3.1 为什么不做纯网页
我知道很多人看到这类工具的第一反应是:做成网页版不是更方便吗?用户打开浏览器就能用,连安装都不用。
但 AnyPS5 的数据场景决定了它必须本地化:奖杯数据、游戏库、价格历史都是长期积累的私有数据,放在服务器上既增加成本,又让用户不放心。而且价格历史这种数据,本地存储可以无限积累、随时查询,如果放在云端,存储和接口费用会随着用户量上涨变得不可控。
纯网页方案还有一个硬伤:本地数据怎么访问。浏览器里拿不到任意本地文件的读写权限,就算用文件选择器,也只能做一轮一次性导入,无法持续维护一个数据库文件。所以从第一版开始,我就定了"本地应用"这个方向。
3.2 前端框架怎么定的
技术栈选择上,我比较过几个方向。
Electron 是最先想到的,因为生态成熟,Vue 和 React 都能无缝接入,打包方案也相对固定。虽然它体积大、内存占用高,但对于"给个人电脑上的工具类应用"这种场景,开发效率和生态完整性比极致性能更重要。
Tauri 我也试过一版,优点是打包体积小、内存占用低,但当时遇到两个问题:一是 Windows 平台下 WebView2 的兼容性在不同机器上有差异,二是它的 Rust 侧对于我这个更熟悉 JavaScript 生态的人来说,维护成本偏高。为了一个个人工具,我不想把精力耗在系统级兼容的细节上。
综合下来,我选了 Electron + Vue 3 + Vite。Vue 3 的组合式 API 写起来比 Options API 更直接,组件间的状态管理用 Pinia 就够了,不需要上重型方案。
3.3 本地数据持久化:SQLite 到底有多合适
本地数据持久化我几乎没有犹豫就选了 SQLite。
对一个游戏库工具来说,数据总量不会特别大——一个重度玩家的游戏库可能几千条,价格快照几万条,用成熟的关系型数据库管理再适合不过。SQLite 提供完整的 SQL 能力、事务支持,而且所有数据就存在一个.db文件里,备份和迁移都极度简单。
有人会说,数据量不大,直接用 JSON 文件存不也行吗?确实可以,但一旦上了"搜索""多条件筛选""增量更新""去重合并"这些需求,用 JSON 文件做会非常难受,每一个操作都要把整个文件读进内存,还要自己维护索引。而 SQLite 可以让我在几天前记录的prices表上做一条索引,查询就直接走索引,速度快得多。
Electron 里接入 SQLite 的方案,我推荐用better-sqlite3,它是同步 API,配合 IPC 做一个数据访问层十分顺手。异步数据库驱动在本地小数据量场景下优势不明显,反而让代码复杂度上升,这是我自己对比之后的感受。
4. 跨区比价模块:接口、限速与缓存策略
4.1 区域商店页面结构差异
跨区比价是整个项目里技术上最"有意思"的部分,因为不同区域的商店页面结构差异非常大。有的区域直接返回 JSON 数据,有的区域返回的是服务端渲染的 HTML,还有的区域需要处理 Cookie 才能看到完整价格。
一开始我以为可以写一个"通用解析器",后来发现不现实。各区域内容的解析逻辑最终还是得拆开做,为此我提供一个适配器机制:每个区域对应一个解析器,统一输入游戏名,统一输出一个标准价格结构体。这个结构体包含region_code、price_raw、currency_code、discount_rate、is_available等字段。
这样一来,后续要新加一个区域,本质上就是多写一个适配器,不需要改动上层逻辑。这也是项目里我最满意的一个设计决策。
4.2 请求封装与限速
跨区请求最怕的就是触发对方的风控策略。虽然我说了只用公开页面,但如果一个小时内发几百个请求,仍然会对服务器造成压力,也可能让自己的 IP 被临时限制。
我在请求层做了三层保护:
- 全局请求队列:所有区域请求抛到一个带并发限制的队列里,默认并发数为 2。
- 每区域限速:同一区域的两个请求之间至少间隔 1.5 秒。实际测试下来,这个速度足以在几分钟内完成一个游戏的全区扫描,又能明显降低风险。
- 失败重试与退避:请求失败后不会立刻重试,而是先等 5 秒,连续失败三次就跳过并把任务状态记入
sync_log。
代码结构大致长这样:
class RegionFetcher { constructor(regionCode, delayMs = 1500) { this.regionCode = regionCode; this.delayMs = delayMs; this.lastRequestAt = 0; } async fetchGamePrice(gameTitle) { const now = Date.now(); const waitTime = this.lastRequestAt + this.delayMs - now; if (waitTime > 0) { await sleep(waitTime); } const result = await requestPrice(gameTitle, this.regionCode); this.lastRequestAt = Date.now(); return result; } }有朋友问我,为什么并发数不做高一点,明明公开页面访问频繁点也不一定有事。我的想法是,作为一个长期维护的工具,稳定性比瞬时速度更重要。万一对方把风控策略升级了,受影响的不只是我的工具,还有所有用这个工具的玩家。主动把请求强度控制在一个合理范围,是对自己的数据源负责,也是让项目能长期运转的前提。
4.3 缓存与增量更新策略
比价模块如果每次都去全量拉取所有游戏的实时价格,既慢又不礼貌。我设计了一套分层的缓存策略。
初次扫描某个游戏时,会拉取该游戏在各个区域的基础价格,存入prices表。之后每次手动刷新或者定时刷新,默认只看折扣字段的变化:如果一个游戏上次抓取时没有折扣、这次抓取时出现了价格变化,就记录快照并标记为"待通知"。
为了控制存储膨胀,我还会做价格快照的定期清理:保留每个游戏每个区域每个自然月的首末两条快照,中间过程数据可以合并成一条变化记录。这个逻辑不复杂,但能有效避免prices表在几个月后膨胀到几万条无用记录。
我建议用定时任务搭配手动触发双通道:应用打开时自动刷新一次愿望清单里的游戏价格,这是主要通道;用户手动点"立刻刷新"是次要通道。定时任务执行前会检查时间戳,距离上次刷新不足 4 小时就直接跳过,避免重复请求。
5. 奖杯统计模块:从公开分享页到本地图表
5.1 公开链接怎么拿
官方奖杯页面是公开可访问的,玩家可以在主机上把自己的奖杯公开,然后把对应的分享链接复制出来。这个链接通常会指向一个包含玩家在线 ID 的页面,页面上展示游戏列表和奖杯获得时间。
拿到链接后,AnyPS5 要做的第一步是解析玩家 ID,然后拉取这个页面。若页面需要处理的游戏很多,通常一次请求只能拿到列表视图,需要再进入每个游戏的具体页面去抓奖杯明细。整个抓取过程同样走上一章说的请求队列和限速策略。
这里有个体验上的小细节:第一次拉取时,玩家只会提供一个公开链接,我不知道他到底有多少款游戏。为了不让用户等待太久,我做了"先同步游戏列表,再按后台队列逐个同步奖杯明细"的两段式方案。用户打开应用时,可以先看到"游戏库奖杯状态已更新,部分明细同步中"的提示,而不必卡在一个漫长的加载页。
5.2 解析与数据处理
解析阶段我用了标准库里的 HTML 解析器,提取游戏名、奖杯名、奖杯类型、获取时间、稀有度等字段。在这个过程中我注意到几个坑:
第一个坑是奖杯稀有度数据在页面上是以百分比形式展示的,但不同区域的页面格式可能不同,有的区域会多加一个"尚未获取的奖杯数量"标签,这会干扰解析。处理方式是做严格的字段白名单匹配,只提取结构化明显的标签,不依赖自然语言分析。
第二个坑是游戏 ID 在不同区域可能不同。同一个游戏,美服和日服的奖杯列表可能是分开计算的,玩家如果切区游玩,会有多份奖杯记录。为了在本地把这些记录合并统计,我会把trophy_id保存为"本地哈希值",而不是直接用页面上的原始 ID。
解析后的数据写入trophies表,并通过game_id关联回游戏库。如果某款游戏在奖杯列表里出现、但游戏库登记表里还没有,系统会自动为它创建一条"待认领"记录,用户可以在界面上手动补全来源和游玩状态。
5.3 增量同步
第一次全量同步之后,后续同步必须做增量,不然每次都要遍历所有页面,浪费大量时间。增量同步的核心逻辑是:以earned_at为排序键,记录每个游戏最近一次已同步的奖杯时间戳。下次同步时,只抓取该时间戳之后的奖杯记录。
这个策略大部分时候有效,但有一个边界情况:如果玩家在主机上删除了奖杯记录或者重置了账号,时间戳会回退,增量同步会漏掉一些记录。因此我保留了一个"强制全量同步"按钮,并且在每次同步完成后记录页面返回的earned_at最大时间戳与本地已存的最大时间戳之差,如果差值异常为负值,就提醒用户执行一次全量同步。
到这一步,AnyPS5 的核心功能基本闭环了:游戏库、跨区价格、奖杯统计,三个原本散落的数据源都在本地汇聚。接下来是那些"文档里不会教你"的部分。
6. 踩坑实录:从原型到可用的八个问题
6.1 Electron 打包体积与安装体验
Electron 应用打包出来的体积,第一版轻松超过 150 MB。对于一个管理游戏库的小工具来说,这个体积不算大,但问题是很多玩家电脑上并没有装什么运行环境,解压路径一旦有中文空格,偶尔还会出现奇怪问题。
我后来把打包方案从electron-builder默认配置调成了"轻量打包":去掉不必要的多语言资源、把图标压到最小尺寸、开启asar压缩。最终体积降到 110 MB 左右,体积优化有限,但体验确实有改善。真正让安装变顺畅的还是加了一个"绿色版"输出——直接解压即可运行,不需要安装器。对玩家社区来说,绿色版比安装版更容易接受。
6.2 Windows 上的中文路径坑
我的开发机是 Windows,而 AnyPS5 的目标用户大概率也是 Windows。开发过程中我遇到了一个很典型的坑:某个游戏的中文标题里带全角冒号,比如"游戏:外传",这类名字在 Windows 文件系统下不能直接作为文件名。
这个问题出现在"游戏封面本地缓存"功能里——我用游戏名作为文件名来保存封面图片,结果三分之一的中文游戏名都无法保存。解决办法是在写文件名时做一次安全过滤,把非法字符替换成下划线,同时保留一份原始标题作为显示字段。这个坑不大,但如果不踩一次,很难注意,也给所有做本地文件工具的人提个醒:任何字符串只要来自用户输入,就不能直接当文件名用。
6.3 请求被拒与"接口悄悄改版"
有一次我连续跑了多次全区刷新,某区域请求突然开始返回异常。排查sync_log后发现,同一个 IP 在短时间内发了几十个请求,频率超过了对方设置的隐性阈值。虽然用的是公开页面,但这种行为确实不够友好。
从那以后,我把每区域重复间隔从 1.5 秒调到了 2.5 秒,并且每次批量刷新最多只处理 20 款游戏。如果用户愿望单超过 20 款,就分批跑,中间手动暂停。另一个经验是:接口解析逻辑要尽量集中在一个模块里。商店页面改版后,通常只需要改适配器内部的选择器,而不需要动业务逻辑。
6.4 better-sqlite3 路径问题与主进程/渲染进程分工
Electron 里使用better-sqlite3有一个坑:require原生模块之后,如果打包时没把.node文件正确包含进去,应用会直接启动失败。解决方式是在electron-builder配置里把node_modules改成"不打包,直接外置",并把asar设为false或做相应排除。
另外,数据库访问要放在主进程,渲染进程通过 IPC 访问。很多新手容易把数据库直接卖到渲染进程里,一调就报沙箱错误。我更推荐用一个统一的dbBridgeIPC 通道,渲染进程发请求时携带操作名和参数,主进程做数据访问后返回结果。这样既安全,又方便以后做数据迁移。
这个架构其实还可以再优化:把数据访问层抽象成一个纯函数模块,不依赖 Electron API,这样后续如果我想把核心逻辑搬到 Node CLI 里跑自动化测试,就非常方便。我自己在本地就是这样做的,所有同步逻辑都可以在无界面环境下直接跑,省掉了大量手动点击测试的时间。
7. 后续规划:插件化、云备份与社区数据共享
7.1 插件化:把"区域适配器"开放出来
AnyPS5 目前支持美区、港区、日区这几个主要区域的价格适配器,但每个区域适配器其实只是一段独立的解析代码。我准备在后续版本里开放插件化接口,让玩家可以自己写适配器,甚至可以通过配置文件把自定义的商店页面解析规则输进去。
这样一来,很多长尾区域、小众商店、限定版页面也许不需要我亲自维护,社区自然会补上。插件化意味着所有解析器不会被打进主程序,用户按需安装,主程序体积也能进一步下降。
7.2 云备份:本地优先但不止于本地
虽然数据完全本地存,但本地数据也怕硬盘坏了。我计划加入一个可选的加密备份通道,用户登录自己的网盘或者 WebDAV 服务后,可以按周自动把数据库文件加密上传。加密密钥只在本地保存,服务端只存储加密后的文件。
这里不能做成依赖远端数据的应用,否则就和一开始"本地优先"的定位冲突了。云备份只是容灾手段,数据读写始终走本地 SQLite,同步失败也不影响正常使用。
7.3 社区数据共享:匿名价格曲线
价格历史数据是很有价值的,如果很多用户愿意匿名共享自己本地的价格快照,那么 AnyPS5 可以聚合出一张"某游戏在近一年的真实价格曲线"。这对玩家判断打折力度非常有用。
但我很清楚,这个功能不能默认开启。数据必须脱敏、必须静默上传、允许用户一键关闭。更重要的是,绝不把用户游戏库信息用于任何其它目的。这个边界我会写进项目说明里,让使用者心里有数。
关于 AnyPS5 的一点个人体会
这个项目从第一行代码到现在,我最大的体会是:工具类项目没必要追求大而全,把"玩家真正会反复使用的数据"管好,就已经赢了。跨区比价、游戏库登记、奖杯统计,单看都不是创新,但把它们放进一个尊重数据来源、控制请求频率、优先本地存储的本地应用里,使用体验就完全不一样了。
如果你也想做一个类似的本地管理工具,我建议从数据库表设计开始,先把"要存什么""从哪来""怎么更新"想清楚,再碰界面。直接写 UI 会很快,但回头发现数据模型撑不住需求,返工成本远高于一开始多想几天。
最后分享一个实操小技巧:给所有外部数据请求增加一层缓存和sync_log的记录。平时没感觉,但一旦某个线上服务改版了,你会非常感激当初留下了足够的日志线索。