回复
5
查看
864
收藏
14

68

赠楼

3%

赠楼率

615

蒸汽

111

主题

1639

帖子

2667

积分
发表于 2026-8-6 22:27:13 · 河北 | 显示全部楼层 |阅读模式
本文为 其乐用户(UID:1574722) 发布的原创文章,转摘前请联系该用户获得许可
本帖最后由 1574722 于 2026-8-11 10:46 编辑

实在是受不了新版市场详情页这一大串的上架物品列表了,
特别是上架胶囊这种大量商品的时候,要看个价格还得往下拉好久
于是用AI跑了一个油猴脚本,顺便加了一个快速下架的操作
界面清爽太多了有木有




PS. 下架顺序是优先下架最晚上架的商品
功能蛮简单的也就不做过多介绍了,脚本已经上架greasyfork 链接如下
https://greasyfork.org/zh-CN/scripts/590179-steam-market-listings-group

版本目前更新到2.3.0
添加了一个根据上架商品数量决定是否插入统计表格的阈值配置


有需求自取,谢谢大家!


有些朋友可能无法访问greasyfork,直接贴下代码吧


  1. // ==UserScript==
  2. // @name         Steam Market Listings Group
  3. // @namespace    https://steamcommunity.com/
  4. // @version      2.6.2
  5. // @description  在Steam市场饰品详情页聚合显示已上架物品,按上架日期与价格分组,支持批量下架与改价重新上架
  6. // @author       RayRoad
  7. // @match        *://steamcommunity.com/market/listings/*
  8. // @homepageURL  https://keylol.com/t1045939-1-1
  9. // @updateURL    https://greasyfork.org/zh-CN/scripts/590179-steam-market-listings-group
  10. // @downloadURL  https://greasyfork.org/zh-CN/scripts/590179-steam-market-listings-group/code/Steam%20Market%20Listings%20Group.user.js
  11. // @grant        GM_addStyle
  12. // @grant        GM_getValue
  13. // @grant        GM_setValue
  14. // @grant        GM_registerMenuCommand
  15. // @grant        unsafeWindow
  16. // @run-at       document-end
  17. // @license      MIT
  18. // ==/UserScript==

  19. (() => {
  20.   'use strict';

  21.   // ── Configuration ──────────────────────────────────────
  22.   const CONFIG = {
  23.     minOrders: GM_getValue('minOrders', 5),
  24.   };

  25.   // 已知游戏的非默认 contextid 映射(其余游戏默认 context 为 2,Steam 社区物品为 6)
  26.   const KNOWN_APP_CONTEXT = { '753': '6' };
  27.   const DEFAULT_CONTEXT_ID = '2';

  28.   // 2025年12月Steam市场规则变更:这些货币手续费用 Math.round 而非 Math.floor
  29.   const CURRENCY_CODES_TO_ROUND = ['JPY', 'IDR', 'UAH', 'CLP', 'COP', 'TWD', 'KZT', 'CRC', 'UYU', 'KRW', 'VND'];
  30.   // wallet_currency ID → 货币代码(部分:25/32/33/37 经真实环境验证;宁缺毋错,未收录 id 走符号推断)
  31.   const CURRENCY_ID_TO_CODE = {
  32.     1: 'USD', 2: 'GBP', 3: 'EUR', 4: 'CHF', 5: 'RUB', 6: 'PLN', 7: 'BRL',
  33.     9: 'NOK', 10: 'IDR', 11: 'MYR', 12: 'PHP', 13: 'SGD', 14: 'THB', 15: 'VND',
  34.     16: 'KRW', 17: 'TRY', 18: 'UAH', 19: 'MXN', 20: 'CAD', 21: 'AUD', 22: 'NZD',
  35.     25: 'CNY', 32: 'TWD', 33: 'HKD', 37: 'JPY',
  36.   };
  37.   // 2025年12月Steam变更:单笔手续费最低额提高到 $0.01 等值(国区 ¥0.07,旧值 0.01 元);
  38.   // 已用真实挂单数据验证:到手 0.85 → 买方 1.00(Steam费4 + 发行商费8),到手 0.60 → 买方 0.74(两费各按最低 0.07)
  39.   // 仅收录有实测数据佐证的货币;其余货币依赖 wallet_fee_minimum,不臆造数值
  40.   const MIN_FEE_BY_CURRENCY = { 'CNY': 7 };
  41.   // 货币元数据:小数位数与符号格式(最低货币单位换算因子 unit = 10^decimals);
  42.   // 未收录货币回退 {decimals:2, symbol:'
  43. , prefix:true}(USD 规则)
  44.   const CURRENCY_META = {
  45.     USD: { decimals: 2, symbol: '
  46. , prefix: true },   EUR: { decimals: 2, symbol: '€', prefix: true },
  47.     GBP: { decimals: 2, symbol: '£', prefix: true },   CNY: { decimals: 2, symbol: '¥', prefix: true },
  48.     JPY: { decimals: 0, symbol: '¥', prefix: true },   KRW: { decimals: 0, symbol: '₩', prefix: true },
  49.     RUB: { decimals: 2, symbol: '₽', prefix: true },   BRL: { decimals: 2, symbol: 'R
  50. , prefix: true },
  51.     MXN: { decimals: 2, symbol: 'MX
  52. , prefix: true }, CAD: { decimals: 2, symbol: 'C
  53. , prefix: true },
  54.     AUD: { decimals: 2, symbol: 'A
  55. , prefix: true },  NZD: { decimals: 2, symbol: 'NZ
  56. , prefix: true },
  57.     CHF: { decimals: 2, symbol: 'CHF', prefix: true }, HKD: { decimals: 2, symbol: 'HK
  58. , prefix: true },
  59.     TWD: { decimals: 0, symbol: 'NT
  60. , prefix: true }, THB: { decimals: 2, symbol: '฿', prefix: true },
  61.     INR: { decimals: 2, symbol: '₹', prefix: true },   IDR: { decimals: 0, symbol: 'Rp', prefix: true },
  62.     MYR: { decimals: 2, symbol: 'RM', prefix: true },  PHP: { decimals: 2, symbol: '₱', prefix: true },
  63.     SGD: { decimals: 2, symbol: 'S
  64. , prefix: true },  TRY: { decimals: 2, symbol: '₺', prefix: true },
  65.     UAH: { decimals: 2, symbol: '₴', prefix: true },   VND: { decimals: 0, symbol: '₫', prefix: false },
  66.     ZAR: { decimals: 2, symbol: 'R', prefix: true },   NOK: { decimals: 2, symbol: 'kr', prefix: false },
  67.     SEK: { decimals: 2, symbol: 'kr', prefix: false }, DKK: { decimals: 2, symbol: 'kr', prefix: false },
  68.     PLN: { decimals: 2, symbol: 'zł', prefix: false }, CZK: { decimals: 2, symbol: 'Kč', prefix: false },
  69.     AED: { decimals: 2, symbol: 'AED', prefix: true }, SAR: { decimals: 2, symbol: 'SAR', prefix: true },
  70.     ILS: { decimals: 2, symbol: '₪', prefix: true },   CLP: { decimals: 0, symbol: '
  71. , prefix: true },
  72.     COP: { decimals: 0, symbol: '
  73. , prefix: true },   CRC: { decimals: 0, symbol: '₡', prefix: true },
  74.     UYU: { decimals: 2, symbol: '$U', prefix: true },  KZT: { decimals: 2, symbol: '₸', prefix: true },
  75.     QAR: { decimals: 2, symbol: 'QR', prefix: true },  KWD: { decimals: 3, symbol: 'KD', prefix: true },
  76.   };
  77.   // 价格字符串前缀 → 货币代码(无钱包信息的新版 SSR 页面用;匹配时最长前缀优先)
  78.   const SYMBOL_TO_CODE = {
  79.     '¥': 'CNY', '¥': 'CNY', '€': 'EUR', '£': 'GBP', '₩': 'KRW', '₽': 'RUB',
  80.     '₹': 'INR', '₺': 'TRY', '₴': 'UAH', '฿': 'THB', '₱': 'PHP', '₡': 'CRC',
  81.     '₸': 'KZT', '₪': 'ILS', '₫': 'VND', 'R
  82. : 'BRL', 'MX
  83. : 'MXN', 'C
  84. : 'CAD',
  85.     'A
  86. : 'AUD', 'NZ
  87. : 'NZD', 'HK
  88. : 'HKD', 'NT
  89. : 'TWD', 'S
  90. : 'SGD',
  91.     'RM': 'MYR', 'zł': 'PLN', 'Kč': 'CZK', 'kr': 'SEK', '
  92. : 'USD',
  93.   };

  94.   GM_registerMenuCommand('\u2699 \u8BBE\u7F6E\u6700\u5C11\u68C0\u6D4B\u4E0A\u67B6\u6570\u91CF\u9608\u503C', () => {
  95.     const val = prompt(`\u8BF7\u8F93\u5165\u6700\u5C11\u68C0\u6D4B\u4E0A\u67B6\u6570\u91CF\u9608\u503C\uFF08\u5F53\u524D: ${CONFIG.minOrders}\uFF09\n\u4E0A\u67B6\u5546\u54C1\u6570 \u2264 \u6B64\u503C\u65F6\u4E0D\u663E\u793A\u6C47\u603B\u9762\u677F`, String(CONFIG.minOrders));
  96.     if (val === null) return;
  97.     const num = parseInt(val, 10);
  98.     if (isNaN(num) || num < 0) {
  99.       alert('\u8BF7\u8F93\u5165\u6709\u6548\u7684\u975E\u8D1F\u6574\u6570');
  100.       return;
  101.     }
  102.     CONFIG.minOrders = num;
  103.     GM_setValue('minOrders', num);
  104.     alert(`\u5DF2\u4FDD\u5B58\uFF0C\u9608\u503C: ${num}\u3002\u5237\u65B0\u9875\u9762\u540E\u751F\u6548`);
  105.   });

  106.   // ── Styles ──────────────────────────────────────────────

  107.   GM_addStyle(`
  108.     #smg-panel {
  109.       box-sizing: border-box;
  110.       background: linear-gradient(180deg, #1d1f23 0%, #23262e 100%);
  111.       border: 1px solid #3d4450; border-radius: 4px;
  112.       padding: 16px 20px; margin: 12px 0 16px; width: 100%;
  113.       font-family: "Motiva Sans", Arial, Helvetica, sans-serif; color: #c7d5e0;
  114.     }
  115.     .smg-header {
  116.       display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px;
  117.     }
  118.     .smg-title { color: #66c0f4; font-size: 15px; font-weight: 500; margin: 0; }
  119.     .smg-toggle-btn {
  120.       background: none; border: 1px solid #3d4450; color: #8f98a0; cursor: pointer;
  121.       padding: 4px 12px; border-radius: 3px; font-size: 12px; transition: all .15s;
  122.     }
  123.     .smg-toggle-btn:hover { border-color: #66c0f4; color: #66c0f4; }

  124.     .smg-table { width: 100%; border-collapse: collapse; }
  125.     .smg-table th {
  126.       color: #8f98a0; text-align: left; padding: 6px 8px; font-size: 12px;
  127.       border-bottom: 1px solid #3d4450; font-weight: 500;
  128.     }
  129.     .smg-table td {
  130.       color: #c7d5e0; padding: 7px 8px; font-size: 13px;
  131.       border-bottom: 1px solid rgba(61,68,80,.4);
  132.     }
  133.     .smg-table tr:last-child td { border-bottom: none; }
  134.     .smg-table tr:hover td { background: rgba(102,192,244,.04); }
  135.     .smg-date-cell { white-space: nowrap; color: #66c0f4; font-weight: 500; width: 110px; }
  136.     .smg-prices-cell { color: #acb2b8; line-height: 1.8; }
  137.     .smg-price-tag {
  138.       display: inline-block; background: rgba(102,192,244,.08); border: 1px solid rgba(102,192,244,.15);
  139.       border-radius: 3px; padding: 1px 7px; margin: 2px 3px 2px 0; font-size: 12px; color: #c7d5e0;
  140.     }
  141.     .smg-price-tag .smg-ct { color: #8f98a0; margin-left: 3px; }
  142.     .smg-price-tag .smg-sp { color: #7a8a96; font-size: 11px; margin-left: 2px; }
  143.     .smg-count-cell { text-align: right; white-space: nowrap; color: #acb2b8; width: 60px; }
  144.     .smg-total-row td {
  145.       border-top: 1px solid #3d4450; font-weight: 500; color: #66c0f4; padding-top: 10px;
  146.     }
  147.     #smg-panel.smg-collapsed .smg-table-wrap { display: none; }

  148.     .smg-delist-section {
  149.       margin-top: 14px; padding-top: 12px; border-top: 1px solid #3d4450;
  150.     }
  151.     .smg-delist-row {
  152.       display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
  153.     }
  154.     .smg-delist-label { color: #8f98a0; font-size: 13px; white-space: nowrap; }
  155.     .smg-delist-select {
  156.       background: #31404d; border: 1px solid #3d4450; color: #c7d5e0;
  157.       padding: 5px 8px; border-radius: 3px; font-size: 13px; min-width: 140px;
  158.     }
  159.     .smg-delist-input {
  160.       background: #31404d; border: 1px solid #3d4450; color: #c7d5e0;
  161.       padding: 5px 8px; border-radius: 3px; font-size: 13px; width: 70px; text-align: center;
  162.     }
  163.     .smg-delist-btn {
  164.       background: linear-gradient(180deg, #c44 0%, #a33 100%); border: none;
  165.       color: #fff; padding: 5px 16px; border-radius: 3px; cursor: pointer;
  166.       font-size: 13px; transition: opacity .15s;
  167.     }
  168.     .smg-delist-btn:hover { opacity: .85; }
  169.     .smg-delist-btn:disabled { opacity: .5; cursor: default; }
  170.     .smg-delist-all-btn { margin-left: auto; }
  171.     .smg-relist-btn {
  172.       background: linear-gradient(180deg, #66c0f4 0%, #417a9b 100%); border: none;
  173.       color: #fff; padding: 5px 16px; border-radius: 3px; cursor: pointer;
  174.       font-size: 13px; transition: opacity .15s;
  175.     }
  176.     .smg-relist-btn:hover { opacity: .85; }
  177.     .smg-relist-btn:disabled { opacity: .5; cursor: default; }
  178.     .smg-price-input {
  179.       background: #31404d; border: 1px solid #3d4450; color: #c7d5e0;
  180.       padding: 6px 10px; border-radius: 3px; font-size: 14px; width: 140px; text-align: center;
  181.     }
  182.     .smg-relist-price-grid {
  183.       display: grid; grid-template-columns: 1fr 1fr; column-gap: 14px; row-gap: 6px; margin-bottom: 10px;
  184.     }
  185.     .smg-relist-price-label {
  186.       color: #8f98a0; font-size: 13px; white-space: nowrap;
  187.     }
  188.     .smg-relist-price-grid .smg-price-input { width: 100%; box-sizing: border-box; }
  189.     .smg-delist-status { color: #8f98a0; font-size: 12px; margin-top: 8px; }
  190.     .smg-delist-status.smg-error { color: #e44; }
  191.     .smg-delist-status.smg-ok { color: #5c7; }

  192.     /* ── Loading overlay ── */
  193.     #smg-loading {
  194.       position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 99998;
  195.       background: rgba(27,40,56,.85);
  196.       display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 16px;
  197.       transition: opacity .25s ease;
  198.     }
  199.     #smg-loading.smg-fade-out { opacity: 0; pointer-events: none; }
  200.     .smg-spinner {
  201.       width: 36px; height: 36px;
  202.       border: 3px solid rgba(102,192,244,.2); border-top-color: #66c0f4;
  203.       border-radius: 50%; animation: smg-spin .7s linear infinite;
  204.     }
  205.     @keyframes smg-spin { to { transform: rotate(360deg); } }
  206.     .smg-loading-text { color: #8f98a0; font-size: 13px; font-family: "Motiva Sans", Arial, sans-serif; }

  207.     /* ── Confirm dialog ── */
  208.     .smg-confirm-overlay {
  209.       position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 99999;
  210.       background: rgba(0,0,0,.6);
  211.       display: flex; align-items: center; justify-content: center;
  212.     }
  213.     .smg-confirm-box {
  214.       background: #1b2838; border: 1px solid #3d4450; border-radius: 6px;
  215.       padding: 24px 28px; max-width: 400px; width: 90%;
  216.       box-shadow: 0 8px 32px rgba(0,0,0,.5);
  217.     }
  218.     .smg-confirm-title { color: #66c0f4; font-size: 15px; font-weight: 600; margin-bottom: 12px; }
  219.     .smg-confirm-body { color: #c7d5e0; font-size: 13px; line-height: 1.6; margin-bottom: 20px; }
  220.     .smg-confirm-body strong { color: #fff; }
  221.     .smg-confirm-actions { display: flex; gap: 10px; justify-content: flex-end; }
  222.     .smg-confirm-btn {
  223.       padding: 6px 20px; border-radius: 3px; font-size: 13px; cursor: pointer;
  224.       border: 1px solid #3d4450; transition: all .15s;
  225.     }
  226.     .smg-confirm-cancel { background: #31404d; color: #c7d5e0; }
  227.     .smg-confirm-cancel:hover { background: #3d4f60; }
  228.     .smg-confirm-ok {
  229.       background: linear-gradient(180deg, #c44 0%, #a33 100%); color: #fff; border-color: #a33;
  230.     }
  231.     .smg-confirm-ok:hover { opacity: .85; }
  232.     .smg-confirm-blue {
  233.       background: linear-gradient(180deg, #66c0f4 0%, #417a9b 100%); color: #fff; border-color: #417a9b;
  234.     }
  235.     .smg-confirm-blue:hover { opacity: .85; }

  236.     /* ── Delist progress bar ── */
  237.     .smg-progress-wrap {
  238.       margin-top: 8px; height: 6px; background: #31404d;
  239.       border-radius: 3px; overflow: hidden; display: none;
  240.     }
  241.     .smg-progress-wrap.smg-visible { display: block; }
  242.     .smg-progress-bar {
  243.       height: 100%; background: linear-gradient(90deg, #66c0f4, #5c7);
  244.       border-radius: 3px; transition: width .15s ease; width: 0%;
  245.     }
  246.   `);

  247.   // ── Utility ─────────────────────────────────────────────

  248.   function escapeHtml(str) {
  249.     const map = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' };
  250.     return str.replace(/[&<>"']/g, c => map[c]);
  251.   }

  252.   function formatDateParts(ts) {
  253.     const d = new Date(ts * 1000);
  254.     return {
  255.       y: d.getFullYear(),
  256.       m: String(d.getMonth() + 1).padStart(2, '0'),
  257.       day: String(d.getDate()).padStart(2, '0'),
  258.       h: String(d.getHours()).padStart(2, '0'),
  259.       min: String(d.getMinutes()).padStart(2, '0'),
  260.     };
  261.   }

  262.   function tsToDateKey(ts) {
  263.     const { y, m, day } = formatDateParts(ts);
  264.     return `${y}-${m}-${day}`;
  265.   }

  266.   function tsToDateTime(ts) {
  267.     const { y, m, day, h, min } = formatDateParts(ts);
  268.     return `${y}-${m}-${day} ${h}:${min}`;
  269.   }

  270.   // ── sessionid ────────────────────────────────────

  271.   // 从任意文本中提取 sessionid(兼容转义引号 "、大小写变体 sessionID/sessionId);
  272.   // Steam sessionid 为 8–48 位十六进制串
  273.   function extractSessionIdFromText(text) {
  274.     if (!text) return '';
  275.     const patterns = [
  276.       /g_sessionID\s*[:=]\s*\\?\s*["']([0-9a-fA-F]{8,48})\\?["']/,
  277.       /\\?["']g_sessionID\\?["']\s*\\?\s*:\s*\\?["']([0-9a-fA-F]{8,48})\\?["']/,
  278.       /\\?["']session[-_]?id\\?["']\s*\\?\s*:\s*\\?["']([0-9a-fA-F]{8,48})\\?["']/i,
  279.       /name=["']sessionid["']\s+content=["']([0-9a-fA-F]{8,48})["']/i,
  280.     ];
  281.     for (const re of patterns) {
  282.       const m = text.match(re);
  283.       if (m) return m[1];
  284.     }
  285.     return '';
  286.   }

  287.   // 递归扫描对象树中的 sessionid 字段(含嵌套 JSON 字符串内的转义形式)
  288.   function findSessionId(node, depth) {
  289.     if (!node || depth > 8) return '';
  290.     if (typeof node === 'string') {
  291.       return node.length > 20 ? extractSessionIdFromText(node) : '';
  292.     }
  293.     if (typeof node !== 'object') return '';
  294.     if (Array.isArray(node)) {
  295.       for (const v of node) {
  296.         const r = findSessionId(v, depth + 1);
  297.         if (r) return r;
  298.       }
  299.       return '';
  300.     }
  301.     for (const [k, v] of Object.entries(node)) {
  302.       if (/^session[-_]?id$/i.test(k) && typeof v === 'string' && /^[0-9a-fA-F]{8,48}$/.test(v)) return v;
  303.     }
  304.     for (const v of Object.values(node)) {
  305.       const r = findSessionId(v, depth + 1);
  306.       if (r) return r;
  307.     }
  308.     return '';
  309.   }

  310.   function scanSSRForSessionId() {
  311.     try {
  312.       if (unsafeWindow.SSR) {
  313.         const r = findSessionId(unsafeWindow.SSR, 0);
  314.         if (r) return r;
  315.       }
  316.     } catch (e) { /* SSR 结构不可遍历 */ }
  317.     return '';
  318.   }

  319.   // 本地同步来源:旧版工具打开变量 → meta 标签 → cookie → SSR 深度扫描 → 页面源码正则
  320.   let _sessionIdCache = '';
  321.   function getSessionId() {
  322.     if (_sessionIdCache) return _sessionIdCache;
  323.     let sid = '';
  324.     try { sid = unsafeWindow.g_sessionID || ''; } catch (e) { /* 旧版工具打开变量可能不存在 */ }
  325.     if (!sid) {
  326.       const meta = document.querySelector('meta[name="sessionid"]');
  327.       if (meta && meta.content) sid = meta.content;
  328.     }
  329.     if (!sid) {
  330.       const match = document.cookie.match(/(^|;\s*)sessionid=([^;]+)/);
  331.       if (match) sid = decodeURIComponent(match[2]);
  332.     }
  333.     if (!sid) sid = scanSSRForSessionId();
  334.     if (!sid) sid = extractSessionIdFromText(document.documentElement.innerHTML);
  335.     if (sid) _sessionIdCache = sid;
  336.     return sid;
  337.   }

  338.   // 本地全部落空时(新版 SSR 页面可能完全不含 sessionid):拉取其他 Steam 页面提取
  339.   async function fetchSessionIdFromRemote() {
  340.     for (const url of ['https://steamcommunity.com/my/inventory', 'https://steamcommunity.com/market/']) {
  341.       try {
  342.         const resp = await fetch(url, { credentials: 'include', redirect: 'follow' });
  343.         if (!resp.ok) continue;
  344.         const sid = extractSessionIdFromText(await resp.text());
  345.         if (sid) {
  346.           console.log(`[SMG] sessionid resolved via remote fetch: ${url}`);
  347.           return sid;
  348.         }
  349.       } catch (e) {
  350.         console.warn(`[SMG] sessionid remote fetch failed: ${url}`, e);
  351.       }
  352.     }
  353.     return '';
  354.   }

  355.   // 批量操作统一入口:同步本地链 → 异步远端兜底;成功结果缓存到会话结束
  356.   async function resolveSessionId() {
  357.     let sid = getSessionId();
  358.     if (!sid) {
  359.       sid = await fetchSessionIdFromRemote();
  360.       if (sid) _sessionIdCache = sid;
  361.     }
  362.     if (sid) {
  363.       console.log('[SMG] sessionid resolved');
  364.     } else {
  365.       console.warn('[SMG] sessionid not found via g_sessionID / meta / cookie / SSR scan / page source / remote fetch');
  366.     }
  367.     return sid;
  368.   }

  369.   // ── Currency ────────────────────────────────────

  370.   // 货币在会话内恒定:main() 提取订单后一次性解析,后续格式化/解析/费用计算共用同一 meta
  371.   let currencyMeta = null;

  372.   function buildCurrencyMeta(code) {
  373.     const spec = CURRENCY_META[code] || { decimals: 2, symbol: '
  374. , prefix: true };
  375.     return { code, decimals: spec.decimals, unit: Math.pow(10, spec.decimals), symbol: spec.symbol, prefix: spec.prefix };
  376.   }

  377.   // 价格字符串前缀 → 货币代码(最长前缀优先,如 MX$ 先于 $)
  378.   function detectCodeBySymbol(priceStr) {
  379.     if (!priceStr) return '';
  380.     const symbols = Object.keys(SYMBOL_TO_CODE).sort((a, b) => b.length - a.length);
  381.     for (const sym of symbols) {
  382.       if (priceStr.startsWith(sym)) return SYMBOL_TO_CODE[sym];
  383.     }
  384.     return '';
  385.   }

  386.   // 货币解析优先级:① 钱包信息(wallet_currency ID → GetCurrencyCode/本地映射)
  387.   // → ② 订单价格符号推断(新版 SSR 页面常见无钱包信息)→ ③ USD 默认规则
  388.   function resolveCurrency() {
  389.     try {
  390.       const wi = unsafeWindow.g_rgWalletInfo;
  391.       if (wi && wi.wallet_currency != null) {
  392.         let code = '';
  393.         try {
  394.           if (typeof unsafeWindow.GetCurrencyCode === 'function') code = unsafeWindow.GetCurrencyCode(wi.wallet_currency) || '';
  395.         } catch (e) { /* 使用本地映射表 */ }
  396.         code = code || CURRENCY_ID_TO_CODE[wi.wallet_currency] || '';
  397.         if (code) {
  398.           currencyMeta = buildCurrencyMeta(code);
  399.           console.log(`[SMG] Currency resolved: ${code} (via wallet info)`);
  400.           return currencyMeta;
  401.         }
  402.       }
  403.     } catch (e) { /* 继续符号推断 */ }

  404.     const sample = (allOrders && allOrders[0]) ? (allOrders[0].buyerPrice || allOrders[0].sellerPrice || '') : '';
  405.     const code = detectCodeBySymbol(sample);
  406.     if (code) {
  407.       currencyMeta = buildCurrencyMeta(code);
  408.       console.log(`[SMG] Currency resolved: ${code} (via symbol)`);
  409.       return currencyMeta;
  410.     }

  411.     console.warn('[SMG] Currency detection failed; falling back to USD rules');
  412.     currencyMeta = buildCurrencyMeta('USD');
  413.     return currencyMeta;
  414.   }

  415.   function getCurrencyMeta() {
  416.     if (!currencyMeta) resolveCurrency();
  417.     return currencyMeta;
  418.   }

  419.   // 最低货币单位整数 → 展示字符串(前缀货币符号后补空格,与 SSR 的 "¥ 0.95" 格式一致)
  420.   function formatMoney(cents, meta) {
  421.     const m = meta || getCurrencyMeta();
  422.     const amount = (cents / m.unit).toFixed(m.decimals);
  423.     return m.prefix ? `${m.symbol} ${amount}` : `${amount} ${m.symbol}`;
  424.   }

  425.   // 用户输入金额 → 最低货币单位整数;兼容千分位逗号与欧式小数逗号,非法/≤0 返回 0
  426.   function parseAmount(str, meta) {
  427.     const m = meta || getCurrencyMeta();
  428.     let s = String(str).replace(/\s/g, '');
  429.     if (m.symbol) s = s.split(m.symbol).join('');
  430.     if (s.includes(',') && s.includes('.')) {
  431.       s = s.replace(/,/g, '');                 // 同时含 , 与 . :逗号为千分位
  432.     } else if (/\d,\d{3}(?:\D|$)/.test(s)) {
  433.       s = s.replace(/,/g, '');                 // 仅逗号且其后恰为 3 位数字:千分位
  434.     } else {
  435.       s = s.replace(',', '.');                 // 其余情况:欧式小数逗号
  436.     }
  437.     const num = parseFloat(s.replace(/[^\d.]/g, ''));
  438.     return (!isNaN(num) && num > 0) ? Math.round(num * m.unit) : 0;
  439.   }

  440.   // ── Loading Overlay ─────────────────────────────────────

  441.   function showLoading() {
  442.     if (document.getElementById('smg-loading')) return;
  443.     const el = document.createElement('div');
  444.     el.id = 'smg-loading';
  445.     el.innerHTML = '<div class="smg-spinner"></div><div class="smg-loading-text">正在加载上架数据...</div>';
  446.     document.body.appendChild(el);
  447.   }

  448.   function hideLoading() {
  449.     const el = document.getElementById('smg-loading');
  450.     if (!el) return;
  451.     el.classList.add('smg-fade-out');
  452.     setTimeout(() => el.remove(), 300);
  453.   }

  454.   // ── Confirm Dialog ──────────────────────────────────────

  455.   function showConfirmDialog(message, opts) {
  456.     const { title = '确认下架', okText = '确认下架' } = opts || {};
  457.     return new Promise(resolve => {
  458.       const overlay = document.createElement('div');
  459.       overlay.className = 'smg-confirm-overlay';
  460.       overlay.innerHTML = `
  461.         <div class="smg-confirm-box">
  462.           <div class="smg-confirm-title">${title}</div>
  463.           <div class="smg-confirm-body">${message}</div>
  464.           <div class="smg-confirm-actions">
  465.             <button class="smg-confirm-btn smg-confirm-cancel" id="smg-confirm-cancel">取消</button>
  466.             <button class="smg-confirm-btn smg-confirm-ok" id="smg-confirm-ok">${okText}</button>
  467.           </div>
  468.         </div>`;
  469.       document.body.appendChild(overlay);

  470.       const cleanup = (result) => { overlay.remove(); resolve(result); };
  471.       overlay.querySelector('#smg-confirm-cancel').addEventListener('click', () => cleanup(false));
  472.       overlay.querySelector('#smg-confirm-ok').addEventListener('click', () => cleanup(true));
  473.       overlay.addEventListener('click', (e) => { if (e.target === overlay) cleanup(false); });
  474.     });
  475.   }

  476.   // ── Data Extraction ──────────────────────────────────────

  477.   function extractOrders() {
  478.     console.log('[SMG] Extracting orders from SSR data...');
  479.     const ssr = unsafeWindow.SSR?.loaderData;
  480.     if (!Array.isArray(ssr)) {
  481.       console.warn('[SMG] SSR data not available');
  482.       return null;
  483.     }

  484.     for (const entry of ssr) {
  485.       try {
  486.         const data = typeof entry === 'string' ? JSON.parse(entry) : entry;
  487.         const orders = data?.myOrders?.rgSellOrders;
  488.         if (Array.isArray(orders) && orders.length > 0) {
  489.           console.log(`[SMG] Found ${orders.length} sell orders`);
  490.           return orders.map(o => ({
  491.             listingid: o.listingid,
  492.             assetid: o.assetid,
  493.             appid: o.appid,
  494.             classid: o.classid,
  495.             rtListed: o.rtListed,
  496.             dateKey: tsToDateKey(o.rtListed),
  497.             dateTime: tsToDateTime(o.rtListed),
  498.             buyerPrice: (o.strBuyerPrice || '').trim(),
  499.             sellerPrice: (o.strSellerPrice || '').trim(),
  500.           }));
  501.         }
  502.       } catch (e) { /* skip */ }
  503.     }

  504.     console.warn('[SMG] No sell orders found in SSR data');
  505.     return null;
  506.   }

  507.   // ── Grouping ────────────────────────────────────────────

  508.   function groupOrders(orders) {
  509.     const dateMap = new Map();

  510.     for (const o of orders) {
  511.       if (!dateMap.has(o.dateKey)) {
  512.         dateMap.set(o.dateKey, { dateKey: o.dateKey, prices: new Map(), items: [] });
  513.       }
  514.       const dg = dateMap.get(o.dateKey);
  515.       if (!dg.prices.has(o.buyerPrice)) {
  516.         dg.prices.set(o.buyerPrice, []);
  517.       }
  518.       dg.prices.get(o.buyerPrice).push(o);
  519.       dg.items.push(o);
  520.     }

  521.     for (const g of dateMap.values()) {
  522.       for (const items of g.prices.values()) {
  523.         items.sort((a, b) => b.rtListed - a.rtListed);
  524.       }
  525.     }

  526.     return [...dateMap.values()]
  527.       .sort((a, b) => b.dateKey.localeCompare(a.dateKey))
  528.       .map(g => ({
  529.         dateKey: g.dateKey,
  530.         prices: g.prices,
  531.         sortedPrices: [...g.prices.keys()].sort((a, b) =>
  532.           parseFloat(a.replace(/[^0-9.]/g, '')) - parseFloat(b.replace(/[^0-9.]/g, ''))
  533.         ),
  534.         totalCount: g.items.length,
  535.       }));
  536.   }

  537.   // ── Rendering ───────────────────────────────────────────

  538.   function renderSummary(grouped, allOrders) {
  539.     const total = grouped.reduce((s, g) => s + g.totalCount, 0);

  540.     let rows = '';
  541.     for (const g of grouped) {
  542.       const tags = g.sortedPrices.map(p => {
  543.         const items = g.prices.get(p);
  544.         const cnt = items.length;
  545.         const sp = items[0].sellerPrice;
  546.         const sellerPart = sp ? `<span class="smg-sp">(${escapeHtml(sp)})</span>` : '';
  547.         return `<span class="smg-price-tag">${escapeHtml(p)}${sellerPart}<span class="smg-ct">x${cnt}</span></span>`;
  548.       }).join('');

  549.       rows += `<tr>
  550.         <td class="smg-date-cell">${escapeHtml(g.dateKey)}</td>
  551.         <td class="smg-prices-cell">${tags}</td>
  552.         <td class="smg-count-cell">${g.totalCount}</td>
  553.       </tr>`;
  554.     }

  555.     return `<div id="smg-panel">
  556.       <div class="smg-header">
  557.         <h3 class="smg-title">上架汇总 - 共 ${total} 件 / ${grouped.length} 个日期</h3>
  558.         <button class="smg-toggle-btn" id="smg-toggle">收起</button>
  559.       </div>
  560.       <div class="smg-table-wrap">
  561.         <table class="smg-table">
  562.           <thead><tr><th>日期</th><th>价格分布</th><th style="text-align:right">数量</th></tr></thead>
  563.           <tbody>${rows}
  564.             <tr class="smg-total-row">
  565.               <td>合计</td><td></td><td class="smg-count-cell">${total}</td>
  566.             </tr>
  567.           </tbody>
  568.         </table>
  569.       </div>
  570.       ${renderDelistSection(allOrders)}
  571.     </div>`;
  572.   }

  573.   function renderDelistSection(allOrders) {
  574.     const priceMap = new Map();
  575.     for (const o of allOrders) {
  576.       if (!priceMap.has(o.buyerPrice)) priceMap.set(o.buyerPrice, 0);
  577.       priceMap.set(o.buyerPrice, priceMap.get(o.buyerPrice) + 1);
  578.     }

  579.     const options = [...priceMap.entries()]
  580.       .sort((a, b) => a[0].localeCompare(b[0]))
  581.       .map(([p, c]) => `<option value="${escapeHtml(p)}">${escapeHtml(p)} (${c}件)</option>`)
  582.       .join('');

  583.     return `<div class="smg-delist-section">
  584.       <div class="smg-delist-row">
  585.         <span class="smg-delist-label">下架价格:</span>
  586.         <select class="smg-delist-select" id="smg-delist-price">
  587.           <option value="">选择价格</option>
  588.           ${options}
  589.         </select>
  590.         <span class="smg-delist-label">数量:</span>
  591.         <input type="number" class="smg-delist-input" id="smg-delist-qty" value="1" min="1" placeholder="数量">
  592.         <button class="smg-delist-btn" id="smg-delist-btn">下架</button>
  593.         <button class="smg-relist-btn" id="smg-relist-btn">重新上架</button>
  594.         <button class="smg-delist-btn smg-delist-all-btn" id="smg-delist-all-btn">全部下架</button>
  595.       </div>
  596.       <div class="smg-delist-status" id="smg-delist-status"></div>
  597.       <div class="smg-progress-wrap" id="smg-progress-wrap">
  598.         <div class="smg-progress-bar" id="smg-progress-bar"></div>
  599.       </div>
  600.     </div>`;
  601.   }

  602.   function refreshPanel(allOrders, grouped) {
  603.     const panel = document.getElementById('smg-panel');
  604.     if (!panel) return;
  605.     const wrapper = document.getElementById('smg-root');
  606.     if (wrapper) {
  607.       wrapper.innerHTML = renderSummary(grouped, allOrders);
  608.       if (!document.getElementById('smg-panel')) {
  609.         console.warn('[SMG] Panel not found after refresh');
  610.         return;
  611.       }
  612.     }
  613.     bindPanelEvents();
  614.   }

  615.   function bindPanelEvents() {
  616.     document.getElementById('smg-toggle')?.addEventListener('click', function () {
  617.       const p = document.getElementById('smg-panel');
  618.       p.classList.toggle('smg-collapsed');
  619.       this.textContent = p.classList.contains('smg-collapsed') ? '展开' : '收起';
  620.     });

  621.     // 处理器内部直接读写模块级 allOrders/grouped,确保批量操作回写后的最新状态被使用
  622.     document.getElementById('smg-delist-btn')?.addEventListener('click', () => {
  623.       handleDelist();
  624.     });

  625.     document.getElementById('smg-delist-all-btn')?.addEventListener('click', () => {
  626.       handleDelist(true);
  627.     });

  628.     document.getElementById('smg-relist-btn')?.addEventListener('click', () => {
  629.       handleRelist();
  630.     });
  631.   }

  632.   // 状态提示统一入口:autoHide=true 时 5 秒后自动清空(非进度类提示);
  633.   // 进度类消息传 false 保持常显,并顺带取消待执行的隐藏定时器
  634.   let _statusTimer = null;
  635.   function showStatus(el, text, cls, autoHide) {
  636.     if (!el) return;
  637.     clearTimeout(_statusTimer);
  638.     el.textContent = text;
  639.     el.className = 'smg-delist-status' + (cls ? ' ' + cls : '');
  640.     if (autoHide) {
  641.       _statusTimer = setTimeout(() => {
  642.         el.textContent = '';
  643.         el.className = 'smg-delist-status';
  644.       }, 5000);
  645.     }
  646.   }

  647.   async function handleDelist(removeAll) {
  648.     const priceSelect = document.getElementById('smg-delist-price');
  649.     const qtyInput = document.getElementById('smg-delist-qty');
  650.     const statusEl = document.getElementById('smg-delist-status');
  651.     const btn = document.getElementById('smg-delist-btn');
  652.     const allBtn = document.getElementById('smg-delist-all-btn');
  653.     const relistBtn = document.getElementById('smg-relist-btn');

  654.     // 互斥:任一批量流程进行中禁止启动另一个批量(比按钮禁用更可靠,不受重注入影响)
  655.     if (batchBusy) {
  656.       showStatus(statusEl, '已有批量操作进行中,请稍候', 'smg-error', true);
  657.       return;
  658.     }
  659.     const progressWrap = document.getElementById('smg-progress-wrap');
  660.     const progressBar = document.getElementById('smg-progress-bar');

  661.     const price = priceSelect.value;
  662.     const qty = parseInt(qtyInput.value, 10);

  663.     let toRemove;
  664.     if (removeAll) {
  665.       // 全部下架:忽略价格/数量选择,按上架时间从新到旧处理
  666.       toRemove = allOrders.slice().sort((a, b) => b.rtListed - a.rtListed);
  667.       if (toRemove.length === 0) {
  668.         showStatus(statusEl, '没有可下架的商品', 'smg-error', true);
  669.         return;
  670.       }
  671.     } else {
  672.       if (!price) {
  673.         showStatus(statusEl, '请选择价格', 'smg-error', true);
  674.         return;
  675.       }
  676.       if (!qty || qty < 1) {
  677.         showStatus(statusEl, '请输入有效数量', 'smg-error', true);
  678.         return;
  679.       }

  680.       const candidates = allOrders
  681.         .filter(o => o.buyerPrice === price)
  682.         .sort((a, b) => b.rtListed - a.rtListed);

  683.       if (candidates.length === 0) {
  684.         showStatus(statusEl, '该价格没有可下架的商品', 'smg-error', true);
  685.         return;
  686.       }

  687.       toRemove = candidates.slice(0, Math.min(qty, candidates.length));
  688.     }
  689.     const removedSet = new Set();

  690.     // Confirm dialog
  691.     const confirmMsg = removeAll
  692.       ? `即将下架全部 <strong>${toRemove.length}</strong> 件商品,确认继续?`
  693.       : `即将下架价格 <strong>${escapeHtml(price)}</strong> 的 <strong>${toRemove.length}</strong> 件商品,确认继续?`;
  694.     const confirmed = await showConfirmDialog(confirmMsg);
  695.     if (!confirmed) {
  696.       showStatus(statusEl, '已取消', '', true);
  697.       return;
  698.     }

  699.     const sessionid = await resolveSessionId();
  700.     if (!sessionid) {
  701.       showStatus(statusEl, '无法获取会话ID', 'smg-error', true);
  702.       return;
  703.     }

  704.     batchBusy = true;
  705.     btn.disabled = true;
  706.     if (allBtn) allBtn.disabled = true;
  707.     if (relistBtn) relistBtn.disabled = true;
  708.     progressWrap.classList.add('smg-visible');
  709.     progressBar.style.width = '0%';

  710.     let success = 0;
  711.     let failed = 0;

  712.     for (let i = 0; i < toRemove.length; i++) {
  713.       const item = toRemove[i];
  714.       const progress = ((i + 1) / toRemove.length * 100).toFixed(1);
  715.       progressBar.style.width = `${progress}%`;
  716.       showStatus(statusEl, `正在下架 ${i + 1}/${toRemove.length}...`);

  717.       try {
  718.         const resp = await fetch(`https://steamcommunity.com/market/removelisting/${item.listingid}`, {
  719.           method: 'POST',
  720.           credentials: 'include',
  721.           headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  722.           body: `sessionid=${encodeURIComponent(sessionid)}`,
  723.         });
  724.         if (resp.ok) {
  725.           success++;
  726.           removedSet.add(item.listingid);
  727.         } else {
  728.           failed++;
  729.           console.warn(`[SMG] Failed to delist ${item.listingid}: HTTP ${resp.status}`);
  730.         }
  731.       } catch (e) {
  732.         failed++;
  733.         console.warn(`[SMG] Failed to delist ${item.listingid}:`, e);
  734.       }
  735.     }

  736.     btn.disabled = false;
  737.     if (allBtn) allBtn.disabled = false;
  738.     if (relistBtn) relistBtn.disabled = false;
  739.     setTimeout(() => progressWrap.classList.remove('smg-visible'), 1500);
  740.     batchBusy = false;

  741.     // 回写模块级状态:reinjectObserver 重注入与后续按钮回调都依赖它,
  742.     // 否则 hydration 重注入会用陈旧数据把已下架商品"复活"到面板
  743.     allOrders = allOrders.filter(o => !removedSet.has(o.listingid));
  744.     grouped = groupOrders(allOrders);
  745.     refreshPanel(allOrders, grouped);
  746.     // 完成提示写入刷新后的新面板(refreshPanel 重建 DOM 会擦掉旧状态),5 秒后自动消失
  747.     showStatus(document.getElementById('smg-delist-status'),
  748.       `下架完成: 成功 ${success} 件` + (failed > 0 ? `, 失败 ${failed} 件` : ''),
  749.       failed > 0 ? 'smg-error' : 'smg-ok', true);
  750.   }

  751.   // ── Relist ─────────────────────────────────────────

  752.   // 钱包费率信息(仅缓存从 g_rgWalletInfo 成功读取的结果,回退值不缓存)
  753.   let _walletInfoCache = null;
  754.   function getWalletInfo() {
  755.     if (_walletInfoCache) return _walletInfoCache;
  756.     try {
  757.       const wi = unsafeWindow.g_rgWalletInfo;
  758.       if (wi && wi.wallet_currency != null) {
  759.         // 货币代码/小数位统一走 resolveCurrency,与格式化/解析入口保持一致
  760.         const m = getCurrencyMeta();
  761.         _walletInfoCache = {
  762.           feePercent: parseFloat(wi.wallet_fee_percent) || 0.05,
  763.           feeBase: parseInt(wi.wallet_fee_base, 10) || 0,
  764.           feeMinimum: parseInt(wi.wallet_fee_minimum, 10) || 1,
  765.           pubPercentDefault: parseFloat(wi.wallet_publisher_fee_percent_default) || 0.10,
  766.           currency: wi.wallet_currency,
  767.           currencyCode: m.code,
  768.           useRound: CURRENCY_CODES_TO_ROUND.includes(m.code),
  769.           decimals: m.decimals,
  770.           unit: m.unit,
  771.         };
  772.         return _walletInfoCache;
  773.       }
  774.     } catch (e) { /* 回退默认费率 */ }
  775.     // 默认值不缓存,下次调用重试 g_rgWalletInfo;
  776.     // 无钱包信息时(新版 SSR 页面常见)货币由 resolveCurrency 统一解析(订单价格符号推断 → USD 默认)
  777.     const m = getCurrencyMeta();
  778.     return { feePercent: 0.05, feeBase: 0, feeMinimum: 1, pubPercentDefault: 0.10, currency: m.code === 'CNY' ? 25 : 1, currencyCode: m.code, useRound: CURRENCY_CODES_TO_ROUND.includes(m.code), decimals: m.decimals, unit: m.unit };
  779.   }
  780.   
  781.   function getFeeRates(appId) {
  782.     const wi = getWalletInfo();
  783.     let pubRate = wi.pubPercentDefault;
  784.     try {
  785.       // app 级发行商费率优先(官方 economy_v2.js 同样优先读 market_pubfee_rate)
  786.       const appCtx = unsafeWindow.g_rgAppContextData && unsafeWindow.g_rgAppContextData[appId];
  787.       if (appCtx && appCtx.market_pubfee_rate != null && !isNaN(parseFloat(appCtx.market_pubfee_rate))) {
  788.         pubRate = parseFloat(appCtx.market_pubfee_rate);
  789.       }
  790.     } catch (e) { /* 保持钱包默认费率 */ }
  791.     return { pubRate, steamRate: wi.feePercent, feeBase: wi.feeBase, feeMinimum: wi.feeMinimum, currencyCode: wi.currencyCode, useRound: wi.useRound, decimals: wi.decimals, unit: wi.unit };
  792.   }
  793.   
  794.   // 卖方到手价 -> 买方支付(百分比 → 加 base → 取整 → 与最低费取 max;
  795.   // 2025年12月起最低费为 $0.01 等值,国区 ¥0.07,见 MIN_FEE_BY_CURRENCY)
  796.   function calcBuyerTotal(receivedCents, appId) {
  797.     const { pubRate, steamRate, feeBase, feeMinimum, currencyCode, useRound } = getFeeRates(appId);
  798.     const effMin = Math.max(feeMinimum, MIN_FEE_BY_CURRENCY[currencyCode] || 0);
  799.     const roundFee = useRound ? Math.round : Math.floor;
  800.     const steamFee = Math.max(parseInt(roundFee(receivedCents * steamRate + feeBase), 10), effMin);
  801.     const publisherFee = pubRate > 0 ? Math.max(parseInt(roundFee(receivedCents * pubRate), 10), effMin) : 0;
  802.     return { total: receivedCents + steamFee + publisherFee, fee: steamFee + publisherFee, steamFee, publisherFee };
  803.   }
  804.   
  805.   // 买方支付 -> 卖方到手价(迭代逼近,与参考实现 calcReceivedAmount 一致)
  806.   function calcReceivedFromTotal(buyerCents, appId) {
  807.     let estimated = buyerCents;
  808.     let lastAmount = 0;
  809.     for (let i = 0; i < 10; i++) {
  810.       const { total, fee } = calcBuyerTotal(estimated, appId);
  811.       if (total === buyerCents || total === lastAmount) return estimated;
  812.       lastAmount = total;
  813.       estimated = buyerCents - fee;
  814.       if (estimated <= 0) return 0;
  815.     }
  816.     return estimated;
  817.   }

  818.   function showPriceDialog(oldPrice, count, appId) {
  819.     return new Promise(resolve => {
  820.       const meta = getCurrencyMeta();
  821.       // 输入框内只填数字(不带符号);小数位数随货币自适应(如 JPY 为整数)
  822.       const fmtAmount = cents => (cents / meta.unit).toFixed(meta.decimals);
  823.       const placeholder = (0).toFixed(meta.decimals);
  824.       const overlay = document.createElement('div');
  825.       overlay.className = 'smg-confirm-overlay';
  826.       overlay.innerHTML = `
  827.         <div class="smg-confirm-box">
  828.           <div class="smg-confirm-title">重新上架 - 输入新价格</div>
  829.           <div class="smg-confirm-body">
  830.             将价格 <strong>${escapeHtml(oldPrice)}</strong> 的 <strong>${count}</strong> 件商品以新价格重新上架:
  831.           </div>
  832.           <div style="margin-bottom: 20px;">
  833.             <div class="smg-relist-price-grid">
  834.               <span class="smg-relist-price-label">您将收到 (${escapeHtml(meta.symbol)}):</span>
  835.               <span class="smg-relist-price-label">买方支付 (${escapeHtml(meta.symbol)}):</span>
  836.               <input type="text" class="smg-price-input" id="smg-relist-received" placeholder="${placeholder}">
  837.               <input type="text" class="smg-price-input" id="smg-relist-buyer" placeholder="${placeholder}">
  838.             </div>
  839.           </div>
  840.           <div class="smg-confirm-actions">
  841.             <button class="smg-confirm-btn smg-confirm-cancel" id="smg-price-cancel">取消</button>
  842.             <button class="smg-confirm-btn smg-confirm-blue" id="smg-price-ok">下一步</button>
  843.           </div>
  844.         </div>`;
  845.       document.body.appendChild(overlay);

  846.       const receivedInput = overlay.querySelector('#smg-relist-received');
  847.       const buyerInput = overlay.querySelector('#smg-relist-buyer');
  848.       const cleanup = (result) => { overlay.remove(); resolve(result); };

  849.       receivedInput.addEventListener('input', () => {
  850.         const receivedCents = parseAmount(receivedInput.value, meta);
  851.         if (!receivedCents) { buyerInput.value = ''; return; }
  852.         buyerInput.value = fmtAmount(calcBuyerTotal(receivedCents, appId).total);
  853.       });

  854.       buyerInput.addEventListener('input', () => {
  855.         const buyerCents = parseAmount(buyerInput.value, meta);
  856.         if (!buyerCents) { receivedInput.value = ''; return; }
  857.         receivedInput.value = fmtAmount(calcReceivedFromTotal(buyerCents, appId));
  858.       });

  859.       overlay.querySelector('#smg-price-cancel').addEventListener('click', () => cleanup(null));
  860.       overlay.querySelector('#smg-price-ok').addEventListener('click', () => {
  861.         // 以"您将收到"为唯一事实来源重算买方价:sellitem 实际只提交 receivedCents,
  862.         // 若两输入框不一致仍原样提交,确认页展示的买方支付将与 Steam 实际挂单价不符
  863.         const receivedCents = parseAmount(receivedInput.value, meta);
  864.         if (!receivedCents) {
  865.           receivedInput.focus();
  866.           return;
  867.         }
  868.         const buyerCents = calcBuyerTotal(receivedCents, appId).total;
  869.         cleanup({ buyerCents, receivedCents });
  870.       });
  871.       [receivedInput, buyerInput].forEach(inp => inp.addEventListener('keydown', (e) => {
  872.         if (e.key === 'Enter') overlay.querySelector('#smg-price-ok').click();
  873.       }));
  874.       overlay.addEventListener('click', (e) => { if (e.target === overlay) cleanup(null); });
  875.       receivedInput.focus();
  876.     });
  877.   }

  878.   function getAppContext(appidHint) {
  879.     // appid: 优先订单数据自带字段,其次旧版工具打开变量,最后 URL 路径
  880.     let appId = appidHint || unsafeWindow.g_appId;
  881.     if (!appId) {
  882.       const m = location.pathname.match(/\/market\/listings\/(\d+)\//);
  883.       appId = m ? Number(m[1]) : 0;
  884.     }

  885.     // contextid: 多来源探测(新版 SSR 页面可能不提供旧版工具打开变量)
  886.     let contextId = '';

  887.     // 1) 旧版 g_rgAppContextData(兼容数组与对象两种结构)
  888.     const appCtx = unsafeWindow.g_rgAppContextData && unsafeWindow.g_rgAppContextData[appId];
  889.     if (Array.isArray(appCtx) && appCtx.length > 0 && appCtx[0].id) {
  890.       contextId = String(appCtx[0].id);
  891.     } else if (appCtx && typeof appCtx === 'object') {
  892.       if (appCtx.contexts && typeof appCtx.contexts === 'object') {
  893.         const first = Object.keys(appCtx.contexts).find(k => /^\d+$/.test(k));
  894.         if (first) contextId = first;
  895.       } else {
  896.         const first = Object.keys(appCtx).find(k => /^\d+$/.test(k));
  897.         if (first) contextId = first;
  898.       }
  899.     }

  900.     // 2) SSR loaderData 深度扫描 contextid 字段
  901.     if (!contextId) contextId = scanSSRForContextId();

  902.     // 3) 页面 HTML 源码(SSR 序列化 JSON / 链接参数)正则扫描
  903.     if (!contextId) {
  904.       const html = document.documentElement.innerHTML;
  905.       const m = html.match(/"contextid"\s*:\s*"?(\d+)"?/i)
  906.         || html.match(/contextid=(\d+)/i);
  907.       if (m) contextId = m[1];
  908.     }

  909.     if (!contextId) {
  910.       console.warn('[SMG] contextid not found; SSR keys:',
  911.         (unsafeWindow.SSR?.loaderData || []).map((e, i) => {
  912.           try { return `${i}:${typeof e === 'string' ? Object.keys(JSON.parse(e)).join(',') : Object.keys(e || {}).join(',')}`; }
  913.           catch (err) { return `${i}:?`; }
  914.         })
  915.       );
  916.     }
  917.     console.log(`[SMG] App context: appid=${appId}, contextid=${contextId || '(not found)'}`);
  918.     return { appId, contextId };
  919.   }

  920.   const sleep = ms => new Promise(r => setTimeout(r, ms));

  921.   // 下架回库后 assetid 可能变化:单次查库存,按 classid 定位可用资产
  922.   async function findReturnedAssetId(appId, contextId, classid, excludeIds) {
  923.     try {
  924.       const resp = await fetch(`https://steamcommunity.com/my/inventory/json/${appId}/${contextId}`, {
  925.         credentials: 'include',
  926.         headers: { 'Accept': 'application/json' },
  927.       });
  928.       if (!resp.ok) return '';
  929.       const data = await resp.json();
  930.       if (!data || !data.success || !data.rgInventory) return '';
  931.       for (const [assetId, entry] of Object.entries(data.rgInventory)) {
  932.         if (entry && String(entry.classid) === String(classid) && !excludeIds.has(assetId)) {
  933.           return assetId;
  934.         }
  935.       }
  936.     } catch (e) {
  937.       console.warn('[SMG] inventory lookup error:', e);
  938.     }
  939.     return '';
  940.   }

  941.   // 轮询等待物品回库并返回其(可能已变化的)assetid,超时返回空串
  942.   async function waitForReturnedAsset(appId, contextId, classid, excludeIds, timeoutMs) {
  943.     const start = Date.now();
  944.     while (Date.now() - start < timeoutMs) {
  945.       const assetId = await findReturnedAssetId(appId, contextId, classid, excludeIds);
  946.       if (assetId) return assetId;
  947.       await sleep(1500);
  948.     }
  949.     return '';
  950.   }

  951.   function scanSSRForContextId() {
  952.     const ssr = unsafeWindow.SSR?.loaderData;
  953.     if (!Array.isArray(ssr)) return '';
  954.     for (const entry of ssr) {
  955.       let obj = entry;
  956.       if (typeof entry === 'string') {
  957.         try { obj = JSON.parse(entry); } catch (e) { continue; }
  958.       }
  959.       const found = findContextId(obj, 0);
  960.       if (found) return found;
  961.     }
  962.     return '';
  963.   }

  964.   function findContextId(node, depth) {
  965.     if (!node || typeof node !== 'object' || depth > 8) return '';
  966.     if (Array.isArray(node)) {
  967.       for (const v of node) {
  968.         const r = findContextId(v, depth + 1);
  969.         if (r) return r;
  970.       }
  971.       return '';
  972.     }
  973.     // 直接字段 contextid / context_id
  974.     for (const [k, v] of Object.entries(node)) {
  975.       const lk = k.toLowerCase();
  976.       if ((lk === 'contextid' || lk === 'context_id') && (typeof v === 'string' || typeof v === 'number')) {
  977.         return String(v);
  978.       }
  979.     }
  980.     // contexts 映射 { "2": {...} }
  981.     for (const [k, v] of Object.entries(node)) {
  982.       const lk = k.toLowerCase();
  983.       if ((lk === 'contexts' || lk === 'rgcontexts' || lk === 'app_contexts') && v && typeof v === 'object') {
  984.         const first = Object.keys(v).find(key => /^\d+$/.test(key));
  985.         if (first) return first;
  986.       }
  987.     }
  988.     // 递归子工具点
  989.     for (const v of Object.values(node)) {
  990.       if (v && typeof v === 'object') {
  991.         const r = findContextId(v, depth + 1);
  992.         if (r) return r;
  993.       }
  994.     }
  995.     return '';
  996.   }

  997.   async function handleRelist() {
  998.     const priceSelect = document.getElementById('smg-delist-price');
  999.     const qtyInput = document.getElementById('smg-delist-qty');
  1000.     const statusEl = document.getElementById('smg-delist-status');
  1001.     const delistBtn = document.getElementById('smg-delist-btn');
  1002.     const relistBtn = document.getElementById('smg-relist-btn');
  1003.     const allBtn = document.getElementById('smg-delist-all-btn');

  1004.     // 互斥:任一批量流程进行中禁止启动另一个批量
  1005.     if (batchBusy) {
  1006.       showStatus(statusEl, '已有批量操作进行中,请稍候', 'smg-error', true);
  1007.       return;
  1008.     }
  1009.     const progressWrap = document.getElementById('smg-progress-wrap');
  1010.     const progressBar = document.getElementById('smg-progress-bar');

  1011.     const price = priceSelect.value;
  1012.     const qty = parseInt(qtyInput.value, 10);

  1013.     if (!price) {
  1014.       showStatus(statusEl, '请选择价格', 'smg-error', true);
  1015.       return;
  1016.     }
  1017.     if (!qty || qty < 1) {
  1018.       showStatus(statusEl, '请输入有效数量', 'smg-error', true);
  1019.       return;
  1020.     }

  1021.     const candidates = allOrders
  1022.       .filter(o => o.buyerPrice === price)
  1023.       .sort((a, b) => b.rtListed - a.rtListed);

  1024.     if (candidates.length === 0) {
  1025.       showStatus(statusEl, '该价格没有可操作的商品', 'smg-error', true);
  1026.       return;
  1027.     }

  1028.     const toRemove = candidates.slice(0, Math.min(qty, candidates.length));

  1029.     const sessionid = await resolveSessionId();
  1030.     if (!sessionid) {
  1031.       showStatus(statusEl, '无法获取会话ID', 'smg-error', true);
  1032.       return;
  1033.     }

  1034.     const { appId, contextId: pageContextId } = getAppContext(toRemove[0].appid);
  1035.     if (!appId) {
  1036.       showStatus(statusEl, '无法获取应用 appid,请刷新页面重试', 'smg-error', true);
  1037.       return;
  1038.     }

  1039.     // Step 1: 输入新价格(您将收到 / 买方支付 联动,含手续费计算)
  1040.     const priceResult = await showPriceDialog(price, toRemove.length, appId);
  1041.     if (priceResult === null) {
  1042.       showStatus(statusEl, '已取消', '', true);
  1043.       return;
  1044.     }
  1045.     const { buyerCents, receivedCents } = priceResult;

  1046.     // Step 2: 二次确认(金额带货币符号展示,随钱包货币自适应)
  1047.     const confirmed = await showConfirmDialog(
  1048.       `即将把价格 <strong>${escapeHtml(price)}</strong> 的 <strong>${toRemove.length}</strong> 件商品下架,` +
  1049.       `并以新价格重新上架(您将收到 <strong>${escapeHtml(formatMoney(receivedCents))}</strong>,` +
  1050.       `买方支付 <strong>${escapeHtml(formatMoney(buyerCents))}</strong>),确认继续?`,
  1051.       { title: '确认重新上架', okText: '确认重新上架' }
  1052.     );
  1053.     if (!confirmed) {
  1054.       showStatus(statusEl, '已取消', '', true);
  1055.       return;
  1056.     }

  1057.     // Step 3: contextid 解析(与参考实现 market.uset.js 一致:页面提取 → 静态映射 + 默认值)
  1058.     const contextId = pageContextId || KNOWN_APP_CONTEXT[String(appId)] || DEFAULT_CONTEXT_ID;
  1059.     if (!pageContextId) {
  1060.       console.log(`[SMG] contextid not found in page sources; using default '${contextId}' for app ${appId}`);
  1061.     }

  1062.     batchBusy = true;
  1063.     delistBtn.disabled = true;
  1064.     relistBtn.disabled = true;
  1065.     if (allBtn) allBtn.disabled = true;
  1066.     progressWrap.classList.add('smg-visible');
  1067.     progressBar.style.width = '0%';

  1068.     let success = 0;
  1069.     let delistFailed = 0;
  1070.     let relistFailed = 0;
  1071.     let lastSellMsg = '';
  1072.     // 已下架但重新上架失败的条目:物品已回库存、listingid 已失效,结束时须从本地数据移除
  1073.     const unlistedSet = new Set();
  1074.     // 同批次内已使用的回库 assetid,避免重复选中同一资产
  1075.     const usedAssetIds = new Set();
  1076.     const nowTs = Math.floor(Date.now() / 1000);

  1077.     for (let i = 0; i < toRemove.length; i++) {
  1078.       const item = toRemove[i];
  1079.       progressBar.style.width = `${((i + 1) / toRemove.length * 100).toFixed(1)}%`;
  1080.       showStatus(statusEl, `正在重新上架 ${i + 1}/${toRemove.length}...`);

  1081.       try {
  1082.         // 先下架旧挂单
  1083.         const rmResp = await fetch(`https://steamcommunity.com/market/removelisting/${item.listingid}`, {
  1084.           method: 'POST',
  1085.           credentials: 'include',
  1086.           headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  1087.           body: `sessionid=${encodeURIComponent(sessionid)}`,
  1088.         });
  1089.         if (!rmResp.ok) {
  1090.           delistFailed++;
  1091.           console.warn(`[SMG] Relist: failed to delist ${item.listingid}: HTTP ${rmResp.status}`);
  1092.           continue;
  1093.         }

  1094.         // 等待物品返回库存并重新定位 assetid(下架回库后 assetid 可能变化,旧值会报“物品不在库存”)
  1095.         showStatus(statusEl, `正在重新上架 ${i + 1}/${toRemove.length}: 等待物品返回库存...`);
  1096.         let sellAssetId = item.assetid;
  1097.         if (item.classid) {
  1098.           const returnedId = await waitForReturnedAsset(appId, contextId, item.classid, usedAssetIds, 10000);
  1099.           if (returnedId) {
  1100.             sellAssetId = returnedId;
  1101.             usedAssetIds.add(returnedId);
  1102.             if (returnedId !== item.assetid) {
  1103.               console.log(`[SMG] Relist: assetid changed after delist: ${item.assetid} -> ${returnedId}`);
  1104.             }
  1105.           } else {
  1106.             console.warn(`[SMG] Relist: returned asset not found by classid ${item.classid}; using original assetid ${item.assetid}`);
  1107.           }
  1108.         }

  1109.         // 再以新价格上架(price 为最低货币单位),失败则再等一次重试
  1110.         let sellOk = false;
  1111.         let sellMsg = '';
  1112.         let newListingId = '';
  1113.         for (let attempt = 0; attempt < 2 && !sellOk; attempt++) {
  1114.           if (attempt > 0) {
  1115.             showStatus(statusEl, `正在重新上架 ${i + 1}/${toRemove.length}: 重试上架...`);
  1116.             await sleep(1500);
  1117.           }
  1118.           sellMsg = '';
  1119.           try {
  1120.             const sellResp = await fetch('https://steamcommunity.com/market/sellitem/', {
  1121.               method: 'POST',
  1122.               credentials: 'include',
  1123.               headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  1124.               body: `sessionid=${encodeURIComponent(sessionid)}&appid=${appId}&contextid=${contextId}` +
  1125.                 `&assetid=${sellAssetId}&amount=1&price=${receivedCents}`,
  1126.             });
  1127.             sellOk = sellResp.ok;
  1128.             try {
  1129.               const data = await sellResp.json();
  1130.               if (data) {
  1131.                 if (data.success === false) sellOk = false;
  1132.                 if (data.message) sellMsg = String(data.message);
  1133.                 if (data.listingid) newListingId = String(data.listingid);
  1134.               }
  1135.             } catch (e) { /* 无法解析时以 HTTP 状态为准 */ }
  1136.             if (!sellOk) {
  1137.               console.warn(`[SMG] Relist: sellitem failed (attempt ${attempt + 1}): HTTP ${sellResp.status}` +
  1138.                 `${sellMsg ? ', message: ' + sellMsg : ''} (asset ${sellAssetId}, context ${contextId})`);
  1139.             }
  1140.           } catch (e) {
  1141.             console.warn(`[SMG] Relist: sellitem error (attempt ${attempt + 1}):`, e);
  1142.           }
  1143.         }
  1144.         if (!sellOk) {
  1145.           relistFailed++;
  1146.           unlistedSet.add(item.listingid);
  1147.           lastSellMsg = lastSellMsg || sellMsg;
  1148.           console.warn(`[SMG] Relist: failed to list ${sellAssetId} at ${receivedCents} (received)`);
  1149.           continue;
  1150.         }

  1151.         success++;
  1152.         // 就地更新本地数据(新挂单价格/时间/assetid),避免刷新页面
  1153.         // sellitem 生成的是全新挂单,旧 listingid 已随 removelisting 销毁,必须回写,
  1154.         // 否则后续对该条目下架会命中失效 id 且永远无法从面板移除
  1155.         if (newListingId) item.listingid = newListingId;
  1156.         item.assetid = sellAssetId;
  1157.         item.rtListed = nowTs;
  1158.         item.dateKey = tsToDateKey(nowTs);
  1159.         item.dateTime = tsToDateTime(nowTs);
  1160.         item.buyerPrice = formatMoney(buyerCents);
  1161.         item.sellerPrice = '';
  1162.       } catch (e) {
  1163.         delistFailed++;
  1164.         console.warn(`[SMG] Relist: failed for ${item.listingid}:`, e);
  1165.       }

  1166.       // 请求间隔,避免触发 Steam 限流
  1167.       if (i < toRemove.length - 1) await sleep(800);
  1168.     }

  1169.     let msg = `重新上架完成: 成功 ${success} 件`;
  1170.     if (delistFailed > 0) msg += `, 下架失败 ${delistFailed} 件`;
  1171.     if (relistFailed > 0) msg += `, 上架失败 ${relistFailed} 件(已回到库存${lastSellMsg ? ': ' + lastSellMsg : ''})`;
  1172.     delistBtn.disabled = false;
  1173.     relistBtn.disabled = false;
  1174.     if (allBtn) allBtn.disabled = false;
  1175.     batchBusy = false;

  1176.     // 回写模块级状态:移除"已下架但上架失败"的失效条目(物品已回库存,继续展示会与服务器状态脱节)
  1177.     if (unlistedSet.size > 0) {
  1178.       allOrders = allOrders.filter(o => !unlistedSet.has(o.listingid));
  1179.     }
  1180.     grouped = groupOrders(allOrders);
  1181.     refreshPanel(allOrders, grouped);
  1182.     // 完成提示写入刷新后的新面板(refreshPanel 重建 DOM 会擦掉旧状态),5 秒后自动消失
  1183.     showStatus(document.getElementById('smg-delist-status'), msg,
  1184.       ((delistFailed || relistFailed) ? 'smg-error' : 'smg-ok'), true);
  1185.   }

  1186.   // ── Main ────────────────────────────────────────────────

  1187.   function findTargetElement() {
  1188.     return document.querySelector(
  1189.       '[style="--background: var(--color-dull-5); --border: 2px solid; ' +
  1190.       '--border-color: var(--color-accent-8); --direction: column; --gap: var(--spacing-2);"]'
  1191.     );
  1192.   }

  1193.   function ensurePanel(allOrders, grouped, targetEl) {
  1194.     if (document.getElementById('smg-panel')) return;

  1195.     const wrapper = document.createElement('div');
  1196.     wrapper.id = 'smg-root';
  1197.     wrapper.innerHTML = renderSummary(grouped, allOrders);

  1198.     targetEl.style.display = 'none';
  1199.     targetEl.parentNode.insertBefore(wrapper, targetEl.nextSibling);

  1200.     bindPanelEvents();

  1201.     console.log('[SMG] Panel injected');
  1202.     hideLoading();
  1203.   }

  1204.   let reinjectObserver = null;

  1205.   function proceed(targetEl) {
  1206.     if (!allOrders || allOrders.length === 0) {
  1207.       console.log('[SMG] No orders to display');
  1208.       hideLoading();
  1209.       return;
  1210.     }

  1211.     // [OPT] 使用预提取的数据,无需重新提取
  1212.     const useGrouped = grouped || groupOrders(allOrders);
  1213.     console.log(`[SMG] Using ${useGrouped.length} date groups`);

  1214.     ensurePanel(allOrders, useGrouped, targetEl);

  1215.     // [OPT] 重注入 Observer 缩小范围(复用引用,防止泄漏)
  1216.     if (reinjectObserver) reinjectObserver.disconnect();
  1217.     reinjectObserver = new MutationObserver(() => {
  1218.       const panel = document.getElementById('smg-panel');
  1219.       if (!panel) {
  1220.         console.log('[SMG] Panel removed by page, re-injecting...');
  1221.         if (targetEl) targetEl.style.display = '';
  1222.         ensurePanel(allOrders, groupOrders(allOrders), targetEl);
  1223.       }
  1224.     });
  1225.     reinjectObserver.observe(targetEl.parentNode || document.body, { childList: true, subtree: true });
  1226.   }

  1227.   let allOrders = [];
  1228.   let grouped = null;
  1229.   // 批量流程互斥标志:为 true 时 handleDelist/handleRelist 入口直接返回
  1230.   let batchBusy = false;

  1231.   function main() {
  1232.     console.log('[SMG] Script started on', location.href);
  1233.     showLoading();

  1234.     // [OPT] 数据预提取:不依赖 DOM,立即开始
  1235.     allOrders = extractOrders();
  1236.     if (!allOrders || allOrders.length <= CONFIG.minOrders) {
  1237.       console.log(`[SMG] Skipping: only ${allOrders?.length || 0} orders (minimum ${CONFIG.minOrders})`);
  1238.       hideLoading();
  1239.       return;
  1240.     }
  1241.     // [OPT] 货币一次性解析(订单数据已就绪,符号推断可用):后续格式化/解析/费用计算共用
  1242.     resolveCurrency();
  1243.     grouped = groupOrders(allOrders);
  1244.     console.log(`[SMG] Pre-grouped into ${grouped.length} date groups`);

  1245.     let targetEl = findTargetElement();
  1246.     if (targetEl) {
  1247.       console.log('[SMG] Target element found immediately');
  1248.       proceed(targetEl);
  1249.       return;
  1250.     }

  1251.     // [OPT] 主动轮询 + MutationObserver 双保险
  1252.     console.log('[SMG] Target element not found, polling + observing...');
  1253.     let found = false;
  1254.     let pollCount = 0;

  1255.     function onFound(el) {
  1256.       if (found) return;
  1257.       found = true;
  1258.       if (waitObs) waitObs.disconnect();
  1259.       console.log('[SMG] Target element found');
  1260.       proceed(el);
  1261.     }

  1262.     // 递归 setTimeout 实现退避轮询
  1263.     function poll() {
  1264.       if (found) return;
  1265.       pollCount++;
  1266.       const target = findTargetElement();
  1267.       if (target) {
  1268.         onFound(target);
  1269.         return;
  1270.       }
  1271.       // 退避策略:前10次50ms,之后200ms,超过30次停止轮询仅靠Observer
  1272.       if (pollCount >= 30) return;
  1273.       const delay = pollCount <= 10 ? 50 : 200;
  1274.       setTimeout(poll, delay);
  1275.     }
  1276.     setTimeout(poll, 50);

  1277.     // [OPT] MutationObserver 缩小范围:只监听 body 直接子工具点
  1278.     const waitObs = new MutationObserver(() => {
  1279.       const target = findTargetElement();
  1280.       if (target) onFound(target);
  1281.     });
  1282.     waitObs.observe(document.body, { childList: true, subtree: false });

  1283.     // 超时清理
  1284.     setTimeout(() => {
  1285.       if (!found) {
  1286.         if (waitObs) waitObs.disconnect();
  1287.         console.warn('[SMG] Target element not found after timeout');
  1288.         hideLoading();
  1289.       }
  1290.     }, 15000);
  1291.   }

  1292.   if (document.readyState === 'loading') {
  1293.     document.addEventListener('DOMContentLoaded', main);
  1294.   } else {
  1295.     main();
  1296.   }
  1297. })();
复制代码

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有帐号?注册

×

本帖被以下淘专辑推荐:

回复

使用道具 举报

浏览本版块需要:
1. 初阶会员或更高等级;
2. (点击此处)绑定Steam账号
您需要登录后才可以回帖 登录 | 注册

本版积分规则

欢迎发帖参与讨论 o(*≧▽≦)ツ,请注意
1. 寻求帮助或答案的帖子请发到问题互助版块,悬赏有助于问题解决的速度。发错可能失去在该板块发布主题的权限(了解更多
2. 表达观点可以,也请务必注意语气和用词,以免影响他人浏览,特别是针对其他会员的内容。如觉得违规可使用举报功能 交由管理人员处理,请勿引用对方的内容。
3. 开箱晒物交易中心游戏互鉴福利放送版块请注意额外的置顶版规。
4. 除了提问帖和交易帖以外,不确认发在哪个版块的帖子可以先发在谈天说地

  作为民间站点,自 2004 年起为广大中文 Steam 用户提供技术支持与讨论空间。历经二十余载风雨,如今已发展为国内最大的正版玩家据点。

列表模式 · · 微博 · Bilibili频道 · Steam 群组 · 贴吧 · QQ群 
Keylol 其乐 ©2004-2026 Chinese Steam User Fan Site.
Designed by Lee in Balestier, Powered by Discuz!
推荐使用 ChromeMicrosoft Edge 来浏览本站
广告投放|手机版|广州数趣信息科技有限公司 版权所有|其乐 Keylol ( 粤ICP备17068105号 )
GMT+8, 2026-8-28 04:18
快速回复 返回顶部 返回列表