SteamPy Plus

Enhance the experience of purchasing Steampy keys, add filters, and enable opening Steam pages with the middle mouse button

当前为 2025-09-28 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name               SteamPy Plus
// @name:zh-CN         SteamPy Plus
// @name:en            SteamPy Plus
// @namespace          http://github.com/blue-bird1/tampermonkey-script
// @version            4.8
// @description        Enhance the experience of purchasing Steampy keys, add filters, and enable opening Steam pages with the middle mouse button
// @description:en     Enhance the experience of purchasing Steampy keys, add filters, and enable opening Steam pages with the middle mouse button
// @description:zh-CN  增强购买steampy key体验, 增加筛选器,鼠标中键打开steam页面
// @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();
    }
})();