这篇文章将记录我如何用 PHP,从零搭建一个全自动、可部署在自己网站上的 Switch 游戏数据中心api。用来展示自己的游戏时长。

效果展示

或者这种

正在获取您的游玩数据...

一、 接口获取与逆向分析

任天堂官方并没有开放公开的 API。我们利用了社区逆向工程解析出的 Web API。

整个调用链路非常复杂,分为三步:

Python点击显示
import requests, json, base64, hashlib, re, sys, os

def NS_GetSessionToken(client_id, ua):
    session = requests.Session()
    auth_code_verifier = base64.urlsafe_b64encode(os.urandom(32))
    auth_cv_hash = hashlib.sha256()
    auth_cv_hash.update(auth_code_verifier.replace(b"=", b""))
    auth_code_challenge = base64.urlsafe_b64encode(auth_cv_hash.digest())
    app_head = {
        'Host': 'accounts.nintendo.com',
        'Connection': 'keep-alive',
        'Cache-Control': 'max-age=0',
        'Upgrade-Insecure-Requests': '1',
        'User-Agent': 'Mozilla/5.0 (Nintendo Switch; WebApplet) AppleWebKit/609.4 (KHTML, like Gecko) NF/6.0.2.15.4 NintendoBrowser/5.1.0.22433',
        'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
        'DNT': '1',
        'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
        'Accept-Encoding': 'gzip, deflate, br',
    }
    body = {
        'state': '',
        'redirect_uri': 'npf{}://auth'.format(client_id),
        'client_id': client_id,
        'scope': 'openid user user.mii user.email user.links[].id',
        'response_type': 'session_token_code',
        'session_token_code_challenge': auth_code_challenge.replace(b"=", b"").decode('utf-8'),
        'session_token_code_challenge_method': 'S256',
        'theme': 'login_form'
    }
    url = 'https://accounts.nintendo.com/connect/1.0.0/authorize'
    r = session.get(url, headers=app_head, params=body)
    post_login = r.history[0].url
    print("\n在浏览器打开这个链接:")
    print(post_login)
    print("\n登录你的账号,右键点击“选择此人”按钮,复制链接地址,粘贴到下面:")
    while True:
        try:
            use_account_url = input("")
            session_token_code = re.search('session_token_code=([^&]+)', use_account_url)
            if not session_token_code:
                print("链接格式不对,请重试:", end=' ')
                continue
                
            app_head = {
                'User-Agent': ua,
                'Accept-Language': 'en-US',
                'Accept': 'application/json',
                'Content-Type': 'application/x-www-form-urlencoded',
                'Host': 'accounts.nintendo.com',
                'Connection': 'Keep-Alive',
                'Accept-Encoding': 'gzip'
            }
            body = {
                'client_id': client_id,
                'session_token_code': session_token_code.group(1),
                'session_token_code_verifier': auth_code_verifier.replace(b"=", b"").decode('utf-8')
            }
            url = 'https://accounts.nintendo.com/connect/1.0.0/api/session_token'
            r = session.post(url, headers=app_head, data=body)
            print("HTTP状态:", r.status_code)
            if r.status_code == 200:
                session_token = json.loads(r.text).get("session_token")
                return session_token
            else:
                print("获取失败,响应:", r.text[:500])
                sys.exit(1)
        except KeyboardInterrupt:
            sys.exit(1)
        except Exception as e:
            print("发生错误:", str(e))

def NS_GetAccessToken(client_id, session_token):
    session = requests.Session()
    body = '{"client_id":"' + client_id + '","session_token":"' + session_token + \
        '","grant_type":"urn:ietf:params:oauth:grant-type:jwt-bearer-session-token"}'
    url = 'https://accounts.nintendo.com/connect/1.0.0/api/token'
    r = session.post(url, headers={'Content-Type': 'application/json'}, data=body)
    print("AccessToken HTTP状态:", r.status_code)
    if r.status_code != 200:
        print("AccessToken 响应:", r.text)
        sys.exit(1)
    return json.loads(r.text)

def NS_GetPlayHistory(access_token, ua):
    session = requests.Session()
    url = 'https://app-api.znej.nintendo.com/api/v2.0/users/me/play_histories'
    
    # 注意:这里更新了较新的 User-Agent
    header = {
        'Authorization': access_token['token_type'] + ' ' + access_token['access_token'],
        'User-Agent': ua, 
        'gentry-locale': 'jp-JP'
    }
    r = session.get(url, headers=header)
    print("PlayHistory HTTP状态:", r.status_code)
    print("PlayHistory 响应:")
    print(r.text)
    return r.text

if __name__ == "__main__":
    client_id = "5c38e31cd085304b"
    # 【重要修改】将 1.13.0 更新为较新的 3.3.1 版本
    ua = 'com.nintendo.znej/3.3.1 (Linux; Android 13)'
    
    print("--- 开始获取 Session Token ---")
    st = NS_GetSessionToken(client_id, ua)
    print("\n成功获取 session_token:", st)
    
    print("\n--- 开始获取 Access Token ---")
    at = NS_GetAccessToken(client_id, st)
    
    print("\n--- 开始获取游玩记录 ---")
    NS_GetPlayHistory(at, ua)
  1. 获取 session_token(有效期长达两年)。
  2. 用 session_token 换取有效期为 15 分钟的 access_token。
  3. 携带 access_token 请求 play_histories 获取游玩记录。

系统架构概览

1. 认证与基础配置

  • config.php:全局配置文件(数据库信息、客户端 ID、User-Agent)。
  • crypto.php:负责对核心凭证(Session Token)进行 AES-256-GCM 加密与解密。
  • bootstrap_token.php:用于首次手动注入 Session Token 的脚本。

2. 核心采集引擎

  • collect.php:拉取数据的核心。通过双刷(先用繁中触发更新,再拉简中)获取最新游玩时长与中文名字。

3. 数据接口层

  • API.php:对外提供 JSON 数据的接口。它具有“冷却机制”和“后台自动采集触发”能力,用于给前端网页提供数据。

4. 详情补充层

  • detail.php:接收 titleId 参数,去第三方开源接口拉取中文简介和价格信息,填补官方 API 不提供简介的空白。

config.php(基础环境)

作用:定义常量。
为什么这么做:将数据库密码、任天堂的 Client_ID 和 User_Agent 统一管理。如果你的服务器到期换新,只需修改这一个文件即可。
安全提醒:主密钥(Master Key)不要放在代码库中,而是放在 ../secret/master.key 里,防止被公开在 Web 目录下。

<?php
// ==========================================
// 1. 主密钥配置
// ==========================================
// 优先从环境变量读取
$masterKeyB64 = getenv('SWITCH_MASTER_KEY');
if (!$masterKeyB64) {
    // ⚠️ 请将这里的路径替换为你服务器上实际的绝对路径
    $keyFile = __DIR__ . '/../secret/master.key'; 
    if (!file_exists($keyFile)) {
        die("缺少主密钥文件\n");
    }
    $masterKeyB64 = trim(file_get_contents($keyFile));
}
define('MASTER_KEY', base64_decode($masterKeyB64)); // 32 字节

// ==========================================
// 2. 数据库配置(请替换为你宝塔里的真实信息)
// ==========================================
// ⚠️ 请将 dbname= 后面的内容替换为你的数据库名
define('DB_DSN', 'mysql:host=127.0.0.1;dbname=你的数据库名;charset=utf8mb4');

// ⚠️ 请替换为你的数据库用户名
define('DB_USER', '你的数据库用户名');

// ⚠️ 请替换为你的数据库密码
define('DB_PASS', '你的数据库密码');

// ==========================================
// 3. 任天堂客户端参数(固定值,请勿修改)
// ==========================================
define('NS_CLIENT_ID', '5c38e31cd085304b');
define('NS_UA', 'com.nintendo.znej/1.13.0 (Android/7.1.2)');

crypto.php(加密保护)

作用:加密和解密。
为什么这么做:任天堂的 session_token 有效期长达两年。如果你把它明文存在数据库里,一旦数据库被拖库,黑客就能直接登录你的任天堂账号。这里使用 AES-256-GCM 加密,即使数据库泄露,没有主密钥,他们也拿不到明文 Token。

<?php
require_once __DIR__ . '/config.php';

function encryptToken(string $plain): string {
    $iv = random_bytes(12);
    $tag = '';
    $cipher = openssl_encrypt(
        $plain, 'aes-256-gcm', MASTER_KEY, OPENSSL_RAW_DATA, $iv, $tag
    );
    if ($cipher === false) throw new RuntimeException('加密失败');
    // 存成 base64(iv + tag + cipher)
    return base64_encode($iv . $tag . $cipher);
}

function decryptToken(string $stored): string {
    $raw = base64_decode($stored, true);
    if ($raw === false || strlen($raw) < 28) throw new RuntimeException('密文格式错误');
    $iv  = substr($raw, 0, 12);
    $tag = substr($raw, 12, 16);
    $cipher = substr($raw, 28);
    $plain = openssl_decrypt(
        $cipher, 'aes-256-gcm', MASTER_KEY, OPENSSL_RAW_DATA, $iv, $tag
    );
    if ($plain === false) throw new RuntimeException('解密失败');
    return $plain;
}

bootstrap_token.php(首次授权)

作用:将你通过 Python 获取的长期 session_token 加密存入数据库。
为什么这么做:这是整个系统的起点。你只需要在服务器终端运行一次 php bootstrap_token.php,粘贴那串 eyJhbGci...,它就会调用 crypto.php 加密并存入 switch_token 表。

<?php
require_once __DIR__ . '/crypto.php';

$pdo = new PDO(DB_DSN, DB_USER, DB_PASS, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$pdo->exec("CREATE TABLE IF NOT EXISTS switch_token (
    id TINYINT PRIMARY KEY,
    token_enc TEXT NOT NULL,
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");

echo "粘贴你的 session_token:";
$token = trim(fgets(STDIN));
if ($token === '') exit("token 为空\n");

$stmt = $pdo->prepare("REPLACE INTO switch_token (id, token_enc) VALUES (1, ?)");
$stmt->execute([encryptToken($token)]);
echo "已加密写入数据库\n";

collect.php(核心拉取器)

作用:拉取游玩记录并写入数据库。
为什么这么做:

  • 增量写入:不仅会全量覆盖历史统计表 dim_switch_game_play_history,还会根据“总时长减去历史累计”,将新增的游玩时长增量写入明细表 dwd_switch_game_played_record。
<?php
require_once __DIR__ . '/crypto.php';

function httpRequest(string $method, string $url, array $headers = [], ?string $body = null): array {
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CUSTOMREQUEST  => $method,
        CURLOPT_HTTPHEADER     => $headers,
        CURLOPT_TIMEOUT        => 30,
    ]);
    if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
    $resp = curl_exec($ch);
    $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $err  = curl_error($ch);
    curl_close($ch);
    if ($resp === false) throw new RuntimeException("cURL 错误: $err");
    return [$code, $resp];
}

function getAccessToken(string $sessionToken): array {
    $body = json_encode([
        'client_id'     => NS_CLIENT_ID,
        'session_token' => $sessionToken,
        'grant_type'    => 'urn:ietf:params:oauth:grant-type:jwt-bearer-session-token',
    ]);
    [$code, $resp] = httpRequest('POST', 'https://accounts.nintendo.com/connect/1.0.0/api/token', [
        'Content-Type: application/json',
    ], $body);
    if ($code !== 200) throw new RuntimeException("换 access_token 失败: HTTP $code $resp");
    return json_decode($resp, true);
}

// 🌟 修改点 1:增加 $locale 参数,允许指定语言
function getPlayHistory(array $accessToken, string $locale = 'zh-CN'): array {
    [$code, $resp] = httpRequest('GET',
        'https://app-api.znej.nintendo.com/api/v2.0/users/me/play_histories',
        [
            'Authorization: ' . $accessToken['token_type'] . ' ' . $accessToken['access_token'],
            'User-Agent: ' . NS_UA,
            // 使用传入的语言
            'gentry-locale: ' . $locale, 
        ]
    );
    if ($code !== 200) throw new RuntimeException("拉取记录失败: HTTP $code $resp");
    return json_decode($resp, true);
}

function utcToBeijing(string $utc): string {
    $dt = new DateTime($utc, new DateTimeZone('UTC'));
    $dt->setTimezone(new DateTimeZone('Asia/Shanghai'));
    return $dt->format('Y-m-d H:i:s');
}

// ---- 主流程 ----
$pdo = new PDO(DB_DSN, DB_USER, DB_PASS, [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);

// 1. 从数据库读取加密的 session_token
$row = $pdo->query("SELECT token_enc FROM switch_token WHERE id=1")->fetch();
if (!$row) {
    fwrite(STDERR, "数据库中没有 session_token,请先运行 bootstrap_token.php 写入\n");
    exit(1);
}
$sessionToken = decryptToken($row['token_enc']);

// 2. 拉数据
$accessToken = getAccessToken($sessionToken);

// 🌟 修改点 2:执行你的“双刷”逻辑
try {
    // 第一步:用繁体中文 (zh-TW) 触发后台更新
    // 我们不需要它的返回结果,只要它去请求一下,任天堂服务器就会去拉取最新数据
    getPlayHistory($accessToken, 'zh-TW');
    
    // 第二步:用简体中文 (zh-CN) 正式拉取最新数据
    // 此时因为第一步已经触发了更新,这里拿到的就是最新的简体数据了
    $history = getPlayHistory($accessToken, 'zh-CN');
    
} catch (Exception $e) {
    // 如果繁体拉取失败(比如偶尔网络波动),为了不影响主流程,我们可以直接回退到简体拉取
    // 或者你也可以直接把错误抛出去
    $history = getPlayHistory($accessToken, 'zh-CN');
}

// 3. 写入历史统计表(整体替换)
$pdo->beginTransaction();
$pdo->exec("DELETE FROM dim_switch_game_play_history");
$stmt = $pdo->prepare("
    INSERT INTO dim_switch_game_play_history
        (titleId, titleName, platform, imageUrl, lastUpdatedAt,
     firstPlayedAt, lastPlayedAt, totalPlayedDays, totalPlayedMinutes)
    VALUES (?,?,?,?,?,?,?,?,?)
");
foreach ($history['playHistories'] as $g) {
    $stmt->execute([
        $g['titleId'],
        $g['titleName'],
        $g['platform'] ?? '',
        $g['imageUrl'],
        utcToBeijing($g['lastUpdatedAt']),
        utcToBeijing($g['firstPlayedAt']),
        utcToBeijing($g['lastPlayedAt']),
        (int)$g['totalPlayedDays'],
        (int)$g['totalPlayedMinutes'],
    ]);
}
$pdo->commit();

// 4. 增量写入明细表
$pdo->exec("
    INSERT INTO dwd_switch_game_played_record
    SELECT NULL, t1.titleId, t1.titleName, t1.lastPlayedAt,
           COALESCE(t1.totalPlayedMinutes - t2.play_time, t1.totalPlayedMinutes),
           NOW(), NOW()
    FROM dim_switch_game_play_history t1
    LEFT JOIN (
        SELECT title_id, SUM(play_time) AS play_time, MAX(last_played_at) AS last_played_at
        FROM dwd_switch_game_played_record GROUP BY title_id
    ) t2 ON t1.titleId = t2.title_id
    WHERE t2.last_played_at IS NULL OR t1.lastPlayedAt != t2.last_played_at
");

echo "采集完成,共 " . count($history['playHistories']) . " 款游戏\n";

API.php(对外接口)

作用:对前端提供 JSON 数据。
为什么这么做:

  • 避免卡顿:如果前端直接去请求 collect.php,一旦任天堂接口抽风,整个网页就会卡死。API.php 只负责从数据库读数据,毫秒级返回。
  • 自动触发:它内置了“冷却时间”(例如1小时)。当有用户访问网页时,它会检查上次更新数据的时间。如果超过1小时,它会在后台静默启动一个进程去执行 collect.php,然后立刻把旧的数据库数据返回给前端。实现“用户无感知,后台悄咪咪更新”。
<?php
// 允许任何域名跨域访问(方便本地测试和前端接入)
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: GET, POST, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type");

require_once __DIR__ . '/config.php';
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/crypto.php';
header('Content-Type: application/json; charset=utf-8');

// 设定冷却时间(单位:秒)。3600 表示 1 小时。
// 也就是说,无论网页被访问多少次,1小时内只会去任天堂拉取一次数据。
$cooldown_seconds = 1;

try {
    $pdo = new PDO(DB_DSN, DB_USER, DB_PASS, [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
    ]);

    // 1. 检查是否到了可以采集的时间
    // 我们直接利用 switch_token 表的 updated_at 字段,或者新建一个状态表。这里为了简单,直接查表里最新的一条记录时间。
    // 实际上,为了不破坏原有的 token 表,我们查一下明细表最后一次插入的时间即可。
    $lastUpdate = $pdo->query("
        SELECT MAX(update_time) FROM dwd_switch_game_played_record
    ")->fetchColumn();

    $now = time();
    $lastUpdateTime = $lastUpdate ? strtotime($lastUpdate) : 0;

    // 如果距离上次更新超过冷却时间,则触发采集
    if (($now - $lastUpdateTime) > $cooldown_seconds) {
        
        // 为了防止并发访问导致同时采集,使用文件锁
        $lockFile = __DIR__ . '/collect.lock';
        $fp = fopen($lockFile, 'w');
        
        // 尝试获取排他锁 (非阻塞)
        if (flock($fp, LOCK_EX | LOCK_NB)) {
            // 获取到锁,说明当前没有其他请求在采集,开始执行采集脚本
            // 使用 exec 在后台静默执行,避免阻塞网页响应
            $phpPath = '/usr/bin/php'; // 你的 PHP CLI 路径
            $scriptPath = __DIR__ . '/collect.php';
            
            // 如果是直接同步执行(阻塞网页直到采集完),用下面的代码:
            // exec("$phpPath $scriptPath > /dev/null 2>&1");
            
            // 如果是后台异步执行(网页秒开,后台慢慢采),加上 & 符号:
            exec("$phpPath $scriptPath > /dev/null 2>&1 &");
            
            // 释放锁
            flock($fp, LOCK_UN);
        }
        fclose($fp);
    }

    // 2. 从数据库查询最新数据并返回给前端
    // 注意:如果是刚触发采集,可能数据还没入库。这里读的是上次采集的数据。
    $rows = $pdo->query("
        SELECT titleId, titleName, platform, imageUrl, lastPlayedAt,
               totalPlayedDays, totalPlayedMinutes
        FROM dim_switch_game_play_history
        ORDER BY lastPlayedAt DESC
    ")->fetchAll(PDO::FETCH_ASSOC);

    // 3. 输出 JSON
    echo json_encode([
        'code' => 200,
        'msg' => 'success',
        'data' => $rows
    ], JSON_UNESCAPED_UNICODE);

} catch (Exception $e) {
    http_response_code(500);
    echo json_encode([
        'code' => 500,
        'msg' => '服务器错误: ' . $e->getMessage()
    ], JSON_UNESCAPED_UNICODE);
}

如何部署?(环境要求:PHP 7.4+,MySQL 5.7+,宝塔面板)

建库与建表

在宝塔中创建一个数据库。导入以下表结构:

  • dim_switch_game_play_history(记录所有游戏的总体数据)
  • dwd_switch_game_played_record(记录每次游玩的明细)
  • switch_token(存储加密的 Token)

你只需要在宝塔的 “数据库” -> 找到你的库 -> “管理 (phpMyAdmin)” -> 点击顶部的“SQL”标签页,把下面的代码全部粘贴进去,点击“执行”即可。

-- 1. 存储加密 Token 的表
CREATE TABLE IF NOT EXISTS `switch_token` (
  `id` TINYINT PRIMARY KEY,
  `token_enc` TEXT NOT NULL,
  `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 2. 记录所有游戏的总体数据表(维表)
CREATE TABLE IF NOT EXISTS `dim_switch_game_play_history` (
  `titleId` varchar(255) NOT NULL,
  `titleName` varchar(255) DEFAULT NULL,
  `platform` varchar(64) DEFAULT NULL,
  `imageUrl` varchar(512) DEFAULT NULL,
  `lastUpdatedAt` datetime DEFAULT NULL,
  `firstPlayedAt` datetime DEFAULT NULL,
  `lastPlayedAt` datetime DEFAULT NULL,
  `totalPlayedDays` int DEFAULT NULL,
  `totalPlayedMinutes` int DEFAULT NULL,
  `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
  `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`titleId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 3. 记录每次游玩的明细表(事实表,用于增量计算)
CREATE TABLE IF NOT EXISTS `dwd_switch_game_played_record` (
  `id` int NOT NULL AUTO_INCREMENT,
  `title_id` varchar(255) DEFAULT NULL,
  `title_name` varchar(255) DEFAULT NULL,
  `last_played_at` datetime DEFAULT NULL,
  `play_time` int DEFAULT NULL,
  `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
  `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

配置 config.php

修改里面的数据库连接信息、账号、密码。

生成主密钥

在终端执行:

php -r "echo base64_encode(random_bytes(32)), PHP_EOL;"

将输出的字符串写入到 ../secret/master.key 文件中,并赋予读写权限(chmod 600)。

获取 session_token 并注入

用你之前的 Python 脚本获取 session_token,然后在服务器执行:

php bootstrap_token.php

把 Token 粘贴进去。

测试采集

执行:

php collect.php

如果终端输出 采集完成,共 xx 款游戏,说明数据已经成功入库。

访问https://你的域名/API/xxxxxx/API.php 就可以返回数据啦!

数据结构

整个数据结构分为外层状态控制和内层数据列表。

  • code: 200:HTTP 状态码,表示请求成功(如果失败可能会是 400、500 等)。
  • msg: "success":请求结果的文字描述,成功就是 success。
  • data:这是一个数组(Array),里面包含了所有的游戏记录。前端 JavaScript 就是通过循环遍历这个 data 数组,把一个个游戏卡片渲染到网页上的。

data 数组里的每一个 { ... } 对象,代表一款游戏的详细信息。以一个游戏为例:

{
  "titleId": "010062b01525c000",
  "titleName": "PERSONA4 The GOLDEN",
  "platform": "HAC",
  "imageUrl": "https://atum-img-lp1.cdn.nintendo.net/i/c/...512",
  "lastPlayedAt": "2026-11-12 00:24:54",
  "totalPlayedDays": 16,
  "totalPlayedMinutes": 1149
}

各个字段的详细含义如下:

1. titleId(字符串)
这是游戏在任天堂服务器中的唯一标识码。前端在渲染时,通常用它来做卡片的唯一 ID(例如 id="game-010062b01525c000"),以防止 DOM 冲突。

2. titleName(字符串)
游戏的完整官方名称,前端直接将其显示在卡片的标题区域。需要注意的是,部分游戏的名字非常长(比如“超级马力欧兄弟 惊奇…”),前端必须配置 CSS 的省略号(text-overflow: ellipsis)或跑马灯效果,否则很容易撑破页面布局。

3. platform(字符串)
代表该游戏运行所在的设备平台。前端可以通过判断这个字段来决定显示哪种图标:如果值是 "HAC",代表是 Switch 1 游戏,显示一代卡带图标;如果值是 "BEE",则代表是 Switch 2 游戏,显示二代卡带图标。

4. imageUrl(字符串)
这是游戏官方封面的图片直链。可以直接用作 <img> 标签的 src 属性。注意,JSON 字符串里的斜杠会带有转义符(例如 https:\/\/...),但前端的 JavaScript 在解析 JSON 后,会自动将其处理成正常的链接格式。

5. lastPlayedAt(字符串)
记录玩家最后一次游玩该游戏的时间。格式为 年-月-日 时:分:秒。前端常截取前半部分(日期)用于在详情弹窗中展示“最后游玩时间”。

6. totalPlayedDays(数字)
累计游玩该游戏的天数。前端直接读取该数字,并在详情页显示“游玩天数:X 天”。

7. totalPlayedMinutes(数字)
累计游玩的总分钟数。这是前端展示时间的核心计算字段。因为直接显示分钟数(如 1149 分钟)不够直观,前端通常会用代码将其换算成小时和分钟: