Steam Games Export with WebSocket

Auto-exports Steam games list with WebSocket login automation support

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

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址