你打开PS商店,切换到港服看到一个价格,切到美服又看到一个价格,再切到日服发现还要再用计算器算一遍汇率。那年我为了等某个3A大作打折,连着两周每天晚上重复这套动作,最后实在烦不过,干脆给自己写了套小工具,名字就叫AnyPS5。它没什么高深的技术,核心就是三件事:把不同区服的PSN商店价格抓下来,统一转换成同一种货币,然后判断现在这个折扣到底值不值得买。
这篇文章就把AnyPS5从最初的手工查价痛点、数据抓取设计、价格计算逻辑到长期跑服务的完整过程拆开讲一遍。如果你也在做和游戏数据、跨区价格、折扣追踪相关的工具,哪怕不是做PS5而是其他平台,这里面的坑和思路大概率也能直接套用。
1. 从一次尴尬的“跨区比价”说起:AnyPS5最初要解决的痛点
AnyPS5这个需求,最初完全不是因为技术挑战,而是因为一个最朴素的问题:跨区买游戏根本不方便。
1.1 我平时的查价流程有多蠢
我主账号常驻港服,但平时也关注美服、日服,偶尔还会看欧服。这里面的逻辑很简单:同一款游戏,不同区服的定价策略差很多。有的大作在美服首发直接打到七折,港服却纹丝不动;有的日服游戏只有日服才有中文或日文语音,美服版本连菜单都不是那个味。所以“该在哪个区买”对于多平台玩家来说,真的是每天都要面对的决策。
我原来的流程说出来大家可能要笑:
- 打开美服PSN网页版,搜索游戏名,看标准版价格;
- 再开港服PSN,搜同一个游戏,拿港币价格;
- 再开日服PSN,搜同一个游戏,拿到日元价格;
- 打开汇率换算网站,把三种货币换算成人民币或者港币;
- 手动记录到Excel表格里,标上“当前折扣还剩几天”;
- 过几天再重复一次。
这套流程最折磨人的不是搜索,而是反复切换和重复劳动。尤其是碰到大型促销季,想追踪的游戏一次有十几款,光记录就要半小时。更烦的是,有些游戏价格降了,我以为是史低,结果后来查历史才发现不是,只是之前没记录下来。
所以当时我给这个项目定了几个很明确要解决的问题:把我关注的游戏铺到一个页面里,哪个区便宜直接排序看;自动抓当前折扣和截止时间;自动换算汇率;自动判断有没有到历史最低价。
1.2 为什么不用现成比价站,非要自己写一个
其实市面上不是没有比价网站。像PSPrices、PS Deals这类工具,总体上设计得挺不错,历史价格曲线也画得明明白白。
但我的需求有些特殊:
第一,很多现成比价站主要覆盖欧美服。日服、港服的小众游戏数据比较弱,尤其是那些只有日版才有中文的游戏,查询结果经常不准。
第二,我想要一套自己能控制的数据源。像是某个游戏在多个区服的封面图、中文名对照、不同版本(标准版/豪华版/终极版)的SKU信息,这些数据比价站不会按我的习惯整理。
第三,也是最重要的一点,我本身是做开发工作的,与其每次等别人网站更新,不如自己写个定时任务,每天早上把数据拉一遍,推送到自己的数据库里。长期来看,这个数据沉淀下来还能做很多事情,比如游戏发售日历提醒、账号游戏库同步、折扣趋势预测。
于是AnyPS5就立项了。
2. AnyPS5的数据抓取链路:不同区服的商店结构差异大了去了
AnyPS5的第一版做得很快,但很快就发现一个核心问题:PSN商店并不是一个方便爬取的结构化数据源。不同区服用的接口路径、字段名、价格格式都不一样,甚至同一个区服在不同时间段返回的数据结构都会变。
2.1 数据源与请求设计
先说数据源。相比直接抓HTML页面,我更倾向找PSN商店页面背后的JSON接口。这种方式有几个好处:数据量小、解析快、不易受页面改版影响。
不同区服的商店域名路径和接口设计不太一样。日服和美服虽然都走类似的内容服务接口,但港服的市场结构又有自己的差异。我在设计抓取器的时候,采用了一个比较朴素的策略:每个区服单独写一个抓取客户端,但是统一对外暴露相同的方法。比如:
class StoreClient: def get_product(self, product_id: str) -> dict: raise NotImplementedError class HKStoreClient(StoreClient): def get_product(self, product_id: str) -> dict: # 港服专用逻辑 ... class USStoreClient(StoreClient): def get_product(self, product_id: str) -> dict: # 美服专用逻辑 ...每个客户端里做的事情大致一样:构造请求URL、带上必要的请求头、解析JSON响应、过滤掉不需要的字段。但实际操作中每个区服的接口输入参数可能是产品ID、可能是标题关键字、也可能是UUID,所以在抓取层做了一层适配。
请求头这块要注意,不同区服的商店服务对客户端标识是有一定要求的。我一开始没有带任何自定义请求头,直接以请求库默认配置去抓,结果部分区服直接给我返回一个国区重定向页面。后来统一加上了类似浏览器访问的UA标识,问题就解决了。这里只是正常请求公开商店页面数据,不需要也不应该用什么特殊手段。
2.2 价格字段的解析陷阱
这是AnyPS5里我最想提醒后来人的一个坑:价格字段在不同区服的JSON里长得完全不一样。
美服商品接口里,价格通常会放在类似“price”下的对象里,有“basePrice”和“originalPrice”之分,还可能有“nonPlusUserPrice”和“plusUserPrice”。日服则经常把含税价和不含税价分开提供,字段名里带“taxIncluded”之类的标识。港服则是把价格直接放在推广活动对象里,平时价和促销价分开存储。
所以我在解析层写了很厚的兼容逻辑。核心思路是先统一标准化,再入库:
- 所有价格统一转换成“以当地货币为单位、保留两位小数”的数字;
- 分别保存原价和折后价两个字段;
- 如果是PS Plus会员优惠价,单独标记出来;
- 把优惠截止时间统一转成UTC时间戳。
我举个典型例子。某游戏在日服的标准版,接口里可能返回“消費税込み:2,189円”。“税込”和“税抜”是两码事。如果把不含税价格当成实际结算价格,最终换算成基准货币后可能产生5到10块人民币的误差。对于比价工具来说,这个误差是致命的,因为它可能直接影响“史低”判断。
{ "productId": "xxxx", "titleName": "Example Game", "price": { "basePrice": 2189, "taxIncluded": true, "discount": { "discountType": "percent", "rate": 20 } } }这个例子看起来简单,实际每个字段的命名在不同区服都不一样,所以解析层的单元测试非常重要。我后来每次上线新区服之前,都会先把该区服历史接口的响应样本存成fixture,用真实响应跑解析测试。
2.3 入库策略:先存原始值再算结果
很多第一次写类似工具的人容易犯一个错误:在抓取阶段就把所有换算都做了,然后只存最终人民币价格。这看起来省事,其实后患无穷。
汇率是会变的。今天港币对人民币是0.92,下个月可能就是0.88。如果你只存了换算后的价格,等汇率波动后你就不知道这个游戏在港区到底卖多少港币了,历史价格曲线也没法重画。
所以AnyPS5的表结构里,价格历史表存的是“原始货币价格”和“当时汇率快照”,最终折算价格是查询时现算的。一始一终,清清楚楚。
举个例子,这个数据结构大概是:
game_region: id game_id region sku_id title price_history: id game_region_id raw_price raw_discount_price currency platform plus_price sale_start_utc sale_end_utc created_at抓取的任务每天跑一次,如果某个游戏的价格和前一天完全一样,就不插入新纪录,只更新时间字段。这样数据量就不会因为每天重复抓取而爆炸式增长。
3. 汇率、时区与史低判定:比价工具的数学核心
价格抓下来只是第一步。如果比价工具只有“陈列价格”而没有“换算逻辑”,那我的使用体验还远谈不上便利。AnyPS5的换算层和史低判定层,才是真正把工具变成决策助手的地方。
3.1 基准货币与汇率口径
因为我自己主要用港服,日常记账也习惯用港币,所以我给AnyPS5设了港币作为基准货币。每个区服的原始价格最后都会显示成折算后的港币金额,同时保留原始货币和原始数值。
汇率数据用的是一个免费汇率接口,每天自动拉取一次,拿到的是中间价。这里有两个细节要注意:
- 中间价不等于结算汇率。如果你真要在某个区购买,实际支付时银行或信用卡给的汇率会有买入卖出价差。
- 汇率接口一天一变,所以如果同一个游戏价格不变,但折算后的金额变了,你要能区分到底是价格变了还是汇率变了。
我在页面上做了个小设计:折算金额旁边标注了“按当日中间价折算”,防止我自己产生“游戏是不是又降价了”的错觉,结果只是汇率波动。
3.2 优惠截止时间必须统一到UTC
Another坑是时间。PSN商店的促销截止时间在不同区服写法千奇百怪。美服常常返回一个具体时间戳,港服有时候只显示“优惠正在进行中”,日服喜欢用JST时区的日期。
如果不在入库前把这些时间都转成UTC时间戳,后面做“折扣即将结束提醒”就会出错。比如一个游戏的美服优惠是太平洋时间晚上12点结束,你以为还有一天,实际上一换算发现只剩几个小时。
我的做法是在标准化层强制把每个区间服的优惠结束时间解析为“带时区信息的ISO格式”,再转成UTC存储。如果是只有日期的字段,默认按该区服当地时间的23:59:59处理。
3.3 史低算法:不只是比较数字
史低看起来很简单:取历史价格里的最低值,对比当前价格。但实际操作中有几个细节会影响判断。
第一,临时折扣和史低要区分。有些游戏全年大部分时间都是原价,只在某些大型促销季给一点折扣。这种“临时打折”如果也被算进史低,那价格曲线就会很混乱。我的处理方式是:只有持续至少一天的折扣价才被标记为“可参考历史价”。
第二,PS Plus会员价要不要参与史低判断。会员价往往比普通价低一截,但不是每个人都是会员。我在AnyPS5里做了开关,默认“史低”是指非会员用户能拿到的普通折扣价,会员价单独显示。
第三,同一款游戏在不同区服的SKU可能不一样。标准版、豪华版、终极版是三个价格历史曲线,不能混在一个“史低”里判断。AnyPS5把SKU维度做进了数据模型里,页面展示的时候会明确标注版本。
我写了一个比较简单的判定函数:
def is_historical_low(sale_price: float, history_min: float) -> bool: if history_min is None: return True return sale_price < history_min - 0.01这里0.01的容差主要是防止浮点误差,同时也避免因为四舍五入导致“明明价格一样却显示为史低”的尴尬。
我更在意的其实是相对折扣幅度。史低是“绝对价格低”,但“折扣幅度大”可能更有参考价值。举个例子,一款定价598港币的游戏打折后418港币,虽然绝对值不小,但折扣幅度只有30%。另一款定价198港币的游戏打折后99港币,刚好半价。买198那款的“决策成本”明显更低。所以在AnyPS5的列表页上,我默认按“折扣后折算价”升序排序,同时展示折扣百分比,而不是一味突出某个游戏到了史低。
4. 把AnyPS5跑成一台24小时运转的小服务
AnyPS5最初是我写的一个命令行脚本,后来越用越顺手,就开始想把它做成一个常驻服务。这样我早上起来不需要手动跑脚本,只要打开网页或者看推送通知就行。
4.1 整体架构
整个服务分成了几个模块,彼此之间是独立进程,方便单独更新和调试。
- 抓取调度器:负责按设定时间触发各区服抓取任务;
- 解析器:对接前面说的标准化逻辑;
- 价格存储层:负责入库、去重、历史汇总;
- Web前端:提供列表页、详情页、筛选条件;
- 通知模块:通过Webhook或邮件推送价格变动和临近截止的折扣。
我选型的时候优先考虑了自己最熟悉的技术栈,没有引入特别复杂的框架,因为这种个人工具最重要的是好维护,而不是设计得多么炫。抓取层用Python写,网页服务用Node.js写,数据库先用SQLite,等数据量上来之后再看要不要迁到MySQL。轻量是现阶段最核心的需求。
4.2 通知触发逻辑
比价工具的价值在于“被动等待”而不是“主动查询”。如果还是要我每天打开页面看,那我为什么不做个Excel表格就行了。所以AnyPS5最让我满意的功能是折扣通知。
通知规则我设计成了一套简单的“若满足则推”逻辑:
- 某个关注游戏的价格跌到设定目标价以下;
- 当前价格跌破此前史低;
- 某个游戏的优惠在24小时内结束;
- 多区域价格对比中,某个区域的价格成为全场最低。
每种通知都可以独立开关。比如我不太关注“优惠即将结束”这类提醒,但非常在意史低价,那我就只开前两条。
通知渠道上,我接了非常轻量的Webhook机器人,直接把消息推到自己常用的通讯软件里。推送内容包含游戏名、区服、当前折算价、折扣比例、优惠截止时间,以及一个跳转到详情页的链接。
4.3 部署与运维
AnyPS5跑在一台低配云服务器上就够了,因为它的抓取频率不是高并发场景,数据库压力也很小。我用了Docker Compose把各个模块编排起来,写了一个简单的部署脚本。
日常运维其实很省心,最常做的事情就是查看日志,确认某个区服当天有没有抓取失败。为了不让日志把磁盘撑爆,我只保留最近7天的日志,并且每个抓取任务都带上耗时统计。如果一个区服连续抓取失败超过三次,就会触发告警通知。
还有一个细节值得一提:因为价格抓取涉及多时区,定时任务我全部显式指定了UTC时区,避免服务器本地时区设置不同导致任务凌晨乱跑。每天抓取一次的频率之外,我还额外在大型促销季把频率提到6小时一次,毕竟促销刚开始的价格变化往往特别频繁,尤其是一些热门游戏首发当天就在多个区服同步调整价格。
5. 上线两周我踩过的坑,以及现在回头看该怎么避
AnyPS5第二周基本就在一边跑一边修,大大小小的问题遇到不少。挑几个有代表性的讲讲,都是那种如果没人提,你可能要自己折腾很久才能发现的坑。
5.1 日服税率混进价格
日服的价格字段里经常有含税和不含税两种,我第一版解析没注意优先级,直接取了含税价,但和另一份不含税的数据混在一起。结果就是某个游戏在日服显示的价格比实际价格高了整整10%,而且这个误差还是均匀出现的那种,很难察觉。
后来我加了校验逻辑:如果同一商品同时出现含税价和不含税价,优先使用含税价;如果只有不含税价,按标准消费税率手动加税后再入库。这样至少保证了日服内部的口径一致。
5.2 港服接口的一段返回重定向
抓港服时,有一段时间我发现访问接口会时不时返回一个带国家参数的重定向页面,而不是正常JSON。排查之后发现是请求头里的语言区域设置被服务端识别成了其他国家。解决方式也很简单,显式把语言区域信息加进请求里,并且抓取失败时自动重试一次,重试时更换备用接口路径。
这种问题最怕的不是抓不到数据,而是抓到了错误的数据。所以我给每个区服都加了“响应体预检”,判断返回内容到底是JSON还是HTML。如果预检不通过,宁可这次不更新,也不能把错误数据写进数据库。
5.3 史低判定被PS Plus价格干扰
一开始我把PS Plus会员价也直接并入史低计算,结果好几个游戏显示“今日史低”,点进去才发现是会员专属价。这对于我这种不想续会员的人没有任何意义,反而让我误以为普通玩家也能买到。
后来我彻底把“普通用户价”和“会员价”分成了两个独立的比较序列,互相不干扰。页面展示时,会员价会以更醒目的方式标注“需PS Plus会员”,史低徽章只出现在非会员普通价的维度上。
5.4 凌晨三点收到了降价通知
有一个游戏在促销活动的第一个小时就降价了,然后我的通知在凌晨3点就把我叫醒了。那一刻我的感受非常复杂:工具是准确的,但不太智能。
后来我加入了“免打扰时段”配置,默认晚上11点到早上8点不推送通知,但会把这段时间内的提醒堆积起来,早上9点统一汇总推一次。这样既不漏信息,也不会打扰正常生活。这个功能看似很简单,但实际体验差别非常大。
5.5 现在的抓取工作流
经过几轮迭代,现在的AnyPS5抓取流程大致长这样:
- 调度器触发任务;
- 从订阅列表里读需要抓取的游戏清单;
- 对每个区服依次发起价格查询请求;
- 解析响应并标准化;
- 与数据库中上一条记录比较,价格无变化就静默跳过;
- 有变化则写入价格历史,并进入通知判断;
- 所有任务完成后输出执行摘要日志。
整个链路不算复杂,但每一步都有对应的异常处理。对于个人工具来说,稳定的运行比炫酷的功能更重要。
6. 如果让我重写一遍AnyPS5,这几个设计我会更早想清楚
AnyPS5现在已经是我的常驻工具了,但回想起来,有几个设计上的调整如果能早点做的话,后续会省很多事。
6.1 数据模型值得再推敲
我最开始把SKU、游戏、区域三个概念搅在一起,导致后期想支持“同一游戏在不同区服的不同版本”时,不得不做一次数据库迁移。如果重新设计,我会从第一天就把这几个维度拆开:
- 游戏实体只存游戏本身的元信息,比如封面、系列、发行日期;
- 区域SKU单独建表,存游戏在某区服具体版本的唯一标识;
- 价格历史表只绑定区域SKU,不直接指向游戏。
这样增加一个新区域、新版本都是加行而不是改表结构。
6.2 多语言标题匹配是真正的大工程
同一款游戏在不同区服的标题经常不一样。美服叫“Final Stranger”,日服可能是片假名直接拼写,港服又可能用繁体中文翻译。要把这些识别成同一个游戏,比我想象中难得多。
我现在的做法是维护了一组别名映射表,手工录入常见游戏的跨区名称。机械匹配还是有一些遗漏,每逢新游戏发售就要补数据。理想状态下应该用模糊匹配加人工审核的流程,但这是个人工具,工作量有限,手工维护勉强够用。
如果你也想做类似工具,建议一开始就预留别名映射表这个功能,别等到数据积累多了再做,那会非常痛苦。
6.3 下一步我准备做些什么
AnyPS5目前只解决“买之前”的问题,也就是比价、史低、通知。我现在在考虑往后端扩展:
- 接入自己账号的游戏库,同步“我买过的游戏”和“我还没买的游戏”;
- 把关注列表做成动态愿望单,支持多平台共享;
- 增加游戏发售日历提醒,预购折扣也能第一时间获知;
- 对历史价格数据做更细维度的统计,看看哪些区服的折扣频率最高、力度最大。
数据已经攒了两三个月,做这些功能的前提条件都具备。但工具这种东西,永远是先解决自己最痛的问题,再考虑锦上添花。
说回AnyPS5本身,它最大的价值不是代码写得多漂亮,也不是架构多先进,而是它把一套“反复手动确认”变成了“自动推送结果”。我在这个项目里最深的体会是,比价类工具真正的难点不在抓取,而在于你如何理解不同区域、不同版本、不同计价规则之间的差异。这些差异处理好了,工具才有长期使用的价值。如果你也在折腾类似的东西,建议先从最小的场景开始,明确你到底想解决哪个购买决策,然后让数据替你说话。