绝赞装死中。

MENU

在博客内放置一个基于Mirages主题的Steam游戏时长小组件

2026 年 05 月 31 日 • 公海

项目特性:做了对Mirages主题日间模式、日落模式及夜间模式的自适应,简洁美观。

1 准备工作

1.1 环境要求

  • PHP 7.2 或更高版本;
  • PHP 扩展含 curljson
  • 服务器可正常访问 api.steampowered.comstore.steampowered.com

1.2 获取Steam ID64

访问STEAMID I/O,在 input...处输入Steam个人主页直链,点击 lookup

image.png
返回的绿框处的数字即为Steam ID64,将之记录下来。

1.3 获取Steam API Key

前往Steam Web API密钥,绑定域(即你的网站域名),申请即可。

image.png
返回的绿框处的密钥即为API Key,将之记录下来。

与公开的ID64不同,API Key是高风险密钥,与你的Steam账号高度关联,请务必妥善保管,不得以任何明文形式泄露。如有泄露风险,麻烦即刻在该页面注销密钥。



2 部署代码

在网站根目录创建一个文件夹,命名为 steam-recent-games后续文件均创建于此内

2.1 api

创建文件夹 api,在其中放置三个文件:

config.php

<?php

return [
    // STEAM_API_KEY = 你的key
    // STEAM_ID64 = 你的steam_id64
    'steam_api_key' => '',
    'steam_id64' => '',

    // 返回游戏数量
    'limit' => 5,

    // 服务端缓存
    'cache_ttl_seconds' => 1800,
];

recent-games.php

<?php

declare(strict_types=1);

const STEAM_RECENT_GAMES_VERSION = '1.0.0';
const STEAM_IMAGE_SCHEMA_VERSION = 'store_appdetails_v4';

header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');
header('Cache-Control: public, max-age=120');

if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
    http_response_code(405);
    header('Allow: GET');
    echo json_encode(['error' => 'Method Not Allowed'], JSON_UNESCAPED_UNICODE);
    exit;
}

$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if ($origin !== '') {
    $host = $_SERVER['HTTP_HOST'] ?? '';
    $originHost = parse_url($origin, PHP_URL_HOST);
    if (is_string($originHost) && hash_equals($host, $originHost)) {
        header('Access-Control-Allow-Origin: ' . $origin);
        header('Vary: Origin');
    }
}

try {
    $config = load_config();
    $apiKey = get_config_value($config, 'steam_api_key', 'STEAM_API_KEY');
    $steamId64 = get_config_value($config, 'steam_id64', 'STEAM_ID64');
    $limit = max(1, min(20, (int)($config['limit'] ?? 5)));
    $cacheTtl = max(60, (int)($config['cache_ttl_seconds'] ?? 1800));

    if ($apiKey === '' || $steamId64 === '') {
        throw new RuntimeException('Steam API is not configured.');
    }

    $cacheFile = cache_file($steamId64);
    $cached = read_cache($cacheFile, $cacheTtl);
    $expiredCache = read_cache($cacheFile, 0, true);
    $imageCache = read_image_cache();

    if (is_array($cached) && cache_is_current($cached)) {
        $cached = apply_image_cache_to_response($cached, $imageCache);
        echo json_encode(array_merge($cached, ['cached' => true]), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        exit;
    }

    if (is_array($cached) && cache_has_games($cached)) {
        $refreshed = refresh_cached_game_images($cached, $imageCache);
        write_cache($cacheFile, $refreshed);
        write_image_cache_from_games($refreshed['response']['games'] ?? [], $imageCache);
        echo json_encode(array_merge($refreshed, [
            'cached' => true,
            'image_refreshed' => true,
        ]), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        exit;
    }

    $payload = fetch_recent_games($apiKey, $steamId64);
    $games = normalize_games($payload['response']['games'] ?? [], $limit, $expiredCache, $imageCache);

    $result = [
        'response' => [
            'total_count' => count($games),
            'games' => $games,
        ],
        'cached' => false,
        'generated_at' => gmdate('c'),
        'version' => STEAM_RECENT_GAMES_VERSION,
        'image_schema' => STEAM_IMAGE_SCHEMA_VERSION,
    ];

    write_cache($cacheFile, $result);
    write_image_cache_from_games($games, $imageCache);
    echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} catch (Throwable $e) {
    $stale = read_stale_fallback();
    if (is_array($stale)) {
        http_response_code(200);
        echo json_encode(array_merge($stale, [
            'cached' => true,
            'stale' => true,
            'warning' => 'Steam data is temporarily unavailable; showing cached data.',
        ]), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        exit;
    }

    http_response_code(500);
    echo json_encode(['error' => $e->getMessage()], JSON_UNESCAPED_UNICODE);
}

function read_stale_fallback(): ?array
{
    try {
        $cache = read_cache(cache_file('fallback'), 86400 * 7, true);
        if (!is_array($cache)) {
            return null;
        }

        $imageCache = read_image_cache();
        if (cache_is_current($cache)) {
            return apply_image_cache_to_response($cache, $imageCache);
        }

        return cache_has_games($cache) ? refresh_cached_game_images($cache, $imageCache) : null;
    } catch (Throwable $e) {
        return null;
    }
}

function cache_is_current(array $cache): bool
{
    return (string)($cache['image_schema'] ?? '') === STEAM_IMAGE_SCHEMA_VERSION;
}

function cache_has_games(array $cache): bool
{
    return isset($cache['response']['games']) && is_array($cache['response']['games']);
}

function refresh_cached_game_images(array $cache, array $imageCache = []): array
{
    $games = $cache['response']['games'];
    $storeDetails = fetch_store_appdetails_for_games($games);
    $previousImages = merge_image_maps($imageCache, previous_image_map($cache));
    $refreshedGames = [];

    foreach ($games as $game) {
        if (!is_array($game)) {
            continue;
        }

        $appid = (int)($game['appid'] ?? 0);
        $game['appid'] = $appid;
        $game['name'] = (string)($game['name'] ?? 'Unknown Game');
        $game['playtime_2weeks'] = (int)($game['playtime_2weeks'] ?? 0);
        $game['playtime_forever'] = (int)($game['playtime_forever'] ?? 0);
        $game['image'] = resolve_game_image($appid, $storeDetails, $previousImages);
        $refreshedGames[] = $game;
    }

    $cache['response']['games'] = $refreshedGames;
    $cache['response']['total_count'] = count($refreshedGames);
    $cache['cached'] = false;
    $cache['generated_at'] = gmdate('c');
    $cache['version'] = STEAM_RECENT_GAMES_VERSION;
    $cache['image_schema'] = STEAM_IMAGE_SCHEMA_VERSION;

    return $cache;
}

function load_config(): array
{
    $configPath = __DIR__ . '/config.php';
    if (is_file($configPath)) {
        $config = require $configPath;
        if (!is_array($config)) {
            throw new RuntimeException('Invalid config.php.');
        }
        return $config;
    }

    return [];
}

function get_config_value(array $config, string $key, string $envKey): string
{
    $env = getenv($envKey);
    if (is_string($env) && trim($env) !== '') {
        return trim($env);
    }

    return trim((string)($config[$key] ?? ''));
}

function cache_file(string $steamId64): string
{
    $cacheDir = dirname(__DIR__) . '/cache';
    if (!is_dir($cacheDir) && !mkdir($cacheDir, 0750, true) && !is_dir($cacheDir)) {
        throw new RuntimeException('Unable to create cache directory.');
    }

    return $cacheDir . '/recent-games-' . sha1($steamId64) . '.json';
}

function read_cache(string $path, int $ttl, bool $ignoreTtl = false): ?array
{
    if (!is_file($path)) {
        return null;
    }

    if (!$ignoreTtl && (time() - filemtime($path)) > $ttl) {
        return null;
    }

    $handle = fopen($path, 'rb');
    if ($handle === false) {
        return null;
    }

    $raw = '';
    while (!feof($handle)) {
        $chunk = fread($handle, 8192);
        if ($chunk === false) {
            fclose($handle);
            return null;
        }
        $raw .= $chunk;
    }
    fclose($handle);

    $decoded = json_decode($raw, true);
    return is_array($decoded) ? $decoded : null;
}

function write_cache(string $path, array $data): void
{
    write_json_file($path, $data);
    copy($path, cache_file('fallback'));
}

function write_json_file(string $path, array $data): void
{
    $encoded = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    if ($encoded === false) {
        return;
    }

    file_put_contents($path, $encoded, LOCK_EX);
}

function fetch_recent_games(string $apiKey, string $steamId64): array
{
    if (!function_exists('curl_init')) {
        throw new RuntimeException('PHP cURL extension is not enabled.');
    }

    $query = http_build_query([
        'key' => $apiKey,
        'steamid' => $steamId64,
        'format' => 'json',
    ]);
    $url = 'https://api.steampowered.com/IPlayerService/GetRecentlyPlayedGames/v0001/?' . $query;

    $ch = curl_init($url);
    if ($ch === false) {
        throw new RuntimeException('Unable to initialize cURL.');
    }

    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_FOLLOWLOCATION => false,
        CURLOPT_CONNECTTIMEOUT => 5,
        CURLOPT_TIMEOUT => 10,
        CURLOPT_SSL_VERIFYPEER => true,
        CURLOPT_SSL_VERIFYHOST => 2,
        CURLOPT_HTTPHEADER => ['Accept: application/json'],
        CURLOPT_USERAGENT => 'SteamRecentGames/' . STEAM_RECENT_GAMES_VERSION,
    ]);

    $body = curl_exec($ch);
    $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    $error = curl_error($ch);
    curl_close($ch);

    if ($body === false || $status < 200 || $status >= 300) {
        throw new RuntimeException('Steam API request failed' . ($error !== '' ? ': ' . $error : '.'));
    }

    $decoded = json_decode($body, true);
    if (!is_array($decoded)) {
        throw new RuntimeException('Steam API returned invalid JSON.');
    }

    return $decoded;
}

function normalize_games(array $games, int $limit, ?array $previousCache = null, array $imageCache = []): array
{
    usort($games, static function (array $a, array $b): int {
        return (int)($b['playtime_2weeks'] ?? 0) <=> (int)($a['playtime_2weeks'] ?? 0);
    });

    $games = array_slice($games, 0, $limit);
    $storeDetails = fetch_store_appdetails_for_games($games);
    $previousImages = is_array($previousCache)
        ? merge_image_maps($imageCache, previous_image_map($previousCache))
        : $imageCache;

    return array_map(static function (array $game) use ($storeDetails, $previousImages): array {
        $appid = (int)($game['appid'] ?? 0);

        return [
            'appid' => $appid,
            'name' => (string)($game['name'] ?? 'Unknown Game'),
            'playtime_2weeks' => (int)($game['playtime_2weeks'] ?? 0),
            'playtime_forever' => (int)($game['playtime_forever'] ?? 0),
            'image' => resolve_game_image($appid, $storeDetails, $previousImages),
        ];
    }, $games);
}

function apply_image_cache_to_response(array $cache, array $imageCache): array
{
    if (!cache_has_games($cache) || !$imageCache) {
        return $cache;
    }

    foreach ($cache['response']['games'] as $index => $game) {
        if (!is_array($game)) {
            continue;
        }

        $appid = (int)($game['appid'] ?? 0);
        $image = trim((string)($game['image'] ?? ''));
        if (
            $appid > 0
            && isset($imageCache[$appid])
            && is_valid_image_url($imageCache[$appid])
            && ($image === '' || is_fallback_image_url($appid, $image))
        ) {
            $cache['response']['games'][$index]['image'] = $imageCache[$appid];
        }
    }

    return $cache;
}

function merge_image_maps(array $primary, array $secondary): array
{
    foreach ($secondary as $appid => $url) {
        if (!isset($primary[$appid])) {
            $primary[$appid] = $url;
        }
    }

    return $primary;
}

function read_image_cache(): array
{
    $cache = read_cache(image_cache_file(), 0, true);
    if (!is_array($cache) || (string)($cache['schema'] ?? '') !== STEAM_IMAGE_SCHEMA_VERSION) {
        return [];
    }

    $images = [];
    foreach (($cache['images'] ?? []) as $appid => $entry) {
        $appid = (int)$appid;
        $url = is_array($entry) ? trim((string)($entry['url'] ?? '')) : '';
        if ($appid > 0 && $url !== '' && is_valid_image_url($url) && !is_fallback_image_url($appid, $url)) {
            $images[$appid] = $url;
        }
    }

    return $images;
}

function write_image_cache_from_games(array $games, array $existingImages = []): void
{
    $images = $existingImages;
    foreach ($games as $game) {
        if (!is_array($game)) {
            continue;
        }

        $appid = (int)($game['appid'] ?? 0);
        $url = trim((string)($game['image'] ?? ''));
        if ($appid > 0 && $url !== '' && is_valid_image_url($url) && !is_fallback_image_url($appid, $url)) {
            $images[$appid] = $url;
        }
    }

    $payload = [
        'schema' => STEAM_IMAGE_SCHEMA_VERSION,
        'generated_at' => gmdate('c'),
        'images' => [],
    ];

    foreach ($images as $appid => $url) {
        $payload['images'][(string)$appid] = [
            'url' => $url,
            'updated_at' => gmdate('c'),
        ];
    }

    write_json_file(image_cache_file(), $payload);
}

function previous_image_map(array $cache): array
{
    if (!cache_has_games($cache)) {
        return [];
    }

    $images = [];
    foreach ($cache['response']['games'] as $game) {
        if (!is_array($game)) {
            continue;
        }

        $appid = (int)($game['appid'] ?? 0);
        $image = trim((string)($game['image'] ?? ''));
        if ($appid > 0 && $image !== '' && is_valid_image_url($image) && !is_fallback_image_url($appid, $image)) {
            $images[$appid] = $image;
        }
    }

    return $images;
}

function fetch_store_appdetails_for_games(array $games): array
{
    $appids = [];
    foreach ($games as $game) {
        $appid = (int)($game['appid'] ?? 0);
        if ($appid > 0) {
            $appids[] = $appid;
        }
    }

    if (!$appids) {
        return [];
    }

    return fetch_store_appdetails(array_values(array_unique($appids)));
}

function resolve_game_image(int $appid, array $storeDetails, array $previousImages = []): string
{
    $fallback = fallback_game_image($appid);
    if ($appid <= 0) {
        return $fallback;
    }

    $data = $storeDetails[(string)$appid]['data'] ?? null;
    if (is_array($data)) {
        foreach (['header_image', 'capsule_image'] as $field) {
            $image = trim((string)($data[$field] ?? ''));
            if ($image !== '' && is_valid_image_url($image)) {
                return $image;
            }
        }
    }

    if (isset($previousImages[$appid]) && is_valid_image_url($previousImages[$appid])) {
        return $previousImages[$appid];
    }

    return $fallback;
}

function fetch_store_appdetails(array $appids): array
{
    if (!function_exists('curl_init') || !function_exists('curl_multi_init')) {
        return [];
    }

    $multi = curl_multi_init();
    if ($multi === false) {
        return [];
    }

    $handles = [];
    foreach ($appids as $appid) {
        $appid = (int)$appid;
        if ($appid <= 0) {
            continue;
        }

        $query = http_build_query([
            'appids' => $appid,
            'filters' => 'basic',
        ]);
        $url = 'https://store.steampowered.com/api/appdetails?' . $query;

        $ch = curl_init($url);
        if ($ch === false) {
            continue;
        }

        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_FOLLOWLOCATION => false,
            CURLOPT_CONNECTTIMEOUT => 2,
            CURLOPT_TIMEOUT => 4,
            CURLOPT_SSL_VERIFYPEER => true,
            CURLOPT_SSL_VERIFYHOST => 2,
            CURLOPT_HTTPHEADER => ['Accept: application/json'],
            CURLOPT_USERAGENT => 'SteamRecentGames/' . STEAM_RECENT_GAMES_VERSION,
        ]);

        $key = (string)$appid;
        $handles[$key] = $ch;
        curl_multi_add_handle($multi, $ch);
    }

    if (!$handles) {
        curl_multi_close($multi);
        return [];
    }

    do {
        $status = curl_multi_exec($multi, $running);
        if ($running) {
            curl_multi_select($multi, 1.0);
        }
    } while ($running && $status === CURLM_OK);

    $details = [];
    foreach ($handles as $appid => $ch) {
        $body = curl_multi_getcontent($ch);
        $httpStatus = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);

        curl_multi_remove_handle($multi, $ch);
        curl_close($ch);

        if ($body === false || $httpStatus < 200 || $httpStatus >= 300) {
            continue;
        }

        $decoded = json_decode($body, true);
        if (is_array($decoded) && isset($decoded[$appid]) && is_array($decoded[$appid])) {
            $details[$appid] = $decoded[$appid];
        }
    }

    curl_multi_close($multi);
    return $details;
}

function fallback_game_image(int $appid): string
{
    return 'https://cdn.cloudflare.steamstatic.com/steam/apps/' . $appid . '/header.jpg';
}

function image_cache_file(): string
{
    $cacheDir = dirname(__DIR__) . '/cache';
    if (!is_dir($cacheDir) && !mkdir($cacheDir, 0750, true) && !is_dir($cacheDir)) {
        throw new RuntimeException('Unable to create cache directory.');
    }

    return $cacheDir . '/image-url-cache.json';
}

function is_fallback_image_url(int $appid, string $url): bool
{
    return $url === fallback_game_image($appid);
}

function is_valid_image_url(string $url): bool
{
    $parts = parse_url($url);
    if (!is_array($parts)) {
        return false;
    }

    $scheme = strtolower((string)($parts['scheme'] ?? ''));
    return $scheme === 'https' && !empty($parts['host']);
}

.htaccess

<FilesMatch "^config\.php$">
  Require all denied
</FilesMatch>

2.2 assets

创建文件夹 assets,在其中放置两个文件:

steam-recent-games.css

.steam-games {
  --steam-row-hover: rgba(255, 255, 255, 0.56);
  --steam-row-active: rgba(255, 255, 255, 0.68);
  --steam-border: rgba(28, 34, 42, 0.12);
  --steam-text: #24272d;
  --steam-muted: #697386;
  --steam-accent: #168f7d;
  --steam-accent-soft: rgba(22, 143, 125, 0.14);
  --steam-row-shadow: 0 10px 24px rgba(22, 28, 38, 0.09);
  --steam-cover-bg: rgba(28, 34, 42, 0.08);

  padding: 0 1.5rem;
  color: var(--steam-text);
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}

.steam-games__title {
  margin: 0 0 1.2rem;
  padding-bottom: 0.8rem;
  color: var(--steam-text);
  font-size: 1.4em;
  font-weight: 700;
}

.steam-games__list {
  overflow: hidden;
  border-bottom: 1px solid var(--steam-border);
  background: transparent;
}

.steam-games__item {
  position: relative;
  display: flex;
  align-items: center;
  gap: 18px;
  min-height: 72px;
  padding: 12px 14px;
  border-bottom: 1px solid var(--steam-border);
  outline: none;
  transition: background-color 180ms ease, box-shadow 180ms ease, transform 180ms ease;
}

.steam-games__item:last-child {
  border-bottom: 0;
}

.steam-games__item::before {
  position: absolute;
  inset: 10px auto 10px 0;
  width: 3px;
  border-radius: 999px;
  background: var(--steam-accent);
  content: "";
  opacity: 0;
  transform: scaleY(0.45);
  transition: opacity 180ms ease, transform 180ms ease;
}

.steam-games__item:hover,
.steam-games__item:focus-visible {
  z-index: 1;
  background: var(--steam-row-hover);
  box-shadow: var(--steam-row-shadow);
  transform: translateX(4px);
}

.steam-games__item:hover::before,
.steam-games__item:focus-visible::before {
  opacity: 1;
  transform: scaleY(1);
}

.steam-games__cover {
  width: 145px;
  aspect-ratio: 460 / 215;
  border-radius: 7px;
  overflow: hidden;
  flex: 0 0 auto;
  background: var(--steam-cover-bg);
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16);
}

.steam-games__cover img {
  display: block;
  width: 100%;
  height: 100%;
  margin: 0 !important;
  object-fit: cover;
  transition: filter 180ms ease, transform 220ms ease;
}

.steam-games__item:hover .steam-games__cover img,
.steam-games__item:focus-visible .steam-games__cover img {
  filter: saturate(1.08) contrast(1.04);
  transform: scale(1.035);
}

.steam-games__info {
  flex: 1 1 auto;
  min-width: 0;
}

.steam-games__name {
  margin: 0;
  overflow: hidden;
  color: var(--steam-text);
  font-size: 16px;
  font-weight: 650;
  line-height: 1.4;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.steam-games__meta {
  margin-top: 3px;
  color: var(--steam-muted);
  font-size: 12px;
  line-height: 1.4;
}

.steam-games__time {
  min-width: 145px;
  flex: 0 0 auto;
  color: var(--steam-muted);
  font-size: 13px;
  line-height: 1.5;
  text-align: right;
}

.steam-games__time span {
  display: block;
}

.steam-games__recent {
  display: inline-block;
  color: var(--steam-accent);
  font-weight: 650;
}

.steam-games__recent::before {
  display: inline-block;
  width: 0.52em;
  height: 0.52em;
  margin-right: 0.45em;
  border-radius: 999px;
  background: var(--steam-accent);
  box-shadow: 0 0 0 5px var(--steam-accent-soft);
  content: "";
  vertical-align: 0.04em;
}

.steam-games__state {
  padding: 2rem;
  color: var(--steam-muted);
  font-size: 14px;
  text-align: center;
}

.steam-games__source {
  margin-top: 1rem;
  color: var(--steam-muted);
  font-size: 0.85em;
  text-align: right;
}

.steam-games__source a {
  color: var(--steam-muted);
  text-decoration: none;
  transition: color 160ms ease;
}

.steam-games__source a:hover {
  color: var(--steam-accent);
}

body.theme-dark .steam-games,
html.theme-dark .steam-games,
.theme-dark .steam-games,
[data-theme="dark"] .steam-games,
[theme="dark"] .steam-games {
  --steam-row-hover: rgba(255, 255, 255, 0.075);
  --steam-row-active: rgba(255, 255, 255, 0.1);
  --steam-border: rgba(255, 255, 255, 0.09);
  --steam-text: #f2f4f7;
  --steam-muted: #b5bdc9;
  --steam-accent: #4fd1b8;
  --steam-accent-soft: rgba(79, 209, 184, 0.16);
  --steam-row-shadow: 0 12px 24px rgba(0, 0, 0, 0.22);
  --steam-cover-bg: rgba(255, 255, 255, 0.08);
}

body.theme-white .steam-games,
html.theme-white .steam-games,
.theme-white .steam-games,
body.theme-light .steam-games,
html.theme-light .steam-games,
.theme-light .steam-games,
body.theme-sunset .steam-games,
html.theme-sunset .steam-games,
.theme-sunset .steam-games,
[data-theme="white"] .steam-games,
[data-theme="light"] .steam-games,
[data-theme="sunset"] .steam-games,
[theme="white"] .steam-games,
[theme="light"] .steam-games,
[theme="sunset"] .steam-games {
  --steam-row-hover: rgba(255, 255, 255, 0.54);
  --steam-row-active: rgba(255, 255, 255, 0.66);
  --steam-border: rgba(32, 39, 50, 0.13);
  --steam-text: #20242a;
  --steam-muted: #5f6877;
  --steam-accent: #b65f2b;
  --steam-accent-soft: rgba(182, 95, 43, 0.14);
  --steam-row-shadow: 0 10px 24px rgba(46, 36, 28, 0.1);
  --steam-cover-bg: rgba(32, 39, 50, 0.08);
}

@media (max-width: 576px) {
  .steam-games {
    padding: 0 1rem;
  }

  .steam-games__item {
    gap: 12px;
    padding: 11px 12px;
  }

  .steam-games__cover {
    width: 126px;
  }

  .steam-games__info {
    display: none;
  }

  .steam-games__time {
    min-width: 118px;
    margin-left: auto;
  }
}

@media (prefers-reduced-motion: reduce) {
  .steam-games__item,
  .steam-games__item::before,
  .steam-games__cover img,
  .steam-games__source a {
    transition: none;
  }

  .steam-games__item:hover,
  .steam-games__item:focus-visible {
    transform: none;
  }
}

steam-recent-games.js

(function () {
  'use strict';

  var DEFAULTS = {
    endpoint: '/steam-recent-games/api/recent-games.php',
    cacheKey: 'steamRecentGames:v2',
    cacheTtl: 30 * 60 * 1000,
  };

  document.addEventListener('DOMContentLoaded', function () {
    var widgets = document.querySelectorAll('[data-steam-recent-games]');
    Array.prototype.forEach.call(widgets, initSteamGames);
  });

  function initSteamGames(root) {
    var config = {
      endpoint: root.getAttribute('data-endpoint') || DEFAULTS.endpoint,
      cacheKey: root.getAttribute('data-cache-key') || DEFAULTS.cacheKey,
      cacheTtl: Number(root.getAttribute('data-cache-ttl') || DEFAULTS.cacheTtl),
    };
    var list = root.querySelector('[data-steam-games-list]');

    if (!list) return;

    renderState(list, '绝赞加载中...');

    var cached = readCache(config);
    if (cached) {
      renderGames(list, cached);
    }

    fetch(config.endpoint, {
      headers: { Accept: 'application/json' },
      credentials: 'same-origin',
    })
      .then(function (response) {
        if (!response.ok) {
          throw new Error('请求失败:HTTP ' + response.status);
        }
        return response.json();
      })
      .then(function (data) {
        if (data.error) {
          throw new Error(data.error);
        }

        var games = data && data.response && Array.isArray(data.response.games)
          ? data.response.games
          : [];
        if (!games.length) {
          throw new Error('最近没有在玩游戏捏');
        }

        writeCache(config, games);
        renderGames(list, games);
      })
      .catch(function (error) {
        if (!cached) {
          renderState(list, error.message || '数据加载失败。', true);
        }
      });
  }

  function renderGames(list, games) {
    var fragment = document.createDocumentFragment();
    games.forEach(function (game) {
      fragment.appendChild(createGameItem(game));
    });
    list.innerHTML = '';
    list.appendChild(fragment);
  }

  function createGameItem(game) {
    var item = document.createElement('div');
    item.className = 'steam-games__item';
    item.tabIndex = 0;

    var cover = document.createElement('div');
    cover.className = 'steam-games__cover';

    var image = document.createElement('img');
    image.loading = 'lazy';
    image.alt = game.name || 'Steam game';
    image.src = game.image || 'https://cdn.cloudflare.steamstatic.com/steam/apps/' + game.appid + '/header.jpg';
    image.addEventListener('error', function () {
      image.style.opacity = '0.45';
    });
    cover.appendChild(image);

    var info = document.createElement('div');
    info.className = 'steam-games__info';

    var title = document.createElement('div');
    title.className = 'steam-games__name';
    title.textContent = game.name || 'Unknown Game';
    info.appendChild(title);

    var meta = document.createElement('div');
    meta.className = 'steam-games__meta';
    meta.textContent = Number(game.playtime_2weeks) > 0 ? '近两周有在玩' : '近两周暂无游玩记录';
    info.appendChild(meta);

    var playtime = document.createElement('div');
    playtime.className = 'steam-games__time';

    if (Number(game.playtime_2weeks) > 0) {
      var recent = document.createElement('span');
      recent.className = 'steam-games__recent';
      recent.textContent = '最近 ' + formatPlaytime(game.playtime_2weeks);
      playtime.appendChild(recent);
    }

    var total = document.createElement('span');
    total.className = 'steam-games__total';
    total.textContent = '总时长 ' + formatPlaytime(game.playtime_forever);
    playtime.appendChild(total);

    item.appendChild(cover);
    item.appendChild(info);
    item.appendChild(playtime);
    return item;
  }

  function renderState(list, message, isError) {
    var state = document.createElement('div');
    state.className = 'steam-games__state';
    if (isError) state.setAttribute('role', 'alert');
    state.textContent = message;
    list.innerHTML = '';
    list.appendChild(state);
  }

  function formatPlaytime(minutes) {
    var value = Number(minutes || 0);
    if (value <= 0) return '0 分钟';

    var hours = Math.floor(value / 60);
    var mins = value % 60;
    if (hours <= 0) return mins + ' 分钟';
    if (mins <= 0) return hours + ' 小时';
    return hours + ' 小时 ' + mins + ' 分钟';
  }

  function readCache(config) {
    try {
      var raw = localStorage.getItem(config.cacheKey);
      if (!raw) return null;

      var cache = JSON.parse(raw);
      if (!cache || Date.now() - cache.timestamp > config.cacheTtl) return null;
      return Array.isArray(cache.data) ? cache.data : null;
    } catch (_) {
      return null;
    }
  }

  function writeCache(config, data) {
    try {
      localStorage.setItem(config.cacheKey, JSON.stringify({
        timestamp: Date.now(),
        data: data,
      }));
    } catch (_) {
      // 隐私模式访问的话本地存储估计会失效
    }
  }
}());

2.3 cache

创建文件夹 cache,在其中放置:

.htaccess

Require all denied

2.4 文件树

综上,项目文件结构应当为:

steam-recent-games/
  api/
    .htaccess
    config.php
    recent-games.php
  assets/
    steam-recent-games.css
    steam-recent-games.js
  cache/
    .htaccess


3 配置与应用

编辑 steam-recent-games/api/config.php,填入你的API Key和ID64:

return [
    'steam_api_key' => '你的 Steam API Key',
    'steam_id64' => '你的 Steam ID64',
    'limit' => 5,
    'cache_ttl_seconds' => 1800,
];

出于安全考量,更推荐使用服务器环境变量配置 STEAM_API_KEYSTEAM_ID64

接着,在 主题设置-主题自定义扩展-head标签结束前引入css和js:

<link rel="stylesheet" href="/steam-recent-games/assets/steam-recent-games.css">
<script src="/steam-recent-games/assets/steam-recent-games.js" defer></script>

最后,只需将以下代码嵌入到任何你想放置小组件的地方:

<section class="steam-games" data-steam-recent-games data-endpoint="/steam-recent-games/api/recent-games.php">
  <h3 class="steam-games__title">最近在玩ヾ(•ω•`)</h3>
  <div class="steam-games__list" data-steam-games-list>
    <div class="steam-games__state">绝赞加载中...</div>
  </div>
  <div class="steam-games__source">
    数据来自 <a href="https://developer.valvesoftware.com/wiki/Steam_Web_API" target="_blank" rel="noopener noreferrer">Steam Web API</a>
  </div>
</section>

大功告成!::EOC:04::



4 DEMO预览

最近在玩ヾ(•ω•`)

绝赞加载中...
数据来自 Steam Web API


5 如果是启用了PJAX的情况..?

如果启用了主题的全站PJAX,则可能出现组件加载不出来的情况。

这时 assets/steam-recent-games.js需要换成下面这个版本:

steam-recent-games.js

(function () {
  'use strict';

  var DEFAULTS = {
    endpoint: '/steam-recent-games/api/recent-games.php',
    cacheKey: 'steamRecentGames:v2',
    cacheTtl: 30 * 60 * 1000
  };

  window.initSteamGames = function initSteamGames(scope) {
    var root = scope && scope.nodeType === 1 ? scope : document;
    var widgets;

    if (root.matches && root.matches('[data-steam-recent-games]')) {
      widgets = [root];
    } else {
      widgets = root.querySelectorAll('[data-steam-recent-games]');
    }

    Array.prototype.forEach.call(widgets, initSteamGamesWidget);
  };

  document.addEventListener('DOMContentLoaded', function () {
    window.initSteamGames();
  });

  function initSteamGamesWidget(root) {
    if (root.getAttribute('data-steam-games-ready') === '1') {
      return;
    }

    root.setAttribute('data-steam-games-ready', '1');

    var config = {
      endpoint: root.getAttribute('data-endpoint') || DEFAULTS.endpoint,
      cacheKey: root.getAttribute('data-cache-key') || DEFAULTS.cacheKey,
      cacheTtl: Number(root.getAttribute('data-cache-ttl') || DEFAULTS.cacheTtl)
    };

    var list = root.querySelector('[data-steam-games-list]');
    if (!list) return;

    renderState(list, '绝赞加载中...');

    var cached = readCache(config);
    if (cached) {
      renderGames(list, cached);
    }

    fetch(config.endpoint, {
      headers: { Accept: 'application/json' },
      credentials: 'same-origin'
    })
      .then(function (response) {
        if (!response.ok) {
          throw new Error('请求失败:HTTP ' + response.status);
        }

        return response.json();
      })
      .then(function (data) {
        if (data.error) {
          throw new Error(data.error);
        }

        var games = data && data.response && Array.isArray(data.response.games)
          ? data.response.games
          : [];

        if (!games.length) {
          throw new Error('最近没有在玩游戏捏');
        }

        writeCache(config, games);
        renderGames(list, games);
      })
      .catch(function (error) {
        root.removeAttribute('data-steam-games-ready');

        if (!cached) {
          renderState(list, error.message || '数据加载失败', true);
        }
      });
  }

  function renderGames(list, games) {
    var fragment = document.createDocumentFragment();

    games.forEach(function (game) {
      fragment.appendChild(createGameItem(game));
    });

    list.innerHTML = '';
    list.appendChild(fragment);
  }

  function createGameItem(game) {
    var item = document.createElement('div');
    item.className = 'steam-games__item';
    item.tabIndex = 0;

    var cover = document.createElement('div');
    cover.className = 'steam-games__cover';

    var image = document.createElement('img');
    image.loading = 'lazy';
    image.alt = game.name || 'Steam game';
    image.src = game.image || 'https://cdn.cloudflare.steamstatic.com/steam/apps/' + game.appid + '/header.jpg';
    image.addEventListener('error', function () {
      image.style.opacity = '0.45';
    });
    cover.appendChild(image);

    var info = document.createElement('div');
    info.className = 'steam-games__info';

    var title = document.createElement('div');
    title.className = 'steam-games__name';
    title.textContent = game.name || 'Unknown Game';
    info.appendChild(title);

    var meta = document.createElement('div');
    meta.className = 'steam-games__meta';
    meta.textContent = Number(game.playtime_2weeks) > 0 ? '近两周有在玩' : '近两周暂无游玩记录';
    info.appendChild(meta);

    var playtime = document.createElement('div');
    playtime.className = 'steam-games__time';

    if (Number(game.playtime_2weeks) > 0) {
      var recent = document.createElement('span');
      recent.className = 'steam-games__recent';
      recent.textContent = '最近 ' + formatPlaytime(game.playtime_2weeks);
      playtime.appendChild(recent);
    }

    var total = document.createElement('span');
    total.className = 'steam-games__total';
    total.textContent = '总时长 ' + formatPlaytime(game.playtime_forever);
    playtime.appendChild(total);

    item.appendChild(cover);
    item.appendChild(info);
    item.appendChild(playtime);

    return item;
  }

  function renderState(list, message, isError) {
    var state = document.createElement('div');
    state.className = 'steam-games__state';

    if (isError) {
      state.setAttribute('role', 'alert');
    }

    state.textContent = message;
    list.innerHTML = '';
    list.appendChild(state);
  }

  function formatPlaytime(minutes) {
    var value = Number(minutes || 0);
    if (value <= 0) return '0 分钟';

    var hours = Math.floor(value / 60);
    var mins = value % 60;

    if (hours <= 0) return mins + ' 分钟';
    if (mins <= 0) return hours + ' 小时';

    return hours + ' 小时 ' + mins + ' 分钟';
  }

  function readCache(config) {
    try {
      var raw = localStorage.getItem(config.cacheKey);
      if (!raw) return null;

      var cache = JSON.parse(raw);
      if (!cache || Date.now() - cache.timestamp > config.cacheTtl) return null;

      return Array.isArray(cache.data) ? cache.data : null;
    } catch (_) {
      return null;
    }
  }

  function writeCache(config, data) {
    try {
      localStorage.setItem(config.cacheKey, JSON.stringify({
        timestamp: Date.now(),
        data: data
      }));
    } catch (_) {
      // 隐私模式访问的话本地存储估计会失效
    }
  }
}());

然后在 主题设置PJAX RELOAD里加一行

initSteamGames();

即可。



6 未来可能的改进方向

  1. 尚未做主题字体的自适应,主要是觉得这个组件跟宋体不搭;
  2. 引入游戏名称汉化的API;
  3. 点击跳转对应的游戏商城页面;
  4. 添加加载动画,比如懒加载;
  5. 游戏封面源换成更稳定的SteamDB;
  6. 待补充。
返回文章列表 文章二维码
本页链接的二维码
打赏二维码