Steam Games Export with WebSocket

Auto-exports Steam games list with WebSocket login automation support

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Steam Games Export with WebSocket
// @namespace    steamutils
// @version      0.7.4
// @description  Auto-exports Steam games list with WebSocket login automation support
// @author       mustafachyi
// @match        *://steamcommunity.com/*
// @grant        GM_cookie
// @grant        GM_xmlhttpRequest
// @grant        GM_download
// @connect      steamcommunity.com
// @connect      localhost
// @connect      127.0.0.1
// @run-at       document-start
// ==/UserScript==

(() => {
    'use strict';

    // Configuration
    const CONFIG = {
        urls: {
            base: 'https://steamcommunity.com',
            login: '/login/home/',
            games: '/games'
        },
        storage: {
            profiles: 'steam_exported_profiles',
            username: 'steam_last_username',
            mode: 'steam_export_mode'
        },
        retry: { max: 20, delay: 500, loginCheck: 1500 },
        ui: { notifyDuration: 3000, animDuration: 300 },
        keys: { logout: { ctrl: true, alt: true, key: 'l' } },
        ws: { url: 'ws://127.0.0.1:27060', fallback: true }
    };

    // URL utilities
    const url = {
        isLogin: () => location.href.includes('/login/home'),
        isGames: () => location.pathname.includes('/games'),
        isProfile: () => /\/(?:id|profiles)\/[^\/]+(?:\/home|\/?$)/.test(location.pathname),
        isFamilyPin: () => location.href.includes('/my/goto'),
        getBase: () => (location.href.match(/(.*\/(?:id|profiles)\/[^\/]+)(?:\/home)?/) || [])[1] || null,
        getSteamId: () => {
            const match = location.pathname.match(/\/(?:id|profiles)\/([^\/]+)(?:\/home)?/);
            return match ? match[1] : null;
        },
        resolveVanityURL: async (vanityURL) => {
            return utils.request(`https://steamcommunity.com/id/${vanityURL}?xml=1`, {
                parser: res => {
                    const steamID64 = res.responseText.match(/<steamID64>(\d+)<\/steamID64>/);
                    return steamID64 ? steamID64[1] : null;
                }
            });
        }
    };

    // Early URL handling
    if (location.href === `${CONFIG.urls.base}/` || location.href === CONFIG.urls.base) {
        location.replace(`${CONFIG.urls.base}${CONFIG.urls.login}`);
        return;
    }

    const profileMatch = location.pathname.match(/^\/(id|profiles)\/([^\/]+)(?:\/(?:home)?)?$/);
    if (profileMatch) {
        const [, type, id] = profileMatch;
        try {
            const profiles = JSON.parse(localStorage.getItem(CONFIG.storage.profiles) || '{}');
            if (profiles[id] === undefined) {
                location.replace(`${CONFIG.urls.base}/${type}/${id}${CONFIG.urls.games}`);
                return;
            }
        } catch {}
    }

    // Utilities
    const utils = {
        setReactValue(input, value) {
            Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set.call(input, value);
            input.dispatchEvent(new Event('input', { bubbles: true }));
            input.dispatchEvent(new Event('change', { bubbles: true }));
        },

        async handleLogin(loginButton) {
            const [userInput, passInput] = document.querySelectorAll('input._2GBWeup5cttgbTw8FM3tfx');
            
            if (userInput && passInput && loginButton) {
                try {
                    loginButton.click();
                    setTimeout(() => {
                        if (url.isLogin()) ws.send({ type: 'login_failed' });
                    }, CONFIG.retry.loginCheck);
                    return true;
                } catch (e) {
                    console.log('Login error:', e);
                }
            }
            
            const form = document.querySelector('form._2v60tM463fW0V7GDe92E5f');
            if (form) {
                try {
                    form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
                    setTimeout(() => {
                        if (url.isLogin()) ws.send({ type: 'login_failed' });
                    }, CONFIG.retry.loginCheck);
                    return true;
                } catch (e) {
                    console.log('Form submission error:', e);
                }
            }
            
            return false;
        },

        request: (url, { method = 'GET', parser } = {}) => new Promise(resolve => 
            GM_xmlhttpRequest({
                method,
                url,
                onload: res => resolve(parser ? parser(res) : res),
                onerror: () => resolve(null)
            })
        )
    };

    // WebSocket handler
    const ws = {
        conn: null,
        connected: false,
        mode: localStorage.getItem(CONFIG.storage.mode) || 'manual',
        
        connect() {
            if (this.conn?.readyState <= WebSocket.OPEN) return;
            
            try {
                this.conn = new WebSocket(CONFIG.ws.url);
                
                this.conn.onopen = () => {
                    this.send({ type: 'identify', client: 'userscript', version: '0.7.4' });
                    this.connected = true;
                };
                
                this.conn.onmessage = ({ data }) => {
                    try {
                        const msg = JSON.parse(data);
                        const handler = this.handlers[msg.type];
                        handler && handler(msg);
                    } catch {}
                };
                
                this.conn.onclose = this.conn.onerror = () => {
                    this.cleanup();
                    if (CONFIG.ws.fallback && url.isLogin()) this.fallback = true;
                };
            } catch {
                this.cleanup();
                if (CONFIG.ws.fallback && url.isLogin()) this.fallback = true;
            }
        },

        handlers: {
            connected(msg) {
                if (ws.mode === msg.mode) return;
                ws.mode = msg.mode;
                localStorage.setItem(CONFIG.storage.mode, ws.mode);
                ui.notify('Connected', `Server connected in ${ws.mode} mode`);
            },
            manual_mode() {
                if (ws.mode === 'manual') return;
                ws.mode = 'manual';
                localStorage.setItem(CONFIG.storage.mode, 'manual');
                ui.notify('Mode Changed', 'Switched to manual mode');
            },
            account_data(msg) {
                const [user, pass] = msg.credentials.split(':').map(s => s.trim());
                auth.fillCredentials(user, pass);
            },
            all_done() {
                ui.notify('Complete', 'All accounts have been processed');
            }
        },

        send(data) {
            return this.conn?.readyState === WebSocket.OPEN && this.conn.send(JSON.stringify(data));
        },

        cleanup() {
            if (this.conn) {
                this.conn.close();
                this.conn = null;
                this.connected = false;
            }
        }
    };

    // UI components
    const ui = {
        styles: `
            .steam_export_notification{position:fixed;bottom:20px;right:20px;background:#1b2838;border:1px solid #66c0f4;color:#fff;padding:15px;border-radius:3px;box-shadow:0 0 10px rgba(0,0,0,.5);z-index:9999;font-family:"Motiva Sans",Arial,sans-serif;animation:steamNotificationSlide .3s ease-out;display:flex;align-items:center;gap:10px;min-width:280px}
            .steam_export_notification .icon{width:24px;height:24px;background:#66c0f4;border-radius:3px;display:flex;align-items:center;justify-content:center}
            .steam_export_notification .content{flex-grow:1}
            .steam_export_notification .title{font-weight:700;margin-bottom:3px;color:#66c0f4}
            .steam_export_notification .message{font-size:12px;color:#acb2b8}
            @keyframes steamNotificationSlide{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}}
            .steam_export_btn{display:inline-flex;align-items:center;padding:0 15px;line-height:24px;border-radius:2px;background:#101822;color:#fff;margin-left:10px;cursor:pointer;border:none;font-family:"Motiva Sans",Arial,sans-serif;transition:all .25s ease}
            .steam_export_btn:hover{background:#4e92b9}
            body.login .responsive_page_frame{height:100vh!important;overflow:hidden!important;display:flex!important;flex-direction:column!important}
            body.login .responsive_page_content{flex:1!important;overflow:hidden!important;display:flex!important;flex-direction:column!important}
            body.login .responsive_page_template_content{flex:1!important;display:flex!important;flex-direction:column!important;justify-content:center!important;align-items:center!important;min-height:0!important}
            body.login .page_content{margin:0!important;padding:0 16px!important;width:100%!important;max-width:740px!important;box-sizing:border-box!important}
            body.login #footer,body.login #global_header,body.login .responsive_header{position:relative!important}
            body.login #footer{margin-top:auto!important;padding:16px 0!important}
            body.login #footer_spacer{display:none!important}
            body.login #global_header{padding:16px 0!important}
            body.login .responsive_header{padding:12px 0!important}
            body.login .login_bottom_row{margin:16px 0!important}
            body.login [data-featuretarget="login"]{margin:0!important;padding:16px!important;background:rgba(0,0,0,0.2)!important;border-radius:4px!important;box-shadow:0 0 10px rgba(0,0,0,0.3)!important}
        `.replace(/\s+/g, ' '),

        init() {
            const style = document.createElement('style');
            style.textContent = this.styles;
            (document.head || document.documentElement).appendChild(style);
        },

        notify(title, message) {
            const el = document.createElement('div');
            el.className = 'steam_export_notification';
            el.innerHTML = `<div class="icon">✓</div><div class="content"><div class="title">${title}</div><div class="message">${message}</div></div>`;
            document.body.appendChild(el);
            
            setTimeout(() => {
                el.style.animation = 'steamNotificationSlide 0.3s ease-in reverse';
                setTimeout(() => el.remove(), CONFIG.ui.animDuration);
            }, CONFIG.ui.notifyDuration);
        },

        addExportButton() {
            const btn = document.createElement('a');
            btn.className = 'steam_export_btn';
            btn.textContent = 'Export Games';
            btn.onclick = () => games.exportFromConfig();

            const observer = new MutationObserver((_, obs) => {
                const header = document.querySelector('.profile_small_header_text');
                if (header) {
                    header.appendChild(btn);
                    obs.disconnect();
                }
            });
            observer.observe(document.documentElement, { childList: true, subtree: true });
        }
    };

    // Storage management
    const storage = {
        get(key) {
            try {
                return JSON.parse(localStorage.getItem(key) || '{}');
            } catch {
                return {};
            }
        },

        set(key, value) {
            localStorage.setItem(key, JSON.stringify(value));
        },

        markExported(steamId, count) {
            const profiles = this.get(CONFIG.storage.profiles);
            profiles[steamId] = count;
            this.set(CONFIG.storage.profiles, profiles);
        },

        shouldExport(steamId, count) {
            const profiles = this.get(CONFIG.storage.profiles);
            return profiles[steamId] === undefined || profiles[steamId] !== count;
        }
    };

    // Games management
    const games = {
        async waitForData(retries = 0) {
            const config = document.getElementById('gameslist_config')?.dataset.profileGameslist;
            if (!config) {
                if (retries < CONFIG.retry.max) {
                    setTimeout(() => this.waitForData(retries + 1), CONFIG.retry.delay);
                }
                return;
            }

            try {
                const data = JSON.parse(config);
                if (!data.rgGames?.length) return;

                if (storage.shouldExport(data.strSteamId, data.rgGames.length)) {
                    await this.export(data, true);
                    storage.markExported(data.strSteamId, data.rgGames.length);
                }
                !document.querySelector('.steam_export_btn') && ui.addExportButton();
            } catch {}
        },

        async exportFromConfig() {
            const config = document.getElementById('gameslist_config')?.dataset.profileGameslist;
            if (!config) return;

            try {
                const data = JSON.parse(config);
                if (data.rgGames?.length) {
                    await this.export(data);
                    storage.markExported(data.strSteamId, data.rgGames.length);
                }
            } catch {}
        },

        async export(data, isAutoExport = false) {
            const username = localStorage.getItem(CONFIG.storage.username) || data.strProfileName || 'unknown';
            const content = data.rgGames.map(g => g.name).join('\n');
            const url = URL.createObjectURL(new Blob([content], { type: 'text/plain' }));
            const cleanup = setTimeout(() => URL.revokeObjectURL(url), 30000);

            try {
                await new Promise((resolve, reject) => {
                    GM_download({
                        url,
                        name: `steam_games/${username}_games.txt`,
                        saveAs: false,
                        onload: resolve,
                        onerror: reject
                    });
                });

                ui.notify('Games List Exported', `Saved ${data.rgGames.length} games to steam_games/${username}_games.txt`);
                isAutoExport && typeof Logout === 'function' && setTimeout(Logout, 1000);
            } catch (error) {
                if (error.includes('No such file or directory')) {
                    await new Promise(resolve => {
                        GM_download({
                            url: 'data:text/plain;base64,',
                            name: 'steam_games/.folder',
                            saveAs: false,
                            onload: resolve
                        });
                    });
                    return this.export(data, isAutoExport);
                }
                ui.notify('Export Failed', 'Could not save games list. Please try again.');
            } finally {
                clearTimeout(cleanup);
                URL.revokeObjectURL(url);
            }
        },

        async resolveID(idOrVanity) {
            if (/^\d+$/.test(idOrVanity)) return idOrVanity;
            return await url.resolveVanityURL(idOrVanity) || idOrVanity;
        },

        async getCount(idOrVanity) {
            const resolvedID = await this.resolveID(idOrVanity);
            const urlPath = /^\d+$/.test(resolvedID) ? `profiles/${resolvedID}` : `id/${resolvedID}`;
            return utils.request(`${CONFIG.urls.base}/${urlPath}/games`, {
                parser: res => {
                    const doc = new DOMParser().parseFromString(res.responseText, 'text/html');
                    const data = JSON.parse(doc.getElementById('gameslist_config')?.dataset.profileGameslist || '{}');
                    return data.rgGames?.length || null;
                }
            });
        }
    };

    // Authentication
    const auth = {
        setupLoginCapture() {
            const observer = new MutationObserver((_, obs) => {
                const form = document.querySelector('form._2v60tM463fW0V7GDe92E5f');
                if (!form) return;

                const [userInput, passInput] = form.querySelectorAll('input._2GBWeup5cttgbTw8FM3tfx');
                if (!userInput || !passInput) return;
                
                const loginButton = form.querySelector('button.DjSvCZoKKfoNSmarsEcTS');
                if (!loginButton) return;

                userInput.addEventListener('paste', e => {
                    const text = (e.clipboardData || window.clipboardData).getData('text');
                    if (!text.includes(':')) return;
                    e.preventDefault();
                    const [user, pass] = text.split(':').map(s => s.trim());
                    this.fillCredentials(user, pass);
                });

                if (!ws.fallback) {
                    ws.connect();
                    setTimeout(() => ws.send({ type: 'ready_for_login' }), 500);
                }

                loginButton.addEventListener('click', () => {
                    const username = userInput.value.trim();
                    username && localStorage.setItem(CONFIG.storage.username, username);
                });

                obs.disconnect();
            });
            observer.observe(document.documentElement, { childList: true, subtree: true });
        },

        fillCredentials(user, pass) {
            if (!user || !pass) return;
            
            const form = document.querySelector('form._2v60tM463fW0V7GDe92E5f');
            if (!form) return;

            const [userInput, passInput] = form.querySelectorAll('input._2GBWeup5cttgbTw8FM3tfx');
            const loginButton = form.querySelector('button.DjSvCZoKKfoNSmarsEcTS');
            if (!userInput || !passInput || !loginButton) return;

            utils.setReactValue(userInput, user);
            utils.setReactValue(passInput, pass);
            localStorage.setItem(CONFIG.storage.username, user);
            ui.notify('Credentials Filled', 'Username and password have been entered');

            ws.connected && setTimeout(() => {
                ws.send({ type: 'credentials_filled' });
                utils.handleLogin(loginButton);
            }, 1000);
        },

        async checkState() {
            return utils.request(`${CONFIG.urls.base}/my/`, {
                parser: res => !res.finalUrl.includes('/login')
            });
        },

        setupLogout() {
            document.addEventListener('keydown', e => {
                const { ctrl, alt, key } = CONFIG.keys.logout;
                if ((!ctrl || e.ctrlKey) && (!alt || e.altKey) && e.key.toLowerCase() === key) {
                    e.preventDefault();
                    typeof Logout === 'function' && Logout();
                }
            }, true);
        }
    };

    // Login page optimization
    if (location.href.includes('/login/home')) {
        const blockStyle = document.createElement('style');
        blockStyle.textContent = `#footer,#global_header,.login_bottom_row{display:none!important;visibility:hidden!important;opacity:0!important;pointer-events:none!important;position:absolute!important;width:0!important;height:0!important;overflow:hidden!important;clip:rect(0,0,0,0)!important}`;
        document.documentElement.appendChild(blockStyle);

        const observer = new MutationObserver(mutations => {
            for (const { addedNodes } of mutations) {
                for (const node of addedNodes) {
                    if (node.nodeType !== 1) continue;
                    if (node.matches?.('#footer,#global_header,.login_bottom_row') && node.parentNode) {
                        node.remove();
                    }
                    if (node.querySelectorAll) {
                        node.querySelectorAll('#footer,#global_header,.login_bottom_row').forEach(el => {
                            if (el && el.parentNode) el.remove();
                        });
                    }
                }
            }
        });

        observer.observe(document.documentElement, { childList: true, subtree: true });
        window.addEventListener('load', () => observer.disconnect(), { once: true });
    }

    // Initialization
    document.addEventListener('DOMContentLoaded', async () => {
        ui.init();
        auth.setupLogout();

        if (url.isLogin()) return auth.setupLoginCapture();
        if (url.isGames()) return games.waitForData();
        if (url.isFamilyPin()) {
            ui.notify('Family Pin Protected', 'Account is protected by family pin. Logging out...');
            setTimeout(() => typeof Logout === 'function' && Logout(), 1000);
            return;
        }
        if (!await auth.checkState()) return (location.href = CONFIG.urls.login);
        
        if (url.isProfile()) {
            const steamId = url.getSteamId();
            if (!steamId) return;

            const profiles = storage.get(CONFIG.storage.profiles);
            const idKey = await games.resolveID(steamId);
            const prevCount = profiles[idKey];
            
            if (prevCount !== undefined) {
                const currCount = await games.getCount(steamId);
                if (!currCount || currCount === prevCount) return;
            }

            const base = url.getBase();
            base && (location.href = base + CONFIG.urls.games);
        }
    });
})();