Steam Games Export with WebSocket

Auto-exports Steam games list with WebSocket login automation support

  1. // ==UserScript==
  2. // @name Steam Games Export with WebSocket
  3. // @namespace steamutils
  4. // @version 0.7.4
  5. // @description Auto-exports Steam games list with WebSocket login automation support
  6. // @author mustafachyi
  7. // @match *://steamcommunity.com/*
  8. // @grant GM_cookie
  9. // @grant GM_xmlhttpRequest
  10. // @grant GM_download
  11. // @connect steamcommunity.com
  12. // @connect localhost
  13. // @connect 127.0.0.1
  14. // @run-at document-start
  15. // ==/UserScript==
  16.  
  17. (() => {
  18. 'use strict';
  19.  
  20. // Configuration
  21. const CONFIG = {
  22. urls: {
  23. base: 'https://steamcommunity.com',
  24. login: '/login/home/',
  25. games: '/games'
  26. },
  27. storage: {
  28. profiles: 'steam_exported_profiles',
  29. username: 'steam_last_username',
  30. mode: 'steam_export_mode'
  31. },
  32. retry: { max: 20, delay: 500, loginCheck: 1500 },
  33. ui: { notifyDuration: 3000, animDuration: 300 },
  34. keys: { logout: { ctrl: true, alt: true, key: 'l' } },
  35. ws: { url: 'ws://127.0.0.1:27060', fallback: true }
  36. };
  37.  
  38. // URL utilities
  39. const url = {
  40. isLogin: () => location.href.includes('/login/home'),
  41. isGames: () => location.pathname.includes('/games'),
  42. isProfile: () => /\/(?:id|profiles)\/[^\/]+(?:\/home|\/?$)/.test(location.pathname),
  43. isFamilyPin: () => location.href.includes('/my/goto'),
  44. getBase: () => (location.href.match(/(.*\/(?:id|profiles)\/[^\/]+)(?:\/home)?/) || [])[1] || null,
  45. getSteamId: () => {
  46. const match = location.pathname.match(/\/(?:id|profiles)\/([^\/]+)(?:\/home)?/);
  47. return match ? match[1] : null;
  48. },
  49. resolveVanityURL: async (vanityURL) => {
  50. return utils.request(`https://steamcommunity.com/id/${vanityURL}?xml=1`, {
  51. parser: res => {
  52. const steamID64 = res.responseText.match(/<steamID64>(\d+)<\/steamID64>/);
  53. return steamID64 ? steamID64[1] : null;
  54. }
  55. });
  56. }
  57. };
  58.  
  59. // Early URL handling
  60. if (location.href === `${CONFIG.urls.base}/` || location.href === CONFIG.urls.base) {
  61. location.replace(`${CONFIG.urls.base}${CONFIG.urls.login}`);
  62. return;
  63. }
  64.  
  65. const profileMatch = location.pathname.match(/^\/(id|profiles)\/([^\/]+)(?:\/(?:home)?)?$/);
  66. if (profileMatch) {
  67. const [, type, id] = profileMatch;
  68. try {
  69. const profiles = JSON.parse(localStorage.getItem(CONFIG.storage.profiles) || '{}');
  70. if (profiles[id] === undefined) {
  71. location.replace(`${CONFIG.urls.base}/${type}/${id}${CONFIG.urls.games}`);
  72. return;
  73. }
  74. } catch {}
  75. }
  76.  
  77. // Utilities
  78. const utils = {
  79. setReactValue(input, value) {
  80. Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set.call(input, value);
  81. input.dispatchEvent(new Event('input', { bubbles: true }));
  82. input.dispatchEvent(new Event('change', { bubbles: true }));
  83. },
  84.  
  85. async handleLogin(loginButton) {
  86. const [userInput, passInput] = document.querySelectorAll('input._2GBWeup5cttgbTw8FM3tfx');
  87. if (userInput && passInput && loginButton) {
  88. try {
  89. loginButton.click();
  90. setTimeout(() => {
  91. if (url.isLogin()) ws.send({ type: 'login_failed' });
  92. }, CONFIG.retry.loginCheck);
  93. return true;
  94. } catch (e) {
  95. console.log('Login error:', e);
  96. }
  97. }
  98. const form = document.querySelector('form._2v60tM463fW0V7GDe92E5f');
  99. if (form) {
  100. try {
  101. form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
  102. setTimeout(() => {
  103. if (url.isLogin()) ws.send({ type: 'login_failed' });
  104. }, CONFIG.retry.loginCheck);
  105. return true;
  106. } catch (e) {
  107. console.log('Form submission error:', e);
  108. }
  109. }
  110. return false;
  111. },
  112.  
  113. request: (url, { method = 'GET', parser } = {}) => new Promise(resolve =>
  114. GM_xmlhttpRequest({
  115. method,
  116. url,
  117. onload: res => resolve(parser ? parser(res) : res),
  118. onerror: () => resolve(null)
  119. })
  120. )
  121. };
  122.  
  123. // WebSocket handler
  124. const ws = {
  125. conn: null,
  126. connected: false,
  127. mode: localStorage.getItem(CONFIG.storage.mode) || 'manual',
  128. connect() {
  129. if (this.conn?.readyState <= WebSocket.OPEN) return;
  130. try {
  131. this.conn = new WebSocket(CONFIG.ws.url);
  132. this.conn.onopen = () => {
  133. this.send({ type: 'identify', client: 'userscript', version: '0.7.4' });
  134. this.connected = true;
  135. };
  136. this.conn.onmessage = ({ data }) => {
  137. try {
  138. const msg = JSON.parse(data);
  139. const handler = this.handlers[msg.type];
  140. handler && handler(msg);
  141. } catch {}
  142. };
  143. this.conn.onclose = this.conn.onerror = () => {
  144. this.cleanup();
  145. if (CONFIG.ws.fallback && url.isLogin()) this.fallback = true;
  146. };
  147. } catch {
  148. this.cleanup();
  149. if (CONFIG.ws.fallback && url.isLogin()) this.fallback = true;
  150. }
  151. },
  152.  
  153. handlers: {
  154. connected(msg) {
  155. if (ws.mode === msg.mode) return;
  156. ws.mode = msg.mode;
  157. localStorage.setItem(CONFIG.storage.mode, ws.mode);
  158. ui.notify('Connected', `Server connected in ${ws.mode} mode`);
  159. },
  160. manual_mode() {
  161. if (ws.mode === 'manual') return;
  162. ws.mode = 'manual';
  163. localStorage.setItem(CONFIG.storage.mode, 'manual');
  164. ui.notify('Mode Changed', 'Switched to manual mode');
  165. },
  166. account_data(msg) {
  167. const [user, pass] = msg.credentials.split(':').map(s => s.trim());
  168. auth.fillCredentials(user, pass);
  169. },
  170. all_done() {
  171. ui.notify('Complete', 'All accounts have been processed');
  172. }
  173. },
  174.  
  175. send(data) {
  176. return this.conn?.readyState === WebSocket.OPEN && this.conn.send(JSON.stringify(data));
  177. },
  178.  
  179. cleanup() {
  180. if (this.conn) {
  181. this.conn.close();
  182. this.conn = null;
  183. this.connected = false;
  184. }
  185. }
  186. };
  187.  
  188. // UI components
  189. const ui = {
  190. styles: `
  191. .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}
  192. .steam_export_notification .icon{width:24px;height:24px;background:#66c0f4;border-radius:3px;display:flex;align-items:center;justify-content:center}
  193. .steam_export_notification .content{flex-grow:1}
  194. .steam_export_notification .title{font-weight:700;margin-bottom:3px;color:#66c0f4}
  195. .steam_export_notification .message{font-size:12px;color:#acb2b8}
  196. @keyframes steamNotificationSlide{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}}
  197. .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}
  198. .steam_export_btn:hover{background:#4e92b9}
  199. body.login .responsive_page_frame{height:100vh!important;overflow:hidden!important;display:flex!important;flex-direction:column!important}
  200. body.login .responsive_page_content{flex:1!important;overflow:hidden!important;display:flex!important;flex-direction:column!important}
  201. 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}
  202. body.login .page_content{margin:0!important;padding:0 16px!important;width:100%!important;max-width:740px!important;box-sizing:border-box!important}
  203. body.login #footer,body.login #global_header,body.login .responsive_header{position:relative!important}
  204. body.login #footer{margin-top:auto!important;padding:16px 0!important}
  205. body.login #footer_spacer{display:none!important}
  206. body.login #global_header{padding:16px 0!important}
  207. body.login .responsive_header{padding:12px 0!important}
  208. body.login .login_bottom_row{margin:16px 0!important}
  209. 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}
  210. `.replace(/\s+/g, ' '),
  211.  
  212. init() {
  213. const style = document.createElement('style');
  214. style.textContent = this.styles;
  215. (document.head || document.documentElement).appendChild(style);
  216. },
  217.  
  218. notify(title, message) {
  219. const el = document.createElement('div');
  220. el.className = 'steam_export_notification';
  221. el.innerHTML = `<div class="icon">✓</div><div class="content"><div class="title">${title}</div><div class="message">${message}</div></div>`;
  222. document.body.appendChild(el);
  223. setTimeout(() => {
  224. el.style.animation = 'steamNotificationSlide 0.3s ease-in reverse';
  225. setTimeout(() => el.remove(), CONFIG.ui.animDuration);
  226. }, CONFIG.ui.notifyDuration);
  227. },
  228.  
  229. addExportButton() {
  230. const btn = document.createElement('a');
  231. btn.className = 'steam_export_btn';
  232. btn.textContent = 'Export Games';
  233. btn.onclick = () => games.exportFromConfig();
  234.  
  235. const observer = new MutationObserver((_, obs) => {
  236. const header = document.querySelector('.profile_small_header_text');
  237. if (header) {
  238. header.appendChild(btn);
  239. obs.disconnect();
  240. }
  241. });
  242. observer.observe(document.documentElement, { childList: true, subtree: true });
  243. }
  244. };
  245.  
  246. // Storage management
  247. const storage = {
  248. get(key) {
  249. try {
  250. return JSON.parse(localStorage.getItem(key) || '{}');
  251. } catch {
  252. return {};
  253. }
  254. },
  255.  
  256. set(key, value) {
  257. localStorage.setItem(key, JSON.stringify(value));
  258. },
  259.  
  260. markExported(steamId, count) {
  261. const profiles = this.get(CONFIG.storage.profiles);
  262. profiles[steamId] = count;
  263. this.set(CONFIG.storage.profiles, profiles);
  264. },
  265.  
  266. shouldExport(steamId, count) {
  267. const profiles = this.get(CONFIG.storage.profiles);
  268. return profiles[steamId] === undefined || profiles[steamId] !== count;
  269. }
  270. };
  271.  
  272. // Games management
  273. const games = {
  274. async waitForData(retries = 0) {
  275. const config = document.getElementById('gameslist_config')?.dataset.profileGameslist;
  276. if (!config) {
  277. if (retries < CONFIG.retry.max) {
  278. setTimeout(() => this.waitForData(retries + 1), CONFIG.retry.delay);
  279. }
  280. return;
  281. }
  282.  
  283. try {
  284. const data = JSON.parse(config);
  285. if (!data.rgGames?.length) return;
  286.  
  287. if (storage.shouldExport(data.strSteamId, data.rgGames.length)) {
  288. await this.export(data, true);
  289. storage.markExported(data.strSteamId, data.rgGames.length);
  290. }
  291. !document.querySelector('.steam_export_btn') && ui.addExportButton();
  292. } catch {}
  293. },
  294.  
  295. async exportFromConfig() {
  296. const config = document.getElementById('gameslist_config')?.dataset.profileGameslist;
  297. if (!config) return;
  298.  
  299. try {
  300. const data = JSON.parse(config);
  301. if (data.rgGames?.length) {
  302. await this.export(data);
  303. storage.markExported(data.strSteamId, data.rgGames.length);
  304. }
  305. } catch {}
  306. },
  307.  
  308. async export(data, isAutoExport = false) {
  309. const username = localStorage.getItem(CONFIG.storage.username) || data.strProfileName || 'unknown';
  310. const content = data.rgGames.map(g => g.name).join('\n');
  311. const url = URL.createObjectURL(new Blob([content], { type: 'text/plain' }));
  312. const cleanup = setTimeout(() => URL.revokeObjectURL(url), 30000);
  313.  
  314. try {
  315. await new Promise((resolve, reject) => {
  316. GM_download({
  317. url,
  318. name: `steam_games/${username}_games.txt`,
  319. saveAs: false,
  320. onload: resolve,
  321. onerror: reject
  322. });
  323. });
  324.  
  325. ui.notify('Games List Exported', `Saved ${data.rgGames.length} games to steam_games/${username}_games.txt`);
  326. isAutoExport && typeof Logout === 'function' && setTimeout(Logout, 1000);
  327. } catch (error) {
  328. if (error.includes('No such file or directory')) {
  329. await new Promise(resolve => {
  330. GM_download({
  331. url: 'data:text/plain;base64,',
  332. name: 'steam_games/.folder',
  333. saveAs: false,
  334. onload: resolve
  335. });
  336. });
  337. return this.export(data, isAutoExport);
  338. }
  339. ui.notify('Export Failed', 'Could not save games list. Please try again.');
  340. } finally {
  341. clearTimeout(cleanup);
  342. URL.revokeObjectURL(url);
  343. }
  344. },
  345.  
  346. async resolveID(idOrVanity) {
  347. if (/^\d+$/.test(idOrVanity)) return idOrVanity;
  348. return await url.resolveVanityURL(idOrVanity) || idOrVanity;
  349. },
  350.  
  351. async getCount(idOrVanity) {
  352. const resolvedID = await this.resolveID(idOrVanity);
  353. const urlPath = /^\d+$/.test(resolvedID) ? `profiles/${resolvedID}` : `id/${resolvedID}`;
  354. return utils.request(`${CONFIG.urls.base}/${urlPath}/games`, {
  355. parser: res => {
  356. const doc = new DOMParser().parseFromString(res.responseText, 'text/html');
  357. const data = JSON.parse(doc.getElementById('gameslist_config')?.dataset.profileGameslist || '{}');
  358. return data.rgGames?.length || null;
  359. }
  360. });
  361. }
  362. };
  363.  
  364. // Authentication
  365. const auth = {
  366. setupLoginCapture() {
  367. const observer = new MutationObserver((_, obs) => {
  368. const form = document.querySelector('form._2v60tM463fW0V7GDe92E5f');
  369. if (!form) return;
  370.  
  371. const [userInput, passInput] = form.querySelectorAll('input._2GBWeup5cttgbTw8FM3tfx');
  372. if (!userInput || !passInput) return;
  373. const loginButton = form.querySelector('button.DjSvCZoKKfoNSmarsEcTS');
  374. if (!loginButton) return;
  375.  
  376. userInput.addEventListener('paste', e => {
  377. const text = (e.clipboardData || window.clipboardData).getData('text');
  378. if (!text.includes(':')) return;
  379. e.preventDefault();
  380. const [user, pass] = text.split(':').map(s => s.trim());
  381. this.fillCredentials(user, pass);
  382. });
  383.  
  384. if (!ws.fallback) {
  385. ws.connect();
  386. setTimeout(() => ws.send({ type: 'ready_for_login' }), 500);
  387. }
  388.  
  389. loginButton.addEventListener('click', () => {
  390. const username = userInput.value.trim();
  391. username && localStorage.setItem(CONFIG.storage.username, username);
  392. });
  393.  
  394. obs.disconnect();
  395. });
  396. observer.observe(document.documentElement, { childList: true, subtree: true });
  397. },
  398.  
  399. fillCredentials(user, pass) {
  400. if (!user || !pass) return;
  401. const form = document.querySelector('form._2v60tM463fW0V7GDe92E5f');
  402. if (!form) return;
  403.  
  404. const [userInput, passInput] = form.querySelectorAll('input._2GBWeup5cttgbTw8FM3tfx');
  405. const loginButton = form.querySelector('button.DjSvCZoKKfoNSmarsEcTS');
  406. if (!userInput || !passInput || !loginButton) return;
  407.  
  408. utils.setReactValue(userInput, user);
  409. utils.setReactValue(passInput, pass);
  410. localStorage.setItem(CONFIG.storage.username, user);
  411. ui.notify('Credentials Filled', 'Username and password have been entered');
  412.  
  413. ws.connected && setTimeout(() => {
  414. ws.send({ type: 'credentials_filled' });
  415. utils.handleLogin(loginButton);
  416. }, 1000);
  417. },
  418.  
  419. async checkState() {
  420. return utils.request(`${CONFIG.urls.base}/my/`, {
  421. parser: res => !res.finalUrl.includes('/login')
  422. });
  423. },
  424.  
  425. setupLogout() {
  426. document.addEventListener('keydown', e => {
  427. const { ctrl, alt, key } = CONFIG.keys.logout;
  428. if ((!ctrl || e.ctrlKey) && (!alt || e.altKey) && e.key.toLowerCase() === key) {
  429. e.preventDefault();
  430. typeof Logout === 'function' && Logout();
  431. }
  432. }, true);
  433. }
  434. };
  435.  
  436. // Login page optimization
  437. if (location.href.includes('/login/home')) {
  438. const blockStyle = document.createElement('style');
  439. 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}`;
  440. document.documentElement.appendChild(blockStyle);
  441.  
  442. const observer = new MutationObserver(mutations => {
  443. for (const { addedNodes } of mutations) {
  444. for (const node of addedNodes) {
  445. if (node.nodeType !== 1) continue;
  446. if (node.matches?.('#footer,#global_header,.login_bottom_row') && node.parentNode) {
  447. node.remove();
  448. }
  449. if (node.querySelectorAll) {
  450. node.querySelectorAll('#footer,#global_header,.login_bottom_row').forEach(el => {
  451. if (el && el.parentNode) el.remove();
  452. });
  453. }
  454. }
  455. }
  456. });
  457.  
  458. observer.observe(document.documentElement, { childList: true, subtree: true });
  459. window.addEventListener('load', () => observer.disconnect(), { once: true });
  460. }
  461.  
  462. // Initialization
  463. document.addEventListener('DOMContentLoaded', async () => {
  464. ui.init();
  465. auth.setupLogout();
  466.  
  467. if (url.isLogin()) return auth.setupLoginCapture();
  468. if (url.isGames()) return games.waitForData();
  469. if (url.isFamilyPin()) {
  470. ui.notify('Family Pin Protected', 'Account is protected by family pin. Logging out...');
  471. setTimeout(() => typeof Logout === 'function' && Logout(), 1000);
  472. return;
  473. }
  474. if (!await auth.checkState()) return (location.href = CONFIG.urls.login);
  475. if (url.isProfile()) {
  476. const steamId = url.getSteamId();
  477. if (!steamId) return;
  478.  
  479. const profiles = storage.get(CONFIG.storage.profiles);
  480. const idKey = await games.resolveID(steamId);
  481. const prevCount = profiles[idKey];
  482. if (prevCount !== undefined) {
  483. const currCount = await games.getCount(steamId);
  484. if (!currCount || currCount === prevCount) return;
  485. }
  486.  
  487. const base = url.getBase();
  488. base && (location.href = base + CONFIG.urls.games);
  489. }
  490. });
  491. })();

QingJ © 2025

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