Developer notes / Archive
从静态快照到实时状态:我的 Steam 数据链路踩坑记录
记录个人博客接入 Steam 在线状态、当前游戏、最近游玩和封面的完整过程,以及 API 参数、网络出口、缓存和 Cloudflare Worker 中转带来的问题。
目录 / On this page
这次给个人博客加 Steam 状态,最初以为只是调用一个接口、渲染一个游戏名称,再加一张封面图。真正做完之后,才发现它同时涉及静态构建、浏览器刷新、服务器缓存、Steam 隐私设置、API 参数、网络出口和第三方中转。
最后实现的目标是:博客可以显示 Steam 的公开状态;如果正在游戏,显示当前游戏和封面;如果没有正在运行的游戏,显示最近游玩记录;外部服务不可用时,首页仍然可以正常打开。
一、最初的实现:构建时快照
博客是 Astro 静态站点。首页在构建时读取 src/data/external.ts,把 Steam 数据转换成站点自己的类型,再交给 SteamStatusCard.astro 渲染。
export type SteamGame = {
name: string;
link: string;
icon: string;
logo: string;
hoursPlayed: string;
hoursOnRecord: string;
};
export type SteamProfile = {
steamId: string;
displayName: string;
onlineState: string;
stateMessage: string;
avatar: string;
profileUrl: string;
games: SteamGame[];
};
组件只关心自己的数据结构:
const steam = await getSteamProfile();
const currentGame = steam.games[0];
<a href={currentGame?.link ?? steam.profileUrl}>
<img src={currentGame?.icon ?? steam.avatar} alt="" data-steam-game-art />
<span data-steam-game-name>
{currentGame?.name ?? "暂无公开的最近游玩记录"}
</span>
</a>
这种方式有一个明显优点:Steam 挂掉时,页面仍然能构建和访问。但它也意味着页面里的状态可能是构建时的旧数据。
二、第一个坑:静态 fallback 不能写死成事实
最开始的静态快照里写了几款游戏,其中包括 Victoria 3。当 Steam API 请求失败时,代码退回到这组静态数据,于是页面会显示一款实际上并不是最近游玩的游戏。
这类 fallback 的问题不在于“有默认值”,而在于默认值伪装成了真实事实。后来把 fallback 的游戏记录清空:没有可靠数据时,页面显示“暂无公开的最近游玩记录”,而不是编造一个游戏名称。
// 不要把过期的手写游戏历史当成实时数据展示。
steamSnapshot.games = [];
经验是:外部 API 的降级状态应该明确告诉用户“数据不可用”,不能用看起来合理但未经验证的数据替代它。
三、第二个坑:steamid 和 steamids 不是一回事
Steam 的不同接口参数名称并不统一:
GetPlayerSummaries steamids
GetRecentlyPlayedGames steamid
GetOwnedGames steamid
Worker 最初复用了同一个查询字符串:
const query = `key=${key}&steamid=${steamId}`;
结果是最近游玩接口返回 200,但个人状态接口返回 400。修复后必须分别构造参数:
const key = `key=${encodeURIComponent(env.STEAM_API_KEY)}`;
const summaryQuery = `${key}&steamids=${encodeURIComponent(env.STEAM_ID)}`;
const recentQuery = `${key}&steamid=${encodeURIComponent(env.STEAM_ID)}&format=json`;
const [summaryResponse, recentResponse] = await Promise.all([
fetch(`${STEAM_API}/ISteamUser/GetPlayerSummaries/v2/?${summaryQuery}`),
fetch(`${STEAM_API}/IPlayerService/GetRecentlyPlayedGames/v1/?${recentQuery}`)
]);
排查第三方 API 时,不能只看“请求发出去了”,还要分别检查每个接口的 HTTP 状态和响应体。
四、正在游玩、在线状态和最近游玩是三件事
个人状态接口里的 gameextrainfo 表示当前正在运行的游戏。personastate 则表示公开状态,常见值包括:
const stateLabels = {
1: "在线",
2: "忙碌",
3: "离开",
4: "小憩",
5: "寻找交易",
6: "寻找游戏"
};
const state = currentGame
? "正在游戏"
: stateLabels[player.personastate] ?? "离线";
Steam 客户端窗口处于打开状态,不一定意味着 Web API 返回 personastate=1。截图里看起来“在线”,API 仍可能返回“离开”,因为公开状态同步存在延迟,也可能受隐身和隐私设置影响。
因此页面逻辑应该分别处理:
gameName:当前是否正在游玩,以及当前游戏名称。state:Steam 对外公开的在线状态。recentGameName:没有当前游戏时,展示最近游玩记录。
五、最近游玩不等于最后启动
GetRecentlyPlayedGames 返回的是近两周游玩时长和总游玩时长。它并不稳定地提供“最后启动时间”,而且列表顺序不能直接当作最后启动顺序。
如果只按近两周时长排序:
const games = [...(recent.response?.games ?? [])].sort(
(left, right) => (right.playtime_2weeks ?? 0) - (left.playtime_2weeks ?? 0)
);
显示出来的可能是近两周玩得最多的游戏,而不是刚刚最后启动的游戏。
后来又增加了 GetOwnedGames,尝试使用部分响应中可能存在的 rtime_last_played:
const ownedGames = [...(owned.response?.games ?? [])]
.filter((game) => game.rtime_last_played)
.sort(
(left, right) =>
(right.rtime_last_played ?? 0) - (left.rtime_last_played ?? 0)
);
const recentGame = ownedGames[0] ?? games[0];
这里仍然要保留回退,因为 Steam 是否返回这个字段取决于账号的游戏详情可见性和接口实际响应。准确的优先级应该是:当前正在游玩 → 最后游玩时间 → 近两周游玩时长 → 无数据提示。
六、服务器端实时接口没有解决网络出口问题
为了避免每次打开首页都触发构建,项目增加了一个 Python 状态服务。它监听本机端口,由 Nginx 反向代理到 /api/status,并用 60 秒缓存减少外部请求:
CACHE_TTL_SECONDS = 60
def load_status():
return {
"updatedAt": datetime.utcnow().replace(microsecond=0).isoformat() + "Z",
"github": load_github(),
"steam": load_steam(),
}
但排查服务器时发现:
- DNS 可以解析
api.steampowered.com。 - 路由表也有默认出口。
- 到 Steam API 的 HTTPS 连接仍然超时。
所以“服务器端有定时任务”不代表“服务器一定能访问 Steam”。网络出口、上游防火墙和区域线路同样是数据链路的一部分。
七、Cloudflare Worker 中转
服务器访问 Steam 不稳定后,增加了 Cloudflare Worker。Worker 通过 Secret 保存敏感配置,浏览器直接请求 Worker:
浏览器 → Cloudflare Worker → Steam API
Worker 只暴露固定路径,不允许外部传入或覆盖 Key:
const STEAM_API = "https://api.steampowered.com";
if (request.method !== "GET" || url.pathname !== "/steam/status") {
return json({ error: "Not found" }, 404);
}
if (!env.STEAM_API_KEY || !env.STEAM_ID) {
return json({ error: "Worker secrets are not configured" }, 500);
}
为了让博客浏览器可以跨域请求 Worker,需要返回 CORS 头:
function corsHeaders() {
return {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type"
};
}
Worker 最终返回网站自己的结构,而不是把 Steam 原始响应直接交给组件:
{
"updatedAt": "2026-08-07T00:00:00.000Z",
"steam": {
"available": true,
"state": "在线",
"gameName": null,
"recentGameName": "示例游戏",
"recentGameUrl": "https://store.steampowered.com/app/123456/",
"recentGameImage": "https://cdn.akamai.steamstatic.com/steam/apps/123456/header.jpg"
}
}
这样页面不需要知道 Steam 原始字段结构,未来更换数据来源时也只需要改 Worker 的映射逻辑。
八、Cloudflare 预览不等于正式部署
Cloudflare 编辑器中的预览页面曾经出现 Error 1031。这个页面是 Workers Preview,不代表 Worker 正式 URL 已经部署成功。
正确的验证方式是:
- 在 Worker 编辑器点击“部署”。
- 使用正式的
workers.dev地址。 - 访问固定路径
/steam/status。 - 检查响应头是否为
application/json。 - 检查 JSON 是否包含
steam.available和游戏字段。
如果返回 HTML 错误页,说明还在看预览错误;如果返回 JSON,才说明正式 Worker 已工作。
九、封面为什么一开始不显示
实时脚本只能替换页面中已经存在的 DOM 节点。如果构建时没有当前游戏,组件最初只渲染了一个占位 div,实时返回封面后没有 <img> 可以替换。
修复方式是:即使没有构建时游戏,也预先渲染一个隐藏的图片节点:
{currentGame?.icon ? (
<img src={currentGame.icon} data-steam-game-art alt="" />
) : (
<img class="hidden" src={steam.avatar} data-steam-game-art alt="" />
)}
实时刷新时再替换地址并显示:
const gameImage = steam.gameImage || steam.recentGameImage;
if (gameImage && node instanceof HTMLImageElement) {
node.src = gameImage;
node.classList.remove("hidden");
}
十、密钥和部署安全
这次排查过程中最容易犯的错误,是把“为了调试而能看到”误认为“可以公开”。以下内容都不应该写入文章、截图或 Git:
- Steam API Key
- Cloudflare Worker Secret
- 服务器密码
- 私人服务器环境变量
- 包含密钥的完整请求 URL
正确做法是:
- 本地使用
.env,并确保不提交到仓库。 - Cloudflare 使用 Secret 保存
STEAM_API_KEY和STEAM_ID。 - 文章代码只使用
${STEAM_API_KEY}、${STEAM_ID}和https://worker.example.workers.dev/steam/status这类占位符。 - 如果 Key 出现在日志、命令输出或截图中,应立即撤销并重新生成。
十一、这次一共踩了多少坑
把这次排查过程按数据、网络、前端和部署分组,主要有 13 个坑:
- 静态 fallback 写死了
Victoria 3,API 失败时就显示了错误的游戏。 GetPlayerSummaries使用steamids,而游戏接口使用steamid,参数写错会直接返回 400。- Steam 在线状态、正在游玩的游戏和最近游玩记录是三个不同概念。
personastate=3表示“离开”,不一定等于 Steam 客户端完全离线。- Steam API 可能不返回当前正在玩的游戏,即使客户端看起来在线。
GetRecentlyPlayedGames主要提供近两周和累计时长,不能保证代表最后启动顺序。- Steam 不一定稳定提供最后启动时间,
rtime_last_played只能作为可用时的优先字段。 - 服务器可以解析 Steam 域名,但不代表 HTTPS 请求一定能建立,实际请求可能超时。
- 服务器同样可能无法访问 Worker,所以最终采用浏览器直连 Worker 的链路。
- Cloudflare Preview 的 Error 1031 只说明预览环境异常,不代表正式 Worker URL 失败。
- 没有预先渲染
<img>节点时,前端拿到封面 URL 也无法替换页面。 - API Key、Worker Secret、服务器密码和 SteamID 都不能写入文章、截图、日志或 Git。
- Nginx 实际服务的是
/var/www/davent-pixel-blog,不是目录内部的current软链接;只切换软链接会导致新文章仍然 404。
这份清单的重点不是记住某个具体修复命令,而是为外部数据链路分别验证:字段含义、网络出口、缓存策略、前端占位节点和最终 Web Server 根目录。
十二、最终验证清单
本次链路最终通过了以下检查:
pnpm typecheck
pnpm build
node --check worker/steam-status-proxy.js
运行时还需要检查:
Worker 正式 URL 返回 JSON
summary 和 recent 请求状态正常
steam.available 为 true
当前游戏和最近游玩字段能够更新
封面 URL 可以加载
博客首页 HTTP 状态为 200
结语
Steam 状态看起来只是一个小组件,实际上是一个很典型的外部数据接入问题:数据来源会变化,网络会失败,字段语义容易被误解,缓存会制造旧状态,部署平台又有自己的预览和正式环境区别。
最后比较可靠的设计不是“永远显示最新数据”,而是:
- 有实时数据时尽量显示实时数据。
- 没有实时数据时明确显示不可用,不伪造事实。
- 外部服务故障时不影响博客主体页面。
- 所有密钥都放在服务端 Secret 中,并且可以随时轮换。
对个人博客来说,这套原则比把一个状态卡片做得更复杂重要得多。
♪
0:00 / 0:00