SGLV ITAD Library

SGLV ITAD (IsThereAnyDeal) API 封装库,统一封装 ITAD 价格相关接口。

이 스크립트는 직접 설치하는 용도가 아닙니다. 다른 스크립트에서 메타 지시문 // @require https://update.greasyfork.org/scripts/590418/1896925/SGLV%20ITAD%20Library.js을(를) 사용하여 포함하는 라이브러리입니다.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         SGLV ITAD Library
// @namespace    sglv-lib
// @version      1.0.2
// @description  SGLV ITAD (IsThereAnyDeal) API 封装库,统一封装 ITAD 价格相关接口。
//               端点覆盖:lookup/id (AppID→GameID) / games/overview (当前最优+史低+Bundle) / games/prices (多商店当前价) /
//               games/history (价格历史) / games/historylow (史低) / games/storelow (单商店最低) / service/shops (商店列表) / testConnection (密钥验证)。
//               设计目标:UI 无关、零业务依赖、可被多个插件复用、与 SGLVCore 网络层解耦。
//               暴露 SGLVITAD:init / setApiKey / hasApiKey / lookupItadId / fetchPriceOverview / fetchPrices /
//               fetchMultiRegionPrices / fetchPriceHistory / fetchHistoryLow / fetchStoreLow / getShops / testConnection / clearCache。
//               参考项目:https://github.com/sys1em/Steam_Buff (shared/config.js) / https://docs.isthereanydeal.com/
// @author       SGLV
// @noframes
// ==/UserScript==

/*
 * SGLV ITAD Library v1.0.0
 *
 * 模块切分:
 *   1) 网络层     _httpXhr / _getJson / _postJson (gmFetchJson 优先, 降级 GM_xmlhttpRequest / fetch)
 *   2) 缓存层     _cacheGet / _cacheSet (GM_setValue 持久化, TTL 过期自动清理)
 *   3) ID 映射层   lookupItadId / lookupItadIds (Steam AppID → ITAD GameID, 批量/单个)
 *   4) 价格概览层  fetchPriceOverview (overview/v2: 当前最优 + 历史最低 + 活跃 Bundle)
 *   5) 多商店价格层 fetchPrices / fetchMultiRegionPrices (prices/v3: 多商店/多区域当前价)
 *   6) 历史价格层  fetchPriceHistory (history/v2: 价格变更日志 + 折扣去重)
 *   7) 史低/单店低  fetchHistoryLow / fetchStoreLow
 *   8) 工具层     getShops / testConnection
 *   9) 归一化层   normalizeOverview / normalizePrices / normalizeHistory / normalizePrice
 *
 * 设计契约:
 *   - 库不依赖 window.SGLVCore 之外的其他 SGLV 库
 *   - API Key 由宿主通过 init()/setApiKey() 注入,库自身不读取 GM 存储
 *   - 不引入任何 UI 渲染逻辑,仅返回结构化数据
 *   - 所有响应经过归一化处理,保证数据结构一致性
 *   - 缓存键前缀 sglv_itad_,可通过 init({ cachePrefix }) 自定义
 */

(function (root) {
    'use strict';

    // 防止重复挂载
    if (root.SGLVITAD && root.SGLVITAD.version === '1.0.2') return;

    // ==================== 常量 ====================
    const VERSION = '1.0.2';

    const ITAD_BASE_URL = 'https://api.isthereanydeal.com';
    const ITAD_SHOP_STEAM = 61; // Steam 商店 ID

    // 缓存 TTL (可被 init() 覆盖)
    const DEFAULT_TTL = {
        lookup: 24 * 3600 * 1000,        // AppID→GameID 映射: 24h (映射关系不变)
        overview: 2 * 3600 * 1000,       // 价格概览: 2h (价格变化较频繁)
        prices: 2 * 3600 * 1000,         // 多商店/多区域价格: 2h
        history: 6 * 3600 * 1000,        // 价格历史: 6h
        historyLow: 6 * 3600 * 1000,     // 史低: 6h
        storeLow: 6 * 3600 * 1000,       // 单商店最低: 6h
        shops: 7 * 24 * 3600 * 1000,     // 商店列表: 7天 (极少变化)
        info: 7 * 24 * 3600 * 1000,      // 游戏信息: 7天
    };

    const DEFAULT_TIMEOUT = 15000;
    const HISTORY_TIMEOUT = 20000;
    const MAX_BATCH = 200; // ITAD API 单次最多 200 个 GameID

    // ==================== 状态 ====================
    let _apiKey = '';
    let _ttl = Object.assign({}, DEFAULT_TTL);
    let _cachePrefix = 'sglv_itad_';

    // ==================== 网络层 ====================
    // 降级路径: 无 SGLVCore 时用本地 GM_xmlhttpRequest + fetch
    // 与 SGLVBadge 网络层设计一致
    // 注意: SGLVCore.gmFetchText 硬编码 method:'GET',不支持 POST;
    //       JSON 请求必须走 gmFetchJson (正确传递 method/headers/data) 或本地 _httpXhr

    function _httpXhr(opts) {
        return new Promise((resolve, reject) => {
            if (typeof GM_xmlhttpRequest === 'function') {
                GM_xmlhttpRequest({
                    method: opts.method || 'GET',
                    url: opts.url,
                    headers: opts.headers || {},
                    data: opts.data,
                    timeout: opts.timeout || DEFAULT_TIMEOUT,
                    anonymous: false,
                    onload(r) {
                        if (r.status >= 200 && r.status < 300) resolve(r);
                        else reject(new Error('HTTP ' + r.status));
                    },
                    onerror: () => reject(new Error('network error')),
                    ontimeout: () => reject(new Error('timeout')),
                });
                return;
            }
            // fetch 降级 (非油猴环境, 如测试)
            const ctl = (typeof AbortController === 'function') ? new AbortController() : null;
            const timer = ctl ? setTimeout(() => ctl.abort(), opts.timeout || DEFAULT_TIMEOUT) : null;
            fetch(opts.url, {
                method: opts.method || 'GET',
                headers: opts.headers || {},
                body: opts.data,
                signal: ctl ? ctl.signal : undefined,
            })
                .then(r => {
                    if (timer) clearTimeout(timer);
                    if (!r.ok) throw new Error('HTTP ' + r.status);
                    return r.text();
                })
                .then(text => resolve({ status: 200, responseText: text }))
                .catch(err => { if (timer) clearTimeout(timer); reject(err); });
        });
    }

    async function _getJson(url, opts) {
        // 优先使用 SGLVCore.gmFetchJson — 正确传递 method/headers/data, 支持 POST
        if (root.SGLVCore && root.SGLVCore.gmFetchJson) {
            return root.SGLVCore.gmFetchJson(url, opts || {});
        }
        // 降级: 本地 GM_xmlhttpRequest / fetch
        const r = await _httpXhr(Object.assign({ url }, opts || {}));
        try { return JSON.parse(r.responseText); } catch (e) { throw new Error('invalid JSON'); }
    }

    async function _postJson(url, body, opts) {
        return _getJson(url, Object.assign({
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            data: JSON.stringify(body),
        }, opts || {}));
    }

    // ==================== 缓存层 ====================
    // 基于 GM_setValue 的 TTL 缓存, 与主脚本 cacheGet/cacheSet 模式一致

    function _cacheGet(key) {
        if (typeof GM_getValue !== 'function') return null;
        try {
            const raw = GM_getValue(_cachePrefix + key, '');
            if (!raw) return null;
            const obj = typeof raw === 'string' ? JSON.parse(raw) : raw;
            if (obj.expires && Date.now() > obj.expires) {
                if (typeof GM_setValue === 'function') GM_setValue(_cachePrefix + key, '');
                return null;
            }
            return obj.data;
        } catch { return null; }
    }

    function _cacheSet(key, data, ttl) {
        if (typeof GM_setValue !== 'function') return;
        try {
            GM_setValue(_cachePrefix + key, JSON.stringify({ data, expires: Date.now() + ttl }));
        } catch { /* ignore quota errors */ }
    }

    // ==================== URL 构建 ====================
    // 使用字符串拼接 (遵循项目约定: 避免 new URL() 相对路径问题)

    function _buildUrl(path, params) {
        let url = ITAD_BASE_URL + path + '?key=' + encodeURIComponent(_apiKey);
        if (params) {
            for (const k of Object.keys(params)) {
                if (params[k] != null && params[k] !== '') {
                    url += '&' + encodeURIComponent(k) + '=' + encodeURIComponent(String(params[k]));
                }
            }
        }
        return url;
    }

    // ==================== ID 映射层 ====================

    /**
     * Steam AppID → ITAD GameID (单个)
     * POST /lookup/id/shop/{shopId}/v1
     *
     * @param {number|string} appId - Steam App ID
     * @param {number} shopId - 商店 ID (默认 61 = Steam)
     * @returns {Promise<string|null>} ITAD Game UUID, 未找到返回 null
     */
    async function lookupItadId(appId, shopId) {
        shopId = shopId || ITAD_SHOP_STEAM;
        const formattedId = 'app/' + appId;
        const cacheKey = 'lookup_' + shopId + '_' + appId;
        const cached = _cacheGet(cacheKey);
        if (cached !== null) return cached;

        try {
            const data = await _postJson(
                ITAD_BASE_URL + '/lookup/id/shop/' + shopId + '/v1?key=' + encodeURIComponent(_apiKey),
                [formattedId],
                { timeout: DEFAULT_TIMEOUT }
            );
            const itadId = (data && data[formattedId]) ? data[formattedId] : null;
            if (itadId) _cacheSet(cacheKey, itadId, _ttl.lookup);
            return itadId;
        } catch (e) {
            console.warn('[SGLVITAD] lookupItadId failed:', e.message);
            return null;
        }
    }

    /**
     * Steam AppID → ITAD GameID (批量)
     * POST /lookup/id/shop/{shopId}/v1
     *
     * @param {Array<number|string>} appIds - Steam App IDs (最多 200)
     * @param {number} shopId - 商店 ID
     * @returns {Promise<Object>} { "app/123": "uuid", "app/456": null, ... }
     */
    async function lookupItadIds(appIds, shopId) {
        shopId = shopId || ITAD_SHOP_STEAM;
        const batch = appIds.slice(0, MAX_BATCH);
        const formatted = batch.map(function (id) { return 'app/' + id; });
        try {
            const data = await _postJson(
                ITAD_BASE_URL + '/lookup/id/shop/' + shopId + '/v1?key=' + encodeURIComponent(_apiKey),
                formatted,
                { timeout: DEFAULT_TIMEOUT }
            );
            return data || {};
        } catch (e) {
            console.warn('[SGLVITAD] lookupItadIds failed:', e.message);
            return {};
        }
    }

    // ==================== 游戏信息 ====================

    /**
     * 获取游戏详细信息 (发行日期、标题等)
     * GET /games/info/v2
     *
     * @param {string} itadId - ITAD Game UUID
     * @returns {Promise<Object|null>} { title, slug, releaseDate, ... }
     */
    async function fetchGameInfo(itadId) {
        const cacheKey = 'info_' + itadId;
        const cached = _cacheGet(cacheKey);
        if (cached) return cached;

        try {
            const data = await _getJson(
                _buildUrl('/games/info/v2', { id: itadId }),
                { timeout: DEFAULT_TIMEOUT }
            );
            _cacheSet(cacheKey, data, _ttl.info);
            return data;
        } catch (e) {
            console.warn('[SGLVITAD] fetchGameInfo failed:', e.message);
            return null;
        }
    }

    // ==================== 价格概览层 (核心增强端点) ====================

    /**
     * 价格概览: 当前最优价 + 历史最低 + 活跃 Bundle (一次调用全覆盖)
     * POST /games/overview/v2
     *
     * @param {string|Array<string>} itadIds - ITAD Game UUID(s), 最多 200
     * @param {Object} opts - { country, shops }
     *   - country: ISO 3166-1 alpha-2, 默认 'US'
     *   - shops: 商店 ID 数组, 如 [61] 仅 Steam; 不传则全部商店
     * @returns {Promise<Object|null>} 归一化后的 { prices: [...], bundles: [...] }
     */
    async function fetchPriceOverview(itadIds, opts) {
        opts = opts || {};
        const country = opts.country || 'US';

        const ids = Array.isArray(itadIds) ? itadIds.slice(0, MAX_BATCH) : [itadIds];
        const cacheKey = 'overview_' + country + '_' + ids.slice().sort().join(',');
        const cached = _cacheGet(cacheKey);
        if (cached) return cached;

        // v1.0.2: 仅发送 API 文档确认的参数, 移除 vouchers (非有效参数导致 400)
        const params = { country: country };
        if (opts.shops && opts.shops.length) params.shops = opts.shops.join(',');

        try {
            const data = await _postJson(_buildUrl('/games/overview/v2', params), ids, { timeout: DEFAULT_TIMEOUT });
            const result = normalizeOverview(data);
            _cacheSet(cacheKey, result, _ttl.overview);
            return result;
        } catch (e) {
            // v1.0.2: 包含 country 便于定位无效区域代码
            console.warn('[SGLVITAD] fetchPriceOverview failed (country=' + country + '):', e.message);
            return null;
        }
    }

    // ==================== 多商店/多区域价格层 ====================

    /**
     * 多商店当前价格 (含历史最低)
     * POST /games/prices/v3
     *
     * @param {string|Array<string>} itadIds - ITAD Game UUID(s)
     * @param {Object} opts - { country, deals, capacity, shops }
     *   - country: ISO 3166-1 alpha-2, 默认 'US'
     *   - deals: 仅返回有折扣的价格, 默认 false (仅 true 时发送)
     *   - capacity: 每个游戏返回多少条价格 (0=不限, 默认)
     *   - shops: 商店 ID 数组, 如 [61] 仅 Steam
     * @returns {Promise<Array|null>} 归一化后的价格数组
     */
    async function fetchPrices(itadIds, opts) {
        opts = opts || {};
        const country = opts.country || 'US';
        const deals = opts.deals || false;
        const capacity = opts.capacity || 0;

        const ids = Array.isArray(itadIds) ? itadIds.slice(0, MAX_BATCH) : [itadIds];
        // v1.0.2: 仅发送 API 文档确认的参数, 移除 vouchers/deals=false (导致 400)
        const params = { country: country };
        if (deals) params.deals = true;       // 仅 deals=true 时发送
        if (capacity > 0) params.capacity = capacity;  // 仅 capacity>0 时发送
        if (opts.shops && opts.shops.length) params.shops = opts.shops.join(',');

        try {
            const data = await _postJson(_buildUrl('/games/prices/v3', params), ids, { timeout: DEFAULT_TIMEOUT });
            return normalizePrices(data);
        } catch (e) {
            // v1.0.2: 包含 country 便于定位无效区域代码 (如 EU 非有效 ISO 3166-1 alpha-2)
            console.warn('[SGLVITAD] fetchPrices failed (country=' + country + '):', e.message);
            return null;
        }
    }

    /**
     * 多区域 Steam 价格: 对多个 country 并发调用 prices/v3 (shops=[61] 仅 Steam)
     * 内部使用 mapLimit 模式限流并发, 与主脚本 fetchMultiRegionPrices 设计一致
     *
     * @param {string} itadId - ITAD Game UUID
     * @param {Array<string>} countries - ISO 3166-1 alpha-2 国家代码数组
     * @param {Object} opts - { concurrency, shops }
     * @returns {Promise<Object>} { CN: { deals: [...] }, US: { deals: [...] }, ... }
     */
    async function fetchMultiRegionPrices(itadId, countries, opts) {
        opts = opts || {};
        const concurrency = opts.concurrency || 3;
        const shops = opts.shops || [ITAD_SHOP_STEAM];
        const results = {};

        // 简单 mapLimit 实现 (与主脚本 mapLimit 模式一致)
        const queue = (countries || ['US', 'CN', 'TR', 'AR', 'RU', 'IN', 'BR', 'UA', 'KZ']).slice();
        let active = 0;
        let idx = 0;

        return new Promise(function (resolve) {
            function runNext() {
                while (active < concurrency && idx < queue.length) {
                    const country = queue[idx++];
                    active++;
                    fetchPrices([itadId], { country: country, shops: shops })
                        .then(function (data) {
                            results[country] = (data && data[0]) ? data[0] : null;
                        })
                        .catch(function () { results[country] = null; })
                        .finally(function () {
                            active--;
                            if (idx < queue.length) runNext();
                            else if (active === 0) resolve(results);
                        });
                }
                if (active === 0 && idx >= queue.length) resolve(results);
            }
            runNext();
        });
    }

    // ==================== 历史价格层 ====================

    /**
     * 价格历史 + 折扣去重 (seenCuts 逻辑)
     * GET /games/history/v2
     *
     * 先尝试不带 since 参数 (默认近 3 个月), 无数据则尝试 5 年范围
     *
     * @param {string} itadId - ITAD Game UUID
     * @param {Object} opts - { country, shops, since }
     * @returns {Promise<Object>} 归一化后的 { history, discounts, lowest }
     */
    async function fetchPriceHistory(itadId, opts) {
        opts = opts || {};
        const country = opts.country || 'CN';
        const shops = opts.shops || [ITAD_SHOP_STEAM];

        const cacheKey = 'history_' + country + '_' + itadId;
        const cached = _cacheGet(cacheKey);
        if (cached) return cached;

        const params = { id: itadId, country: country, shops: shops.join(',') };
        if (opts.since) params.since = opts.since;

        try {
            // 先尝试不带 since 参数
            let data = await _getJson(_buildUrl('/games/history/v2', params), { timeout: HISTORY_TIMEOUT });
            if (!data || !data.length) {
                // 尝试带 since 参数获取更早数据 (5 年前)
                var sinceDate = new Date(Date.now() - 365 * 5 * 86400000).toISOString().replace(/\.\d+Z$/, '+00:00');
                params.since = sinceDate;
                data = await _getJson(_buildUrl('/games/history/v2', params), { timeout: HISTORY_TIMEOUT });
            }
            var result = normalizeHistory(data || []);
            _cacheSet(cacheKey, result, _ttl.history);
            return result;
        } catch (e) {
            console.warn('[SGLVITAD] fetchPriceHistory failed:', e.message);
            return { history: [], discounts: [], lowest: null };
        }
    }

    // ==================== 史低 / 单商店最低 ====================

    /**
     * 历史最低价 (批量)
     * POST /games/historylow/v1
     *
     * @param {string|Array<string>} itadIds - ITAD Game UUID(s)
     * @param {Object} opts - { country }
     * @returns {Promise<Object|null>} { "uuid": { amount, currency, ... }, ... }
     */
    async function fetchHistoryLow(itadIds, opts) {
        opts = opts || {};
        var country = opts.country || 'US';
        var ids = Array.isArray(itadIds) ? itadIds.slice(0, MAX_BATCH) : [itadIds];

        var cacheKey = 'historylow_' + country + '_' + ids.slice().sort().join(',');
        var cached = _cacheGet(cacheKey);
        if (cached) return cached;

        try {
            var data = await _postJson(_buildUrl('/games/historylow/v1', { country: country }), ids, { timeout: DEFAULT_TIMEOUT });
            _cacheSet(cacheKey, data, _ttl.historyLow);
            return data;
        } catch (e) {
            console.warn('[SGLVITAD] fetchHistoryLow failed:', e.message);
            return null;
        }
    }

    /**
     * 单商店最低价 (批量)
     * POST /games/storelow/v2
     *
     * @param {string|Array<string>} itadIds - ITAD Game UUID(s)
     * @param {Object} opts - { country, shops }
     * @returns {Promise<Object|null>}
     */
    async function fetchStoreLow(itadIds, opts) {
        opts = opts || {};
        var country = opts.country || 'US';
        var shops = opts.shops;
        var ids = Array.isArray(itadIds) ? itadIds.slice(0, MAX_BATCH) : [itadIds];

        var params = { country: country };
        if (shops && shops.length) params.shops = shops.join(',');

        try {
            var data = await _postJson(_buildUrl('/games/storelow/v2', params), ids, { timeout: DEFAULT_TIMEOUT });
            return data;
        } catch (e) {
            console.warn('[SGLVITAD] fetchStoreLow failed:', e.message);
            return null;
        }
    }

    // ==================== 工具层 ====================

    /**
     * 获取所有商店列表
     * GET /service/shops/v1
     *
     * @returns {Promise<Array|null>} [{ id, name, ... }, ...]
     */
    async function getShops() {
        var cacheKey = 'shops';
        var cached = _cacheGet(cacheKey);
        if (cached) return cached;

        try {
            var data = await _getJson(_buildUrl('/service/shops/v1'), { timeout: DEFAULT_TIMEOUT });
            _cacheSet(cacheKey, data, _ttl.shops);
            return data;
        } catch (e) {
            console.warn('[SGLVITAD] getShops failed:', e.message);
            return null;
        }
    }

    /**
     * 测试 API Key 连接 (调用 /service/shops/v1 验证)
     *
     * @param {string} [key] - 待测试的 Key (不传则使用当前已设置的 Key)
     * @returns {Promise<Object>} { success: boolean, error?: string, shopCount?: number }
     */
    async function testConnection(key) {
        var testKey = key || _apiKey;
        if (!testKey) return { success: false, error: 'No API key provided' };

        try {
            var url = ITAD_BASE_URL + '/service/shops/v1?key=' + encodeURIComponent(testKey);
            var data = await _getJson(url, { timeout: 10000 });
            return { success: true, shopCount: Array.isArray(data) ? data.length : 0 };
        } catch (e) {
            var match = e.message.match(/HTTP (\d+)/);
            if (match && (match[1] === '401' || match[1] === '403')) {
                return { success: false, error: 'Invalid API key (HTTP ' + match[1] + ')' };
            }
            return { success: false, error: e.message };
        }
    }

    // ==================== 响应归一化层 ====================

    function normalizePrice(p) {
        if (!p) return null;
        return {
            amount: p.amount || 0,
            amountInt: p.amountInt || 0,
            currency: p.currency || '',
        };
    }

    /**
     * 归一化 overview/v2 响应
     * 输入: { prices: [...], bundles: [...] }
     * 输出: { prices: [{ id, current, lowest, bundled, urls }], bundles: [{ id, title, url, tiers, expiry }] }
     */
    function normalizeOverview(data) {
        if (!data || !data.prices) return { prices: [], bundles: [] };

        var prices = (data.prices || []).map(function (item) {
            var current = item.current;
            var lowest = item.lowest;
            return {
                id: item.id || '',
                current: current ? {
                    shop: { id: (current.shop || {}).id, name: (current.shop || {}).name || '' },
                    price: normalizePrice(current.price),
                    regular: normalizePrice(current.regular),
                    cut: current.cut || 0,
                    timestamp: current.timestamp || '',
                    url: current.url || '',
                    drm: current.drm || [],
                    platforms: current.platforms || [],
                } : null,
                lowest: lowest ? {
                    shop: { id: (lowest.shop || {}).id, name: (lowest.shop || {}).name || '' },
                    price: normalizePrice(lowest.price),
                    regular: normalizePrice(lowest.regular),
                    cut: lowest.cut || 0,
                    timestamp: lowest.timestamp || '',
                } : null,
                bundled: item.bundled || 0,
                urls: item.urls || {},
            };
        });

        var bundles = (data.bundles || []).map(function (b) {
            return {
                id: b.id || '',
                title: b.title || '',
                url: b.url || '',
                subsidized: b.subsidized || false,
                tiers: (b.tiers || []).map(function (t) {
                    return {
                        games: (t.games || []).map(function (g) {
                            return { id: g.id || '', title: g.title || '' };
                        }),
                        price: normalizePrice(t.price),
                        regular: normalizePrice(t.regular),
                    };
                }),
                expiry: b.expiry || null,
                timestamp: b.timestamp || '',
            };
        });

        return { prices: prices, bundles: bundles };
    }

    /**
     * 归一化 prices/v3 响应
     * 输入: [{ id, historyLow: { all, y1, m3 }, deals: [...] }]
     * 输出: 同结构, 但 price 对象经过 normalizePrice 处理
     */
    function normalizePrices(data) {
        if (!Array.isArray(data)) return [];
        return data.map(function (item) {
            return {
                id: item.id || '',
                historyLow: {
                    all: normalizePrice((item.historyLow || {}).all),
                    y1: normalizePrice((item.historyLow || {}).y1),
                    m3: normalizePrice((item.historyLow || {}).m3),
                },
                deals: (item.deals || []).map(function (deal) {
                    return {
                        shop: { id: (deal.shop || {}).id, name: (deal.shop || {}).name || '' },
                        price: normalizePrice(deal.price),
                        regular: normalizePrice(deal.regular),
                        cut: deal.cut || 0,
                        voucher: deal.voucher || null,
                        storeLow: normalizePrice(deal.storeLow),
                        flag: deal.flag || null,
                        drm: deal.drm || [],
                        platforms: deal.platforms || [],
                        timestamp: deal.timestamp || '',
                        expiry: deal.expiry || null,
                        url: deal.url || '',
                    };
                }),
            };
        });
    }

    /**
     * 归一化 history/v2 响应: 解析折扣记录 (seenCuts 去重)
     * 输入: [{ timestamp, shop, deal: { cut, price, regular } }]
     * 输出: { history: [{ price, regular, currency, cut, store, date }], discounts: [...], lowest: {...}|null }
     */
    function normalizeHistory(data) {
        if (!Array.isArray(data)) return { history: [], discounts: [], lowest: null };

        var seenCuts = {};
        var discounts = [];
        var allHistory = [];

        for (var i = 0; i < data.length; i++) {
            var item = data[i];
            var deal = item.deal || {};
            var cut = deal.cut || 0;
            var timestamp = item.timestamp || '';
            var dealPrice = deal.price || {};
            var regularPrice = deal.regular || {};
            var shop = item.shop || '';
            var shopName = typeof shop === 'string' ? shop : ((shop && shop.name) || '');

            var historyItem = {
                price: dealPrice.amount || 0,
                regular: regularPrice.amount || 0,
                currency: dealPrice.currency || '',
                cut: cut,
                store: shopName,
                date: timestamp,
            };
            allHistory.push(historyItem);

            // 只保留每个折扣百分比第一次出现 (seenCuts 去重, 参考项目约定)
            if (cut > 0 && !seenCuts[cut]) {
                seenCuts[cut] = true;
                discounts.push({
                    cut: cut,
                    price: historyItem.price,
                    regular: historyItem.regular,
                    currency: historyItem.currency,
                    store: shopName,
                    date: timestamp,
                });
            }
        }

        // 排序: 折扣记录按折扣百分比降序, 价格历史按日期降序
        discounts.sort(function (a, b) { return b.cut - a.cut; });
        allHistory.sort(function (a, b) { return new Date(b.date) - new Date(a.date); });

        // 计算史低 (最大折扣对应的价格)
        var lowest = null;
        if (discounts.length > 0) {
            var maxCut = discounts[0];
            lowest = {
                price: maxCut.price,
                currency: maxCut.currency,
                store: maxCut.store,
                date: maxCut.date,
                cut: maxCut.cut,
            };
        }

        return {
            history: allHistory.slice(0, 20),
            discounts: discounts,
            lowest: lowest,
        };
    }

    // ==================== 公共 API ====================

    /**
     * 初始化 ITAD 客户端
     * @param {Object} config
     *   - apiKey: ITAD API Key (32 位十六进制)
     *   - ttl: 自定义缓存 TTL (覆盖 DEFAULT_TTL)
     *   - cachePrefix: 自定义缓存键前缀
     */
    function init(config) {
        config = config || {};
        _apiKey = config.apiKey || '';
        if (config.ttl) _ttl = Object.assign({}, DEFAULT_TTL, config.ttl);
        if (config.cachePrefix) _cachePrefix = config.cachePrefix;
    }

    function setApiKey(key) {
        _apiKey = key || '';
    }

    function getApiKey() {
        return _apiKey;
    }

    function hasApiKey() {
        return !!_apiKey;
    }

    /**
     * 清除缓存
     * @param {string} [prefix] - 只清除以指定前缀开头的缓存键
     */
    function clearCache(prefix) {
        if (typeof GM_listValues !== 'function') return;
        var keys = GM_listValues();
        var p = _cachePrefix + (prefix || '');
        for (var i = 0; i < keys.length; i++) {
            if (keys[i].indexOf(p) === 0) {
                try { GM_setValue(keys[i], ''); } catch (e) { /* ignore */ }
            }
        }
    }

    var SGLVITAD = {
        version: VERSION,
        // 常量
        ITAD_BASE_URL: ITAD_BASE_URL,
        SHOP_STEAM: ITAD_SHOP_STEAM,
        DEFAULT_TTL: DEFAULT_TTL,
        MAX_BATCH: MAX_BATCH,
        // 初始化
        init: init,
        setApiKey: setApiKey,
        getApiKey: getApiKey,
        hasApiKey: hasApiKey,
        clearCache: clearCache,
        // ID 映射
        lookupItadId: lookupItadId,
        lookupItadIds: lookupItadIds,
        // 游戏信息
        fetchGameInfo: fetchGameInfo,
        // 价格概览 (核心增强端点)
        fetchPriceOverview: fetchPriceOverview,
        // 多商店/多区域价格
        fetchPrices: fetchPrices,
        fetchMultiRegionPrices: fetchMultiRegionPrices,
        // 历史价格
        fetchPriceHistory: fetchPriceHistory,
        // 史低/单店低
        fetchHistoryLow: fetchHistoryLow,
        fetchStoreLow: fetchStoreLow,
        // 工具
        getShops: getShops,
        testConnection: testConnection,
        // 归一化 (暴露用于测试/自定义处理)
        _normalize: {
            overview: normalizeOverview,
            prices: normalizePrices,
            history: normalizeHistory,
            price: normalizePrice,
        },
    };

    root.SGLVITAD = SGLVITAD;
})(typeof window !== 'undefined' ? window : globalThis);