SGLV ITAD Library

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

Tento skript by neměl být instalován přímo. Jedná se o knihovnu, kterou by měly jiné skripty využívat pomocí meta příkazu // @require https://update.greasyfork.org/scripts/590418/1896925/SGLV%20ITAD%20Library.js

K instalaci tototo skriptu si budete muset nainstalovat rozšíření jako Tampermonkey, Greasemonkey nebo Violentmonkey.

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

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Violentmonkey.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Userscripts.

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

K instalaci tohoto skriptu si budete muset nainstalovat manažer uživatelských skriptů.

(Už mám manažer uživatelských skriptů, nechte mě ho nainstalovat!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Už mám manažer uživatelských stylů, nechte mě ho nainstalovat!)

// ==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);