☰
用Steam Web API和GitHub Actions在个人简介同步正在玩的游戏
2026/9/26 17:16:25 网站建设 项目流程

前阵子翻一个开发者主页的时候,发现他的个人简介里有一张卡片,实时显示着"正在玩某款Steam游戏",底下还挂着最近几个成就。第一反应是这玩意儿挺酷,第二反应是,我也要给自己整一个。于是就有了这个"在个人简介同步正在玩的Steam游戏"的小项目。整个过程其实不复杂,你只需要一个Steam Web API Key、一个能定时跑的任务,再加一个显示用的模板,就能让简介里出现一张会自己更新的游戏状态卡。如果你想让GitHub主页、个人博客或导航页多点"活人感",这篇文章可以直接帮你跑通全链路。

这个"正在玩"的状态信息,其实不用自己抓网页,Steam官方就提供了Web API,接口稳定、返回字段干净,个人项目完全够用。我后面会从接口申请讲到数据解析,再讲展示层怎么做,最后把定时刷新和排错一起说清楚,你可以照着一步步实现。

1. 这个项目要解决的真实问题:简介不该全是静态列表

1.1 从访客视角看个人简介

写个人简介这件事,大部分人做成了"简历墙":技能图标一行、项目列表一排、联系方式丢末尾。信息量没问题,但内容完全静态。访客点进来扫一眼,发现没有"活人"的感觉,也就没有停留的理由。

我最初看到别人简介里出现"Currently Playing"卡片时意识到,动态内容天然具备互动属性。访客如果也是个玩家,看到某款游戏,第一反应是点进去看看你的时长、成就,或者直接聊起来。哪怕不是玩家,也会因为"这人的主页会动"而多停留几秒。这块内容放在博主自己的个人简介里,就是一张天然的破冰名片。

1.2 别急着写爬虫,Steam官方API就够了

有一类实现会往"steam爬虫"方向走,试图直接抓个人主页的HTML,再解析出当前游戏。我做过类似的事,结论是:大可不必。Steam个人主页的HTML结构会随Steam改版而变化,Cookie过期、反爬验证、区域页面差异都是坑。

Steam官方提供的Web API,覆盖个人摘要、游戏列表、成就、用户关系等常见数据,限速也比较宽松。个人项目拿它来做"正在玩"状态同步,完全够用,而且字段是结构化的JSON,不用跟页面DOM较劲。

1.3 项目的最小可行定义

我把这个项目砍到最简,就三个部分:

  • 输入:Steam Web API Key + 你的Steam ID
  • 处理:拉取个人摘要,解析出当前正在玩的游戏名、AppID、游戏封面
  • 输出:一张可嵌入个人简介的SVG卡片,或者一段更新到README里的文本

定时刷新由GitHub Actions负责,不用自己买服务器。整个项目跑起来之后,远程仓库会自动更新状态,过程完全无人值守。

2. Steam Web API是这套方案的地基

2.1 API Key的获取与存放

先登录Steam社区,进入开发者API Key申请页面,随便写个域名(个人项目可以用自己的博客域名,没有就填localhost),就能拿到Key。这个Key基本秒批,不需要审核,但它的权限很大,能读取你账号的游戏和隐私数据。

所以存放要慎重。我建项目时直接把Key放在GitHub仓库的Actions secrets里,命名为STEAM_API_KEY,脚本通过环境变量读取,绝不让Key出现在源码或README中。如果你是自己搭后端,就放服务端的配置文件或环境变量,前端页面永远不要引用它。

2.2 先用ResolveVanityURL把昵称解析成数字ID

Steam Web API的绝大多数接口都要求传steamID64,也就是个人资料页URL里那一长串19位数字。如果你的账号开了自定义URL(vanity URL),可以用ResolveVanityURL接口把字母昵称解析成数字ID:

import requests API_KEY = "你的key" CUSTOM_ID = "你的自定义url" # 例如 steamcommunity.com/id/xxx resp = requests.get( "https://api.steampowered.com/ISteamUser/ResolveVanityURL/v1/", params={"key": API_KEY, "vanityurl": CUSTOM_ID}, timeout=10, ) data = resp.json()["response"] if data["success"] == 1: steam_id = data["steamid"] print("解析结果:", steam_id) else: print("未找到该自定义URL:", data)

如果没开自定义URL,那就更简单,直接复制个人资料页URL里的数字串当steamid参数用。两个途径最终都落到同一串ID上,后续接口只认这个。

2.3 GetPlayerSummaries返回的字段里,哪些才是"正在玩"

定位"正在玩"的核心接口是GetPlayerSummaries v2,它会返回账号的在线状态、昵称、头像,以及当前正在运行的游戏信息:

resp = requests.get( "https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/", params={"key": API_KEY, "steamids": steam_id}, timeout=10, ) player = resp.json()["response"]["players"][0] print(player)

返回的players[0]里,我们需要重点关注这几个字段:

字段含义
personastate在线状态:0离线、1在线、2忙碌、3离开、4打盹、5想交易、6想玩
gameid当前正在玩的应用AppID,停止玩游戏后会消失
gameextrainfo游戏名称,比如"Rust""Dota 2"
gameserverip正在连接的服务器地址,联机游戏才有
gameextrafield大部分时候为空,个别游戏会存服务器名或模式信息

"正在玩"的判断核心就两个字段:gameid有没有值,以及gameextrainfo能不能取到可读的游戏名。

这里有个需要注意的点:GetPlayerSummaries拿到的游戏详情,依赖于对方(也就是你自己)在Steam隐私设置里公开了"游戏详情"。如果隐私设置里游戏详情是"仅好友可见"或私密,gameid可能还在,但gameextrainfo会缺失。个人项目给自己用,记得在Steam隐私设置里把"游戏详情"设为公开。

2.4 请求的边界:加超时,不加代理

Steam Web API部署在不同区域的服务器上,访客和你所在网络环境到它的链路质量并不稳定。我自己实测时遇到最多的不是鉴权问题,而是超时。所以所有请求构造我都强制加timeout,防止某个时刻API响应慢,把定时任务卡死。关于"代理"我只想多说一句:不要为了让请求看起来更快而引入公网中转,个人项目不值得冒这个风险,后面我会讲重试和缓存比任何优化都实在。

3. 拿到数据之后的三个关键判断

3.1 "有gameid"才算真正在玩,但光这还不够

最直接的做法是读gameid,有值就认为在玩。但我在实际跑数据时发现两个边界情况:

一是刚退出游戏的几分钟内,Steam有时仍会返回上一个游戏的gameid,表现为"最近游玩"残留窗口。如果你的简介卡片刷新频率很高,就会看到游戏名在"刚退出的游戏"和"无游戏"之间反复跳。比较有效的处理是做状态老化:只有当连续两次请求都落在同一个gameid上,或者间隔一段时间后依然存在,才认定"正在玩"并更新展示。

二是某些非游戏应用也会占据gameid。最典型的就是Wallpaper Engine这类工具软件,打开后Steam会把它当成"当前应用"返回。展示一张"正在玩壁纸引擎"的卡片,多少有点奇怪。我的做法是维护一个排除AppID集合,遇到底下这些就按"未在玩游戏"处理。

3.2 挂机类游戏的过滤策略

Steam上有一类游戏本质是"挂卡工具"或"放置挂机",比如纯增量游戏、挂机养成,甚至还有一些只为了集换式卡牌挂时长的工具。它们确实通过Steam客户端启动,技术上符合"正在玩",但对访客来说,看到你简介里连续几天都是同一个挂机游戏,观感不太好。

我建议把这些AppID也放进黑名单。注意这纯粹是展示层偏好,不影响真实游戏时长统计,也不需要把相关请求接口一起过滤。

3.3 要不要顺便拿成就和游戏时长

如果想让简介卡片的信息更丰富,还可以用IPlayerService的GetOwnedGames拿全量游戏库和总时长,或者用ISteamUserStats的GetPlayerAchievements拿当前游戏成就进度。前者可以展示"最近玩过的游戏Top5",后者可以做成"成就进度条":

resp = requests.get( "https://api.steampowered.com/ISteamUserStats/GetPlayerAchievements/v1/", params={ "key": API_KEY, "steamid": steam_id, "appid": game_id, "l": "schinese", # 想显示中文成就名就传这个 }, timeout=10, ) achievements = resp.json()["playerstats"]["achievements"] earned = sum(1 for a in achievements if a["achieved"] == 1) total = len(achievements) print(f"{earned}/{total}")

但要提前确认隐私设置。GetOwnedGames需要"游戏详情"公开,GetPlayerAchievements要求具体游戏的成就数据公开。如果隐私没开,接口会返回空列表或错误码,这块的排错比核心状态同步更费时间。我的建议是先跑通"正在玩"这个最小闭环,再加附加数据。

4. 展示层方案选型:我最终选了"GitHub Actions + README占位符"

4.1 三种常见实现路径对比

"正在玩"这张卡片放哪、怎么生成,我比较了三种思路:

方案维护成本适用位置主要问题
现成在线服务生成卡片最低GitHub README、个人博客样式不自由,依赖第三方服务稳定性
自建后端定时生成SVG中个人博客、导航页要服务器,要处理CORS、缓存
GitHub Actions更新README低GitHub个人主页只适合README场景,不适合外链

我最终选的是第三套。GitHub个人主页本身就是README,直接在仓库里加一个定时任务,让它跑脚本、改README、再提交回去,全程不产生额外服务器费用。数据展示也在同一平台,省掉了外链的域名和HTTPS问题。

当然,第二套方案我也做过一个精简版——把同一份脚本迁到服务器上用crontab跑,输出SVG文件供个人博客引用。两者的核心解析逻辑完全一致,只换入口。

4.2 SVG卡片模板的细节

SVG相比PNG的好处是体积小、支持夜间模式适配,而且可以灵活嵌入GitHub README。GitHub对README里的SVG相对宽松,一个简单的卡片模板长这样:

<svg width="400" height="120" xmlns="http://www.w3.org/2000/svg"> <defs> <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"> <stop offset="0%" stop-color="#1b2838"/> <stop offset="100%" stop-color="#2a475e"/> </linearGradient> </defs> <rect width="400" height="120" rx="12" fill="url(#bg)"/> <image x="16" y="16" width="88" height="88" href="{cover_url}"/> <text x="122" y="42" font-family="sans-serif" font-size="20" fill="#c7d5e0"> {game_name} </text> <text x="122" y="70" font-family="sans-serif" font-size="14" fill="#66c0f4"> 正在游玩中 </text> </svg>

游戏封面图可以直接用Steam CDN的StandardLibraryImage链接,AppID配上固定URL模板就能拿。如果游戏不在库里或封面链接无效,就画一个占位色块,不要让整张卡片红叉。

4.3 生成文本时最容易翻车的转义问题

游戏名不是安全文本,里面可能带&、"、<这些字符。往SVG里填之前,不转义轻则显示错乱,重则SVG直接渲染失败。Python里一行就能解决:

import html safe_name = html.escape(game_name, quote=True)

README占位符替换同理。我在README里放一对注释标记:

<!--STEAM_STATUS:START--> <!--STEAM_STATUS:END-->

脚本读取README全文,用正则把这对标记之间的内容整体替换掉,避免重复堆叠旧记录:

import re pattern = re.compile( r"<!--STEAM_STATUS:START-->.*?<!--STEAM_STATUS:END-->", re.S, ) readme_text = pattern.sub(new_status_block, readme_text)

5. 定时刷新中的两个常见问题:连接报错和自动提交

5.1 定时任务用GitHub Actions怎么搭

GitHub Actions的schedule用的是UTC时间,cron表达式里写"0 * * * *"就是每到整点跑一次。对"正在玩"状态来说一小时一次完全够,再频繁就纯粹是对API的浪费。

我的workflow文件长这样:

name: update-steam-status on: schedule: - cron: "0 * * * *" workflow_dispatch: jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.12" - name: Install dependencies run: pip install requests - name: Run update script env: STEAM_API_KEY: ${{ secrets.STEAM_API_KEY }} STEAM_ID: ${{ secrets.STEAM_ID }} run: python scripts/update_status.py - name: Commit if changed run: | if [ -n "$(git status --porcelain)" ]; then git config user.name "steam-status-bot" git config user.email "bot@example.com" git add . git commit -m "chore: update steam status" git push fi

5.2 "server failed to connect"这类报错怎么处理

看到"server failed to connected to steam"这类信息时,先分清是Steam客户端弹的还是你脚本里报的。如果是客户端界面层的问题,通常和Steam UI进程有关;如果是脚本请求API时的网络错误,那就是链路质量问题。

对脚本来说,最稳妥的做法是超时+重试+缓存。我给请求套了指数退避重试,最多试三次:

import time import requests def fetch_json(url, params, max_retries=3): for attempt in range(max_retries): try: resp = requests.get(url, params=params, timeout=10) resp.raise_for_status() return resp.json() except requests.RequestException as exc: print(f"第{attempt + 1}次请求失败: {exc}") if attempt < max_retries - 1: time.sleep(2 ** attempt) return None

如果三次都失败,就用上一次成功的结果继续渲染,不更新README。这样网络抖动不会破坏已经生成的卡片内容,也不至于因为一次请求失败就让Actions任务失败报警。

5.3 自动提交的坑:别让Actions自己跑死循环

第一次把"Commit if changed"写进去后,我很快遇到一个问题:每次任务运行,即使文件内容没变,也照样提交,导致提交记录刷屏。这就是为什么我在提交前必须检查git diff:

if [ -n "$(git status --porcelain)" ]; then

只有确实有文件变化才提交。对于"正在玩"状态,游戏不变时内容不会变,这个判断能把无效提交直接拦掉。另外,如果哪天你想强制刷新一次展示,可以直接在GitHub仓库页手动触发workflow_dispatch,不用等下一个整点。

5.4 密钥和隐私的补充提醒

最后回到Key本身。Steam Web API Key不区分应用,绑定的是账号,泄露后别人可以用它读取与你账号相关的公开数据。虽然有隐私设置兜底,还是建议用环境变量或secrets管理。README本身是公开的,所以我会刻意不在SVG里放任何不想公开展示的信息,比如真实姓名、邮箱、交易链接。

我在跑这个项目一个多月后的体会是,动态内容给个人简介带来的互动感确实比静态列表强很多。把"正在玩Steam游戏"这件事同步到简介里,技术上的难度不高,但整套链路从API申请到数据解析、展示模板再到定时刷新,每一环都有值得打磨的细节。如果你也打算做,建议先拿API Key在浏览器里手动请求一次接口,看清楚JSON长什么样再写代码,这一步能帮你省掉大量排查时间。

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

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

立即咨询