← 全部文章

技术与生活 / 一篇记录

用 Hugo 和 GitHub Actions,搭建一个自动同步 Steam 的个人主页

我把原来的 Typecho 博客,改成了一个与 Steam 结合的个人空间:大幅游戏截图作为背景,展示最近在玩什么、全成就记录,以及按下 F12 留下来的瞬间。旧文章继续保留在「技术笔记」里。

首页看起来会更新,但线上没有为它增加数据库、常驻应用进程或实时查询接口。访问者看到的依然是一套由 Nginx 提供的静态文件。

这篇文章记录这次改造的架构和实现,尤其是一个实际遇到的问题:建站服务器访问不了 Steam,数据该在哪里同步?

从博客首页,换成自己的个人空间

这台机器的配置并不高:2 个 vCPU、约 1.7 GiB 内存、40 GB 系统盘,上面还运行着其他服务。原站使用 Typecho 1.1 和较老的 PHP 环境,后来因为安全维护问题停掉了。

恢复网站时,我最初迁移到了 Hugo 博客。但看完第一版,发现自己真正想要的是一个能表达兴趣的个人入口:游戏是主角,文章是其中一个入口。

最终首页由几个部分组成:

  • 用自己的 Steam 截图做首屏,手动切换背景。
  • 展示近期游戏、累计时长和成就进度。
  • 保留人工选择的全成就游戏陈列室。
  • 做一个支持筛选、放大和键盘翻图的截图画廊。
  • 将笔记、网盘和联系方式放在页面后半段。

个人主页在这里:xdouble.cn。旧文章入口仍然是技术笔记。

这里有个边界:精选内容由自己决定,数值交给程序更新。 背景、截图、签名和展柜名单不需要随着一次定时任务被全部替换。

技术栈:静态页面,加一条离线数据管道

我保留 Hugo 作为生成工具,为个人主页编写独立的模板、CSS 和原生 JavaScript。

组件职责
Hugo将模板、Markdown 和 JSON 数据生成 HTML
原生 CSS / JavaScript响应式布局、背景切换、截图筛选和图片浏览
Steam Web API提供游戏、时长、等级和成就等数据
Python 3采集、校验和打包数据
GitHub Actions在服务器之外定时执行采集
SSH 与固定功能接收器将数据送到服务器
Nginx对外提供生成好的静态文件

Hugo 支持先生成静态文件,再传到自己的 Web 服务器。这次则把构建放在服务器上,采集工作放到 GitHub runner,两者通过数据包连接。Hugo 部署文档

Steam 个人主页架构:GitHub Actions 读取 Steam,SSH 提交数据,服务器校验构建,再由 Nginx 提供静态页面。

访问网页和同步数据是两条独立的链路。即使同步失败,已经生成的页面仍然可以正常访问。

用 JSON 把内容与页面分开

项目里最重要的几个目录如下:

content/posts/               技术笔记,使用 Markdown
data/steam.json              个人资料与游戏数据
static/steam/                已保存到本地的头像、封面、截图
layouts/home.html           个人主页模板
layouts/_partials/steam/    游戏与成就组件
static/assets/player.css    首页样式
static/assets/player.js     首页交互
scripts/steam/               采集、数据契约、接收器和测试
.github/workflows/           定时同步工作流

steam.json 同时包含两类数据:一类是自己的编辑选择,例如精选截图;另一类是可同步的数据,例如游戏时长。下面是精简的结构示例,数字仅用于说明字段:

{
  "handle": "CHATION",
  "avatar": "/steam/avatar.jpg",
  "updated": "2026.11.06",
  "synced_at": "2026-11-06T16:28:33+08:00",
  "recent": [
    {
      "appid": "2622380",
      "name": "艾尔登法环 黑夜君临",
      "hours": "600",
      "achieved": 37,
      "total": 37,
      "percent": 100,
      "image": "/steam/game-2622380.jpg"
    }
  ],
  "gallery": [],
  "sync_warnings": []
}

Hugo 构建时读取这些数据,输出最终的文字、图片地址和进度条。浏览器不用调用 Steam,也不会接触 API Key。

图片同样先下载到本站。这样即使访问者连接 Steam 图片服务器不稳定,首屏和游戏卡片也不必等待外站响应。

服务器访问不了 Steam,就把采集移出去

第一次检查连通性时,这台服务器访问 Steam 社区主页、XML、官方 API 和商店接口都出现了连接失败或超时。

这种情况下,在服务器上增加一个 cron,只会让失败的请求按时重复。

我选择让 GitHub Actions 执行采集,再通过 SSH 主动把结果推送回来。服务器只需要接受已有的 SSH 连接,不必连接 Steam,也不必从 GitHub 拉取文件。

一次同步按下面的顺序进行:

  1. runner 从服务器读取当前正在使用的数据快照。
  2. 调用 Steam API,更新必要字段。
  3. 获取新头像与游戏封面,生成压缩数据包。
  4. 通过 SSH 把数据包提交到服务器。
  5. 服务器校验、构建,通过后切换发布目录。

第一步读取的是服务器快照,而不是仓库里的初始 JSON。 动态数据没有每次都提交回 Git。如果直接以仓库旧数据为基础,一旦某个可选接口失败,页面就可能退回很久以前的数值。

哪些数据适合用官方 API

当前主要使用这些接口:

接口用途
ISteamUser/GetPlayerSummaries头像、昵称和账号核对
IPlayerService/GetOwnedGames游戏库数量
IPlayerService/GetRecentlyPlayedGames近两周的游戏和累计时长
IPlayerService/GetSteamLevelSteam 等级
ISteamUserStats/GetPlayerAchievements指定游戏已解锁的成就

游戏详情需要对采集方可见。具体参数可参考 IPlayerService 和 ISteamUserStats 官方文档。

需要留意统计口径:API 返回的游戏库数量,可能与社区主页显示的游戏收藏数量不同,不应该默认两者完全一致。

截图总数、全成就游戏总数另外尝试读取公开社区页面。它们是可选数据源:页面打不开时,保留上次成功的值。

首次自动运行时,官方 API 数据已经更新,但社区计数没有取到。因此页面实际显示的是:

Steam 2026.11.06 16:28 · 自动同步 · 部分数据沿用上次记录

这个状态比把失败解释成“0 张截图”更符合实际情况。

GitHub Actions:每六小时更新一次

定时任务采用 UTC 表达式:

name: Sync Steam profile

on:
  schedule:
    - cron: '17 */6 * * *'
  workflow_dispatch:

permissions:
  contents: read

concurrency:
  group: steam-profile-sync
  cancel-in-progress: false

这段是工作流的调度与权限部分。它对应北京时间每天 02:17、08:17、14:17、20:17,也保留了手动运行入口。

GitHub 的定时调度可能延迟,因此页面展示最后一次成功同步的时间。它适合个人主页的数据刷新,不承担实时在线状态展示。GitHub 定时触发说明

工作流中的凭据放进 GitHub Secrets,连接参数放进 Variables:

配置用途
STEAM_API_KEY调用 Steam API
STEAM_DEPLOY_KEY专用部署 SSH 私钥
STEAM_KNOWN_HOSTS固定服务器 SSH 主机公钥
STEAM_SSH_HOST目标服务器地址
STEAM_SSH_USER专用部署账号

不要把 API Key 放进前端代码、JSON、Markdown 或 Git 历史。Secrets 的配置入口是仓库的 Settings → Secrets and variables → Actions。GitHub Secrets 文档

准备好临时密钥文件和严格主机校验参数后,采集与推送的核心命令只有三步:

ssh "${ssh_options[@]}" "$SSH_USER@$SSH_HOST" snapshot > seed.json

python3 scripts/steam/sync.py \
  --seed seed.json \
  --output update.tar.gz

ssh "${ssh_options[@]}" "$SSH_USER@$SSH_HOST" deploy < update.tar.gz

这里的 snapshot 和 deploy 是服务器网关识别的操作名。SSH 参数里启用了 BatchMode、连接超时、固定的 known_hosts 和 StrictHostKeyChecking=yes。

部署通道只接收数据

我没有把日常使用的 root 私钥交给 Actions,而是创建了专用账号和部署密钥。

这把密钥通过 SSH 的 forced command 进入固定网关,网关只识别两个操作:

snapshot  读取当前 Steam 数据
deploy    接收更新包并触发校验、构建

交互终端、端口转发以及任意远程命令不在它的能力范围内。需要 root 执行的步骤,通过 sudo 白名单进入固定功能的接收器;runner 不能上传或更换这个接收程序。

数据包也有明确的边界,只能包含清单、指定 JSON,以及规定命名方式的头像或游戏封面 JPEG。接收时会检查:

  • 压缩包与解压后数据的体积,以及文件数量。
  • 文件路径、文件类型,拒绝重复条目、符号链接和路径穿越。
  • SteamID、数据结构、数值范围和图片路径。
  • 个人签名、精选截图、背景及展柜名单是否被意外改变。

包里的 base_sha256 用来确认采集所依据的快照仍然是服务器当前版本。它是并发一致性检查;连接认证由 SSH 密钥承担。

先构建完整版本,再切换入口

线上目录分成三个部分:

site/
├── source/                 Hugo 源码与当前数据
├── releases/
│   ├── release-a/          上一个版本
│   └── release-b/          新版本
└── current -> releases/release-b/

Nginx 的根目录指向 current。源码、草稿、备份和密钥都不放在这个公开目录里。

自动接收器先复制一份候选源码,应用新的数据和图片,然后运行 Hugo 构建。页面、资源和迁移校验全部通过后,才更新源码数据并原子替换 current 符号链接。

手动发布与自动同步共用文件锁,避免两个任务同时改动发布入口。自动产生的 steam-* 发布目录保留最近 12 个,早期人工发布目录单独保留。

回退时可以重新指向上一个完整版本。若还需要继续执行后续构建,也应恢复对应的数据快照,避免下一次构建重新带回不希望发布的数据。

失败需要分清类型

情况当前处理
资料或游戏库读取不到中止本次发布,保留线上版本
最近两周确实没有公开记录接受空列表,显示没有近期记录
新游戏封面下载失败中止发布,避免上线破图卡片
某款游戏成就不可读精选展柜保留旧值;近期游戏卡片显示暂不可读
社区计数读取失败沿用上次成功的数值,显示部分更新提示
数据校验或 Hugo 构建失败不切换线上目录

另一个容易忽略的情况是游戏新增成就。展柜里曾经完成的游戏,不一定永远是 100%。因此页面使用同步得到的已解锁数量和总数计算比例,不把“全成就”写死在模板里。

旧文章和备案信息如何保留

个人主页改版并不需要删除历史内容。这次共迁移了 5 篇公开文章、3 个页面和 21 条附件记录;另外 2 篇隐藏文章保存为草稿。

旧文章在 Hugo front matter 中显式指定原地址:

+++
title = '一篇旧文章'
url = '/archives/72/'
draft = false
+++

这样外部收藏和历史链接仍然可用。Hugo 的 url 字段可以直接覆盖页面输出路径。Hugo URL 文档

旧的图片、视频继续使用原资源路径。Typecho 的 Markdown 标记、缺少空格的标题,以及 DPlayer 写法,在迁移时做了对应转换。

页脚的 ICP 备案、公安备案、查询链接和图标提取到公共模板中,个人主页和技术笔记都能保留。

这次验证了什么,还留下什么

首次 GitHub Actions 任务完成了官方 API 采集、4 个图片文件下载、服务器校验与发布,线上也显示了新的同步时间。

本地和工作流中覆盖了 7 项同步测试,包括空的近期记录、社区接口不可达、库数据缺失、非法路径和符号链接等情况。生成结果另外检查旧文章地址、本地资源、RSS、搜索索引、备案信息,以及隐藏文章未被公开。

首次运行中社区计数没有更新,这是当前已知限制。精选图片仍由自己维护,也没有为了首页增加一个实时状态服务。

静态化减少了这个站点运行时对 PHP 和数据库的依赖,宿主系统及其他服务的升级仍是后续维护工作。对这台小服务器而言,我更希望把资源留给真正需要常驻运行的应用。

现在,这个网站既能放游戏里的旅途,也能留下折腾过程。平时只负责打开页面;数据更新在访问者到来之前完成。

保持好奇,下一局见。

搜索文章

输入关键词,找回一篇记录。