文章 NEW

Developer notes / Archive

从静态快照到实时状态:我的 Steam 数据链路踩坑记录

记录个人博客接入 Steam 在线状态、当前游戏、最近游玩和封面的完整过程,以及 API 参数、网络出口、缓存和 Cloudflare Worker 中转带来的问题。

2026/08/07 12 min read 0 浏览
#Steam#Astro#Cloudflare Worker#API#部署
猫耳看板娘修理 Steam 状态到博客的水平数据管线
目录 / 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 的降级状态应该明确告诉用户“数据不可用”,不能用看起来合理但未经验证的数据替代它。

三、第二个坑:steamidsteamids 不是一回事

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 已经部署成功。

正确的验证方式是:

  1. 在 Worker 编辑器点击“部署”。
  2. 使用正式的 workers.dev 地址。
  3. 访问固定路径 /steam/status
  4. 检查响应头是否为 application/json
  5. 检查 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_KEYSTEAM_ID
  • 文章代码只使用 ${STEAM_API_KEY}${STEAM_ID}https://worker.example.workers.dev/steam/status 这类占位符。
  • 如果 Key 出现在日志、命令输出或截图中,应立即撤销并重新生成。

十一、这次一共踩了多少坑

把这次排查过程按数据、网络、前端和部署分组,主要有 13 个坑:

  1. 静态 fallback 写死了 Victoria 3,API 失败时就显示了错误的游戏。
  2. GetPlayerSummaries 使用 steamids,而游戏接口使用 steamid,参数写错会直接返回 400。
  3. Steam 在线状态、正在游玩的游戏和最近游玩记录是三个不同概念。
  4. personastate=3 表示“离开”,不一定等于 Steam 客户端完全离线。
  5. Steam API 可能不返回当前正在玩的游戏,即使客户端看起来在线。
  6. GetRecentlyPlayedGames 主要提供近两周和累计时长,不能保证代表最后启动顺序。
  7. Steam 不一定稳定提供最后启动时间,rtime_last_played 只能作为可用时的优先字段。
  8. 服务器可以解析 Steam 域名,但不代表 HTTPS 请求一定能建立,实际请求可能超时。
  9. 服务器同样可能无法访问 Worker,所以最终采用浏览器直连 Worker 的链路。
  10. Cloudflare Preview 的 Error 1031 只说明预览环境异常,不代表正式 Worker URL 失败。
  11. 没有预先渲染 <img> 节点时,前端拿到封面 URL 也无法替换页面。
  12. API Key、Worker Secret、服务器密码和 SteamID 都不能写入文章、截图、日志或 Git。
  13. 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 状态看起来只是一个小组件,实际上是一个很典型的外部数据接入问题:数据来源会变化,网络会失败,字段语义容易被误解,缓存会制造旧状态,部署平台又有自己的预览和正式环境区别。

最后比较可靠的设计不是“永远显示最新数据”,而是:

  1. 有实时数据时尽量显示实时数据。
  2. 没有实时数据时明确显示不可用,不伪造事实。
  3. 外部服务故障时不影响博客主体页面。
  4. 所有密钥都放在服务端 Secret 中,并且可以随时轮换。

对个人博客来说,这套原则比把一个状态卡片做得更复杂重要得多。

Now Playing

Every Day Is NightGaroad

0:00 / 0:00

播放列表 · 4 首