Steam Games Export with WebSocket

Auto-exports Steam games list with WebSocket login automation support

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 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);
        }
    });
})();