☰
AnyPS5:用Python和SQLite打造游戏库存管理与价格追踪系统
2026/10/14 9:12:21 网站建设 项目流程

周末晚上我本来想从库里翻个游戏出来放松一下,结果盯着屏幕划了快二十分钟,愣是没想好该玩哪个。库里躺着三百多个数字版游戏,有些是哪个区买的都记不清了,更不用说谁在打折、谁进了会员库、谁其实早就通关了。那天之后我开始认真琢磨写一个叫 AnyPS5 的小工具,专门把自己跟 PS5 相关的内容整合到本地,用起来顺手、数据也完全自己掌控。如果你想给自己打理一套游戏库存管理系统,或者对抓取、落库、定时任务这类轻量开发感兴趣,这篇内容应该能给你提供一条完整可落地的思路。

这个项目的定位从一开始就很清晰:不做破解、不做私服、不碰任何灰色手段,只围绕公开可获取的商店信息与自己手动维护的记录,把所有数据集中在一个本地服务里。它解决的是玩家最日常的痛点:游戏太多导致的选择困难、跨区价格没法直观对比、史低信息要开好几个网页才能确认。对开发者来说,它也是一个很小的、却能完整覆盖“采集—存储—处理—展示—提醒”全链路的练手项目。

1. 为什么会有 AnyPS5:玩家库存管理里的真实痛点

1.1 从一次“想玩却找不到”的经历说起

那天的真实情况是这样的:我先把库里所有游戏按安装状态排了一下,发现装了六十多个,但根本想不起来其中一半当初为什么买。接着我打开商店想看看最近有什么值得买的,结果同一个游戏在不同区域的价格差能到百分之四十。我又想对照一下自己已有的版本和另一个区的版本有没有内容差异,发现得同时开五六个网页才能勉强比对。整个过程没有一步是“玩”本身,却花掉了比玩游戏还多的时间。

后来我在一个玩家社群里聊起这事,发现很多人有类似的困扰:有人为了找史低专门做了个表格手动记录价格,有人因为重复购买了同一个游戏在不同区的版本而懊恼,还有人压根不知道自己会员库的免费游戏里已经包含了想玩的标题。这些需求其实都需要一个统一的本地数据层来承接,而不是每次打开浏览器现场查。

1.2 需求拆解:不追求大而全,先把高频场景做透

开始动工之前,我把需求收敛成了四件事:

  • 库存画像:知道自己有哪些游戏、在哪个区、什么版本、总共花了多少钱;
  • 价格追踪:定期记录公开商店的价格信息,算史低、算当前折扣力度;
  • 区服对比:同一个游戏在不同区域的价格、版本信息放在一起看;
  • 进度管理:标记已经通关、正在玩、想玩,以及心愿单里等待打折的条目。

这四个方向里没有一项涉及绕过平台限制的操作,全部建立在用户自己能看到的信息和手动补充的记录之上。我当时给自己定的原则是“宁可少一个花哨功能,也要让核心记录的准确性足够过硬”。这个边界很重要,想清楚边界之后,后面选型、设计表结构、写采集逻辑都会顺畅很多。

2. 整体架构选型:一台常开的小机器就能跑起来

2.1 为什么选 Python + SQLite 而不是上一套重型框架

我最早考虑过用关系型数据库加后端框架再加独立前端的全家桶方案,但仔细算了一下数据量和并发量,发现完全没必要。个人使用的场景下,可能同时只有一台手机加一台电脑在访问,游戏数量撑死也就几千条,价格历史按每天一次记录跑一年也不过百万行以内,这个量级 SQLite 处理起来绰绰有余。

技术选型我最终定为:

  • Python 3.11:数据处理生态成熟,写起来快,社区资料多;
  • SQLite + 自定义数据访问层:不需要单独装数据库服务,备份就是一个文件;
  • APScheduler:负责定时触发价格采集任务,轻量且支持持久化任务状态;
  • 一个非常简单的 Web 服务:用 FastAPI 提供几个只读接口,前端是单个 HTML 文件;
  • 前端纯静态:用原生 JavaScript 加一个小型图表库展示价格曲线。

这个组合的好处是部署极其简单,一台常开的迷你主机甚至树莓派都能跑。我没有引入消息队列,也没有用容器编排,因为问题的复杂度根本没到那一步。很多人一上来就把架构搞得很宏大,结果数据量连一个 Excel 文件都装不满,维护成本反而成了最大的负担。

2.2 数据从哪来:公开商店数据的采集与本地化落库

价格数据完全来自商店公开的网页端点,每次请求拿到的都是普通的 JSON 响应,里面包含标题、标准价格、折扣价格、折扣比例、分级信息、简介等字段。我只需要用带请求头的 HTTP 客户端按一定频率去拉取,解析后落入本地库。

这里有个很重要的原则:采集频率必须克制。个人工具不需要实时同步,一天跑一次完全够用。频率太高不仅给对方服务器造成压力,还可能让自己的 IP 被临时限制,反而导致后面正常请求也失败。我实际采用的是每天凌晨一次全量价格更新,加上手动触发一次增量更新。

落库的时候有个容易忽略的点:数据源返回的字段名和格式可能随时变化。比如折扣价格字段可能从discounted_price改成price_with_discount,类型可能从字符串变成数值。所以我写了一个简单的适配层,把外部字段映射到内部统一的模型,任何字段解析失败都会记录到日志而不是让整个任务崩溃。这种防御性设计在后来的实际使用中救了我好几次。

2.3 核心表结构设计:围绕“游戏”和“记录”两组实体建模

设计表结构时,我把数据分成两类:一类是相对静态的元数据,比如游戏名称、发行商、分类;另一类是随时间变化的状态数据,比如价格、折扣、我的游玩进度。分开建模之后,查询和分析都会舒服很多。

实际落地的主要表结构如下:

CREATE TABLE games ( id INTEGER PRIMARY KEY AUTOINCREMENT, normalized_title TEXT NOT NULL, region TEXT NOT NULL, store_id TEXT, content_type TEXT DEFAULT 'game', release_date TEXT, publisher TEXT, metadata_json TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP, updated_at TEXT DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE game_variants ( id INTEGER PRIMARY KEY AUTOINCREMENT, game_id INTEGER NOT NULL, region TEXT NOT NULL, title TEXT NOT NULL, subtitle TEXT, store_url TEXT, raw_platform TEXT, raw_price REAL, raw_discount_price REAL, currency TEXT, last_seen_at TEXT, FOREIGN KEY (game_id) REFERENCES games(id) ); CREATE TABLE price_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, variant_id INTEGER NOT NULL, recorded_at TEXT NOT NULL, standard_price REAL, discounted_price REAL, currency TEXT, discount_ratio REAL, FOREIGN KEY (variant_id) REFERENCES game_variants(id) ); CREATE TABLE play_status ( id INTEGER PRIMARY KEY AUTOINCREMENT, game_id INTEGER NOT NULL, status TEXT CHECK(status IN ('backlog', 'playing', 'completed', 'wishlist')), rating INTEGER, finished_at TEXT, notes TEXT, updated_at TEXT, FOREIGN KEY (game_id) REFERENCES games(id) );

games表存的是抽象后的游戏本体,game_variants存的是它在各个区域商店里的具体条目,price_history记录每一次采集到的价格快照,play_status则完全由我自己手动维护。这套模型不复杂,但足够支撑后面的价格曲线、区域对比和进度管理。

3. 核心功能实现:从价格追踪到跨区对比

3.1 价格追踪任务的实现:轮询调度与增量更新

价格追踪是 AnyPS5 最核心的模块。我使用 APScheduler 的CronTrigger设置每天凌晨 2 点执行一次全量采集,另外提供一个手动触发的接口用于临时补抓。采集流程分三步:拉取商店搜索列表、逐条获取详情页数据、把结果写入价格历史表。

下面这段是采集任务的核心骨架:

from apscheduler.schedulers.blocking import BlockingScheduler from apscheduler.triggers.cron import CronTrigger import requests, sqlite3, datetime HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AnyPS5/1.0", "Accept": "application/json", } def fetch_region_snapshot(region_code: str): items = [] offset = 0 while True: params = { "region": region_code, "offset": offset, "size": 50, } resp = requests.get("https://store.example.api/catalog", params=params, headers=HEADERS, timeout=30) resp.raise_for_status() data = resp.json() items.extend(data.get("items", [])) if not data.get("has_next"): break offset += 50 return items def record_prices(region_code: str): con = sqlite3.connect("anyps5.db") cur = con.cursor() for item in fetch_region_snapshot(region_code): title = item.get("name") if not title: continue variant_id = upsert_variant(cur, region_code, item) if variant_id is None: continue std_price = parse_price(item.get("base_price")) disc_price = parse_price(item.get("discounted_price")) cur.execute( "INSERT INTO price_history (variant_id, recorded_at, standard_price, discounted_price, currency, discount_ratio) " "VALUES (?, ?, ?, ?, ?, ?)", (variant_id, datetime.date.today().isoformat(), std_price, disc_price, item.get("currency", "USD"), round((1 - disc_price / std_price), 4) if std_price and disc_price else None) ) con.commit() con.close()

upsert_variant的作用是根据store_id判断这个区域的商店条目是否已存在,存在就更新标题和链接,不存在就插入新记录。这样每次都只会对价格历史追加新行,不会重复插入游戏条目。这个设计避免了把“游戏元数据”和“价格快照”混在一起,后续查史低价格时只需对price_history做聚合查询。

3.2 史低价格与折扣力度的计算逻辑

有了价格历史,剩下的问题就是怎样把“最低价”算得准确。我这里的口径是:折扣价有值且大于零时取折扣价,否则取标准价,比较区间可以按全部历史、最近一年、最近三个月分别聚合。最终展示时,把当前价格、历史最低、折扣比例放在同一条记录里。

SELECT v.id, v.title, v.raw_discount_price AS current_price, MIN( CASE WHEN ph.discounted_price IS NOT NULL AND ph.discounted_price > 0 THEN ph.discounted_price ELSE ph.standard_price END ) AS historical_low FROM game_variants v JOIN price_history ph ON ph.variant_id = v.id WHERE v.region = 'HK' GROUP BY v.id, v.title, v.raw_discount_price;

这个查询在数据量变大之后可能会慢,但个人使用完全感知不到。我没有做额外的缓存,因为每次打开页面触发一次实时查询也就几十毫秒。真到了需要优化的时候,再对这个查询加一个物化视图也不迟,过早优化总是浪费时间的最大来源。

3.3 跨区域版本归一化:把“同一个游戏”关联起来

同一个游戏在美服、港服、日服往往有不同的标题、不同的版本、不同的商店编号。如果不对这些条目做归一化,我的库存统计和价格对比就会出现重复。我采用了两层策略:先按“规范化后的标题 + 平台”自动匹配,匹配不到的进入手工关联队列。

规范化规则不复杂但很有效:全角转半角,去除非字母数字字符,去掉常见的副标题标记比如- Digital、- Deluxe Edition,统一大小写。八成以上的条目能靠这套规则自动对上,剩下的在页面上提供一个“手动关联到已有游戏”的按钮,把两个区域的game_variants指向同一个games.id。

跨区对比的页面核心就是把同一个game_id下的所有变体放在一起,横向展示各区域当前价格和史低。这个功能在打折季特别好用,因为不同区域的折扣节奏完全不一样,有些地方提前一两天就开始打折了。

3.4 游玩进度与心愿单:从“拥有”走向“管理”

进度管理的实现非常直白:play_status表按游戏记录状态,页面上四个 tab 分别展示“想玩”“在玩”“已通关”“心愿单”。通关的时候可以顺手打个分、写一句短评,下次打开页面看到自己的评价,比任何奖杯列表都更能唤起记忆。

心愿单单独说明一下:它虽然也叫“想玩”,但与play_status里的 backlog 不同,它更侧重“等打折再买”的预期。因此心愿单条目会额外绑定一个目标价格,当某次价格采集发现当前价已经低于目标价,就触发提醒逻辑,在首页横幅显示“你心愿单里的某某游戏已经降到目标价以下了”。这种基于规则的提醒比单纯的被动展示更有价值。

4. 实测中最容易翻车的五个细节

4.1 区域货币换算:别让价格对比变成“数字比大小”

最开始我把不同区域的价格直接放在同一张表里做对比,港币、美元、日元、人民币混在一起,完全没考虑汇率。后来某个游戏在日服的数字看着是两千多日元,折合下来其实比美服的 29.99 美元还便宜,我这才意识到必须引入换算基准。

我的做法是:每天凌晨采集价格的同时拉取一次公开汇率,把各个区域的价格都按当日汇率折算成一个统一的基准货币存到表里,展示时才同时显示原币价格和折算价格。比较历史价格时,也统一用折算后的基准货币,否则隔了几天的记录直接比毫无意义。

这里有个容易被忽略的点:历史换算要用当天的汇率,而不是今天的汇率。如果某条记录是三个月前的价格,我就得用三个月前的汇率折算,否则会受到汇率波动干扰,导致“假史低”。我因此在price_history表里加了一列base_currency_price,在写入时就完成折算并固化下来。

4.2 同一游戏不同版本条目的去重问题

跨区归一化看起来简单,实际跑起来总有些边缘情况:有些游戏在同一个区就有普通版、豪华版、完全版三个条目,标题几乎一样。自动匹配会把这几个版本并到同一个game_id下面,价格对比表里就会出现三行,容易混淆。

我的处理是在自动匹配后增加一个置信度分数:标题完全一致加 100 分,发行商一致加 30 分,发售日期一致加 20 分,原价接近加 10 分。超过 150 分才允许自动关联,否则进手动队列。这个阈值是我在两百多个样本上调出来的,一开始设 100 分时误关联太多,后来调到 150 分才稳定。

手动管理界面做了一个小的“合并/拆分”操作:合并时选择两个变体归到同一个game_id,拆分时把关联错误的一个变体重新分配到一个新的游戏条目。这些操作都会记录操作日志,避免误操作后无法恢复。

4.3 商店接口字段变动导致的采集中断

运营了不到两个月,我就遇到过一次采集任务大面积失败。现象是日志里出现大量KeyError: 'discounted_price',定位后发现接口把折扣字段改名了,同时把原本的字符串数字改成了整数。如果没有适配层和异常捕获,整个任务会在第一个异常处崩溃,当天价格历史就断档了。

我后来加了两个机制:解析函数对每个字段做独立 try,解析失败只跳过该字段,不影响整条记录;任务执行完成后对比“预期条目数”与“实际写入条数”,差异过大时输出告警。这让问题能被提前发现,而不是等两周后想看历史价格时才察觉有好多天是残缺数据。

4.4 大量历史数据导入时的写入性能

刚开始写采集任务时,我图省事,每条价格记录都单独INSERT后立刻commit,抓一个区几百条数据还好,跑全量时明显很慢。后来改成批量执行加粗粒度提交:

rows = [] for item in items: rows.append(convert_to_price_row(item)) con.executemany( "INSERT INTO price_history (variant_id, recorded_at, standard_price, discounted_price, currency, discount_ratio) " "VALUES (?, ?, ?, ?, ?, ?)", rows ) con.commit()

实测单次批量写入一万行,时间从十几秒降到了两秒以内。这类优化非常不起眼,但采集任务每天都要跑,长期积累下来的性能差异很可观。此外给price_history.variant_id建了索引,按月分表归档超过一年的历史数据,保持主表体积可控。

4.5 前端展示层对缺失字段的容错

这个坑不是技术难度大,而是很容易被忽略。接口返回的数据偶尔会缺封面图、缺简介、缺发行日期,如果前端直接取字段渲染,就会显示一堆空白卡片。我在展示层写了一个with_default处理函数,对缺失字段统一填充占位信息,封面缺失的使用灰底加标题首字,简介缺失的显示“暂无简介”,价格缺失的显示“当前不可用”。

这样操作是为了保证页面在任何异常数据下都能正常渲染,而不是某个字段出错就整页白屏。对个人项目来说,稳定可用永远比功能丰富优先级更高。

5. 部署与日常使用配置参考

5.1 最小化部署需要的东西

一台能联网、能装 Python 的常开设备就够了,我实际跑在是一台普通的迷你主机上,内存占用不超过三百兆。依赖库只有requests、fastapi、uvicorn、apscheduler,没有数据库服务端,没有缓存中间件,整个后端就一个进程。

启动方式也很简单:

pip install requests fastapi uvicorn apscheduler python main.py

main.py里同时启动了定时调度器和 Web 服务,端口固定在 8000。我在路由器上做了端口转发,在外面也能通过域名访问自己的库存。不过这里要提醒一句:这个服务默认没有鉴权,暴露在公网之前务必加一层访问密码或者用支持认证的反代。

5.2 推荐的时间计划与运行方式

价格采集每天执行一次足够了,再多就是浪费。推荐的计划参考:

任务时间频率
价格全量采集02:00每天
汇率更新02:10每天
数据库备份03:00每天
手动增量采集不限需要时触发

这三个任务按顺序执行,备份直接复制 SQLite 文件到另一个目录。我还写了一个启动脚本,进程异常退出后自动拉起,跑了一个多月没再因为进程挂掉而缺数据。

5.3 数据备份、恢复与迁移的关键点

SQLite 的备份非常朴素:直接复制文件即可。但热复制有个小坑,如果写入任务正在执行时复制,可能出现文件锁或数据不一致。我用的是在线备份接口:

import sqlite3 source = sqlite3.connect("anyps5.db") backup = sqlite3.connect(f"backup/anyps5-{datetime.date.today().isoformat()}.db") source.backup(backup) backup.close() source.close()

这个方式不需要停服务,备份出来的文件一致性也有保障。迁移到新机器时,把备份文件复制过去,安装同样版本的 Python 和依赖,直接启动就能识别全部数据。

6. 后续的一些实战体会

这套系统实际用了几个月之后,我最深的感受是:它把“管理游戏”这个动作从脑海里搬到了本地,不再需要记挂某个游戏是否在史低、哪个区的版本更划算。定期看一看价格曲线的走势,反而让我购物更克制了,因为大多数游戏过一段时间都会回到相同甚至更低的价位,实在没必要在首发时冲动付款。

如果你也想复刻一个类似的东西,我的建议是从最小闭环起步:先跑通单区域的价格采集和展示,再逐步加跨区对比、进度管理、提醒规则。一步到位往往意味着前期代码架构复杂,反而拖慢了上线时间。这个项目后续我打算再加一个“每年游戏开销统计”的视图,把买过的游戏按时间和金额汇总,看看自己的消费习惯到底是怎么形成的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询