SteamPy Plus

增强购买Steampy密钥的体验,增加筛选功能,支持鼠标中键打开Steam页面。

// ==UserScript==
// @name            SteamPy Plus
// @name:zh-CN      SteamPy Plus
// @name:en         SteamPy Plus
// @namespace       http://github.com/blue-bird1/tampermonkey-script
// @version         4.10
// @description     增强购买Steampy密钥的体验,增加筛选功能,支持鼠标中键打开Steam页面。
// @description:en  Enhance the experience of purchasing Steampy keys, add filter functionality, and support opening Steam pages with the middle mouse button.  // 英文描述(可选,补充默认描述的英文版本)
// @author          豆包 (Doubao)
// @match           https://steampy.com/*
// @grant           GM_setValue
// @grant           GM_getValue
// @icon            https://steampy.com/logo.ico
// @require         https://scriptcat.org/lib/637/1.4.8/ajaxHooker.js#sha256=dTF50feumqJW36kBpbf6+LguSLAtLr7CEs3oPmyfbiM=
// @require         https://scriptcat.org/lib/513/2.1.0/ElementGetter.js#sha256=aQF7JFfhQ7Hi+weLrBlOsY24Z2ORjaxgZNoni7pAz5U=
// @run-at          document-start
// @license         MIT
// ==/UserScript==

/*global elmGetter,ajaxHooker*/

(function () {
    'use strict';

    // 状态管理
    const StateManager = {
        saveState(state) {
            GM_setValue('steamPriceFilterState', JSON.stringify(state));
        },
        loadState() {
            const saved = GM_getValue('steamPriceFilterState', null);
            return saved ? JSON.parse(saved) : {
                minPrice: 0,
                maxPrice: 9999,
                isActive: false
            };
        }
    };

    let filterState = StateManager.loadState();

    // 修复后的工具函数:提取游戏ID(优先读取data-src)
    function getSteamAppId(gameBlock) {
        const iconImg = gameBlock.querySelector('.cdkGameIcon');
        if (!iconImg) return null;

        // 优先读取真实图片地址(data-src),再兼容src
        const imgUrl = iconImg.dataset.src || iconImg.src;
        // 从图片地址中匹配游戏ID(例如从steam/apps/1651560/中提取1651560)
        const match = imgUrl.match(/steam\/apps\/(\d+)\/header/);
        return match ? match[1] : null;
    }

    // 游戏数据存储
    const TempDataStore = {
        steamGameData: null,
        setGameData(data) {
            this.steamGameData = data;
        },
        getGameData() {
            return this.steamGameData || { result: { content: [] } };
        },
        getRatingByAppId(appId) {
            const gameList = this.getGameData().result.content;
            const targetGame = gameList.find(game => game.appId === appId);
            return targetGame?.rating || 0;
        }
    };

    // 接口拦截
    ajaxHooker.hook(request => {
        if (request.url.includes('/xboot/steamGame/keyHot')) {
            request.response = (res) => {
                try {
                    const originalData = JSON.parse(res.responseText);
                    TempDataStore.setGameData(originalData);
                    res.responseText = JSON.stringify(originalData);
                } catch (e) {
                    console.error('接口数据处理失败:', e);
                }
            };
        }
        return request;
    });

    // 单个游戏评分更新(使用Steam风格文本描述)
    function updateGameRating(gameBlock) {
        if (!gameBlock) return;

        const appId = getSteamAppId(gameBlock);
        const gameHead = gameBlock.querySelector('.gameHead');

        // 只有存在有效ID时才处理评分
        if (appId && gameHead) {
            const rating = TempDataStore.getRatingByAppId(appId);
            const ratingEl = gameHead.querySelector('.gameRating');

            // 有评分数据
            if (rating > 0) {
                // 计算百分比并映射到Steam评分等级
                const ratingPercent = Math.round(rating * 100);
                let ratingText, ratingClass;

                // Steam风格评分标准
                if (ratingPercent >= 90) {
                    ratingText = "好评如潮";
                    ratingClass = "overwhelmingly-positive";
                } else if (ratingPercent >= 80) {
                    ratingText = "特别好评";
                    ratingClass = "very-positive";
                } else if (ratingPercent >= 70) {
                    ratingText = "多半好评";
                    ratingClass = "positive";
                } else if (ratingPercent >= 40) {
                    ratingText = "褒贬不一";
                    ratingClass = "mixed";
                } else if (ratingPercent >= 20) {
                    ratingText = "多半差评";
                    ratingClass = "negative";
                } else {
                    ratingText = "特别差评";
                    ratingClass = "very-negative";
                }

                if (ratingEl) {
                    // 只在内容变化时更新
                    if (ratingEl.textContent !== ratingText) {
                        ratingEl.textContent = ratingText;
                    }

                    // 更新评分等级类名
                    if (!ratingEl.classList.contains(ratingClass)) {
                        ratingEl.classList.remove(
                            'overwhelmingly-positive',
                            'very-positive',
                            'positive',
                            'mixed',
                            'negative',
                            'very-negative'
                        );
                        ratingEl.classList.add(ratingClass);
                    }
                } else {
                    // 创建新评分标签
                    const newRatingEl = document.createElement('div');
                    newRatingEl.className = `gameRating ${ratingClass}`;
                    newRatingEl.textContent = ratingText;
                    gameHead.appendChild(newRatingEl);
                }
            }
            // 无评分数据则移除标签
            else if (ratingEl) {
                ratingEl.remove();
            }
        }
        // 无ID时移除现有评分标签
        else if (gameHead) {
            const ratingEl = gameHead.querySelector('.gameRating');
            if (ratingEl) ratingEl.remove();
        }
    }

    // 同步更新评分样式
    function injectRatingStyle() {
        const existingStyle = document.getElementById('ratingStyle');
        if (existingStyle) {
            existingStyle.remove();
        }

        const style = document.createElement('style');
        style.id = 'ratingStyle';
        style.textContent = `
        .gameHead .gameRating {
            padding: 0 8px !important;
            height: .3rem !important;
            position: absolute !important;
            top: 0 !important;
            left: 0 !important;
            color: #fff !important;
            text-align: center !important;
            line-height: .3rem !important;
            border-radius: .09rem 0 0 0 !important;
            font-size: .12rem !important;
            font-weight: bold !important;
            z-index: 10 !important;
            white-space: nowrap !important;
        }
        /* Steam风格评分颜色 */
        .gameRating.overwhelmingly-positive { background: #4CAF50 !important; } /* 好评如潮 - 深绿 */
        .gameRating.very-positive { background: #8BC34A !important; } /* 特别好评 - 中绿 */
        .gameRating.positive { background: #CDDC39 !important; color: #333 !important; } /* 多半好评 - 浅绿 */
        .gameRating.mixed { background: #FFC107 !important; color: #333 !important; } /* 褒贬不一 - 黄色 */
        .gameRating.negative { background: #FF9800 !important; } /* 多半差评 - 橙色 */
        .gameRating.very-negative { background: #F44336 !important; } /* 特别差评 - 红色 */
    `;
        document.head.appendChild(style);
    }

    // 等待元素加载
    function waitForElement(selector, callback, timeout = 10000) {
        const start = Date.now();
        const timer = setInterval(() => {
            const el = document.querySelector(selector);
            if (el) {
                clearInterval(timer);
                callback(el);
            } else if (Date.now() - start > timeout) {
                clearInterval(timer);
                console.warn(`超时未找到元素: ${selector}`);
                insertFilterUI();
            }
        }, 200);
    }

    // 筛选UI
    function createFilterUI() {
        const ui = document.createElement('div');
        ui.id = 'priceFilterContainer';
        ui.className = 'ml-5-rem c-point tagBtnTwo flex-row align-items-center';
        ui.style.cssText = `font-family:Arial,sans-serif;font-size:13px;align-items:center;gap:8px;padding:8px;z-index:9999;position:relative;background:#f9f9f9;border-radius:4px;border:1px solid #eee;height:.25rem;`;

        const title = document.createElement('span');
        title.className = 'tag-titleOne ml-3-rem';
        title.textContent = '价格筛选';
        title.style.fontWeight = 'bold';
        ui.appendChild(title);

        const presets = [
            { text: '0-20元', min: 0, max: 20 },
            { text: '20元以上', min: 20, max: 9999 }
        ];
        const presetContainer = document.createElement('div');
        presetContainer.className = 'flex-row jc-space-flex-start align-items-center pr5-rem';
        presetContainer.style.gap = '8px';

        presets.forEach(p => {
            const btn = document.createElement('div');
            btn.className = 'tagBtn';
            btn.dataset.min = p.min;
            btn.dataset.max = p.max;
            btn.textContent = p.text;
            btn.style.cssText = `padding:4px 10px;border-radius:4px;cursor:pointer;font-size:13px;border:1px solid #ddd;color:#666;background:transparent;transition:all 0.2s;`;

            if (filterState.isActive && filterState.minPrice === p.min && filterState.maxPrice === p.max) {
                btn.style.cssText = `padding:4px 10px;border-radius:4px;cursor:pointer;font-size:13px;border:1px solid #409EFF;color:#fff;background:#409EFF;transition:all 0.2s;`;
            }

            btn.onclick = () => {
                filterState.minPrice = p.min;
                filterState.maxPrice = p.max;
                filterState.isActive = true;
                StateManager.saveState(filterState);
                syncInputValues();
                applyFilter(); // 仅应用筛选,不更新评分
                updatePresetHighlights();
            };
            presetContainer.appendChild(btn);
        });
        ui.appendChild(presetContainer);

        const inputContainer = document.createElement('div');
        inputContainer.className = 'flex-row align-items-center';
        inputContainer.style.gap = '8px';

        const minInp = document.createElement('input');
        minInp.id = 'priceFilterMin';
        minInp.type = 'number';
        minInp.placeholder = '最低价';
        minInp.min = 0;
        minInp.step = 0.01;
        minInp.style.cssText = `width:70px;height:28px;padding:0 8px;border:1px solid #ccc;border-radius:4px;box-sizing:border-box;font-size:13px;`;
        minInp.addEventListener('input', (e) => {
            filterState.minPrice = parseFloat(e.target.value) || 0;
            filterState.isActive = true;
            StateManager.saveState(filterState);
        });

        const maxInp = document.createElement('input');
        maxInp.id = 'priceFilterMax';
        maxInp.type = 'number';
        maxInp.placeholder = '最高价';
        maxInp.min = 0;
        maxInp.step = 0.01;
        maxInp.style.cssText = `width:70px;height:28px;padding:0 8px;border:1px solid #ccc;border-radius:4px;box-sizing:border-box;font-size:13px;`;
        maxInp.addEventListener('input', (e) => {
            filterState.maxPrice = parseFloat(e.target.value) || 9999;
            filterState.isActive = true;
            StateManager.saveState(filterState);
        });

        const filterBtn = document.createElement('button');
        filterBtn.className = 'ivu-btn ivu-btn-default ivu-btn-sm';
        filterBtn.textContent = '筛选';
        filterBtn.style.cssText = `margin-left:4px;padding:4px 12px;cursor:pointer;background:#409EFF;color:white;border:1px solid #409EFF;border-radius:4px;`;
        filterBtn.onclick = () => {
            applyFilter(); // 仅应用筛选,不更新评分
            updatePresetHighlights(false);
        };

        inputContainer.append(minInp, document.createTextNode('-'), maxInp, filterBtn);
        ui.appendChild(inputContainer);

        return ui;
    }

    function insertFilterUI() {
        if (document.getElementById('priceFilterContainer')) return;
        const ui = createFilterUI();
        const targetContainer = document.querySelector('.tag.flex-row.align-items-center');
        if (targetContainer) {
            targetContainer.appendChild(ui);
            syncInputValues();
        }
        if (filterState.isActive) {
            applyFilter();
        }
    }

    function updatePresetHighlights(shouldHighlight = true) {
        document.querySelectorAll('.tagBtn[data-min]').forEach(btn => {
            const btnMin = parseFloat(btn.dataset.min);
            const btnMax = parseFloat(btn.dataset.max);
            const isMatch = filterState.isActive && filterState.minPrice === btnMin && filterState.maxPrice === btnMax;

            btn.style.cssText = shouldHighlight && isMatch
                ? `padding:4px 10px;border-radius:4px;cursor:pointer;font-size:13px;border:1px solid #409EFF;color:#fff;background:#409EFF;transition:all 0.2s;`
                : `padding:4px 10px;border-radius:4px;cursor:pointer;font-size:13px;border:1px solid #ddd;color:#666;background:transparent;transition:all 0.2s;`;
        });
    }

    // 价格筛选核心逻辑
    function syncInputValues() {
        const minInp = document.getElementById('priceFilterMin');
        const maxInp = document.getElementById('priceFilterMax');
        if (minInp && filterState.isActive) minInp.value = filterState.minPrice;
        if (maxInp && filterState.isActive) maxInp.value = filterState.maxPrice;
    }

    function getGamePrice(gameBlock) {
        const priceEl = gameBlock.querySelector('.gamePrice');
        if (!priceEl) return 0;
        const priceText = priceEl.textContent.replace(/[¥元]/g, '').trim().toLowerCase();
        return priceText === '免费' ? 0 : (parseFloat(priceText) || 0);
    }

    function processGame(gameBlock) {
        if (gameBlock.dataset.filterProcessed) return;
        gameBlock.dataset.filterProcessed = 'true';

        gameBlock.addEventListener('mousedown', e => {
            if (e.button === 1 && !e.ctrlKey && !e.shiftKey) {
                const appId = getSteamAppId(gameBlock);
                if (appId) {
                    e.preventDefault();
                    window.open(`https://store.steampowered.com/app/${appId}/`, '_blank');
                }
            }
        });

        applyFilterToGame(gameBlock);
    }

    // 应用筛选到单个游戏(仅在状态变化且变为可见时才更新评分)
    function applyFilterToGame(gameBlock, forceRatingUpdate = false) {
        if (!gameBlock) return false;

        const price = getGamePrice(gameBlock);
        const shouldShow = !filterState.isActive ||
            (price >= filterState.minPrice && price <= filterState.maxPrice);
        const wasShowing = gameBlock.style.display !== 'none';

        // 状态变化时才更新DOM
        if (shouldShow !== wasShowing) {
            gameBlock.style.display = shouldShow ? 'block' : 'none';

            // 只有变为可见状态时才可能需要更新评分
            if (shouldShow) {
                updateGameRating(gameBlock);
            }
            return true;
        }

        // 强制更新评分(用于ID变化的情况)
        if (forceRatingUpdate && shouldShow) {
            updateGameRating(gameBlock);
        }

        return false;
    }

    // 批量应用筛选(不主动更新评分)
    function applyFilter() {
        document.querySelectorAll('.gameblock:not([data-filter-processed])')
            .forEach(processGame);
        let hasChanges = false;
        document.querySelectorAll('.gameblock').forEach(gameBlock => {
            if (applyFilterToGame(gameBlock)) {
                hasChanges = true;
            }
        });

        // 处理空状态显示
        const visibleCount = 35 - document.querySelectorAll('.gameblock[style="display: none;"]').length;
        const emptyMsg = document.querySelector('.tc.mt-50-rem.pb-20-rem');
        if (emptyMsg) {
            emptyMsg.style.display = visibleCount === 0 ? 'block' : 'none';
        }

        return hasChanges;
    }

    // 精准监控器:仅在游戏ID变化时更新评分
    async function startContentMonitor() {
        const gameContainer = await elmGetter.get('.ivu-tabs-content'); 

        if (!gameContainer) {
            console.warn('未找到游戏容器,1秒后重试');
            setTimeout(startContentMonitor, 1000);
            return;
        }

        const observerConfig = {
            subtree: true,
            attributes: true,
            attributeFilter: ['src', 'style'],
            characterData: true
        };


        const observer = new MutationObserver((mutations) => {

            mutations.forEach(mutation => {
                let gameBlock = null;
                let requiresRatingUpdate = false;

                // 游戏图标变化(ID可能改变)- 需要更新评分
                if (mutation.type === 'attributes' && mutation.attributeName === 'src') {
                    if (mutation.target.classList?.contains('cdkGameIcon')) {
                        gameBlock = mutation.target.closest('.gameblock');
                        requiresRatingUpdate = true; // 图片变化意味着ID可能变化
                    }
                }
                // 价格文本变化 - 只影响筛选,不更新评分
                else if (mutation.type === 'characterData') {
                    const priceEl = mutation.target.parentElement;
                    if (priceEl && priceEl.classList.contains('gamePrice')) {
                        gameBlock = priceEl.closest('.gameblock');
                        requiresRatingUpdate = false; // 价格变化不影响评分
                    }
                }

                // 处理找到的游戏块
                if (gameBlock) {

                    if (requiresRatingUpdate) {
                        // ID变化或变为可见,需要更新评分
                        applyFilterToGame(gameBlock, true);
                    } else {
                        // 仅应用筛选,不更新评分
                        applyFilterToGame(gameBlock, false);
                    }
                }
            });
        });

        observer.observe(gameContainer, observerConfig);
        console.log('监控器已启动');

        window.addEventListener('beforeunload', () => {
            observer.disconnect();
        });

        // 初始加载后执行一次
        setTimeout(() => {
            applyFilter();
            // 初始加载时对所有可见游戏更新评分
            document.querySelectorAll('.gameblock')
                .forEach(gameBlock => updateGameRating(gameBlock));
        }, 600);
    }

    // 路径处理
    const TARGET_PATH = '/cdKey/cdKey';
    let isInitialized = false;

    function isTargetPath() {
        return window.location.pathname.startsWith(TARGET_PATH);
    }

    function cleanUp() {
        if (!isInitialized) return;
        isInitialized = false;
    }

    function handlePathChange() {
        if (isTargetPath() && !isInitialized) {
            console.log("run script in path")
            init();
        } else if (!isTargetPath() && isInitialized) {
            cleanUp();
        }
    }

    async function init() {
        if (isInitialized) return;
        injectRatingStyle();
        waitForElement('.tag.flex-row.align-items-center', insertFilterUI);
        await startContentMonitor();
        isInitialized = true;
    }

    // 监听历史变化
    let lastPath = location.pathname + location.search;
    const originalPushState = history.pushState;
    const originalReplaceState = history.replaceState;

    history.pushState = function (...args) {
        originalPushState.apply(this, args);
        const newPath = location.pathname + location.search;
        if (newPath !== lastPath) {
            lastPath = newPath;
            handlePathChange();
        }
    };

    history.replaceState = function (...args) {
        originalReplaceState.apply(this, args);
        const newPath = location.pathname + location.search;
        if (newPath !== lastPath) {
            lastPath = newPath;
            handlePathChange();
        }
    };

    window.addEventListener('popstate', handlePathChange);
    window.addEventListener('hashchange', handlePathChange);

    // 初始检查
    if (isTargetPath()) {
        init();
    }
})();