1 准备工作
1.1 环境要求
- PHP 7.2 或更高版本;
- PHP 扩展含
curl、json; - 服务器可正常访问
api.steampowered.com和store.steampowered.com。
1.2 获取Steam ID64
访问STEAMID I/O,在 input...处输入Steam个人主页直链,点击 lookup。
1.3 获取Steam API Key
前往Steam Web API密钥,绑定域(即你的网站域名),申请即可。
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/
.htaccess3 配置与应用
编辑 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_KEY和 STEAM_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预览
最近在玩ヾ(•ω•`)
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 未来可能的改进方向
- 尚未做主题字体的自适应,主要是觉得这个组件跟宋体不搭;
- 引入游戏名称汉化的API;
- 点击跳转对应的游戏商城页面;
- 添加加载动画,比如懒加载;
- 游戏封面源换成更稳定的SteamDB;
- 待补充。
|´・ω・) ノ未经允许,不能随便转载哦~