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

头像、昵称、「最近两周」和全库时长都是从 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,并把隐私放开
- 打开 https://steamcommunity.com/dev/apikey(要登录 Steam),域名随便填
localhost就行,拿到 key; - 个人资料 → 编辑 → 隐私设置,把「游戏详情」设为公开。
第 2 步很容易漏:它不是公开的话,接口返回的 games 是空数组(接口本身还是 200,不会报错),
页面就会显示”库为空”。我第一次就是这么被自己绕进去的。
第二步:部署 Worker
Worker 我单独放了一个仓库:**Bytelv/steam-worker**。
1 | git clone https://github.com/Bytelv/steam-worker.git |
不知道自己的 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 | # steamID64(17 位)。留空的话脚本会用下面的 profile 去解析 |
第五步:抓一份构建时快照
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'); /** * 游戏库:{% 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, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } 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 | --- |
再在 _config.stellar.yml 的 leftbar.menu 里加一项,它才会出现在左栏:
1 | leftbar: |
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 的 {% 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 | /* ===== 游戏库(/games/)===== */ |
小结
技术栈就是三样:Steam Web API 提供数据、Cloudflare Worker 做代理和缓存、Hexo 标签 + 一小段前端 JS 负责呈现。
总共不到 400 行,效果是打开博客的「游戏库」页面就能看到我现在在品鉴什么游戏。
Cloudflare Worker 的代码开源了:
- Worker:Bytelv/steam-worker
页面在这里:blog.lvbyte.top/games/


