前阵子给博客加了装备图鉴,做完就想着再搞一个游戏库页面:把 Steam 库里的游戏、
各自的游玩时长,还有「现在正在玩什么」都显示出来。

游戏库页面(/games/)
游戏库页面(/games/)

头像、昵称、「最近两周」和全库时长都是从 Steam 拉的,打开页面时会自动刷新一次。

为什么不能在前端直接调 Steam 接口

想法很直接:页面上 fetch 一下 Steam 的接口不就行了?但有两个拦路虎:

两个硬障碍

CORS:api.steampowered.com 不返回 Access-Control-Allow-Origin,浏览器直接请求会被拦下来;密钥:Steam Web API 要带 API key,而它是账号凭证,不能写进前端代码——静态博客的 JS 人人可见。

所以中间必须有个”代理”:它拿着 key 去问 Steam,把结果用带 CORS 的形式吐给页面。
所以我的方案是用Cloudflare Worker——免费额度足够,几十行代码,还顺手能加缓存。

整体架构:快照 + 实时

两条通路,各管一件事:

通路什么时候更新作用
构建时快照每次部署(hexo generate)把游戏库渲染成静态 HTML——没 JS 也能看,SEO 也友好,数据是”最近一次构建时”的
客户端实时刷新每次打开页面向 Worker 要一次实时数据,只更新「正在玩」、时长、最近两周;失败就静默保持快照

这样分工的好处是:Worker 挂了、网络抽风,页面都还是完整的(只是数字旧一点);
反过来,Worker 正常时「正在玩」这类信息是分钟级的。

部署指南

第一步:拿 Steam API key,并把隐私放开

  1. 打开 https://steamcommunity.com/dev/apikey(要登录 Steam),域名随便填 localhost 就行,拿到 key;
  2. 个人资料 → 编辑 → 隐私设置,把「游戏详情」设为公开。

第 2 步很容易漏:它不是公开的话,接口返回的 games 是空数组(接口本身还是 200,不会报错),
页面就会显示”库为空”。我第一次就是这么被自己绕进去的。

第二步:部署 Worker

Worker 我单独放了一个仓库:**Bytelv/steam-worker**。

1
2
3
4
5
git clone https://github.com/Bytelv/steam-worker.git
cd steam-worker
npx wrangler deploy
npx wrangler secret put STEAM_API_KEY # 上一步拿到的 key
npx wrangler secret put STEAM_ID # steamID64(17 位数字)

不知道自己的 steamID64?用 key 调一次就行:

1
curl "https://api.steampowered.com/ISteamUser/ResolveVanityURL/v1/?key=你的key&vanityurl=你的个性网址"

部署完 curl https://你的域名/ 应该能拿到一份 JSON;加 ?debug=1 还会带一个
X-Steam-Cache 头,写着各级缓存是命中还是回源。

第三步:绑自己的域名(重要)

别用默认的

*.workers.dev workers.dev 在国内被 DNS 污染,无法访问

正确做法是绑自己的域名:Cloudflare Dashboard → Workers & Pages → 选中你的 Worker →
Settings → Domains & Routes → Add → Custom Domain,填一个子域名。

第四步:站点侧的配置

我的博客是 Hexo + Stellar,配置集中在一个数据文件里
(source/_data/steam.yml,改这里就够了,不用动模板):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# steamID64(17 位)。留空的话脚本会用下面的 profile 去解析
steamid: '76561199xxxxxxxxx'

# 上一步绑好的 Worker 地址;留空 = 不做客户端实时刷新,只用构建时快照
api: 'https://steam.你的域名'

# Steam 个人主页(hero 右侧的链接,也用于解析 steamid)
profile: 'https://steamcommunity.com/id/你的个性网址/'

recent_limit: 8 # 「最近两周」显示几款;0 = 不显示该区块
library_limit: 24 # 游戏库先显示几款,其余折叠成「显示全部」

# 不想展示的 appid(Steamworks、Wallpaper Engine 这类)
exclude:
- 228980

第五步:抓一份构建时快照

Hexo 是静态站点,页面内容得在构建时就存在——不然没有 JS 的访客看到的是空白。
抓取脚本干的事就是调 Steam 这几个接口,揉成一份 JSON:

接口拿什么
ISteamUser/ResolveVanityURL个性网址 → steamID64(steamid 填了就不需要)
ISteamUser/GetPlayerSummaries昵称、头像、在线状态、当前在玩的游戏(gameextrainfo + gameid)
IPlayerService/GetOwnedGames整个库 + 每款总时长(要加 include_played_free_games=1)
IPlayerService/GetRecentlyPlayedGames最近两周(playtime_2weeks 比前者更全)

完整脚本就是下面这份(放在 tools/steam/fetch.js;除了调接口,它还处理参数解析、排除表,
以及”本机 Node 证书不被信任时退回 curl”):

tools/steam/fetch.js 完整代码

tools/steam/fetch.js · 196 行

#!/usr/bin/env node /**  * 抓一份 Steam 游戏库快照 → source/_data/steam_games.json  *  * 用法(在仓库根目录):  *   STEAM_API_KEY=xxxx node tools/steam/fetch.js  *   node tools/steam/fetch.js --key xxxx [--steamid 7656119xxxxxxxxx]  *  * API key 申请:https://steamcommunity.com/dev/apikey (要登录 Steam,域名随便填 localhost)  * ⚠️ key 只走环境变量/参数,不要写进仓库;CI 里放 GitHub Secret。  *  * 依赖 Steam Web API 三个接口:  *   ResolveVanityURL      vanity(个性网址)-> steamID64,只在 steam.yml 没写 steamid 时用  *   GetPlayerSummaries    名字/头像/在线状态/正在玩的游戏(gameextrainfo + gameid)  *   GetOwnedGames         整个库 + 每款的总时长;include_played_free_games=1 才含免费游戏  *   GetRecentlyPlayedGames 最近两周玩的(拿 playtime_2weeks 更全)  *  * Steam 的隐私设置会决定接口返回什么:库/时长设为「仅好友」或「私密」时,  * GetOwnedGames 会返回空的 games 数组——页面会显示"库为空",别误以为是脚本坏了。  */ 'use strict';  const fs = require('fs'); const path = require('path'); const { execFileSync } = require('child_process');  const ROOT = path.resolve(__dirname, '..', '..'); const OUT = path.join(ROOT, 'source', '_data', 'steam_games.json'); const CFG = path.join(ROOT, 'source', '_data', 'steam.yml'); const API = 'https://api.steampowered.com';  function parseArgs(argv) {   const out = {};   for (let i = 0; i < argv.length; i++) {     if (argv[i] === '--key') out.key = argv[++i];     else if (argv[i] === '--steamid') out.steamid = argv[++i];     else if (argv[i] === '--quiet') out.quiet = true;   }   return out; }  function readConfig() {   try {     const yaml = require(path.join(ROOT, 'node_modules', 'js-yaml'));     return yaml.load(fs.readFileSync(CFG, 'utf8')) || {};   } catch (error) {     return {};   } }  /**  * 带 curl 兜底的 GET。  *  * 为什么需要兜底:Node 的 fetch 用**自带 CA 库**,在本机(挂着代理/透明劫持)会报  * `UNABLE_TO_GET_ISSUER_CERT_LOCALLY`;curl 用系统证书库则正常——这就是仓库里  * "网络请求一律用 curl.exe" 那条经验的由来(见 AGENTS.md)。  * 只在**连接/TLS 层**失败时兜底;HTTP 4xx/5xx 属于接口层,直接抛错,不掩盖真实原因。  * GitHub Actions 上 fetch 正常,不会走到 curl 分支。  */ let curlFallbackWarned = false;  function curlGet(url) {   if (!curlFallbackWarned) {     curlFallbackWarned = true;     console.warn('提示:Node fetch 失败(多半是证书链不被 Node 信任),本机改用 curl 兜底。');   }   const bin = process.platform === 'win32' ? 'curl.exe' : 'curl';   return execFileSync(bin, ['-sS', '-L', '--max-time', '45', url], { maxBuffer: 32 * 1024 * 1024 }).toString('utf8'); }  async function httpGet(url) {   let res;   try {     res = await fetch(url, { headers: { 'User-Agent': 'lvbyte-blog-steam/1.0' } });   } catch (error) {     return curlGet(url);   }   const text = await res.text();   if (!res.ok) throw new Error('HTTP ' + res.status + ': ' + text.slice(0, 200));   return text; }  async function api(pathname, params) {   const url = new URL(API + pathname);   Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));   const text = await httpGet(url.toString());   try {     return JSON.parse(text);   } catch (error) {     throw new Error(pathname + ' 返回的不是 JSON:' + text.slice(0, 200));   } }  async function resolveVanity(key, vanity) {   const r = await api('/ISteamUser/ResolveVanityURL/v1/', { key, vanityurl: vanity });   if (r.response && r.response.success === 1) return r.response.steamid;   throw new Error(`解析个性网址 "${vanity}" 失败(success=${r.response && r.response.success});请直接在 steam.yml 里填 steamid(17 位)`); }  async function fetchSnapshot({ key, steamid }) {   if (!key) throw new Error('缺少 API key(STEAM_API_KEY 环境变量或 --key)');   if (!steamid) throw new Error('缺少 steamid(source/_data/steam.yml 的 steamid,或 --steamid)');    const [summaries, owned, recent] = await Promise.all([     api('/ISteamUser/GetPlayerSummaries/v2/', { key, steamids: steamid }),     api('/IPlayerService/GetOwnedGames/v1/', {       key, steamid, include_appinfo: 1, include_played_free_games: 1, format: 'json'     }),     api('/IPlayerService/GetRecentlyPlayedGames/v1/', { key, steamid, count: 0 })       .catch(() => ({ response: {} }))   ]);    const player = ((summaries.response && summaries.response.players) || [])[0] || {};   const games = ((owned.response && owned.response.games) || []).slice();   const recentMap = new Map(     (((recent.response && recent.response.games) || [])).map(g => [g.appid, g.playtime_2weeks || 0])   );    games.forEach(g => {     if (!g.playtime_2weeks && recentMap.has(g.appid)) g.playtime_2weeks = recentMap.get(g.appid);   });   games.sort((a, b) => (b.playtime_forever || 0) - (a.playtime_forever || 0));    const totalPlaytimeMinutes = games.reduce((sum, g) => sum + (g.playtime_forever || 0), 0);   const recentPlaytimeMinutes = games.reduce((sum, g) => sum + (g.playtime_2weeks || 0), 0);    return {     _note: '由 tools/steam/fetch.js 生成,不要手改;改配置看 source/_data/steam.yml',     fetchedAt: new Date().toISOString(),     player: {       steamid: player.steamid || steamid,       personaname: player.personaname || '',       avatar: player.avatarfull || player.avatarmedium || '',       profileurl: player.profileurl || '',       personastate: typeof player.personastate === 'number' ? player.personastate : 0,       gameextrainfo: player.gameextrainfo || '',       gameid: player.gameid || ''     },     totalCount: games.length,     totalPlaytimeMinutes,     recentPlaytimeMinutes,     games: games.map(g => ({       appid: g.appid,       name: g.name || `App ${g.appid}`,       playtime_forever: g.playtime_forever || 0,       playtime_2weeks: g.playtime_2weeks || 0,       rtime_last_played: g.rtime_last_played || 0     }))   }; }  function applyExclude(snapshot, exclude) {   const drop = new Set((exclude || []).map(Number).filter(Boolean));   if (drop.size === 0) return snapshot;   const games = snapshot.games.filter(g => !drop.has(g.appid));   return Object.assign({}, snapshot, {     games,     totalCount: games.length,     totalPlaytimeMinutes: games.reduce((s, g) => s + g.playtime_forever, 0),     recentPlaytimeMinutes: games.reduce((s, g) => s + g.playtime_2weeks, 0)   }); }  async function main() {   const args = parseArgs(process.argv.slice(2));   const cfg = readConfig();   const key = args.key || process.env.STEAM_API_KEY || process.env.STEAM_KEY || '';   let steamid = args.steamid || cfg.steamid || '';    if (!steamid && cfg.profile) {     const vanity = String(cfg.profile).replace(/\/+$/, '').split('/').pop();     if (vanity && key) {       steamid = await resolveVanity(key, vanity);       if (!args.quiet) console.log(`解析 ${cfg.profile} -> steamID64 ${steamid}(建议把它填进 steam.yml 的 steamid)`);     }   }    const snapshot = applyExclude(await fetchSnapshot({ key, steamid }), cfg.exclude);   if (snapshot.totalCount === 0) {     console.warn('⚠️ 库里 0 款游戏:多半是 Steam 隐私设置把「游戏详情」设成了仅好友/私密,去个人资料隐私设置里改成公开。');   }   fs.writeFileSync(OUT, JSON.stringify(snapshot, null, 2) + '\n', 'utf8');   if (!args.quiet) {     console.log(`已写入 ${path.relative(ROOT, OUT)}:${snapshot.totalCount} 款游戏,总时长 ${(snapshot.totalPlaytimeMinutes / 60).toFixed(1)} 小时`);     console.log(snapshot.player.gameextrainfo ? `当前正在玩:${snapshot.player.gameextrainfo}` : '当前不在游戏中');   } }  if (require.main === module) {   main().catch(error => {     console.error('抓取失败:' + error.message);     process.exit(1);   }); }  module.exports = { fetchSnapshot, applyExclude, readConfig }; 

结果写进 source/_data/steam_games.json 就ok了:

1
node tools/steam/fetch.js --key 你的key        # 或者用 STEAM_API_KEY 环境变量

第六步:Stellar 侧怎么接

1)注册一个标签(放在站点根目录 scripts/steam.js,Hexo 会自动加载这个目录)。
完整实现如下,两个选择值得说明:直接读磁盘上的数据文件(fs.readFileSync + js-yaml),
而不是走 hexo.locals.get('data'),这样只要页面重新渲染就能拿到最新内容;
**封面加 no-lazy**——Stellar 的懒加载会把 src 换成占位图、再由外部 CDN 脚本还原,
而封面是跨域外链,实测整页封面会空白,no-lazy 是主题唯一认可的退出标记:

scripts/steam.js 完整代码

scripts/steam.js · 205 行

/* global hexo */ 'use strict';  const fs = require('fs'); const path = require('path');  /**  * 游戏库:&#123;% steam %}  *  * 数据:  *   source/_data/steam.yml         站点配置(steamid / Worker 地址 / 显示条数 / 排除)  *   source/_data/steam_games.json  游戏库快照,由 tools/steam/fetch.js 生成  *  * 两个数据文件都**直接从磁盘读**(而不是走 hexo.locals 的数据缓存):  * 这样只要页面重新渲染,拿到的就是最新内容,不必猜 Hexo 给 JSON 起的 key 叫什么。  *  * 页面结构:  *   .steam-hero           头像 + 名字 + 在线状态(正在玩 XXX)+ 库统计  *   .steam-block(最近两周) recent_limit 款玩得最多的(按 playtime_2weeks 排)  *   .steam-block(游戏库)   全部游戏按总时长排;超过 library_limit 款先折叠  * 实时刷新:页面里带 data-steam-api 时,source/js/steam-live.js 会去 Worker  * 拉一次实时数据(正在玩、时长、最近两周),失败就保持静态快照。  *  * 封面统一用 Steam CDN 的 header.jpg(460×215,约 34KB):  * 卡片里显示宽度约 190px,2.4 倍足够清晰;用 capsule_616x353.jpg 会大一倍。  */  const COVER = appid => `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}/header.jpg`; const STORE = appid => `https://store.steampowered.com/app/${appid}/`;  const PERSONA_STATE = ['离线', '在线', '忙碌', '离开', '打盹', '想交易', '想玩游戏'];  /**  * steam-live.js 的版本查询串:取文件内容的哈希。  *  * 为什么必须加:`/js/*` 在部署侧的缓存策略是 `max-age=31536000, immutable`(见 public/_headers),  * 而标签里写的是固定地址 /js/steam-live.js——不换 URL 的话,访客浏览器会把旧版 JS 用满一年,  * 改了脚本也没人看得到(实测踩过:封面降级链改完线上仍是旧行为)。  * 带上内容哈希后,文件一改 URL 就变,缓存自然失效,旧文件也不用管。  */ let scriptVersionCache = null; function scriptVersion() {   if (scriptVersionCache !== null) return scriptVersionCache;   try {     const file = path.join(hexo.base_dir, 'source', 'js', 'steam-live.js');     const hash = require('crypto').createHash('sha1').update(fs.readFileSync(file)).digest('hex').slice(0, 8);     scriptVersionCache = '?v=' + hash;   } catch (error) {     hexo.log.warn('[steam] 算不出 steam-live.js 的版本号:' + error.message);     scriptVersionCache = '';   }   return scriptVersionCache; }  function esc(value) {   if (value == null) return '';   return String(value)     .replace(/&/g, '&amp;')     .replace(/</g, '&lt;')     .replace(/>/g, '&gt;')     .replace(/"/g, '&quot;')     .replace(/'/g, '&#39;'); }  function text(value) {   return value == null ? '' : String(value).trim(); }  // 时长显示:0 分钟不显示;<1 小时按分钟;<100 小时保留一位小数;再大取整带千分位 function fmtMinutes(minutes) {   const m = Number(minutes) || 0;   if (m <= 0) return '';   if (m < 60) return `${m} 分钟`;   const h = m / 60;   if (h < 100) return `${h.toFixed(1)} 小时`;   return `${Math.round(h).toLocaleString('en-US')} 小时`; }  function readData() {   const base = hexo.base_dir;   const yaml = require(path.join(base, 'node_modules', 'js-yaml'));   const cfgPath = path.join(base, 'source', '_data', 'steam.yml');   const dataPath = path.join(base, 'source', '_data', 'steam_games.json');   let cfg = {};   let data = null;   try {     if (fs.existsSync(cfgPath)) cfg = yaml.load(fs.readFileSync(cfgPath, 'utf8')) || {};   } catch (error) {     hexo.log.warn('[steam] 读 source/_data/steam.yml 失败:' + error.message);   }   try {     if (fs.existsSync(dataPath)) data = JSON.parse(fs.readFileSync(dataPath, 'utf8'));   } catch (error) {     hexo.log.warn('[steam] 读 source/_data/steam_games.json 失败:' + error.message);   }   return { cfg, data }; }  function stateText(player, fallbackGame) {   const game = text(player.gameextrainfo) || text(fallbackGame);   if (game) return { playing: true, text: `正在玩 ${game}` };   const state = typeof player.personastate === 'number' ? player.personastate : 0;   return { playing: false, text: PERSONA_STATE[state] || '离线' }; }  function card(game, currentAppId, opts) {   const appid = Number(game.appid) || 0;   const name = text(game.name) || `App ${appid}`;   const playing = currentAppId && String(currentAppId) === String(appid);   const forever = Number(game.playtime_forever) || 0;   const twoWeeks = Number(game.playtime_2weeks) || 0;   const hide = opts && opts.hidden ? ' is-collapsed' : '';   const parts = [];   if (twoWeeks > 0) parts.push(`近两周 ${fmtMinutes(twoWeeks)}`);   if (forever > 0) parts.push(`总 ${fmtMinutes(forever)}`);   const timeText = parts.length ? parts.join(' · ') : '未玩过';    let el = `<article class="steam-card${hide}${playing ? ' is-playing' : ''}" data-appid="${appid}"`     + ` data-playtime-forever="${forever}" data-playtime-2weeks="${twoWeeks}"`     + ` data-name="${esc(name)}">`;   el += `<a class="steam-card__pic" href="${esc(STORE(appid))}" target="_blank" rel="noopener">`;   // no-lazy:主题的懒加载会把 src 换成占位图并交给外部 CDN 脚本还原(jsdelivr 在国内不稳),   // 封面是跨域外链,用原生 loading="lazy" 更稳(no-lazy 是主题唯一认可的退出标记)   el += `<img no-lazy src="${esc(COVER(appid))}" alt="${esc(name)}" loading="lazy" decoding="async">`;   el += `<span class="steam-card__badge">正在玩</span>`;   el += '</a>';   el += '<div class="steam-card__info">';   el += `<span class="steam-card__name" title="${esc(name)}">${esc(name)}</span>`;   el += `<span class="steam-card__time" data-steam-time>${esc(timeText)}</span>`;   el += '</div>';   return el + '</article>'; }  hexo.extend.tag.register('steam', function () {   const { cfg, data } = readData();   if (!data || !Array.isArray(data.games) || data.games.length === 0) {     return '<div class="steam steam--empty">游戏库为空:先跑 <code>node tools/steam/fetch.js</code>(需要 STEAM_API_KEY),'       + '并确认 Steam 隐私设置里「游戏详情」是公开的。详见 source/_data/steam.yml 顶部说明。</div>';   }    const player = data.player || {};   const currentAppId = text(player.gameid);   const profile = text(cfg.profile) || text(player.profileurl) || '';   const api = text(cfg.api);   const recentLimit = cfg.recent_limit == null ? 8 : Number(cfg.recent_limit) || 0;   const libraryLimit = cfg.library_limit == null ? 24 : Number(cfg.library_limit) || 0;    const games = data.games.slice();   const recent = games     .filter(g => (Number(g.playtime_2weeks) || 0) > 0)     .sort((a, b) => (Number(b.playtime_2weeks) || 0) - (Number(a.playtime_2weeks) || 0))     .slice(0, recentLimit);    // 排除表同时交给客户端:Worker 不知道站点配置,实时数据里会带上被排除的 appid,   // 客户端要按同一份清单过滤(否则「实时」刷新会把排除项插回来、统计也会不一致)   const excludeIds = (Array.isArray(cfg.exclude) ? cfg.exclude : [])     .map(v => String(v).trim())     .filter(Boolean);    let el = `<div class="steam"${api ? ` data-steam-api="${esc(api)}"` : ''}`     + `${excludeIds.length ? ` data-steam-exclude="${esc(excludeIds.join(','))}"` : ''}>`;    // hero:头像 + 名字 + 状态 + 统计   const state = stateText(player, '');   // 头像走 Worker 反代:头像在 avatars.steamstatic.com(和封面的 cdn.cloudflare.steamstatic.com   // 不是一个域名),国内经常连不上;没配 Worker 时才退回直接用 Steam 的地址。   const avatarSrc = api ? api.replace(/\/+$/, '') + '/avatar' : text(player.avatar);   el += '<section class="steam-hero">';   if (avatarSrc) el += `<img class="steam-hero__avatar" no-lazy src="${esc(avatarSrc)}" alt="" loading="lazy" decoding="async">`;   el += '<div class="steam-hero__main">';   el += `<div class="steam-hero__name">${esc(player.personaname || 'Steam')}`;   el += `<span class="steam-hero__state${state.playing ? ' is-playing' : ''}" data-steam-state>${esc(state.text)}</span>`;   el += '</div>';   el += '<div class="steam-hero__stats">';   el += `<span>游戏库 <b data-steam-count>${games.length}</b> 款</span>`;   el += `<span>总时长 <b data-steam-total>${esc(fmtMinutes(data.totalPlaytimeMinutes) || '0 小时')}</b></span>`;   el += `<span>最近两周 <b data-steam-recent>${esc(fmtMinutes(data.recentPlaytimeMinutes) || '0 小时')}</b></span>`;   if (data.fetchedAt) {     el += `<span class="steam-hero__updated" data-steam-updated>快照于 ${esc(String(data.fetchedAt).slice(0, 16).replace('T', ' '))}</span>`;   }   el += '</div></div>';   if (profile) el += `<a class="steam-hero__link" href="${esc(profile)}" target="_blank" rel="noopener">Steam 主页</a>`;   el += '</section>';    if (recentLimit > 0 && recent.length > 0) {     el += `<section class="steam-block" data-steam-recent-block data-recent-limit="${recentLimit}">`;     el += '<h2 class="steam-block__title">最近两周</h2><div class="steam-grid">';     el += recent.map(g => card(g, currentAppId)).join('');     el += '</div></section>';   }    const hiddenCount = libraryLimit > 0 ? Math.max(0, games.length - libraryLimit) : 0;   el += '<section class="steam-block"><h2 class="steam-block__title">游戏库';   el += `<span class="steam-block__count">${games.length} 款 · 按总时长排序</span></h2>`;   el += '<div class="steam-grid">';   el += games.map((g, i) => card(g, currentAppId, { hidden: hiddenCount > 0 && i >= libraryLimit })).join('');   el += '</div>';   if (hiddenCount > 0) {     el += `<button type="button" class="steam-more" data-steam-more data-more-count="${hiddenCount}">显示全部(还有 ${hiddenCount} 款)</button>`;   }   el += '</section>';    el += `<script src="/js/steam-live.js${scriptVersion()}" defer></script>`;   return el + '</div>'; }); 

页面本体只要一行标签:

1
2
3
4
5
---
title: 游戏库
active_menu: games # 对应左栏菜单里的 id,Stellar v2 用它高亮当前项
---
{% raw %}{% steam %}{% endraw %}

再在 _config.stellar.yml 的 leftbar.menu 里加一项,它才会出现在左栏:

1
2
3
4
5
6
7
leftbar:
menu:
- id: games
title: 游戏库
icon: default:fire
url: /games/
accent: '#66C0F4' # Steam 蓝

2)前端刷新(source/js/steam-live.js):只更新会变的东西,失败就保持快照。
完整代码放在 source/js/steam-live.js——除了刷新状态和时长,它还负责折叠展开、
把新买的游戏插进网格,以及封面图的降级:

source/js/steam-live.js 完整代码

source/js/steam-live.js · 244 行

/*!  * 游戏库实时刷新 + 「显示全部」  *  * 页面由 scripts/steam.js 的 &#123;% steam %} 静态渲染(构建时的快照),  * 这里再向 Cloudflare Worker 拉一次实时数据(正在玩 / 时长 / 最近两周)。  * Worker 地址写在容器上:<div class="steam" data-steam-api="https://...">,  * 没有这个属性就只做折叠交互,不请求任何东西(纯静态也能用)。  *  * 设计取舍:  *   - 失败一律静默(保持静态快照),控制台打一条 warn 方便排查;  *   - 只更新文本与徽章,不重排整个网格,避免滚动位置跳动;  *   - 快照里没有的新游戏(刚买的)插到游戏库网格最前面并打上「新」标。  */ (function () {   'use strict';    var STATE_LABEL = ['离线', '在线', '忙碌', '离开', '打盹', '想交易', '想玩游戏'];    function fmtMinutes(minutes) {     var m = Number(minutes) || 0;     if (m <= 0) return '';     if (m < 60) return m + ' 分钟';     var h = m / 60;     if (h < 100) return h.toFixed(1) + ' 小时';     return Math.round(h).toLocaleString('en-US') + ' 小时';   }    function timeText(game) {     var parts = [];     var two = Number(game.playtime_2weeks) || 0;     var forever = Number(game.playtime_forever) || 0;     if (two > 0) parts.push('近两周 ' + fmtMinutes(two));     if (forever > 0) parts.push('总 ' + fmtMinutes(forever));     return parts.length ? parts.join(' · ') : '未玩过';   }    function cover(appid) {     return 'https://cdn.cloudflare.steamstatic.com/steam/apps/' + appid + '/header.jpg';   }    function storeUrl(appid) {     return 'https://store.steampowered.com/app/' + appid + '/';   }    function coverUrl(appid, file) {     return 'https://cdn.cloudflare.steamstatic.com/steam/apps/' + appid + '/' + file;   }    // 有些游戏 Steam 没提供 header.jpg(小众作品 / Demo,连 capsule、library_600x900、logo 都是 404),   // CDN 会回 404 或一张 32×32 默认图,拉大就是糊的。实测这类游戏往往还有 library_hero.jpg   // (库页宽幅横幅),所以按 header → capsule → library_hero → 纯文字占位 逐级降级。   var COVER_FALLBACKS = ['capsule_616x353.jpg', 'library_hero.jpg'];    function upgradeCover(img) {     if (img.getAttribute('data-cover-checked')) return;     img.setAttribute('data-cover-checked', '1');     var card = img.closest ? img.closest('.steam-card') : null;     var pic = card ? card.querySelector('.steam-card__pic') : null;     if (!card || !pic) return;     var m = /\/steam\/apps\/(\d+)\//.exec(img.getAttribute('src') || img.src || '');     if (!m) return;     var appid = m[1];     var step = 0;      function acceptable() {       return img.complete && img.naturalWidth >= 200 && img.naturalHeight >= 90;     }      function giveUp() {       pic.classList.add('is-nocover');       if (!pic.querySelector('.steam-card__nocover')) {         var span = document.createElement('span');         span.className = 'steam-card__nocover';         span.textContent = card.getAttribute('data-name') || m[0];         pic.appendChild(span);       }     }      function next() {       if (acceptable()) return;       if (step >= COVER_FALLBACKS.length) { giveUp(); return; }       img.src = coverUrl(appid, COVER_FALLBACKS[step++]);     }      img.addEventListener('load', next);     img.addEventListener('error', next);     if (img.complete) next();     else setTimeout(next, 6000); // 兜底:卡住的请求也让占位出来   }    function fixCovers(root) {     Array.prototype.forEach.call(root.querySelectorAll('.steam-card__pic img'), upgradeCover);   }    // 新游戏的卡片:与 scripts/steam.js 里 card() 的结构保持一致   function buildCard(game) {     var el = document.createElement('article');     el.className = 'steam-card is-new';     el.setAttribute('data-appid', String(game.appid));     el.setAttribute('data-name', game.name || '');     var name = game.name || ('App ' + game.appid);     el.innerHTML =       '<a class="steam-card__pic" href="' + storeUrl(game.appid) + '" target="_blank" rel="noopener">' +       '<img src="' + cover(game.appid) + '" alt="" loading="lazy" decoding="async">' +       '<span class="steam-card__badge">正在玩</span></a>' +       '<div class="steam-card__info">' +       '<span class="steam-card__name"></span>' +       '<span class="steam-card__time" data-steam-time></span></div>';     el.querySelector('.steam-card__name').textContent = name;     el.querySelector('.steam-card__name').title = name;     el.querySelector('[data-steam-time]').textContent = timeText(game);     return el;   }    function excluded(root) {     var raw = root.getAttribute('data-steam-exclude') || '';     var set = {};     raw.split(',').forEach(function (id) {       id = id.trim();       if (id) set[id] = 1;     });     return set;   }    function update(root, data) {     var player = data.player || {};     var excl = excluded(root);     // Worker 不知道站点配置里的排除表,所以在这里过一遍,并重算统计,     // 保证「实时」与「构建时快照」显示的是同一批游戏、同一套数字。     var games = (Array.isArray(data.games) ? data.games : [])       .filter(function (g) { return !excl[String(g.appid)]; });     var currentId = player.gameid ? String(player.gameid) : '';     var totalMinutes = games.reduce(function (s, g) { return s + (Number(g.playtime_forever) || 0); }, 0);     var recentMinutes = games.reduce(function (s, g) { return s + (Number(g.playtime_2weeks) || 0); }, 0);      // 1) hero 状态     var stateEl = root.querySelector('[data-steam-state]');     if (stateEl) {       var playing = !!player.gameextrainfo;       stateEl.textContent = playing ? ('正在玩 ' + player.gameextrainfo) : (STATE_LABEL[player.personastate] || '离线');       stateEl.classList.toggle('is-playing', playing);     }     var set = function (sel, value) {       var el = root.querySelector(sel);       if (el && value != null) el.textContent = value;     };     set('[data-steam-count]', games.length);     set('[data-steam-total]', fmtMinutes(totalMinutes) || '0 小时');     set('[data-steam-recent]', fmtMinutes(recentMinutes) || '0 小时');     if (data.fetchedAt) {       set('[data-steam-updated]', '实时 · ' + String(data.fetchedAt).slice(0, 16).replace('T', ' '));     }      // 2) 已知卡片:更新时长文本 + 「正在玩」徽章     var byAppid = {};     games.forEach(function (g) { byAppid[String(g.appid)] = g; });     var cards = root.querySelectorAll('.steam-card[data-appid]');     Array.prototype.forEach.call(cards, function (el) {       var id = el.getAttribute('data-appid');       var game = byAppid[id];       if (!game) return;       var time = el.querySelector('[data-steam-time]');       if (time) time.textContent = timeText(game);       el.classList.toggle('is-playing', currentId === id);     });      // 3) 快照里没有的新游戏 -> 插到游戏库网格最前面     var libraryGrid = root.querySelector('.steam-block:last-of-type .steam-grid');     if (libraryGrid) {       var fresh = games.filter(function (g) { return !root.querySelector('.steam-card[data-appid="' + g.appid + '"]'); });       fresh.slice(0, 60).forEach(function (g) {         libraryGrid.insertBefore(buildCard(g), libraryGrid.firstChild);       });       if (fresh.length) {         var count = root.querySelector('.steam-block:last-of-type .steam-block__count');         if (count) count.textContent = games.length + ' 款 · 按总时长排序';       }     }      // 4) 「最近两周」区块整体重渲染(数量少,重建成本可忽略)     var recentSection = root.querySelector('[data-steam-recent-block]');     if (recentSection) {       var recent = games.filter(function (g) { return (Number(g.playtime_2weeks) || 0) > 0; })         .sort(function (a, b) { return (Number(b.playtime_2weeks) || 0) - (Number(a.playtime_2weeks) || 0); })         .slice(0, Number(recentSection.getAttribute('data-recent-limit')) || 8);       var grid = recentSection.querySelector('.steam-grid');       if (grid && recent.length) {         grid.innerHTML = '';         recent.forEach(function (g) {           var el = buildCard(g);           el.classList.remove('is-new');           el.classList.toggle('is-playing', currentId === String(g.appid));           grid.appendChild(el);         });       }     }      // 5) 封面降级:必须放在所有重建之后重新扫一遍。     //    上面的「最近两周」是 innerHTML 重建的,新建的 <img> 不会带着以前的处理状态,     //    早先只在插入新游戏的分支里调用,导致实时刷新后这一整行的封面降级失效(线上实测踩到)。     fixCovers(root);   }    function wireMore(root) {     var button = root.querySelector('[data-steam-more]');     if (!button) return;     button.addEventListener('click', function () {       Array.prototype.forEach.call(root.querySelectorAll('.steam-card.is-collapsed'), function (el) {         el.classList.remove('is-collapsed');       });       button.remove();     });   }    function init() {     var roots = document.querySelectorAll('.steam[data-steam-api], .steam');     Array.prototype.forEach.call(roots, function (root) {       wireMore(root);       fixCovers(root);       var api = root.getAttribute('data-steam-api');       if (!api) return;        var controller = typeof AbortController === 'function' ? new AbortController() : null;       var timer = setTimeout(function () { if (controller) controller.abort(); }, 8000);        fetch(api, { signal: controller ? controller.signal : undefined, credentials: 'omit' })         .then(function (res) {           if (!res.ok) throw new Error('HTTP ' + res.status);           return res.json();         })         .then(function (data) {           clearTimeout(timer);           if (data && Array.isArray(data.games) && data.games.length) update(root, data);         })         .catch(function (error) {           clearTimeout(timer);           console.warn('[steam] 实时数据获取失败,保持静态快照:' + error.message);         });     });   }    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);   else init(); })(); 

3)样式:卡片直接复用站点已有的卡片体系(我这边就是装备图鉴那套)——
圆角 14px、1px 描边、阴影、repeat(auto-fill, minmax(210px, 1fr))、间距 20px。
完整的游戏库样式如下,追加到 source/css/custom.css 末尾即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
/* ===== 游戏库(/games/)===== */
.steam {
--steam-card-bg: var(--card);
--steam-card-border: var(--block-border);
--steam-card-shadow: var(--hoverable-card-hover-shadow, 0 8px 18px rgba(0, 0, 0, .08));
--steam-title: var(--theme);
--steam-name: var(--text);
--steam-desc: var(--text-p2);
--steam-meta: var(--text-p3);
--steam-cols-mobile: 2; /* 手机上几列 */
}

/* hero:头像 + 名字 + 在线状态 + 库统计 */
.steam-hero {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 14px;
padding: 16px;
border: 1px solid var(--steam-card-border);
border-radius: 14px;
background: var(--steam-card-bg);
box-shadow: var(--steam-card-shadow);
}
.steam-hero__avatar { width: 64px; height: 64px; border-radius: 50%; object-fit: cover; flex: 0 0 auto; }
.steam-hero__main { flex: 1 1 260px; min-width: 0; }
.steam-hero__name {
display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px;
font-size: 1rem; font-weight: 600; line-height: 1.4; color: var(--text);
}
.steam-hero__state { display: inline-flex; align-items: center; gap: 6px; font-size: .8125rem; color: var(--steam-meta); }
.steam-hero__state.is-playing { color: var(--theme); }
.steam-hero__state.is-playing::before {
content: ''; width: 6px; height: 6px; border-radius: 50%;
background: currentColor; box-shadow: 0 0 0 3px var(--theme-a20);
}
.steam-hero__stats { display: flex; flex-wrap: wrap; gap: 4px 14px; margin-top: 6px; font-size: .8125rem; color: var(--steam-desc); }
.steam-hero__stats b { color: var(--text); font-weight: 600; }
.steam-hero__updated { color: var(--text-p4); }
.steam-hero__link {
flex: 0 0 auto; padding: 6px 14px; border: 1px solid var(--steam-card-border);
border-radius: 999px; font-size: .8125rem; color: var(--theme); text-decoration: none;
}
.steam-hero__link:hover { background: var(--theme-a10); }

/* 区块标题 */
.steam-block { margin: 1.5rem 0 0; }
.steam .steam-block__title {
margin: 1rem 0; padding: 0; border: 0; background: none;
font-size: 1.05rem; font-weight: 700; line-height: 1.6; color: var(--steam-title);
}
.steam-block__count { margin-left: 10px; font-size: .8125rem; font-weight: 400; color: var(--steam-meta); }

/* 网格与卡片 */
.steam-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 20px; }
.steam-card {
position: relative; display: flex; flex-direction: column; overflow: hidden;
border: 1px solid var(--steam-card-border); border-radius: 14px;
background: var(--steam-card-bg); box-shadow: var(--steam-card-shadow);
transition: transform .2s ease, box-shadow .2s ease;
}
.steam-card:hover { transform: translateY(-2px); }

/* 封面:Steam 的 header.jpg 就是 460×215,满铺裁切最像游戏库 */
.steam .steam-card__pic { position: relative; display: block; aspect-ratio: 460 / 215; overflow: hidden; background: var(--block); }
.steam .steam-card__pic img {
display: block; width: 100%; height: 100%; margin: 0; border-radius: 0;
object-fit: cover; transition: transform .3s ease;
}
.steam-card:hover .steam-card__pic img { transform: scale(1.04); }

/* 「正在玩」角标:默认隐藏,只有当前在玩的卡显示 */
.steam-card__badge {
display: none; position: absolute; left: 8px; top: 8px; padding: 2px 8px;
border-radius: 999px; background: var(--theme); color: #fff; font-size: .6875rem; line-height: 1.6;
}
.steam-card.is-playing .steam-card__badge { display: inline-block; }
.steam-card.is-playing { border-color: color-mix(in srgb, var(--theme) 55%, transparent); }

/* Steam 上没有封面素材的游戏,用文字占位(由 steam-live.js 加类) */
.steam-card__pic.is-nocover {
display: flex; align-items: center; justify-content: center; padding: 0 14px;
background: linear-gradient(135deg, var(--theme-a20), var(--block));
}
.steam-card__pic.is-nocover img { display: none; }
.steam-card__nocover { font-size: .8125rem; font-weight: 500; line-height: 1.4; color: var(--steam-desc); text-align: center; }

/* 新买、快照里还没有的游戏(steam-live.js 动态插入) */
.steam-card.is-new::after {
content: '新'; position: absolute; right: 8px; top: 8px; padding: 2px 8px;
border-radius: 999px; background: var(--theme-a20); color: var(--theme); font-size: .6875rem; line-height: 1.6;
}

/* 卡片信息区 */
.steam-card__info { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 12px 16px 16px; text-align: center; }
.steam-card__name {
max-width: 100%; overflow: hidden; font-size: .875rem; font-weight: 500;
line-height: 1.4; color: var(--steam-name); text-overflow: ellipsis; white-space: nowrap;
}
.steam-card__time { font-size: .75rem; line-height: 1.5; color: var(--steam-meta); }

/* 超过 library_limit 的卡先折叠,点「显示全部」展开 */
.steam-card.is-collapsed { display: none; }
.steam-more {
display: block; margin: 1.25rem auto 0; padding: 8px 20px;
border: 1px solid var(--steam-card-border); border-radius: 999px;
background: var(--theme-a10); color: var(--theme); font-size: .8125rem; cursor: pointer;
}
.steam-more:hover { background: var(--theme-a20); }

@media screen and (max-width: 500px) {
.steam-grid { grid-template-columns: repeat(var(--steam-cols-mobile), minmax(0, 1fr)); gap: 14px; }
.steam-hero { padding: 12px; gap: 10px; }
.steam-hero__avatar { width: 48px; height: 48px; }
}
@media (prefers-reduced-motion: reduce) {
.steam-card,
.steam-card:hover .steam-card__pic img { transform: none; }
}

小结

技术栈就是三样:Steam Web API 提供数据、Cloudflare Worker 做代理和缓存、Hexo 标签 + 一小段前端 JS 负责呈现。
总共不到 400 行,效果是打开博客的「游戏库」页面就能看到我现在在品鉴什么游戏。

Cloudflare Worker 的代码开源了:

页面在这里:blog.lvbyte.top/games/

站内搜索

没有找到内容!