// ==UserScript== // @name Steam 社区市场增强 // @name:en Steam Community Market Enhancer // @namespace steam-market-enhancer // @version 1.3.1 // @description Steam社区市场增强:上架汇总分组、超价/低价检测标注、一键重新上架、批量下架、价格历史趋势、订单深度可视化、已拥有物品标识、我的挂单排序搜索批量操作、库存页价格标签与快速出售、交易报价价值摘要、货币特定取整(2025年12月Steam变更)。采用玻璃态UI设计。更新日志见脚本目录 README。 // @author SmallFork // @match https://steamcommunity.com/market // @match https://steamcommunity.com/market/* // @match https://steamcommunity.com/id/*/inventory* // @match https://steamcommunity.com/profiles/*/inventory* // @match https://steamcommunity.com/tradeoffer/* // @grant GM_addStyle // @grant GM_getValue // @grant GM_setValue // @grant GM_xmlhttpRequest // @grant unsafeWindow // @run-at document-idle // @license MIT // @tag Steam // @tag market // @icon data:image/svg+xml, // ==/UserScript== (function () { 'use strict'; const VERSION = '1.3.1'; const PREFIX = 'sme'; console.log(`%c[Steam 社区市场增强] v${VERSION} 已启动`, 'color:#66c0f4;font-weight:bold;font-size:13px'); // ==================== 常量配置 ==================== const CACHE_TTL = { PRICE_HISTORY: 30 * 60 * 1000, // 价格历史缓存30分钟 ORDER_BOOK: 5 * 60 * 1000, // 订单簿缓存5分钟 INVENTORY: 30 * 60 * 1000, // 库存缓存30分钟 }; const INVENTORY_COUNT = 2000; const INVENTORY_FALLBACK_COUNT = 1000; const REQUEST_DELAY = 600; const RETRY_MAX = 2; const RETRY_DELAY = 800; const APP_CONTEXT = { '753': '6' }; const SKIP_CONTEXT_1 = { '570': true }; const CHART_COLORS = { line: '#66c0f4', area: 'rgba(102,192,244,0.18)', grid: 'rgba(255,255,255,0.05)', text: '#8f98a0', sell: '#e24a4a', buy: '#4caf50', dot: '#66c0f4', }; // 2025年12月Steam市场规则变更:这些货币使用 Math.round 而非 Math.floor 计算手续费 const CURRENCY_CODES_TO_ROUND = [ 'JPY', 'IDR', 'UAH', 'CLP', 'COP', 'TWD', 'KZT', 'CRC', 'UYU', 'KRW', 'VND', ]; // 价格状态颜色(用于库存/挂单页背景着色) const PRICE_COLORS = { ERROR: '#8A4243', SUCCESS: '#407736', PENDING: '#908F44', FAIR: '#496424', CHEAP: '#837433', EXPENSIVE: '#813030', UNCHECKED: '#26566c', }; // 库存/挂单请求间隔 const INV_REQUEST_DELAY = 1000; // 市场 API 请求间隔 const SELL_DELAY_MIN = 1000; // 上架成功后最小等待 const SELL_DELAY_MAX = 1500; // 上架成功后最大等待 const RETRY_BACKOFF_MIN = 30000; // 重试消息退避最小 const RETRY_BACKOFF_MAX = 45000; // 重试消息退避最大 // ==================== CSS 样式 ==================== const CSS = ` :root{ --sme-bg:rgba(23,26,33,0.98); --sme-bg-card:rgba(30,41,59,0.5); --sme-bg-hover:rgba(255,255,255,0.05); --sme-border:rgba(102,192,244,0.15); --sme-border-hi:rgba(102,192,244,0.35); --sme-text:#c7d5e0; --sme-text-dim:#8f98a0; --sme-text-bright:#fff; --sme-blue:#66c0f4; --sme-green:#4caf50; --sme-red:#e24a4a; --sme-amber:#f59e0b; --sme-purple:#8b5cf6; --sme-radius:12px; --sme-radius-sm:8px; --sme-transition:all 0.2s cubic-bezier(0.4,0,0.2,1); } .sme-svg{width:16px;height:16px;display:inline-block;vertical-align:middle;stroke-width:2.2;flex-shrink:0} .sme-panel{ background:linear-gradient(180deg,rgba(29,31,35,0.98) 0%,rgba(35,38,46,0.98) 100%); border:1px solid var(--sme-border);border-radius:var(--sme-radius); margin:12px 0;overflow:hidden;font-family:"Motiva Sans",system-ui,-apple-system,sans-serif; color:var(--sme-text);box-shadow:0 8px 32px rgba(0,0,0,0.3); } .sme-header{ display:flex;align-items:center;gap:10px;padding:12px 16px;flex-wrap:wrap; background:linear-gradient(135deg,rgba(102,192,244,0.08) 0%,rgba(23,26,33,0.6) 100%); border-bottom:1px solid var(--sme-border); } .sme-header-title{display:flex;align-items:center;gap:8px;font-size:15px;font-weight:700;color:var(--sme-text-bright);white-space:nowrap} .sme-header-title .sme-svg{width:20px;height:20px;color:var(--sme-blue)} .sme-tabs{display:flex;gap:2px;margin-left:auto;flex-wrap:wrap} .sme-tab{ padding:6px 14px;border:none;background:transparent;color:var(--sme-text-dim); font-size:13px;font-weight:600;cursor:pointer;border-radius:7px;transition:var(--sme-transition); display:inline-flex;align-items:center;gap:5px;white-space:nowrap; } .sme-tab .sme-svg{width:14px;height:14px} .sme-tab:hover{color:var(--sme-text);background:rgba(255,255,255,0.04)} .sme-tab.active{background:rgba(102,192,244,0.15);color:var(--sme-blue)} .sme-tab-btn{ width:28px;height:28px;border:none;border-radius:6px;background:rgba(255,255,255,0.06); color:var(--sme-text-dim);cursor:pointer;display:flex;align-items:center;justify-content:center; transition:var(--sme-transition);flex-shrink:0; } .sme-tab-btn:hover{background:rgba(255,255,255,0.12);color:var(--sme-text-bright)} .sme-tab-btn .sme-svg{width:14px;height:14px} .sme-content{padding:16px;min-height:120px} .sme-tab-pane{display:none} .sme-tab-pane.active{display:block;animation:sme-fade-in 0.2s ease} @keyframes sme-fade-in{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}} /* 统计卡片 */ .sme-stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(110px,1fr));gap:10px;margin-bottom:16px} .sme-stat-card{ background:var(--sme-bg-card);border:1px solid var(--sme-border);border-radius:var(--sme-radius-sm); padding:10px 12px;display:flex;flex-direction:column;gap:4px;transition:var(--sme-transition); } .sme-stat-card:hover{border-color:var(--sme-border-hi);background:rgba(30,41,59,0.7)} .sme-stat-val{font-size:18px;font-weight:800;color:var(--sme-text-bright);line-height:1.2;font-variant-numeric:tabular-nums} .sme-stat-lbl{font-size:11px;color:var(--sme-text-dim);font-weight:600} /* 汇总表格 */ .sme-table{width:100%;border-collapse:collapse} .sme-table th{ color:var(--sme-text-dim);text-align:left;padding:6px 8px;font-size:12px; border-bottom:1px solid var(--sme-border);font-weight:600; } .sme-table td{ color:var(--sme-text);padding:7px 8px;font-size:13px; border-bottom:1px solid rgba(102,192,244,0.06); } .sme-table tr:last-child td{border-bottom:none} .sme-table tr:hover td{background:rgba(102,192,244,0.04)} .sme-date-cell{white-space:nowrap;color:var(--sme-blue);font-weight:600;width:110px} .sme-prices-cell{color:var(--sme-text-dim);line-height:1.8} .sme-price-tag{ display:inline-block;background:rgba(102,192,244,0.08);border:1px solid rgba(102,192,244,0.15); border-radius:3px;padding:1px 7px;margin:2px 3px 2px 0;font-size:12px;color:var(--sme-text); } .sme-price-tag .sme-ct{color:var(--sme-text-dim);margin-left:3px} .sme-count-cell{text-align:right;white-space:nowrap;color:var(--sme-text-dim);width:60px;font-variant-numeric:tabular-nums} .sme-total-row td{border-top:1px solid var(--sme-border);font-weight:700;color:var(--sme-blue);padding-top:10px} /* 下架区域 */ .sme-delist-section{margin-top:16px;padding-top:14px;border-top:1px solid var(--sme-border)} .sme-delist-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap} .sme-delist-label{color:var(--sme-text-dim);font-size:13px;white-space:nowrap} .sme-delist-select,.sme-delist-input{ background:rgba(30,41,59,0.6);border:1px solid var(--sme-border);color:var(--sme-text); padding:5px 8px;border-radius:6px;font-size:13px;transition:var(--sme-transition); } .sme-delist-select:focus,.sme-delist-input:focus{outline:none;border-color:var(--sme-border-hi)} .sme-delist-select{min-width:140px} .sme-delist-input{width:70px;text-align:center} .sme-delist-btn{ background:linear-gradient(180deg,#c44 0%,#a33 100%);border:none;color:#fff; padding:6px 16px;border-radius:6px;cursor:pointer;font-size:13px;font-weight:600; transition:opacity 0.15s; } .sme-delist-btn:hover{opacity:0.85} .sme-delist-btn:disabled{opacity:0.4;cursor:not-allowed} .sme-delist-all-btn{ background:rgba(244,63,94,0.12);border:1px solid rgba(244,63,94,0.3);color:#f87171; padding:6px 14px;border-radius:6px;cursor:pointer;font-size:13px;font-weight:600;transition:var(--sme-transition); } .sme-delist-all-btn:hover{background:rgba(244,63,94,0.2);border-color:rgba(244,63,94,0.5)} .sme-delist-status{color:var(--sme-text-dim);font-size:12px;margin-top:8px} .sme-delist-status.sme-error{color:var(--sme-red)} .sme-delist-status.sme-ok{color:var(--sme-green)} .sme-progress-wrap{height:3px;background:rgba(255,255,255,0.06);border-radius:2px;margin-top:8px;overflow:hidden} .sme-progress-bar{height:100%;width:0;background:linear-gradient(90deg,var(--sme-blue),var(--sme-green));border-radius:2px;transition:width .3s ease} /* 价格图表 */ .sme-chart-wrap{background:var(--sme-bg-card);border:1px solid var(--sme-border);border-radius:var(--sme-radius-sm);padding:12px} .sme-chart-svg{width:100%;height:auto;display:block} .sme-range-bar{display:flex;gap:4px;margin-bottom:12px} .sme-range-btn{ padding:4px 12px;border:1px solid var(--sme-border);background:transparent;color:var(--sme-text-dim); border-radius:6px;cursor:pointer;font-size:12px;font-weight:600;transition:var(--sme-transition); } .sme-range-btn:hover{color:var(--sme-text);border-color:var(--sme-border-hi)} .sme-range-btn.active{background:rgba(102,192,244,0.15);color:var(--sme-blue);border-color:rgba(102,192,244,0.3)} /* 订单簿 */ .sme-order-section{margin-bottom:16px} .sme-order-title{font-size:13px;font-weight:700;margin-bottom:8px;display:flex;align-items:center;gap:6px} .sme-order-title .sme-svg{width:15px;height:15px} .sme-order-title.sell{color:var(--sme-red)} .sme-order-title.buy{color:var(--sme-green)} .sme-order-row{ display:flex;align-items:center;gap:8px;padding:3px 0;position:relative;font-size:13px; font-variant-numeric:tabular-nums;overflow:hidden; } .sme-order-bar{height:18px;border-radius:3px;position:absolute;left:0;opacity:0.15} .sme-order-row.sell .sme-order-bar{background:var(--sme-red)} .sme-order-row.buy .sme-order-bar{background:var(--sme-green)} .sme-order-price{position:relative;z-index:1;min-width:80px;font-weight:600} .sme-order-row.sell .sme-order-price{color:var(--sme-red)} .sme-order-row.buy .sme-order-price{color:var(--sme-green)} .sme-order-qty{position:relative;z-index:1;color:var(--sme-text-dim);margin-left:auto} .sme-spread-box{ display:flex;align-items:center;justify-content:center;gap:12px;padding:10px;margin:8px 0; background:rgba(102,192,244,0.06);border:1px solid var(--sme-border);border-radius:var(--sme-radius-sm); } .sme-spread-val{font-size:18px;font-weight:800;color:var(--sme-blue);font-variant-numeric:tabular-nums} .sme-spread-lbl{font-size:12px;color:var(--sme-text-dim)} /* 设置 */ .sme-setting-row{ display:flex;align-items:center;justify-content:space-between;padding:10px 0; border-bottom:1px solid rgba(102,192,244,0.06); } .sme-setting-row:last-child{border-bottom:none} .sme-setting-info{display:flex;flex-direction:column;gap:3px} .sme-setting-name{font-size:13px;font-weight:600;color:var(--sme-text)} .sme-setting-desc{font-size:11px;color:var(--sme-text-dim)} .sme-toggle{position:relative;width:40px;height:22px;flex-shrink:0;cursor:pointer} .sme-toggle input{opacity:0;width:0;height:0} .sme-toggle-slider{ position:absolute;inset:0;background:rgba(255,255,255,0.1);border-radius:11px;transition:var(--sme-transition); } .sme-toggle-slider::before{ content:'';position:absolute;width:16px;height:16px;left:3px;top:3px;background:#fff;border-radius:50%;transition:var(--sme-transition); } .sme-toggle input:checked + .sme-toggle-slider{background:var(--sme-blue)} .sme-toggle input:checked + .sme-toggle-slider::before{transform:translateX(18px)} .sme-setting-select{ background:rgba(30,41,59,0.6);border:1px solid var(--sme-border);color:var(--sme-text); padding:5px 8px;border-radius:6px;font-size:13px; } .sme-danger-btn{ background:rgba(244,63,94,0.1);border:1px solid rgba(244,63,94,0.3);color:#f87171; padding:7px 16px;border-radius:6px;cursor:pointer;font-size:13px;font-weight:600;transition:var(--sme-transition); } .sme-danger-btn:hover{background:rgba(244,63,94,0.2)} .sme-info-box{margin-top:16px;padding:12px;background:rgba(102,192,244,0.05);border:1px solid var(--sme-border);border-radius:var(--sme-radius-sm);font-size:12px;color:var(--sme-text-dim);line-height:1.6} /* Toast */ .sme-toast{ position:fixed;bottom:50px;left:50%;transform:translateX(-50%) translateY(20px); background:rgba(15,23,42,0.95);color:#fff;border:1px solid var(--sme-blue); padding:10px 24px;border-radius:20px;font-size:13px;font-weight:500; box-shadow:0 4px 16px rgba(0,0,0,0.3);z-index:99999;opacity:0;pointer-events:none; transition:opacity 0.3s,transform 0.3s; } .sme-toast.sme-show{opacity:1;transform:translateX(-50%) translateY(0)} .sme-toast.sme-success{border-color:var(--sme-green)} .sme-toast.sme-error{border-color:var(--sme-red)} .sme-toast.sme-warning{border-color:var(--sme-amber)} /* 价格状态标注 */ .sme-price-tag.overpriced{background:rgba(226,74,74,0.12);border-color:rgba(226,74,74,0.3);color:var(--sme-red)} .sme-price-tag.underpriced{background:rgba(245,158,11,0.12);border-color:rgba(245,158,11,0.3);color:var(--sme-amber)} .sme-price-tag.fair{background:rgba(76,175,80,0.12);border-color:rgba(76,175,80,0.3);color:var(--sme-green)} .sme-price-tag.unchecked{background:rgba(102,192,244,0.08);border-color:rgba(102,192,244,0.15)} .sme-price-tag .sme-suggested{color:var(--sme-text-dim);font-size:10px;margin-left:4px;opacity:0.8} .sme-price-tag .sme-status-dot{display:inline-block;width:6px;height:6px;border-radius:50%;margin-right:4px;vertical-align:middle} .sme-price-tag.overpriced .sme-status-dot{background:var(--sme-red)} .sme-price-tag.underpriced .sme-status-dot{background:var(--sme-amber)} .sme-price-tag.fair .sme-status-dot{background:var(--sme-green)} /* 价格检测统计卡片 */ .sme-stat-card.overpriced{border-left:3px solid var(--sme-red)} .sme-stat-card.underpriced{border-left:3px solid var(--sme-amber)} .sme-stat-card.fair{border-left:3px solid var(--sme-green)} .sme-price-check-info{display:flex;align-items:center;gap:8px;margin-bottom:12px;padding:8px 12px;background:rgba(102,192,244,0.06);border:1px solid var(--sme-border);border-radius:var(--sme-radius-sm);font-size:12px;color:var(--sme-text-dim)} .sme-price-check-info .sme-svg{width:14px;height:14px;color:var(--sme-blue)} .sme-price-check-info .sme-algo-name{color:var(--sme-blue);font-weight:600} /* 重新上架区域 */ .sme-relist-section{margin-top:12px;padding:12px;background:rgba(139,92,246,0.06);border:1px solid rgba(139,92,246,0.2);border-radius:var(--sme-radius-sm)} .sme-relist-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap} .sme-relist-btn{ background:linear-gradient(180deg,#7c3aed 0%,#6d28d9 100%);border:none;color:#fff; padding:6px 16px;border-radius:6px;cursor:pointer;font-size:13px;font-weight:600; transition:opacity 0.15s;display:inline-flex;align-items:center;gap:5px; } .sme-relist-btn:hover{opacity:0.85} .sme-relist-btn:disabled{opacity:0.4;cursor:not-allowed} .sme-relist-btn .sme-svg{width:14px;height:14px} .sme-relist-summary{font-size:12px;color:var(--sme-text-dim);margin-top:8px} .sme-relist-item{display:flex;align-items:center;gap:8px;padding:4px 0;font-size:12px} .sme-relist-item .sme-relist-from{color:var(--sme-red);text-decoration:line-through;opacity:0.7} .sme-relist-item .sme-relist-arrow{color:var(--sme-text-dim)} .sme-relist-item .sme-relist-to{color:var(--sme-green);font-weight:600} /* 设置:定价算法 */ .sme-setting-group-title{font-size:13px;font-weight:700;color:var(--sme-blue);margin:16px 0 8px;padding-bottom:6px;border-bottom:1px solid var(--sme-border)} .sme-setting-group-title:first-child{margin-top:0} .sme-number-input{ background:rgba(30,41,59,0.6);border:1px solid var(--sme-border);color:var(--sme-text); padding:5px 8px;border-radius:6px;font-size:13px;width:80px;transition:var(--sme-transition); } .sme-number-input:focus{outline:none;border-color:var(--sme-border-hi)} .sme-setting-inline{display:flex;align-items:center;gap:8px} .sme-setting-inline .sme-setting-info{flex:1} /* 加载 & 空状态 */ .sme-loading{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;padding:40px 20px} .sme-spinner{ width:28px;height:28px;border:2.5px solid rgba(102,192,244,0.15); border-top-color:var(--sme-blue);border-radius:50%;animation:sme-spin 0.8s linear infinite; } @keyframes sme-spin{to{transform:rotate(360deg)}} .sme-loading-text{color:var(--sme-text-dim);font-size:13px} .sme-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;padding:40px 20px;color:var(--sme-text-dim);font-size:13px;text-align:center} .sme-empty .sme-svg{width:32px;height:32px;opacity:0.4} .sme-error-box{display:flex;flex-direction:column;align-items:center;gap:10px;padding:30px 20px;color:var(--sme-red);font-size:13px;text-align:center} /* 已拥有标识 */ .sme-owned-badge{ display:inline-flex;align-items:center;background:rgba(102,192,244,0.12);border:1px solid rgba(102,192,244,0.2); color:var(--sme-blue);font-size:11px;font-weight:700;padding:1px 6px;border-radius:4px;margin-right:5px; white-space:nowrap;vertical-align:middle; } /* 浮动按钮(浏览页设置) */ .sme-fab{ position:fixed;bottom:24px;right:24px;width:40px;height:40px;border:none;border-radius:50%; background:linear-gradient(135deg,#3b82f6 0%,#1d4ed8 100%);color:#fff;cursor:pointer;z-index:500; display:flex;align-items:center;justify-content:center;box-shadow:0 4px 20px rgba(59,130,246,0.4); transition:var(--sme-transition); } .sme-fab:hover{transform:scale(1.1);box-shadow:0 8px 30px rgba(59,130,246,0.6)} .sme-fab .sme-svg{width:20px;height:20px} .sme-fab-popup{ position:fixed;bottom:74px;right:24px;width:300px;background:var(--sme-bg);border:1px solid var(--sme-border); border-radius:var(--sme-radius);padding:16px;z-index:501;box-shadow:0 8px 32px rgba(0,0,0,0.4); display:none;font-family:"Motiva Sans",system-ui,sans-serif;color:var(--sme-text); } .sme-fab-popup.sme-show{display:block;animation:sme-fade-in 0.2s ease} .sme-fab-popup h4{margin:0 0 12px;font-size:14px;color:var(--sme-text-bright);display:flex;align-items:center;gap:6px} /* ===== 库存页价格标签 ===== */ .sme-inv-price{ display:block;font-size:10px;font-weight:600;color:#beee11;text-align:center; padding:1px 3px;line-height:1.3;text-shadow:0 1px 2px rgba(0,0,0,0.8); pointer-events:none;white-space:nowrap; } .sme-inv-price.sme-na{color:#66c0f4} .sme-inv-price.sme-inf{color:#8f98a0} /* ===== 库存页工具栏 ===== */ .sme-inv-toolbar{ display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:8px 12px;margin:4px 0; background:linear-gradient(135deg,rgba(102,192,244,0.06) 0%,rgba(23,26,33,0.6) 100%); border:1px solid var(--sme-border);border-radius:var(--sme-radius-sm); font-family:"Motiva Sans",system-ui,sans-serif; } .sme-inv-toolbar-title{display:flex;align-items:center;gap:6px;font-size:13px;font-weight:700;color:var(--sme-text-bright);margin-right:8px} .sme-inv-btn{ display:inline-flex;align-items:center;gap:5px;padding:5px 12px;border:1px solid rgba(102,192,244,0.25); background:rgba(102,192,244,0.08);color:var(--sme-text);font-size:12px;font-weight:600; border-radius:6px;cursor:pointer;transition:var(--sme-transition);white-space:nowrap;font-family:inherit; } .sme-inv-btn:hover{background:rgba(102,192,244,0.18);border-color:var(--sme-border-hi);color:var(--sme-text-bright)} .sme-inv-btn:disabled{opacity:0.4;cursor:not-allowed} .sme-inv-btn.sme-danger{background:rgba(226,74,74,0.1);border-color:rgba(226,74,74,0.3);color:#e24a4a} .sme-inv-btn.sme-danger:hover{background:rgba(226,74,74,0.2)} .sme-inv-btn .sme-svg{width:13px;height:13px} .sme-inv-progress{ flex:1;min-width:120px;height:6px;background:rgba(255,255,255,0.08);border-radius:3px;overflow:hidden; } .sme-inv-progress-bar{height:100%;background:linear-gradient(90deg,var(--sme-blue),var(--sme-green));border-radius:3px;transition:width 0.3s ease} .sme-inv-status{font-size:12px;color:var(--sme-text-dim);white-space:nowrap} /* ===== 快速出售面板 ===== */ .sme-quick-sell{ margin-top:8px;padding:10px;background:var(--sme-bg-card);border:1px solid var(--sme-border); border-radius:var(--sme-radius-sm);font-family:"Motiva Sans",system-ui,sans-serif; } .sme-quick-sell-title{font-size:12px;font-weight:700;color:var(--sme-blue);margin-bottom:8px;display:flex;align-items:center;gap:5px} .sme-quick-sell-btns{display:flex;gap:6px;flex-wrap:wrap;align-items:center} .sme-qs-btn{ padding:5px 10px;border:1px solid var(--sme-green);background:rgba(76,175,80,0.12);color:#4caf50; font-size:11px;font-weight:700;border-radius:5px;cursor:pointer;transition:var(--sme-transition);font-family:inherit; } .sme-qs-btn:hover{background:rgba(76,175,80,0.25)} .sme-qs-btn.sme-start{border-color:var(--sme-blue);background:rgba(102,192,244,0.12);color:var(--sme-blue)} .sme-qs-btn.sme-start:hover{background:rgba(102,192,244,0.25)} .sme-qs-input{ width:70px;padding:4px 6px;border:1px solid var(--sme-border);background:rgba(0,0,0,0.3); color:var(--sme-text);font-size:11px;border-radius:4px;font-family:inherit; } .sme-qs-book{margin-top:8px;font-size:11px;color:var(--sme-text-dim);line-height:1.5} .sme-qs-book-row{display:flex;justify-content:space-between;padding:1px 0} .sme-qs-book-row.sme-sell{color:#e24a4a} .sme-qs-book-row.sme-buy{color:#4caf50} /* ===== 市场我的挂单批量操作 ===== */ .sme-market-toolbar{ display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:8px 0; font-family:"Motiva Sans",system-ui,sans-serif; } .sme-market-btn{ display:inline-flex;align-items:center;gap:4px;padding:4px 10px;border:1px solid rgba(102,192,244,0.25); background:rgba(102,192,244,0.06);color:var(--sme-text);font-size:12px;font-weight:600; border-radius:5px;cursor:pointer;transition:var(--sme-transition);white-space:nowrap;font-family:inherit; } .sme-market-btn:hover{background:rgba(102,192,244,0.15);border-color:var(--sme-border-hi);color:var(--sme-text-bright)} .sme-market-btn.sme-danger{background:rgba(226,74,74,0.08);border-color:rgba(226,74,74,0.25);color:#e24a4a} .sme-market-btn.sme-danger:hover{background:rgba(226,74,74,0.18)} .sme-market-btn .sme-svg{width:13px;height:13px} .sme-market-search{ width:140px;padding:4px 8px;border:1px solid var(--sme-border);background:rgba(0,0,0,0.3); color:var(--sme-text);font-size:12px;border-radius:5px;font-family:inherit; } .sme-market-search:focus{outline:none;border-color:var(--sme-border-hi)} .sme-market-select{margin-left:4px;cursor:pointer;accent-color:var(--sme-blue)} /* ===== 交易报价摘要 ===== */ .sme-trade-summary{ padding:10px 12px;margin:4px 0;background:var(--sme-bg-card);border:1px solid var(--sme-border); border-radius:var(--sme-radius-sm);font-family:"Motiva Sans",system-ui,sans-serif;font-size:12px; color:var(--sme-text);line-height:1.6; } .sme-trade-summary strong{color:var(--sme-text-bright)} .sme-trade-summary .sme-trade-total{font-size:14px;color:var(--sme-blue);font-weight:700} .sme-trade-btn{ display:inline-flex;align-items:center;gap:5px;padding:5px 12px;border:1px solid rgba(102,192,244,0.25); background:rgba(102,192,244,0.08);color:var(--sme-text);font-size:12px;font-weight:600; border-radius:6px;cursor:pointer;transition:var(--sme-transition);font-family:inherit;margin:4px 0; } .sme-trade-btn:hover{background:rgba(102,192,244,0.18);border-color:var(--sme-border-hi)} .sme-trade-btn .sme-svg{width:13px;height:13px} `; GM_addStyle(CSS); // ==================== SVG 图标 ==================== const _S = 'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"'; const ICONS = { bag: ``, list: ``, chart: ``, book: ``, settings: ``, refresh: ``, close: ``, trash: ``, check: ``, alert: ``, package: ``, trendingDown: ``, trendingUp: ``, layers: ``, refreshCw: ``, zap: ``, dollarSign: ``, // v1.3.0 新增图标 tag: ``, sell: ``, copy: ``, gem: ``, unpack: ``, exchange: ``, sort: ``, search: ``, selectAll: ``, scale: ``, }; // ==================== 工具函数 ==================== function h(tag, props = {}, children = []) { const el = document.createElement(tag); for (const [k, v] of Object.entries(props)) { if (v == null) continue; if (k === 'style' && typeof v === 'object') { for (const [sk, sv] of Object.entries(v)) { if (sk.startsWith('--')) el.style.setProperty(sk, sv); else el.style[sk] = sv; } } else if (k === 'text') { el.textContent = v; } else if (k === 'html') { el.innerHTML = v; } else if (k.startsWith('on') && typeof v === 'function') { el.addEventListener(k.slice(2).toLowerCase(), v); } else if (k === 'dataset') { Object.assign(el.dataset, v); } else if (k === 'disabled' && v) { el.setAttribute('disabled', ''); } else if (k === 'checked' && v) { el.setAttribute('checked', ''); } else { el.setAttribute(k, v); } } for (const c of (Array.isArray(children) ? children : [children])) { if (c == null || c === false) continue; el.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); } return el; } function escapeHtml(str) { if (str == null) return ''; const d = document.createElement('div'); d.textContent = String(str); return d.innerHTML; } let _toastEl = null; function toast(msg, type = 'info') { if (!_toastEl) { _toastEl = h('div', { class: 'sme-toast' }); document.body.appendChild(_toastEl); } _toastEl.textContent = msg; _toastEl.className = 'sme-toast' + (type ? ' sme-' + type : ''); requestAnimationFrame(() => _toastEl.classList.add('sme-show')); clearTimeout(_toastEl._timer); _toastEl._timer = setTimeout(() => { _toastEl.classList.remove('sme-show'); }, 3000); } const LP = '[SME]'; function L() { console.log.apply(console, [LP].concat([].slice.call(arguments))); } function W() { console.warn.apply(console, [LP].concat([].slice.call(arguments))); } function getSessionId() { try { if (unsafeWindow.g_sessionID) return unsafeWindow.g_sessionID; } catch (e) {} const meta = document.querySelector('meta[name="sessionid"]'); if (meta) return meta.content; const m = document.cookie.match(/sessionid=([^;]+)/); return m ? decodeURIComponent(m[1]) : ''; } function getSteamId() { try { if (unsafeWindow.g_steamID) return String(unsafeWindow.g_steamID); } catch (e) {} try { const scripts = document.querySelectorAll('script'); for (const s of scripts) { const m = (s.textContent || '').match(/g_steamID\s*=\s*["'](\d+)["']/); if (m) return m[1]; } } catch (e) {} try { const link = document.querySelector('a[href*="/profiles/"]'); if (link) { const m = link.href.match(/\/profiles\/(\d+)/); if (m) return m[1]; } } catch (e) {} return null; } function tsToDateKey(ts) { const d = new Date(ts * 1000); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; } function tsToDateTime(ts) { const d = new Date(ts * 1000); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; } const MONTH_MAP = { Jan: 0, Feb: 1, Mar: 2, Apr: 3, May: 4, Jun: 5, Jul: 6, Aug: 7, Sep: 8, Oct: 9, Nov: 10, Dec: 11 }; function parsePriceDate(s) { const m = String(s).match(/(\w{3})\s+(\d{2})\s+(\d{4})\s+(\d{2}):\s*\+(\d+)/); if (!m) return new Date(s); return new Date(parseInt(m[3]), MONTH_MAP[m[1]] || 0, parseInt(m[2]), parseInt(m[4])); } // ==================== 设置管理 ==================== const settings = { _defaults: { ownedBadges: true, autoLoadHistory: true, historyRange: '30d', priceAlgorithm: 1, // 1=max(历史均价,最低卖单) 2=最低卖单 3=最高买单或最低卖单 4=历史均价 priceOffset: 0, // 价格偏移(本地货币单位,可为负) historyHours: 12, // 历史均价计算窗口(小时) ignoreLowQuantity: true, // 最低价挂单数量过低时使用第二低价 autoCheckPrices: true, // 进入汇总页自动检测价格 minPrice: 0.03, // 最低上架价格 maxPrice: 999, // 最高上架价格 // v1.3.0 新增:库存页 inventoryPriceLabels: true, // 库存页价格标签 quickSellButtons: true, // 快速出售按钮 // v1.3.0 新增:市场我的挂单页 marketBatchButtons: true, // 批量操作按钮 marketSorting: true, // 排序功能 marketSearch: true, // 搜索过滤 // v1.3.0 新增:物品类型差异化定价 minMiscPrice: 0.05, maxMiscPrice: 10, // 杂项物品 minNormalCardPrice: 0.05, maxNormalCardPrice: 2.50, // 普通卡牌 minFoilCardPrice: 0.15, maxFoilCardPrice: 10, // 闪箔卡牌 // v1.3.0 新增:交易报价 tradeOfferSummary: true, // 交易报价价值摘要 }, get(key) { return GM_getValue('sme_' + key, this._defaults[key]); }, set(key, val) { GM_setValue('sme_' + key, val); }, }; // ==================== 缓存管理 ==================== const cache = { get(key, ttl) { try { const raw = localStorage.getItem('sme_cache_' + key); if (!raw) return null; const entry = JSON.parse(raw); if (Date.now() - entry.ts > ttl) return null; return entry.data; } catch (e) { return null; } }, set(key, data) { try { localStorage.setItem('sme_cache_' + key, JSON.stringify({ ts: Date.now(), data })); } catch (e) {} }, clear() { try { Object.keys(localStorage) .filter(k => k.startsWith('sme_cache_')) .forEach(k => localStorage.removeItem(k)); } catch (e) {} }, }; // 库存缓存(按 SteamID 分离,支持 Map 序列化 + 会话级缓存验证) const invCache = {}; // { "appid_ctxid_fmt": { ts, data: Map | null } } const invPending = {}; // { "appid_ctxid_fmt": Promise } const jsonFallbackDone = {}; // 追踪 json 格式降级尝试,避免逐条触发 const SESSION_KEY = 'sme_inv_sess'; /** 检测浏览器新会话:sessionStorage 在浏览器关闭时自动清除 */ function isFreshSession() { try { return sessionStorage.getItem(SESSION_KEY) === null; } catch (e) { return true; } } /** 标记会话已激活(刷新页面后保留,浏览器关闭后自动清除) */ function markSession() { try { sessionStorage.setItem(SESSION_KEY, '1'); } catch (e) {} } /** 从 localStorage 恢复缓存(正确反序列化 Map 对象) */ function loadInvCache(sid) { try { const raw = localStorage.getItem('sme_inv_' + sid); if (!raw) return; const obj = JSON.parse(raw); for (const key in obj) { if (obj[key] && Array.isArray(obj[key].data)) { invCache[key] = { ts: obj[key].ts, data: new Map(obj[key].data) }; } else if (obj[key] && obj[key].data === null) { invCache[key] = { ts: obj[key].ts, data: null }; } } L('Loaded persisted inventory cache:', Object.keys(invCache).length, 'entries'); } catch (e) { L('Failed to load persisted inventory cache:', e.message); } } /** 保存缓存到 localStorage(防抖 200ms,正确序列化 Map 对象) */ let _invSaveTimer = null; function saveInvCache(sid) { if (_invSaveTimer) clearTimeout(_invSaveTimer); _invSaveTimer = setTimeout(() => { try { const obj = {}; for (const key in invCache) { if (invCache[key] && invCache[key].data instanceof Map) { obj[key] = { ts: invCache[key].ts, data: Array.from(invCache[key].data.entries()) }; } else if (invCache[key]) { obj[key] = { ts: invCache[key].ts, data: null }; } } localStorage.setItem('sme_inv_' + sid, JSON.stringify(obj)); } catch (e) { L('Failed to persist inventory cache:', e.message); } _invSaveTimer = null; }, 200); } /** 清除指定 SteamID 的库存缓存 */ function clearInvCache(sid) { try { localStorage.removeItem('sme_inv_' + sid); } catch (e) {} } /** 会话级缓存加载:新会话清除过期缓存,相同会话从 localStorage 恢复 */ function loadCacheForSession(sid) { if (isFreshSession()) { L('Fresh browser session, clearing stale inventory cache'); clearInvCache(sid); markSession(); } else { loadInvCache(sid); L('Same browser session, restored inventory cache from storage'); } } /** 写入内存缓存并触发持久化保存 */ function writeInvCache(key, data, sid) { invCache[key] = { ts: Date.now(), data }; if (sid) saveInvCache(sid); } // ==================== 请求队列(限流) ==================== const invQueue = []; let invProcessing = false; function enqueueInv(task) { return new Promise((resolve, reject) => { invQueue.push({ t: task, r: resolve, j: reject }); drainInvQueue(); }); } function drainInvQueue() { if (invProcessing || invQueue.length === 0) return; invProcessing = true; function next() { if (invQueue.length === 0) { invProcessing = false; return; } const item = invQueue.shift(); item.t().then(item.r, item.j).then(() => { if (invQueue.length > 0) { sleep(REQUEST_DELAY).then(next); } else { invProcessing = false; if (invQueue.length > 0) drainInvQueue(); } }); } next(); } // ==================== 页面检测 ==================== function detectPageType() { const p = location.pathname; // 市场详情页 if (/^\/market\/listings\/\d+\/.+/.test(p)) return 'listing'; // 市场浏览页 / 我的市场 if (/^\/market\/?$/.test(p) || /^\/market\/search/.test(p)) return 'browse'; // 库存页 if (/^\/(?:id|profiles)\/[^/]+\/inventory/.test(p)) return 'inventory'; // 交易报价页 if (/^\/tradeoffer/.test(p)) return 'tradeoffer'; return 'other'; } function getListingInfo() { const m = location.pathname.match(/^\/market\/listings\/(\d+)\/(.+?)(?:\?.*)?$/); if (!m) return null; try { return { appid: m[1], marketHashName: decodeURIComponent(m[2]) }; } catch (e) { return { appid: m[1], marketHashName: m[2] }; } } // ==================== SSR 数据提取 ==================== let _capturedItemNameId = null; /** 递归搜索对象中的 item_nameid 属性(最多 5 层深度) */ function findItemNameIdDeep(obj, depth) { if (!obj || depth > 5) return null; if (typeof obj !== 'object') return null; // 直接属性 if (obj.item_nameid) return String(obj.item_nameid); // 遍历子属性 for (const key in obj) { if (key === 'item_nameid' && obj[key]) return String(obj[key]); if (typeof obj[key] === 'object' && obj[key] !== null) { const found = findItemNameIdDeep(obj[key], depth + 1); if (found) return found; } } return null; } function extractSSRData() { const result = { sellOrders: null, itemNameId: null }; try { const ssr = unsafeWindow.SSR?.loaderData; if (!Array.isArray(ssr)) return result; for (const entry of ssr) { try { const data = typeof entry === 'string' ? JSON.parse(entry) : entry; if (!data || typeof data !== 'object') continue; if (data.myOrders?.rgSellOrders && Array.isArray(data.myOrders.rgSellOrders)) { result.sellOrders = data.myOrders.rgSellOrders.map(o => ({ listingid: o.listingid, assetid: o.assetid, rtListed: o.rtListed, dateKey: tsToDateKey(o.rtListed), dateTime: tsToDateTime(o.rtListed), buyerPrice: (o.strBuyerPrice || '').trim(), sellerPrice: (o.strSellerPrice || '').trim(), })); } // 直接路径 if (data.item_nameid) result.itemNameId = String(data.item_nameid); if (data.asset_description?.item_nameid) result.itemNameId = String(data.asset_description.item_nameid); // 递归搜索(处理嵌套结构,如 mediators/servicehelpers 等) if (!result.itemNameId) { const found = findItemNameIdDeep(data, 0); if (found) result.itemNameId = found; } } catch (e) {} } } catch (e) {} return result; } // ==================== SSR 订单簿提取(新版页面,无 item_nameid) ==================== /** Steam 货币代码 → 前缀/后缀映射(常用货币) */ const CURRENCY_SYMBOLS = { 1: { prefix: '$', suffix: '' }, // USD 2: { prefix: '£', suffix: '' }, // GBP 3: { prefix: '€', suffix: '' }, // EUR 4: { prefix: 'CHF ', suffix: '' }, // CHF 5: { prefix: 'RUB ', suffix: ' pуб.' },// RUB (旧格式) 6: { prefix: 'R$', suffix: '' }, // BRL 7: { prefix: '¥', suffix: '' }, // JPY 8: { prefix: 'kr ', suffix: '' }, // NOK 9: { prefix: 'kr ', suffix: '' }, // IDR 10: { prefix: 'RM', suffix: '' }, // MYR 11: { prefix: '₱', suffix: '' }, // PHP 12: { prefix: 'S$', suffix: '' }, // SGD 13: { prefix: '฿', suffix: '' }, // THB 14: { prefix: '₫', suffix: '' }, // VND 16: { prefix: '₩', suffix: '' }, // KRW 17: { prefix: 'TL', suffix: '' }, // TRY 18: { prefix: '₴', suffix: '' }, // UAH 19: { prefix: 'Mex$ ', suffix: '' }, // MXN 20: { prefix: 'CDN$ ', suffix: '' }, // CAD 21: { prefix: 'A$', suffix: '' }, // AUD 22: { prefix: 'NZ$ ', suffix: '' }, // NZD 23: { prefix: '₹', suffix: '' }, // INR (₹) 24: { prefix: '¥', suffix: '' }, // CNY (¥) 25: { prefix: 'HK$', suffix: '' }, // HKD 26: { prefix: 'NT$', suffix: '' }, // TWD 27: { prefix: 'SR ', suffix: ' SR' }, // SAR 28: { prefix: 'AED ', suffix: ' AED' },// AED }; /** 获取货币前缀/后缀(优先从 Steam 原生函数提取) */ function getCurrencySymbol(currencyCode) { try { if (unsafeWindow.v_currencyformat) { const formatted = unsafeWindow.v_currencyformat(123456, currencyCode || 1); const match = formatted.match(/^([^\d]*)([\d.,]+)([^\d]*)$/); if (match) { return { prefix: match[1] || '', suffix: match[3] || '' }; } } } catch (e) {} return CURRENCY_SYMBOLS[currencyCode] || { prefix: '', suffix: '' }; } /** 格式化分(cents)为价格字符串 */ function formatCentsStr(cents, currencyCode) { const sym = getCurrencySymbol(currencyCode); try { if (unsafeWindow.v_currencyformat) { return unsafeWindow.v_currencyformat(cents, currencyCode || 1); } } catch (e) {} return sym.prefix + (cents / 100).toFixed(2) + sym.suffix; } /** 递归搜索对象中的订单簿数据(rgCompactBuyOrders + rgCompactSellOrders) */ function findOrderBookDeep(obj, depth) { if (!obj || depth > 6 || typeof obj !== 'object') return null; if (Array.isArray(obj.rgCompactBuyOrders) && Array.isArray(obj.rgCompactSellOrders)) { return obj; } for (const key in obj) { if (typeof obj[key] === 'object' && obj[key] !== null) { const found = findOrderBookDeep(obj[key], depth + 1); if (found) return found; } } return null; } /** 解析压缩格式的订单簿数组 [price, qty, price, qty, ...] → [{price, qty}, ...] */ function parseCompactOrders(arr, currencyCode) { const orders = []; for (let i = 0; i + 1 < arr.length; i += 2) { const priceCents = arr[i]; const qty = arr[i + 1]; if (qty > 0) { orders.push({ price: formatCentsStr(priceCents, currencyCode), qty: qty, _cents: priceCents, }); } } return orders; } /** 从 SSR raw 对象(含 rgCompactBuyOrders/rgCompactSellOrders)构建订单簿结果 */ function buildOrderBookFromRaw(raw, sourceTag) { const currencyCode = raw.eCurrency || 1; const sym = getCurrencySymbol(currencyCode); const result = { sellOrders: parseCompactOrders(raw.rgCompactSellOrders || [], currencyCode), buyOrders: parseCompactOrders(raw.rgCompactBuyOrders || [], currencyCode), sellSummary: '', buySummary: '', highestBuy: String(raw.amtMaxBuyOrder || ''), lowestSell: String(raw.amtMinSellOrder || ''), prefix: sym.prefix, suffix: sym.suffix, _source: sourceTag, }; L(`Order book extracted (${sourceTag}):`, result.sellOrders.length, 'sell,', result.buyOrders.length, 'buy'); return result; } /** 从 SSR 数据中提取订单簿(新版页面,无需 item_nameid) */ function extractOrderBookFromSSR() { // 1. 搜索 loaderData 数组(部分旧版 SSR 页面) try { const ssr = unsafeWindow.SSR?.loaderData; if (Array.isArray(ssr)) { for (const entry of ssr) { try { const data = typeof entry === 'string' ? JSON.parse(entry) : entry; if (!data || typeof data !== 'object') continue; const raw = findOrderBookDeep(data, 0); if (raw) return buildOrderBookFromRaw(raw, 'ssr-loaderData'); } catch (e) {} } } } catch (e) {} // 2. 搜索 renderContext.queryData(新版 React SSR 页面,订单簿内嵌在 React Query dehydrated state 中) // 结构: SSR.renderContext = JSON.parse('{"queryData":"{\\"queries\\":[{...\\"rgCompactBuyOrders\\":...}]}"}') // renderContext 已被浏览器 JSON.parse 为对象,但 queryData 仍是嵌套 JSON 字符串,需二次解析 try { const rc = unsafeWindow.SSR?.renderContext; if (rc && typeof rc === 'object') { // queryData 是嵌套 JSON 字符串,包含 React Query 的 mutations 和 queries const qd = rc.queryData; if (typeof qd === 'string' && qd.length > 0) { const parsed = JSON.parse(qd); const raw = findOrderBookDeep(parsed, 0); if (raw) return buildOrderBookFromRaw(raw, 'ssr-renderContext'); } // 也直接递归搜索 renderContext 本身(万一 queryData 已被预解析为对象) const raw2 = findOrderBookDeep(rc, 0); if (raw2) return buildOrderBookFromRaw(raw2, 'ssr-renderContext-obj'); } } catch (e) {} // 3. 正则兜底:从页面 script 标签文本提取 return extractOrderBookFromScripts(); } /** 从页面 script 标签正则提取订单簿数据(兜底方案) */ function extractOrderBookFromScripts() { try { for (const script of document.querySelectorAll('script')) { const text = script.textContent || ''; if (text.indexOf('rgCompactBuyOrders') === -1) continue; // 提取 rgCompactBuyOrders 数组 // 注意:新版 SSR 页面中数据被多层 JSON 转义,关键词与冒号间可能出现 \\\" 等转义引号 // [\\\"]*\s* 匹配任意数量的反斜杠、引号和空白字符 const buyMatch = text.match(/rgCompactBuyOrders[\\\"]*\s*:\s*\[([^\]]+)\]/); const sellMatch = text.match(/rgCompactSellOrders[\\\"]*\s*:\s*\[([^\]]+)\]/); const maxBuyMatch = text.match(/amtMaxBuyOrder[\\\"]*\s*:\s*"?(\d+)/); const minSellMatch = text.match(/amtMinSellOrder[\\\"]*\s*:\s*"?(\d+)/); const curMatch = text.match(/eCurrency[\\\"]*\s*:\s*"?(\d+)/); if (buyMatch && sellMatch) { const currencyCode = curMatch ? parseInt(curMatch[1], 10) : 1; const sym = getCurrencySymbol(currencyCode); const buyArr = buyMatch[1].split(',').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n)); const sellArr = sellMatch[1].split(',').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n)); const result = { sellOrders: parseCompactOrders(sellArr, currencyCode), buyOrders: parseCompactOrders(buyArr, currencyCode), sellSummary: '', buySummary: '', highestBuy: maxBuyMatch ? maxBuyMatch[1] : '', lowestSell: minSellMatch ? minSellMatch[1] : '', prefix: sym.prefix, suffix: sym.suffix, _source: 'ssr-regex', }; L('Order book extracted from scripts (regex):', result.sellOrders.length, 'sell,', result.buyOrders.length, 'buy'); return result; } } } catch (e) {} return null; } /** * 统一获取订单簿数据(SSR 优先 → item_nameid+API 降级) * 新版页面无 item_nameid,订单簿数据直接内嵌在 SSR React Query 状态中 */ async function fetchOrderBook() { // 1. 优先从 SSR 提取(新版页面,无需 item_nameid,零网络请求) const ssrData = extractOrderBookFromSSR(); // SSR 数据有效条件:有买卖单明细,或有最高买/最低卖价(低流动性物品可能无明细但有价格) if (ssrData && (ssrData.sellOrders.length > 0 || ssrData.buyOrders.length > 0 || ssrData.highestBuy || ssrData.lowestSell)) { return ssrData; } // 2. 降级:通过 item_nameid + API 获取(旧版页面) const itemNameId = await getItemNameIdAsync(); if (!itemNameId) { throw new Error('无法获取订单簿数据(SSR 无数据且 item_nameid 不可用)'); } return fetchOrderHistogram(itemNameId); } function getItemNameId() { // 1. 全局变量 g_item_nameid try { if (unsafeWindow.g_item_nameid) return String(unsafeWindow.g_item_nameid); } catch (e) {} // 2. SSR 数据(含递归搜索) const ssr = extractSSRData(); if (ssr.itemNameId) return ssr.itemNameId; // 3. 页面 script 标签正则匹配(多种格式) for (const script of document.querySelectorAll('script')) { const text = script.textContent || ''; let m; if (m = text.match(/Market_LoadOrderSpread\(\s*(\d+)/)) return m[1]; if (m = text.match(/g_item_nameid\s*=\s*["']?(\d+)/)) return m[1]; if (m = text.match(/"item_nameid"\s*:\s*"?(\d+)/)) return m[1]; if (m = text.match(/item_nameid["'\s:=]+(\d{5,})/)) return m[1]; } // 4. XHR 拦截器捕获的值 if (_capturedItemNameId) return _capturedItemNameId; // 5. DOM data-item-nameid 属性 const el = document.querySelector('[data-item-nameid]'); if (el) return el.dataset.itemNameid; return null; } /** 异步获取 item_nameid(带重试,等待页面异步加载完成) */ async function getItemNameIdAsync(maxRetries, interval) { if (maxRetries === undefined) maxRetries = 6; if (interval === undefined) interval = 500; for (let i = 0; i < maxRetries; i++) { const id = getItemNameId(); if (id) return id; if (i < maxRetries - 1) { L('getItemNameId retry', (i + 1) + '/' + maxRetries); await sleep(interval); } } return null; } // XHR 拦截器(捕获 item_nameid) (function interceptXHR() { const origOpen = XMLHttpRequest.prototype.open; XMLHttpRequest.prototype.open = function (method, url) { if (url && url.indexOf('itemordershistogram') !== -1) { const m = url.match(/item_nameid=(\d+)/); if (m) _capturedItemNameId = m[1]; } return origOpen.apply(this, arguments); }; })(); // ==================== 订单分组 ==================== function groupOrders(orders) { const dateMap = new Map(); for (const o of orders) { if (!dateMap.has(o.dateKey)) { dateMap.set(o.dateKey, { dateKey: o.dateKey, prices: new Map(), items: [] }); } const dg = dateMap.get(o.dateKey); if (!dg.prices.has(o.buyerPrice)) dg.prices.set(o.buyerPrice, []); dg.prices.get(o.buyerPrice).push(o); dg.items.push(o); } for (const g of dateMap.values()) { for (const items of g.prices.values()) items.sort((a, b) => b.rtListed - a.rtListed); } return [...dateMap.values()] .sort((a, b) => b.dateKey.localeCompare(a.dateKey)) .map(g => ({ dateKey: g.dateKey, prices: g.prices, sortedPrices: [...g.prices.keys()].sort(), totalCount: g.items.length, })); } // ==================== 库存 API ==================== function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } function doFetch(url) { return fetch(url, { credentials: 'include' }).then(r => { if (r.status === 429) throw new Error('RateLimited'); if (r.status === 400) throw new Error('BadRequest'); if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }); } function parseInventoryData(data) { const owned = new Map(); if (Array.isArray(data.assets) && data.assets.length > 0 && Array.isArray(data.descriptions)) { const descMap = {}; data.descriptions.forEach(d => { descMap[d.classid + '_' + (d.instanceid || '0')] = d; }); data.assets.forEach(item => { const d = descMap[item.classid + '_' + (item.instanceid || '0')]; if (d) { const name = d.market_hash_name || d.name || d.market_name; if (name) { const amt = parseInt(item.amount, 10) || 1; owned.set(name, (owned.get(name) || 0) + amt); } } }); } if (owned.size === 0 && data.rgInventory && data.rgDescriptions) { Object.keys(data.rgInventory).forEach(id => { const item = data.rgInventory[id]; const d = data.rgDescriptions[item.classid + '_' + item.instanceid]; if (d) { const name = d.market_hash_name || d.name || d.market_name; if (name) { const amt = parseInt(item.amount, 10) || 1; owned.set(name, (owned.get(name) || 0) + amt); } } }); } return owned; } function buildInvUrl(sid, appid, ctxId, count, fmt, startAssetid) { const base = fmt === 'json' ? `https://steamcommunity.com/profiles/${sid}/inventory/json/${appid}/${ctxId}/` : `https://steamcommunity.com/inventory/${sid}/${appid}/${ctxId}`; let params = `?l=english&count=${count}`; if (startAssetid) params += `&start_assetid=${startAssetid}`; return base + params; } function fetchOnePage(url, retry) { if (retry === undefined) retry = 0; return doFetch(url).catch(err => { const msg = err.message || String(err); if (msg === 'RateLimited') { W('Rate limited, waiting', RETRY_DELAY * (retry + 1), 'ms'); } else if (msg === 'BadRequest') { W('Bad request (400), no retry'); throw err; // 400 不重试,由上层 fallback 处理 } else if (msg.indexOf('HTTP 5') === 0) { L('Server error (' + msg + '), skipping retry'); throw err; // 5xx 不重试,返回 null 触发级联降级 } else { W('Request failed (#' + (retry + 1) + '):', msg); } if (retry < RETRY_MAX) { return sleep(RETRY_DELAY * (retry + 1)).then(() => fetchOnePage(url, retry + 1)); } throw err; }); } function fetchInventory(appid, ctxId, count, fmt) { if (!fmt) fmt = 'default'; const key = appid + '_' + ctxId + '_' + fmt; const sid = getSteamId(); if (!sid) return Promise.resolve(null); const entry = invCache[key]; if (entry && (Date.now() - entry.ts) < CACHE_TTL.INVENTORY) return Promise.resolve(entry.data); if (invPending[key]) return invPending[key]; invPending[key] = (async () => { try { const firstUrl = buildInvUrl(sid, appid, ctxId, count, fmt, null); L('Fetching inventory [' + fmt + ']: appid=' + appid + ', ctx=' + ctxId + ', count=' + count); const data = await fetchOnePage(firstUrl, 0); if (!data || !data.success) { L('Inventory empty/inaccessible [' + fmt + ']: appid=' + appid + ', success=' + (data ? data.success : 'null')); const empty = new Map(); writeInvCache(key, empty, sid); return empty; } let owned = parseInventoryData(data); const total = data.total_inventory_count || 0; let fetched = (data.assets ? data.assets.length : (data.rgInventory ? Object.keys(data.rgInventory).length : 0)); if (data.more_items && data.last_assetid && fetched < total) { L('[Pagination] fetched ' + fetched + '/' + total + ' items, continuing with last_assetid=' + data.last_assetid); const fetchNext = async (lastId, acc) => { await sleep(REQUEST_DELAY); L('[Pagination] requesting next page: start_assetid=' + lastId); const pageData = await fetchOnePage(buildInvUrl(sid, appid, ctxId, count, fmt, lastId), 0); if (!pageData || !pageData.success) { L('[Pagination] next page failed, returning data fetched so far'); return acc; } const pageOwned = parseInventoryData(pageData); pageOwned.forEach((amt, name) => acc.set(name, (acc.get(name) || 0) + amt)); const pageCount = (pageData.assets ? pageData.assets.length : (pageData.rgInventory ? Object.keys(pageData.rgInventory).length : 0)); L('[Pagination] this page: ' + pageCount + ' items, cumulative unique items=' + acc.size); if (pageData.more_items && pageData.last_assetid) return fetchNext(pageData.last_assetid, acc); return acc; }; owned = await fetchNext(data.last_assetid, owned).catch(() => { L('[Pagination] error fetching next page, returning data fetched so far'); return owned; }); } L('[Pagination] complete: appid=' + appid + ', unique items=' + owned.size); writeInvCache(key, owned, sid); return owned; } catch (err) { const msg = err.message || String(err); // BadRequest(400) — count 可能过大,降级到更小的 count if (msg === 'BadRequest') { if (count > INVENTORY_FALLBACK_COUNT) { W('count=' + count + ' rejected, falling back to count=' + INVENTORY_FALLBACK_COUNT); delete invPending[key]; return fetchInventory(appid, ctxId, INVENTORY_FALLBACK_COUNT, fmt); } W('Inventory inaccessible (400) [' + fmt + ']: appid=' + appid); const empty400 = new Map(); writeInvCache(key, empty400, sid); return empty400; } // HTTP 5xx — 服务器端错误,返回 null 触发级联降级(json 格式 + context 1) if (msg.indexOf('HTTP 5') === 0) { L('Server error [' + fmt + ']: appid=' + appid + ', ' + msg); writeInvCache(key, null, sid); return null; } W('Inventory fetch failed [' + fmt + ']:', msg); writeInvCache(key, null, sid); return null; } finally { delete invPending[key]; } })(); return invPending[key]; } async function getOwnedCount(appid, hashName, ctxId) { if (!ctxId) ctxId = APP_CONTEXT[appid] || '2'; const tryCtx = async (cid, fmt) => { const key = appid + '_' + cid + '_' + fmt; const cached = invCache[key]; if (cached) { if (cached.data instanceof Map) return cached.data.get(hashName) || 0; return -1; // null = 之前请求失败 } // 通过请求队列限流,避免同时大量请求触发 429 const owned = await enqueueInv(() => fetchInventory(appid, cid, INVENTORY_COUNT, fmt)); if (!owned) return -1; return owned.get(hashName) || 0; }; // 主流程:使用游戏默认 context(含分页) let count = await tryCtx(ctxId, 'default'); if (count > 0) return count; const fallbackKey = appid + '_' + ctxId; // 降级1: default 格式失败时尝试 json 传统格式(用 jsonFallbackDone 避免逐条触发) if (count < 0 && !jsonFallbackDone[fallbackKey]) { jsonFallbackDone[fallbackKey] = true; L('[Fallback] default format failed, trying json legacy format: appid=' + appid); count = await tryCtx(ctxId, 'json'); if (count > 0) return count; } // 降级2: 尝试 context 1(某些游戏物品在 context 1 下) if (ctxId === '2' && !SKIP_CONTEXT_1[appid]) { count = await tryCtx('1', 'default'); if (count > 0) return count; } return 0; } // ==================== 市场 API ==================== async function fetchPriceHistory(appid, hashName) { const cacheKey = `ph_${appid}_${hashName}`; const cached = cache.get(cacheKey, CACHE_TTL.PRICE_HISTORY); // 验证缓存数据:t 必须是数字(时间戳),旧版缓存可能是字符串(Date 序列化) if (cached && cached.prices && cached.prices.length > 0 && typeof cached.prices[0].t === 'number') return cached; const url = `https://steamcommunity.com/market/pricehistory/?appid=${appid}&market_hash_name=${encodeURIComponent(hashName)}`; try { const resp = await fetch(url, { credentials: 'include' }); if (!resp.ok) throw new Error('HTTP ' + resp.status); const data = await resp.json(); if (!data.success) throw new Error('API returned success=false'); const result = { prices: (data.prices || []).map(p => ({ t: parsePriceDate(p[0]).getTime(), p: p[1], v: parseInt(p[2], 10) || 0 })), prefix: data.price_prefix || '', suffix: data.price_suffix || '', }; cache.set(cacheKey, result); return result; } catch (err) { W('Price history fetch failed:', err.message); throw err; } } async function fetchOrderHistogram(itemNameId) { const cacheKey = `ob_${itemNameId}`; const cached = cache.get(cacheKey, CACHE_TTL.ORDER_BOOK); if (cached) return cached; let country = 'US', language = 'english', currency = 1; try { if (unsafeWindow.g_strCountryCode) country = unsafeWindow.g_strCountryCode; if (unsafeWindow.g_strLanguage) language = unsafeWindow.g_strLanguage; if (unsafeWindow.g_rgWalletInfo?.wallet_currency != null) currency = unsafeWindow.g_rgWalletInfo.wallet_currency; } catch (e) {} const url = `https://steamcommunity.com/market/itemordershistogram/?country=${country}&language=${language}¤cy=${currency}&item_nameid=${itemNameId}&two_factor=0`; try { const resp = await fetch(url, { credentials: 'include' }); if (!resp.ok) throw new Error('HTTP ' + resp.status); const data = await resp.json(); if (!data.success) throw new Error('API returned success=false'); const result = { sellOrders: parseOrderTable(data.sell_order_table), buyOrders: parseOrderTable(data.buy_order_table), sellSummary: data.sell_order_summary || '', buySummary: data.buy_order_summary || '', highestBuy: data.highest_buy_order || '', lowestSell: data.lowest_sell_order || '', prefix: data.price_prefix || '', suffix: data.price_suffix || '', }; cache.set(cacheKey, result); return result; } catch (err) { W('Order histogram fetch failed:', err.message); throw err; } } function parseOrderTable(htmlStr) { if (!htmlStr) return []; try { const doc = new DOMParser().parseFromString(htmlStr, 'text/html'); const rows = doc.querySelectorAll('tr'); const orders = []; rows.forEach(row => { const cells = row.querySelectorAll('td'); if (cells.length >= 2) { const price = cells[0].textContent.trim(); const qty = parseInt(cells[1].textContent.trim().replace(/[^\d]/g, ''), 10); if (price && qty > 0) orders.push({ price, qty }); } }); return orders; } catch (e) { return []; } } // ==================== 手续费计算 ==================== let _walletInfo = null; function getWalletInfo() { if (_walletInfo) return _walletInfo; try { const wi = unsafeWindow.g_rgWalletInfo; if (wi) { _walletInfo = { wallet_fee_percent: parseFloat(wi.wallet_fee_percent) || 0.05, wallet_fee_base: parseInt(wi.wallet_fee_base) || 0, wallet_fee_minimum: parseInt(wi.wallet_fee_minimum) || 1, wallet_publisher_fee_percent_default: parseFloat(wi.wallet_publisher_fee_percent_default) || 0.10, wallet_currency: wi.wallet_currency || 1, }; // 检测货币代码以决定取整方式(2025年12月Steam变更) _walletInfo.currency_code = getCurrencyCode(_walletInfo.wallet_currency); _walletInfo.use_round = CURRENCY_CODES_TO_ROUND.includes(_walletInfo.currency_code); return _walletInfo; } } catch (e) {} // 默认值:Steam 5% + 发行商 10% _walletInfo = { wallet_fee_percent: 0.05, wallet_fee_base: 0, wallet_fee_minimum: 1, wallet_publisher_fee_percent_default: 0.10, wallet_currency: 1, currency_code: 'USD', use_round: false, }; return _walletInfo; } /** Steam 货币 ID → 货币代码映射(常见货币) */ const CURRENCY_ID_TO_CODE = { 1: 'USD', 2: 'GBP', 3: 'EUR', 4: 'CHF', 5: 'RUB', 6: 'PLN', 7: 'BRL', 9: 'NOK', 10: 'IDR', 11: 'MYR', 12: 'PHP', 13: 'SGD', 14: 'THB', 15: 'VND', 16: 'KRW', 17: 'TRY', 18: 'UAH', 19: 'MXN', 20: 'CAD', 21: 'AUD', 22: 'NZD', 23: 'CNY', 24: 'INR', 25: 'CLP', 26: 'PEN', 27: 'COP', 28: 'ZAR', 29: 'HKD', 30: 'TWD', 31: 'SAR', 32: 'AED', 34: 'ARS', 35: 'ILS', 36: 'BYN', 37: 'KZT', 38: 'UYU', 39: 'CZK', 40: 'CRC', 41: 'QAR', 43: 'JPY', }; function getCurrencyCode(currencyId) { try { if (unsafeWindow.GetCurrencyCode) return unsafeWindow.GetCurrencyCode(currencyId); } catch (e) {} return CURRENCY_ID_TO_CODE[currencyId] || 'USD'; } /** * 从卖家到手价反推买家支付价(含手续费) * 参考 Steam Economy Enhancer 的 CalculateAmountToSendForDesiredReceivedAmount * 2025年12月Steam变更:部分货币使用 Math.round 而非 Math.floor */ function calcAmountToSend(receivedAmount, publisherFee) { const wi = getWalletInfo(); if (publisherFee === undefined) publisherFee = wi.wallet_publisher_fee_percent_default; // 2025年12月变更:根据货币类型选择取整方式 const roundFee = wi.use_round ? Math.round : Math.floor; const minFee = wi.wallet_fee_minimum; // 关键顺序:计算百分比 → 加 base → 取整 → 与最低手续费比较取最大值 const nSteamFee = Math.max( parseInt(roundFee(receivedAmount * wi.wallet_fee_percent + wi.wallet_fee_base)), minFee ); const nPublisherFee = publisherFee > 0 ? Math.max(parseInt(roundFee(receivedAmount * publisherFee)), minFee) : 0; return { steam_fee: nSteamFee, publisher_fee: nPublisherFee, fees: nSteamFee + nPublisherFee, amount: receivedAmount + nSteamFee + nPublisherFee, }; } /** * 从买家支付价反推卖家到手价(不含手续费) * 使用迭代逼近法解决 floor 导致的 1-2 分误差 */ function calcReceivedAmount(paidAmount, publisherFee) { const wi = getWalletInfo(); if (publisherFee === undefined) publisherFee = wi.wallet_publisher_fee_percent_default; let estimatedReceived = paidAmount; let iterations = 0; let lastAmount = 0; while (iterations < 10) { const result = calcAmountToSend(estimatedReceived, publisherFee); if (result.amount === paidAmount || result.amount === lastAmount) { return { received: estimatedReceived, ...result }; } lastAmount = result.amount; estimatedReceived = paidAmount - result.fees; iterations++; } const result = calcAmountToSend(estimatedReceived, publisherFee); return { received: estimatedReceived, ...result }; } /** 买家支付价 → 卖家到手价(分) */ function priceBeforeFees(priceWithFeesCents) { return calcReceivedAmount(priceWithFeesCents).received; } /** 卖家到手价 → 买家支付价(分) */ function priceIncludingFees(priceBeforeFeesCents) { return calcAmountToSend(priceBeforeFeesCents).amount; } /** * 解析价格字符串为分(cents) * 如 "¥3.50" → 350, "$0.03" → 3 */ function parsePriceToCents(priceStr) { if (!priceStr) return 0; const m = String(priceStr).match(/([\d.,]+)/); if (!m) return 0; let numStr = m[1].replace(/,/g, ''); // 处理欧洲格式(如 3.50,00) if (numStr.lastIndexOf('.') < numStr.lastIndexOf(',')) { numStr = numStr.replace(/\./g, '').replace(',', '.'); } return Math.round(parseFloat(numStr) * 100) || 0; } /** * 格式化分→价格字符串(复用 Steam 原生函数或自行格式化) */ function formatCents(cents, prefix = '', suffix = '') { try { const wi = getWalletInfo(); if (unsafeWindow.v_currencyformat) { return unsafeWindow.v_currencyformat(cents, wi.wallet_currency); } } catch (e) {} return prefix + (cents / 100).toFixed(2) + suffix; } // ==================== 物品类型检测 ==================== /** 判断是否为交易卡牌 */ function isTradingCard(item) { if (!item) return false; const type = (item.type || item.item_type || '').toLowerCase(); const tags = item.tags || {}; // 通过 tags 检测 if (tags.item_class) { const cls = Array.isArray(tags.item_class) ? tags.item_class : [tags.item_class]; for (const t of cls) { if (t && t.internal_name === 'item_class_2') return true; } } // 通过 type 名称检测 return type === 'trading card' || type === '交易卡牌'; } /** 判断是否为闪箔卡牌 */ function isFoilTradingCard(item) { if (!item) return false; if (!isTradingCard(item)) return false; const name = (item.name || item.market_hash_name || '').toLowerCase(); return name.includes('foil') || name.includes('闪箔'); } /** 判断是否为补充包 */ function isBoosterPack(item) { if (!item) return false; const name = (item.name || item.market_hash_name || '').toLowerCase(); return name.includes('booster pack') || name.includes('补充包'); } /** 判断是否为宝石 */ function isGems(item) { if (!item) return false; const name = (item.name || item.market_hash_name || '').toLowerCase(); const type = (item.type || '').toLowerCase(); return name.includes('gem') || type.includes('gem') || name.includes('宝石'); } /** 判断是否为箱子/容器(TF2 等) */ function isCrate(item) { if (!item) return false; const type = (item.type || '').toLowerCase(); return type === 'container' || type === 'crate' || type === '箱子'; } /** * 根据物品类型获取差异化价格范围 * 参考 Steam Economy Enhancer 的 getPriceInformation * @returns {{min:number, max:number}} 本地货币单位 */ function getItemPriceInfo(item) { if (!item) { return { min: settings.get('minPrice'), max: settings.get('maxPrice') }; } if (isFoilTradingCard(item)) { return { min: settings.get('minFoilCardPrice'), max: settings.get('maxFoilCardPrice') }; } if (isTradingCard(item)) { return { min: settings.get('minNormalCardPrice'), max: settings.get('maxNormalCardPrice') }; } return { min: settings.get('minMiscPrice'), max: settings.get('maxMiscPrice') }; } /** 判断错误消息是否为"上一个操作未完成"重试类消息 */ const RETRY_MSG_RE = /(?:在上一个操作完成之前|before your previous action|You cannot do this right now)/i; function isRetryMessage(msg) { return RETRY_MSG_RE.test(msg || ''); } /** 随机整数 [min, max] */ function randInt(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; } // ==================== 定价算法 ==================== const ALGORITHM_NAMES = { 1: '最高价优先(历史均价 vs 最低卖单)', 2: '最低卖单', 3: '最高买单或最低卖单', 4: '历史均价', }; /** * 计算价格历史加权均价(转为分,不含手续费) * 参考 Steam Economy Enhancer 的 calculateAverageHistoryPriceBeforeFees */ function calcAverageHistoryPrice(priceHistory, hours) { if (!priceHistory || !priceHistory.prices || priceHistory.prices.length === 0) return -1; const cutoff = Date.now() - hours * 60 * 60 * 1000; let totalValue = 0, totalQty = 0; for (const p of priceHistory.prices) { if (p.t >= cutoff) { totalValue += p.p * p.v; totalQty += p.v; } } if (totalQty === 0) return -1; const avgPrice = Math.floor(totalValue / totalQty) * 100; // 转为分 return priceBeforeFees(avgPrice); } /** * 获取最低卖单价(分,不含手续费),含智能低数量过滤 * 参考 Steam Economy Enhancer 的 calculateListingPriceBeforeFees */ function calcLowestListingPrice(orderBook, ignoreLowQty) { if (!orderBook || !orderBook.lowestSell) return -1; // lowestSell 已是分(cents),直接 parseInt const lowestCents = parseInt(orderBook.lowestSell, 10) || 0; if (!ignoreLowQty || !orderBook.sellOrders || orderBook.sellOrders.length < 2) { return priceBeforeFees(lowestCents); } // 智能过滤:最低价挂单数量极少时使用第二低价 const lowestQty = orderBook.sellOrders[0]?.qty || 0; // sellOrders 价格可能是格式化字符串(如 "¥20.70")或带 _cents 属性 const secondOrder = orderBook.sellOrders[1]; const secondCents = secondOrder?._cents != null ? secondOrder._cents : parsePriceToCents(secondOrder?.price); const secondQty = secondOrder?.qty || 0; if (secondQty === 0 || lowestQty === 0 || !secondCents) return priceBeforeFees(lowestCents); const pctLower = 100 * (lowestQty / secondQty); // 按第二低价数量分级判断 let useSecond = false; if (secondQty >= 1000 && pctLower <= 5) useSecond = true; else if (secondQty >= 100 && pctLower <= 10) useSecond = true; else if (secondQty >= 50 && pctLower <= 15) useSecond = true; else if (secondQty >= 25 && pctLower <= 20) useSecond = true; else if (secondQty >= 10 && pctLower <= 25) useSecond = true; else if (pctLower <= 30) useSecond = true; return priceBeforeFees(useSecond ? secondCents : lowestCents); } /** * 获取最高买单价(分,不含手续费) */ function calcHighestBuyOrderPrice(orderBook) { if (!orderBook || !orderBook.highestBuy) return -1; // highestBuy 已是分(cents),直接 parseInt return priceBeforeFees(parseInt(orderBook.highestBuy, 10) || 0); } /** * 综合定价算法 — 计算建议出售价格(分,不含手续费) * 参考 Steam Economy Enhancer 的 calculateSellPriceBeforeFees * @param {object} priceHistory - 价格历史数据 * @param {object} orderBook - 订单簿数据 * @param {boolean} applyOffset - 是否应用价格偏移 * @returns {object} { priceCents, source, historyPrice, listingPrice, buyOrderPrice } */ function calcSellPrice(priceHistory, orderBook, applyOffset) { const algo = settings.get('priceAlgorithm'); const ignoreLowQty = settings.get('ignoreLowQuantity'); const historyHours = settings.get('historyHours'); const offsetCents = Math.round(settings.get('priceOffset') * 100); const minCents = Math.round(settings.get('minPrice') * 100); const maxCents = Math.round(settings.get('maxPrice') * 100); const historyPrice = calcAverageHistoryPrice(priceHistory, historyHours); const listingPrice = calcLowestListingPrice(orderBook, ignoreLowQty); const buyOrderPrice = calcHighestBuyOrderPrice(orderBook); let calculatedPrice = 0; let source = ''; if (algo === 3 && buyOrderPrice > 0) { // 算法3: 最高买单(即时卖出) calculatedPrice = buyOrderPrice; source = '最高买单'; } else if (algo === 4 && historyPrice > 0) { // 算法4: 仅历史均价 calculatedPrice = historyPrice; source = '历史均价'; } else if (algo === 2 && listingPrice > 0) { // 算法2: 最低卖单 calculatedPrice = listingPrice; source = '最低卖单'; } else { // 算法1(默认): max(历史均价, 最低卖单) if (historyPrice > 0 && listingPrice > 0) { calculatedPrice = Math.max(historyPrice, listingPrice); source = historyPrice >= listingPrice ? '历史均价' : '最低卖单'; } else if (listingPrice > 0) { calculatedPrice = listingPrice; source = '最低卖单'; } else if (historyPrice > 0) { calculatedPrice = historyPrice; source = '历史均价'; } } // 无数据时使用最大价格 let changedToMax = false; if (calculatedPrice <= 0) { calculatedPrice = maxCents; changedToMax = true; source = '最大限价'; } // 应用价格偏移 if (!changedToMax && applyOffset && offsetCents !== 0) { calculatedPrice += offsetCents; } // 钳制在 min/max 范围内 calculatedPrice = Math.max(minCents, Math.min(maxCents, calculatedPrice)); // 如果买单价格高于计算价格,使用买单价格(除非算法4) if (algo !== 4 && orderBook && orderBook.highestBuy) { const buyPrice = calcHighestBuyOrderPrice(orderBook); if (buyPrice > calculatedPrice) { calculatedPrice = buyPrice; source = '最高买单(覆盖)'; } } return { priceCents: calculatedPrice, source, historyPrice, listingPrice, buyOrderPrice, }; } /** * 判断单个挂单的价格状态 * @returns {object} { status: 'overpriced'|'underpriced'|'fair'|'unchecked', suggestedCents, source } */ function checkListingPrice(order, priceHistory, orderBook) { if (!orderBook || !orderBook.lowestSell) { return { status: 'unchecked', suggestedCents: 0, source: '' }; } const result = calcSellPrice(priceHistory, orderBook, false); const suggestedWithFees = priceIncludingFees(result.priceCents); const orderPriceCents = parsePriceToCents(order.buyerPrice); let status; if (suggestedWithFees > 0 && orderPriceCents > suggestedWithFees) { status = 'overpriced'; } else if (suggestedWithFees > 0 && orderPriceCents < suggestedWithFees) { status = 'underpriced'; } else { status = 'fair'; } return { status, suggestedCents: result.priceCents, source: result.source }; } // ==================== 上架 API ==================== /** * 上架物品 * POST /market/sellitem/ * @param {object} params - { assetid, appid, contextid, priceCents } */ async function sellItem(params) { const sessionid = getSessionId(); if (!sessionid) throw new Error('无法获取会话ID'); const body = new URLSearchParams({ sessionid, appid: String(params.appid), contextid: String(params.contextid), assetid: String(params.assetid), amount: '1', price: String(params.priceCents), }); const resp = await fetch('https://steamcommunity.com/market/sellitem/', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }, body: body.toString(), }); if (!resp.ok) throw new Error('HTTP ' + resp.status); const data = await resp.json(); if (!data.success) throw new Error(data.message || '上架失败'); return data; } /** * 从挂单信息获取 asset 信息(appid, contextid, assetid) * 从 SSR 数据的 myOrders 中提取 */ function getAssetInfoFromOrder(order) { return { appid: listingInfo?.appid || '', contextid: APP_CONTEXT[listingInfo?.appid] || '2', assetid: order.assetid || '', }; } // ==================== 价格图表 SVG ==================== function buildPriceChartSVG(prices, prefix = '') { if (!prices || prices.length === 0) return ''; const W = 800, H = 280, pL = 52, pR = 16, pT = 16, pB = 32; const cw = W - pL - pR, ch = H - pT - pB; let data = prices; if (data.length > 200) { const step = Math.ceil(data.length / 200); data = data.filter((_, i) => i % step === 0); data.push(prices[prices.length - 1]); } const n = data.length; const minP = Math.min(...data.map(d => d.p)); const maxP = Math.max(...data.map(d => d.p)); const range = maxP - minP || 1; const pts = data.map((d, i) => ({ x: pL + (n === 1 ? cw / 2 : (i / (n - 1)) * cw), y: pT + ch - ((d.p - minP) / range) * ch, })); const linePath = pts.map((pt, i) => (i === 0 ? 'M' : 'L') + pt.x.toFixed(1) + ',' + pt.y.toFixed(1)).join(' '); const areaPath = linePath + ` L ${pts[n - 1].x.toFixed(1)},${(pT + ch).toFixed(1)} L ${pts[0].x.toFixed(1)},${(pT + ch).toFixed(1)} Z`; let gridSVG = ''; for (let i = 0; i <= 4; i++) { const y = pT + (ch / 4) * i; const val = maxP - (range / 4) * i; gridSVG += ``; gridSVG += `${prefix}${val.toFixed(2)}`; } let xAxisSVG = ''; for (let i = 0; i <= 4; i++) { const idx = Math.min(Math.floor((n - 1) * i / 4), n - 1); const pt = pts[idx]; const d = new Date(data[idx].t); const dateStr = `${d.getMonth() + 1}/${d.getDate()}`; xAxisSVG += `${dateStr}`; } const lastPt = pts[n - 1]; const dotSVG = `当前: ${prefix}${data[n-1].p.toFixed(2)}`; return ` ${gridSVG} ${dotSVG} ${xAxisSVG} `; } // ==================== 面板状态 ==================== let panelEl = null; let allSellOrders = []; let currentTab = 'summary'; let priceHistoryData = null; let orderBookData = null; let listingInfo = null; let priceCheckResults = null; // 价格检测结果:Map let priceCheckLoading = false; // ==================== 面板创建 ==================== function createPanel() { if (panelEl) return panelEl; panelEl = h('div', { class: 'sme-panel', id: 'sme-panel' }, [ h('div', { class: 'sme-header' }, [ h('div', { class: 'sme-header-title' }, [ h('span', { html: ICONS.bag }), h('span', { text: '社区市场增强' }), ]), h('div', { class: 'sme-tabs' }, [ h('button', { class: 'sme-tab active', id: 'sme-tab-summary', html: ICONS.list + '上架汇总', onClick: () => switchTab('summary') }), h('button', { class: 'sme-tab', id: 'sme-tab-history', html: ICONS.chart + '价格历史', onClick: () => switchTab('history') }), h('button', { class: 'sme-tab', id: 'sme-tab-orderbook', html: ICONS.layers + '订单深度', onClick: () => switchTab('orderbook') }), h('button', { class: 'sme-tab', id: 'sme-tab-settings', html: ICONS.settings + '设置', onClick: () => switchTab('settings') }), ]), ]), h('div', { class: 'sme-content', id: 'sme-content' }), ]); return panelEl; } function switchTab(tab) { currentTab = tab; panelEl.querySelectorAll('.sme-tab').forEach(b => b.classList.remove('active')); const btn = document.getElementById('sme-tab-' + tab); if (btn) btn.classList.add('active'); const content = document.getElementById('sme-content'); if (!content) return; content.innerHTML = ''; if (tab === 'summary') renderSummary(content); else if (tab === 'history') renderPriceHistory(content); else if (tab === 'orderbook') renderOrderBook(content); else if (tab === 'settings') renderSettings(content); } // ==================== 渲染:上架汇总 ==================== function renderSummary(container) { if (!allSellOrders || allSellOrders.length === 0) { container.appendChild(h('div', { class: 'sme-empty' }, [ h('span', { html: ICONS.package }), h('span', { text: '您没有在售的此物品' }), ])); return; } const grouped = groupOrders(allSellOrders); const total = grouped.reduce((s, g) => s + g.totalCount, 0); const priceSet = new Set(allSellOrders.map(o => o.buyerPrice)); // 计算买家/卖家总价 let totalBuyerCents = 0, totalSellerCents = 0; for (const o of allSellOrders) { const buyerCents = parsePriceToCents(o.buyerPrice); totalBuyerCents += buyerCents; totalSellerCents += priceBeforeFees(buyerCents); } // 统计卡片(含买家/卖家总价) const statGrid = h('div', { class: 'sme-stats-grid' }, [ makeStatCard(total, '在售总数'), makeStatCard(formatCents(totalBuyerCents), '买家总价'), makeStatCard(formatCents(totalSellerCents), '卖家到手'), makeStatCard(priceSet.size, '价格种类'), ]); container.appendChild(statGrid); // 价格检测信息条 + 汇总表格 const tableContainer = h('div'); container.appendChild(tableContainer); if (settings.get('autoCheckPrices') && listingInfo) { // 自动检测价格 renderSummaryWithPriceCheck(tableContainer, grouped, total); } else { renderSummaryTable(tableContainer, grouped, total, null); } // 下架区域 container.appendChild(renderDelistSection(allSellOrders)); // 重新上架区域(仅当有价格检测结果时显示) if (priceCheckResults) { container.appendChild(renderRelistSection(allSellOrders, priceCheckResults)); } } /** * 渲染带价格检测的汇总(异步获取订单簿 + 价格历史后标注) */ function renderSummaryWithPriceCheck(container, grouped, total) { container.appendChild(h('div', { class: 'sme-price-check-info', id: 'sme-price-check-info' }, [ h('span', { html: ICONS.refreshCw }), h('span', { text: '正在检测价格...' }), ])); renderSummaryTable(container, grouped, total, null); priceCheckLoading = true; const tasks = []; // 立即获取价格历史(不依赖 item_nameid) tasks.push( fetchPriceHistory(listingInfo.appid, listingInfo.marketHashName) .then(data => { priceHistoryData = data; }) .catch(err => W('Price check: price history failed:', err.message)) ); // 获取订单簿(SSR 优先,降级到 item_nameid+API) tasks.push( fetchOrderBook() .then(data => { orderBookData = data; }) .catch(err => W('Price check: order book failed:', err.message)) ); Promise.allSettled(tasks).then(() => { priceCheckLoading = false; // 计算每个价格的检测状态 priceCheckResults = new Map(); for (const order of allSellOrders) { if (!priceCheckResults.has(order.buyerPrice)) { const result = checkListingPrice(order, priceHistoryData, orderBookData); priceCheckResults.set(order.buyerPrice, result); } } // 重新渲染表格 const infoBar = document.getElementById('sme-price-check-info'); if (infoBar) { const overpricedCount = [...priceCheckResults.values()].filter(r => r.status === 'overpriced').length; const underpricedCount = [...priceCheckResults.values()].filter(r => r.status === 'underpriced').length; const fairCount = [...priceCheckResults.values()].filter(r => r.status === 'fair').length; const uncheckedCount = [...priceCheckResults.values()].filter(r => r.status === 'unchecked').length; const algoName = ALGORITHM_NAMES[settings.get('priceAlgorithm')] || ''; infoBar.innerHTML = ''; infoBar.appendChild(h('span', { html: ICONS.check })); infoBar.appendChild(h('span', { text: `价格检测完成 · 算法: ` })); infoBar.appendChild(h('span', { class: 'sme-algo-name', text: algoName })); if (overpricedCount > 0) infoBar.appendChild(h('span', { style: { color: 'var(--sme-red)', marginLeft: '8px' }, text: `超价 ${overpricedCount}种` })); if (underpricedCount > 0) infoBar.appendChild(h('span', { style: { color: 'var(--sme-amber)', marginLeft: '8px' }, text: `低价 ${underpricedCount}种` })); if (fairCount > 0) infoBar.appendChild(h('span', { style: { color: 'var(--sme-green)', marginLeft: '8px' }, text: `合理 ${fairCount}种` })); if (uncheckedCount > 0) infoBar.appendChild(h('span', { style: { color: 'var(--sme-text-dim)', marginLeft: '8px' }, text: `未检 ${uncheckedCount}种` })); } // 刷新表格和重新上架区域 container.innerHTML = ''; container.appendChild(infoBar || h('div')); renderSummaryTable(container, grouped, total, priceCheckResults); // 刷新重新上架区域 const oldRelist = document.getElementById('sme-relist-section'); if (oldRelist) oldRelist.remove(); const content = document.getElementById('sme-content'); if (content && priceCheckResults) { content.appendChild(renderRelistSection(allSellOrders, priceCheckResults)); } }); } /** * 渲染汇总表格(可带价格检测结果) */ function renderSummaryTable(container, grouped, total, checkResults) { let rows = ''; for (const g of grouped) { const tags = g.sortedPrices.map(p => { const cnt = g.prices.get(p).length; const check = checkResults?.get(p); let statusClass = ''; let suggestedHtml = ''; if (check) { statusClass = ' ' + check.status; if (check.suggestedCents > 0 && check.status !== 'fair') { const suggestedWithFees = priceIncludingFees(check.suggestedCents); suggestedHtml = `→ ${escapeHtml(formatCents(suggestedWithFees))}`; } } const dot = check ? `` : ''; return `${dot}${escapeHtml(p)}×${cnt}${suggestedHtml}`; }).join(''); rows += `${escapeHtml(g.dateKey)}${tags}${g.totalCount}`; } const tableWrap = h('div', { style: { overflowX: 'auto' } }, []); tableWrap.innerHTML = `${rows}
日期价格分布数量
合计${total}
`; container.appendChild(tableWrap); } function makeStatCard(val, lbl) { return h('div', { class: 'sme-stat-card' }, [ h('div', { class: 'sme-stat-val', text: String(val) }), h('div', { class: 'sme-stat-lbl', text: lbl }), ]); } function renderDelistSection(orders) { const priceMap = new Map(); for (const o of orders) { priceMap.set(o.buyerPrice, (priceMap.get(o.buyerPrice) || 0) + 1); } const options = [...priceMap.entries()] .sort((a, b) => a[0].localeCompare(b[0])) .map(([p, c]) => ``) .join(''); const section = h('div', { class: 'sme-delist-section' }, [ h('div', { class: 'sme-delist-row' }, [ h('span', { class: 'sme-delist-label', text: '下架价格:' }), h('select', { class: 'sme-delist-select', id: 'sme-delist-price', html: `${options}` }), h('span', { class: 'sme-delist-label', text: '数量:' }), h('input', { type: 'number', class: 'sme-delist-input', id: 'sme-delist-qty', value: '1', min: '1', placeholder: '数量' }), h('button', { class: 'sme-delist-btn', id: 'sme-delist-btn', text: '下架', onClick: () => handleDelist(orders, false) }), h('button', { class: 'sme-delist-all-btn', id: 'sme-delist-all-btn', text: '全部下架', onClick: () => handleDelist(orders, true) }), ]), h('div', { class: 'sme-progress-wrap', id: 'sme-delist-progress', style: { display: 'none' } }, [ h('div', { class: 'sme-progress-bar', id: 'sme-delist-progress-bar' }), ]), h('div', { class: 'sme-delist-status', id: 'sme-delist-status' }), ]); return section; } async function handleDelist(orders, delistAll) { const priceSelect = document.getElementById('sme-delist-price'); const qtyInput = document.getElementById('sme-delist-qty'); const statusEl = document.getElementById('sme-delist-status'); const btn = document.getElementById('sme-delist-btn'); const allBtn = document.getElementById('sme-delist-all-btn'); const progressWrap = document.getElementById('sme-delist-progress'); const progressBar = document.getElementById('sme-delist-progress-bar'); let candidates; if (delistAll) { candidates = [...orders].sort((a, b) => b.rtListed - a.rtListed); if (!confirm(`确定要下架全部 ${candidates.length} 件物品吗?`)) return; } else { const price = priceSelect.value; const qty = parseInt(qtyInput.value, 10); if (!price) { statusEl.textContent = '请选择价格'; statusEl.className = 'sme-delist-status sme-error'; return; } if (!qty || qty < 1) { statusEl.textContent = '请输入有效数量'; statusEl.className = 'sme-delist-status sme-error'; return; } candidates = orders.filter(o => o.buyerPrice === price).sort((a, b) => b.rtListed - a.rtListed).slice(0, qty); } if (candidates.length === 0) { statusEl.textContent = '没有可下架的商品'; statusEl.className = 'sme-delist-status sme-error'; return; } const sessionid = getSessionId(); if (!sessionid) { statusEl.textContent = '无法获取会话ID'; statusEl.className = 'sme-delist-status sme-error'; return; } btn.disabled = true; allBtn.disabled = true; progressWrap.style.display = ''; progressBar.style.width = '0%'; let success = 0, failed = 0; for (let i = 0; i < candidates.length; i++) { const item = candidates[i]; statusEl.textContent = `正在下架 ${i + 1}/${candidates.length}...`; statusEl.className = 'sme-delist-status'; progressBar.style.width = Math.round((i / candidates.length) * 100) + '%'; try { const resp = await fetch(`https://steamcommunity.com/market/removelisting/${item.listingid}`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: `sessionid=${encodeURIComponent(sessionid)}`, }); if (resp.ok) { success++; const idx = allSellOrders.indexOf(item); if (idx !== -1) allSellOrders.splice(idx, 1); } else { failed++; W('Delist failed:', item.listingid, 'HTTP', resp.status); } } catch (e) { failed++; W('Delist error:', item.listingid, e); } if (i < candidates.length - 1) await sleep(300); } progressBar.style.width = '100%'; statusEl.textContent = `下架完成: 成功 ${success} 件` + (failed > 0 ? `, 失败 ${failed} 件` : ''); statusEl.className = 'sme-delist-status' + (failed > 0 ? ' sme-error' : ' sme-ok'); btn.disabled = false; allBtn.disabled = false; toast(`下架完成: 成功 ${success} 件` + (failed > 0 ? `, 失败 ${failed} 件` : ''), failed > 0 ? 'warning' : 'success'); setTimeout(() => { progressWrap.style.display = 'none'; }, 2000); // 刷新汇总 switchTab('summary'); } // ==================== 重新上架(超价物品) ==================== /** * 渲染重新上架区域 */ function renderRelistSection(orders, checkResults) { // 找出所有超价物品 const overpricedOrders = orders.filter(o => { const check = checkResults.get(o.buyerPrice); return check && check.status === 'overpriced' && check.suggestedCents > 0; }); if (overpricedOrders.length === 0) return h('div'); // 无超价物品时不显示 // 按价格分组统计 const priceMap = new Map(); for (const o of overpricedOrders) { const check = checkResults.get(o.buyerPrice); if (!priceMap.has(o.buyerPrice)) { priceMap.set(o.buyerPrice, { count: 0, suggestedCents: check.suggestedCents, source: check.source }); } priceMap.get(o.buyerPrice).count++; } const section = h('div', { class: 'sme-relist-section', id: 'sme-relist-section' }, [ h('div', { class: 'sme-relist-row' }, [ h('span', { html: ICONS.zap, style: { color: 'var(--sme-purple)' } }), h('span', { style: { fontWeight: '700', color: 'var(--sme-text-bright)' }, text: '重新上架超价物品' }), h('button', { class: 'sme-relist-btn', id: 'sme-relist-btn', html: ICONS.refreshCw + '重新上架全部', onClick: () => handleRelist(overpricedOrders, checkResults), }), ]), h('div', { class: 'sme-relist-summary', text: `检测到 ${overpricedOrders.length} 件超价物品,将以下架后按建议价格重新上架` }), ]); // 列出超价价格和建议价格 const listEl = h('div', { style: { marginTop: '8px' } }); for (const [price, info] of priceMap) { const suggestedWithFees = priceIncludingFees(info.suggestedCents); listEl.appendChild(h('div', { class: 'sme-relist-item' }, [ h('span', { class: 'sme-relist-from', text: `${price} ×${info.count}` }), h('span', { class: 'sme-relist-arrow', text: '→' }), h('span', { class: 'sme-relist-to', text: formatCents(suggestedWithFees) }), h('span', { style: { color: 'var(--sme-text-dim)', fontSize: '10px' }, text: `(${info.source})` }), ])); } section.appendChild(listEl); // 进度条和状态 section.appendChild(h('div', { class: 'sme-progress-wrap', id: 'sme-relist-progress', style: { display: 'none' } }, [ h('div', { class: 'sme-progress-bar', id: 'sme-relist-progress-bar' }), ])); section.appendChild(h('div', { class: 'sme-delist-status', id: 'sme-relist-status' })); return section; } /** * 处理重新上架:下架 → 等待 → 重新上架 * 参考 Steam Economy Enhancer 的 marketOverpricedQueueWorker 三步流程 */ async function handleRelist(overpricedOrders, checkResults) { const btn = document.getElementById('sme-relist-btn'); const statusEl = document.getElementById('sme-relist-status'); const progressWrap = document.getElementById('sme-relist-progress'); const progressBar = document.getElementById('sme-relist-progress-bar'); const sessionid = getSessionId(); if (!sessionid) { statusEl.textContent = '无法获取会话ID'; statusEl.className = 'sme-delist-status sme-error'; return; } if (!confirm(`确定要重新上架 ${overpricedOrders.length} 件超价物品吗?\n这将先下架再按建议价格重新上架。`)) return; btn.disabled = true; progressWrap.style.display = ''; progressBar.style.width = '0%'; let success = 0, failed = 0; const total = overpricedOrders.length; for (let i = 0; i < overpricedOrders.length; i++) { const order = overpricedOrders[i]; const check = checkResults.get(order.buyerPrice); const suggestedCents = check.suggestedCents; statusEl.textContent = `重新上架 ${i + 1}/${total}: 下架中...`; statusEl.className = 'sme-delist-status'; progressBar.style.width = Math.round((i / total) * 100) + '%'; try { // 步骤1: 下架 const removeResp = await fetch(`https://steamcommunity.com/market/removelisting/${order.listingid}`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: `sessionid=${encodeURIComponent(sessionid)}`, }); if (!removeResp.ok) { failed++; W('Relist: remove failed:', order.listingid, 'HTTP', removeResp.status); continue; } // 从内存列表中移除 const idx = allSellOrders.indexOf(order); if (idx !== -1) allSellOrders.splice(idx, 1); // 步骤2: 等待物品返回库存(1.5-2.5秒) statusEl.textContent = `重新上架 ${i + 1}/${total}: 等待物品返回库存...`; await sleep(1500 + Math.random() * 1000); // 步骤3: 以建议价格重新上架 statusEl.textContent = `重新上架 ${i + 1}/${total}: 上架中 (${formatCents(priceIncludingFees(suggestedCents))})...`; const assetInfo = getAssetInfoFromOrder(order); if (!assetInfo.assetid) { W('Relist: no assetid for order', order.listingid); failed++; continue; } try { await sellItem({ assetid: assetInfo.assetid, appid: assetInfo.appid, contextid: assetInfo.contextid, priceCents: suggestedCents, }); success++; } catch (sellErr) { W('Relist: sell failed:', sellErr.message); failed++; } } catch (err) { W('Relist error:', order.listingid, err); failed++; } // 请求间隔 if (i < overpricedOrders.length - 1) await sleep(800); } progressBar.style.width = '100%'; statusEl.textContent = `重新上架完成: 成功 ${success} 件` + (failed > 0 ? `, 失败 ${failed} 件` : ''); statusEl.className = 'sme-delist-status' + (failed > 0 ? ' sme-error' : ' sme-ok'); toast(`重新上架完成: 成功 ${success} 件` + (failed > 0 ? `, 失败 ${failed} 件` : ''), failed > 0 ? 'warning' : 'success'); setTimeout(() => { progressWrap.style.display = 'none'; }, 3000); // 刷新汇总 priceCheckResults = null; setTimeout(() => switchTab('summary'), 500); } // ==================== 渲染:价格历史 ==================== function renderPriceHistory(container) { if (!listingInfo) { container.appendChild(makeEmpty('无法获取物品信息')); return; } container.appendChild(makeLoading('正在加载价格历史...')); const range = settings.get('historyRange'); fetchPriceHistory(listingInfo.appid, listingInfo.marketHashName) .then(data => { priceHistoryData = data; container.innerHTML = ''; renderPriceHistoryContent(container, data, range); }) .catch(err => { container.innerHTML = ''; container.appendChild(makeError('价格历史加载失败: ' + err.message)); }); } function renderPriceHistoryContent(container, data, range) { const now = Date.now(); const rangeMs = { '7d': 7 * 864e5, '30d': 30 * 864e5, '90d': 90 * 864e5, all: Infinity }; const cutoff = now - (rangeMs[range] || rangeMs['30d']); let prices = data.prices.filter(p => p.t >= cutoff); if (prices.length === 0) { container.appendChild(makeEmpty('所选时间范围内无价格数据')); return; } // 时间范围选择器 const rangeBar = h('div', { class: 'sme-range-bar' }); for (const [label, val] of [['7天', '7d'], ['30天', '30d'], ['90天', '90d'], ['全部', 'all']]) { rangeBar.appendChild(h('button', { class: 'sme-range-btn' + (val === range ? ' active' : ''), text: label, onClick: () => { settings.set('historyRange', val); container.innerHTML = ''; renderPriceHistoryContent(container, data, val); }, })); } container.appendChild(rangeBar); // 统计卡片 const pricesOnly = prices.map(p => p.p); const current = pricesOnly[pricesOnly.length - 1]; const min = Math.min(...pricesOnly); const max = Math.max(...pricesOnly); const avg = pricesOnly.reduce((s, v) => s + v, 0) / pricesOnly.length; const totalVol = prices.reduce((s, p) => s + p.v, 0); const prefix = data.prefix || ''; const trendIcon = current >= avg ? ICONS.trendingUp : ICONS.trendingDown; const trendColor = current >= avg ? 'var(--sme-green)' : 'var(--sme-red)'; container.appendChild(h('div', { class: 'sme-stats-grid' }, [ makeStatCard(prefix + current.toFixed(2), '当前价格'), makeStatCard(prefix + min.toFixed(2), '最低价格'), makeStatCard(prefix + max.toFixed(2), '最高价格'), makeStatCard(prefix + avg.toFixed(2), '平均价格'), makeStatCard(totalVol.toLocaleString(), '总成交量'), ])); // 图表 const chartWrap = h('div', { class: 'sme-chart-wrap' }); chartWrap.innerHTML = buildPriceChartSVG(prices, prefix); container.appendChild(chartWrap); } // ==================== 渲染:订单深度 ==================== function renderOrderBook(container) { container.appendChild(makeLoading('正在获取订单深度...')); fetchOrderBook() .then(data => { orderBookData = data; container.innerHTML = ''; renderOrderBookContent(container, data); }) .catch(err => { container.innerHTML = ''; container.appendChild(makeError('订单深度加载失败: ' + err.message)); }); } function renderOrderBookContent(container, data) { const prefix = data.prefix || ''; const suffix = data.suffix || ''; // fmtPrice: 将分(cents)转换为货币单位显示 // data.lowestSell/highestBuy 是分字符串(如 "207000"),需 / 100 转为货币单位 const fmtPrice = (val) => { const n = parseFloat(val); if (isNaN(n)) return val; return prefix + (n / 100).toFixed(2) + suffix; }; // 价差统计(lowestSell/highestBuy 均为分) const highestBuy = parseInt(data.highestBuy, 10) || 0; const lowestSell = parseInt(data.lowestSell, 10) || 0; const spread = lowestSell - highestBuy; const spreadPct = lowestSell > 0 ? (spread / lowestSell * 100).toFixed(2) : '0.00'; container.appendChild(h('div', { class: 'sme-stats-grid' }, [ makeStatCard(fmtPrice(data.lowestSell), '最低卖单'), makeStatCard(fmtPrice(data.highestBuy), '最高买单'), makeStatCard(fmtPrice(spread), '价差'), makeStatCard(spreadPct + '%', '价差比'), ])); // 卖单列表 const sellOrders = (data.sellOrders || []).slice(0, 15); const buyOrders = (data.buyOrders || []).slice(0, 15); const maxQty = Math.max(...sellOrders.map(o => o.qty), ...buyOrders.map(o => o.qty), 1); if (sellOrders.length > 0) { const sellSection = h('div', { class: 'sme-order-section' }, [ h('div', { class: 'sme-order-title sell', html: ICONS.trendingDown + '卖单 (Sell Orders)' }), ]); for (const o of sellOrders) { const widthPct = (o.qty / maxQty * 100).toFixed(1); sellSection.appendChild(h('div', { class: 'sme-order-row sell' }, [ h('div', { class: 'sme-order-bar', style: { width: widthPct + '%' } }), h('span', { class: 'sme-order-price', text: o.price }), h('span', { class: 'sme-order-qty', text: o.qty.toLocaleString() }), ])); } container.appendChild(sellSection); } // 价差指示器 if (spread > 0) { container.appendChild(h('div', { class: 'sme-spread-box' }, [ h('span', { class: 'sme-spread-lbl', text: '价差' }), h('span', { class: 'sme-spread-val', text: fmtPrice(spread) + ' (' + spreadPct + '%)' }), ])); } // 买单列表 if (buyOrders.length > 0) { const buySection = h('div', { class: 'sme-order-section' }, [ h('div', { class: 'sme-order-title buy', html: ICONS.trendingUp + '买单 (Buy Orders)' }), ]); for (const o of buyOrders) { const widthPct = (o.qty / maxQty * 100).toFixed(1); buySection.appendChild(h('div', { class: 'sme-order-row buy' }, [ h('div', { class: 'sme-order-bar', style: { width: widthPct + '%' } }), h('span', { class: 'sme-order-price', text: o.price }), h('span', { class: 'sme-order-qty', text: o.qty.toLocaleString() }), ])); } container.appendChild(buySection); } if (sellOrders.length === 0 && buyOrders.length === 0) { container.appendChild(makeEmpty('暂无订单数据')); } } // ==================== 渲染:设置 ==================== function renderSettings(container) { // --- 基础设置 --- container.appendChild(h('div', { class: 'sme-setting-group-title', text: '基础设置' })); container.appendChild(h('div', { class: 'sme-setting-row' }, [ h('div', { class: 'sme-setting-info' }, [ h('div', { class: 'sme-setting-name', text: '已拥有物品标识' }), h('div', { class: 'sme-setting-desc', text: '在市场浏览页和详情页显示库存中已拥有的物品数量' }), ]), makeToggle('ownedBadges', settings.get('ownedBadges'), val => settings.set('ownedBadges', val)), ])); container.appendChild(h('div', { class: 'sme-setting-row' }, [ h('div', { class: 'sme-setting-info' }, [ h('div', { class: 'sme-setting-name', text: '自动加载价格历史' }), h('div', { class: 'sme-setting-desc', text: '进入物品详情页时自动加载价格趋势图' }), ]), makeToggle('autoLoadHistory', settings.get('autoLoadHistory'), val => settings.set('autoLoadHistory', val)), ])); container.appendChild(h('div', { class: 'sme-setting-row' }, [ h('div', { class: 'sme-setting-info' }, [ h('div', { class: 'sme-setting-name', text: '默认价格历史范围' }), h('div', { class: 'sme-setting-desc', text: '价格趋势图默认显示的时间范围' }), ]), h('select', { class: 'sme-setting-select', html: [['7天', '7d'], ['30天', '30d'], ['90天', '90d'], ['全部', 'all']] .map(([l, v]) => ``).join(''), onChange: e => settings.set('historyRange', e.target.value), }), ])); // --- 价格检测设置 --- container.appendChild(h('div', { class: 'sme-setting-group-title', html: ICONS.dollarSign + '价格检测设置' })); container.appendChild(h('div', { class: 'sme-setting-row' }, [ h('div', { class: 'sme-setting-info' }, [ h('div', { class: 'sme-setting-name', text: '自动价格检测' }), h('div', { class: 'sme-setting-desc', text: '进入上架汇总页时自动检测超价/低价' }), ]), makeToggle('autoCheckPrices', settings.get('autoCheckPrices'), val => settings.set('autoCheckPrices', val)), ])); container.appendChild(h('div', { class: 'sme-setting-row' }, [ h('div', { class: 'sme-setting-info' }, [ h('div', { class: 'sme-setting-name', text: '定价算法' }), h('div', { class: 'sme-setting-desc', text: '计算建议价格时使用的数据源策略' }), ]), h('select', { class: 'sme-setting-select', html: Object.entries(ALGORITHM_NAMES) .map(([v, l]) => ``).join(''), onChange: e => settings.set('priceAlgorithm', parseInt(e.target.value, 10)), }), ])); container.appendChild(h('div', { class: 'sme-setting-row' }, [ h('div', { class: 'sme-setting-info' }, [ h('div', { class: 'sme-setting-name', text: '价格偏移' }), h('div', { class: 'sme-setting-desc', text: '在计算价格上加减偏移量(可为负,如 -0.01 表示低于建议价1分上架)' }), ]), h('div', { class: 'sme-setting-inline' }, [ h('input', { type: 'number', class: 'sme-number-input', id: 'sme-setting-offset', value: String(settings.get('priceOffset')), step: '0.01', onChange: e => settings.set('priceOffset', parseFloat(e.target.value) || 0), }), h('span', { style: { color: 'var(--sme-text-dim)', fontSize: '12px' }, text: '元' }), ]), ])); container.appendChild(h('div', { class: 'sme-setting-row' }, [ h('div', { class: 'sme-setting-info' }, [ h('div', { class: 'sme-setting-name', text: '历史均价时间窗口' }), h('div', { class: 'sme-setting-desc', text: '计算历史加权均价时回溯的小时数' }), ]), h('div', { class: 'sme-setting-inline' }, [ h('input', { type: 'number', class: 'sme-number-input', id: 'sme-setting-hours', value: String(settings.get('historyHours')), step: '1', min: '1', onChange: e => settings.set('historyHours', parseInt(e.target.value, 10) || 12), }), h('span', { style: { color: 'var(--sme-text-dim)', fontSize: '12px' }, text: '小时' }), ]), ])); container.appendChild(h('div', { class: 'sme-setting-row' }, [ h('div', { class: 'sme-setting-info' }, [ h('div', { class: 'sme-setting-name', text: '智能过滤异常低价挂单' }), h('div', { class: 'sme-setting-desc', text: '最低价挂单数量极少时使用第二低价作为参考' }), ]), makeToggle('ignoreLowQuantity', settings.get('ignoreLowQuantity'), val => settings.set('ignoreLowQuantity', val)), ])); container.appendChild(h('div', { class: 'sme-setting-row' }, [ h('div', { class: 'sme-setting-info' }, [ h('div', { class: 'sme-setting-name', text: '价格范围限制' }), h('div', { class: 'sme-setting-desc', text: '建议价格将被钳制在此范围内' }), ]), h('div', { class: 'sme-setting-inline' }, [ h('input', { type: 'number', class: 'sme-number-input', id: 'sme-setting-min', value: String(settings.get('minPrice')), step: '0.01', min: '0.01', style: { width: '60px' }, onChange: e => settings.set('minPrice', parseFloat(e.target.value) || 0.03), }), h('span', { style: { color: 'var(--sme-text-dim)', fontSize: '12px' }, text: '~' }), h('input', { type: 'number', class: 'sme-number-input', id: 'sme-setting-max', value: String(settings.get('maxPrice')), step: '1', style: { width: '60px' }, onChange: e => settings.set('maxPrice', parseFloat(e.target.value) || 999), }), h('span', { style: { color: 'var(--sme-text-dim)', fontSize: '12px' }, text: '元' }), ]), ])); // --- 物品类型差异化定价 --- container.appendChild(h('div', { class: 'sme-setting-group-title', html: ICONS.layers + '物品类型差异化定价' })); container.appendChild(h('div', { class: 'sme-setting-row' }, [ h('div', { class: 'sme-setting-info' }, [ h('div', { class: 'sme-setting-name', text: '普通卡牌价格范围' }), h('div', { class: 'sme-setting-desc', text: '普通交易卡牌的最低/最高上架价格' }), ]), h('div', { class: 'sme-setting-inline' }, [ h('input', { type: 'number', class: 'sme-number-input', value: String(settings.get('minNormalCardPrice')), step: '0.01', style: { width: '60px' }, onChange: e => settings.set('minNormalCardPrice', parseFloat(e.target.value) || 0.05) }), h('span', { style: { color: 'var(--sme-text-dim)', fontSize: '12px' }, text: '~' }), h('input', { type: 'number', class: 'sme-number-input', value: String(settings.get('maxNormalCardPrice')), step: '0.5', style: { width: '60px' }, onChange: e => settings.set('maxNormalCardPrice', parseFloat(e.target.value) || 2.5) }), h('span', { style: { color: 'var(--sme-text-dim)', fontSize: '12px' }, text: '元' }), ]), ])); container.appendChild(h('div', { class: 'sme-setting-row' }, [ h('div', { class: 'sme-setting-info' }, [ h('div', { class: 'sme-setting-name', text: '闪箔卡牌价格范围' }), h('div', { class: 'sme-setting-desc', text: '闪亮(Foil)交易卡牌的最低/最高上架价格' }), ]), h('div', { class: 'sme-setting-inline' }, [ h('input', { type: 'number', class: 'sme-number-input', value: String(settings.get('minFoilCardPrice')), step: '0.01', style: { width: '60px' }, onChange: e => settings.set('minFoilCardPrice', parseFloat(e.target.value) || 0.15) }), h('span', { style: { color: 'var(--sme-text-dim)', fontSize: '12px' }, text: '~' }), h('input', { type: 'number', class: 'sme-number-input', value: String(settings.get('maxFoilCardPrice')), step: '1', style: { width: '60px' }, onChange: e => settings.set('maxFoilCardPrice', parseFloat(e.target.value) || 10) }), h('span', { style: { color: 'var(--sme-text-dim)', fontSize: '12px' }, text: '元' }), ]), ])); container.appendChild(h('div', { class: 'sme-setting-row' }, [ h('div', { class: 'sme-setting-info' }, [ h('div', { class: 'sme-setting-name', text: '杂项物品价格范围' }), h('div', { class: 'sme-setting-desc', text: '非卡牌类物品的最低/最高上架价格' }), ]), h('div', { class: 'sme-setting-inline' }, [ h('input', { type: 'number', class: 'sme-number-input', value: String(settings.get('minMiscPrice')), step: '0.01', style: { width: '60px' }, onChange: e => settings.set('minMiscPrice', parseFloat(e.target.value) || 0.05) }), h('span', { style: { color: 'var(--sme-text-dim)', fontSize: '12px' }, text: '~' }), h('input', { type: 'number', class: 'sme-number-input', value: String(settings.get('maxMiscPrice')), step: '1', style: { width: '60px' }, onChange: e => settings.set('maxMiscPrice', parseFloat(e.target.value) || 10) }), h('span', { style: { color: 'var(--sme-text-dim)', fontSize: '12px' }, text: '元' }), ]), ])); // --- 库存页设置 --- container.appendChild(h('div', { class: 'sme-setting-group-title', html: ICONS.tag + '库存页设置' })); container.appendChild(h('div', { class: 'sme-setting-row' }, [ h('div', { class: 'sme-setting-info' }, [ h('div', { class: 'sme-setting-name', text: '库存价格标签' }), h('div', { class: 'sme-setting-desc', text: '在库存页物品上显示市场参考价格' }), ]), makeToggle('inventoryPriceLabels', settings.get('inventoryPriceLabels'), val => settings.set('inventoryPriceLabels', val)), ])); container.appendChild(h('div', { class: 'sme-setting-row' }, [ h('div', { class: 'sme-setting-info' }, [ h('div', { class: 'sme-setting-name', text: '快速出售按钮' }), h('div', { class: 'sme-setting-desc', text: '选中物品时显示快速出售面板(最高买单/最低卖单等)' }), ]), makeToggle('quickSellButtons', settings.get('quickSellButtons'), val => settings.set('quickSellButtons', val)), ])); // --- 市场挂单页设置 --- container.appendChild(h('div', { class: 'sme-setting-group-title', html: ICONS.list + '市场挂单页设置' })); container.appendChild(h('div', { class: 'sme-setting-row' }, [ h('div', { class: 'sme-setting-info' }, [ h('div', { class: 'sme-setting-name', text: '批量操作按钮' }), h('div', { class: 'sme-setting-desc', text: '在"我的挂单"页显示批量选择/下架/重新上架按钮' }), ]), makeToggle('marketBatchButtons', settings.get('marketBatchButtons'), val => settings.set('marketBatchButtons', val)), ])); container.appendChild(h('div', { class: 'sme-setting-row' }, [ h('div', { class: 'sme-setting-info' }, [ h('div', { class: 'sme-setting-name', text: '排序功能' }), h('div', { class: 'sme-setting-desc', text: '点击表头按名称/价格/日期排序' }), ]), makeToggle('marketSorting', settings.get('marketSorting'), val => settings.set('marketSorting', val)), ])); container.appendChild(h('div', { class: 'sme-setting-row' }, [ h('div', { class: 'sme-setting-info' }, [ h('div', { class: 'sme-setting-name', text: '搜索过滤' }), h('div', { class: 'sme-setting-desc', text: '在挂单列表中添加搜索框实时过滤' }), ]), makeToggle('marketSearch', settings.get('marketSearch'), val => settings.set('marketSearch', val)), ])); // --- 交易报价设置 --- container.appendChild(h('div', { class: 'sme-setting-group-title', html: ICONS.exchange + '交易报价设置' })); container.appendChild(h('div', { class: 'sme-setting-row' }, [ h('div', { class: 'sme-setting-info' }, [ h('div', { class: 'sme-setting-name', text: '交易报价价值摘要' }), h('div', { class: 'sme-setting-desc', text: '显示双方物品清单、总价值和差额对比' }), ]), makeToggle('tradeOfferSummary', settings.get('tradeOfferSummary'), val => settings.set('tradeOfferSummary', val)), ])); // --- 数据管理 --- container.appendChild(h('div', { class: 'sme-setting-group-title', text: '数据管理' })); container.appendChild(h('div', { style: { marginTop: '12px' } }, [ h('button', { class: 'sme-danger-btn', text: '清除缓存', onClick: () => { cache.clear(); Object.keys(invCache).forEach(k => delete invCache[k]); Object.keys(jsonFallbackDone).forEach(k => delete jsonFallbackDone[k]); const sid = getSteamId(); if (sid) { clearInvCache(sid); try { sessionStorage.removeItem(SESSION_KEY); } catch (e) {} } toast('缓存已清除', 'success'); }}), ])); container.appendChild(h('div', { class: 'sme-info-box' }, [ h('div', { text: `Steam 社区市场增强 v${VERSION}` }), h('div', { text: '功能:上架汇总分组 · 超价/低价检测 · 一键重新上架 · 批量下架 · 价格历史趋势 · 订单深度 · 已拥有标识 · 我的挂单排序搜索批量操作 · 库存页价格标签与快速出售 · 交易报价价值摘要 · 物品类型差异化定价 · 货币特定取整(2025年12月Steam变更)' }), h('div', { text: '参考:Steam Market Listings Group + Steam Economy Enhancer + steam-friend-manager 设计语言' }), ])); } function makeToggle(key, checked, onChange) { const input = h('input', { type: 'checkbox' }); if (checked) input.setAttribute('checked', ''); input.addEventListener('change', () => onChange(input.checked)); return h('label', { class: 'sme-toggle' }, [ input, h('span', { class: 'sme-toggle-slider' }), ]); } // ==================== 辅助:加载/空/错误状态 ==================== function makeLoading(msg) { return h('div', { class: 'sme-loading' }, [ h('div', { class: 'sme-spinner' }), h('div', { class: 'sme-loading-text', text: msg || '加载中...' }), ]); } function makeEmpty(msg) { return h('div', { class: 'sme-empty' }, [ h('span', { html: ICONS.package }), h('span', { text: msg || '暂无数据' }), ]); } function makeError(msg) { return h('div', { class: 'sme-error-box' }, [ h('span', { html: ICONS.alert }), h('span', { text: msg || '加载失败' }), ]); } // ==================== 面板注入(详情页) ==================== function findListingContainer() { const priceEls = []; const walker = document.createTreeWalker(document.body, 4, null); let count = 0; while (walker.nextNode() && count < 5) { if (/¥\s*\d/.test(walker.currentNode.nodeValue)) { priceEls.push(walker.currentNode.parentElement); count++; } } if (priceEls.length === 0) return null; let common = priceEls[0].parentElement; while (common && common !== document.body) { if (priceEls.every(el => common.contains(el)) && common.children.length > 2) return common; common = common.parentElement; } return priceEls[0].parentElement?.parentElement?.parentElement || null; } function injectPanel() { if (document.getElementById('sme-panel')) return; const panel = createPanel(); const listingContainer = findListingContainer(); if (listingContainer && listingContainer.parentNode) { listingContainer.parentNode.insertBefore(panel, listingContainer); } else { const target = document.querySelector('#mainContents') || document.querySelector('[class*="pagecontent"]') || document.body; target.insertBefore(panel, target.firstChild); } switchTab('summary'); L('Panel injected on listing page'); } // ==================== 详情页:已拥有物品标识 ==================== /** 在详情页物品名称前显示已拥有数量徽章 */ function processDetailPageBadge() { if (!settings.get('ownedBadges')) return; const info = getListingInfo(); if (!info) return; // 尝试多个可能的物品名称位置 const nameEl = document.querySelector('#largeiteminfo .market_listing_nav .market_listing_item_name') || document.querySelector('.market_listing_nav .market_listing_item_name') || document.querySelector('.market_listing_item_name:first-of-type'); if (!nameEl || nameEl.querySelector('.sme-owned-badge')) return; getOwnedCount(info.appid, info.marketHashName).then(count => { if (count > 0 && nameEl.parentNode && !nameEl.querySelector('.sme-owned-badge')) { nameEl.parentNode.insertBefore( h('span', { class: 'sme-owned-badge', title: `您库存中有 ${count} 件此物品`, text: '×' + count }), nameEl ); } }).catch(() => {}); } // ==================== 浏览页:已拥有物品标识 ==================== const browseSeen = new WeakSet(); let browseObserver = null; function processBrowseRow(row) { if (browseSeen.has(row)) return; browseSeen.add(row); if (row.querySelector('.sme-owned-badge')) return; const href = row.href || row.getAttribute('href'); if (!href) return; const m = href.match(/\/market\/listings\/(\d+)\/(.+?)(?:\?.*)?$/); if (!m) return; let appid, hashName; try { appid = m[1]; hashName = decodeURIComponent(m[2]); } catch (e) { return; } const nameBlock = row.querySelector('.market_listing_item_name_block'); if (!nameBlock) return; const nameSpan = nameBlock.querySelector('.market_listing_item_name'); if (!nameSpan) return; getOwnedCount(appid, hashName).then(count => { if (count > 0) { nameBlock.insertBefore( h('span', { class: 'sme-owned-badge', title: `您库存中有 ${count} 件此物品`, text: '×' + count }), nameSpan ); } }).catch(() => {}); } function processAllBrowseRows() { const rows = document.querySelectorAll('a.market_listing_row_link'); rows.forEach(r => processBrowseRow(r)); } function setupBrowseObserver() { if (browseObserver) browseObserver.disconnect(); const target = document.getElementById('searchResults') || document.body; browseObserver = new MutationObserver(mutations => { const rows = []; mutations.forEach(mut => { if (mut.type !== 'childList') return; mut.addedNodes.forEach(node => { if (node.nodeType !== 1) return; if (node.classList && node.classList.contains('market_listing_row_link')) rows.push(node); if (node.querySelectorAll) { node.querySelectorAll('a.market_listing_row_link').forEach(r => rows.push(r)); } }); }); if (rows.length > 0) rows.forEach(r => processBrowseRow(r)); }); browseObserver.observe(target, { childList: true, subtree: true }); } // ==================== 浏览页:浮动设置按钮 ==================== let fabEl = null, fabPopup = null; function createFab() { if (fabEl) return; fabEl = h('button', { class: 'sme-fab', title: '市场增强设置', html: ICONS.settings, onClick: toggleFabPopup }); document.body.appendChild(fabEl); fabPopup = h('div', { class: 'sme-fab-popup' }, [ h('h4', { html: ICONS.settings + '市场增强设置' }), h('div', { class: 'sme-setting-row' }, [ h('div', { class: 'sme-setting-info' }, [ h('div', { class: 'sme-setting-name', text: '已拥有物品标识' }), h('div', { class: 'sme-setting-desc', text: '浏览页和详情页显示库存中已拥有的物品数量' }), ]), makeToggle('ownedBadges', settings.get('ownedBadges'), val => { settings.set('ownedBadges', val); if (val) { processAllBrowseRows(); processDetailPageBadge(); } else { document.querySelectorAll('.sme-owned-badge').forEach(el => el.remove()); } }), ]), h('div', { class: 'sme-setting-row' }, [ h('div', { class: 'sme-setting-info' }, [ h('div', { class: 'sme-setting-name', text: '自动加载价格历史' }), h('div', { class: 'sme-setting-desc', text: '详情页自动加载价格趋势图' }), ]), makeToggle('autoLoadHistory', settings.get('autoLoadHistory'), val => settings.set('autoLoadHistory', val)), ]), h('div', { style: { marginTop: '12px', fontSize: '11px', color: 'var(--sme-text-dim)', textAlign: 'center' }, text: `Steam 社区市场增强 v${VERSION}` }), ]); document.body.appendChild(fabPopup); document.addEventListener('mousedown', e => { if (fabPopup.classList.contains('sme-show') && !fabPopup.contains(e.target) && !fabEl.contains(e.target)) { fabPopup.classList.remove('sme-show'); } }, true); } function toggleFabPopup() { fabPopup.classList.toggle('sme-show'); } // ==================== 我的挂单增强(批量选择 / 排序 / 搜索) ==================== // 为市场主页"我的挂单"区域提供:批量选择与下架、超价检测与重新上架、列排序、名称搜索。 // 依赖现有工具函数:h, settings, toast, getSessionId, parsePriceToCents, priceBeforeFees, // priceIncludingFees, calcSellPrice, fetchPriceHistory, fetchOrderHistogram, sellItem, // isRetryMessage, randInt, sleep, ICONS, GM_addStyle, unsafeWindow, APP_CONTEXT, // INV_REQUEST_DELAY, RETRY_BACKOFF_MIN, RETRY_BACKOFF_MAX, L, W。 let myListingsModuleActive = false; // 模块是否已激活(防止重复初始化) let myListingsStyleAdded = false; // CSS 是否已注入 let myListingsObserver = null; // 挂单区域变动观察器 let myListingsToolbarEl = null; // 工具栏元素 let myListingsSortKey = null; // 当前排序键: 'name' | 'date' | 'price' | null let myListingsSortAsc = true; // 升序/降序 let myListingsSearchQ = ''; // 搜索关键词(小写) let myListingsBusy = false; // 是否有批量操作进行中 const myListingsRowData = new WeakMap(); // 行元素 → 解析数据 const myListingsPriceCheck = new Map(); // listingid → { overpriced, suggestedCents, source, buyerCents, unknown? } const myListItemNameIdCache = new Map(); // "appid|hash" → item_nameid const MY_LISTINGS_CSS = ` .sme-ml-toolbar{ display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:8px 10px;margin:6px 0; background:linear-gradient(135deg,rgba(102,192,244,0.06) 0%,rgba(23,26,33,0.6) 100%); border:1px solid var(--sme-border);border-radius:var(--sme-radius-sm); font-family:"Motiva Sans",system-ui,-apple-system,sans-serif; } .sme-ml-toolbar .sme-market-btn:disabled{opacity:0.4;cursor:not-allowed} .sme-ml-sep{width:1px;height:18px;background:var(--sme-border);margin:0 2px;display:inline-block} .sme-ml-count{font-size:12px;color:var(--sme-text-dim);white-space:nowrap;margin-left:auto} .sme-ml-count b{color:var(--sme-blue)} .sme-ml-status{width:100%;font-size:12px;color:var(--sme-text-dim);margin-top:6px;min-height:14px} .sme-ml-status.sme-err{color:var(--sme-red)} .sme-ml-status.sme-ok{color:var(--sme-green)} .sme-ml-progress{width:100%;height:3px;background:rgba(255,255,255,0.06);border-radius:2px;margin-top:6px;overflow:hidden;display:none} .sme-ml-progress-bar{height:100%;width:0;background:linear-gradient(90deg,var(--sme-blue),var(--sme-green));border-radius:2px;transition:width .3s ease} .sme-ml-check{margin:0 6px 0 2px;cursor:pointer;accent-color:var(--sme-blue);vertical-align:middle} .market_listing_row.sme-ml-overpriced{box-shadow:inset 3px 0 0 var(--sme-red)} .market_listing_row.sme-ml-overpriced .market_listing_my_price{background:rgba(226,74,74,0.08)} .sme-ml-sortable{cursor:pointer;user-select:none} .sme-ml-sortable:hover{color:var(--sme-blue)} .sme-ml-sortarrow{font-size:10px;color:var(--sme-blue);margin-left:3px;opacity:0} .sme-ml-sortable.sme-active .sme-ml-sortarrow{opacity:1} `; /** 入口:在市场主页"我的挂单"区域启用增强功能 */ function initMyListingsEnhancement() { if (myListingsModuleActive) return; const enableBatch = settings.get('marketBatchButtons'); const enableSort = settings.get('marketSorting'); const enableSearch = settings.get('marketSearch'); if (!enableBatch && !enableSort && !enableSearch) return; if (!myListingsStyleAdded) { GM_addStyle(MY_LISTINGS_CSS); myListingsStyleAdded = true; } // "我的挂单"区域为 AJAX 异步加载,轮询等待其出现 let attempts = 0; const tryStart = () => { const container = document.getElementById('my_market_selllistings'); if (container && container.querySelector('.market_listing_row')) { startMyListingsModule(container); } else if (attempts++ < 30) { setTimeout(tryStart, 500); } }; tryStart(); } function startMyListingsModule(container) { if (myListingsModuleActive) return; myListingsModuleActive = true; L('My Listings enhancement module active'); injectMyListingsToolbar(container); setupMyListingsSortHeaders(container); processMyListingsRows(container); setupMyListingsObserver(container); } /** 清理模块状态(URL 变化时调用) */ function cleanupMyListingsEnhancement() { if (myListingsObserver) { myListingsObserver.disconnect(); myListingsObserver = null; } if (myListingsToolbarEl) { myListingsToolbarEl.remove(); myListingsToolbarEl = null; } myListingsModuleActive = false; myListingsSortKey = null; myListingsSortAsc = true; myListingsSearchQ = ''; myListingsBusy = false; myListingsPriceCheck.clear(); myListItemNameIdCache.clear(); } // ---------- 工具栏 ---------- function injectMyListingsToolbar(container) { if (myListingsToolbarEl && document.body.contains(myListingsToolbarEl)) { updateMyListingsCount(); return; } const enableBatch = settings.get('marketBatchButtons'); const enableSearch = settings.get('marketSearch'); const controls = []; if (enableBatch) { controls.push( h('button', { class: 'sme-market-btn', html: ICONS.selectAll + '全选', onClick: onSelectAllMyListings }), h('button', { class: 'sme-market-btn', text: '选5个', onClick: () => selectNMyListings(5) }), h('button', { class: 'sme-market-btn', text: '选25个', onClick: () => selectNMyListings(25) }), h('button', { class: 'sme-market-btn', html: ICONS.alert + '选超价', onClick: onSelectOverpricedMyListings }), h('span', { class: 'sme-ml-sep' }), h('button', { class: 'sme-market-btn sme-danger', html: ICONS.trash + '批量下架', onClick: onRemoveSelectedMyListings }), h('button', { class: 'sme-market-btn', html: ICONS.refreshCw + '超价重挂', onClick: onRelistOverpricedMyListings }), ); } if (enableSearch) { controls.push( h('span', { html: ICONS.search, style: { color: 'var(--sme-text-dim)', display: 'inline-flex', alignItems: 'center' } }), h('input', { class: 'sme-market-search', type: 'text', placeholder: '搜索物品名称…', value: myListingsSearchQ, oninput: (e) => { myListingsSearchQ = e.target.value.trim().toLowerCase(); const c = document.getElementById('my_market_selllistings'); if (c) applyMyListingsSortAndFilter(c); }, }), ); } controls.push(h('span', { class: 'sme-ml-count', id: 'sme-ml-count' })); myListingsToolbarEl = h('div', { class: 'sme-ml-toolbar' }, [ h('div', { style: { display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap', width: '100%' } }, controls), h('div', { class: 'sme-ml-status', id: 'sme-ml-status' }), h('div', { class: 'sme-ml-progress', id: 'sme-ml-progress' }, [ h('div', { class: 'sme-ml-progress-bar', id: 'sme-ml-progress-bar' }), ]), ]); container.parentNode.insertBefore(myListingsToolbarEl, container); updateMyListingsCount(); } function getMyListingsStatusEls() { return { statusEl: document.getElementById('sme-ml-status'), progressEl: document.getElementById('sme-ml-progress'), progressBarEl: document.getElementById('sme-ml-progress-bar'), }; } function setMyListingsStatus(el, msg, cls) { if (!el) return; el.textContent = msg || ''; el.className = 'sme-ml-status' + (cls === 'err' ? ' sme-err' : (cls === 'ok' ? ' sme-ok' : '')); } function showMyListingsProgress(el, show) { if (el) el.style.display = show ? '' : 'none'; } function setMyListingsProgress(el, ratio) { if (el) el.style.width = Math.max(0, Math.min(1, ratio)) * 100 + '%'; } function setMyListingsButtonsDisabled(disabled) { if (!myListingsToolbarEl) return; myListingsToolbarEl.querySelectorAll('button.sme-market-btn').forEach(b => { b.disabled = disabled; }); } function updateMyListingsCount() { const el = document.getElementById('sme-ml-count'); if (!el) return; const c = document.getElementById('my_market_selllistings'); if (!c) return; let total = 0, selected = 0, visible = 0; c.querySelectorAll('.market_listing_row').forEach(r => { const d = myListingsRowData.get(r); if (!d) return; total++; if (r.style.display !== 'none') visible++; if (d.checked) selected++; }); el.innerHTML = `已选 ${selected} / 显示 ${visible} / 共 ${total}`; } // ---------- 行解析与复选框 ---------- function parseMyListingRow(row) { const m = (row.id || '').match(/^mylisting_(\d+)$/); if (!m) return null; const listingid = m[1]; const nameEl = row.querySelector('.market_listing_item_name'); const name = nameEl ? nameEl.textContent.trim() : ''; const dateEl = row.querySelector('.market_listing_listed_date'); const dateText = dateEl ? dateEl.textContent.trim() : ''; const { buyerCents, sellerCents } = extractMyListingPrices(row); return { row, listingid, name, dateText, dateTs: parseMyListingDate(dateText), buyerCents, sellerCents, checked: false }; } function extractMyListingPrices(row) { let buyerCents = 0, sellerCents = 0; const sellerRe = /你将收到|You will receive|Sie erhalten|Вы получите|receberá|会收到/i; row.querySelectorAll('.market_listing_price').forEach(el => { const txt = el.textContent || ''; const cents = parsePriceToCents(txt); if (sellerRe.test(txt)) { if (!sellerCents) sellerCents = cents; } else { if (!buyerCents) buyerCents = cents; } }); if (buyerCents && !sellerCents) sellerCents = priceBeforeFees(buyerCents); if (sellerCents && !buyerCents) buyerCents = priceIncludingFees(sellerCents); return { buyerCents, sellerCents }; } function parseMyListingDate(text) { if (!text) return 0; const t = text.trim(); let d = new Date(t); if (isNaN(d.getTime())) d = new Date(t + ' ' + new Date().getFullYear()); if (isNaN(d.getTime())) return 0; // 仅含月日时,若解析到未来超过 7 天,视为去年 if (d.getTime() - Date.now() > 7 * 24 * 3600 * 1000) d.setFullYear(d.getFullYear() - 1); return d.getTime(); } function injectMyListingCheckbox(row, data) { if (row.querySelector('.sme-ml-check')) return; const cb = h('input', { type: 'checkbox', class: 'sme-ml-check', title: '选择此挂单' }); cb.addEventListener('change', () => { data.checked = cb.checked; updateMyListingsCount(); }); const area = row.querySelector('.market_listing_cancel_button') || row.querySelector('.market_listing_cancel'); if (area) area.insertBefore(cb, area.firstChild); else row.insertBefore(cb, row.firstChild); } function processMyListingsRows(container) { let fresh = false; container.querySelectorAll('.market_listing_row').forEach(row => { if (myListingsRowData.has(row)) return; const data = parseMyListingRow(row); if (!data) return; myListingsRowData.set(row, data); if (settings.get('marketBatchButtons')) injectMyListingCheckbox(row, data); fresh = true; }); if (fresh) { applyMyListingsSortAndFilter(container); updateMyListingsCount(); } } // ---------- 排序与搜索 ---------- function applyMyListingsSortAndFilter(container) { const rows = Array.from(container.querySelectorAll('.market_listing_row')); if (rows.length === 0) return; if (myListingsSortKey) { rows.sort((a, b) => { const da = myListingsRowData.get(a), db = myListingsRowData.get(b); if (!da || !db) return 0; let cmp = 0; if (myListingsSortKey === 'name') cmp = da.name.localeCompare(db.name, 'zh'); else if (myListingsSortKey === 'date') cmp = (da.dateTs || 0) - (db.dateTs || 0); else if (myListingsSortKey === 'price') cmp = (da.buyerCents || 0) - (db.buyerCents || 0); return myListingsSortAsc ? cmp : -cmp; }); reorderMyListingRows(rows); } const q = myListingsSearchQ; rows.forEach(row => { const data = myListingsRowData.get(row); const match = !q || (data && data.name.toLowerCase().indexOf(q) !== -1); row.style.display = match ? '' : 'none'; }); updateMyListingsCount(); } function reorderMyListingRows(sortedRows) { if (sortedRows.length === 0) return; const parent = sortedRows[0].parentNode; const placeholder = document.createComment('sme-ml-sort'); parent.insertBefore(placeholder, sortedRows[0]); // 依次把已排序行插入占位符之前,最终顺序即为排序顺序;表头/分页等非行元素保持原位 for (const row of sortedRows) parent.insertBefore(row, placeholder); placeholder.remove(); } function setupMyListingsSortHeaders(container) { if (!settings.get('marketSorting')) return; const header = container.querySelector('.market_listing_table_header'); if (!header || header.dataset.smeMlSort) return; header.dataset.smeMlSort = '1'; const nameRe = /(物品|名称|商品|item|name)/i; const priceRe = /(价格|价钱|price)/i; const dateRe = /(时间|日期|挂单|listed|date)/i; Array.from(header.children).forEach(cell => { const txt = cell.textContent || ''; let key = null; if (nameRe.test(txt)) key = 'name'; else if (priceRe.test(txt)) key = 'price'; else if (dateRe.test(txt)) key = 'date'; if (!key) return; cell.classList.add('sme-ml-sortable'); cell.dataset.smeMlKey = key; cell.appendChild(h('span', { class: 'sme-ml-sortarrow', text: '▼' })); cell.addEventListener('click', () => { if (myListingsSortKey === key) myListingsSortAsc = !myListingsSortAsc; else { myListingsSortKey = key; myListingsSortAsc = true; } refreshMyListingsSortHeaders(header); const c = document.getElementById('my_market_selllistings'); if (c) applyMyListingsSortAndFilter(c); }); }); refreshMyListingsSortHeaders(header); } function refreshMyListingsSortHeaders(header) { header.querySelectorAll('.sme-ml-sortable').forEach(cell => { const key = cell.dataset.smeMlKey; const active = key && myListingsSortKey === key; cell.classList.toggle('sme-active', active); const arrow = cell.querySelector('.sme-ml-sortarrow'); if (arrow) arrow.textContent = active ? (myListingsSortAsc ? '▲' : '▼') : '▼'; }); } function setupMyListingsObserver(container) { if (myListingsObserver) myListingsObserver.disconnect(); let debounce = null; myListingsObserver = new MutationObserver(() => { clearTimeout(debounce); debounce = setTimeout(() => processMyListingsRows(container), 200); }); myListingsObserver.observe(container, { childList: true, subtree: true }); } // ---------- 选择操作 ---------- function getVisibleMyListingRows() { const c = document.getElementById('my_market_selllistings'); if (!c) return []; return Array.from(c.querySelectorAll('.market_listing_row')).filter(r => { const d = myListingsRowData.get(r); return d && r.style.display !== 'none'; }); } function setMyListingChecked(row, data, checked) { if (!data) return; data.checked = checked; const cb = row.querySelector('.sme-ml-check'); if (cb) cb.checked = checked; } function onSelectAllMyListings() { const rows = getVisibleMyListingRows(); const allChecked = rows.length > 0 && rows.every(r => myListingsRowData.get(r).checked); rows.forEach(r => setMyListingChecked(r, myListingsRowData.get(r), !allChecked)); updateMyListingsCount(); } function selectNMyListings(n) { const c = document.getElementById('my_market_selllistings'); if (c) c.querySelectorAll('.market_listing_row').forEach(r => { const d = myListingsRowData.get(r); if (d) setMyListingChecked(r, d, false); }); getVisibleMyListingRows().slice(0, n).forEach(r => setMyListingChecked(r, myListingsRowData.get(r), true)); updateMyListingsCount(); } // ---------- 资产信息提取 ---------- function getMyListingAssetInfo(listingid) { let info = null; try { const ml = unsafeWindow.g_oMyListings; if (ml) info = (ml.m_rgListingInfo && ml.m_rgListingInfo[listingid]) || (ml.m_rgListings && ml.m_rgListings[listingid]) || null; } catch (e) {} if (!info) return null; const asset = info.asset || info.rgAsset || info; const appid = String(asset.appid || info.appid || ''); const contextid = String(asset.contextid || info.contextid || APP_CONTEXT[appid] || ''); const assetid = String(asset.assetid || asset.id || info.assetid || ''); let marketHashName = ''; if (appid && contextid && assetid) { try { const ctx = unsafeWindow.g_rgAssets && unsafeWindow.g_rgAssets[appid] && unsafeWindow.g_rgAssets[appid][contextid]; const a = ctx && ctx[assetid]; if (a) { const desc = a.description || a; marketHashName = desc.market_hash_name || desc.market_name || desc.name || ''; } } catch (e) {} } if (!marketHashName) { const desc = asset.description || info.description; if (desc) marketHashName = desc.market_hash_name || desc.market_name || desc.name || ''; } if (!marketHashName) marketHashName = asset.market_hash_name || info.market_hash_name || ''; return { appid, contextid, assetid, marketHashName }; } // ---------- 带限流重试的请求包装 ---------- async function withMarketRetry(fn, label, maxAttempts) { if (maxAttempts === undefined) maxAttempts = 3; let lastErr; for (let attempt = 0; attempt < maxAttempts; attempt++) { try { return await fn(); } catch (err) { lastErr = err; const msg = err && err.message ? err.message : String(err); const isRate = msg === 'RateLimited' || /429/i.test(msg); if ((isRate || isRetryMessage(msg)) && attempt < maxAttempts - 1) { const wait = randInt(RETRY_BACKOFF_MIN, RETRY_BACKOFF_MAX); toast(`${label || '操作'}: 请求受限,${Math.round(wait / 1000)} 秒后重试…`, 'warning'); await sleep(wait); continue; } throw err; } } throw lastErr; } async function fetchMyListingItemNameId(appid, hash) { const key = appid + '|' + hash; if (myListItemNameIdCache.has(key)) return myListItemNameIdCache.get(key); let result = null; try { result = await withMarketRetry(async () => { const url = `https://steamcommunity.com/market/listings/${appid}/${encodeURIComponent(hash)}`; const resp = await fetch(url, { credentials: 'include' }); if (resp.status === 429) throw new Error('RateLimited'); if (!resp.ok) return null; const html = await resp.text(); let m = html.match(/Market_LoadOrderSpread\(\s*['"]?(\d+)/); if (!m) m = html.match(/g_item_nameid\s*=\s*["']?(\d+)/); if (!m) m = html.match(/"item_nameid"\s*:\s*"?(\d+)/); return m ? m[1] : null; }, 'item_nameid'); } catch (e) { /* 限流重试耗尽,返回 null */ } myListItemNameIdCache.set(key, result); return result; } async function fetchMyListingOrderBook(appid, hash) { const itemNameId = await fetchMyListingItemNameId(appid, hash); if (!itemNameId) return null; await sleep(randInt(INV_REQUEST_DELAY, INV_REQUEST_DELAY + 500)); try { return await withMarketRetry(() => fetchOrderHistogram(itemNameId), '订单簿'); } catch (e) { return null; } } async function computeMyListingSuggestedPrice(appid, hash) { let priceHistory = null; try { priceHistory = await withMarketRetry(() => fetchPriceHistory(appid, hash), '价格历史'); } catch (e) {} await sleep(randInt(INV_REQUEST_DELAY, INV_REQUEST_DELAY + 500)); const orderBook = await fetchMyListingOrderBook(appid, hash); // calcSellPrice 在缺少订单簿时会退化为仅按历史均价定价 return calcSellPrice(priceHistory, orderBook, true); } async function runMyListingsPriceCheck(statusEl, progressEl, progressBarEl) { const c = document.getElementById('my_market_selllistings'); if (!c) return; const rows = Array.from(c.querySelectorAll('.market_listing_row')).filter(r => { const d = myListingsRowData.get(r); return d && r.style.display !== 'none'; }); // 按唯一物品分组,避免重复请求 const itemMap = new Map(); for (const row of rows) { const d = myListingsRowData.get(row); const ai = getMyListingAssetInfo(d.listingid); if (!ai || !ai.appid || !ai.marketHashName) { myListingsPriceCheck.set(d.listingid, { overpriced: false, suggestedCents: 0, source: '无物品信息', buyerCents: d.buyerCents, unknown: true }); continue; } d._asset = ai; const key = ai.appid + '|' + ai.marketHashName; if (!itemMap.has(key)) itemMap.set(key, { appid: ai.appid, hash: ai.marketHashName, listingids: [] }); itemMap.get(key).listingids.push(d.listingid); } const items = Array.from(itemMap.values()); const total = items.length; if (total === 0) { setMyListingsStatus(statusEl, '无可检测的物品(缺少物品信息)', 'err'); return; } showMyListingsProgress(progressEl, true); setMyListingsProgress(progressBarEl, 0); let done = 0; for (const item of items) { setMyListingsStatus(statusEl, `正在检测价格 ${done}/${total}:${item.hash.slice(0, 30)}…`, ''); try { const suggested = await computeMyListingSuggestedPrice(item.appid, item.hash); const suggestedBuyerCents = priceIncludingFees(suggested.priceCents); for (const lid of item.listingids) { const row = document.getElementById('mylisting_' + lid); const d = row ? myListingsRowData.get(row) : null; const buyerCents = d ? d.buyerCents : 0; const overpriced = suggestedBuyerCents > 0 && buyerCents > suggestedBuyerCents; myListingsPriceCheck.set(lid, { overpriced, suggestedCents: suggested.priceCents, source: suggested.source, buyerCents }); if (row) { row.classList.toggle('sme-ml-overpriced', overpriced); if (d) d._suggested = suggested; } } } catch (err) { W('Price check failed for', item.appid, item.hash, err && err.message); for (const lid of item.listingids) { myListingsPriceCheck.set(lid, { overpriced: false, suggestedCents: 0, source: '检测失败', buyerCents: 0, unknown: true }); } } done++; setMyListingsProgress(progressBarEl, done / total); setMyListingsStatus(statusEl, `正在检测价格 ${done}/${total}…`, ''); if (done < total) await sleep(randInt(INV_REQUEST_DELAY, INV_REQUEST_DELAY + 500)); } setMyListingsStatus(statusEl, `价格检测完成,共 ${total} 种物品`, 'ok'); setTimeout(() => showMyListingsProgress(progressEl, false), 1500); } async function onSelectOverpricedMyListings() { if (myListingsBusy) { toast('已有操作进行中,请稍候', 'warning'); return; } myListingsBusy = true; setMyListingsButtonsDisabled(true); const { statusEl, progressEl, progressBarEl } = getMyListingsStatusEls(); try { const c = document.getElementById('my_market_selllistings'); if (c) c.querySelectorAll('.sme-ml-overpriced').forEach(r => r.classList.remove('sme-ml-overpriced')); myListingsPriceCheck.clear(); await runMyListingsPriceCheck(statusEl, progressEl, progressBarEl); let count = 0; if (c) c.querySelectorAll('.market_listing_row').forEach(r => { const d = myListingsRowData.get(r); if (!d) return; const chk = myListingsPriceCheck.get(d.listingid); const over = chk && chk.overpriced; setMyListingChecked(r, d, over); if (over) count++; }); updateMyListingsCount(); toast(`检测完成,选中 ${count} 件超价物品`, count > 0 ? 'success' : 'info'); } catch (err) { setMyListingsStatus(statusEl, '价格检测出错: ' + (err && err.message), 'err'); toast('价格检测出错: ' + (err && err.message), 'error'); } finally { myListingsBusy = false; setMyListingsButtonsDisabled(false); } } // ---------- 下架 / 重新上架 ---------- async function removeMyListing(listingid) { const sessionid = getSessionId(); if (!sessionid) throw new Error('无法获取会话ID'); const resp = await fetch(`https://steamcommunity.com/market/removelisting/${listingid}`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: `sessionid=${encodeURIComponent(sessionid)}`, }); if (resp.status === 429) throw new Error('RateLimited'); if (!resp.ok) throw new Error('HTTP ' + resp.status); let data = null; try { data = await resp.json(); } catch (e) {} if (data && data.success === false) throw new Error(data.message || '下架失败'); return true; } async function onRemoveSelectedMyListings() { if (myListingsBusy) { toast('已有操作进行中,请稍候', 'warning'); return; } const c = document.getElementById('my_market_selllistings'); if (!c) return; const selected = Array.from(c.querySelectorAll('.market_listing_row')).filter(r => { const d = myListingsRowData.get(r); return d && d.checked; }); if (selected.length === 0) { toast('未选中任何挂单', 'warning'); return; } if (!confirm(`确定要下架选中的 ${selected.length} 件物品吗?`)) return; myListingsBusy = true; setMyListingsButtonsDisabled(true); const { statusEl, progressEl, progressBarEl } = getMyListingsStatusEls(); showMyListingsProgress(progressEl, true); let success = 0, failed = 0; for (let i = 0; i < selected.length; i++) { const row = selected[i]; const d = myListingsRowData.get(row); setMyListingsStatus(statusEl, `正在下架 ${i + 1}/${selected.length}…`, ''); setMyListingsProgress(progressBarEl, i / selected.length); try { await withMarketRetry(() => removeMyListing(d.listingid), '下架'); success++; row.remove(); myListingsRowData.delete(row); myListingsPriceCheck.delete(d.listingid); } catch (err) { failed++; W('下架失败:', d.listingid, err && err.message); setMyListingChecked(row, d, false); } if (i < selected.length - 1) await sleep(randInt(INV_REQUEST_DELAY, INV_REQUEST_DELAY + 500)); } setMyListingsProgress(progressBarEl, 1); setMyListingsStatus(statusEl, `下架完成: 成功 ${success} 件` + (failed > 0 ? `,失败 ${failed} 件` : ''), failed > 0 ? 'err' : 'ok'); toast(`下架完成: 成功 ${success} 件` + (failed > 0 ? `,失败 ${failed} 件` : ''), failed > 0 ? 'warning' : 'success'); setTimeout(() => showMyListingsProgress(progressEl, false), 2000); updateMyListingsCount(); myListingsBusy = false; setMyListingsButtonsDisabled(false); } async function onRelistOverpricedMyListings() { if (myListingsBusy) { toast('已有操作进行中,请稍候', 'warning'); return; } const c = document.getElementById('my_market_selllistings'); if (!c) return; const checkedRows = Array.from(c.querySelectorAll('.market_listing_row')).filter(r => { const d = myListingsRowData.get(r); return d && d.checked; }); // 若尚无价格检测结果或未选中任何行,则先对全部可见挂单进行检测 const needCheck = myListingsPriceCheck.size === 0 || checkedRows.length === 0; const relistAll = needCheck; myListingsBusy = true; setMyListingsButtonsDisabled(true); const { statusEl, progressEl, progressBarEl } = getMyListingsStatusEls(); try { if (needCheck) { if (c) c.querySelectorAll('.sme-ml-overpriced').forEach(r => r.classList.remove('sme-ml-overpriced')); myListingsPriceCheck.clear(); await runMyListingsPriceCheck(statusEl, progressEl, progressBarEl); } const rows = Array.from(c.querySelectorAll('.market_listing_row')).filter(r => { const d = myListingsRowData.get(r); if (!d) return false; const chk = myListingsPriceCheck.get(d.listingid); if (!chk || !chk.overpriced || chk.suggestedCents <= 0) return false; return relistAll ? true : d.checked; }); if (rows.length === 0) { setMyListingsStatus(statusEl, '未检测到超价物品', ''); toast('未检测到超价物品', 'info'); return; } if (!confirm(`确定要重新上架 ${rows.length} 件超价物品吗?\n将先下架再按建议价格重新上架。`)) return; const total = rows.length; showMyListingsProgress(progressEl, true); let success = 0, failed = 0; for (let i = 0; i < rows.length; i++) { const row = rows[i]; const d = myListingsRowData.get(row); const chk = myListingsPriceCheck.get(d.listingid); const suggestedCents = chk.suggestedCents; setMyListingsStatus(statusEl, `重新上架 ${i + 1}/${total}: 下架中…`, ''); setMyListingsProgress(progressBarEl, i / total); try { // 步骤1: 下架 await withMarketRetry(() => removeMyListing(d.listingid), '下架'); // 步骤2: 等待物品返回库存(1.5-2.5 秒) setMyListingsStatus(statusEl, `重新上架 ${i + 1}/${total}: 等待物品返回库存…`, ''); await sleep(randInt(1500, 2500)); // 步骤3: 以建议价格重新上架(assetid 在下架后保持不变) const ai = d._asset || getMyListingAssetInfo(d.listingid); if (!ai || !ai.assetid || !ai.appid) { W('重挂缺少资产信息:', d.listingid); failed++; row.remove(); myListingsRowData.delete(row); continue; } setMyListingsStatus(statusEl, `重新上架 ${i + 1}/${total}: 上架中 (${formatCents(priceIncludingFees(suggestedCents))})…`, ''); await withMarketRetry(() => sellItem({ assetid: ai.assetid, appid: ai.appid, contextid: ai.contextid, priceCents: suggestedCents, }), '上架'); success++; row.remove(); myListingsRowData.delete(row); myListingsPriceCheck.delete(d.listingid); } catch (err) { failed++; W('重挂失败:', d.listingid, err && err.message); setMyListingChecked(row, d, false); } if (i < rows.length - 1) await sleep(randInt(INV_REQUEST_DELAY, INV_REQUEST_DELAY + 500)); } setMyListingsProgress(progressBarEl, 1); setMyListingsStatus(statusEl, `重新上架完成: 成功 ${success} 件` + (failed > 0 ? `,失败 ${failed} 件` : ''), failed > 0 ? 'err' : 'ok'); toast(`重新上架完成: 成功 ${success} 件` + (failed > 0 ? `,失败 ${failed} 件` : ''), failed > 0 ? 'warning' : 'success'); setTimeout(() => showMyListingsProgress(progressEl, false), 3000); updateMyListingsCount(); } catch (err) { setMyListingsStatus(statusEl, '重新上架出错: ' + (err && err.message), 'err'); toast('重新上架出错: ' + (err && err.message), 'error'); } finally { myListingsBusy = false; setMyListingsButtonsDisabled(false); } } // ==================== 库存页 ==================== // 库存页增强:价格标签、快速出售面板、批量操作工具栏 let invObserver = null; // MutationObserver 实例 let invToolbarEl = null; // 批量操作工具栏 DOM let invPriceLabels = new Map(); // 价格缓存:key=`appid|hash` → {cents,text,done,error} let invQuickSellEl = null; // 当前快速出售面板 DOM let invSelectedItem = null; // 当前选中物品数据(供"转宝石"使用) let invBatchRunning = false; // 批量操作进行中标记 const invPricePending = new Map(); // 正在请求的价格 key → Promise(去重) let invPriceQueueTail = Promise.resolve(); // 价格请求串行队列尾指针(限流) /** 库存页入口 */ function initInventoryPage() { L('Initializing inventory page features'); let attempt = 0; const tryInit = () => { const ready = unsafeWindow.g_ActiveInventory || document.querySelector('.inventory_ctn .item'); if (ready || attempt >= 30) { if (settings.get('inventoryPriceLabels')) { processInventoryItems(); setupInventoryObserver(); } if (settings.get('quickSellButtons')) { addInventoryToolbar(); hookItemSelection(); } } else { attempt++; setTimeout(tryInit, 500); } }; tryInit(); } /** 注入批量操作工具栏 */ function addInventoryToolbar() { if (invToolbarEl) return; const toolbar = h('div', { class: 'sme-inv-toolbar' }, [ h('div', { class: 'sme-inv-toolbar-title', html: ICONS.bag + '批量操作' }), h('button', { class: 'sme-inv-btn', html: ICONS.sell + '出售所有物品', onclick: onBatchSellAll }), h('button', { class: 'sme-inv-btn', html: ICONS.layers + '出售重复物品', onclick: onBatchSellDuplicates }), h('button', { class: 'sme-inv-btn', html: ICONS.tag + '出售所有卡牌', onclick: onBatchSellCards }), h('button', { class: 'sme-inv-btn sme-danger', html: ICONS.gem + '选中转宝石', onclick: turnSelectedIntoGems }), h('div', { class: 'sme-inv-progress' }, [h('div', { class: 'sme-inv-progress-bar', style: { width: '0%' } })]), h('span', { class: 'sme-inv-status', text: '就绪' }), ]); invToolbarEl = toolbar; const anchor = document.querySelector('.inventory_ctn') || document.querySelector('.filter_ctn') || document.querySelector('.games_list_tab_ctn'); if (anchor && anchor.parentNode) { anchor.parentNode.insertBefore(toolbar, anchor); } else { const slot = document.querySelector('.responsive_page_template_content') || document.body; slot.insertBefore(toolbar, slot.firstChild); } } /** 更新进度条与状态文本 */ function updateInvProgress(done, total, status) { if (!invToolbarEl) return; const bar = invToolbarEl.querySelector('.sme-inv-progress-bar'); const txt = invToolbarEl.querySelector('.sme-inv-status'); if (bar) bar.style.width = (total > 0 ? Math.round(done / total * 100) : 0) + '%'; if (txt && status != null) txt.textContent = status; } /** 禁用/启用工具栏按钮 */ function setBatchButtonsDisabled(disabled) { if (!invToolbarEl) return; invToolbarEl.querySelectorAll('.sme-inv-btn').forEach(b => { b.disabled = !!disabled; }); } /** 获取所有库存物品 DOM 元素(id 形如 appid_contextid_assetid) */ function getInventoryItemElements() { const els = document.querySelectorAll('.inventory_ctn .item'); const out = []; for (const el of els) { if (el.id && /^\d+_\d+_\d+$/.test(el.id)) out.push(el); } return out; } /** 标准化物品数据(合并 asset 与 description) */ function normalizeInvItem(raw, appid, contextid, assetid) { if (!raw) return null; const desc = raw.description || raw.rgDescription || raw; return { appid: String(raw.appid || appid), contextid: String(raw.contextid || contextid), assetid: String(raw.assetid || raw.id || assetid), market_hash_name: desc.market_hash_name || raw.market_hash_name || desc.market_name || raw.name || '', name: desc.name || raw.name || desc.market_hash_name || '', type: desc.type || raw.type || '', marketable: (desc.marketable != null ? desc.marketable : (raw.marketable != null ? raw.marketable : 0)), tradable: (desc.tradable != null ? desc.tradable : (raw.tradable != null ? raw.tradable : 0)), tags: desc.tags || raw.tags || {}, owner_actions: desc.owner_actions || raw.owner_actions || [], amount: parseInt(raw.amount || raw.original_amount, 10) || 1, original_amount: parseInt(raw.original_amount || raw.amount, 10) || 1, element: raw.element || null, }; } function isInvMarketable(data) { return !!(data && (data.marketable === 1 || data.marketable === true)); } /** 通过多种途径获取物品数据(jQuery data → 直接属性 → g_ActiveInventory) */ function getRgItemFromElement(itemEl) { if (!itemEl || !itemEl.id) return null; const parts = itemEl.id.split('_'); if (parts.length < 3) return null; const appid = parts[0], contextid = parts[1], assetid = parts[2]; // 1. jQuery data rgItem(Steam 在元素上挂载 CItem) try { const jq = unsafeWindow.$J || unsafeWindow.jQuery || unsafeWindow.$; if (jq) { const data = jq(itemEl).data('rgItem'); if (data) return normalizeInvItem(data, appid, contextid, assetid); } } catch (e) {} // 2. 直接属性 try { if (itemEl.rgItem) return normalizeInvItem(itemEl.rgItem, appid, contextid, assetid); } catch (e) {} // 3. g_ActiveInventory 查找 try { const inv = unsafeWindow.g_ActiveInventory; if (inv) { if (inv.rgChildInventories) { const child = inv.rgChildInventories[contextid]; if (child) { if (child.rgItems && child.rgItems[assetid]) { return normalizeInvItem(child.rgItems[assetid], appid, contextid, assetid); } if (child.m_rgAssets && child.m_rgAssets[assetid]) { const asset = child.m_rgAssets[assetid]; const descKey = asset.classid + '_' + (asset.instanceid || '0'); const desc = (child.m_rgDescriptions && child.m_rgDescriptions[descKey]) || (inv.m_rgDescriptions && inv.m_rgDescriptions[descKey]) || null; return normalizeInvItem(desc ? Object.assign({}, asset, desc) : asset, appid, contextid, assetid); } } } if (inv.m_rgAssets && inv.m_rgAssets[assetid]) { return normalizeInvItem(inv.m_rgAssets[assetid], appid, contextid, assetid); } } } catch (e) {} return null; } /** 遍历可见物品,添加价格标签 */ function processInventoryItems() { const els = getInventoryItemElements(); for (const el of els) { if (el.querySelector('.sme-inv-price')) continue; // 已处理 const data = getRgItemFromElement(el); if (!data || !isInvMarketable(data) || !data.market_hash_name) continue; addPriceLabelToItem(el, data); } } /** 为单个物品添加价格标签并异步获取价格 */ function addPriceLabelToItem(itemEl, data) { const label = h('span', { class: 'sme-inv-price sme-inf', text: '···' }); itemEl.appendChild(label); fetchInvPriceOverview(data.appid, data.market_hash_name).then(entry => { label.textContent = entry.text; label.classList.remove('sme-inf'); if (entry.error) label.classList.add('sme-na'); }); } /** 价格概览请求(串行限流 1s/次,按 appid|hash 缓存去重) */ function fetchInvPriceOverview(appid, hashName) { const key = `${appid}|${hashName}`; if (invPriceLabels.has(key)) return Promise.resolve(invPriceLabels.get(key)); if (invPricePending.has(key)) return invPricePending.get(key); const p = invPriceQueueTail.then(async () => { await sleep(INV_REQUEST_DELAY); try { const url = `https://steamcommunity.com/market/priceoverview/?appid=${appid}&market_hash_name=${encodeURIComponent(hashName)}`; const po = await doFetch(url); let cents = 0, text = '—'; if (po && po.success && po.lowest_price) { cents = parsePriceToCents(po.lowest_price); text = formatCents(cents); } const entry = { cents, text, done: true }; invPriceLabels.set(key, entry); return entry; } catch (e) { const entry = { cents: 0, text: '×', done: true, error: true }; invPriceLabels.set(key, entry); return entry; } }); invPricePending.set(key, p); invPriceQueueTail = p.then(() => {}, () => {}); p.then(() => invPricePending.delete(key), () => invPricePending.delete(key)); return p; } /** 通过市场详情页 HTML 提取 item_nameid(库存页无该值,需抓取列表页) */ async function fetchItemNameIdByListing(appid, hashName) { const cacheKey = `nid_${appid}_${hashName}`; const cached = cache.get(cacheKey, CACHE_TTL.ORDER_BOOK); if (cached) return cached; const url = `https://steamcommunity.com/market/listings/${appid}/${encodeURIComponent(hashName)}`; const resp = await fetch(url, { credentials: 'include' }); if (!resp.ok) throw new Error('HTTP ' + resp.status); const html = await resp.text(); let nameId = null, m; if ((m = html.match(/Market_LoadOrderSpread\(\s*(\d+)/))) nameId = m[1]; else if ((m = html.match(/"item_nameid"\s*:\s*"?(\d+)/))) nameId = m[1]; else if ((m = html.match(/item_nameid["'\s:=]+(\d{5,})/))) nameId = m[1]; if (nameId) cache.set(cacheKey, nameId); return nameId; } /** 获取库存物品的订单簿(最低卖单 + 最高买单) */ async function fetchInventoryOrderBook(appid, hashName) { const result = { lowestSell: 0, highestBuy: 0, volume: null, lowestText: '—' }; // 价格概览(轻量,提供最低卖单与成交量) try { const entry = await fetchInvPriceOverview(appid, hashName); if (entry && entry.cents > 0) { result.lowestSell = entry.cents; result.lowestText = entry.text; } } catch (e) {} // 订单直方图(提供最高买单,best-effort) try { const nameId = await fetchItemNameIdByListing(appid, hashName); if (nameId) { const hist = await fetchOrderHistogram(nameId); if (hist.highestBuy) result.highestBuy = parseInt(hist.highestBuy, 10) || 0; if (hist.lowestSell && !result.lowestSell) result.lowestSell = parseInt(hist.lowestSell, 10) || 0; } } catch (e) { W('Inventory order histogram fetch failed:', e.message); } return result; } /** 监听库存 DOM 变化,处理新加载的物品(防抖 400ms) */ function setupInventoryObserver() { const ctn = document.querySelector('.inventory_ctn'); if (!ctn) return; if (invObserver) invObserver.disconnect(); let timer = null; invObserver = new MutationObserver(() => { if (timer) return; timer = setTimeout(() => { timer = null; if (settings.get('inventoryPriceLabels')) processInventoryItems(); }, 400); }); invObserver.observe(ctn, { childList: true, subtree: true }); } /** 挂载物品选择钩子(事件委托),选中后显示快速出售面板 */ function hookItemSelection() { const ctn = document.querySelector('.inventory_ctn') || document.body; const handler = (e) => { const itemEl = e.target && e.target.closest ? e.target.closest('.item') : null; if (!itemEl || !/^\d+_\d+_\d+$/.test(itemEl.id)) return; setTimeout(() => showQuickSellForElement(itemEl), 300); }; ctn.addEventListener('click', handler, true); } function showQuickSellForElement(itemEl) { const data = getRgItemFromElement(itemEl); invSelectedItem = data; // 记录当前选中(供"转宝石"使用) removeQuickSellPanel(); if (!data || !isInvMarketable(data) || !data.market_hash_name) return; showQuickSell(data); } function getActiveItemInfoContent() { return document.getElementById('iteminfo0_content') || document.getElementById('iteminfo1_content'); } function removeQuickSellPanel() { if (invQuickSellEl && invQuickSellEl.parentNode) { invQuickSellEl.parentNode.removeChild(invQuickSellEl); } invQuickSellEl = null; } /** 显示快速出售面板 */ function showQuickSell(data) { const content = getActiveItemInfoContent(); if (!content) return; const panel = h('div', { class: 'sme-quick-sell' }, [ h('div', { class: 'sme-quick-sell-title', html: ICONS.sell + '快速出售 — ' + escapeHtml(data.name || data.market_hash_name) + '' }), h('div', { class: 'sme-qs-book', text: '加载价格中...' }), h('div', { class: 'sme-quick-sell-btns' }, []), ]); invQuickSellEl = panel; content.appendChild(panel); fetchInventoryOrderBook(data.appid, data.market_hash_name).then(book => { renderQuickSellPanel(panel, data, book); }).catch(() => { const bookEl = panel.querySelector('.sme-qs-book'); if (bookEl) bookEl.textContent = '价格加载失败'; }); } /** 渲染快速出售按钮与订单簿摘要 */ function renderQuickSellPanel(panel, data, book) { const bookEl = panel.querySelector('.sme-qs-book'); const btnsEl = panel.querySelector('.sme-quick-sell-btns'); if (!bookEl || !btnsEl) return; bookEl.innerHTML = ''; const lowestSellCents = book.lowestSell || 0; const highestBuyCents = book.highestBuy || 0; bookEl.appendChild(h('div', { class: 'sme-qs-book-row sme-sell', text: '最低挂单: ' + (lowestSellCents ? formatCents(lowestSellCents) : '—') })); bookEl.appendChild(h('div', { class: 'sme-qs-book-row sme-buy', text: '最高买单: ' + (highestBuyCents ? formatCents(highestBuyCents) : '—') })); if (book.volume) bookEl.appendChild(h('div', { class: 'sme-qs-book-row', text: '24h成交量: ' + book.volume })); btnsEl.innerHTML = ''; // 最高买单(即时成交) if (highestBuyCents > 0) { const buyReceived = priceBeforeFees(highestBuyCents); if (buyReceived > 0) { btnsEl.appendChild(h('button', { class: 'sme-qs-btn sme-start', html: ICONS.dollarSign + '最高买单 ' + formatCents(buyReceived) + '', onclick: () => doQuickSell(data, buyReceived, panel) })); } } // 最低卖单 -1 / 最低卖单(到手价) if (lowestSellCents > 0) { const sellReceived = priceBeforeFees(lowestSellCents); const minus1 = Math.max(sellReceived - 1, 1); btnsEl.appendChild(h('button', { class: 'sme-qs-btn', html: ICONS.tag + '最低卖-1 ' + formatCents(minus1) + '', onclick: () => doQuickSell(data, minus1, panel) })); btnsEl.appendChild(h('button', { class: 'sme-qs-btn', html: ICONS.tag + '最低卖 ' + formatCents(sellReceived) + '', onclick: () => doQuickSell(data, sellReceived, panel) })); } // 自定义价格(售价 = 买家支付价,自动换算为到手价) const input = h('input', { class: 'sme-qs-input', type: 'number', step: '0.01', min: '0', placeholder: '售价' }); btnsEl.appendChild(input); btnsEl.appendChild(h('button', { class: 'sme-qs-btn', html: ICONS.sell + '出售', onclick: () => { const val = parseFloat(input.value); if (!val || val <= 0) { toast('请输入有效价格', 'warning'); return; } const buyerPaid = Math.round(val * 100); const received = Math.max(priceBeforeFees(buyerPaid), 1); doQuickSell(data, received, panel); }})); } /** 执行快速出售 */ async function doQuickSell(data, priceCents, panel) { const btns = panel.querySelectorAll('.sme-qs-btn'); btns.forEach(b => b.disabled = true); const statusEl = panel.querySelector('.sme-qs-book'); const prevHtml = statusEl ? statusEl.innerHTML : ''; if (statusEl) statusEl.innerHTML = '
出售中...
'; try { await sellItem({ appid: data.appid, contextid: data.contextid, assetid: data.assetid, priceCents }); toast('已上架: ' + (data.name || data.market_hash_name), 'success'); if (statusEl) statusEl.innerHTML = '
上架成功,到手 ' + formatCents(priceCents) + '
'; if (data.element) data.element.style.opacity = '0.4'; } catch (e) { toast('上架失败: ' + e.message, 'error'); if (statusEl) statusEl.innerHTML = prevHtml + '
失败: ' + escapeHtml(e.message) + '
'; btns.forEach(b => b.disabled = false); } } /** 收集所有可出售物品 */ function collectSellableItems(filterFn) { const els = getInventoryItemElements(); const items = []; for (const el of els) { const data = getRgItemFromElement(el); if (!data || !isInvMarketable(data) || !data.market_hash_name) continue; if (filterFn && !filterFn(data)) continue; items.push({ element: el, data }); } return items; } /** 收集重复物品(每种保留一件,出售其余) */ function collectDuplicateItems() { const all = collectSellableItems(); const seen = new Map(); const result = []; for (const it of all) { const k = it.data.market_hash_name; const n = seen.get(k) || 0; if (n > 0) result.push(it); // 跳过首个,出售重复 seen.set(k, n + 1); } return result; } /** 计算批量出售价格:优先用最低卖单到手价,回退到物品类型最低价 */ async function computeBatchSellPrice(data) { try { const entry = await fetchInvPriceOverview(data.appid, data.market_hash_name); if (entry && entry.cents > 0) return Math.max(priceBeforeFees(entry.cents), 1); } catch (e) {} const info = getItemPriceInfo(data); return Math.max(Math.round(parseFloat(info.min) * 100), 1); } /** 批量出售(带进度与限流重试) */ async function batchSell(items, opLabel) { if (invBatchRunning) { toast('已有批量操作进行中', 'warning'); return; } if (!items || items.length === 0) { toast('没有符合条件的可出售物品', 'info'); return; } invBatchRunning = true; setBatchButtonsDisabled(true); let success = 0, fail = 0; updateInvProgress(0, items.length, opLabel + ': 准备出售 ' + items.length + ' 件...'); for (let i = 0; i < items.length; i++) { const { data } = items[i]; updateInvProgress(i, items.length, opLabel + ' (' + (i + 1) + '/' + items.length + '): ' + (data.name || data.market_hash_name)); try { const price = await computeBatchSellPrice(data); await sellItem({ appid: data.appid, contextid: data.contextid, assetid: data.assetid, priceCents: price }); success++; if (data.element) data.element.style.opacity = '0.4'; } catch (e) { fail++; if (isRetryMessage(e.message)) { updateInvProgress(i, items.length, opLabel + ': 遇到限流,等待重试...'); await sleep(randInt(RETRY_BACKOFF_MIN, RETRY_BACKOFF_MAX)); try { const price = await computeBatchSellPrice(data); await sellItem({ appid: data.appid, contextid: data.contextid, assetid: data.assetid, priceCents: price }); success++; fail--; if (data.element) data.element.style.opacity = '0.4'; } catch (e2) { W('Batch sell retry failed:', e2.message); } } else { W('Batch sell failed for', data.market_hash_name, e.message); } } await sleep(randInt(SELL_DELAY_MIN, SELL_DELAY_MAX)); } updateInvProgress(items.length, items.length, opLabel + ' 完成: 成功 ' + success + ', 失败 ' + fail); toast(opLabel + '完成: 成功 ' + success + ', 失败 ' + fail, success > 0 ? 'success' : 'error'); invBatchRunning = false; setBatchButtonsDisabled(false); } function invConfirm(msg) { try { if (unsafeWindow.confirm) return unsafeWindow.confirm(msg); } catch (e) {} return confirm(msg); } function onBatchSellAll() { if (invBatchRunning) { toast('已有批量操作进行中', 'warning'); return; } const items = collectSellableItems(); if (items.length === 0) { toast('没有可出售的市场物品', 'info'); return; } if (!invConfirm('确定出售所有 ' + items.length + ' 件可市场物品吗?')) return; batchSell(items, '出售所有物品'); } function onBatchSellDuplicates() { if (invBatchRunning) { toast('已有批量操作进行中', 'warning'); return; } const items = collectDuplicateItems(); if (items.length === 0) { toast('没有重复物品可出售', 'info'); return; } if (!invConfirm('确定出售 ' + items.length + ' 件重复物品吗(每种保留一件)?')) return; batchSell(items, '出售重复物品'); } function onBatchSellCards() { if (invBatchRunning) { toast('已有批量操作进行中', 'warning'); return; } const items = collectSellableItems(isTradingCard); if (items.length === 0) { toast('没有可出售的卡牌', 'info'); return; } if (!invConfirm('确定出售所有 ' + items.length + ' 张可市场卡牌吗?')) return; batchSell(items, '出售所有卡牌'); } /** 获取物品可转换的宝石数量(GET /ajaxgetgoovalue/) */ async function getGooValue(data) { const url = `https://steamcommunity.com/ajaxgetgoovalue/?appid=${data.appid}&assetid=${data.assetid}&contextid=${data.contextid}`; const json = await doFetch(url); if (!json || !json.success) throw new Error((json && json.message) || '无法获取宝石价值'); return parseInt(json.goo_value, 10) || 0; } /** 转换为宝石(POST /ajaxgrindintogoo/) */ async function turnIntoGems(data, gooValue) { const sessionid = getSessionId(); if (!sessionid) throw new Error('无法获取 sessionid'); const body = new URLSearchParams({ sessionid, appid: String(data.appid), assetid: String(data.assetid), contextid: String(data.contextid), goo_value_expected: String(gooValue), }); const resp = await fetch('https://steamcommunity.com/ajaxgrindintogoo/', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }, body: body.toString(), }); if (!resp.ok) throw new Error('HTTP ' + resp.status); const json = await resp.json(); if (!json || !json.success) throw new Error((json && json.message) || '转换宝石失败'); return json; } /** 将当前选中物品转换为宝石 */ async function turnSelectedIntoGems() { if (invBatchRunning) { toast('已有操作进行中', 'warning'); return; } if (!invSelectedItem) { toast('请先选择一个物品', 'warning'); return; } const data = invSelectedItem; if (isGems(data)) { toast('该物品已经是宝石', 'info'); return; } invBatchRunning = true; setBatchButtonsDisabled(true); try { updateInvProgress(0, 1, '转换宝石中: ' + (data.name || data.market_hash_name)); const goo = await getGooValue(data); await turnIntoGems(data, goo); updateInvProgress(1, 1, '转换成功: 获得 ' + goo + ' 颗宝石'); toast('已转换为 ' + goo + ' 颗宝石', 'success'); if (data.element) data.element.style.opacity = '0.4'; } catch (e) { updateInvProgress(1, 1, '转换失败: ' + e.message); toast('转换为宝石失败: ' + e.message, 'error'); W('Turn into gems failed:', e.message); } finally { invBatchRunning = false; setBatchButtonsDisabled(false); } } // ==================== 交易报价页 ==================== let tradeObserver = null; let tradeLastSum = 0; /** 初始化交易报价页 */ function initTradeOfferPage() { L('Initializing trade offer page'); if (!settings.get('tradeOfferSummary')) return; // 等待交易报价 DOM 加载 const tryInit = (attempt) => { const tradeBox = document.querySelector('.trade_item_box'); if (tradeBox) { setupTradeObserver(); injectSelectAllButton(); setTimeout(updateTradeOfferSummary, 1000); } else if (attempt < 30) { setTimeout(() => tryInit(attempt + 1), 500); } }; tryInit(0); } /** 设置 MutationObserver 监听交易框变化 */ function setupTradeObserver() { if (tradeObserver) tradeObserver.disconnect(); tradeObserver = new MutationObserver(() => { setTimeout(updateTradeOfferSummary, 300); }); // 监听双方交易框 const boxes = document.querySelectorAll('.trade_item_box'); boxes.forEach(box => { tradeObserver.observe(box, { childList: true, subtree: true }); }); L('Trade observer set up on', boxes.length, 'boxes'); } /** 检查所有交易物品是否已加载 */ function hasLoadedAllTradeOfferItems() { try { const status = unsafeWindow.g_rgCurrentTradeStatus; if (!status) return false; const checkSide = (user, assets) => { if (!assets || !user) return true; for (const a of assets) { const item = user.findAsset(a.appid, a.contextid, a.assetid); if (!item) return false; } return true; }; return checkSide(unsafeWindow.UserYou, status.me?.assets) && checkSide(unsafeWindow.UserThem, status.them?.assets); } catch (e) { return false; } } /** * 汇总交易一方的物品 * @param {Array} assets - 资产数组 * @param {Object} user - UserYou 或 UserThem * @returns {{text: string, totalPrice: number, uniqueCount: number, totalCount: number}} */ function sumTradeOfferAssets(assets, user) { const grouped = {}; let totalPrice = 0; let totalCount = 0; if (!assets || !user) return { text: '', totalPrice: 0, uniqueCount: 0, totalCount: 0 }; for (const asset of assets) { let text = ''; try { const rgItem = user.findAsset(asset.appid, asset.contextid, asset.assetid); if (rgItem) { // 从 DOM 读取价格标签(库存页设置的 price_ class) if (rgItem.element) { const priceEl = rgItem.element.querySelector('[class*="price_"]'); if (priceEl) { const cls = priceEl.className || ''; const m = cls.match(/price_(\d+)/); if (m) totalPrice += parseInt(m[1], 10); } } // 处理堆叠物品的已用数量 if (rgItem.original_amount != null && rgItem.amount != null) { const used = parseInt(rgItem.original_amount) - parseInt(rgItem.amount); if (used > 0) text += `${used}x `; } text += rgItem.name || rgItem.market_hash_name || '未知物品'; if (rgItem.type && rgItem.type.length > 0) { text += ` (${rgItem.type})`; } } else { text = '未知物品'; } } catch (e) { text = '未知物品'; } grouped[text] = (grouped[text] || 0) + 1; totalCount++; } // 按数量降序排序 const sorted = Object.entries(grouped).sort((a, b) => b[1] - a[1]); let text = `唯一物品:${sorted.length} · 总价值:${formatCents(totalPrice)} · 物品总数:${totalCount}

`; for (const [name, count] of sorted) { text += `${count}x ${name}
`; } return { text, totalPrice, uniqueCount: sorted.length, totalCount }; } /** 更新交易报价双方摘要 */ function updateTradeOfferSummary() { try { if (!hasLoadedAllTradeOfferItems()) return; const status = unsafeWindow.g_rgCurrentTradeStatus; if (!status) return; const currentSum = (status.me?.assets?.length || 0) + (status.them?.assets?.length || 0); if (currentSum === tradeLastSum) return; // 无变化 tradeLastSum = currentSum; // 你的物品摘要 const yourSum = sumTradeOfferAssets(status.me?.assets, unsafeWindow.UserYou); const yourEl = document.querySelector('#trade_offer_your_sum'); if (yourEl) yourEl.remove(); if (yourSum.text) { const yourHeader = document.querySelector('.offerheader.left') || document.querySelector('#trade_offer_your'); if (yourHeader) { const el = h('div', { id: 'trade_offer_your_sum', class: 'sme-trade-summary', html: yourSum.text }); yourHeader.appendChild(el); } } // 对方物品摘要 const theirSum = sumTradeOfferAssets(status.them?.assets, unsafeWindow.UserThem); const theirEl = document.querySelector('#trade_offer_their_sum'); if (theirEl) theirEl.remove(); if (theirSum.text) { const theirHeader = document.querySelector('.offerheader.right') || document.querySelector('#trade_offer_their'); if (theirHeader) { const el = h('div', { id: 'trade_offer_their_sum', class: 'sme-trade-summary', html: theirSum.text }); theirHeader.appendChild(el); } } // 价值对比提示 if (yourSum.totalPrice > 0 || theirSum.totalPrice > 0) { const diff = yourSum.totalPrice - theirSum.totalPrice; let diffText = ''; if (diff > 0) { diffText = `你多付 ${formatCents(diff)}`; } else if (diff < 0) { diffText = `对方多付 ${formatCents(-diff)}`; } else { diffText = '双方价值相等'; } const diffEl = document.querySelector('#trade_offer_diff'); if (diffEl) diffEl.remove(); const yourSumEl = document.querySelector('#trade_offer_your_sum'); if (yourSumEl) { yourSumEl.appendChild(h('div', { id: 'trade_offer_diff', style: { marginTop: '6px', fontSize: '13px', fontWeight: '700', color: diff > 0 ? '#e24a4a' : (diff < 0 ? '#4caf50' : '#66c0f4') }, text: diffText, })); } } } catch (e) { W('Trade offer summary error:', e.message); } } /** 注入"选中页面全部物品"按钮 */ function injectSelectAllButton() { if (document.querySelector('#sme_select_all_page')) return; // 查找库存区域底部 const inventoryPage = document.querySelector('.inventory_page_tabs') || document.querySelector('.inventory_ctn'); if (!inventoryPage) return; // 检查是否为还价场景(需先点击"修改报价") const modifyBtn = document.querySelector('.modify_offer_btn'); if (modifyBtn && modifyBtn.offsetParent !== null) { // 还价场景,暂不注入 return; } const btn = h('a', { id: 'sme_select_all_page', class: 'sme-trade-btn', html: ICONS.selectAll + ' 选中页面全部物品', onClick: selectAllItemsOnPage, }); // 插入到库存页控制区域 const pageControl = document.querySelector('#inventory_pagecontrols'); if (pageControl) { pageControl.parentElement.insertBefore(btn, pageControl.nextSibling); } else { inventoryPage.parentElement.insertBefore(btn, inventoryPage); } } /** 选中当前可见库存页的所有可交易物品并移入交易 */ function selectAllItemsOnPage() { try { const holders = document.querySelectorAll('.inventory_ctn:visible > .inventory_page:visible > .itemHolder:visible, .inventory_ctn > .inventory_page:not([style*="none"]) > .itemHolder:not([style*="none"])'); let count = 0; const items = []; holders.forEach(holder => { const itemEl = holder.querySelector('.item'); if (!itemEl) return; const rgItem = itemEl.rgItem || getRgItemFromElement(itemEl); if (!rgItem) return; if (rgItem.is_stackable) return; if (!rgItem.tradable) return; items.push(holder); }); if (items.length === 0) { toast('当前页面无可交易物品', 'info'); return; } toast(`正在移入 ${items.length} 个物品...`, 'info'); // 逐个移入(250ms 间隔) let idx = 0; const moveNext = () => { if (idx >= items.length) { toast(`已移入 ${count} 个物品`, 'success'); return; } const holder = items[idx++]; try { if (unsafeWindow.MoveItemToTrade) { unsafeWindow.MoveItemToTrade(holder); count++; } } catch (e) {} setTimeout(moveNext, 250); }; moveNext(); } catch (e) { toast('选中全部物品失败: ' + e.message, 'error'); } } // ==================== 初始化 ==================== let initialized = false; let lastURL = ''; let urlCheckTimer = null; function init() { if (initialized) return; initialized = true; L('Initializing on', location.href); const sid = getSteamId(); if (sid) loadCacheForSession(sid); else W('Steam ID not detected, some features may not work'); const pageType = detectPageType(); L('Page type:', pageType); if (pageType === 'listing') { initListingPage(); } else if (pageType === 'browse') { initBrowsePage(); } else if (pageType === 'inventory') { initInventoryPage(); } else if (pageType === 'tradeoffer') { initTradeOfferPage(); } // URL 变化检测(Steam 市场使用 AJAX 导航) lastURL = location.href; urlCheckTimer = setInterval(() => { if (location.href !== lastURL) { lastURL = location.href; handleURLChange(); } }, 800); } function initListingPage() { listingInfo = getListingInfo(); L('Listing info:', listingInfo); // 详情页已拥有物品标识 if (settings.get('ownedBadges')) { setTimeout(processDetailPageBadge, 600); } // 等待 SSR 数据可用 const tryExtract = (attempt) => { const ssrData = extractSSRData(); if (ssrData.sellOrders !== null) { allSellOrders = ssrData.sellOrders; L(`Found ${allSellOrders.length} sell orders`); injectPanel(); if (settings.get('autoLoadHistory')) { // 预加载价格历史 if (listingInfo) { fetchPriceHistory(listingInfo.appid, listingInfo.marketHashName) .then(data => { priceHistoryData = data; }) .catch(() => {}); } } } else if (attempt < 20) { setTimeout(() => tryExtract(attempt + 1), 500); } else { L('No sell orders found after timeout, injecting panel anyway'); injectPanel(); } }; tryExtract(0); } function initBrowsePage() { if (settings.get('ownedBadges')) { setTimeout(processAllBrowseRows, 400); setupBrowseObserver(); } createFab(); initMyListingsEnhancement(); } function handleURLChange() { L('URL changed to', location.href); const pageType = detectPageType(); // 清理旧面板 if (panelEl) { panelEl.remove(); panelEl = null; } if (fabEl) { fabEl.remove(); fabEl = null; } if (fabPopup) { fabPopup.remove(); fabPopup = null; } if (browseObserver) { browseObserver.disconnect(); browseObserver = null; } if (invObserver) { invObserver.disconnect(); invObserver = null; } if (invToolbarEl) { invToolbarEl.remove(); invToolbarEl = null; } if (invPriceLabels) { invPriceLabels.clear(); } if (tradeObserver) { tradeObserver.disconnect(); tradeObserver = null; } cleanupMyListingsEnhancement(); allSellOrders = []; priceHistoryData = null; orderBookData = null; currentTab = 'summary'; priceCheckResults = null; priceCheckLoading = false; if (pageType === 'listing') { initListingPage(); } else if (pageType === 'browse') { initBrowsePage(); } else if (pageType === 'inventory') { initInventoryPage(); } else if (pageType === 'tradeoffer') { initTradeOfferPage(); } } // ==================== 启动(等待 URL 稳定) ==================== let stableSince = 0; let lastCheck = location.href; let domReady = false; function isOnMarket() { return /^\/market(?:\/|$)/.test(location.pathname) || /^\/(?:id|profiles)\/[^/]+\/inventory/.test(location.pathname) || /^\/tradeoffer/.test(location.pathname); } function tryStart() { if (!domReady) return; const now = Date.now(); const curURL = location.href; if (curURL !== lastCheck) { lastCheck = curURL; stableSince = now; return; } if (now - stableSince < 800) return; if (!isOnMarket()) return; clearInterval(stableTimer); init(); } let stableTimer; function onDOMReady() { domReady = true; stableSince = Date.now(); lastCheck = location.href; stableTimer = setInterval(tryStart, 250); setTimeout(() => { if (domReady && isOnMarket() && !initialized) { clearInterval(stableTimer); init(); } }, 2000); } if (document.readyState === 'interactive' || document.readyState === 'complete') { onDOMReady(); } else { document.addEventListener('DOMContentLoaded', onDOMReady); } })();