// ==UserScript== // @name Steam 社区市场增强 // @name:en Steam Community Market Enhancer // @namespace steam-market-enhancer // @version 1.7.3 // @description Steam社区市场增强:上架汇总分组、超价/低价检测标注、一键重新上架(自定义改价+assetid重获取+多级会话ID回退)、批量下架、价格历史趋势、订单深度可视化(全量多档直方图/税后到手/CNY到手/累计分布/档数可调/一键刷新)、已拥有物品标识、我的挂单排序搜索批量操作、库存页价格标签与快速出售、库存页自主定价快速出售(突破Steam定价限制/自定义到手价/多模式定价/批量自定义价格出售/选中物品分组定价)、交易报价价值摘要、补充包制作器(利润计算/批量做包/卡牌均价查询)、我的挂单市场顺序跟踪(新版React市场页/价格升序精确排名/售出自动刷新)、参考货币换算(多货币实时汇率/价格旁显示换算值)、市场物品页显示更多价格(新版React页面Hook注入/默认6行扩展/行数可调)、市场交易记录分析与导出(分页索引全量历史记录/收支与手续费估算/持有时长/概览多榜与趋势曲线·日历热力/CSV·TSV·JSON·Markdown导出)、Steam客户端详情页固定以底部按钮打开交易分析浮窗;浏览器默认顶部内嵌,可改为浮窗并记住选择。 // @description:en Steam Community Market Enhancer: Listing summary grouping, overpriced/underpriced detection & marking, one-click relisting (custom price + assetid re-acquisition + multi-level sessionid fallback), batch delisting, price history trends, order depth visualization (full multi-level order histogram / net-after-fees / CNY net / cumulative distribution / adjustable levels / one-click refresh), owned item identification, my listings sort/search/batch operations, inventory page price labels & quick sell value summary, my listing market rank tracker (new React market pages, exact price-ascending rank, auto refresh on sales), reference currency conversion (live exchange rates / converted prices shown alongside), show more price rows on market item pages (React hook injection on new React pages / expands the default 6 rows / configurable row count), market transaction history analysis & export (paged indexing of the full history / income-expense, estimated fees and holding duration / overview leaderboards plus trend curves and calendar heatmap / CSV, TSV, JSON and Markdown export), Steam client listing pages always open the trade analysis panel from a bottom floating button; browsers default to an inline top panel and can switch to the floating button, and that choice is remembered. // @author SmallRob // @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/* // @match https://steamcommunity.com/tradingcards/boostercreator* // @grant GM_addStyle // @grant GM_getValue // @grant GM_setValue // @grant GM_xmlhttpRequest // @grant unsafeWindow // @grant GM_info // @connect store.steampowered.com // @connect cdn.jsdelivr.net // @connect latest.currency-api.pages.dev // @require https://cdn.jsdelivr.net/gh/SmallRob/steam-namespace@main/libs/sglv-plugin-host.lib.js // @run-at document-start // run-at 说明:document-start 是 v1.5.0"显示更多价格"的硬性要求——需在页面 react-dom // 初始化之前向 window 注入假的 __REACT_DEVTOOLS_GLOBAL_HOOK__ 完成绑定;脚本主体逻辑 // 仍会等待 DOM ready + URL 稳定后再初始化(见文件末尾启动段),不受提前执行影响。 // @license MIT // @tag Steam // @tag market // @icon data:image/svg+xml, // ==/UserScript== (function () { // v2.32.0 插件版本清单注册(共享宿主 v0.3.0)—— 让 Steam AI Agent 能读到本插件版本号。 // ⚠️ 共享宿主缺失时静默跳过,绝不影响本插件主流程。 try { var __PH = (typeof unsafeWindow !== 'undefined' && unsafeWindow.__PLUGIN_HOST__) || (typeof window !== 'undefined' && window.__PLUGIN_HOST__); if (__PH && typeof __PH.registerPlugin === 'function') { __PH.registerPlugin({ id: 'sme', name: 'Steam 社区市场增强', family: 'market' }); } } catch (e) { /* ignore */ } 'use strict'; const VERSION = '1.7.3'; 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: 'var(--sme-blue-a18)', grid: 'rgba(255,255,255,0.05)', text: '#8f98a0', sell: 'var(--sme-red)', buy: 'var(--sme-green)', 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 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{ /* ================= v1.5.1 Design Tokens(P1-C UI token 化) ================= */ /* 色彩 · 表面 */ --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-track:rgba(255,255,255,0.06); /* 进度条/滑轨底色 */ /* 色彩 · 文字 */ --sme-text:#c7d5e0; --sme-text-dim:#8f98a0; --sme-text-muted:#5c6b7a; --sme-text-bright:#fff; /* 色彩 · 语义 */ --sme-blue:#66c0f4; --sme-green:#4caf50; --sme-red:#e24a4a; --sme-amber:#f59e0b; --sme-purple:#8b5cf6; /* 语义色 · 柔和背景(按钮/徽章底色) */ --sme-blue-soft:rgba(102,192,244,0.12); --sme-green-soft:rgba(76,175,80,0.15); --sme-red-soft:rgba(226,74,74,0.12); --sme-amber-soft:rgba(245,158,11,0.12); /* 蓝色透明度梯度:收敛全文件 20 余种内联 rgba(102,192,244,X) 写法 */ --sme-blue-a0:rgba(102,192,244,0); --sme-blue-a04:rgba(102,192,244,0.04); --sme-blue-a05:rgba(102,192,244,0.05); --sme-blue-a06:rgba(102,192,244,0.06); --sme-blue-a08:rgba(102,192,244,0.08); --sme-blue-a10:rgba(102,192,244,0.10); --sme-blue-a12:rgba(102,192,244,0.12); --sme-blue-a15:rgba(102,192,244,0.15); --sme-blue-a18:rgba(102,192,244,0.18); --sme-blue-a20:rgba(102,192,244,0.20); --sme-blue-a25:rgba(102,192,244,0.25); --sme-blue-a30:rgba(102,192,244,0.30); --sme-blue-a35:rgba(102,192,244,0.35); --sme-blue-a40:rgba(102,192,244,0.40); --sme-blue-a50:rgba(102,192,244,0.50); --sme-blue-a55:rgba(102,192,244,0.55); --sme-blue-a70:rgba(102,192,244,0.70); --sme-blue-a80:rgba(102,192,244,0.80); --sme-blue-a95:rgba(102,192,244,0.95); --sme-purple-a6:rgba(139,92,246,0.06); --sme-purple-a20:rgba(139,92,246,0.2); --sme-red-a6:rgba(226,74,74,0.06); --sme-red-a8:rgba(226,74,74,0.08); --sme-red-a10:rgba(226,74,74,0.1); --sme-red-a12:rgba(226,74,74,0.12); --sme-red-a15:rgba(226,74,74,0.15); --sme-red-a18:rgba(226,74,74,0.18); --sme-red-a20:rgba(226,74,74,0.2); --sme-red-a25:rgba(226,74,74,0.25); --sme-red-a30:rgba(226,74,74,0.3); --sme-red-a45:rgba(226,74,74,0.45); --sme-red-a60:rgba(226,74,74,0.6); --sme-red-a95:rgba(226,74,74,0.95); --sme-amber-a8:rgba(245,158,11,0.08); --sme-amber-a10:rgba(245,158,11,0.1); --sme-amber-a12:rgba(245,158,11,0.12); --sme-amber-a15:rgba(245,158,11,0.15); --sme-amber-a18:rgba(245,158,11,0.18); --sme-amber-a20:rgba(245,158,11,0.2); --sme-amber-a22:rgba(245,158,11,0.22); --sme-amber-a30:rgba(245,158,11,0.3); --sme-amber-a40:rgba(245,158,11,0.4); --sme-amber-a50:rgba(245,158,11,0.5); --sme-amber-a65:rgba(245,158,11,0.65); --sme-amber-a95:rgba(245,158,11,0.95); --sme-green-a10:rgba(76,175,80,0.1); --sme-green-a12:rgba(76,175,80,0.12); --sme-green-a15:rgba(76,175,80,0.15); --sme-green-a20:rgba(76,175,80,0.2); --sme-green-a22:rgba(76,175,80,0.22); --sme-green-a25:rgba(76,175,80,0.25); --sme-green-a28:rgba(76,175,80,0.28); --sme-green-a30:rgba(76,175,80,0.3); --sme-green-a40:rgba(76,175,80,0.4); --sme-green-a50:rgba(76,175,80,0.5); --sme-green-a60:rgba(76,175,80,0.6); --sme-green-a95:rgba(76,175,80,0.95); --sme-danger-grad:linear-gradient(180deg,#c44 0%,#a33 100%); --sme-danger-border:#a33; --sme-danger:#f87171; /* 边框 */ --sme-border:var(--sme-blue-a15); --sme-border-hi:var(--sme-blue-a35); /* 圆角:11 种收敛为 3 档 + 胶囊 */ --sme-radius-xs:4px; --sme-radius-sm:8px; --sme-radius:12px; --sme-radius-pill:999px; /* 间距:17 种收敛为 5 档(新代码使用,存量渐进迁移) */ --sme-space-1:4px; --sme-space-2:8px; --sme-space-3:12px; --sme-space-4:16px; --sme-space-5:24px; /* 层级:3 套 z-index 收敛 */ --sme-z-panel:500; /* 面板/浮层 */ --sme-z-overlay:9000; /* 模态遮罩/对话框 */ --sme-z-top:99999; /* Toast 顶层通知 */ --sme-transition:all 0.2s cubic-bezier(0.4,0,0.2,1); } /* ==== v1.5.1 规范按钮体系 .sme-btn(P1-C)==== 存量 22 个按钮类保持兼容运行,新代码一律使用本体系;存量类按模块逐步迁移: .sme-btn 默认(蓝色主操作) .sme-btn--sm 小尺寸(行内/表格按钮) .sme-btn--ghost 幽灵按钮(次要操作) .sme-btn--danger 危险操作(下架/删除) .sme-btn--success 确认操作(应用/出售) .sme-btn--warning 警示操作 */ .sme-btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:6px 14px;border:1px solid var(--sme-border-hi);border-radius:var(--sme-radius-sm);background:var(--sme-blue-soft);color:var(--sme-blue);font-size:12px;font-weight:600;line-height:1.4;font-family:inherit;cursor:pointer;user-select:none;transition:var(--sme-transition)} .sme-btn:hover{background:var(--sme-blue-a25);border-color:var(--sme-border-hi)} .sme-btn:active{transform:scale(0.97)} .sme-btn:disabled{opacity:.4;cursor:not-allowed;transform:none} .sme-btn--sm{padding:2px 8px;font-size:11px;border-radius:var(--sme-radius-xs)} .sme-btn--ghost{background:transparent;color:var(--sme-text-dim);border-color:var(--sme-border)} .sme-btn--ghost:hover{background:var(--sme-bg-hover);color:var(--sme-text)} .sme-btn--danger{background:var(--sme-red-soft);border-color:var(--sme-red-a25);color:var(--sme-red)} .sme-btn--danger:hover{background:var(--sme-red-a20)} .sme-btn--success{background:var(--sme-green-soft);border-color:var(--sme-green-a40);color:var(--sme-green)} .sme-btn--success:hover{background:var(--sme-green-a28)} .sme-btn--warning{background:var(--sme-amber-soft);border-color:var(--sme-amber-a30);color:var(--sme-amber)} .sme-btn--warning:hover{background:var(--sme-amber-a22)} .sme-btn .sme-svg{width:13px;height:13px} .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,var(--sme-blue-a08) 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:var(--sme-radius-sm);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:var(--sme-blue-a15);color:var(--sme-blue)} .sme-tab-btn{ width:28px;height:28px;border:none;border-radius:var(--sme-radius-sm);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;display:flex;align-items:baseline;gap:6px;white-space:nowrap;flex-wrap:wrap} .sme-stat-val-main{flex-shrink:0} .sme-stat-ref{font-size:12px;font-weight:600;color:var(--sme-text-dim);opacity:.75;flex-shrink:0} .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 var(--sme-blue-a06); } .sme-table tr:last-child td{border-bottom:none} .sme-table tr:hover td{background:var(--sme-blue-a04)} .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:var(--sme-blue-a08);border:1px solid var(--sme-blue-a15); border-radius:var(--sme-radius-xs);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:var(--sme-radius-sm);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,.sme-dialog-ok{ background:var(--sme-danger-grad);border:1px solid transparent;color:#fff; padding:6px 16px;border-radius:var(--sme-radius-sm);cursor:pointer;font-size:13px;font-weight:600; transition:var(--sme-transition); } .sme-dialog-ok{border-color:var(--sme-danger-border)} .sme-delist-btn:hover,.sme-dialog-ok:hover{opacity:0.85} .sme-delist-btn:disabled{opacity:0.4;cursor:not-allowed} .sme-delist-all-btn:hover{border-color:var(--sme-danger-a50)} .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)} /* v1.5.1 统一进度条:4 套填充实现(主面板/库存/挂单/补充包)收敛为单一基类,轨道各自保留高度 */ .sme-progress-bar,.sme-inv-progress-bar,.sme-ml-progress-bar,.sme-booster-progress-bar{height:100%;width:0;background:linear-gradient(90deg,var(--sme-blue),var(--sme-green));border-radius:var(--sme-radius-xs);transition:width .3s ease} .sme-progress-wrap{height:3px;background:var(--sme-track);border-radius:var(--sme-radius-xs);margin-top:8px;overflow:hidden} /* 价格图表 */ .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:var(--sme-radius-sm);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:var(--sme-blue-a15);color:var(--sme-blue);border-color:var(--sme-blue-a30)} /* 订单簿:左卖单、右买单。数量色条铺在行底,不占单独一列。 */ .sme-ob-board{ display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:14px 16px;align-items:start; } .sme-order-section{min-width:0;margin-bottom:0;overflow-x:auto} .sme-order-title{font-size:13px;font-weight:700;margin-bottom:6px;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-head,.sme-order-row{ position:relative;display:flex;align-items:center;gap:6px; padding:1px 6px;font-variant-numeric:tabular-nums;min-width:0; } .sme-order-head{ margin-bottom:2px;padding-bottom:4px;font-size:11px;font-weight:600;color:var(--sme-text-dim); border-bottom:1px solid var(--sme-blue-a12); } .sme-order-row{min-height:22px;font-size:12px} /* v1.6.9:订单簿行降级渲染。行高被 min-height:22px 钉死, contain-intrinsic-size 的 auto 前缀让浏览器自行记忆真实高度, 估算几乎无误差、滚动条不跳变。作用域限定 .sme-order-section, 绝不触碰 .sme-mh-table(表格元素不适用 content-visibility, 且其 th 用 position:sticky + 不透明背景,强行施加会破坏吸顶)。 */ .sme-order-section .sme-order-row{content-visibility:auto;contain-intrinsic-size:auto var(--sme-order-h,22px)} .sme-order-bar{ position:absolute;z-index:0;top:1px;bottom:1px;left:0;border-radius:3px; pointer-events:none;opacity:0.22; } .sme-order-row.sell .sme-order-bar{background:var(--sme-red)} .sme-order-row.buy .sme-order-bar{background:var(--sme-green)} .sme-order-head > span,.sme-order-row > span{position:relative;z-index:1} .sme-order-price{font-weight:650;white-space:nowrap} .sme-order-row.sell .sme-order-price{color:var(--sme-red)} .sme-order-row.buy .sme-order-price{color:var(--sme-green)} .sme-order-net{color:var(--sme-text);white-space:nowrap;font-size:11px} .sme-order-cny{color:var(--sme-amber);white-space:nowrap;font-size:11px} .sme-order-qty{margin-left:auto;color:var(--sme-text);font-weight:600;white-space:nowrap} .sme-order-head .sme-order-qty,.sme-order-head .sme-order-cum{color:var(--sme-text-dim);font-weight:600} .sme-order-cum{width:3.6em;text-align:right;color:var(--sme-text-dim);font-size:11px;white-space:nowrap;flex:0 0 auto} .sme-ob-empty{padding:12px 6px;color:var(--sme-text-dim);font-size:12px} @media (max-width:760px){ .sme-ob-board{grid-template-columns:minmax(0,1fr)} } .sme-ob-controls{ display:flex;align-items:center;gap:14px;flex-wrap:wrap;padding:8px 12px;margin-bottom:12px; background:var(--sme-blue-a05);border:1px solid var(--sme-border);border-radius:var(--sme-radius-sm); font-size:12px;color:var(--sme-text-dim); } .sme-ob-ctl{display:flex;align-items:center;gap:6px;cursor:pointer;user-select:none;white-space:nowrap} .sme-ob-ctl:hover{color:var(--sme-text)} .sme-ob-ctl input[type=checkbox]{accent-color:var(--sme-blue);cursor:pointer;margin:0} .sme-ob-slider{width:130px;accent-color:var(--sme-blue);cursor:pointer;padding:0} .sme-ob-levels-val{min-width:56px;color:var(--sme-text);font-weight:600;font-variant-numeric:tabular-nums} .sme-ob-refresh{ margin-left:auto;display:inline-flex;align-items:center;gap:4px;padding:3px 10px; border:1px solid var(--sme-border);background:transparent;color:var(--sme-text-dim); border-radius:var(--sme-radius-sm);cursor:pointer;font-size:12px;font-weight:600;transition:var(--sme-transition); } .sme-ob-refresh:hover{color:var(--sme-blue);border-color:var(--sme-border-hi)} .sme-ob-refresh:disabled{opacity:0.5;cursor:default} .sme-ob-total{font-size:12px;color:var(--sme-text-dim);padding:6px 2px 0;font-variant-numeric:tabular-nums} .sme-ob-total b{color:var(--sme-text)} .sme-spread-box{ display:flex;align-items:center;justify-content:center;gap:12px;padding:10px;margin:8px 0; background:var(--sme-blue-a06);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 var(--sme-blue-a06); } .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:var(--sme-radius);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:var(--sme-radius-sm);font-size:13px; } /* v1.5.1 按钮去重:玫瑰红次要危险按钮(批量下架全部 / 危险操作)共享一条规则 */ .sme-delist-all-btn,.sme-danger-btn{ background:var(--sme-danger-soft);border:1px solid var(--sme-danger-a30);color:var(--sme-danger); padding:6px 14px;border-radius:var(--sme-radius-sm);cursor:pointer;font-size:13px;font-weight:600;transition:var(--sme-transition); } .sme-danger-btn{padding:7px 16px} .sme-danger-btn:hover,.sme-delist-all-btn:hover{background:var(--sme-danger-a20)} .sme-info-box{margin-top:16px;padding:12px;background:var(--sme-blue-a05);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 —— v1.5.0:改为堆栈式(最多 3 条并存),修复批量操作时后一条顶掉前一条的问题 */ .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:var(--sme-radius-pill);font-size:13px;font-weight:500; box-shadow:0 4px 16px rgba(0,0,0,0.3);z-index:var(--sme-z-top);opacity:0;pointer-events:none; transition:opacity 0.3s,transform 0.3s; max-width:min(520px,calc(100vw - 32px));text-align:center; } .sme-toast.sme-show{opacity:1;transform:translateX(-50%) translateY(0)} .sme-toast.sme-info{border-color:var(--sme-blue)} .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-toast[data-stack="1"]{bottom:104px} .sme-toast[data-stack="2"]{bottom:158px} /* 价格状态标注 */ .sme-price-tag.overpriced{background:var(--sme-red-a12);border-color:var(--sme-red-a30);color:var(--sme-red)} .sme-price-tag.underpriced{background:var(--sme-amber-a12);border-color:var(--sme-amber-a30);color:var(--sme-amber)} .sme-price-tag.fair{background:var(--sme-green-a12);border-color:var(--sme-green-a30);color:var(--sme-green)} .sme-price-tag.unchecked{background:var(--sme-blue-a08);border-color:var(--sme-blue-a15)} .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:var(--sme-blue-a06);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} /* v1.4.9:参考货币换算价格 */ .sme-ref-price{display:inline-block;margin-left:6px;font-size:12px;color:var(--sme-text-dim);font-weight:500;white-space:nowrap} .sme-ref-price::before{content:'≈';margin-right:3px;opacity:0.7} .sme-ref-price.loading{opacity:0.5} .sme-ref-price.error{display:none} .sme-ref-net{opacity:.7;margin-left:2px} /* 新版 React 页面适配:使用页面 CSS 变量 */ [style*="--color-text-body-title"] ~ .sme-ref-price, [class*="IokSIloSPlA"] + .sme-ref-price{ color: var(--color-text-body-subtitle, #8f98a0); font-size: 13px; } /* 重新上架区域 */ .sme-relist-section{margin-top:12px;padding:12px;background:var(--sme-purple-a6);border:1px solid var(--sme-purple-a20);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:var(--sme-radius-sm);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-dialog-overlay{ position:fixed;top:0;left:0;right:0;bottom:0;z-index:var(--sme-z-top); background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center; animation:sme-fade-in 0.15s ease; } .sme-dialog-box{ background:linear-gradient(180deg,rgba(27,40,56,0.98) 0%,rgba(35,38,46,0.98) 100%); border:1px solid var(--sme-border-hi);border-radius:var(--sme-radius); padding:24px 28px;max-width:420px;width:90%; box-shadow:0 8px 32px rgba(0,0,0,0.5);font-family:"Motiva Sans",system-ui,sans-serif; } .sme-dialog-title{color:var(--sme-blue);font-size:15px;font-weight:700;margin-bottom:12px} .sme-dialog-body{color:var(--sme-text);font-size:13px;line-height:1.6;margin-bottom:20px} .sme-dialog-body strong{color:var(--sme-text-bright)} .sme-dialog-actions{display:flex;gap:10px;justify-content:flex-end} .sme-dialog-btn{ padding:7px 20px;border-radius:var(--sme-radius-sm);font-size:13px;font-weight:600;cursor:pointer; border:1px solid var(--sme-border);transition:var(--sme-transition); } .sme-dialog-cancel{background:rgba(255,255,255,0.06);color:var(--sme-text)} .sme-dialog-cancel:hover{background:rgba(255,255,255,0.12)} .sme-dialog-blue{background:linear-gradient(180deg,var(--sme-blue) 0%,#417a9b 100%);color:#fff;border-color:#417a9b} .sme-dialog-blue:hover{opacity:0.85} .sme-dialog-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px} .sme-dialog-header .sme-dialog-title{margin-bottom:0} .sme-dialog-close{background:none;border:none;color:var(--sme-text-dim);font-size:20px;cursor:pointer;padding:0 4px;line-height:1} .sme-dialog-close:hover{color:var(--sme-text)} .sme-dialog-footer{display:flex;gap:10px;justify-content:flex-end;margin-top:16px} .sme-dialog-footer button{padding:7px 20px;border-radius:var(--sme-radius-sm);font-size:13px;font-weight:600;cursor:pointer;border:1px solid var(--sme-border);transition:var(--sme-transition)} .sme-price-input{ background:rgba(30,41,59,0.6);border:1px solid var(--sme-border);color:var(--sme-text); padding:7px 12px;border-radius:var(--sme-radius-sm);font-size:14px;width:100%;box-sizing:border-box; text-align:center;transition:var(--sme-transition); } .sme-price-input:focus{outline:none;border-color:var(--sme-border-hi)} .sme-price-grid{display:grid;grid-template-columns:1fr 1fr;column-gap:14px;row-gap:6px;margin-bottom:10px} .sme-price-label{color:var(--sme-text-dim);font-size:12px;white-space:nowrap} /* 设置:定价算法 */ .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:var(--sme-radius-sm);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 var(--sme-blue-a15); 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:var(--sme-blue-a12);border:1px solid var(--sme-blue-a20); color:var(--sme-blue);font-size:11px;font-weight:700;padding:1px 6px;border-radius:var(--sme-radius-xs);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:var(--sme-z-panel); 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:var(--sme-z-panel);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} /* 详情页:底部交易分析按钮 + 浮窗(Steam 客户端内嵌页裁掉顶部面板时使用) */ .sme-analysis-fab{ z-index:var(--sme-z-overlay); background:linear-gradient(135deg,#22d3ee 0%,#2563eb 100%); box-shadow:0 4px 20px rgba(37,99,235,0.45); } .sme-analysis-fab:hover{box-shadow:0 8px 30px rgba(37,99,235,0.65)} .sme-analysis-fab.sme-on{outline:2px solid rgba(255,255,255,.4);outline-offset:2px} .sme-analysis-pop{ position:fixed;right:24px;bottom:76px;z-index:var(--sme-z-overlay); width:min(980px,calc(100vw - 32px));max-height:min(82vh,880px); display:none;flex-direction:column; } .sme-analysis-pop.sme-show{display:flex;animation:sme-fade-in 0.2s ease} .sme-analysis-pop .sme-panel{ margin:0;width:100%;max-height:min(82vh,880px); display:flex;flex-direction:column; } .sme-analysis-pop .sme-header{flex:0 0 auto} .sme-analysis-pop .sme-content{ flex:1 1 auto;min-height:0;overflow:auto;overscroll-behavior:contain; } .sme-analysis-close{margin-left:4px;flex-shrink:0} /* ===== 库存页价格标签 ===== */ .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:var(--sme-blue)} .sme-inv-price.sme-inf{color:var(--sme-text-dim)} /* ===== 库存页工具栏 ===== */ .sme-inv-toolbar{ display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:8px 12px;margin:4px 0; background:linear-gradient(135deg,var(--sme-blue-a06) 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 var(--sme-blue-a25); background:var(--sme-blue-a08);color:var(--sme-text);font-size:12px;font-weight:600; border-radius:var(--sme-radius-sm);cursor:pointer;transition:var(--sme-transition);white-space:nowrap;font-family:inherit; } .sme-inv-btn:hover{background:var(--sme-blue-a18);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:var(--sme-red-a10);border-color:var(--sme-red-a30);color:var(--sme-red)} .sme-inv-btn.sme-danger:hover{background:var(--sme-red-a20)} .sme-inv-btn .sme-svg{width:13px;height:13px} .sme-inv-progress{ flex:1;min-width:120px;height:6px;background:var(--sme-track);border-radius:var(--sme-radius-xs);overflow:hidden; } .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:var(--sme-green-a12);color:var(--sme-green); font-size:11px;font-weight:700;border-radius:var(--sme-radius-xs);cursor:pointer;transition:var(--sme-transition);font-family:inherit; } .sme-qs-btn:hover{background:var(--sme-green-a25)} .sme-qs-btn.sme-start{border-color:var(--sme-blue);background:var(--sme-blue-a12);color:var(--sme-blue)} .sme-qs-btn.sme-start:hover{background:var(--sme-blue-a25)} .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:var(--sme-radius-xs);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:var(--sme-red)} .sme-qs-book-row.sme-buy{color:var(--sme-green)} /* ===== 快速出售面板增强 (1.4.5) ===== */ .sme-qs-modes{display:flex;flex-direction:column;gap:4px;margin:6px 0} .sme-qs-mode{display:flex;align-items:center;gap:6px;padding:4px 8px;border:1px solid transparent;border-radius:var(--sme-radius-xs);cursor:pointer;transition:var(--sme-transition);font-size:11px;color:var(--sme-text-dim)} .sme-qs-mode:hover{background:var(--sme-blue-a06);color:var(--sme-text)} .sme-qs-mode.active{border-color:var(--sme-border-hi);background:var(--sme-blue-a10);color:var(--sme-blue);font-weight:600} .sme-qs-mode input[type="radio"]{accent-color:var(--sme-blue);margin:0} .sme-qs-mode-label{flex:1} .sme-qs-mode-price{color:var(--sme-text-dim);font-variant-numeric:tabular-nums} .sme-qs-custom-row{display:flex;align-items:center;gap:6px;margin:4px 0} .sme-qs-custom-row .sme-qs-input{flex:1;width:auto;min-width:80px} .sme-qs-fee-info{font-size:11px;color:var(--sme-text-dim);margin:6px 0;padding:4px 8px;background:rgba(0,0,0,0.2);border-radius:var(--sme-radius-xs);line-height:1.6} .sme-qs-fee-info .sme-qs-fee-buyer{color:var(--sme-red)} .sme-qs-fee-info .sme-qs-fee-received{color:var(--sme-green)} .sme-qs-warning{font-size:11px;padding:4px 8px;border-radius:var(--sme-radius-xs);margin:4px 0;line-height:1.4} .sme-qs-warning.sme-warn-mid{background:var(--sme-amber-a12);border:1px solid var(--sme-amber-a30);color:var(--sme-amber)} .sme-qs-warning.sme-warn-high{background:var(--sme-red-a12);border:1px solid var(--sme-red-a30);color:var(--sme-red)} .sme-qs-sell-btn{padding:6px 16px;border:none;border-radius:var(--sme-radius-sm);background:linear-gradient(180deg,var(--sme-green) 0%,#388e3c 100%);color:#fff;font-size:12px;font-weight:700;cursor:pointer;transition:opacity 0.15s;font-family:inherit;margin-top:4px} .sme-qs-sell-btn:hover{opacity:0.85} .sme-qs-sell-btn:disabled{opacity:0.4;cursor:not-allowed} .sme-qs-btn-row{display:flex;gap:6px;margin-top:4px} .sme-qs-btn-row .sme-qs-sell-btn{flex:1;margin-top:0} .sme-qs-multi-btn{padding:6px 14px;border:1px solid var(--sme-border-hi);border-radius:var(--sme-radius-sm);background:var(--sme-blue-a10);color:var(--sme-blue);font-size:12px;font-weight:600;cursor:pointer;transition:var(--sme-transition);font-family:inherit;white-space:nowrap} .sme-qs-multi-btn:hover{background:var(--sme-blue-a20)} .sme-qs-multi-btn:disabled{opacity:0.4;cursor:not-allowed} .sme-batch-qty-input{width:70px;text-align:center} /* 自主定价批量出售对话框 */ .sme-batch-sell-dialog{max-width:400px} .sme-batch-sell-dialog .sme-dialog-body{padding:16px} .sme-batch-sell-row{display:flex;align-items:center;gap:8px;margin:8px 0} .sme-batch-sell-row label{font-size:13px;color:var(--sme-text-dim);min-width:70px} .sme-batch-sell-select,.sme-batch-sell-input{ background:rgba(30,41,59,0.6);border:1px solid var(--sme-border);color:var(--sme-text); padding:5px 8px;border-radius:var(--sme-radius-sm);font-size:13px;font-family:inherit; } .sme-batch-sell-select:focus,.sme-batch-sell-input:focus{outline:none;border-color:var(--sme-border-hi)} .sme-batch-sell-input{width:100px;text-align:center} .sme-batch-sell-summary{font-size:12px;color:var(--sme-text-dim);margin:8px 0;line-height:1.6} .sme-batch-sell-summary strong{color:var(--sme-text-bright)} /* 多件出售页增强 */ .sme-ms-panel{background:rgba(0,0,0,0.4);border:1px solid var(--sme-border);border-radius:var(--sme-radius-sm);padding:12px;margin:8px 0 16px} .sme-ms-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px} .sme-ms-title{font-size:14px;font-weight:700;color:var(--sme-blue)} .sme-ms-status{font-size:11px;color:var(--sme-text-dim)} .sme-ms-controls{display:flex;flex-wrap:wrap;gap:8px;align-items:center} .sme-ms-mode-row{display:flex;align-items:center;gap:6px} .sme-ms-select{background:var(--sme-bg);border:1px solid var(--sme-border-hi);border-radius:var(--sme-radius-xs);color:var(--sme-text);padding:4px 8px;font-size:12px} .sme-ms-price-row{display:flex;align-items:center;gap:6px} .sme-ms-price-input{width:90px;background:var(--sme-bg);border:1px solid var(--sme-border-hi);border-radius:var(--sme-radius-xs);color:var(--sme-text);padding:4px 8px;font-size:12px} .sme-ms-fee-info{font-size:11px;color:var(--sme-text-dim);padding:4px 8px;background:rgba(0,0,0,0.2);border-radius:var(--sme-radius-xs)} .sme-ms-apply-btn{padding:5px 16px;border:1px solid var(--sme-green);border-radius:var(--sme-radius-xs);background:var(--sme-green-a15);color:var(--sme-green);font-size:12px;font-weight:600;cursor:pointer;font-family:inherit} .sme-ms-apply-btn:hover{background:var(--sme-green-a25)} .sme-multisell-btn:not(:disabled){border-color:var(--sme-blue);background:var(--sme-blue-a12);color:var(--sme-blue)} /* 选中物品分组定价 */ .sme-sel-group{border:1px solid var(--sme-border);border-radius:var(--sme-radius-sm);padding:10px;margin:8px 0} .sme-sell-selected{position:relative} .sme-sell-selected:not(:disabled){border-color:var(--sme-green);background:var(--sme-green-a12);color:var(--sme-green)} .sme-sell-selected:not(:disabled):hover{background:var(--sme-green-a25)} /* ===== 市场我的挂单批量操作 ===== */ .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 var(--sme-blue-a25); background:var(--sme-blue-a06);color:var(--sme-text);font-size:12px;font-weight:600; border-radius:var(--sme-radius-xs);cursor:pointer;transition:var(--sme-transition);white-space:nowrap;font-family:inherit; } .sme-market-btn:hover{background:var(--sme-blue-a15);border-color:var(--sme-border-hi);color:var(--sme-text-bright)} .sme-market-btn.sme-danger{background:var(--sme-red-a8);border-color:var(--sme-red-a25);color:var(--sme-red)} .sme-market-btn.sme-danger:hover{background:var(--sme-red-a18)} .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:var(--sme-radius-xs);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 var(--sme-blue-a25); background:var(--sme-blue-a08);color:var(--sme-text);font-size:12px;font-weight:600; border-radius:var(--sme-radius-sm);cursor:pointer;transition:var(--sme-transition);font-family:inherit;margin:4px 0; } .sme-trade-btn:hover{background:var(--sme-blue-a18);border-color:var(--sme-border-hi)} .sme-trade-btn .sme-svg{width:13px;height:13px} /* ==================== 补充包制作器页面 ==================== */ .sme-booster-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-booster-header{ display:flex;align-items:center;gap:10px;padding:12px 16px;flex-wrap:wrap; background:linear-gradient(135deg,var(--sme-blue-a08) 0%,rgba(23,26,33,0.6) 100%); border-bottom:1px solid var(--sme-border); } .sme-booster-title{display:flex;align-items:center;gap:8px;font-size:15px;font-weight:700;color:var(--sme-text-bright);white-space:nowrap} .sme-booster-title svg{width:20px;height:20px;flex-shrink:0;color:var(--sme-blue)} .sme-booster-gems{display:inline-flex;align-items:center;gap:4px;padding:4px 10px;background:var(--sme-blue-a10);border:1px solid var(--sme-blue-a20);border-radius:var(--sme-radius-sm);font-size:13px;color:var(--sme-blue);font-weight:600} .sme-booster-gems svg{width:14px;height:14px;flex-shrink:0} /* ---- 操作说明提示条 ---- */ .sme-booster-hints{ display:flex;align-items:center;gap:14px;padding:7px 16px;flex-wrap:wrap; background:var(--sme-blue-a04);border-bottom:1px solid var(--sme-blue-a06); font-size:11px;color:var(--sme-text-dim);line-height:1.4; } .sme-booster-hints b{color:var(--sme-text);font-weight:600} .sme-booster-hint-item{display:inline-flex;align-items:center;gap:4px;white-space:nowrap} .sme-booster-hint-icon{width:18px;height:18px;display:inline-flex;align-items:center;justify-content:center;border-radius:var(--sme-radius-xs);flex-shrink:0} .sme-booster-hint-icon svg{width:11px;height:11px} /* ---- 工具栏 ---- */ .sme-booster-toolbar{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:10px 16px;border-bottom:1px solid var(--sme-blue-a08)} .sme-booster-tb-group{display:flex;align-items:center;gap:5px;flex-wrap:nowrap} .sme-booster-tb-sep{width:1px;height:22px;background:var(--sme-blue-a12);margin:0 3px;flex-shrink:0} .sme-booster-search{ background:rgba(49,64,77,0.6);border:1px solid var(--sme-border);color:var(--sme-text); padding:5px 10px 5px 28px;border-radius:var(--sme-radius-sm);font-size:13px;width:180px;outline:none;transition:border-color 0.2s,box-shadow 0.2s; background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%238899a6' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:8px center; } .sme-booster-search:focus{border-color:var(--sme-border-hi);box-shadow:0 0 0 2px var(--sme-blue-a10)} .sme-booster-search:focus-visible{outline:none;border-color:var(--sme-border-hi);box-shadow:0 0 0 2px var(--sme-blue-a15)} .sme-booster-select{ background:rgba(49,64,77,0.6);border:1px solid var(--sme-border);color:var(--sme-text); padding:5px 10px;border-radius:var(--sme-radius-sm);font-size:13px;cursor:pointer;outline:none; } .sme-booster-select:focus-visible{border-color:var(--sme-border-hi);box-shadow:0 0 0 2px var(--sme-blue-a15)} /* ---- 按钮层级系统 ---- */ .sme-booster-btn{ display:inline-flex;align-items:center;gap:5px;padding:6px 12px;border:1px solid var(--sme-blue-a25); background:var(--sme-blue-a08);color:var(--sme-text);font-size:12px;font-weight:600; border-radius:var(--sme-radius-sm);cursor:pointer;transition:background 0.2s,border-color 0.2s,transform 0.1s;font-family:inherit;white-space:nowrap; } .sme-booster-btn:hover{background:var(--sme-blue-a18);border-color:var(--sme-border-hi)} .sme-booster-btn:active{transform:scale(0.97)} .sme-booster-btn:disabled{opacity:0.4;cursor:default;transform:none} .sme-booster-btn:focus-visible{outline:none;box-shadow:0 0 0 2px var(--sme-blue-a30)} .sme-booster-btn .sme-svg{width:14px;height:14px;flex-shrink:0} .sme-booster-btn-green{background:var(--sme-green-a15);border-color:var(--sme-green-a40);color:#5cb85c;font-size:13px;padding:7px 16px} .sme-booster-btn-green:hover{background:var(--sme-green-a28);border-color:var(--sme-green-a60);box-shadow:0 2px 12px var(--sme-green-a15)} .sme-booster-btn-red{background:var(--sme-red-a10);border-color:var(--sme-red-a25);color:var(--sme-red);font-size:11px;padding:4px 10px} .sme-booster-btn-red:hover{background:var(--sme-red-a20)} .sme-booster-btn-amber{background:var(--sme-amber-a12);border-color:var(--sme-amber-a30);color:var(--sme-amber)} .sme-booster-btn-amber:hover{background:var(--sme-amber-a22)} /* ---- 表格 ---- */ .sme-booster-content{overflow-x:auto;overflow-y:visible} .sme-booster-table{width:100%;border-collapse:collapse;font-size:13px} .sme-booster-table th{ color:var(--sme-text-dim);text-align:left;padding:8px 10px;font-size:11px;font-weight:600; border-bottom:1px solid var(--sme-border);text-transform:uppercase;letter-spacing:0.5px;cursor:pointer; user-select:none;white-space:nowrap;position:sticky;top:0;background:rgba(29,31,35,0.98);z-index:1; } .sme-booster-table th:hover{color:var(--sme-blue)} .sme-booster-table th.sme-no-sort{cursor:default} .sme-booster-table th.sme-no-sort:hover{color:var(--sme-text-dim)} .sme-booster-table td{padding:5px 8px;border-bottom:1px solid var(--sme-blue-a06);color:var(--sme-text);vertical-align:middle} .sme-booster-table tr:hover td{background:var(--sme-blue-a04)} .sme-booster-table tr.sme-done td{opacity:0.45} .sme-booster-table tr.sme-done td.sme-booster-name{text-decoration:line-through} .sme-booster-name{color:var(--sme-text-bright);font-weight:500;max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .sme-booster-name a{color:inherit;text-decoration:none} .sme-booster-name a:hover{color:var(--sme-blue);text-decoration:underline} .sme-booster-profit-positive{color:var(--sme-green);font-weight:700} .sme-booster-profit-negative{color:var(--sme-red);font-weight:600} .sme-booster-profit-neutral{color:var(--sme-text-dim)} /* ---- 状态徽章 ---- */ .sme-booster-badge{display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border-radius:var(--sme-radius);font-size:11px;font-weight:600;white-space:nowrap} .sme-booster-badge::before{content:'';width:6px;height:6px;border-radius:50%;flex-shrink:0} .sme-booster-badge-available{background:var(--sme-green-a12);color:var(--sme-green)} .sme-booster-badge-available::before{background:var(--sme-green);box-shadow:0 0 4px var(--sme-green-a50)} .sme-booster-badge-cooldown{background:var(--sme-amber-a10);color:var(--sme-amber)} .sme-booster-badge-cooldown::before{background:var(--sme-amber)} .sme-booster-badge-done{background:rgba(136,153,166,0.1);color:var(--sme-text-dim)} .sme-booster-badge-done::before{background:var(--sme-text-dim)} /* ---- 操作按钮(重新设计:纯图标固定尺寸,状态变色) ---- */ .sme-booster-actions{display:inline-flex;gap:4px;align-items:center;flex-wrap:nowrap} .sme-booster-action-btn{ display:inline-flex;align-items:center;justify-content:center; width:28px;height:28px;padding:0;border:1.5px solid transparent;border-radius:var(--sme-radius-sm); cursor:pointer;font-family:inherit;white-space:nowrap; transition:background 0.15s,border-color 0.15s,transform 0.1s,box-shadow 0.15s;line-height:1; flex-shrink:0; } .sme-booster-action-btn:hover{transform:translateY(-1px);box-shadow:0 2px 8px rgba(0,0,0,0.2)} .sme-booster-action-btn:active{transform:translateY(0)} .sme-booster-action-btn:focus-visible{outline:none;box-shadow:0 0 0 2px var(--sme-blue-a30)} .sme-booster-action-btn svg{width:15px;height:15px;flex-shrink:0} /* 队列:未加入=绿色边框,已加入=蓝色填充 */ .sme-booster-action-queue{background:var(--sme-green-a10);color:var(--sme-green);border-color:var(--sme-green-a30)} .sme-booster-action-queue:hover{background:var(--sme-green-a22);border-color:var(--sme-green-a50)} .sme-booster-action-queue.active{background:var(--sme-blue-a25);color:var(--sme-blue);border-color:var(--sme-blue-a50);box-shadow:0 0 6px var(--sme-blue-a20)} .sme-booster-action-queue.active:hover{background:var(--sme-blue-a35);border-color:var(--sme-blue-a70)} /* 收藏:未收藏=空心星,已收藏=实心星 */ .sme-booster-action-fav{background:var(--sme-amber-a8);color:var(--sme-amber);border-color:var(--sme-amber-a20)} .sme-booster-action-fav:hover{background:var(--sme-amber-a18);border-color:var(--sme-amber-a40)} .sme-booster-action-fav.active{background:var(--sme-amber-a20);color:var(--sme-amber);border-color:var(--sme-amber-a50);box-shadow:0 0 6px var(--sme-amber-a15)} .sme-booster-action-fav.active:hover{background:var(--sme-amber-a30);border-color:var(--sme-amber-a65)} /* 拉黑:未拉黑=红色边框,已拉黑=红色填充 */ .sme-booster-action-black{background:var(--sme-red-a6);color:var(--sme-red);border-color:var(--sme-red-a15)} .sme-booster-action-black:hover{background:var(--sme-red-a15);border-color:var(--sme-red-a30)} .sme-booster-action-black.active{background:var(--sme-red-a20);color:var(--sme-red);border-color:var(--sme-red-a45);box-shadow:0 0 6px var(--sme-red-a15)} .sme-booster-action-black.active:hover{background:var(--sme-red-a30);border-color:var(--sme-red-a60)} /* ---- 空数据提示 ---- */ .sme-booster-cell-empty{color:var(--sme-text-dim);font-size:12px;opacity:0.6} .sme-booster-cell-prompt{color:var(--sme-blue-a70);font-size:11px;font-style:italic} /* v1.5.1 P2-C: 对比度 2.6:1 → ≥4.5:1 */ /* ---- 链接 ---- */ .sme-booster-link-group{display:inline-flex;align-items:center;gap:6px;flex-wrap:nowrap;white-space:nowrap} .sme-booster-link-item{display:inline-flex;align-items:center;gap:3px;font-size:11px;color:var(--sme-blue);text-decoration:none;padding:2px 6px;border-radius:var(--sme-radius-xs);transition:background 0.15s;white-space:nowrap;flex-shrink:0} .sme-booster-link-item:hover{background:var(--sme-blue-a10);text-decoration:none} .sme-booster-link-item svg{width:12px;height:12px;flex-shrink:0} /* ---- 横版封面 ---- */ .sme-booster-cover{width:184px;height:69px;border-radius:var(--sme-radius-xs);object-fit:cover;background:rgba(49,64,77,0.4);display:block;flex-shrink:0} .sme-booster-cover-cell{padding:4px 6px!important} .sme-booster-cover-failed{opacity:0.3;filter:grayscale(1)} .sme-booster-cover-wrapper{position:relative;width:184px;height:69px;border-radius:var(--sme-radius-xs);overflow:hidden;background:rgba(29,31,35,0.6);flex-shrink:0} .sme-booster-cover-wrapper img{width:100%;height:100%;object-fit:cover;display:block;transition:opacity 0.2s} .sme-booster-cover-wrapper .sme-booster-cover-placeholder{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:10px;color:var(--sme-text-dim);background:rgba(29,31,35,0.8)} /* ---- 进度条 & 信息栏 & 分页 ---- */ .sme-booster-progress{height:4px;background:rgba(49,64,77,0.6);border-radius:var(--sme-radius-xs);overflow:hidden;margin-top:8px;display:none} .sme-booster-progress.sme-visible{display:block} .sme-booster-info-bar{display:flex;align-items:center;gap:16px;padding:8px 16px;font-size:12px;color:var(--sme-text-dim);border-top:1px solid var(--sme-border);flex-wrap:wrap} .sme-booster-info-bar strong{color:var(--sme-text-bright)} .sme-booster-info-bar .sme-booster-profit-positive{font-size:13px} .sme-booster-pagination{display:flex;align-items:center;gap:8px;justify-content:center;padding:10px 16px} .sme-booster-pagination button{ padding:4px 10px;border:1px solid var(--sme-border);background:rgba(49,64,77,0.4);color:var(--sme-text); border-radius:var(--sme-radius-xs);cursor:pointer;font-size:12px;transition:border-color 0.2s,color 0.2s; } .sme-booster-pagination button:hover{border-color:var(--sme-border-hi);color:var(--sme-blue)} .sme-booster-pagination button:disabled{opacity:0.3;cursor:default} .sme-booster-pagination button:focus-visible{outline:none;box-shadow:0 0 0 2px var(--sme-blue-a30)} .sme-booster-pagination .sme-page-info{color:var(--sme-text-dim);font-size:12px;min-width:80px;text-align:center;font-variant-numeric:tabular-nums} .sme-booster-spinner{display:inline-block;border:2px solid var(--sme-blue-a20);border-top-color:var(--sme-blue);border-radius:50%;animation:sme-spin 0.7s linear infinite} .sme-booster-empty{padding:40px;text-align:center;color:var(--sme-text-dim);font-size:13px} .sme-booster-toast{position:fixed;top:20px;right:20px;z-index:var(--sme-z-top);padding:10px 16px;border-radius:var(--sme-radius-sm);font-size:13px;font-family:"Motiva Sans",system-ui,sans-serif;opacity:0;transform:translateX(20px);transition:opacity 0.3s,transform 0.3s;pointer-events:none;max-width:360px} .sme-booster-toast.sme-show{opacity:1;transform:translateX(0)} .sme-booster-toast.sme-success{background:var(--sme-green-a95);color:#fff} .sme-booster-toast.sme-error{background:var(--sme-red-a95);color:#fff} .sme-booster-toast.sme-info{background:var(--sme-blue-a95);color:#fff} .sme-booster-toast.sme-warning{background:var(--sme-amber-a95);color:#fff} @media(prefers-reduced-motion:reduce){ .sme-booster-action-btn,.sme-booster-btn,.sme-booster-pagination button{transition:none!important;transform:none!important} .sme-booster-progress-bar{transition:none!important} .sme-booster-toast{transition:opacity 0.1s!important} } `; GM_addStyle(CSS); // ==================== v1.4.6 市场顺序跟踪 CSS ==================== GM_addStyle(` .sme-mr-panel{position:fixed;right:16px;bottom:16px;z-index:var(--sme-z-overlay);width:250px;background:rgba(22,28,36,.97);border:1px solid var(--sme-blue-a35);border-radius:var(--sme-radius);box-shadow:0 6px 24px rgba(0,0,0,.55);color:#c6d4df;font-family:"Motiva Sans",Arial,Helvetica,sans-serif;font-size:12px;overflow:hidden;backdrop-filter:blur(6px)} /* v1.4.9:嵌入到筛选面板内部的侧边栏样式(与筛选面板统一风格) */ .sme-mr-panel.sme-mr-inline{position:relative;right:auto;bottom:auto;width:100%;margin-top:0;z-index:auto;box-shadow:none;background:transparent;border:none;border-radius:0;backdrop-filter:none;padding-top:var(--spacing-2);border-top:1px solid var(--color-border-border)} .sme-mr-panel.sme-mr-inline .sme-mr-head{background:transparent;border-bottom:none;padding:4px 0} .sme-mr-panel.sme-mr-inline .sme-mr-head-title{font-size:13px;font-weight:var(--font-weight-medium,600);color:var(--color-text-body-subtitle)} .sme-mr-panel.sme-mr-inline .sme-mr-body{padding:8px 0 4px} .sme-mr-panel.sme-mr-inline .sme-mr-foot{padding:6px 0 0;border-top:none} .sme-mr-panel.sme-mr-inline.collapsed{width:100%;padding-bottom:0} .sme-mr-panel.sme-mr-inline .sme-mr-main{padding:12px 14px;background:linear-gradient(135deg,var(--color-accent-8-10,var(--sme-blue-a10)),transparent);border:1px solid var(--color-accent-8-20,var(--sme-blue-a20));border-radius:var(--sme-radius-sm)} .sme-mr-panel.sme-mr-inline .sme-mr-rank-label{color:var(--color-accent-8,var(--sme-blue))} .sme-mr-panel.sme-mr-inline .sme-mr-rank-big{background:linear-gradient(180deg,var(--color-accent-9,#8ad8ff) 0%,var(--color-accent-8,var(--sme-blue)) 50%,var(--color-accent-7,#4a9fd4) 100%);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text} .sme-mr-panel.sme-mr-inline .sme-mr-total{color:var(--color-text-body-subtitle)} .sme-mr-panel.sme-mr-inline .sme-mr-note{color:var(--color-text-body-subtitle)} .sme-mr-panel.sme-mr-inline .sme-mr-row{color:var(--color-text-body-body)} .sme-mr-panel.sme-mr-inline .sme-mr-row .pr{color:var(--color-text-body-body)} .sme-mr-panel.sme-mr-inline .sme-mr-row.front{background:var(--color-accent-8-10,var(--sme-blue-a10))} .sme-mr-panel.sme-mr-inline .sme-mr-rows{border-top-color:var(--color-border-border)} .sme-mr-panel.sme-mr-inline .sme-mr-foot{color:var(--color-text-body-subtitle)} .sme-mr-panel.sme-mr-inline .sme-mr-btn{background:var(--color-dull-8-10,rgba(255,255,255,.08));border-color:var(--color-border-border);color:var(--color-text-body-body)} .sme-mr-panel.sme-mr-inline .sme-mr-btn:hover{background:var(--color-dull-8-20,rgba(255,255,255,.15))} .sme-mr-panel.sme-mr-inline .sme-mr-changenote{border-top-color:var(--color-border-border);color:var(--color-text-body-subtitle)} .sme-mr-panel.collapsed{width:auto} .sme-mr-head{display:flex;align-items:center;gap:6px;padding:8px 10px;background:var(--sme-blue-a10);border-bottom:1px solid var(--sme-blue-a18);cursor:pointer;user-select:none} .sme-mr-head-title{font-weight:700;color:var(--sme-blue);font-size:12px;flex:1;white-space:nowrap} .sme-mr-dot{width:8px;height:8px;border-radius:50%;background:var(--sme-text-dim);flex-shrink:0} .sme-mr-dot.ok{background:#6cc900;box-shadow:0 0 6px rgba(108,201,0,.7)} .sme-mr-dot.scanning{background:var(--sme-blue);animation:sme-mr-pulse 1s ease-in-out infinite} .sme-mr-dot.error{background:#e05a5a} @keyframes sme-mr-pulse{0%,100%{opacity:.35}50%{opacity:1}} .sme-mr-body{padding:10px} .sme-mr-main{display:flex;align-items:baseline;gap:8px;margin-bottom:8px;padding:12px 14px;background:linear-gradient(135deg,var(--sme-blue-a12),var(--sme-blue-a04));border:1px solid var(--sme-blue-a20);border-radius:var(--sme-radius-sm)} .sme-mr-rank-label{font-size:11px;font-weight:700;color:var(--sme-blue);text-transform:uppercase;letter-spacing:1px;opacity:.8} .sme-mr-rank-big{font-size:34px;font-weight:900;background:linear-gradient(180deg,#8ad8ff 0%,var(--sme-blue) 50%,#4a9fd4 100%);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;line-height:1;transition:filter .3s;text-shadow:0 0 20px var(--sme-blue-a30)} .sme-mr-rank-big.up{background:linear-gradient(180deg,#a8e063 0%,#6cc900 50%,#5aa800 100%);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;text-shadow:0 0 20px rgba(108,201,0,.3)} .sme-mr-rank-big.down{background:linear-gradient(180deg,#ff7b7b 0%,#e05a5a 50%,#c44 100%);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;text-shadow:0 0 20px rgba(224,90,90,.3)} .sme-mr-total{color:var(--sme-text-dim);font-size:14px;font-weight:600} .sme-mr-note{color:var(--sme-text-dim);font-size:11px;margin-bottom:6px} .sme-mr-rows{border-top:1px solid rgba(255,255,255,.08);margin-top:6px;padding-top:6px;max-height:120px;overflow-y:auto} .sme-mr-row{display:flex;align-items:center;gap:6px;padding:2px 0;color:#c6d4df} .sme-mr-row .rk{color:var(--sme-blue);font-weight:700;min-width:38px} .sme-mr-row .pr{flex:1;color:#c6d4df} .sme-mr-row .lid{color:#67c1f5;font-size:10px;cursor:help;text-decoration:underline dotted} .sme-mr-row.front{background:var(--sme-blue-a10);margin:0 -6px;padding:2px 6px;border-radius:var(--sme-radius-xs)} .sme-mr-foot{display:flex;align-items:center;gap:6px;padding:6px 10px;border-top:1px solid rgba(255,255,255,.08);color:var(--sme-text-dim);font-size:11px} .sme-mr-btn{background:var(--sme-blue-a15);border:1px solid var(--sme-blue-a35);color:var(--sme-blue);border-radius:var(--sme-radius-xs);padding:2px 8px;font-size:11px;cursor:pointer;line-height:1.4} .sme-mr-btn:hover{background:var(--sme-blue-a30)} .sme-mr-btn:disabled{opacity:.45;cursor:not-allowed} .sme-mr-changenote{padding:4px 10px;color:#a3cc5a;font-size:11px;border-top:1px solid rgba(255,255,255,.06)} /* v1.4.9:详情页设置面板改为双列布局 + 分组卡片式,更紧凑清晰 */ .sme-settings-grid{display:flex;flex-direction:column;gap:12px} .sme-setting-group{ background:var(--sme-bg-card); border:1px solid var(--sme-border); border-radius:var(--sme-radius-sm); padding:12px 14px; display:grid; grid-template-columns:repeat(2,1fr); gap:0 16px; position:relative; } .sme-setting-group::before{content:'';position:absolute;left:50%;top:12px;bottom:12px;width:1px;background:var(--sme-border);transform:translateX(-0.5px);pointer-events:none} .sme-setting-group .sme-setting-group-title{ grid-column:1/-1; margin:0 0 8px 0; padding:0 0 8px 0; border-bottom:1px solid var(--sme-border); font-size:13px; font-weight:700; color:var(--sme-blue); display:flex; align-items:center; gap:6px; } .sme-setting-group .sme-setting-row{padding:7px 10px;border:1px solid transparent;border-radius:var(--sme-radius-sm);margin:1px 0;transition:background 0.15s,border-color 0.15s} .sme-setting-group .sme-setting-row:hover{background:var(--sme-blue-a04);border-color:var(--sme-blue-a10)} .sme-setting-group .sme-setting-name{font-size:12px} .sme-setting-group .sme-setting-desc{font-size:10px} .sme-setting-group .sme-info-box{grid-column:1/-1;margin-top:8px} .sme-setting-group .sme-settings-full{grid-column:1/-1} .sme-setting-group .sme-danger-btn{margin-top:4px} @media (max-width:640px){ .sme-setting-group{grid-template-columns:1fr} .sme-setting-group::before{display:none} } /* v1.4.8:排名徽章改用 data 属性 + ::after 渲染,不再向 React 管理的容器插入子节点, 避免 hydration/reconciliation 冲突导致新版页面列表被清空重建 */ span[data-sme-rank]::after{content:attr(data-sme-rank);display:inline-block;margin-left:6px;padding:1px 7px;border-radius:var(--sme-radius-sm);background:linear-gradient(135deg,var(--sme-blue-a25),var(--sme-blue-a10));border:1px solid var(--sme-blue-a55);color:#8ad8ff!important;font-size:12px;font-weight:700;line-height:1.5;white-space:nowrap;vertical-align:middle} span[data-sme-rank][data-sme-front]::after{border-color:#6cc900;color:#6cc900!important;background:rgba(108,201,0,.10)} div[data-sme-rank]::after{content:attr(data-sme-rank);display:block;margin-top:4px;font-size:11px;color:var(--sme-blue);font-weight:700;text-align:center} div[data-sme-rank][data-sme-front]::after{color:#6cc900} .sme-mr-flash{animation:sme-mr-flash 1.2s ease-out} @keyframes sme-mr-flash{0%{box-shadow:0 0 0 0 var(--sme-blue-a80)}100%{box-shadow:0 0 0 14px var(--sme-blue-a0)}} `); // ==================== 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: ``, // v1.4.4 操作按钮专用图标 queue: ``, queueActive: ``, starOutline: ``, starFilled: ``, ban: ``, banActive: ``, card: ``, // v1.6.0 新增图标 download: ``, calendar: ``, clock: ``, }; // ==================== 工具函数 ==================== // v1.6.9:通用尾沿节流(trailing debounce)。 // 用途:高频 input 事件(滑杆/拖拽)触发的重绘 + 持久化。 // 之前订单簿档数滑杆的 input 事件**无节流**,每派发一次就整块重建订单簿 // (最多 2×200=400 行)并同步写一次 localStorage;拖动全程约 40 次事件, // 实测累计 301.8ms。加 120ms 尾沿节流后拖动期间只重绘 1 次,11.4ms。 function debounce(fn, delay = 120) { let timer = null; const wrapped = function (...args) { if (timer) clearTimeout(timer); timer = setTimeout(() => { timer = null; fn.apply(this, args); }, delay); }; wrapped.cancel = () => { if (timer) { clearTimeout(timer); timer = null; } }; return wrapped; } 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; } // v1.5.0:Toast 由单例改为堆栈。 // 原实现复用同一个 DOM 节点并覆盖 textContent,批量操作(如下架 20 件)时 // 每条限流/成功提示都会顶掉前一条,用户只看到最后一条。 // 现在最多 3 条并存,超出时挤掉最旧的一条。 const _toastStack = []; const _TOAST_MAX = 3; const _TOAST_MS = 3000; function toast(msg, type = 'info') { const el = h('div', { class: 'sme-toast' + (type ? ' sme-' + type : '') }); el.textContent = msg; document.body.appendChild(el); _toastStack.push(el); const dismiss = () => { const idx = _toastStack.indexOf(el); if (idx === -1) return; _toastStack.splice(idx, 1); el.classList.remove('sme-show'); setTimeout(() => el.remove(), 320); _restackToasts(); }; // 超过上限:挤掉最旧的一条 while (_toastStack.length > _TOAST_MAX) { const oldest = _toastStack[0]; const oi = _toastStack.indexOf(oldest); if (oi !== -1) _toastStack.splice(oi, 1); oldest.classList.remove('sme-show'); oldest.remove(); } requestAnimationFrame(() => el.classList.add('sme-show')); setTimeout(dismiss, _TOAST_MS); } /** 重排堆叠位置,保证多条 toast 垂直不重叠 */ function _restackToasts() { _toastStack.forEach((el, i) => { if (i === 0) el.removeAttribute('data-stack'); else el.setAttribute('data-stack', String(Math.min(i, 2))); }); } 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))); } // ---- 会话ID获取(6级回退链 + 缓存 + 异步远端兜底)---- // 参考 steam-market-listings-group 2.6.2 的多级 sessionid 解析方案, // 解决新版 Steam SSR 页面可能完全不含 sessionid 的兼容问题。 /** 从任意文本中提取 sessionid(兼容转义引号、大小写变体);Steam sessionid 为 8–48 位十六进制串 */ function extractSessionIdFromText(text) { if (!text) return ''; const patterns = [ /g_sessionID\s*[:=]\s*\\?\s*["']([0-9a-fA-F]{8,48})\\?["']/, /\\?["']g_sessionID\\?["']\s*\\?\s*:\s*\\?["']([0-9a-fA-F]{8,48})\\?["']/, /\\?["']session[-_]?id\\?["']\s*\\?\s*:\s*\\?["']([0-9a-fA-F]{8,48})\\?["']/i, /name=["']sessionid["']\s+content=["']([0-9a-fA-F]{8,48})["']/i, ]; for (const re of patterns) { const m = text.match(re); if (m) return m[1]; } return ''; } /** 递归扫描对象树中的 sessionid 字段(含嵌套 JSON 字符串内的转义形式) */ function findSessionIdInObject(node, depth) { if (!node || depth > 8) return ''; if (typeof node === 'string') { return node.length > 20 ? extractSessionIdFromText(node) : ''; } if (typeof node !== 'object') return ''; if (Array.isArray(node)) { for (const v of node) { const r = findSessionIdInObject(v, depth + 1); if (r) return r; } return ''; } for (const [k, v] of Object.entries(node)) { if (/^session[-_]?id$/i.test(k) && typeof v === 'string' && /^[0-9a-fA-F]{8,48}$/.test(v)) return v; } for (const v of Object.values(node)) { const r = findSessionIdInObject(v, depth + 1); if (r) return r; } return ''; } /** 扫描 SSR 全局对象中的 sessionid */ function scanSSRForSessionId() { try { if (unsafeWindow.SSR) { return findSessionIdInObject(unsafeWindow.SSR, 0); } } catch (e) {} return ''; } let _sessionIdCache = ''; /** 同步获取 sessionid:g_sessionID → meta → cookie → SSR深度扫描 → 页面源码正则 → 缓存 */ function getSessionId() { if (_sessionIdCache) return _sessionIdCache; let sid = ''; try { sid = unsafeWindow.g_sessionID || ''; } catch (e) {} if (!sid) { const meta = document.querySelector('meta[name="sessionid"]'); if (meta && meta.content) sid = meta.content; } if (!sid) { const m = document.cookie.match(/(^|;\s*)sessionid=([^;]+)/); if (m) sid = decodeURIComponent(m[2]); } if (!sid) sid = scanSSRForSessionId(); if (!sid) sid = extractSessionIdFromText(getSsrHtmlSnapshot()); if (sid) _sessionIdCache = sid; return sid; } // v1.5.1 P2-B:SSR HTML 快照缓存——documentElement.innerHTML 全量序列化在主线程耗时数百 ms, // 而 sessionid/contextid 等提取目标在页面生命周期内不变,快照一次即可 let _ssrHtmlCache = null; function getSsrHtmlSnapshot() { if (_ssrHtmlCache === null) { try { _ssrHtmlCache = document.documentElement.innerHTML; } catch (e) { _ssrHtmlCache = ''; } } return _ssrHtmlCache; } /** 本地全部落空时拉取其他 Steam 页面提取 sessionid(新版 SSR 页面兜底) */ async function fetchSessionIdFromRemote() { for (const url of ['https://steamcommunity.com/my/inventory', 'https://steamcommunity.com/market/']) { try { const html = await smeRequest(url, { expect: 'text', onNonOk: 'null' }); if (!html) continue; const sid = extractSessionIdFromText(html); if (sid) { L('sessionid resolved via remote fetch:', url); return sid; } } catch (e) { W('sessionid remote fetch failed:', url, e); } } return ''; } /** 异步获取 sessionid:同步本地链 → 异步远端兜底;成功后缓存到会话结束 */ async function resolveSessionId() { let sid = getSessionId(); if (!sid) { sid = await fetchSessionIdFromRemote(); if (sid) _sessionIdCache = sid; } if (sid) { L('sessionid resolved'); } else { W('sessionid not found via g_sessionID / meta / cookie / SSR scan / page source / remote fetch'); } return sid; } 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, // 交易报价价值摘要 // v1.4.5 新增:自主定价 priceBypass: true, // 启用自主定价(突破价格限制) defaultPriceMode: 'instant', // 默认定价模式 instant/undercut/match/custom_buyer/custom_received // v1.4.6 新增:我的挂单市场顺序跟踪 marketRankTracker: true, // 物品页展示我的挂单市场顺序 rankAutoRefresh: true, // 自动刷新顺序(跟踪售出变化) rankRefreshInterval: 60, // 刷新间隔(秒) // v1.4.7 新增:订单深度增强 orderDepthLevels: 30, // 订单深度默认显示档数 orderDepthShowNet: true, // 显示税后到手列 orderDepthShowCumulative: true, // 显示累计列 orderDepthShowCny: true, // 外币钱包时额外显示 CNY 税后到手列 // v1.4.9 新增:参考货币换算 referenceCurrency: 'CNY', // 参考货币代码(ISO 4217),默认人民币 showReferencePrice: true, // 在市场价格旁显示参考货币价格 // v1.5.0 新增:市场物品页显示更多价格(新版 React 市场页) showMorePrices: true, // 订单表格显示更多价格行(默认 6 行 → 更多) showMorePricesRows: 20, // 显示行数(6-100) // v1.6.0 新增:市场交易记录汇总与导出 marketHistoryPanel: true, // 在"出售物品"旁显示汇总/导出工具条 marketHistoryFormat: 'csv', // 导出格式 csv | tsv | json | md marketHistoryPageSize: 100, // 每页抓取条数(Steam 单页硬上限 100) marketHistoryDelay: 700, // 翻页请求间隔(毫秒),过小易触发限流 marketHistoryTopN: 20, // 汇总榜单展示条目数 // v1.6.4 新增:隐藏官方 Beta 公告条 hideMarketBetaNote: true, // 浏览器:inline 顶部内嵌,float 底部浮窗。Steam 客户端忽略此项,固定浮窗。 listingPanelMode: 'inline', }, // v1.5.1 P1-B:内存缓存。GM 存储为同步读,ownedBadges 等设置在 MutationObserver // 回调里高频调用,逐次跨脚本边界读取浪费;首次读取后驻留内存,set 时同步更新。 // (设置仅由本脚本写入,不存在外部并发修改问题) _mem: {}, get(key) { if (key in this._mem) return this._mem[key]; const v = GM_getValue('sme_' + key, this._defaults[key]); this._mem[key] = v; return v; }, set(key, val) { GM_setValue('sme_' + key, val); this._mem[key] = val; }, }; // ==================== 缓存管理 ==================== // v1.5.0:QuotaExceededError 不再静默失败。 // 原实现 catch(e){} 直接吞掉配额超限,导致用户浏览一段时间后 // 缓存无声失效(表现:明明设了 30 分钟 TTL,价格历史却一直不更新), // 只能在 DevTools 里手动清缓存才能恢复。 const _cacheQuotaWarned = { v: false }; /** 存储配额超限处理:清理本脚本的缓存后重试一次,仍失败则告警一次 */ function _handleCacheQuota(key, raw) { if (raw.length < 512 * 1024) { // 单条就超过 512KB,缓存它没有意义,直接放弃并提示 if (!_cacheQuotaWarned.v) { _cacheQuotaWarned.v = true; W('localStorage 配额不足,已跳过缓存条目:', key); toast('浏览器存储空间不足,价格缓存可能无法保存。可在设置中清理缓存。', 'warning'); } try { localStorage.removeItem('sme_cache_' + key); } catch (_) {} return false; } // 大条目:清理全部本脚本缓存后重试 cache.clear(); try { localStorage.setItem('sme_cache_' + key, raw); W('localStorage 配额不足,已清理全部缓存后重试成功:', key); return true; } catch (_) { if (!_cacheQuotaWarned.v) { _cacheQuotaWarned.v = true; toast('浏览器存储空间不足且清理后仍无法写入缓存。', 'warning'); } return false; } } // v1.5.1 P1-B:LRU + 4MB 容量上限 + in-flight 去重。 // 此前 localStorage 只进不出,逼近 5MB 配额后触发 QuotaExceededError // (v1.5.0 起告警但仍会粗暴全清);现在按 LRU 淘汰最旧条目,容量封顶 4MB。 const CACHE_MAX_BYTES = 4 * 1024 * 1024; const cache = { _sizes: new Map(), // key → 字节数(Map 迭代序 = 插入序,get 触碰移到尾部 → LRU) _indexReady: false, // 惰性扫描 localStorage,把历史条目纳入容量统计 _inflight: new Map(), // key → Promise(同 key 并发请求去重) _ensureIndex() { if (this._indexReady) return; this._indexReady = true; try { Object.keys(localStorage).forEach(k => { if (!k.startsWith('sme_cache_')) return; const v = localStorage.getItem(k); if (v) this._sizes.set(k.slice('sme_cache_'.length), v.length); }); } catch (e) {} }, 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; // LRU touch:命中后移到 Map 尾部 if (this._sizes.has(key)) { const b = this._sizes.get(key); this._sizes.delete(key); this._sizes.set(key, b); } return entry.data; } catch (e) { return null; } }, set(key, data) { const payload = JSON.stringify({ ts: Date.now(), data }); try { this._ensureIndex(); localStorage.setItem('sme_cache_' + key, payload); this._sizes.set(key, payload.length); this._evict(); } catch (e) { if (e && (e.name === 'QuotaExceededError' || e.code === 22 || e.code === 1014)) { try { _handleCacheQuota(key, payload); } catch (_) { /* 二次失败已由 _handleCacheQuota 处理 */ } } else { W('缓存写入失败:', key, e); } } }, /** LRU 淘汰:总量超过 4MB 时从最旧条目开始清理(不再等到配额爆炸后全清) */ _evict() { let total = 0; this._sizes.forEach(b => { total += b; }); if (total <= CACHE_MAX_BYTES) return; for (const [k, b] of this._sizes) { if (total <= CACHE_MAX_BYTES) break; try { localStorage.removeItem('sme_cache_' + k); } catch (e) {} total -= b; this._sizes.delete(k); } W('缓存总量超过 4MB,已按 LRU 淘汰最旧条目'); }, /** in-flight 去重:同 key 并发调用共享同一 Promise,消除 TTL 到期瞬间的并发穿透 */ inflight(key, factory) { if (this._inflight.has(key)) return this._inflight.get(key); const p = Promise.resolve().then(factory).finally(() => this._inflight.delete(key)); this._inflight.set(key, p); return p; }, clear() { try { Object.keys(localStorage) .filter(k => k.startsWith('sme_cache_')) .forEach(k => localStorage.removeItem(k)); // 清理成功后允许再次告警 _cacheQuotaWarned.v = false; this._sizes.clear(); this._inflight.clear(); } 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); } // ==================== 统一限流调度器(v1.5.1 P1-A 深化) ==================== // 此前 4 套限流器(库存串行队列 600ms / 库存页价格串行队列 1000ms / MR 时间窗+冷却 / // 批量操作裸 sleep)互不感知,同时活跃时叠加请求量可能超过 Steam 会话限流阈值。 // 现收敛为单一调度器: // 1. 每个通道保留各自节奏策略(行为兼容),实现归一; // 2. 新增全局跨通道记账窗口:62s 内所有通道合计不超过 SME_GLOBAL_MAX 次带凭据请求, // 作为多模块并发时的安全网(单通道正常使用远低于该上限,无可感知变慢)。 const SME_GLOBAL_WINDOW = 62000; const SME_GLOBAL_MAX = 100; // ≈1.6 req/s,正常浏览不会触达 const smeGlobalTs = []; function smeGlobalTrim(now) { while (smeGlobalTs.length && now - smeGlobalTs[0] > SME_GLOBAL_WINDOW) smeGlobalTs.shift(); } class SmeRateChannel { /** * @param {string} name 通道名(日志用) * @param {object} opt * gapAfter: 任务完成后到下一任务放行的节流间隔 ms(兼容旧"完成后 sleep"队列语义; * 当前调用者的 Promise 不被延迟) * gapBefore: 距上次放行的最小间隔 ms(对应旧"起止间隔"语义) * window/max: 本通道滑动窗口限速(不传则不限) */ constructor(name, opt) { opt = opt || {}; this.name = name; this.gapAfter = opt.gapAfter || 0; this.gapBefore = opt.gapBefore || 0; this.window = opt.window || 0; this.max = opt.max || Infinity; this.ts = []; this.last = 0; this.tail = Promise.resolve(); } _trim(now) { if (!this.window) return; while (this.ts.length && now - this.ts[0] > this.window) this.ts.shift(); } /** 预订一个名额:窗口饱和时等待;cancel() 返回 false 则中止(返回 null) */ async acquire(cancel) { for (;;) { if (cancel && !cancel()) return null; const now = Date.now(); this._trim(now); if (this.ts.length >= this.max) { const wait = this.ts[0] + this.window - now + 100; L(this.name + ': 通道窗口限速,等待 ' + Math.ceil(wait / 1000) + 's'); await sleep(Math.min(15000, wait)); continue; } smeGlobalTrim(now); if (smeGlobalTs.length >= SME_GLOBAL_MAX) { const wait = smeGlobalTs[0] + SME_GLOBAL_WINDOW - now + 100; L(this.name + ': 全局窗口饱和,等待 ' + Math.ceil(wait / 1000) + 's'); await sleep(Math.min(15000, wait)); continue; } if (this.last && this.gapBefore) { const remain = this.gapBefore - (Date.now() - this.last); if (remain > 0) await sleep(remain); } const now2 = Date.now(); this.ts.push(now2); this.last = now2; smeGlobalTrim(now2); smeGlobalTs.push(now2); return true; } } /** 串行执行 task(严格 FIFO);节流等待只作用于后续任务,不延迟当前结果 */ run(task, cancel) { const p = this.tail.then(async () => { const ok = await this.acquire(cancel); if (ok === null) throw new Error('任务已作废'); return task(); }); const throttle = () => (this.gapAfter ? sleep(this.gapAfter) : Promise.resolve()); this.tail = p.then(throttle, throttle); return p; } /** 清空本通道窗口(收到 429 进入冷却时调用) */ reset() { this.ts.length = 0; } } // 库存通道:任务完成后 600ms 节流(与原 invQueue 语义一致) const smeChInv = new SmeRateChannel('inventory', { gapAfter: REQUEST_DELAY }); function enqueueInv(task) { return smeChInv.run(task); } // ==================== 页面检测 ==================== function detectPageType() { const p = location.pathname; // 补充包制作器页面 if (/^\/tradingcards\/boostercreator/.test(p)) return 'booster'; // 市场详情页 if (/^\/market\/listings\/\d+\/.+/.test(p)) return 'listing'; // 市场浏览页 / 我的市场 if (/^\/market\/?$/.test(p) || /^\/market\/search/.test(p)) return 'browse'; // 市场多件出售页 if (/^\/market\/multisell/.test(p)) return 'multisell'; // 库存页 if (/^\/(?:id|profiles)\/[^/]+\/inventory/.test(p)) return 'inventory'; // 交易报价页 if (/^\/tradeoffer/.test(p)) return 'tradeoffer'; return 'other'; } /** * Steam 客户端内置浏览器(steamwebhelper / 游戏内覆盖)的 UA 带 Valve Steam Client。 */ function isSteamClient() { try { const ua = navigator.userAgent || ''; if (/Valve Steam Client|Valve Steam GameOverlay|steamwebhelper/i.test(ua)) return true; } catch (e) {} try { const w = unsafeWindow || window; if (w.SteamClient || w.Steam || w.Millennium) return true; } catch (e) {} return false; } /** 浏览器里记住的选择。旧值 auto 以及未设置都视为顶部内嵌。 */ function browserListingPanelMode() { return settings.get('listingPanelMode') === 'float' ? 'float' : 'inline'; } /** Steam 客户端固定浮窗。浏览器默认内嵌,只有用户选过浮窗才用浮窗。 */ function shouldUseFloatPanel() { if (isSteamClient()) return true; return browserListingPanelMode() === 'float'; } /** 只在浏览器里保存。Steam 客户端不写入,避免把固定浮窗记成用户选择。 */ function applyListingPanelMode(mode) { if (isSteamClient()) return; const next = mode === 'float' ? 'float' : 'inline'; settings.set('listingPanelMode', next); if (detectPageType() === 'listing') presentListingPanel(next === 'float'); } function listingPanelModeControl() { if (isSteamClient()) { return h('span', { class: 'sme-setting-desc', text: '底部浮窗' }); } const mode = browserListingPanelMode(); return h('select', { class: 'sme-setting-select', 'aria-label': '详情页分析面板', html: [['页面顶部内嵌', 'inline'], ['底部浮窗按钮', 'float']] .map(([l, v]) => ``).join(''), onChange: e => applyListingPanelMode(e.target.value), }); } 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, classid: o.classid || '', 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); // 提取 classid(用于下架后 assetid 重新获取) if (data.asset_description?.classid) result.classid = String(data.asset_description.classid); // 递归搜索(处理嵌套结构,如 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: '' }, // CNY (¥) 24: { prefix: '₹', suffix: '' }, // INR (₹) 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 状态中 * @param {boolean} force 强制刷新:绕过 SSR 与缓存,直接请求 histogram API 拿最新全量数据 */ async function fetchOrderBook(force) { const ssrUsable = (d) => d && (d.sellOrders.length > 0 || d.buyOrders.length > 0 || d.highestBuy || d.lowestSell); // 1. 优先从 SSR 提取(新版页面,无需 item_nameid,零网络请求) const ssrData = extractOrderBookFromSSR(); // SSR 数据有效条件:有买卖单明细,或有最高买/最低卖价(低流动性物品可能无明细但有价格) const ssrValid = ssrUsable(ssrData); // SSR 明细档位较少(内嵌数据仅前几档)时,若能拿到 item_nameid 仍走 API 换取全量直方图 const ssrLevels = ssrData ? Math.max(ssrData.sellOrders.length, ssrData.buyOrders.length) : 0; if (!force && ssrValid && ssrLevels >= 10) { return ssrData; } // 2. 通过 item_nameid + API 获取(旧版页面,或 SSR 档位不足时的全量升级) // SSR 已给出可用数据时仅同步尝试 getItemNameId()(旧版页面内联变量,立即可得); // 新版页面拿不到 item_nameid,不能为它白等 getItemNameIdAsync 的重试窗口 const itemNameId = (ssrValid && !force) ? getItemNameId() : await getItemNameIdAsync(); if (itemNameId) { try { return await fetchOrderHistogram(itemNameId, force); } catch (e) { if (!force && ssrValid) return ssrData; // 非强制时回退页面内嵌数据;强制刷新不回退,如实报错 throw e; } } // 强制刷新却拿不到 item_nameid(新版 React 页面):无任何获取新数据的途径,如实报错而非返回旧数据 if (force) throw new Error('无法强制刷新:当前页面拿不到 item_nameid(新版市场页请直接刷新网页)'); if (ssrValid) return ssrData; throw new Error('无法获取订单簿数据(SSR 无数据且 item_nameid 不可用)'); } 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) // v1.5.0:新增 `sme_xhr_restore` 事件 + pagehide 还原。 // 原实现永久改写 XMLHttpRequest.prototype.open 且从不还原,会污染整页 // (含 Steam 自身的 XHR 调用)。任何其它脚本若检测 open 是否为原生会误判, // 且调试时无法定位是谁改的。现在暴露一个显式还原入口,并在页面离开时自动还原。 (function interceptXHR() { const PATCH_FLAG = '__smeItemNameIdHook'; if (XMLHttpRequest.prototype.open[PATCH_FLAG]) return; // 防重复注入 const origOpen = XMLHttpRequest.prototype.open; const patched = 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); }; patched[PATCH_FLAG] = true; patched._smeOriginal = origOpen; XMLHttpRequest.prototype.open = patched; /** 还原原型方法(幂等);可由外部主动调用 window.dispatchEvent(new Event('sme_xhr_restore')) */ window.addEventListener('sme_xhr_restore', () => { if (XMLHttpRequest.prototype.open === patched) { XMLHttpRequest.prototype.open = origOpen; L('XHR open 已还原'); } }, { once: true }); })(); // 页面离开时自动还原,避免污染残留到其他页面 window.addEventListener('pagehide', () => { window.dispatchEvent(new Event('sme_xhr_restore')); }); // ==================== 订单分组 ==================== 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)); } // ==================== v1.5.1 P1-A 统一请求层 ==================== // 目标:收敛散落 20 处的裸 fetch。统一负责:credentials / 超时(AbortController) / // 429·400·401·403·5xx 错误码分类 / JSON·text 解析 / 外部 signal 透传。 // 错误消息与旧实现保持一致('RateLimited' / 'BadRequest' / 'HTTP xxx'), // 确保 withMarketRetry 的重试判定(msg === 'RateLimited' || /429/)无需改动。 const SME_ERR = Object.freeze({ RATE_LIMITED: 'RateLimited', BAD_REQUEST: 'BadRequest', AUTH: 'AuthError', TIMEOUT: 'Timeout', SERVER: 'ServerError', NETWORK: 'NetworkError', }); /** 统一请求错误:err.code ∈ SME_ERR,err.status 为 HTTP 状态码(网络层错误为 0) */ class SmeRequestError extends Error { constructor(code, status, message) { super(message || code); this.name = 'SmeRequestError'; this.code = code; this.status = status || 0; } } /** * 统一请求入口(P1-A)。 * @param {string} url * @param {object} [opt] * expect: 'json'(默认) | 'text' | 'none'(返回原始 Response) * timeout: 毫秒,默认 15000;0 表示不设超时(外部 signal 自行控制) * signal: 外部 AbortSignal(传入后本函数不再建超时控制器) * method/headers/body/cache: 透传 fetch * credentials: 默认 'include' * onNonOk: 'throw'(默认) | 'null'(非 2xx 返回 null,对应旧代码 `!resp.ok → null` 语义) */ async function smeRequest(url, opt) { opt = opt || {}; const expect = opt.expect || 'json'; const externalSignal = opt.signal || null; const ctrl = externalSignal ? null : new AbortController(); const signal = externalSignal || ctrl.signal; const timeout = opt.timeout === undefined ? 15000 : opt.timeout; const timer = (ctrl && timeout > 0) ? setTimeout(() => ctrl.abort('sme-timeout'), timeout) : null; let resp; try { resp = await fetch(url, { method: opt.method || 'GET', credentials: opt.credentials || 'include', headers: opt.headers, body: opt.body, cache: opt.cache, signal: signal, }); } catch (e) { if (e && e.name === 'AbortError') throw new SmeRequestError(SME_ERR.TIMEOUT, 0, 'Timeout'); throw new SmeRequestError(SME_ERR.NETWORK, 0, 'Network error: ' + ((e && e.message) || e)); } finally { if (timer) clearTimeout(timer); } if (resp.status === 429) throw new SmeRequestError(SME_ERR.RATE_LIMITED, 429, 'RateLimited'); if (resp.status === 400) throw new SmeRequestError(SME_ERR.BAD_REQUEST, 400, 'BadRequest'); if (resp.status === 401 || resp.status === 403) throw new SmeRequestError(SME_ERR.AUTH, resp.status, 'HTTP ' + resp.status); if (resp.status >= 500) throw new SmeRequestError(SME_ERR.SERVER, resp.status, 'HTTP ' + resp.status); if (!resp.ok) { if (opt.onNonOk === 'null') return null; throw new SmeRequestError(SME_ERR.NETWORK, resp.status, 'HTTP ' + resp.status); } if (expect === 'none') return resp; if (expect === 'text') return resp.text(); try { return await resp.json(); } catch (e) { throw new SmeRequestError(SME_ERR.NETWORK, resp.status, 'JSON parse error: ' + e.message); } } // v1.5.1 P1-A:doFetch 收敛为统一请求层 smeRequest 的薄包装(错误消息保持兼容) function doFetch(url) { return smeRequest(url, { expect: 'json' }); } // v1.5.1 P2-A:错误码 → 用户可读文案映射(替代 err.message 英文原文直出) const SME_ERR_TEXT = { RateLimited: '请求过于频繁(Steam 限流),请稍后再试', BadRequest: '请求参数错误', AuthError: '登录状态失效,请刷新页面后重试', Timeout: '请求超时,请检查网络后重试', ServerError: 'Steam 服务器异常,请稍后再试', NetworkError: '网络错误,请检查网络连接', }; function friendlyError(err) { if (err && err.code && SME_ERR_TEXT[err.code]) return SME_ERR_TEXT[err.code]; return (err && err.message) ? err.message : String(err || '未知错误'); } /** GM_xmlhttpRequest 封装:跨域获取 JSON(用于 store.steampowered.com API) */ function gmFetchJson(url, timeout = 15000) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: url, timeout: timeout, onload: (resp) => { if (resp.status >= 200 && resp.status < 300) { try { resolve(JSON.parse(resp.responseText)); } catch (e) { reject(new Error('JSON parse error: ' + e.message)); } } else { reject(new Error('HTTP ' + resp.status)); } }, onerror: () => reject(new Error('Network error')), ontimeout: () => reject(new Error('Timeout')), }); }); } 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]; // v1.5.0: 补 TTL 校验。原实现直接返回缓存而不检查时间戳, // 导致库存数量在整个浏览器会话内永不刷新(TTL 形同虚设)。 if (cached && Date.now() - cached.ts < CACHE_TTL.INVENTORY) { if (cached.data instanceof Map) return cached.data.get(hashName) || 0; return -1; // null = 之前请求失败 } // 缓存过期(或不存在)→ 删除后走正常请求路径,让 fetchInventory 内的 TTL 写入生效 if (cached) delete invCache[key]; // 通过请求队列限流,避免同时大量请求触发 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)}`; // v1.5.1 P1-B:in-flight 去重——同物品并发调用共享同一次请求 return cache.inflight('phreq_' + cacheKey, async () => { try { const data = await smeRequest(url, { expect: '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, force) { const cacheKey = `ob_${itemNameId}`; if (!force) { const cached = cache.get(cacheKey, CACHE_TTL.ORDER_BOOK); // _v:2 标记含全量 graph 档位数据的新结构,旧缓存(仅 table 前几档)视为失效 if (cached && cached._v === 2) return cached; } // v1.5.1 P1-B:in-flight 去重——同物品并发调用(含 force 与非 force)共享同一次请求 return cache.inflight('obreq_' + itemNameId, async () => { const wi = getWalletInfo(); let country = wi.wallet_country || 'US'; let language = 'english'; let currency = wi.wallet_currency; try { if (unsafeWindow.g_strCountryCode) country = unsafeWindow.g_strCountryCode; if (unsafeWindow.g_strLanguage) language = unsafeWindow.g_strLanguage; } catch (e) {} const url = `https://steamcommunity.com/market/itemordershistogram/?country=${country}&language=${language}¤cy=${currency}&item_nameid=${itemNameId}&two_factor=0`; try { const data = await smeRequest(url, { expect: 'json' }); if (!data.success) throw new Error('API returned success=false'); const result = { _v: 2, 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 || '', }; // graph 为全量档位(100+ 档),table 仅前几档;档位数更多者胜出 const sellGraph = parseOrderGraph(data.sell_order_graph, currency); const buyGraph = parseOrderGraph(data.buy_order_graph, currency); const tableSell = parseOrderTable(data.sell_order_table); const tableBuy = parseOrderTable(data.buy_order_table); result.sellOrders = sellGraph.length > tableSell.length ? sellGraph : tableSell; result.buyOrders = buyGraph.length > tableBuy.length ? buyGraph : tableBuy; 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, _cents: parsePriceToCents(price) }); } }); return orders; } catch (e) { return []; } } /** * graph 价格 → 最小货币单位(分) * itemordershistogram 的 *_order_graph 价格可能是「分」整数(请求时带 currency 参数), * 也可能是「显示值」小数(如 5.63),非整数时按 ×100 换算 */ function graphPriceToCents(price) { const n = parseFloat(price); if (isNaN(n)) return 0; return n % 1 !== 0 ? Math.round(n * 100) : n; } /** * 解析 itemordershistogram 的 sell_order_graph / buy_order_graph([[价格, 累计数], ...]) * 借鉴 steam-market-quantity-viewer:graph 的数量为累计值,逐档做差还原每档真实在售/求购数, * 可获得 100+ 档全量分布(原生 sell_order_table 只返回前几档)。 * 自适应检测:若逐档数量本就单调不减且非累计格式,差值会得到错误结果—— * 通过"是否为累计曲线"检测(数量整体单调不减则视为累计),与数据格式无关地输出每档数量。 */ function parseOrderGraph(graph, currencyCode) { if (!Array.isArray(graph) || graph.length === 0) return []; const raw = graph.map(e => ({ cents: graphPriceToCents(e && e[0]), value: parseInt(e && e[1], 10) || 0, })).filter(r => r.cents > 0); if (raw.length === 0) return []; let cumulative = raw.length > 1; for (let i = 1; i < raw.length; i++) { if (raw[i].value < raw[i - 1].value) { cumulative = false; break; } } let prev = 0; const orders = []; for (const r of raw) { const qty = cumulative ? Math.max(0, r.value - prev) : r.value; if (cumulative) prev = r.value; if (qty <= 0) continue; orders.push({ price: formatCentsStr(r.cents, currencyCode), qty, _cents: r.cents }); } return orders; } // ==================== 手续费计算 ==================== let _walletInfo = null; function getWalletInfo() { // 仅缓存从 g_rgWalletInfo 成功读取的钱包信息;回退值不缓存, // 以便后续调用在 g_rgWalletInfo 加载完成后能获取到正确的货币。 if (_walletInfo) return _walletInfo; try { const wi = unsafeWindow.g_rgWalletInfo; if (wi && wi.wallet_currency != null) { _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, wallet_country: wi.wallet_country || '', }; // 检测货币代码以决定取整方式(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%(不缓存,下次调用重试 g_rgWalletInfo) return { wallet_fee_percent: 0.05, wallet_fee_base: 0, wallet_fee_minimum: 1, wallet_publisher_fee_percent_default: 0.10, wallet_currency: 1, wallet_country: '', currency_code: 'USD', use_round: false, }; } /** 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'; } // ==================== 汇率换算(v1.4.9) ==================== const _exchangeCache = { rates: {}, // { fromCurrency: { toCurrency: rate } } lastFetch: 0, lastAttempt: 0, // v1.5.0:失败时也更新,用于退避,避免每次调用重遍历镜像 inflight: null, // v1.5.0:single-flight Promise,消除多币种并发下的惊群下载 ttl: 6 * 60 * 60 * 1000, // 6 小时缓存 retryCooldown: 60 * 1000, // v1.5.0:失败后的最小重试间隔 }; /** * v1.5.0:拉取汇率表(single-flight)。 * 原实现每次 getExchangeRate 未命中缓存就独立发一次 usd.json 下载, * 跨币种页面会同时触发 N 次完整下载(数百 KB × N)。 * 这里改为共享同一个 in-flight Promise,任何并发调用都复用同一次网络请求。 */ function _fetchExchangeTable() { if (_exchangeCache.inflight) return _exchangeCache.inflight; _exchangeCache.inflight = (async () => { // 以 USD 为基准货币 // 使用 GM_xmlhttpRequest 绕过 Steam 的 CSP 限制 // 多镜像备用,按顺序尝试 const mirrors = [ 'https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/usd.json', 'https://latest.currency-api.pages.dev/v1/currencies/usd.json', ]; let lastErr = null; let data = null; for (const url of mirrors) { try { data = await new Promise((resolve, reject) => { if (typeof GM_xmlhttpRequest === 'function') { GM_xmlhttpRequest({ method: 'GET', url: url, onload: resp => { if (resp.status >= 200 && resp.status < 300) { try { resolve(JSON.parse(resp.responseText)); } catch (e) { reject(new Error('JSON 解析失败: ' + e.message)); } } else { reject(new Error('HTTP ' + resp.status)); } }, onerror: () => reject(new Error('网络请求失败')), ontimeout: () => reject(new Error('请求超时')), timeout: 10000, }); } else { // v1.5.1 P1-A:走统一请求层;跨域镜像不携带凭据(credentials: 'omit'), // 避免带 cookie 触发 CORS 严格模式失败 smeRequest(url, { credentials: 'omit' }) .then(resolve).catch(reject); } }); break; // 成功则跳出循环 } catch (e) { lastErr = e; W('汇率镜像', url, '请求失败:', e.message); } } if (!data) throw lastErr || new Error('所有镜像均请求失败'); const usdRates = data.usd || {}; if (!_exchangeCache.rates['USD']) _exchangeCache.rates['USD'] = {}; // 存储 USD -> 各货币的汇率 for (const k of Object.keys(usdRates)) { const cur = k.toUpperCase(); const perUsd = usdRates[k]; _exchangeCache.rates['USD'][cur] = perUsd; // 反向:各货币 -> USD(除零保护:某些货币汇率可能为 0) if (!_exchangeCache.rates[cur]) _exchangeCache.rates[cur] = {}; _exchangeCache.rates[cur]['USD'] = perUsd ? 1 / perUsd : null; } _exchangeCache.lastFetch = Date.now(); return true; })().finally(() => { // 无论成败都清空 in-flight,避免失败后永久卡在 rejected Promise _exchangeCache.inflight = null; // v1.5.0:失败时也更新 lastAttempt,形成退避 _exchangeCache.lastAttempt = Date.now(); }); return _exchangeCache.inflight; } /** * 获取汇率(从 fawazahmed0/currency-api,以 USD 为基准) * @param {string} fromCode - 源货币代码(ISO 4217 大写,如 USD) * @param {string} toCode - 目标货币代码(ISO 4217 大写,如 CNY) * @returns {Promise} 汇率(1 fromCode = ? toCode) */ async function getExchangeRate(fromCode, toCode) { fromCode = fromCode.toUpperCase(); toCode = toCode.toUpperCase(); if (fromCode === toCode) return 1; // 检查缓存 const now = Date.now(); if (_exchangeCache.rates[fromCode]?.[toCode] != null && now - _exchangeCache.lastFetch < _exchangeCache.ttl) { return _exchangeCache.rates[fromCode][toCode]; } // v1.5.0:失败后的最小重试间隔,防止每次调用都重遍历两个镜像(失败放大) if (_exchangeCache.lastAttempt && now - _exchangeCache.lastAttempt < _exchangeCache.retryCooldown) { // 冷却期内:直接用可能过期的缓存,没有才抛 const stale = _exchangeCache.rates[fromCode]?.[toCode]; if (stale != null) return stale; } try { await _fetchExchangeTable(); // 计算 fromCode -> toCode 的交叉汇率 const usdPerFrom = _exchangeCache.rates[fromCode]?.['USD']; const toPerUsd = _exchangeCache.rates['USD']?.[toCode]; if (usdPerFrom != null && toPerUsd != null) { const rate = usdPerFrom * toPerUsd; if (!_exchangeCache.rates[fromCode]) _exchangeCache.rates[fromCode] = {}; _exchangeCache.rates[fromCode][toCode] = rate; return rate; } throw new Error(`汇率数据中未找到 ${fromCode} 或 ${toCode}`); } catch (e) { W('汇率获取失败:', e.message); // 失败时返回缓存值(即使过期),若无缓存则抛出 if (_exchangeCache.rates[fromCode]?.[toCode] != null) { return _exchangeCache.rates[fromCode][toCode]; } throw e; } } /** * 同步获取汇率(仅从缓存读取,缓存未命中返回 null,不发起网络请求) */ function getExchangeRateSync(fromCode, toCode) { fromCode = fromCode.toUpperCase(); toCode = toCode.toUpperCase(); if (fromCode === toCode) return 1; if (_exchangeCache.rates[fromCode]?.[toCode] != null) { return _exchangeCache.rates[fromCode][toCode]; } // 尝试通过 USD 交叉计算 const usdPerFrom = _exchangeCache.rates[fromCode]?.['USD']; const toPerUsd = _exchangeCache.rates['USD']?.[toCode]; if (usdPerFrom != null && toPerUsd != null) { const rate = usdPerFrom * toPerUsd; if (!_exchangeCache.rates[fromCode]) _exchangeCache.rates[fromCode] = {}; _exchangeCache.rates[fromCode][toCode] = rate; return rate; } return null; } /** * 将指定货币的分值转换为参考货币价格字符串 * @param {number} cents - 源货币的分值(如 233400 表示 INR 2334.00) * @param {number|string} sourceCurrencyIdOrCode - 源货币 ID 或代码 * @param {string} targetCode - 目标货币代码(默认从设置读取) * @returns {Promise} 格式化后的参考价格字符串,失败返回 null */ async function formatReferencePrice(cents, sourceCurrencyIdOrCode, targetCode) { if (!cents || cents <= 0) return null; const refCur = targetCode || settings.get('referenceCurrency') || 'CNY'; const sourceCode = typeof sourceCurrencyIdOrCode === 'number' ? getCurrencyCode(sourceCurrencyIdOrCode) : String(sourceCurrencyIdOrCode).toUpperCase(); if (sourceCode === refCur.toUpperCase()) return null; // 同源无需换算 try { const rate = await getExchangeRate(sourceCode, refCur); const amount = (cents / 100) * rate; const sym = CURRENCY_SYMBOLS_BY_CODE[refCur.toUpperCase()] || { prefix: '', suffix: ' ' + refCur }; // 根据货币类型决定小数位数 const decimals = CURRENCY_ZERO_DECIMAL.includes(refCur.toUpperCase()) ? 0 : 2; return sym.prefix + amount.toFixed(decimals) + sym.suffix; } catch (e) { return null; } } /** 货币代码 → 符号映射(参考货币显示用) */ const CURRENCY_SYMBOLS_BY_CODE = { USD: { prefix: '$', suffix: '' }, CNY: { prefix: '¥', suffix: '' }, EUR: { prefix: '€', suffix: '' }, GBP: { prefix: '£', suffix: '' }, JPY: { prefix: '¥', suffix: '' }, KRW: { prefix: '₩', suffix: '' }, INR: { prefix: '₹', suffix: '' }, RUB: { prefix: '₽', suffix: '' }, BRL: { prefix: 'R$', suffix: '' }, AUD: { prefix: 'A$', suffix: '' }, CAD: { prefix: 'CDN$', suffix: '' }, SGD: { prefix: 'S$', suffix: '' }, HKD: { prefix: 'HK$', suffix: '' }, TWD: { prefix: 'NT$', suffix: '' }, THB: { prefix: '฿', suffix: '' }, VND: { prefix: '₫', suffix: '' }, IDR: { prefix: 'Rp', suffix: '' }, MYR: { prefix: 'RM', suffix: '' }, PHP: { prefix: '₱', suffix: '' }, MXN: { prefix: 'Mex$', suffix: '' }, ARS: { prefix: 'ARS$', suffix: '' }, CLP: { prefix: 'CLP$', suffix: '' }, COP: { prefix: 'COL$', suffix: '' }, PEN: { prefix: 'S/', suffix: '' }, UYU: { prefix: '$U', suffix: '' }, SAR: { prefix: 'SR ', suffix: ' SR' }, AED: { prefix: 'AED ', suffix: ' AED' }, TRY: { prefix: '₺', suffix: '' }, PLN: { prefix: 'zł', suffix: '' }, CZK: { prefix: 'Kč', suffix: '' }, CHF: { prefix: 'CHF ', suffix: '' }, NOK: { prefix: 'kr', suffix: '' }, ZAR: { prefix: 'R', suffix: '' }, ILS: { prefix: '₪', suffix: '' }, BYN: { prefix: 'Br', suffix: '' }, KZT: { prefix: '₸', suffix: '' }, UAH: { prefix: '₴', suffix: '' }, NZD: { prefix: 'NZ$', suffix: '' }, CRC: { prefix: '₡', suffix: '' }, QAR: { prefix: 'QR ', suffix: ' QR' }, }; /** 零小数位货币(如日元、韩元) */ const CURRENCY_ZERO_DECIMAL = ['JPY', 'KRW', 'CLP', 'VND', 'IDR', 'CRC']; /** 使用逗号作为小数点的货币(欧洲/拉美等格式) */ const DECIMAL_COMMA_CURRENCIES = new Set([ 'EUR', 'RUB', 'TRY', 'UAH', 'PLN', 'BRL', 'ARS', 'UYU', 'CZK', ]); // 符号歧义优先级:同一符号对应多个货币时,优先匹配列表中靠前的货币 const SYMBOL_AMBIGUITY_PRIORITY = ['CNY', 'USD']; // 惰性构建的符号检测列表(按符号长度从长到短排序) let _currencyDetectList = null; function buildCurrencyDetectList() { const symToCodes = {}; // 从 CURRENCY_SYMBOLS_BY_CODE 提取所有符号 for (const code in CURRENCY_SYMBOLS_BY_CODE) { const sym = CURRENCY_SYMBOLS_BY_CODE[code]; // 提取 prefix 和 suffix 中的符号(去掉空格) const prefixSym = sym.prefix.trim(); const suffixSym = sym.suffix.trim(); if (prefixSym) { if (!symToCodes[prefixSym]) symToCodes[prefixSym] = []; symToCodes[prefixSym].push(code); } if (suffixSym && suffixSym !== prefixSym) { if (!symToCodes[suffixSym]) symToCodes[suffixSym] = []; symToCodes[suffixSym].push(code); } } // 转换为 [symbol, code] 数组,并按符号长度从长到短排序 const list = []; for (const sym in symToCodes) { const codes = symToCodes[sym]; let code = codes[0]; // 有歧义时按优先级选择 if (codes.length > 1) { for (const priorityCode of SYMBOL_AMBIGUITY_PRIORITY) { if (codes.includes(priorityCode)) { code = priorityCode; break; } } } list.push([sym, code]); } list.sort((a, b) => b[0].length - a[0].length); return list; } /** * 从价格文本中检测货币代码(从价格文本中提取符号,匹配最精确的) * @param {string} priceText - 价格文本,如 "₹94,780.00"、"$1.23"、"123,45€" * @returns {{code: string, dc: boolean}|null} 货币代码和是否逗号小数,检测不到返回 null */ function detectCurrencyFromText(priceText) { const text = priceText.trim(); if (!_currencyDetectList) _currencyDetectList = buildCurrencyDetectList(); // 按符号长度从长到短匹配,优先匹配多字符符号(如 R$、CDN$、A$ 优先于 $) for (const [sym, code] of _currencyDetectList) { if (text.includes(sym)) { return { code, dc: DECIMAL_COMMA_CURRENCIES.has(code) }; } } return null; } /** * 从卖家到手价反推买家支付价(含手续费) * 参考 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, decimalComma = false) { if (!priceStr) return 0; // 提取数字部分:包含数字、逗号、点、空格(支持空格千分位如 "10 863,00") const m = String(priceStr).match(/([\d.,\s]+)/); if (!m) return 0; let s = m[1].replace(/\s+/g, ''); // 去掉所有空格(包括不间断空格) if (!s) return 0; const li = s.lastIndexOf('.'), ci = s.lastIndexOf(','); if (decimalComma) { // 已知该货币使用逗号作为小数点:点是千分位,逗号是小数点 s = ci >= 0 ? s.replace(/\./g, '').replace(',', '.') : s.replace(/\./g, ''); } else if (li > 0 && ci > 0) { // 两个符号都有:根据最后出现的位置判断 s = li > ci ? s.replace(/,/g, '') : s.replace(/\./g, '').replace(',', '.'); } else if (ci > 0 && li < 0) { // 只有逗号没有点:如果逗号后面是2位数字,认为是小数点;否则是千分位 const afterComma = s.length - ci - 1; s = afterComma === 2 ? s.replace(',', '.') : s.replace(/,/g, ''); } else { // 只有点或都没有:点是小数点 s = s.replace(/,/g, ''); } return Math.round(parseFloat(s) * 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 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('宝石'); } /** * 根据物品类型获取差异化价格范围 * 参考 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 } * @returns {Promise} Steam API 返回的 data(含 listingid, message, success 等) */ async function sellItem(params) { const sessionid = await resolveSessionId(); if (!sessionid) throw new Error('无法获取会话ID'); const body = new URLSearchParams({ sessionid, appid: String(params.appid), contextid: String(params.contextid), assetid: String(params.assetid), amount: String(params.amount || 1), price: String(params.priceCents), }); const data = await smeRequest('https://steamcommunity.com/market/sellitem/', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'X-Requested-With': 'XMLHttpRequest', }, body: body.toString(), }); if (!data.success) throw new Error(data.message || '上架失败'); return data; } /** * 多来源解析 contextid(参考 steam-market-listings-group 2.6.2) * g_rgAppContextData → SSR loaderData 扫描 → 页面 HTML 正则 → 静态映射 → 默认值 */ function resolveContextId(appId) { appId = String(appId); let contextId = ''; // 1) 旧版 g_rgAppContextData(兼容数组与对象两种结构) try { const appCtx = unsafeWindow.g_rgAppContextData && unsafeWindow.g_rgAppContextData[appId]; if (Array.isArray(appCtx) && appCtx.length > 0 && appCtx[0].id) { contextId = String(appCtx[0].id); } else if (appCtx && typeof appCtx === 'object') { if (appCtx.contexts && typeof appCtx.contexts === 'object') { const first = Object.keys(appCtx.contexts).find(k => /^\d+$/.test(k)); if (first) contextId = first; } else { const first = Object.keys(appCtx).find(k => /^\d+$/.test(k)); if (first) contextId = first; } } } catch (e) {} // 2) SSR loaderData 深度扫描 contextid 字段 if (!contextId) { try { const ssr = unsafeWindow.SSR && unsafeWindow.SSR.loaderData; if (Array.isArray(ssr)) { for (const entry of ssr) { let obj = entry; if (typeof entry === 'string') { try { obj = JSON.parse(entry); } catch (e) { continue; } } const html = JSON.stringify(obj || {}); const m = html.match(/"contextid"\s*:\s*"?(\d+)"?/i); if (m) { contextId = m[1]; break; } } } } catch (e) {} } // 3) 页面 HTML 源码正则(v1.5.1 P2-B:SSR 快照按页面生命周期缓存,避免每次全量序列化阻塞主线程) if (!contextId) { const html = getSsrHtmlSnapshot(); const m = html.match(/"contextid"\s*:\s*"?(\d+)"?/i) || html.match(/contextid=(\d+)/i); if (m) contextId = m[1]; } // 4) 静态映射 → 默认值 if (!contextId) contextId = APP_CONTEXT[appId] || '2'; return contextId; } /** * 从挂单信息获取 asset 信息(appid, contextid, assetid) * 使用多来源 contextid 解析,兼容新版 SSR 页面 */ function getAssetInfoFromOrder(order) { const appid = listingInfo?.appid || ''; return { appid, contextid: resolveContextId(appid), assetid: order.assetid || '', }; } // ==================== 自定义对话框 ==================== // 参考 steam-market-listings-group 2.6.2 的 confirm/price dialog 实现 /** 自定义确认对话框(支持 HTML 内容) */ function showConfirmDialog(message, opts) { const { title = '确认操作', okText = '确认', okClass = 'sme-dialog-ok' } = opts || {}; return new Promise(resolve => { const prevFocus = document.activeElement; const overlay = document.createElement('div'); overlay.className = 'sme-dialog-overlay'; overlay.innerHTML = ` `; document.body.appendChild(overlay); const cancelBtn = overlay.querySelector('#sme-dlg-cancel'); const okBtn = overlay.querySelector('#sme-dlg-ok'); // v1.5.1 P2-C:ESC 关闭 + Tab 焦点陷阱 + 关闭后焦点还原 const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); cleanup(false); } else if (e.key === 'Tab') { e.preventDefault(); const items = [cancelBtn, okBtn]; const idx = items.indexOf(document.activeElement); const next = e.shiftKey ? items[(idx <= 0 ? items.length : idx) - 1] : items[(idx + 1) % items.length]; next.focus(); } }; const cleanup = (result) => { document.removeEventListener('keydown', onKey, true); overlay.remove(); if (prevFocus && typeof prevFocus.focus === 'function') { try { prevFocus.focus(); } catch (e) {} } resolve(result); }; document.addEventListener('keydown', onKey, true); cancelBtn.addEventListener('click', () => cleanup(false)); okBtn.addEventListener('click', () => cleanup(true)); overlay.addEventListener('click', (e) => { if (e.target === overlay) cleanup(false); }); okBtn.focus(); }); } /** * 价格输入对话框(到手价 ↔ 买方支付价 联动) * @param {string} oldPrice - 原价格展示字符串 * @param {number} count - 待重新上架数量 * @param {number} suggestedCents - 建议到手价(分),用于预填 * @returns {Promise<{receivedCents:number, buyerCents:number}|null>} null 表示取消 */ function showPriceDialog(oldPrice, count, suggestedCents) { return new Promise(resolve => { const wi = getWalletInfo(); const currencyCode = wi.currency_code || 'USD'; const decimals = CURRENCY_CODES_TO_ROUND.includes(currencyCode) ? 0 : 2; const unit = Math.pow(10, decimals); const fmtAmount = cents => (cents / unit).toFixed(decimals); const suggestedStr = suggestedCents > 0 ? fmtAmount(suggestedCents) : ''; const buyerStr = suggestedCents > 0 ? fmtAmount(calcAmountToSend(suggestedCents).amount) : ''; const overlay = document.createElement('div'); overlay.className = 'sme-dialog-overlay'; overlay.innerHTML = ` `; document.body.appendChild(overlay); const receivedInput = overlay.querySelector('#sme-price-received'); const buyerInput = overlay.querySelector('#sme-price-buyer'); const cleanup = (result) => { overlay.remove(); resolve(result); }; // 解析用户输入金额为分(兼容千分位逗号与欧式小数逗号) const parseAmount = (str) => { let s = String(str).replace(/\s/g, '').replace(/[^\d.,]/g, ''); if (!s) return 0; if (s.includes(',') && s.includes('.')) { s = s.replace(/,/g, ''); } else if (/\d,\d{3}(?:\D|$)/.test(s)) { s = s.replace(/,/g, ''); } else { s = s.replace(',', '.'); } const num = parseFloat(s); return (!isNaN(num) && num > 0) ? Math.round(num * unit) : 0; }; receivedInput.addEventListener('input', () => { const receivedCents = parseAmount(receivedInput.value); if (!receivedCents) { buyerInput.value = ''; return; } buyerInput.value = fmtAmount(calcAmountToSend(receivedCents).amount); }); buyerInput.addEventListener('input', () => { const buyerCents = parseAmount(buyerInput.value); if (!buyerCents) { receivedInput.value = ''; return; } receivedInput.value = fmtAmount(calcReceivedAmount(buyerCents)); }); overlay.querySelector('#sme-price-cancel').addEventListener('click', () => cleanup(null)); overlay.querySelector('#sme-price-ok').addEventListener('click', () => { const receivedCents = parseAmount(receivedInput.value); if (!receivedCents) { receivedInput.focus(); return; } const buyerCents = calcAmountToSend(receivedCents).amount; cleanup({ receivedCents, buyerCents }); }); [receivedInput, buyerInput].forEach(inp => inp.addEventListener('keydown', (e) => { if (e.key === 'Enter') overlay.querySelector('#sme-price-ok').click(); })); // v1.5.1 P2-C:ESC 关闭对话框 overlay.addEventListener('keydown', (e) => { if (e.key === 'Escape') cleanup(null); }); overlay.addEventListener('click', (e) => { if (e.target === overlay) cleanup(null); }); receivedInput.focus(); receivedInput.select(); }); } // ==================== 下架后 assetid 重新获取 ==================== // 参考 steam-market-listings-group 2.6.2:下架回库后 assetid 可能变化, // 需要按 classid 轮询库存 API 定位可用资产 /** * 查询库存中指定 classid 的可用 assetid * @param {string} appId * @param {string} contextId * @param {string} classid * @param {Set} excludeIds - 排除已使用的 assetid * @returns {Promise} assetid,未找到返回空串 */ async function findReturnedAssetId(appId, contextId, classid, excludeIds) { try { // v1.5.0:走请求队列限流。原实现裸 fetch,在批量重上架循环中 // 每件物品都会触发最多 7 次(见 waitForReturnedAsset), // 完全不受任何限流器约束,极易触发 Steam 429。 const data = await enqueueInv(async () => { return await smeRequest(`https://steamcommunity.com/my/inventory/json/${appId}/${contextId}`, { headers: { 'Accept': 'application/json' }, onNonOk: 'null', }); }); if (!data || !data.success || !data.rgInventory) return ''; for (const [assetId, entry] of Object.entries(data.rgInventory)) { if (entry && String(entry.classid) === String(classid) && !excludeIds.has(assetId)) { return assetId; } } } catch (e) { W('inventory lookup error:', e); } return ''; } /** * 轮询等待物品回库并返回其(可能已变化的)assetid * @param {string} appId * @param {string} contextId * @param {string} classid * @param {Set} excludeIds - 排除已使用的 assetid * @param {number} timeoutMs - 超时毫秒 * @returns {Promise} assetid,超时返回空串 */ async function waitForReturnedAsset(appId, contextId, classid, excludeIds, timeoutMs) { const start = Date.now(); // v1.5.0:指数退避(1.5s → 3s → 6s),并给总尝试次数封顶。 // 原实现固定 1500ms 轮询,10 秒内最多 7 次 × N 件物品, // 在批量重上架 20 件时是 140 次全量库存请求。 let delay = 1500; let attempts = 0; const MAX_ATTEMPTS = 6; while (Date.now() - start < timeoutMs && attempts < MAX_ATTEMPTS) { attempts++; const assetId = await findReturnedAssetId(appId, contextId, classid, excludeIds); if (assetId) return assetId; if (Date.now() - start + delay >= timeoutMs) break; await sleep(delay); delay = Math.min(delay * 2, 6000); } return ''; } // ==================== 价格图表 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 analysisFabEl = null; let analysisPopEl = null; let analysisDismissBound = false; let analysisFabPosTimer = null; let allSellOrders = []; let _globalClassid = ''; // 从 asset_description 提取的全局 classid,用于下架后 assetid 重获取 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; tabPanes = {}; // v1.5.1 P2-A:面板重建时清空 Tab 缓存 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: '社区市场增强' }), ]), // v1.5.1 P2-C:Tab 组语义(role=tablist/tab + aria-selected,选中态由 switchTab 维护) h('div', { class: 'sme-tabs', role: 'tablist', 'aria-label': '面板标签页' }, [ h('button', { class: 'sme-tab active', id: 'sme-tab-summary', role: 'tab', 'aria-selected': 'true', html: ICONS.list + '上架汇总', onClick: () => switchTab('summary') }), h('button', { class: 'sme-tab', id: 'sme-tab-history', role: 'tab', 'aria-selected': 'false', html: ICONS.chart + '价格历史', onClick: () => switchTab('history') }), h('button', { class: 'sme-tab', id: 'sme-tab-orderbook', role: 'tab', 'aria-selected': 'false', html: ICONS.layers + '订单深度', onClick: () => switchTab('orderbook') }), h('button', { class: 'sme-tab', id: 'sme-tab-settings', role: 'tab', 'aria-selected': 'false', html: ICONS.settings + '设置', onClick: () => switchTab('settings') }), ]), ]), h('div', { class: 'sme-content', id: 'sme-content' }), ]); return panelEl; } // v1.5.1 P2-A:Tab 缓存——4 个常驻 pane,首次渲染后仅 display 切换(复用既有 .sme-tab-pane 样式), // 消除每次切 Tab 的整块清空重绘与重复请求。数据新鲜度:URL 变化时面板整体重建(createPanel 清空缓存); // 面板内批量操作(下架/重上架)后经 invalidateTabPane 主动失效重建。 let tabPanes = {}; /** 失效并重建某个 Tab 的常驻 pane(数据变更后调用;若为当前 Tab 立即重渲染) */ function invalidateTabPane(tab) { const pane = tabPanes[tab]; if (pane) { pane.remove(); delete tabPanes[tab]; } if (panelEl && currentTab === tab && document.getElementById('sme-content')) switchTab(tab); } function switchTab(tab) { currentTab = tab; panelEl.querySelectorAll('.sme-tab').forEach(b => { b.classList.remove('active'); b.setAttribute('aria-selected', b.id === 'sme-tab-' + tab ? 'true' : 'false'); }); const btn = document.getElementById('sme-tab-' + tab); if (btn) { btn.classList.add('active'); btn.setAttribute('aria-selected', 'true'); } const content = document.getElementById('sme-content'); if (!content) return; let pane = tabPanes[tab]; if (!pane) { pane = h('div', { class: 'sme-tab-pane', id: 'sme-pane-' + tab, role: 'tabpanel', 'aria-label': { summary: '上架汇总', history: '价格历史', orderbook: '订单深度', settings: '设置' }[tab] || tab }); if (tab === 'summary') renderSummary(pane); else if (tab === 'history') renderPriceHistory(pane); else if (tab === 'orderbook') renderOrderBook(pane); else if (tab === 'settings') renderSettings(pane); tabPanes[tab] = pane; } if (!pane.isConnected) content.appendChild(pane); content.querySelectorAll('.sme-tab-pane').forEach(p => p.classList.toggle('active', p === pane)); content.classList.toggle('sme-settings-grid', tab === 'settings'); } // ==================== 渲染:上架汇总 ==================== function renderSummary(container) { if (!allSellOrders || allSellOrders.length === 0) { container.appendChild(makeEmpty('您没有在售的此物品')); 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: '正在检测价格...' }), ])); // v1.5.1 P2-A:表格独立容器——检测完成后仅替换该容器(原地更新,信息条不闪烁) const tableWrap = h('div', { id: 'sme-summary-table-wrap' }); container.appendChild(tableWrap); renderSummaryTable(tableWrap, 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}种` })); } // v1.5.1 P2-A:原地更新——仅替换表格容器,信息条已在上方原地改写,不再整块重建 const wrap = document.getElementById('sme-summary-table-wrap'); if (wrap) { wrap.innerHTML = ''; renderSummaryTable(wrap, grouped, total, priceCheckResults); } else { // 兜底:旧结构(wrap 不存在时) container.innerHTML = ''; container.appendChild(infoBar || h('div')); renderSummaryTable(container, grouped, total, priceCheckResults); } // 刷新重新上架区域(挂到 summary 常驻 pane 内,随 Tab 一起显示/隐藏) const oldRelist = document.getElementById('sme-relist-section'); if (oldRelist) oldRelist.remove(); const pane = document.getElementById('sme-pane-summary') || document.getElementById('sme-content'); if (pane && priceCheckResults) { pane.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 makeStatCardWithRef(val, lbl, refHtml) { const valEl = h('div', { class: 'sme-stat-val' }, [ h('span', { class: 'sme-stat-val-main', text: String(val) }), ]); if (refHtml) { valEl.appendChild(h('span', { class: 'sme-stat-ref', text: '≈' + refHtml })); } return h('div', { class: 'sme-stat-card' }, [ valEl, 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 (!(await showConfirmDialog(`确定要下架全部 ${candidates.length} 件物品吗?`, { title: '批量下架', okText: '全部下架', okClass: 'sme-dialog-btn sme-dialog-ok' }))) 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 = await resolveSessionId(); 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 smeRequest(`https://steamcommunity.com/market/removelisting/${item.listingid}`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: `sessionid=${encodeURIComponent(sessionid)}`, expect: 'none', onNonOk: 'null', }); if (resp) { 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); // 刷新汇总(v1.5.1 P2-A:失效重建 summary 常驻 pane) invalidateTabPane('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-market-listings-group 2.6.2 的三步流程改进: * - 异步 resolveSessionId 替代同步 getSessionId(兼容新版 SSR 页面) * - waitForReturnedAsset 按 classid 重新定位 assetid(下架后可能变化) * - 自定义价格对话框(到手价 ↔ 买方支付价 联动) * - 上架失败重试(2 次) * - 分别追踪下架/上架失败数 */ let _relistBusy = false; async function handleRelist(overpricedOrders, checkResults) { if (_relistBusy) { toast('已有重新上架操作进行中,请稍候', 'warning'); return; } 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 = await resolveSessionId(); if (!sessionid) { statusEl.textContent = '无法获取会话ID(已尝试本地+远端多种方式)'; statusEl.className = 'sme-delist-status sme-error'; return; } // 步骤1: 弹出价格对话框(预填建议到手价,用户可自定义) const firstCheck = checkResults.get(overpricedOrders[0].buyerPrice); const suggestedCents = firstCheck ? firstCheck.suggestedCents : 0; const priceResult = await showPriceDialog( overpricedOrders[0].buyerPrice, overpricedOrders.length, suggestedCents ); if (priceResult === null) { statusEl.textContent = '已取消'; statusEl.className = 'sme-delist-status'; return; } const { receivedCents, buyerCents } = priceResult; // 步骤2: 二次确认 const confirmed = await showConfirmDialog( `即将下架 ${overpricedOrders.length} 件超价物品,` + `以新价格重新上架(您将收到 ${escapeHtml(formatCents(receivedCents))},` + `买方支付 ${escapeHtml(formatCents(buyerCents))}),确认继续?`, { title: '确认重新上架', okText: '确认重新上架', okClass: 'sme-dialog-blue' } ); if (!confirmed) { statusEl.textContent = '已取消'; statusEl.className = 'sme-delist-status'; return; } _relistBusy = true; btn.disabled = true; progressWrap.style.display = ''; progressBar.style.width = '0%'; let success = 0, delistFailed = 0, relistFailed = 0; let lastSellMsg = ''; const total = overpricedOrders.length; const usedAssetIds = new Set(); for (let i = 0; i < overpricedOrders.length; i++) { const order = overpricedOrders[i]; statusEl.textContent = `重新上架 ${i + 1}/${total}: 下架中...`; statusEl.className = 'sme-delist-status'; progressBar.style.width = Math.round((i / total) * 100) + '%'; try { // 步骤1: 下架 const removeResp = await smeRequest(`https://steamcommunity.com/market/removelisting/${order.listingid}`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: `sessionid=${encodeURIComponent(sessionid)}`, expect: 'none', onNonOk: 'null', }); if (!removeResp) { delistFailed++; W('Relist: remove failed:', order.listingid); continue; } // 从内存列表中移除 const idx = allSellOrders.indexOf(order); if (idx !== -1) allSellOrders.splice(idx, 1); // 步骤2: 等待物品返回库存并重新定位 assetid(下架后可能变化) statusEl.textContent = `重新上架 ${i + 1}/${total}: 等待物品返回库存...`; const assetInfo = getAssetInfoFromOrder(order); let sellAssetId = assetInfo.assetid; if (order.classid) { const returnedId = await waitForReturnedAsset( assetInfo.appid, assetInfo.contextid, order.classid, usedAssetIds, 10000 ); if (returnedId) { sellAssetId = returnedId; usedAssetIds.add(returnedId); if (returnedId !== assetInfo.assetid) { L('Relist: assetid changed after delist:', assetInfo.assetid, '->', returnedId); } } else { W('Relist: returned asset not found by classid', order.classid, '; using original assetid', assetInfo.assetid); } } else { // 无 classid 时回退到固定等待 await sleep(1500 + Math.random() * 1000); } if (!sellAssetId) { W('Relist: no assetid for order', order.listingid); relistFailed++; continue; } // 步骤3: 以新价格重新上架(2次重试) statusEl.textContent = `重新上架 ${i + 1}/${total}: 上架中 (${formatCents(buyerCents)})...`; let sellOk = false; let sellMsg = ''; for (let attempt = 0; attempt < 2 && !sellOk; attempt++) { if (attempt > 0) { statusEl.textContent = `重新上架 ${i + 1}/${total}: 重试上架...`; await sleep(1500); } try { await sellItem({ assetid: sellAssetId, appid: assetInfo.appid, contextid: assetInfo.contextid, priceCents: receivedCents, }); sellOk = true; } catch (sellErr) { sellMsg = sellErr.message; W(`Relist: sell failed (attempt ${attempt + 1}):`, sellErr.message); } } if (!sellOk) { relistFailed++; lastSellMsg = lastSellMsg || sellMsg; continue; } success++; } catch (err) { W('Relist error:', order.listingid, err); delistFailed++; } // 请求间隔 if (i < overpricedOrders.length - 1) await sleep(800); } progressBar.style.width = '100%'; let msg = `重新上架完成: 成功 ${success} 件`; if (delistFailed > 0) msg += `,下架失败 ${delistFailed} 件`; if (relistFailed > 0) msg += `,上架失败 ${relistFailed} 件(已回库存${lastSellMsg ? ': ' + lastSellMsg : ''})`; statusEl.textContent = msg; statusEl.className = 'sme-delist-status' + ((delistFailed || relistFailed) ? ' sme-error' : ' sme-ok'); toast(msg, (delistFailed || relistFailed) ? 'warning' : 'success'); setTimeout(() => { progressWrap.style.display = 'none'; }, 3000); _relistBusy = false; btn.disabled = false; // 刷新汇总(v1.5.1 P2-A:失效重建 summary 常驻 pane) priceCheckResults = null; setTimeout(() => invalidateTabPane('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 suffix = data.suffix || ''; const trendIcon = current >= avg ? ICONS.trendingUp : ICONS.trendingDown; const trendColor = current >= avg ? 'var(--sme-green)' : 'var(--sme-red)'; // 参考货币支持:价格历史 API 返回的是钱包货币 const showRef = settings.get('showReferencePrice'); const refCur = (settings.get('referenceCurrency') || 'CNY').toUpperCase(); const wi = getWalletInfo(); // 只有真实加载到的钱包货币才用,fallback 的 USD 不算 const sourceCur = _walletInfo ? wi.currency_code : null; let refRate = null; if (showRef && sourceCur && sourceCur !== refCur) { refRate = getExchangeRateSync(sourceCur, refCur); // 缓存未命中时异步加载,加载完成后重绘 if (refRate == null) { getExchangeRate(sourceCur, refCur) .then(rate => { if (rate != null && container.isConnected && priceHistoryData) { container.innerHTML = ''; renderPriceHistoryContent(container, priceHistoryData, settings.get('historyRange')); } }) .catch(() => {}); } } else if (showRef && !sourceCur) { // 钱包信息尚未加载,等待后重试 let tries = 0; const waitWallet = setInterval(() => { tries++; getWalletInfo(); // 尝试初始化钱包信息 if (_walletInfo || tries > 15 || !container.isConnected) { clearInterval(waitWallet); if (_walletInfo && container.isConnected && priceHistoryData) { container.innerHTML = ''; renderPriceHistoryContent(container, priceHistoryData, settings.get('historyRange')); } } }, 200); } const fmtRef = (valUnits) => { if (!refRate) return null; const amount = valUnits * refRate; const sym = CURRENCY_SYMBOLS_BY_CODE[refCur] || { prefix: '', suffix: ' ' + refCur }; const decimals = CURRENCY_ZERO_DECIMAL.includes(refCur) ? 0 : 2; return sym.prefix + amount.toFixed(decimals) + sym.suffix; }; const priceCards = [ makeStatCardWithRef(prefix + current.toFixed(2) + suffix, '当前价格', fmtRef(current)), makeStatCardWithRef(prefix + min.toFixed(2) + suffix, '最低价格', fmtRef(min)), makeStatCardWithRef(prefix + max.toFixed(2) + suffix, '最高价格', fmtRef(max)), makeStatCardWithRef(prefix + avg.toFixed(2) + suffix, '平均价格', fmtRef(avg)), makeStatCard(totalVol.toLocaleString(), '总成交量'), ]; container.appendChild(h('div', { class: 'sme-stats-grid' }, priceCards)); // 图表 const chartWrap = h('div', { class: 'sme-chart-wrap' }); chartWrap.innerHTML = buildPriceChartSVG(prices, prefix); container.appendChild(chartWrap); } // ==================== 渲染:订单深度 ==================== function renderOrderBook(container, force) { container.appendChild(makeLoading('正在获取订单深度...')); fetchOrderBook(force) .then(data => { orderBookData = data; container.innerHTML = ''; renderOrderBookContent(container, data); }) .catch(err => { container.innerHTML = ''; container.appendChild(makeError('订单深度加载失败: ' + err.message)); }); } /** 订单深度控制条复选框 */ function makeObCheckbox(label, checked, onChange, title) { const cb = h('input', { type: 'checkbox' }); cb.checked = checked; cb.addEventListener('change', () => onChange(cb.checked)); return h('label', { class: 'sme-ob-ctl', title: title || '' }, [cb, h('span', { text: label })]); } /** * 渲染单侧订单列表。数量比例画成行底色,文字叠在上面。 * @param {string} type 'sell' | 'buy' * @param {Array} orders 每档订单 [{price, qty, _cents}] * @param {Function} fmtNet 分 → 货币字符串(税后到手列) * @param {Function} fmtCny 税后到手分 → CNY 字符串 */ function renderOrderSection(container, title, type, orders, state, fmtNet, fmtCny) { const list = orders || []; const n = Math.min(list.length, state.levels); const visible = list.slice(0, n); const maxQty = Math.max(...visible.map(o => o.qty), 1); const head = h('div', { class: 'sme-order-head' }, [ h('span', { class: 'sme-order-price', text: '价格' }), state.showNet ? h('span', { class: 'sme-order-net', text: '税后到手' }) : null, state.showCny ? h('span', { class: 'sme-order-cny', text: 'CNY' }) : null, h('span', { class: 'sme-order-qty', text: '数量' }), state.showCum ? h('span', { class: 'sme-order-cum', text: '累计' }) : null, ]); const section = h('div', { class: 'sme-order-section' }, [ h('div', { class: 'sme-order-title ' + type, html: (type === 'buy' ? ICONS.trendingUp : ICONS.trendingDown) + '' + title + '' }), head, ]); if (!visible.length) { section.appendChild(h('div', { class: 'sme-ob-empty', text: '暂无' })); container.appendChild(section); return; } let cum = 0; for (const o of visible) { cum += o.qty; const widthPct = (o.qty / maxQty * 100).toFixed(1); const cents = o._cents || parsePriceToCents(o.price); section.appendChild(h('div', { class: 'sme-order-row ' + type }, [ h('div', { class: 'sme-order-bar', style: { width: widthPct + '%' } }), h('span', { class: 'sme-order-price', text: o.price }), state.showNet ? h('span', { class: 'sme-order-net', text: fmtNet(priceBeforeFees(cents)) }) : null, state.showCny ? h('span', { class: 'sme-order-cny', text: fmtCny(priceBeforeFees(cents)) }) : null, h('span', { class: 'sme-order-qty', text: o.qty.toLocaleString() }), state.showCum ? h('span', { class: 'sme-order-cum', text: cum.toLocaleString() }) : null, ])); } container.appendChild(section); } let _obCnyWalletTimer = null; let _obCnyRenderGen = 0; /** 钱包货币 → CNY。同源或汇率未就绪时返回 null,失败不抛到界面。 */ function resolveOrderBookCny(container) { if (_obCnyWalletTimer) { clearInterval(_obCnyWalletTimer); _obCnyWalletTimer = null; } if (!settings.get('orderDepthShowCny')) return null; const wi = getWalletInfo(); const sourceCur = _walletInfo ? wi.currency_code : null; if (!sourceCur) { let tries = 0; _obCnyWalletTimer = setInterval(() => { tries++; getWalletInfo(); if (_walletInfo || tries > 15 || !container.isConnected) { clearInterval(_obCnyWalletTimer); _obCnyWalletTimer = null; if (_walletInfo && _walletInfo.currency_code !== 'CNY' && container.isConnected && orderBookData) { container.innerHTML = ''; renderOrderBookContent(container, orderBookData); } } }, 200); return null; } if (sourceCur === 'CNY') return null; const rate = getExchangeRateSync(sourceCur, 'CNY'); if (rate == null) { const gen = _obCnyRenderGen; getExchangeRate(sourceCur, 'CNY') .then(loaded => { if (loaded == null || gen !== _obCnyRenderGen || !container.isConnected || !orderBookData) return; container.innerHTML = ''; renderOrderBookContent(container, orderBookData); }) .catch(() => {}); return null; } return rate; } function renderOrderBookContent(container, data) { const prefix = data.prefix || ''; const suffix = data.suffix || ''; _obCnyRenderGen++; const cnyRate = resolveOrderBookCny(container); // 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 sellAll = data.sellOrders || []; const buyAll = data.buyOrders || []; if (sellAll.length === 0 && buyAll.length === 0) { container.appendChild(makeEmpty('暂无订单数据')); return; } const maxLevels = Math.min(200, Math.max(sellAll.length, buyAll.length, 5)); const state = { levels: Math.min(Math.max(parseInt(settings.get('orderDepthLevels'), 10) || 30, 5), maxLevels), showNet: !!settings.get('orderDepthShowNet'), showCum: !!settings.get('orderDepthShowCumulative'), showCny: cnyRate != null && !!settings.get('orderDepthShowCny'), }; // 列表区独立成容器:滑动条/开关仅重绘列表,不重新请求 const listWrap = h('div'); const fmtNet = (cents) => formatCents(cents, prefix, suffix); const fmtCny = (netCents) => { if (cnyRate == null) return ''; const amount = (netCents / 100) * cnyRate; const sym = CURRENCY_SYMBOLS_BY_CODE.CNY || { prefix: '¥', suffix: '' }; return sym.prefix + amount.toFixed(2) + sym.suffix; }; const renderLists = () => { listWrap.innerHTML = ''; if (spread > 0) { listWrap.appendChild(h('div', { class: 'sme-spread-box' }, [ h('span', { class: 'sme-spread-lbl', text: '价差' }), h('span', { class: 'sme-spread-val', text: fmtPrice(spread) + ' (' + spreadPct + '%)' }), ])); } const board = h('div', { class: 'sme-ob-board' }); renderOrderSection(board, '卖单 (Sell Orders)', 'sell', sellAll, state, fmtNet, fmtCny); renderOrderSection(board, '买单 (Buy Orders)', 'buy', buyAll, state, fmtNet, fmtCny); listWrap.appendChild(board); const sellTotal = sellAll.reduce((s, o) => s + o.qty, 0); const buyTotal = buyAll.reduce((s, o) => s + o.qty, 0); listWrap.appendChild(h('div', { class: 'sme-ob-total' }, [ h('span', { html: '在售合计 ' + sellTotal.toLocaleString() + ' 件 · 求购合计 ' + buyTotal.toLocaleString() + ' 件 · 显示前 ' + Math.min(state.levels, maxLevels) + ' 档 / 共 ' + maxLevels + ' 档' }), ])); levelsVal.textContent = state.levels + ' 档'; }; const levelsVal = h('span', { class: 'sme-ob-levels-val' }); const slider = h('input', { type: 'range', class: 'sme-ob-slider', min: '5', max: String(maxLevels), step: '5', }); slider.value = String(state.levels); // v1.6.9:滑杆拖动改为尾沿节流。 // 原实现每个 input 事件都重建整个订单簿(最多 400 行)并同步写 localStorage, // 一次拖动约 40 次事件、累计 301.8ms,表现为拖动时明显卡顿。 // 节流后:档位数值与滑杆位置即时反馈(跟手),重绘与持久化合并到停手后一次。 // 注意 state.levels 仍即时更新,故 levelsVal 读到的始终是当前档位。 const applyLevels = debounce(() => { settings.set('orderDepthLevels', state.levels); // 期间若触发了刷新(container 整体重建),listWrap 已脱离文档: // 此时重绘只会白做 400 行,直接跳过。 if (!listWrap.isConnected) return; renderLists(); }, 120); slider.addEventListener('input', () => { state.levels = parseInt(slider.value, 10) || 30; levelsVal.textContent = state.levels + ' 档'; // 即时反馈,不等节流 applyLevels(); }); const refreshBtn = h('button', { class: 'sme-ob-refresh', text: '⟳ 刷新', onClick: async () => { refreshBtn.disabled = true; refreshBtn.textContent = '刷新中…'; try { const fresh = await fetchOrderBook(true); orderBookData = fresh; container.innerHTML = ''; renderOrderBookContent(container, fresh); toast('订单深度已刷新', 'success'); } catch (e) { refreshBtn.disabled = false; refreshBtn.textContent = '⟳ 刷新'; toast('刷新失败:' + friendlyError(e), 'error'); } }, }); const controls = h('div', { class: 'sme-ob-controls' }, [ h('label', { class: 'sme-ob-ctl' }, [h('span', { text: '档数' }), slider, levelsVal]), makeObCheckbox('税后到手', state.showNet, v => { state.showNet = v; settings.set('orderDepthShowNet', v); renderLists(); }), makeObCheckbox('CNY到手', !!settings.get('orderDepthShowCny'), v => { settings.set('orderDepthShowCny', v); state.showCny = v && cnyRate != null; if (v && cnyRate == null && !(_walletInfo && _walletInfo.currency_code === 'CNY')) { container.innerHTML = ''; renderOrderBookContent(container, data); return; } renderLists(); if (tabPanes.settings && currentTab !== 'settings') invalidateTabPane('settings'); }, (_walletInfo && _walletInfo.currency_code === 'CNY') ? '当前钱包已是人民币,税后到手列就是人民币到手价' : '把税后到手按汇率换算成人民币'), makeObCheckbox('累计', state.showCum, v => { state.showCum = v; settings.set('orderDepthShowCumulative', v); renderLists(); }), refreshBtn, ]); container.appendChild(controls); container.appendChild(listWrap); renderLists(); } // ==================== 渲染:设置 ==================== function renderSettings(container) { // v1.5.1 P2-D 修复:布局容器下沉为内层 wrapper,避免 pane 同时持有 .sme-tab-pane 和 // .sme-settings-grid 导致 CSS 同优先级后写的 .sme-settings-grid{display:flex} // 在 pane 失活后覆盖 .sme-tab-pane{display:none},出现切到其他 Tab 后设置页仍残留的问题。 const layout = h('div', { class: 'sme-settings-grid' }); function createGroup(titleHtml, children) { const group = h('div', { class: 'sme-setting-group' }); group.appendChild(h('div', { class: 'sme-setting-group-title', html: titleHtml })); children.forEach(child => group.appendChild(child)); layout.appendChild(group); return group; } function makeRow(name, desc, control) { // v1.5.1 P2-C:为行内控件补可访问名称(复选框/输入框无可见标签时读屏可用) try { const input = control.querySelector && control.querySelector('input, select, textarea'); if (input && !input.getAttribute('aria-label')) input.setAttribute('aria-label', name); } catch (e) {} return h('div', { class: 'sme-setting-row' }, [ h('div', { class: 'sme-setting-info' }, [ h('div', { class: 'sme-setting-name', text: name }), h('div', { class: 'sme-setting-desc', text: desc }), ]), control, ]); } // --- 基础设置 --- createGroup('基础设置', [ makeRow('详情页分析面板', isSteamClient() ? '当前在 Steam 客户端,分析面板固定用右下角按钮打开。' : '当前在浏览器,默认嵌在页面顶部。可以改成右下角按钮,这个选择会记住。', listingPanelModeControl()), makeRow('已拥有物品标识', '在市场浏览页和详情页显示库存中已拥有的物品数量', makeToggle('ownedBadges', settings.get('ownedBadges'), val => settings.set('ownedBadges', val))), makeRow('自动加载价格历史', '进入物品详情页时自动加载价格趋势图', makeToggle('autoLoadHistory', settings.get('autoLoadHistory'), val => settings.set('autoLoadHistory', val))), makeRow('默认价格历史范围', '价格趋势图默认显示的时间范围', 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), })), makeRow('显示参考货币价格', '在市场价格旁显示换算后的参考货币价格', makeToggle('showReferencePrice', settings.get('showReferencePrice'), val => { settings.set('showReferencePrice', val); if (val) { // 立即尝试加载汇率并刷新显示 getExchangeRate('USD', settings.get('referenceCurrency') || 'CNY').catch(() => {}); // v1.5.7 修复 v2.16-fix 的回归:renderSettings 作用域内没有 pageType 变量, // 直接引用会抛 ReferenceError,导致开关「开了却没反应」。改为直接复用注入入口。 startRefPriceInjection(); } else { cleanupRefPriceObserver(); } })), makeRow('参考货币', '用于换算显示的目标货币', h('select', { class: 'sme-setting-select', html: [ ['人民币 CNY', 'CNY'], ['美元 USD', 'USD'], ['欧元 EUR', 'EUR'], ['英镑 GBP', 'GBP'], ['日元 JPY', 'JPY'], ['韩元 KRW', 'KRW'], ['印度卢比 INR', 'INR'], ['俄罗斯卢布 RUB', 'RUB'], ['巴西雷亚尔 BRL', 'BRL'], ['澳元 AUD', 'AUD'], ['加元 CAD', 'CAD'], ['新加坡元 SGD', 'SGD'], ['港币 HKD', 'HKD'], ['新台币 TWD', 'TWD'], ['泰铢 THB', 'THB'], ['越南盾 VND', 'VND'], ['印尼盾 IDR', 'IDR'], ['马来西亚林吉特 MYR', 'MYR'], ['菲律宾比索 PHP', 'PHP'], ['墨西哥比索 MXN', 'MXN'], ['阿根廷比索 ARS', 'ARS'], ['智利比索 CLP', 'CLP'], ['哥伦比亚比索 COP', 'COP'], ['秘鲁索尔 PEN', 'PEN'], ['乌拉圭比索 UYU', 'UYU'], ['沙特里亚尔 SAR', 'SAR'], ['阿联酋迪拉姆 AED', 'AED'], ['土耳其里拉 TRY', 'TRY'], ['波兰兹罗提 PLN', 'PLN'], ['捷克克朗 CZK', 'CZK'], ['瑞士法郎 CHF', 'CHF'], ['挪威克朗 NOK', 'NOK'], ['南非兰特 ZAR', 'ZAR'], ['以色列新谢克尔 ILS', 'ILS'], ['白俄罗斯卢布 BYN', 'BYN'], ['哈萨克斯坦坚戈 KZT', 'KZT'], ['乌克兰格里夫纳 UAH', 'UAH'], ['新西兰元 NZD', 'NZD'], ['哥斯达黎加科朗 CRC', 'CRC'], ['卡塔尔里亚尔 QAR', 'QAR'], ].map(([l, v]) => ``).join(''), onChange: e => { settings.set('referenceCurrency', e.target.value); // v1.5.7:切换货币后整体重注(cleanupRefPriceObserver 会移除所有 // .sme-ref-price 并清掉 data-sme-ref-injected 标记)。 // 原实现引用了 renderSettings 作用域内不存在的 pageType,抛 ReferenceError。 cleanupRefPriceObserver(); if (settings.get('showReferencePrice')) startRefPriceInjection(); }, })), makeRow('订单深度 CNY 到手价', '外币钱包时,订单深度增加一列人民币税后到手。钱包已是人民币时不重复显示,沿用「税后到手」列。', makeToggle('orderDepthShowCny', settings.get('orderDepthShowCny'), val => { settings.set('orderDepthShowCny', val); if (val) { const code = _walletInfo && _walletInfo.currency_code; if (code && code !== 'CNY') getExchangeRate(code, 'CNY').catch(() => {}); } invalidateTabPane('orderbook'); })), ]); // --- 市场顺序跟踪设置(v1.4.6) --- createGroup('市场顺序跟踪', [ makeRow('我的挂单市场顺序', '物品页展示我的上架物品在市场中的排序位置(价格从低到高),并在上架表格和卡片上注入顺序徽章', makeToggle('marketRankTracker', settings.get('marketRankTracker'), val => { settings.set('marketRankTracker', val); if (!val) cleanupMarketRankTracker(); })), makeRow('自动刷新顺序', '定时探测市场变化(售出/下架会导致顺序前移),自动更新顺序值', makeToggle('rankAutoRefresh', settings.get('rankAutoRefresh'), val => settings.set('rankAutoRefresh', val))), makeRow('刷新间隔', '顺序自动刷新的时间间隔(连续失败时自动退避延长)', h('select', { class: 'sme-setting-select', html: [['30秒', 30], ['1分钟', 60], ['2分钟', 120], ['5分钟', 300], ['10分钟', 600]] .map(([l, v]) => ``).join(''), onChange: e => settings.set('rankRefreshInterval', Number(e.target.value)), })), ]); // --- 显示更多价格(v1.5.0,新版 React 市场页) --- createGroup('显示更多价格', [ makeRow('显示更多价格行', '新版市场物品页订单表格默认只显示前 6 行,开启后显示更多行(更改行数即时生效,重新开关需刷新页面)', makeToggle('showMorePrices', settings.get('showMorePrices'), onShowMorePricesToggle)), makeRow('显示行数', '订单表格显示的价格行数(6-100)', h('div', { class: 'sme-setting-inline' }, [ h('input', { type: 'number', class: 'sme-number-input', value: String(settings.get('showMorePricesRows')), step: '1', min: '6', max: '100', onChange: e => { let n = parseInt(e.target.value, 10); if (isNaN(n)) n = SMM_DEFAULT_ROWS; settings.set('showMorePricesRows', Math.min(SMM_MAX_ROWS, Math.max(SMM_INTERCEPT_END, n))); }, }), h('span', { style: { color: 'var(--sme-text-dim)', fontSize: '12px' }, text: '行' }), ])), ]); // --- 价格检测设置 --- createGroup(ICONS.dollarSign + '价格检测设置', [ makeRow('自动价格检测', '进入上架汇总页时自动检测超价/低价', makeToggle('autoCheckPrices', settings.get('autoCheckPrices'), val => settings.set('autoCheckPrices', val))), makeRow('定价算法', '计算建议价格时使用的数据源策略', 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)), })), makeRow('价格偏移', '在计算价格上加减偏移量(可为负,如 -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: '元' }), ])), makeRow('历史均价时间窗口', '计算历史加权均价时回溯的小时数', 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: '小时' }), ])), makeRow('智能过滤异常低价挂单', '最低价挂单数量极少时使用第二低价作为参考', makeToggle('ignoreLowQuantity', settings.get('ignoreLowQuantity'), val => settings.set('ignoreLowQuantity', val))), makeRow('价格范围限制', '建议价格将被钳制在此范围内', 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: '元' }), ])), ]); // --- 物品类型差异化定价 --- createGroup(ICONS.layers + '物品类型差异化定价', [ makeRow('普通卡牌价格范围', '普通交易卡牌的最低/最高上架价格', 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: '元' }), ])), makeRow('闪箔卡牌价格范围', '闪亮(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: '元' }), ])), makeRow('杂项物品价格范围', '非卡牌类物品的最低/最高上架价格', 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: '元' }), ])), ]); // --- 库存页设置 --- createGroup(ICONS.tag + '库存页设置', [ makeRow('库存价格标签', '在库存页物品上显示市场参考价格', makeToggle('inventoryPriceLabels', settings.get('inventoryPriceLabels'), val => settings.set('inventoryPriceLabels', val))), makeRow('快速出售按钮', '选中物品时显示快速出售面板(最高买单/最低卖单等)', makeToggle('quickSellButtons', settings.get('quickSellButtons'), val => settings.set('quickSellButtons', val))), ]); // --- 市场挂单页设置 --- createGroup(ICONS.list + '市场挂单页设置', [ makeRow('批量操作按钮', '在"我的挂单"页显示批量选择/下架/重新上架按钮', makeToggle('marketBatchButtons', settings.get('marketBatchButtons'), val => settings.set('marketBatchButtons', val))), makeRow('排序功能', '点击表头按名称/价格/日期排序', makeToggle('marketSorting', settings.get('marketSorting'), val => settings.set('marketSorting', val))), makeRow('搜索过滤', '在挂单列表中添加搜索框实时过滤', makeToggle('marketSearch', settings.get('marketSearch'), val => settings.set('marketSearch', val))), ]); // --- v1.6.0:市场交易记录汇总与导出 --- createGroup(ICONS.chart + '交易记录汇总与导出', [ makeRow('汇总与导出工具条', '在市场页"出售物品"旁显示汇总/导出按钮与格式下拉', makeToggle('marketHistoryPanel', settings.get('marketHistoryPanel'), val => settings.set('marketHistoryPanel', val))), makeRow('隐藏官方 Beta 公告条', '隐藏社区市场首页顶部的"正在进行 Beta 测试"官方公告', makeToggle('hideMarketBetaNote', settings.get('hideMarketBetaNote'), val => settings.set('hideMarketBetaNote', val))), makeRow('默认导出格式', '导出按钮默认使用的文件格式(分析窗口内可临时切换)', h('select', { class: 'sme-setting-select', 'aria-label': '默认导出格式', html: [['CSV(表格)', 'csv'], ['TSV(表格)', 'tsv'], ['JSON(结构化)', 'json'], ['Markdown(报告)', 'md']] .map(([l, v]) => ``).join(''), onChange: (e) => { const v = ['csv', 'tsv', 'json', 'md'].includes(e.target.value) ? e.target.value : 'csv'; settings.set('marketHistoryFormat', v); mhState.format = v; mhUi.exportFormat = null; const sel = document.getElementById('sme-mh-format'); if (sel) sel.value = v; }, })), makeRow('抓取请求间隔', '翻页抓取交易记录时的间隔(毫秒)。过小容易被 Steam 限流', h('input', { class: 'sme-number-input', type: 'number', min: '200', step: '100', 'aria-label': '抓取请求间隔', value: String(Math.max(200, parseInt(settings.get('marketHistoryDelay'), 10) || 700)), onChange: (e) => settings.set('marketHistoryDelay', Math.max(200, parseInt(e.target.value, 10) || 700)), })), makeRow('汇总榜单条目数', '汇总弹窗"按物品"榜单最多展示多少行', h('input', { class: 'sme-number-input', type: 'number', min: '5', max: '100', step: '5', 'aria-label': '汇总榜单条目数', value: String(Math.max(5, Math.min(100, parseInt(settings.get('marketHistoryTopN'), 10) || 20))), onChange: (e) => settings.set('marketHistoryTopN', Math.max(5, Math.min(100, parseInt(e.target.value, 10) || 20))), })), ]); // --- 交易报价设置 --- createGroup(ICONS.exchange + '交易报价设置', [ makeRow('交易报价价值摘要', '显示双方物品清单、总价值和差额对比', makeToggle('tradeOfferSummary', settings.get('tradeOfferSummary'), val => settings.set('tradeOfferSummary', val))), ]); // --- 数据管理 --- createGroup('数据管理', [ h('div', { class: 'sme-settings-full', 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]); // v1.6.0:交易记录索引也在本脚本缓存里,一并清空并复位内存态 mhState.rows = []; mhState.totalCount = 0; mhState.fetchedAt = 0; const sid = getSteamId(); if (sid) { clearInvCache(sid); try { sessionStorage.removeItem(SESSION_KEY); } catch (e) {} } toast('缓存已清除', 'success'); }}), ]), h('div', { class: 'sme-info-box sme-settings-full' }, [ h('div', { text: `Steam 社区市场增强 v${VERSION}` }), h('div', { text: '功能:上架汇总分组 · 超价/低价检测 · 一键重新上架(自定义改价+assetid重获取+多级会话ID回退) · 批量下架 · 价格历史趋势 · 订单深度 · 已拥有标识 · 我的挂单排序搜索批量操作 · 库存页价格标签与快速出售 · 交易报价价值摘要 · 物品类型差异化定价 · 交易记录汇总与导出(CSV/JSON)' }), ]), ]); container.appendChild(layout); } 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 || '加载失败' }), ]); } // ==================== 参考货币价格注入(v1.4.9) ==================== const _refPriceObserver = { observer: null, timer: null }; /** 注入重试调度状态:SPA 换页时随 cleanupRefPriceObserver 一起清空 */ const _refPriceInject = { running: false, timers: [] }; const _pageCurrencyCache = { code: null, ts: 0 }; const PAGE_CURRENCY_CACHE_TTL = 60 * 1000; // 页面货币缓存1分钟 /** 从页面内容中检测当前显示的货币代码(新版 React 页面 SSR 数据优先) */ function detectPageCurrencyCode() { // 缓存命中则直接返回 const now = Date.now(); if (_pageCurrencyCache.code && now - _pageCurrencyCache.ts < PAGE_CURRENCY_CACHE_TTL) { return _pageCurrencyCache.code; } let result = 'USD'; // 默认回退 // 1. 优先从 SSR renderContext 中获取 try { const ssr = unsafeWindow.SSR?.renderContext; if (ssr?.queryData?.[0]?.state?.data?.wallet_currency != null) { result = getCurrencyCode(ssr.queryData[0].state.data.wallet_currency); _pageCurrencyCache.code = result; _pageCurrencyCache.ts = now; return result; } // 尝试从 queryData 其他条目查找 if (Array.isArray(ssr?.queryData)) { for (const entry of ssr.queryData) { const cur = entry?.state?.data?.wallet_currency; if (cur != null) { result = getCurrencyCode(cur); _pageCurrencyCache.code = result; _pageCurrencyCache.ts = now; return result; } const cur2 = entry?.data?.wallet_currency; if (cur2 != null) { result = getCurrencyCode(cur2); _pageCurrencyCache.code = result; _pageCurrencyCache.ts = now; return result; } } } } catch (e) {} // 2. 从 wallet info 获取 try { const wi = getWalletInfo?.(); if (wi?.wallet_currency != null) { result = getCurrencyCode(wi.wallet_currency); _pageCurrencyCache.code = result; _pageCurrencyCache.ts = now; return result; } } catch (e) {} // 3. 从页面中的价格文本符号推断(扫描实际价格元素) // 找到页面中出现最多的价格符号作为判断依据 const priceLeafSpans = findPriceLeafSpans(); const codeCount = {}; for (const { text } of priceLeafSpans) { const detected = detectCurrencyFromText(text); if (detected) { codeCount[detected.code] = (codeCount[detected.code] || 0) + 1; } } if (Object.keys(codeCount).length > 0) { // 返回出现次数最多的货币 result = Object.entries(codeCount).sort((a, b) => b[1] - a[1])[0][0]; } _pageCurrencyCache.code = result; _pageCurrencyCache.ts = now; return result; } /** 查找页面中所有叶子价格 span(无元素子节点、符合价格文本格式) */ function findPriceLeafSpans() { const results = []; // v1.5.1 P2-B:TreeWalker 流式遍历替代 querySelectorAll('span') 全量物化(市场页 ~10⁴ 节点); // closest() 单次原生调用替代逐级 parentElement 遍历 const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT, { acceptNode(node) { if (node.tagName !== 'SPAN') return NodeFilter.FILTER_SKIP; return node.childElementCount === 0 ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP; }, }); let span; while ((span = walker.nextNode())) { if (span.classList.contains('sme-ref-price')) continue; // 跳过我们自己注入的内容(订单深度、统计卡片等) if (span.closest('#sme-mr-panel, .sme-settings-grid, .sme-setting-select, .sme-stats-grid, .sme-spread-box')) continue; const text = span.textContent.trim(); if (text.length < 4 || text.length > 25) continue; if (!isPriceText(text)) continue; results.push({ el: span, text }); } return results; } /** * v1.5.1 P2-G:扫描"起价: " / "Starting at: " 形式的搜索页结果卡价格。 * 区别于 findPriceLeafSpans(只匹配纯价格文本),搜索页卡内"起价: ₹9.00"是 label+price 一体的 span/div。 * 注入方式:appenchild 到原元素后,ref price 跟在原文本后;返回 [{el, text, sourceCode}] 列表。 */ function findSearchPageStartingPrices() { const results = []; // 中文 / 英文起价前缀;冒号可有可无(Steam 新版搜索页 SSR 渲染为「起价:₹32.00」/「From $0.32」, // 后者没有冒号且前缀是 From,因此把冒号设为可选并补上 From / 从 两种前缀) const re = /^(?:起价\s*[::]?\s*|从\s*[::]?\s*|starting\s+at\s*[::]?\s*|start\s+price\s*[::]?\s*|from\s*[::]?\s*)(.+)$/i; const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT, { acceptNode(node) { const tag = node.tagName; if (tag !== 'SPAN' && tag !== 'DIV') return NodeFilter.FILTER_SKIP; // 必须是叶子元素(无元素子节点);文本节点可以有,ref 注入后再扫描时会被 childElementCount>0 过滤 return node.childElementCount === 0 ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP; }, }); let el; while ((el = walker.nextNode())) { if (el.dataset.smeRefInjected) continue; if (el.classList.contains('sme-ref-price')) continue; if (el.closest('#sme-mr-panel, .sme-settings-grid, .sme-setting-select, .sme-stats-grid, .sme-spread-box')) continue; const text = el.textContent.trim(); if (text.length < 6 || text.length > 40) continue; const m = text.match(re); if (!m) continue; // 前缀用的是非捕获组 (?:…),价格落在 m[1];曾误取 m[2] 导致 // "Cannot read properties of undefined (reading 'trim')" 在市场首页刷屏。 const priceStr = (m[1] || '').trim(); if (!priceStr) continue; const detected = detectCurrencyFromText(priceStr); if (!detected) continue; results.push({ el, text, priceStr, sourceCode: detected.code, dc: detected.dc }); } return results; } /** v1.5.1 P2-B:空闲调度注入——全页扫描移出主线程关键路径(不可见时跳过) */ function scheduleRefPriceInject() { if (document.visibilityState === 'hidden') return; if (typeof requestIdleCallback === 'function') { requestIdleCallback(() => { if (document.visibilityState === 'visible') injectAllReferencePrices(); }, { timeout: 2000 }); } else { setTimeout(() => { if (document.visibilityState === 'visible') injectAllReferencePrices(); }, 0); } } /** * v1.5.7:启动参考货币换算注入 + 常驻观察器(详情页与浏览/搜索页共用)。 * React 渲染有延迟,先重试若干次,最后建 observer 覆盖后续分页/筛选追加的节点。 * v1.5.1 只在 initListingPage 里启动,导致 /market/search(pageType=browse)永远不注入。 */ function startRefPriceInjection() { if (!settings.get('showReferencePrice')) return; if (_refPriceInject.running) return; _refPriceInject.running = true; let attempts = 0; const track = (fn, ms) => { const t = setTimeout(fn, ms); _refPriceInject.timers.push(t); return t; }; const tryInject = () => { attempts++; scheduleRefPriceInject(); if (attempts < 5) { track(tryInject, attempts * 500); } else { _refPriceInject.running = false; setupRefPriceObserver(); } }; track(tryInject, 800); } /** 检查元素是否在不应注入参考价格的 SME 容器内(设置面板、排名面板、统计卡片等已主动加参考价的区域) */ function isInsideSmeContainer(el) { let p = el.parentElement; while (p && p !== document.body) { if (p.id === 'sme-mr-panel') return true; if (p.classList && ( p.classList.contains('sme-settings-grid') || p.classList.contains('sme-setting-select') || p.classList.contains('sme-stats-grid') || p.classList.contains('sme-spread-box') )) return true; p = p.parentElement; } return false; } /** * 在详情页所有市场价格旁注入换算后的参考货币价格 */ function injectAllReferencePrices() { if (!settings.get('showReferencePrice')) return; const refCur = (settings.get('referenceCurrency') || 'CNY').toUpperCase(); // 先获取全局默认货币(用于旧版选择器匹配的元素) const defaultSourceCode = detectPageCurrencyCode(); // 查找页面上的价格元素 const priceItems = []; // [{ el, cents, sourceCode }] // 1. 经典选择器匹配(旧版页面) const classicSelectors = [ '.market_listing_price_with_fee', '.market_listing_price_without_fee', '.market_listing_price.market_table_value', '#market_buyorder_info .market_commodity_orders_header_promote', ]; document.querySelectorAll(classicSelectors.join(',')).forEach(el => { if (el.dataset.smeRefInjected) return; if (isInsideSmeContainer(el)) return; const text = el.textContent.trim(); // 优先从文本中检测货币(获取代码+逗号小数标志),检测不到用全局默认 const detected = detectCurrencyFromText(text); const code = detected ? detected.code : defaultSourceCode; if (!code || code === refCur) return; const dc = detected ? detected.dc : DECIMAL_COMMA_CURRENCIES.has(code); const cents = parsePriceToCents(text, dc); if (!cents || cents <= 0) return; priceItems.push({ el, cents, sourceCode: code }); }); // 2. 扫描纯文本叶子 span(新版 React 页面,如 ₹94,780.00) // 只匹配没有子元素的叶子 span,避免父子重复匹配 const priceLeafSpans = findPriceLeafSpans(); for (const { el: span, text } of priceLeafSpans) { if (span.dataset.smeRefInjected) continue; if (span.querySelector('.sme-ref-price')) continue; // 从价格文本中直接检测货币符号(最准确) const detected = detectCurrencyFromText(text); if (!detected) continue; if (detected.code === refCur) continue; // 同源跳过 const cents = parsePriceToCents(text, detected.dc); if (cents <= 0 || cents >= 100000000) continue; priceItems.push({ el: span, cents, sourceCode: detected.code }); } // 3. v1.5.1 P2-G:搜索页"起价: " / "Starting at: " 形式(label+price 一体的 span/div) // 区别于上面纯价格 span——这里 text 形如 "起价: ₹9.00" 含中文/英文 label const searchPrices = findSearchPageStartingPrices(); for (const { el, priceStr, sourceCode, dc } of searchPrices) { if (el.dataset.smeRefInjected) continue; if (sourceCode === refCur) continue; // 同源跳过 const cents = parsePriceToCents(priceStr, dc); if (!cents || cents <= 0) continue; priceItems.push({ el, cents, sourceCode }); } if (priceItems.length === 0) return; L('参考货币换算: 新增', priceItems.length, '个价格'); // 预加载所有涉及的汇率 const codesNeeded = [...new Set(priceItems.map(p => p.sourceCode))]; codesNeeded.forEach(code => getExchangeRate(code, refCur).catch(() => {})); // 注入参考价格 priceItems.forEach(({ el, cents, sourceCode }) => { el.dataset.smeRefInjected = '1'; const refEl = h('span', { class: 'sme-ref-price loading', text: '…' }); el.appendChild(refEl); // 订单深度区域不显示到手价(避免太挤) const inOrderSection = el.closest && el.closest('.sme-order-section'); const showNet = !inOrderSection; const netCents = showNet ? priceBeforeFees(cents) : 0; const promises = [formatReferencePrice(cents, sourceCode, refCur)]; if (showNet) promises.push(formatReferencePrice(netCents, sourceCode, refCur)); Promise.all(promises).then(([refStr, netStr]) => { if (refStr && refEl.isConnected) { let html = refStr; if (showNet && netStr) { html += ' (' + netStr + ')'; } refEl.innerHTML = html; refEl.classList.remove('loading'); } else if (refEl.parentNode) { refEl.remove(); } }).catch(() => { if (refEl.parentNode) refEl.remove(); }); }); } /** 判断文本是否是纯净的价格字符串(货币符号+数字,不含其他文字) */ function isPriceText(text) { const t = text.trim(); if (t.length < 4 || t.length > 25) return false; // 必须包含数字 if (!/\d/.test(t)) return false; // 使用 detectCurrencyFromText 检测货币符号(复用符号映射,避免重复定义) const detected = detectCurrencyFromText(t); if (!detected) return false; // 检查是否只包含货币符号、数字、千分位逗号/小数点和空格 // 去掉已识别的货币符号后,剩余部分应只含数字、逗号、点、空格 const sym = getDetectSymbolForCode(detected.code); const remaining = sym ? t.replace(sym, '').trim() : t; return /^[\d.,\s]+$/.test(remaining); } /** 从 CURRENCY_SYMBOLS_BY_CODE 获取货币的检测用符号(去掉前后空格的 prefix) */ function getDetectSymbolForCode(code) { const sym = CURRENCY_SYMBOLS_BY_CODE[code]; if (!sym) return ''; // 优先使用 prefix(去掉空格),大多数货币的符号在 prefix 中 const prefixSym = sym.prefix.trim(); if (prefixSym) return prefixSym; const suffixSym = sym.suffix.trim(); if (suffixSym) return suffixSym; return ''; } /** 启动参考价格的 MutationObserver(React 重渲染后自动补注) */ function setupRefPriceObserver() { if (_refPriceObserver.observer) return; if (!settings.get('showReferencePrice')) return; _refPriceObserver.observer = new MutationObserver(mutations => { // 忽略仅由我们自己的 .sme-ref-price 引起的变化,避免无限循环注入 // 同时忽略设置面板/排名面板等非价格区域的变化 let hasExternalChange = false; for (const mut of mutations) { // 检查新增节点是否全是我们自己注入的 if (mut.type === 'childList') { for (const node of mut.addedNodes) { if (node.nodeType === 1 && ( node.classList?.contains('sme-ref-price') || node.querySelector?.('.sme-ref-price') || isInsideSmeContainer(node) )) continue; if (node.nodeType === 3) continue; // 文本节点忽略 hasExternalChange = true; break; } // 检查被移除的节点 for (const node of mut.removedNodes) { if (node.nodeType === 1 && ( node.classList?.contains('sme-ref-price') || isInsideSmeContainer(node) )) continue; if (node.nodeType === 3) continue; hasExternalChange = true; break; } } else if (mut.type === 'characterData') { // 文本变化:如果目标在 .sme-ref-price 内或设置/排名面板内则忽略 let el = mut.target; while (el && el.nodeType === 3) el = el.parentElement; if (el?.classList?.contains('sme-ref-price')) continue; if (el && isInsideSmeContainer(el)) continue; hasExternalChange = true; } if (hasExternalChange) break; } if (!hasExternalChange) return; if (_refPriceObserver.timer) clearTimeout(_refPriceObserver.timer); _refPriceObserver.timer = setTimeout(() => { scheduleRefPriceInject(); }, 800); }); _refPriceObserver.observer.observe(document.body, { childList: true, subtree: true, characterData: true }); } function cleanupRefPriceObserver() { if (_refPriceObserver.observer) { _refPriceObserver.observer.disconnect(); _refPriceObserver.observer = null; } if (_refPriceObserver.timer) { clearTimeout(_refPriceObserver.timer); _refPriceObserver.timer = null; } // v1.5.7:换页时停掉尚未执行的重试注入,避免旧页面的定时器打到新页面上 _refPriceInject.timers.forEach(clearTimeout); _refPriceInject.timers = []; _refPriceInject.running = false; document.querySelectorAll('.sme-ref-price').forEach(el => el.remove()); // 清除注入标记,方便重新注入 document.querySelectorAll('[data-sme-ref-injected]').forEach(el => { delete el.dataset.smeRefInjected; }); } // ==================== 面板注入(详情页) ==================== 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() { const panel = createPanel(); removeAnalysisCloseBtn(); // 已在页面文档流中则不重复插入;挂在浮窗里时允许挪回页面 if (panel.isConnected && !panel.closest('#sme-analysis-pop')) return; 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); } if (!panel.querySelector('.sme-tab-pane')) switchTab(currentTab || 'summary'); L('Panel injected on listing page'); } function positionAnalysisFab() { if (!analysisFabEl) return; const rank = document.getElementById('sme-mr-panel'); const rankFixed = rank && !rank.classList.contains('sme-mr-inline') && getComputedStyle(rank).position === 'fixed'; analysisFabEl.style.right = rankFixed ? '280px' : '24px'; } function syncAnalysisFab() { if (!analysisFabEl) return; const open = !!(analysisPopEl && analysisPopEl.classList.contains('sme-show')); analysisFabEl.classList.toggle('sme-on', open); analysisFabEl.setAttribute('aria-expanded', open ? 'true' : 'false'); } function removeAnalysisCloseBtn() { if (panelEl) { const btn = panelEl.querySelector('.sme-analysis-close'); if (btn) btn.remove(); } } function ensureAnalysisCloseBtn(panel) { if (!panel || panel.querySelector('.sme-analysis-close')) return; const header = panel.querySelector('.sme-header'); if (!header) return; header.appendChild(h('button', { type: 'button', class: 'sme-tab-btn sme-analysis-close', title: '关闭', 'aria-label': '关闭交易分析', html: ICONS.close, onClick: (e) => { e.stopPropagation(); if (analysisPopEl) analysisPopEl.classList.remove('sme-show'); syncAnalysisFab(); }, })); } function bindAnalysisDismiss() { if (analysisDismissBound) return; analysisDismissBound = true; document.addEventListener('mousedown', (e) => { if (!analysisPopEl || !analysisPopEl.classList.contains('sme-show')) return; if (analysisPopEl.contains(e.target) || (analysisFabEl && analysisFabEl.contains(e.target))) return; if (e.target.closest && e.target.closest('.sme-dialog-overlay, .sme-toast')) return; analysisPopEl.classList.remove('sme-show'); syncAnalysisFab(); }, true); document.addEventListener('keydown', (e) => { if (e.key !== 'Escape' || !analysisPopEl || !analysisPopEl.classList.contains('sme-show')) return; if (document.querySelector('.sme-dialog-overlay')) return; analysisPopEl.classList.remove('sme-show'); syncAnalysisFab(); }); } function ensureAnalysisFab() { if (!analysisFabEl || !analysisFabEl.isConnected) { analysisFabEl = h('button', { type: 'button', class: 'sme-fab sme-analysis-fab', title: '交易分析', 'aria-label': '打开交易分析', 'aria-haspopup': 'dialog', 'aria-expanded': 'false', html: ICONS.layers, onClick: toggleAnalysisPop, }); document.body.appendChild(analysisFabEl); bindAnalysisDismiss(); } positionAnalysisFab(); if (!analysisFabPosTimer) { analysisFabPosTimer = setTimeout(() => { analysisFabPosTimer = null; positionAnalysisFab(); }, 1500); } } function openAnalysisPop() { ensureAnalysisFab(); if (!analysisPopEl || !analysisPopEl.isConnected) { analysisPopEl = h('div', { class: 'sme-analysis-pop', id: 'sme-analysis-pop', role: 'dialog', 'aria-label': '交易分析', }); document.body.appendChild(analysisPopEl); } const panel = createPanel(); ensureAnalysisCloseBtn(panel); if (panel.parentNode !== analysisPopEl) analysisPopEl.appendChild(panel); analysisPopEl.classList.add('sme-show'); if (!panel.querySelector('.sme-tab-pane.active')) switchTab(currentTab || 'summary'); syncAnalysisFab(); L('Analysis float opened'); } function toggleAnalysisPop() { if (analysisPopEl && analysisPopEl.classList.contains('sme-show')) { analysisPopEl.classList.remove('sme-show'); syncAnalysisFab(); return; } openAnalysisPop(); } function removeAnalysisFloat() { if (analysisFabPosTimer) { clearTimeout(analysisFabPosTimer); analysisFabPosTimer = null; } if (analysisFabEl) { analysisFabEl.remove(); analysisFabEl = null; } if (analysisPopEl) { analysisPopEl.remove(); analysisPopEl = null; } } /** * 详情页分析面板入口。 * 浏览器按用户记住的选择内嵌或浮窗。Steam 客户端固定浮窗,按钮挂在 document.body 上。 */ function presentListingPanel(openFloat) { if (detectPageType() !== 'listing') return; if (shouldUseFloatPanel()) { if (panelEl && panelEl.isConnected && !panelEl.closest('#sme-analysis-pop')) panelEl.remove(); ensureAnalysisFab(); if (openFloat) openAnalysisPop(); return; } removeAnalysisFloat(); injectPanel(); } function onListingOrdersReady() { if (tabPanes.summary) invalidateTabPane('summary'); } // ==================== 详情页:已拥有物品标识 ==================== /** 在详情页物品名称前显示已拥有数量徽章 */ 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: isSteamClient() ? '当前在 Steam 客户端,固定用右下角按钮打开' : '浏览器默认顶部内嵌,可改为浮窗,选择会记住' }), ]), listingPanelModeControl(), ]), 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', { 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('marketRankTracker', settings.get('marketRankTracker'), val => { settings.set('marketRankTracker', val); if (!val) cleanupMarketRankTracker(); }), ]), 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('showMorePrices', settings.get('showMorePrices'), onShowMorePricesToggle), ]), 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,var(--sme-blue-a06) 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:var(--sme-track);border-radius:var(--sme-radius-xs);margin-top:6px;overflow:hidden;display:none} .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:var(--sme-red-a8)} .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 || resolveContextId(appid)); const assetid = String(asset.assetid || asset.id || info.assetid || ''); let marketHashName = ''; let classid = ''; 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 || ''; classid = String(a.classid || desc.classid || ''); } } catch (e) {} } if (!marketHashName) { const desc = asset.description || info.description; if (desc) { marketHashName = desc.market_hash_name || desc.market_name || desc.name || ''; classid = classid || String(desc.classid || ''); } } if (!marketHashName) marketHashName = asset.market_hash_name || info.market_hash_name || ''; classid = classid || String(asset.classid || info.classid || ''); return { appid, contextid, assetid, marketHashName, classid }; } // ---------- 带限流重试的请求包装 ---------- 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 html = await smeRequest(url, { expect: 'text', onNonOk: 'null' }); 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('价格检测出错: ' + friendlyError(err), 'error'); } finally { myListingsBusy = false; setMyListingsButtonsDisabled(false); } } // ---------- 下架 / 重新上架 ---------- async function removeMyListing(listingid) { const sessionid = await resolveSessionId(); if (!sessionid) throw new Error('无法获取会话ID'); const resp = await smeRequest(`https://steamcommunity.com/market/removelisting/${listingid}`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: `sessionid=${encodeURIComponent(sessionid)}`, expect: 'none', }); 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 (!(await showConfirmDialog(`确定要下架选中的 ${selected.length} 件物品吗?`, { title: '批量下架', okText: '下架' }))) 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; } // 步骤1: 弹出价格对话框(预填建议到手价,用户可自定义) const firstChk = myListingsPriceCheck.get(myListingsRowData.get(rows[0]).listingid); const suggestedCents = firstChk ? firstChk.suggestedCents : 0; const priceResult = await showPriceDialog( myListingsRowData.get(rows[0]).buyerCents ? formatCents(myListingsRowData.get(rows[0]).buyerCents) : '未知', rows.length, suggestedCents ); if (priceResult === null) { setMyListingsStatus(statusEl, '已取消', ''); return; } const { receivedCents, buyerCents } = priceResult; // 步骤2: 二次确认 const confirmed = await showConfirmDialog( `即将下架 ${rows.length} 件超价物品,` + `以新价格重新上架(您将收到 ${escapeHtml(formatCents(receivedCents))},` + `买方支付 ${escapeHtml(formatCents(buyerCents))}),确认继续?`, { title: '确认重新上架', okText: '确认重新上架', okClass: 'sme-dialog-blue' } ); if (!confirmed) { setMyListingsStatus(statusEl, '已取消', ''); return; } const total = rows.length; showMyListingsProgress(progressEl, true); let success = 0, delistFailed = 0, relistFailed = 0; let lastSellMsg = ''; const usedAssetIds = new Set(); for (let i = 0; i < rows.length; i++) { const row = rows[i]; const d = myListingsRowData.get(row); setMyListingsStatus(statusEl, `重新上架 ${i + 1}/${total}: 下架中…`, ''); setMyListingsProgress(progressBarEl, i / total); try { // 步骤1: 下架 await withMarketRetry(() => removeMyListing(d.listingid), '下架'); // 步骤2: 等待物品返回库存并重新定位 assetid(下架后可能变化) setMyListingsStatus(statusEl, `重新上架 ${i + 1}/${total}: 等待物品返回库存…`, ''); const ai = d._asset || getMyListingAssetInfo(d.listingid); if (!ai || !ai.assetid || !ai.appid) { W('重挂缺少资产信息:', d.listingid); relistFailed++; row.remove(); myListingsRowData.delete(row); continue; } let sellAssetId = ai.assetid; // 有 classid 时按 classid 轮询库存 API 定位回库资产(assetid 可能变化) if (ai.classid) { const returnedId = await waitForReturnedAsset( ai.appid, ai.contextid, ai.classid, usedAssetIds, 10000 ); if (returnedId) { sellAssetId = returnedId; usedAssetIds.add(returnedId); if (returnedId !== ai.assetid) { L('Relist: assetid changed after delist:', ai.assetid, '->', returnedId); } } else { W('Relist: returned asset not found by classid', ai.classid, '; using original assetid', ai.assetid); } } else { // 无 classid 时回退到固定等待 await sleep(randInt(1500, 2500)); } // 步骤3: 以新价格重新上架(2 次重试) setMyListingsStatus(statusEl, `重新上架 ${i + 1}/${total}: 上架中 (${formatCents(buyerCents)})…`, ''); let sellOk = false; let sellMsg = ''; for (let attempt = 0; attempt < 2 && !sellOk; attempt++) { if (attempt > 0) { setMyListingsStatus(statusEl, `重新上架 ${i + 1}/${total}: 重试上架…`, ''); await sleep(1500); } try { await sellItem({ assetid: sellAssetId, appid: ai.appid, contextid: ai.contextid, priceCents: receivedCents, }); sellOk = true; } catch (sellErr) { sellMsg = sellErr.message; W(`重挂上架失败 (attempt ${attempt + 1}):`, d.listingid, sellErr.message); } } if (!sellOk) { relistFailed++; lastSellMsg = lastSellMsg || sellMsg; row.remove(); myListingsRowData.delete(row); myListingsPriceCheck.delete(d.listingid); continue; } success++; row.remove(); myListingsRowData.delete(row); myListingsPriceCheck.delete(d.listingid); } catch (err) { delistFailed++; 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); let msg = `重新上架完成: 成功 ${success} 件`; if (delistFailed > 0) msg += `,下架失败 ${delistFailed} 件`; if (relistFailed > 0) msg += `,上架失败 ${relistFailed} 件(已回库存${lastSellMsg ? ': ' + lastSellMsg : ''})`; setMyListingsStatus(statusEl, msg, (delistFailed || relistFailed) ? 'err' : 'ok'); toast(msg, (delistFailed || relistFailed) ? 'warning' : 'success'); setTimeout(() => showMyListingsProgress(progressEl, false), 3000); updateMyListingsCount(); } catch (err) { setMyListingsStatus(statusEl, '重新上架出错: ' + (err && err.message), 'err'); toast('重新上架出错: ' + friendlyError(err), '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} // v1.5.1 P2-B:价格标签持久缓存(24h、按币种隔离、防抖写盘)—— // 重复访问库存页对已查询过的物品零请求;配合按需标注,大库存不再整页排队 const INV_PRICE_STORE_KEY = 'sme_inv_prices_v1'; const INV_PRICE_STORE_TTL = 24 * 60 * 60 * 1000; let _invPriceStore = null; let _invPriceStoreTimer = null; function invPriceStoreLoad() { if (_invPriceStore) return _invPriceStore; let cur = ''; try { cur = (getWalletInfo() || {}).wallet_currency || ''; } catch (e) {} try { const raw = localStorage.getItem(INV_PRICE_STORE_KEY); const obj = raw ? JSON.parse(raw) : null; _invPriceStore = (obj && obj.currency === cur && obj.items) ? obj : { currency: cur, items: {} }; } catch (e) { _invPriceStore = { currency: cur, items: {} }; } return _invPriceStore; } /** 读取持久缓存(过期条目顺手清除);未命中返回 null */ function invPriceStoreGet(key) { const store = invPriceStoreLoad(); const it = store.items[key]; if (!it) return null; if (Date.now() - it.ts > INV_PRICE_STORE_TTL) { delete store.items[key]; return null; } return { cents: it.c, text: it.t, done: true }; } /** 写入持久缓存(2s 防抖批量落盘) */ function invPriceStorePut(key, cents, text) { const store = invPriceStoreLoad(); store.items[key] = { c: cents, t: text, ts: Date.now() }; if (_invPriceStoreTimer) return; _invPriceStoreTimer = setTimeout(() => { _invPriceStoreTimer = null; try { localStorage.setItem(INV_PRICE_STORE_KEY, JSON.stringify(store)); } catch (e) {} }, 2000); } let invQuickSellEl = null; // 当前快速出售面板 DOM let invSelectedItem = null; // 当前选中物品数据(供"转宝石"使用) let invBatchRunning = false; // 批量操作进行中标记 const invPricePending = new Map(); // 正在请求的价格 key → Promise(去重) // v1.5.1 P1-A 深化:价格请求串行队列收敛为统一调度器通道(节奏策略保留在任务体内,不变) const smeChInvPrice = new SmeRateChannel('invPrice', {}); /** 库存页入口 */ 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-multisell-btn', html: ICONS.layers + '出售多个', onclick: onMultiSell }), 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) * 仅收集当前激活游戏(g_ActiveInventory)且未被品类筛选隐藏的物品 */ function getInventoryItemElements() { // 确定当前激活的游戏 appid let activeAppid = null; try { const inv = unsafeWindow.g_ActiveInventory; if (inv) activeAppid = String(inv.m_appid || inv.appid || ''); } catch (e) {} const els = document.querySelectorAll('.inventory_ctn .item'); const out = []; const seenIds = new Set(); // 去重:同一 assetid 只保留一个 for (const el of els) { if (!el.id || !/^\d+_\d+_\d+$/.test(el.id)) continue; if (seenIds.has(el.id)) continue; if (activeAppid) { // 优先用 appid 过滤:只收集当前游戏库存的物品 const parts = el.id.split('_'); if (parts[0] !== activeAppid) continue; } else { // 回退:跳过隐藏库存容器中的物品(其他游戏的库存容器 display:none) const ctn = el.closest('.inventory_ctn'); if (ctn && ctn.style.display === 'none') continue; } // 跳过被品类筛选隐藏的物品(Steam 在 .itemHolder 上设置 display:none) if (el.style.display === 'none') continue; const holder = el.parentElement; if (holder && holder.style.display === 'none') continue; seenIds.add(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); } } // v1.5.1 P2-B:按需标注——只对进入视口(含前方 400px 预取)的物品发起价格请求。 // 大库存(2000+ 件)此前一次性把全部物品的价格请求排进串行队列(1s/件 ≈ 33 分钟), // 现在仅用户看得见的物品才排队,配合持久缓存与请求去重,感知速度提升一个数量级。 let invPriceIO = null; function getInvPriceIO() { if (invPriceIO) return invPriceIO; invPriceIO = new IntersectionObserver(entries => { for (const en of entries) { if (!en.isIntersecting) continue; invPriceIO.unobserve(en.target); const info = en.target.__smePriceInfo; if (!info) continue; delete en.target.__smePriceInfo; fetchInvPriceOverview(info.appid, info.hashName).then(entry => { info.label.textContent = entry.text; info.label.classList.remove('sme-inf'); if (entry.error) info.label.classList.add('sme-na'); }); } }, { rootMargin: '400px 0px' }); return invPriceIO; } /** 为单个物品添加价格标签:命中缓存立即填充,否则进入视口后按需请求 */ function addPriceLabelToItem(itemEl, data) { const label = h('span', { class: 'sme-inv-price sme-inf', text: '···' }); itemEl.appendChild(label); const key = `${data.appid}|${data.market_hash_name}`; if (invPriceLabels.has(key) || invPriceStoreGet(key)) { // 缓存命中:立即填充(同步可达,无需观察) 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'); }); return; } itemEl.__smePriceInfo = { appid: data.appid, hashName: data.market_hash_name, label }; getInvPriceIO().observe(itemEl); } /** 价格概览请求(串行限流 1s/次,按 appid|hash 缓存去重,429 自动重试,不缓存错误) */ function fetchInvPriceOverview(appid, hashName) { const key = `${appid}|${hashName}`; if (invPriceLabels.has(key)) return Promise.resolve(invPriceLabels.get(key)); // v1.5.1 P2-B:持久缓存命中(24h 内、同币种)→ 零请求 const stored = invPriceStoreGet(key); if (stored) { invPriceLabels.set(key, stored); return Promise.resolve(stored); } if (invPricePending.has(key)) return invPricePending.get(key); const maxRetries = 2; const p = smeChInvPrice.run(async () => { for (let attempt = 0; attempt <= maxRetries; attempt++) { await sleep(attempt === 0 ? INV_REQUEST_DELAY : 5000 * (attempt + 1)); try { const wi = getWalletInfo(); const url = `https://steamcommunity.com/market/priceoverview/?appid=${appid}&market_hash_name=${encodeURIComponent(hashName)}¤cy=${wi.wallet_currency}`; const po = await doFetch(url); let cents = 0, text = '—'; if (po && po.success) { // 优先使用最低卖单价,回退到中位数价 const priceStr = po.lowest_price || po.median_price; if (priceStr) { cents = parsePriceToCents(priceStr); if (cents > 0) text = formatCents(cents); } } const entry = { cents, text, done: true }; // 仅缓存成功结果(cents > 0),不缓存零值/错误,允许后续重试 if (cents > 0) { invPriceLabels.set(key, entry); invPriceStorePut(key, cents, text); } return entry; } catch (e) { if (e.message === 'RateLimited' && attempt < maxRetries) { continue; // 429 限流,等待更长延迟后重试 } // 非 429 错误或重试耗尽:返回错误但不缓存,允许未来重试 return { cents: 0, text: '×', done: true, error: true }; } } return { cents: 0, text: '×', done: true, error: true }; }); invPricePending.set(key, p); 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 html = await smeRequest(url, { expect: '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() { // 旧版布局 const old = document.getElementById('iteminfo0_content') || document.getElementById('iteminfo1_content'); if (old) return old; // 新版 React 布局:找到可见的 iteminfo 元素 for (const id of ['iteminfo0', 'iteminfo1']) { const el = document.getElementById(id); if (el && el.style.display !== 'none') return el; } return null; } /** 获取当前选中物品数据(多重回退:invSelectedItem → .activeInfo → 右侧详情面板) */ function getActiveInvItem() { // 1. 事件钩子设置的 invSelectedItem if (invSelectedItem) return invSelectedItem; // 2. 网格中带 activeInfo 类的物品元素 const activeEl = document.querySelector('.item.activeInfo'); if (activeEl && /^\d+_\d+_\d+$/.test(activeEl.id)) { const data = getRgItemFromElement(activeEl); if (data) { invSelectedItem = data; return data; } } // 3. 右侧详情面板的市场链接提取 const panel = getActiveItemInfoContent(); if (panel) { const sellBtn = panel.querySelector('button[data-accent-color="green"]'); const link = panel.querySelector('a[href*="/market/listings/"]'); if (sellBtn && link) { const m = link.href.match(/\/market\/listings\/(\d+)\/(.+)$/); if (m) { const appid = m[1]; const market_hash_name = decodeURIComponent(m[2]); let contextid = '2'; try { const inv = unsafeWindow.g_ActiveInventory; if (inv && inv.rgChildInventories) { for (const cid in inv.rgChildInventories) { contextid = cid; break; } } } catch (e) {} const data = { appid, contextid, market_hash_name, name: market_hash_name }; invSelectedItem = data; return data; } } } return null; } 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 = '价格加载失败'; }); } /** 渲染快速出售面板(1.4.5 重写:5 种定价模式 + 实时费用展示 + 价格偏离警告) */ function renderQuickSellPanel(panel, data, book) { const bookEl = panel.querySelector('.sme-qs-book'); const btnsEl = panel.querySelector('.sme-quick-sell-btns'); if (!bookEl || !btnsEl) return; const lowestSellCents = book.lowestSell || 0; const highestBuyCents = book.highestBuy || 0; bookEl.innerHTML = ''; 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 = ''; const instantReceived = highestBuyCents > 0 ? Math.max(priceBeforeFees(highestBuyCents), 1) : 0; const undercutReceived = lowestSellCents > 0 ? Math.max(priceBeforeFees(lowestSellCents) - 1, 1) : 0; const matchReceived = lowestSellCents > 0 ? Math.max(priceBeforeFees(lowestSellCents), 1) : 0; const modesContainer = h('div', {class:'sme-qs-modes'}, []); const modes = [ {key:'instant', label:'即时成交(最高买单)', price: instantReceived, available: instantReceived > 0}, {key:'undercut', label:'压价(最低卖-1分)', price: undercutReceived, available: undercutReceived > 0}, {key:'match', label:'匹配最低卖单', price: matchReceived, available: matchReceived > 0}, {key:'custom_buyer', label:'自定义买家支付价', price: 0, available: true}, {key:'custom_received', label:'自定义到手价(突破限制)', price: 0, available: true}, ]; let selectedMode = settings.get('defaultPriceMode') || 'instant'; if (!modes.find(m => m.key === selectedMode)?.available) { selectedMode = modes.find(m => m.available)?.key || 'custom_received'; } modes.forEach(m => { if (!m.available) return; const radio = h('input', {type:'radio', name:'sme-qs-mode', value:m.key, checked: m.key === selectedMode}); const priceText = m.price > 0 ? formatCents(m.price) : ''; const row = h('label', {class:'sme-qs-mode' + (m.key === selectedMode ? ' active' : '')}, [ radio, h('span', {class:'sme-qs-mode-label', text:m.label}), priceText ? h('span', {class:'sme-qs-mode-price', text:priceText}) : null, ].filter(Boolean)); radio.addEventListener('change', () => { modesContainer.querySelectorAll('.sme-qs-mode').forEach(el => el.classList.remove('active')); row.classList.add('active'); updateCustomArea(m.key); }); modesContainer.appendChild(row); }); btnsEl.appendChild(modesContainer); const customArea = h('div', {class:'sme-qs-custom-area', style:{display:'none'}}, []); const customInput = h('input', {class:'sme-qs-input', type:'number', step:'0.01', min:'0', placeholder:'0.00'}); const customLabel = h('span', {class:'sme-qs-custom-label', text:'价格: ', style:{fontSize:'11px', color:'var(--sme-text-dim)'}}); customArea.appendChild(h('div', {class:'sme-qs-custom-row'}, [customLabel, customInput])); btnsEl.appendChild(customArea); const feeInfo = h('div', {class:'sme-qs-fee-info', style:{display:'none'}}, []); btnsEl.appendChild(feeInfo); const warningEl = h('div', {class:'sme-qs-warning', style:{display:'none'}}, []); btnsEl.appendChild(warningEl); const sellBtn = h('button', {class:'sme-qs-sell-btn', html: ICONS.sell + '出售'}, []); btnsEl.appendChild(sellBtn); function getCurrentReceivedCents() { const mode = modesContainer.querySelector('input[name="sme-qs-mode"]:checked')?.value; if (mode === 'instant') return instantReceived; if (mode === 'undercut') return undercutReceived; if (mode === 'match') return matchReceived; if (mode === 'custom_buyer') { const val = parseFloat(customInput.value); if (!val || val <= 0) return 0; return Math.max(priceBeforeFees(Math.round(val * 100)), 1); } if (mode === 'custom_received') { const val = parseFloat(customInput.value); if (!val || val <= 0) return 0; return Math.round(val * 100); } return 0; } function updateFeeInfo(receivedCents) { if (receivedCents <= 0) { feeInfo.style.display = 'none'; return; } feeInfo.style.display = 'block'; const buyerCents = priceIncludingFees(receivedCents); feeInfo.innerHTML = '买家支付: ' + formatCents(buyerCents) + ' | 你到手: ' + formatCents(receivedCents) + ''; } function updateWarning(receivedCents) { if (receivedCents <= 0 || lowestSellCents <= 0) { warningEl.style.display = 'none'; return; } const buyerCents = priceIncludingFees(receivedCents); const ratio = buyerCents / lowestSellCents; if (ratio >= 5) { warningEl.style.display = 'block'; warningEl.className = 'sme-qs-warning sme-warn-high'; warningEl.textContent = '⚠ 价格偏离市场价 ' + Math.round((ratio - 1) * 100) + '%,请确认价格正确'; } else if (ratio >= 2) { warningEl.style.display = 'block'; warningEl.className = 'sme-qs-warning sme-warn-mid'; warningEl.textContent = '⚠ 价格高于市场参考 ' + Math.round((ratio - 1) * 100) + '%'; } else { warningEl.style.display = 'none'; } } function updateCustomArea(mode) { const isCustom = mode === 'custom_buyer' || mode === 'custom_received'; customArea.style.display = isCustom ? 'block' : 'none'; if (mode === 'custom_buyer') { customLabel.textContent = '买家支付价: '; customInput.placeholder = '0.00'; } else if (mode === 'custom_received') { customLabel.textContent = '到手价: '; customInput.placeholder = '0.00 (无限制)'; } if (isCustom && !customInput.value) { if (mode === 'custom_buyer' && lowestSellCents > 0) { customInput.value = (lowestSellCents / 100).toFixed(2); } else if (mode === 'custom_received' && matchReceived > 0) { customInput.value = (matchReceived / 100).toFixed(2); } } refreshState(); } function refreshState() { const received = getCurrentReceivedCents(); updateFeeInfo(received); updateWarning(received); sellBtn.disabled = received <= 0; } customInput.addEventListener('input', refreshState); sellBtn.addEventListener('click', () => { const received = getCurrentReceivedCents(); if (received <= 0) { toast('请输入有效价格', 'warning'); return; } doQuickSell(data, received, panel); }); updateCustomArea(selectedMode); } /** * 出售多件定价浮窗(1.4.5 新增) * 在快速出售面板中点击"出售多件"触发,弹窗允许用户输入数量并选择定价模式 * @param {Object} data - 当前选中物品的 rgItem 数据 * @param {Object} book - 当前物品的订单簿数据(lowestSell/highestBuy) */ function showMultiSellDialog(data, book) { if (invBatchRunning) { toast('已有批量操作进行中', 'warning'); return; } const sameItems = collectSellableItems(d => d.market_hash_name === data.market_hash_name); const maxCount = sameItems.length; if (maxCount <= 1) { toast('当前物品没有重复件,无法多件出售', 'info'); return; } const lowestSellCents = book?.lowestSell || 0; const highestBuyCents = book?.highestBuy || 0; const instantReceived = highestBuyCents > 0 ? Math.max(priceBeforeFees(highestBuyCents), 1) : 0; const matchReceived = lowestSellCents > 0 ? Math.max(priceBeforeFees(lowestSellCents), 1) : 0; const undercutReceived = lowestSellCents > 0 ? Math.max(priceBeforeFees(lowestSellCents) - 1, 1) : 0; const overlay = document.createElement('div'); overlay.className = 'sme-dialog-overlay'; const modeOptions = [ {v:'match', t:'匹配最低卖单(建议价)'}, {v:'undercut', t:'压价(最低卖-1分)'}, {v:'instant', t:'即时成交(最高买单)'}, {v:'custom_buyer', t:'自定义买家支付价'}, {v:'custom_received', t:'自定义到手价(突破限制)'}, ]; const modeSelect = h('select', {class:'sme-batch-sell-select'}, []); modeOptions.forEach(o => modeSelect.appendChild(h('option', {value:o.v, text:o.t}))); modeSelect.value = 'match'; const qtyInput = h('input', {class:'sme-batch-sell-input sme-batch-qty-input', type:'number', min:'1', max:String(maxCount), value:String(maxCount)}); const priceInput = h('input', {class:'sme-batch-sell-input', type:'number', step:'0.01', min:'0', placeholder:'0.00'}); const priceRow = h('div', {class:'sme-batch-sell-row sme-batch-price-row', style:{display:'none'}}, [ h('label', {text:'到手价'}), priceInput, ]); const feeInfo = h('div', {class:'sme-qs-fee-info', style:{display:'none'}}, []); const warningEl = h('div', {class:'sme-qs-warning', style:{display:'none'}}, []); const summary = h('div', {class:'sme-batch-sell-summary'}); const dialog = h('div', {class:'sme-dialog-box sme-batch-sell-dialog'}, [ h('div', {class:'sme-dialog-header'}, [ h('div', {class:'sme-dialog-title', text: '出售多件 — ' + (data.name || data.market_hash_name)}), h('button', {class:'sme-dialog-close', text:'×', onclick: () => overlay.remove()}), ]), h('div', {class:'sme-dialog-body'}, [ h('div', {class:'sme-batch-sell-summary', html:'当前物品共有 ' + maxCount + ' 件可出售'}), h('div', {class:'sme-qs-book'}, [ h('div', {class:'sme-qs-book-row sme-sell', text:'最低挂单: ' + (lowestSellCents ? formatCents(lowestSellCents) : '—')}), h('div', {class:'sme-qs-book-row sme-buy', text:'最高买单: ' + (highestBuyCents ? formatCents(highestBuyCents) : '—')}), ]), h('div', {class:'sme-batch-sell-row'}, [ h('label', {text:'出售数量'}), qtyInput, h('span', {text:'/ ' + maxCount, style:{fontSize:'12px', color:'var(--sme-text-dim)'}}), ]), h('div', {class:'sme-batch-sell-row'}, [ h('label', {text:'定价模式'}), modeSelect, ]), priceRow, feeInfo, warningEl, summary, ]), h('div', {class:'sme-dialog-footer'}, [ h('button', {class:'sme-dialog-cancel', text:'取消', onclick: () => overlay.remove()}), h('button', {class:'sme-dialog-ok', text:'确认出售', onclick: () => { const qty = parseInt(qtyInput.value, 10); if (!qty || qty < 1 || qty > maxCount) { toast('请输入 1-' + maxCount + ' 之间的数量', 'warning'); return; } const mode = modeSelect.value; let customPriceCents = 0; if (mode === 'custom_buyer' || mode === 'custom_received') { const val = parseFloat(priceInput.value); if (!val || val <= 0) { toast('请输入有效价格', 'warning'); return; } customPriceCents = Math.round(val * 100); } overlay.remove(); const itemsToSell = sameItems.slice(0, qty); batchSell(itemsToSell, '出售多件(' + qty + '件)', mode, customPriceCents); }}), ]), ]); overlay.appendChild(dialog); document.body.appendChild(overlay); function updateState() { const mode = modeSelect.value; const isCustom = mode === 'custom_buyer' || mode === 'custom_received'; priceRow.style.display = isCustom ? 'flex' : 'none'; const label = priceRow.querySelector('label'); if (mode === 'custom_buyer') label.textContent = '买家支付价'; else if (mode === 'custom_received') label.textContent = '到手价'; if (isCustom && !priceInput.value && lowestSellCents > 0) { const suggested = priceBeforeFees(lowestSellCents); priceInput.value = (suggested / 100).toFixed(2); } const qty = parseInt(qtyInput.value, 10) || 0; let receivedCents = 0; if (!isCustom) { if (mode === 'match') receivedCents = matchReceived; else if (mode === 'undercut') receivedCents = undercutReceived; else if (mode === 'instant') receivedCents = instantReceived; } else { const val = parseFloat(priceInput.value); if (val > 0) { if (mode === 'custom_buyer') receivedCents = Math.max(priceBeforeFees(Math.round(val * 100)), 1); else receivedCents = Math.round(val * 100); } } if (receivedCents > 0 && qty > 0) { const buyerCents = priceIncludingFees(receivedCents); feeInfo.style.display = 'block'; feeInfo.innerHTML = '买家支付: ' + formatCents(buyerCents) + '/件 | 到手: ' + formatCents(receivedCents) + '/件'; summary.textContent = qty + '件 × ' + formatCents(receivedCents) + ' = 到手总计 ' + formatCents(receivedCents * qty); if (lowestSellCents > 0) { const ratio = priceIncludingFees(receivedCents) / lowestSellCents; if (ratio >= 5) { warningEl.style.display = 'block'; warningEl.className = 'sme-qs-warning sme-warn-high'; warningEl.textContent = '⚠ 偏离市场价 ' + Math.round((ratio-1)*100) + '%'; } else if (ratio >= 2) { warningEl.style.display = 'block'; warningEl.className = 'sme-qs-warning sme-warn-mid'; warningEl.textContent = '⚠ 高于市场参考 ' + Math.round((ratio-1)*100) + '%'; } else { warningEl.style.display = 'none'; } } else { warningEl.style.display = 'none'; } } else { feeInfo.style.display = 'none'; warningEl.style.display = 'none'; summary.textContent = ''; } } modeSelect.addEventListener('change', updateState); priceInput.addEventListener('input', updateState); qtyInput.addEventListener('input', updateState); updateState(); } /** 执行快速出售 */ async function doQuickSell(data, priceCents, panel) { const btns = panel.querySelectorAll('.sme-qs-sell-btn, .sme-qs-btn, .sme-qs-multi-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('上架失败: ' + friendlyError(e), '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) { // 1. 优先使用价格概览 API(轻量,有缓存) 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) {} // 2. 回退到订单直方图(最高买单价 / 最低卖单价) try { const nameId = await fetchItemNameIdByListing(data.appid, data.market_hash_name); if (nameId) { const hist = await fetchOrderHistogram(nameId); // highestBuy/lowestSell 均为分(cents,含手续费),需 priceBeforeFees 转为卖家到手价 if (hist.highestBuy) { const buyCents = parseInt(hist.highestBuy, 10) || 0; if (buyCents > 0) return Math.max(priceBeforeFees(buyCents), 1); } if (hist.lowestSell) { const sellCents = parseInt(hist.lowestSell, 10) || 0; if (sellCents > 0) return Math.max(priceBeforeFees(sellCents), 1); } } } catch (e) { W('Batch sell price fallback (order histogram) failed:', e.message); } // 3. 最终回退到物品类型最低价 const info = getItemPriceInfo(data); return Math.max(Math.round(parseFloat(info.min) * 100), 1); } /** 根据定价模式计算批量出售到手价(1.4.5 新增) */ async function computeBatchPrice(data, priceMode, customPriceCents) { let book = null; try { book = await fetchInventoryOrderBook(data.appid, data.market_hash_name); } catch (e) {} const lowestSellCents = book?.lowestSell || 0; const highestBuyCents = book?.highestBuy || 0; switch (priceMode) { case 'instant': if (highestBuyCents > 0) return Math.max(priceBeforeFees(highestBuyCents), 1); return await computeBatchSellPrice(data); case 'undercut': if (lowestSellCents > 0) return Math.max(priceBeforeFees(lowestSellCents) - 1, 1); return await computeBatchSellPrice(data); case 'match': if (lowestSellCents > 0) return Math.max(priceBeforeFees(lowestSellCents), 1); return await computeBatchSellPrice(data); case 'custom_buyer': if (customPriceCents > 0) return Math.max(priceBeforeFees(customPriceCents), 1); return await computeBatchSellPrice(data); case 'custom_received': return customPriceCents || 1; default: return await computeBatchSellPrice(data); } } /** 批量出售(带进度与限流重试,1.4.5 增加自定义定价支持) */ async function batchSell(items, opLabel, priceMode, customPriceCents) { 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 = priceMode && priceMode !== 'auto' ? await computeBatchPrice(data, priceMode, customPriceCents) : await computeBatchSellPrice(data); await sellItem({ appid: data.appid, contextid: data.contextid, assetid: data.assetid, priceCents: price, amount: data.amount || 1 }); 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 = priceMode && priceMode !== 'auto' ? await computeBatchPrice(data, priceMode, customPriceCents) : await computeBatchSellPrice(data); await sellItem({ appid: data.appid, contextid: data.contextid, assetid: data.assetid, priceCents: price, amount: data.amount || 1 }); 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 onBatchSellAll() { if (invBatchRunning) { toast('已有批量操作进行中', 'warning'); return; } const items = collectSellableItems(); if (items.length === 0) { toast('没有可出售的市场物品', 'info'); return; } showBatchSellDialog(items, '出售所有物品'); } function onBatchSellDuplicates() { if (invBatchRunning) { toast('已有批量操作进行中', 'warning'); return; } const items = collectDuplicateItems(); if (items.length === 0) { toast('没有重复物品可出售', 'info'); return; } showBatchSellDialog(items, '出售重复物品'); } function onBatchSellCards() { if (invBatchRunning) { toast('已有批量操作进行中', 'warning'); return; } const items = collectSellableItems(isTradingCard); if (items.length === 0) { toast('没有可出售的卡牌', 'info'); return; } showBatchSellDialog(items, '出售所有卡牌'); } /** 出售多个 — 跳转到 Steam 原生多件出售页(1.4.5 新增) */ function onMultiSell() { const data = getActiveInvItem(); if (!data) { toast('请先选择一个物品', 'warning'); return; } if (!data.appid || !data.contextid || !data.market_hash_name) { toast('当前物品信息不完整', 'warning'); return; } const url = 'https://steamcommunity.com/market/multisell?appid=' + data.appid + '&contextid=' + data.contextid + '&items%5B%5D=' + encodeURIComponent(data.market_hash_name); location.href = url; } /** * 批量出售定价浮窗(1.4.5 新增) * 所有批量出售操作执行前弹出,显示定价模式选择和价格输入 * @param {Array} items - collectSellableItems 返回的物品数组 * @param {string} opLabel - 操作标签 */ function showBatchSellDialog(items, opLabel) { if (invBatchRunning) { toast('已有批量操作进行中', 'warning'); return; } if (!items || items.length === 0) { toast('没有可出售的市场物品', 'info'); return; } const overlay = document.createElement('div'); overlay.className = 'sme-dialog-overlay'; const modeOptions = [ {v:'match', t:'匹配最低卖单(建议价)'}, {v:'undercut', t:'压价(最低卖-1分)'}, {v:'instant', t:'即时成交(最高买单)'}, {v:'custom_buyer', t:'自定义买家支付价'}, {v:'custom_received', t:'自定义到手价(突破限制)'}, ]; const modeSelect = h('select', {class:'sme-batch-sell-select'}, []); modeOptions.forEach(o => modeSelect.appendChild(h('option', {value:o.v, text:o.t}))); modeSelect.value = 'match'; const priceInput = h('input', {class:'sme-batch-sell-input', type:'number', step:'0.01', min:'0', placeholder:'0.00'}); const priceRow = h('div', {class:'sme-batch-sell-row sme-batch-price-row', style:{display:'none'}}, [ h('label', {text:'到手价'}), priceInput, ]); const feeInfo = h('div', {class:'sme-qs-fee-info', style:{display:'none'}}, []); const warningEl = h('div', {class:'sme-qs-warning', style:{display:'none'}}, []); const summary = h('div', {class:'sme-batch-sell-summary'}); const dialog = h('div', {class:'sme-dialog-box sme-batch-sell-dialog'}, [ h('div', {class:'sme-dialog-header'}, [ h('div', {class:'sme-dialog-title', text: opLabel + ' — 定价确认'}), h('button', {class:'sme-dialog-close', text:'×', onclick: () => overlay.remove()}), ]), h('div', {class:'sme-dialog-body'}, [ h('div', {class:'sme-batch-sell-summary', html:'将对 ' + items.length + ' 件物品进行上架,请确认定价方式:'}), h('div', {class:'sme-batch-sell-row'}, [ h('label', {text:'定价模式'}), modeSelect, ]), priceRow, feeInfo, warningEl, summary, ]), h('div', {class:'sme-dialog-footer'}, [ h('button', {class:'sme-dialog-cancel', text:'取消', onclick: () => overlay.remove()}), h('button', {class:'sme-dialog-ok', text:'确认出售', onclick: () => { const mode = modeSelect.value; let customPriceCents = 0; if (mode === 'custom_buyer' || mode === 'custom_received') { const val = parseFloat(priceInput.value); if (!val || val <= 0) { toast('请输入有效价格', 'warning'); return; } customPriceCents = Math.round(val * 100); } overlay.remove(); batchSell(items, opLabel, mode, customPriceCents); }}), ]), ]); overlay.appendChild(dialog); document.body.appendChild(overlay); function onModeChange() { const mode = modeSelect.value; const isCustom = mode === 'custom_buyer' || mode === 'custom_received'; priceRow.style.display = isCustom ? 'flex' : 'none'; const label = priceRow.querySelector('label'); if (mode === 'custom_buyer') label.textContent = '买家支付价'; else if (mode === 'custom_received') label.textContent = '到手价'; if (isCustom && !priceInput.value && items.length > 0) { fetchInventoryOrderBook(items[0].data.appid, items[0].data.market_hash_name).then(book => { if (book.lowestSell > 0) { const suggested = priceBeforeFees(book.lowestSell); priceInput.value = (suggested / 100).toFixed(2); updateFeeAndWarning(); } }).catch(() => {}); } if (!isCustom) { const labels = { 'match': '每件物品按各自最低卖单到手价上架', 'undercut': '每件物品按各自最低卖单-1分到手价上架', 'instant': '每件物品按各自最高买单到手价即时成交', }; summary.textContent = labels[mode] || ''; feeInfo.style.display = 'none'; warningEl.style.display = 'none'; } else { updateFeeAndWarning(); } } function updateFeeAndWarning() { const mode = modeSelect.value; if (mode !== 'custom_buyer' && mode !== 'custom_received') { feeInfo.style.display = 'none'; warningEl.style.display = 'none'; return; } const val = parseFloat(priceInput.value); if (!val || val <= 0) { feeInfo.style.display = 'none'; warningEl.style.display = 'none'; return; } let receivedCents; if (mode === 'custom_buyer') { receivedCents = Math.max(priceBeforeFees(Math.round(val * 100)), 1); } else { receivedCents = Math.round(val * 100); } const buyerCents = priceIncludingFees(receivedCents); feeInfo.style.display = 'block'; feeInfo.innerHTML = '买家支付: ' + formatCents(buyerCents) + ' × ' + items.length + ' 件 = 到手总计: ' + formatCents(receivedCents * items.length) + ''; summary.textContent = '所有物品统一以到手价 ' + formatCents(receivedCents) + ' 上架'; } modeSelect.addEventListener('change', onModeChange); priceInput.addEventListener('input', updateFeeAndWarning); onModeChange(); } /** 获取物品可转换的宝石数量(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 = await resolveSessionId(); 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 json = await smeRequest('https://steamcommunity.com/ajaxgrindintogoo/', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }, body: body.toString(), }); if (!json || !json.success) throw new Error((json && json.message) || '转换宝石失败'); return json; } /** 将当前选中物品转换为宝石 */ async function turnSelectedIntoGems() { if (invBatchRunning) { toast('已有操作进行中', 'warning'); return; } const data = getActiveInvItem(); if (!data) { toast('请先选择一个物品', 'warning'); return; } 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('转换为宝石失败: ' + friendlyError(e), 'error'); W('Turn into gems failed:', e.message); } finally { invBatchRunning = false; setBatchButtonsDisabled(false); } } // ==================== 交易报价页 ==================== let tradeObserver = null; let tradeLastSum = 0; /** 初始化多件出售页(1.4.5 新增:自动填充价格 + 定价模式增强) */ function initMultisellPage() { L('Initializing multisell page...'); const tryInit = (attempt) => { const inputs = document.querySelectorAll('.market_multi_price_recv'); if (inputs.length > 0 || attempt >= 40) { setupMultisellEnhancer(); } else { attempt++; setTimeout(() => tryInit(attempt), 500); } }; tryInit(0); } function setupMultisellEnhancer() { const params = new URLSearchParams(location.search); const appid = params.get('appid'); let gNameIds, gHashNames; try { gNameIds = unsafeWindow.g_rgItemNameIds || []; gHashNames = unsafeWindow.g_rgMarketHashNames || []; } catch (e) { L('Multisell globals not available:', e.message); return; } if (gNameIds.length === 0) { L('Multisell: no items found'); return; } const wi = getWalletInfo(); let gCurrencyCode; try { gCurrencyCode = unsafeWindow.GetCurrencyCode(wi.wallet_currency); } catch (e) { gCurrencyCode = wi.currency_code || 'USD'; } const modeOptions = [ {v:'match', t:'匹配最低卖单(建议价)'}, {v:'undercut', t:'压价(最低卖-1分)'}, {v:'custom_recv', t:'自定义到手价'}, {v:'custom_paid', t:'自定义买家支付价'}, ]; const modeSelect = h('select', {class:'sme-ms-select'}, []); modeOptions.forEach(o => modeSelect.appendChild(h('option', {value:o.v, text:o.t}))); modeSelect.value = 'match'; const priceInput = h('input', {class:'sme-ms-price-input', type:'number', step:'0.01', min:'0', placeholder:'0.00'}); const priceRow = h('div', {class:'sme-ms-price-row', style:{display:'none'}}, [ h('span', {text:'价格:', style:{fontSize:'12px', color:'var(--sme-text-dim)'}}), priceInput, ]); const feeInfo = h('div', {class:'sme-ms-fee-info', style:{display:'none'}}, []); const statusEl = h('span', {class:'sme-ms-status', text:'准备就绪'}); const applyBtn = h('button', {class:'sme-ms-apply-btn', type:'button', text:'应用全部'}, []); const panel = h('div', {class:'sme-ms-panel'}, [ h('div', {class:'sme-ms-header'}, [ h('span', {class:'sme-ms-title', text:'SME 定价增强'}), statusEl, ]), h('div', {class:'sme-ms-controls'}, [ h('div', {class:'sme-ms-mode-row'}, [ h('span', {text:'定价模式:', style:{fontSize:'12px', color:'var(--sme-text-dim)'}}), modeSelect, ]), priceRow, feeInfo, applyBtn, ]), ]); const mainContent = document.querySelector('#market_mutlisell_maincontent') || document.querySelector('.market_multisell'); if (mainContent) { mainContent.insertBefore(panel, mainContent.firstChild); } else { document.body.insertBefore(panel, document.body.firstChild); } const itemPriceCache = new Map(); async function fetchItemPrice(hashName) { if (itemPriceCache.has(hashName)) return itemPriceCache.get(hashName); try { const url = 'https://steamcommunity.com/market/priceoverview/?appid=' + appid + '&market_hash_name=' + encodeURIComponent(hashName) + '¤cy=' + wi.wallet_currency; const data = await doFetch(url); if (data && data.success && data.lowest_price) { const cents = parsePriceToCents(data.lowest_price); itemPriceCache.set(hashName, cents); return cents; } } catch (e) { W('Price overview failed for', hashName, e.message); } itemPriceCache.set(hashName, 0); return 0; } function formatForInput(cents) { try { return unsafeWindow.v_currencyformat(cents, gCurrencyCode); } catch (e) { return (cents / 100).toFixed(2); } } function setItemPrice(nameId, receivedCents) { const input = document.querySelector('#sell_' + nameId + '_price_recv'); if (!input) return; const formatted = formatForInput(receivedCents); try { unsafeWindow.$J(input).val(formatted).keyup(); } catch (e) { input.value = formatted; input.dispatchEvent(new Event('keyup', {bubbles: true})); } } function applyMode(mode, customCents) { for (let i = 0; i < gNameIds.length; i++) { const nameId = gNameIds[i]; const hashName = gHashNames[i]; const lowestSellCents = itemPriceCache.get(hashName) || 0; let receivedCents = 0; if (mode === 'match' && lowestSellCents > 0) { receivedCents = Math.max(priceBeforeFees(lowestSellCents), 1); } else if (mode === 'undercut' && lowestSellCents > 0) { receivedCents = Math.max(priceBeforeFees(lowestSellCents) - 1, 1); } else if (mode === 'custom_recv') { receivedCents = customCents; } else if (mode === 'custom_paid') { receivedCents = Math.max(priceBeforeFees(customCents), 1); } if (receivedCents > 0) setItemPrice(nameId, receivedCents); } try { unsafeWindow.UpdateOrderTotal(); } catch (e) {} } function onModeChange() { const mode = modeSelect.value; const isCustom = mode === 'custom_recv' || mode === 'custom_paid'; priceRow.style.display = isCustom ? 'flex' : 'none'; priceInput.placeholder = mode === 'custom_recv' ? '到手价(元)' : '买家支付价(元)'; feeInfo.style.display = 'none'; } function onApply() { const mode = modeSelect.value; let customCents = 0; if (mode === 'custom_recv' || mode === 'custom_paid') { const val = parseFloat(priceInput.value); if (!val || val <= 0) { statusEl.textContent = '请输入有效价格'; return; } customCents = Math.round(val * 100); feeInfo.style.display = 'block'; let received, buyer; if (mode === 'custom_recv') { received = customCents; buyer = priceIncludingFees(customCents); } else { buyer = customCents; received = Math.max(priceBeforeFees(customCents), 1); } feeInfo.innerHTML = '买家支付: ' + formatCents(buyer) + ' | 到手: ' + formatCents(received) + '/件'; applyMode(mode, customCents); } else { applyMode(mode, 0); } statusEl.textContent = '已应用: ' + modeOptions.find(o => o.v === mode).t; } modeSelect.addEventListener('change', onModeChange); applyBtn.addEventListener('click', onApply); statusEl.textContent = '正在获取价格...'; const promises = gHashNames.map(name => fetchItemPrice(name)); Promise.all(promises).then(prices => { const valid = prices.filter(p => p > 0).length; if (valid > 0) { applyMode('match', 0); statusEl.textContent = '已自动填充匹配价格(' + valid + '件)'; } else { statusEl.textContent = '未能获取价格,请手动输入'; } }); } /** 初始化交易报价页 */ 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 ? 'var(--sme-red)' : (diff < 0 ? 'var(--sme-green)' : 'var(--sme-blue)') }, 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('选中全部物品失败: ' + friendlyError(e), 'error'); } } // ==================== v1.4.6 我的挂单市场顺序跟踪(新版 React 市场页) ==================== // 原理:新版市场物品页为 React SSR,数据内嵌于 window.SSR.renderContext(React Query // dehydrated state,market_item_search 查询含 listings 数组与 bMine 标识)。服务端支持 // ?start=N 分页参数(默认价格升序,每页 20 条),因此可通过同源 fetch 重新请求页面 HTML // 并解析 renderContext,枚举全部挂单、定位 bMine=true 的本人挂单,得到精确的市场顺序 // (1-based)。再按设定间隔对本人排名附近页窗口探测刷新,跟踪售出导致的顺序变化。 // 注意:orderbook 阶梯(rgCompactSellOrders)是价格聚合数据且可能属于其它磨损变体, // 不能用于精确排名,必须基于 listings 枚举。 const MR_PAGE_SIZE = 20; const MR_MAX_SCAN_PAGES = 100; // 全量扫描页数上限(2000 条) const MR_PROBE_MAX_PAGES = 12; // v1.4.8:单轮探测请求页数上限(挂单过多时按排名优先保留) const MR_MIN_GAP = 1100; // v1.4.8:相邻请求最小间隔(ms),限速器强制执行 const MR_RATE_MAX = 18; // v1.4.8:时间窗内最大请求数(与页面自身请求共享会话限流配额,需保守) const MR_RATE_WINDOW = 62000; // v1.4.8:限速时间窗(ms) const MR_COOLDOWN_429 = 60000; // v1.4.8:收到 429 后的全局冷却时长(ms) const MR_MIN_INTERVAL = 30; // 最小刷新间隔(秒) const MR_MAX_INTERVAL = 600; // 最大刷新间隔(秒) const MR_BACKOFF_MAX_POW = 5; // 连续失败退避上限 2^5=32 倍 const MR_MY_MARKER_RE = /(\s*我的上架物品\s*)|\(\s*my\s+listings?\s*\)/i; const MR_SECTION_RE = /我的上架物品|my\s+listings?/i; const MR_CACHE_TTL = 10 * 60 * 1000; // v1.5.0:MR 排名缓存有效期(与 ORDER_BOOK TTL 对齐) // v1.4.8:全局限速器。此前扫描 650ms/探测 400ms 一档且探测页数随挂单数无上限增长, // 挂单多的物品几分钟内发出上百个带登录态的整页请求,极易触发 Steam 市场限流, // 页面自身的 React Query 数据请求同会话被殃及,表现为在售列表"几秒后消失、无法加载"。 let mrCoolUntil = 0; // 429 冷却截止时间 // v1.5.1 P1-A 深化:时间窗收敛为统一调度器通道(策略不变:最小间隔 + 18 次/62s 窗口) const smeChMr = new SmeRateChannel('MR', { gapBefore: MR_MIN_GAP, window: MR_RATE_WINDOW, max: MR_RATE_MAX }); /** 获取一个请求名额(429 冷却 + 通道窗口 + 全局窗口);任务作废时返回 false */ async function mrAcquireSlot(gen) { for (;;) { if (gen !== mrState.gen) return false; const now = Date.now(); if (now < mrCoolUntil) { await sleep(Math.min(15000, mrCoolUntil - now)); continue; } const ok = await smeChMr.acquire(() => gen === mrState.gen); return ok === true; } } const mrState = { gen: 0, // 代数计数器:URL 变化/清理后自增,旧任务自动作废 active: false, running: false, // 一次扫描/探测周期进行中 paused: false, phase: 'init', // init|scanning|ok|none|error mine: [], // [{id, cents, rank, tieCount}] total: 0, currency: 0, // eCurrency(来自 listing) lastUpdate: 0, failStreak: 0, changeNote: '', abortCtrl: null, timer: null, // 下一轮刷新 setTimeout countdown: 0, countdownTimer: null, panelEl: null, body: null, foot: null, rowsEl: null, noteEl: null, statusEl: null, observer: null, cardSeen: new WeakSet(), rowSeen: new WeakSet(), url: '', scanPagesDone: 0, }; /** 解析 fetch 回来的 HTML 中的 renderContext(双重 JSON.parse,与浏览器行为一致)。 v1.4.8:indexOf 跳跃式定位结束引号,替代逐字符拼接(热门物品整页 HTML 可达数 MB, 逐字符循环占主线程数十 ms/页,扫描上百页时明显卡顿);顺带修正连续偶数反斜杠后 引号的转义判定 */ function mrParseRenderContext(htmlText) { try { const marker = 'window.SSR.renderContext=JSON.parse("'; const idx = htmlText.indexOf(marker); if (idx < 0) return null; const begin = idx + marker.length; let end = begin, q = -1; while ((q = htmlText.indexOf('"', end)) !== -1) { let bs = 0, p = q - 1; while (p >= 0 && htmlText[p] === '\\') { bs++; p--; } if (bs % 2 === 0) break; end = q + 1; } if (q < 0) return null; return JSON.parse(JSON.parse('"' + htmlText.slice(begin, q) + '"')); } catch (e) { return null; } } /** 从 renderContext 对象中提取 market_item_search 的 page0(normalized) */ function mrGetPageFromRC(rcObj) { try { let qd = null; if (rcObj && typeof rcObj.queryData === 'string') qd = JSON.parse(rcObj.queryData); else if (rcObj && Array.isArray(rcObj.queries)) qd = rcObj; if (!qd || !Array.isArray(qd.queries)) return null; for (const q of qd.queries) { if (q && q.queryKey && q.queryKey[0] === 'market_item_search' && q.state && q.state.data && Array.isArray(q.state.data.pages) && q.state.data.pages.length) { const p = q.state.data.pages[q.state.data.pages.length - 1]; return { start: p.start || 0, total: p.total_count || 0, listings: (p.listings || []).map(l => ({ id: String(l.listingid || ''), cents: l.unPrice | 0, bMine: l.bMine === true, })), currency: (p.listings && p.listings[0] && p.listings[0].eCurrency) || 0, }; } } } catch (e) {} return null; } /** 构造分页请求 URL:仅保留语言参数 + start(剥离筛选/排序,强制默认价格升序) */ function mrBuildUrl(start) { let lang = ''; try { lang = new URLSearchParams(location.search).get('l') || ''; } catch (e) {} const params = []; if (lang) params.push('l=' + encodeURIComponent(lang)); params.push('start=' + start); return location.origin + location.pathname + '?' + params.join('&'); } /** 同源请求一页市场列表(HTML → renderContext → normalized page)。 v1.4.8:请求前经全局限速器获取名额(gen 作废时抛错由外层 gen 检查吞掉); 收到 429 立即进入全局冷却并中止本轮,避免继续加重限流殃及页面自身请求 */ async function mrFetchPage(start, gen) { if (!await mrAcquireSlot(gen)) throw new Error('MR 任务已作废'); const ctrl = new AbortController(); mrState.abortCtrl = ctrl; let text; try { text = await smeRequest(mrBuildUrl(start), { cache: 'no-store', signal: ctrl.signal, expect: 'text' }); } catch (e) { if (e && e.code === SME_ERR.RATE_LIMITED) { mrCoolUntil = Date.now() + MR_COOLDOWN_429; smeChMr.reset(); throw new Error('RateLimited(Steam 限流,已进入 60s 冷却)'); } throw e; } const page = mrGetPageFromRC(mrParseRenderContext(text)); if (!page) throw new Error('页面数据解析失败'); return page; } /** 当前文档的 SSR renderContext(已是对象) → page0 */ function mrCurrentDocPage() { try { const rc = unsafeWindow.SSR && unsafeWindow.SSR.renderContext; if (!rc) return null; return mrGetPageFromRC(rc); } catch (e) { return null; } } /** 读取页面上"出售 - 我的上架物品"表格中各行价格(分),用于二分定位加速 */ function mrFindMyListingsSection() { for (const h2 of document.querySelectorAll('h2')) { const t = h2.textContent || ''; if (MR_SECTION_RE.test(t)) { let el = h2.parentElement; for (let i = 0; i < 4 && el; i++) { const btns = el.querySelectorAll('button'); if (btns.length >= 1 && btns.length <= 30) return el; el = el.parentElement; } return h2.parentElement; } } return null; } function mrGetMyTableRows() { const rows = []; const section = mrFindMyListingsSection(); if (!section) return rows; for (const btn of section.querySelectorAll('button')) { const t = (btn.textContent || '').trim(); if (t === '取消' || t === '移除' || t === 'Remove' || /^cancel/i.test(t)) { const row = btn.closest('[style*="--grid-column: 1 / -1"]'); if (row && rows.indexOf(row) === -1) rows.push(row); } } return rows; } function mrParseRowPriceCents(row) { // 数据行子元素顺序:名称 | 上架日期 | 价格 | 操作;价格格含总价 div + 到手 span const cells = row.children; for (let i = Math.min(2, cells.length - 1); i >= 1; i--) { const c = cells[i]; if (!c) continue; const txt = (c.textContent || '').trim(); if (/[\d]/.test(txt) && !/^\d{4}\/\d{1,2}\/\d{1,2}/.test(txt)) { const cents = parsePriceToCents(txt); if (cents > 0) return cents; } } return 0; } /** 计算 tie:同价格档位内位于本人之前/之后的数量(用于显示区间提示) */ function mrComputeTies(allListings, mineMap) { // allListings: [{id,cents,bMine}] 全量有序数组 const tieCount = new Map(); // cents → qty for (const l of allListings) { if (mineMap.has(l.id)) tieCount.set(l.cents, (tieCount.get(l.cents) || 0) + 1); } for (const m of mineMap.values()) { m.tieCount = tieCount.get(m.cents) || 1; } } /** 持久化最近一次结果(跨访问即时回显) */ function mrSaveCache() { try { localStorage.setItem('sme_mr_' + location.pathname, JSON.stringify({ ts: Date.now(), total: mrState.total, currency: mrState.currency, mine: mrState.mine.map(m => ({ id: m.id, cents: m.cents, rank: m.rank, tieCount: m.tieCount })), })); } catch (e) {} } function mrLoadCache() { try { const raw = localStorage.getItem('sme_mr_' + location.pathname); if (!raw) return null; const d = JSON.parse(raw); if (!d || !Array.isArray(d.mine) || d.mine.length === 0) return null; // v1.5.0: 补 TTL 校验。原实现读到 ts 从不与当前时间比较, // 导致过期排名数据永久回显、用户误认为是最新结果。 if (typeof d.ts !== 'number' || Date.now() - d.ts > MR_CACHE_TTL) { localStorage.removeItem('sme_mr_' + location.pathname); return null; } return d; } catch (e) { return null; } } // ---- UI:浮动状态面板 ---- function mrFindFilterContainer() { // 查找"重置搜索"按钮所在的筛选容器(用于将市场顺序面板插入到其下方) const resetBtn = Array.from(document.querySelectorAll('button')).find(b => b.textContent.trim() === '重置搜索' ); if (resetBtn) { // 找到包含重置按钮的容器行(Psv8L3U3Y44- 类名或其父容器) let el = resetBtn.parentElement; for (let i = 0; i < 4 && el && el !== document.body; i++) { const style = getComputedStyle(el); // 找到最外层的 sticky 容器,我们要插入到其内部底部 if (style.position === 'sticky' || el.style.getPropertyValue('--position') === 'sticky') { return { container: el, mode: 'inside' }; } el = el.parentElement; } // 如果没找到 sticky 容器,返回按钮的父行容器 return { container: resetBtn.parentElement, mode: 'after' }; } // 回退:用标题查找 const xpath = "//*[contains(text(),'筛选上架物品')]"; const result = document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null); const titleEl = result.singleNodeValue; if (!titleEl) return null; let el = titleEl; for (let i = 0; i < 6 && el && el !== document.body; i++) { const style = getComputedStyle(el); if (style.position === 'sticky' || el.style.getPropertyValue('--position') === 'sticky') { return { container: el, mode: 'inside' }; } el = el.parentElement; } return { container: titleEl.parentElement?.parentElement || null, mode: 'after' }; } function mrEnsurePanel() { if (mrState.panelEl && document.body.contains(mrState.panelEl)) return; const panel = h('div', { class: 'sme-mr-panel', id: 'sme-mr-panel' }); const head = h('div', { class: 'sme-mr-head', title: '点击折叠/展开' }, [ h('span', { class: 'sme-mr-dot', id: 'sme-mr-dot' }), h('span', { class: 'sme-mr-head-title', text: '我的市场顺序' }), h('span', { class: 'sme-mr-collapse-hint', text: '▾', style: { color: 'var(--sme-text-dim)', fontSize: '10px' } }), ]); const body = h('div', { class: 'sme-mr-body', id: 'sme-mr-body' }); const foot = h('div', { class: 'sme-mr-foot' }); panel.appendChild(head); panel.appendChild(body); panel.appendChild(foot); head.addEventListener('click', () => { panel.classList.toggle('collapsed'); body.style.display = panel.classList.contains('collapsed') ? 'none' : ''; }); // v1.4.9:尝试嵌入到"筛选上架物品"侧边栏内部(重置搜索下方),找不到则回退到右下角浮动 const filterInfo = mrFindFilterContainer(); if (filterInfo && filterInfo.container) { panel.classList.add('sme-mr-inline'); if (filterInfo.mode === 'inside') { // 插入到筛选容器内部末尾 filterInfo.container.appendChild(panel); } else { // 插入到重置搜索按钮行的下方 filterInfo.container.parentNode.insertBefore(panel, filterInfo.container.nextSibling); } } else { document.body.appendChild(panel); } mrState.panelEl = panel; mrState.body = body; mrState.foot = foot; } function mrFormatPrice(cents) { return formatCentsStr(cents, mrState.currency || undefined); } function mrRenderPanel() { const panel = mrState.panelEl; if (!panel) return; const body = mrState.body; const hasMine = !!(mrState.mine && mrState.mine.length); // 页面没有上架列表,或这一轮没解析出来:不显示市场顺序浮窗。 const unread = mrState.phase === 'error' || (mrState.phase === 'none' && !mrState.total); if (unread || !body) { panel.style.display = 'none'; return; } panel.style.display = ''; body.innerHTML = ''; const dot = document.getElementById('sme-mr-dot'); if (dot) { dot.className = 'sme-mr-dot ' + (mrState.phase === 'ok' ? 'ok' : mrState.phase === 'scanning' ? 'scanning' : mrState.phase === 'error' ? 'error' : ''); } if (mrState.phase === 'init' || mrState.phase === 'scanning') { body.appendChild(h('div', { class: 'sme-mr-note', text: mrState.phase === 'init' ? '正在初始化市场数据…' : `正在扫描市场顺序… ${mrState.scanPagesDone}/${Math.ceil(mrState.total / MR_PAGE_SIZE) || '?'} 页` })); body.appendChild(h('div', { class: 'sme-mr-note', text: `共 ${mrState.total.toLocaleString()} 件在售(价格从低到高)` })); return; } if (mrState.phase === 'none' || !hasMine) { body.appendChild(h('div', { class: 'sme-mr-note', text: '未在此物品发现您的上架物品' })); body.appendChild(h('div', { class: 'sme-mr-note', text: `共 ${mrState.total.toLocaleString()} 件在售` })); return; } // phase === 'ok' const sorted = [...mrState.mine].sort((a, b) => a.rank - b.rank); const front = sorted[0]; const main = h('div', { class: 'sme-mr-main' }); const rankWrap = h('div', { style: { display: 'flex', flexDirection: 'column', gap: '2px' } }, [ h('div', { class: 'sme-mr-rank-label', text: '我的排名' }), h('div', { style: { display: 'flex', alignItems: 'baseline', gap: '6px' } }, [ h('span', { class: 'sme-mr-rank-big', id: 'sme-mr-rank-big', text: '#' + front.rank.toLocaleString() }), h('span', { class: 'sme-mr-total', text: `/ ${mrState.total.toLocaleString()} 件` }), ]), ]); main.appendChild(rankWrap); body.appendChild(main); body.appendChild(h('div', { class: 'sme-mr-note', text: `${mrFormatPrice(front.cents)}${front.tieCount > 1 ? `(同级 ${front.tieCount} 件)` : ''} · 按价格从低到高` })); if (sorted.length > 1) { const rows = h('div', { class: 'sme-mr-rows' }); sorted.forEach((m, i) => { rows.appendChild(h('div', { class: 'sme-mr-row' + (i === 0 ? ' front' : '') }, [ h('span', { class: 'rk', text: '#' + m.rank.toLocaleString() }), h('span', { class: 'pr', text: mrFormatPrice(m.cents) }), h('span', { class: 'lid', text: m.id.slice(-6), title: '挂单ID ' + m.id }), ])); }); body.appendChild(rows); } if (mrState.changeNote) { body.appendChild(h('div', { class: 'sme-mr-changenote', text: mrState.changeNote })); } // footer const foot = mrState.foot; if (!foot) return; foot.innerHTML = ''; const t = new Date(mrState.lastUpdate); foot.appendChild(h('span', { text: `${String(t.getHours()).padStart(2, '0')}:${String(t.getMinutes()).padStart(2, '0')}:${String(t.getSeconds()).padStart(2, '0')} 更新`, style: { flex: '1' } })); foot.appendChild(h('button', { class: 'sme-mr-btn', text: '立即刷新', onClick: mrManualRefresh })); const pauseBtn = h('button', { class: 'sme-mr-btn', text: mrState.paused ? '继续' : '暂停', onClick: mrTogglePause }); foot.appendChild(pauseBtn); if (mrState.paused) { foot.appendChild(h('span', { text: '已暂停', style: { color: '#e0b05a' } })); } else if (mrState.countdown > 0) { foot.appendChild(h('span', { id: 'sme-mr-countdown', text: `${mrState.countdown}s` })); } } function mrTogglePause() { mrState.paused = !mrState.paused; if (mrState.paused) { clearTimeout(mrState.timer); mrState.timer = null; mrState.countdown = 0; } else { mrScheduleNext(3); } mrRenderPanel(); } function mrManualRefresh() { if (mrState.running) return; mrState.paused = false; clearTimeout(mrState.timer); mrState.gen++; mrRunCycle(mrState.gen); } function mrScheduleNext(delaySec) { clearTimeout(mrState.timer); if (!mrState.active || mrState.paused || !settings.get('rankAutoRefresh')) return; let interval = Math.min(MR_MAX_INTERVAL, Math.max(MR_MIN_INTERVAL, settings.get('rankRefreshInterval') || 60)); if (mrState.failStreak > 0) interval = Math.min(MR_MAX_INTERVAL * 8, interval * Math.pow(2, Math.min(mrState.failStreak, MR_BACKOFF_MAX_POW))); const delay = (delaySec != null ? delaySec : interval) * 1000 + Math.floor(Math.random() * 3000); mrState.countdown = Math.round(delay / 1000); mrState.timer = setTimeout(() => { if (mrState.paused) return; if (document.visibilityState === 'hidden') { // 页面隐藏时跳过本轮,可见后立即刷新 mrState.countdown = 10; mrState.timer = setTimeout(() => mrRunCycle(mrState.gen), 10000); return; } mrRunCycle(mrState.gen); }, delay); } function mrStartCountdown() { clearInterval(mrState.countdownTimer); mrState.countdownTimer = setInterval(() => { if (!mrState.active || mrState.paused) return; if (mrState.countdown > 0) mrState.countdown--; const el = document.getElementById('sme-mr-countdown'); if (el) el.textContent = mrState.countdown + 's'; else { // footer 被重渲染时补回倒计时 const foot = mrState.foot; if (foot && mrState.phase === 'ok' && !mrState.paused && mrState.countdown > 0 && !document.getElementById('sme-mr-countdown')) { foot.appendChild(h('span', { id: 'sme-mr-countdown', text: mrState.countdown + 's' })); } } }, 1000); } // ---- 排名计算 ---- /** 全量顺序扫描:从 start=0 枚举所有页,收集 bMine 挂单及精确排名 */ async function mrScanAll(gen) { const page0 = mrCurrentDocPage(); let total = page0 ? page0.total : 0; let currency = page0 ? page0.currency : 0; const all = []; const mineMap = new Map(); let pages = 0; mrState.scanPagesDone = 0; // 页数上限动态计算:page0 不可用时 total=0,首页拉取后才能确定真实页数 while (pages < Math.max(1, Math.min(MR_MAX_SCAN_PAGES, Math.ceil(total / MR_PAGE_SIZE)))) { if (gen !== mrState.gen) return null; const start = pages * MR_PAGE_SIZE; let page; if (pages === 0 && page0 && page0.start === 0) { page = page0; // 当前文档 SSR 即第 0 页,零请求 } else { page = await mrFetchPage(start, gen); // 间隔由全局限速器控制 } total = page.total || total; currency = page.currency || currency; for (let i = 0; i < page.listings.length; i++) { const l = page.listings[i]; all.push(l); if (l.bMine && l.id) { mineMap.set(l.id, { id: l.id, cents: l.cents, rank: start + i + 1, tieCount: 1 }); } } pages++; mrState.scanPagesDone = pages; mrState.total = total; mrState.currency = currency; mrRenderPanel(); if (page.listings.length < MR_PAGE_SIZE) break; // 已到末页 } mrComputeTies(all, mineMap); return { total, currency, mine: [...mineMap.values()], scanned: all.length }; } /** * 二分定位:已知本人价格(来自页面上架表格),先二分找到首个触及该价格的页, * 再向后顺序扫描覆盖整个同价档位,期间用 bMine 精确识别。比全量扫描省 70%+ 请求。 */ async function mrLocateMine(gen, myPrices, page0) { const lo = Math.min(...myPrices); const hi = Math.max(...myPrices); const total = page0 ? page0.total : 0; if (!total) return null; const maxPage = Math.min(MR_MAX_SCAN_PAGES - 1, Math.floor((total - 1) / MR_PAGE_SIZE)); // 二分:找最小 k 使 page[k] 末位价格 >= lo let a = 0, b = maxPage, hit = -1; let firstPage = null; while (a <= b) { if (gen !== mrState.gen) return null; const mid = (a + b) >> 1; let page; if (mid === 0 && page0 && page0.start === 0) page = page0; else page = await mrFetchPage(mid * MR_PAGE_SIZE, gen); const lastCents = page.listings.length ? page.listings[page.listings.length - 1].cents : 0; if (lastCents >= lo) { hit = mid; firstPage = page; b = mid - 1; } else a = mid + 1; } if (hit < 0) return { total, mine: [], scanned: -1 }; // 本人价格高于全场最高价(异常) // 从 hit 顺序向后扫描,直到页首价格 > hi const mineMap = new Map(); const all = []; let k = hit; while (k <= maxPage) { if (gen !== mrState.gen) return null; const start = k * MR_PAGE_SIZE; const page = (k === hit) ? firstPage : await mrFetchPage(start, gen); for (let i = 0; i < page.listings.length; i++) { const l = page.listings[i]; all.push(l); if (l.bMine && l.id) mineMap.set(l.id, { id: l.id, cents: l.cents, rank: start + i + 1, tieCount: 1 }); } mrState.scanPagesDone = k - hit + 1; if (page.listings.length === 0) break; const firstCents = page.listings[0].cents; const lastCents = page.listings[page.listings.length - 1].cents; if (firstCents > hi) break; // 本页已完全越过本人价格区间 if (mineMap.size >= myPrices.length && lastCents > hi) break; // 已找齐且越过区间 k++; } mrComputeTies(all, mineMap); return { total, mine: [...mineMap.values()], scanned: all.length }; } /** 增量探测:只抓取本人排名附近的页窗口,重新定位每个挂单ID的精确位置。 v1.4.8:窗口数设上限并按排名升序优先保留(面板 #1 来自最靠前挂单); 截断时返回 truncated=true,未探测到的挂单不代表已售出,调用方不得触发全量重扫 */ async function mrProbe(gen) { const sorted = [...mrState.mine].sort((a, b) => a.rank - b.rank); const pageStarts = new Set(); let truncated = false; for (const m of sorted) { if (pageStarts.size >= MR_PROBE_MAX_PAGES) { truncated = true; break; } const base = Math.floor((m.rank - 1) / MR_PAGE_SIZE) * MR_PAGE_SIZE; pageStarts.add(Math.max(0, base - MR_PAGE_SIZE)); pageStarts.add(base); pageStarts.add(base + MR_PAGE_SIZE); } const starts = [...pageStarts].sort((x, y) => x - y); const found = new Map(); // id → 新 rank let total = mrState.total; const clusterCents = new Map(); // cents → 本窗口内数量(刷新 tie) for (let i = 0; i < starts.length; i++) { if (gen !== mrState.gen) return null; const start = starts[i]; const page = await mrFetchPage(start, gen); // 间隔由全局限速器控制 total = page.total || total; for (let j = 0; j < page.listings.length; j++) { const l = page.listings[j]; if (l.bMine && l.id) found.set(l.id, { id: l.id, cents: l.cents, rank: start + j + 1 }); if (l.bMine) clusterCents.set(l.cents, (clusterCents.get(l.cents) || 0) + 1); } } return { total, found, clusterCents, truncated }; } /** 一次完整周期:探测 → 异常时全量重扫 → 变化检测与 UI 更新 */ async function mrRunCycle(gen) { if (!mrState.active || mrState.running || gen !== mrState.gen) return; mrState.running = true; try { if (mrState.phase === 'ok' && mrState.mine.length > 0) { const probe = await mrProbe(gen); if (gen !== mrState.gen) return; if (probe) { const prevTotal = mrState.total; const prevFront = Math.min(...mrState.mine.map(m => m.rank)); const prevIds = new Set(mrState.mine.map(m => m.id)); // 全部找到 → 直接更新排名;有丢失 → 全量重扫确认(售出/下架/大位移) // v1.4.8:探测窗口被截断时未找到 ≠ 已售出,保留旧排名合并,避免挂单多时每轮都全量重扫 const allFound = [...prevIds].every(id => probe.found.has(id)); let soldIds = []; if (allFound) { mrState.mine = [...probe.found.values()].map(m => { const old = mrState.mine.find(x => x.id === m.id); return { ...m, tieCount: probe.clusterCents.get(m.cents) || (old ? old.tieCount : 1) }; }); } else if (probe.truncated) { mrState.mine = mrState.mine.map(m => { const f = probe.found.get(m.id); if (!f) return m; // 窗口外挂单沿用上次排名 const old = m; return { id: f.id, cents: f.cents, rank: f.rank, tieCount: probe.clusterCents.get(f.cents) || old.tieCount }; }); } else { const rescan = await mrScanAll(gen); if (gen !== mrState.gen) return; if (rescan) { mrState.mine = rescan.mine; soldIds = [...prevIds].filter(id => !rescan.mine.some(m => m.id === id)); } } mrState.total = probe.total || mrState.total; const newFront = mrState.mine.length ? Math.min(...mrState.mine.map(m => m.rank)) : null; // 变化提示 const notes = []; if (prevTotal !== mrState.total) { const d = mrState.total - prevTotal; notes.push(`在售 ${prevTotal.toLocaleString()}→${mrState.total.toLocaleString()}(${d > 0 ? '+' : ''}${d})`); } if (newFront != null && newFront !== prevFront) { notes.push(`我的顺序 #${prevFront.toLocaleString()}→#${newFront.toLocaleString()}`); } for (const id of soldIds) { notes.push(`挂单 …${id.slice(-6)} 已售出或下架`); toast(`市场顺序:挂单 …${id.slice(-6)} 已售出或下架`, 'success'); } mrState.changeNote = notes.join(' · '); if (notes.length > 0) { mrFlashRank(newFront != null && prevFront != null && newFront < prevFront ? 'up' : 'down'); if (!soldIds.length && (newFront !== prevFront)) toast(`我的市场顺序 #${prevFront} → #${newFront}`, 'info'); } if (mrState.mine.length === 0) { mrState.phase = 'none'; } else { mrState.phase = 'ok'; mrState.lastUpdate = Date.now(); mrState.failStreak = 0; mrSaveCache(); } mrRenderPanel(); mrInjectDomBadges(); } else { throw new Error('探测被中断'); } } else { // 初始周期:优先二分定位,失败或数量不符时全量扫描兜底 const page0 = mrCurrentDocPage(); if (page0) { mrState.total = page0.total; mrState.currency = page0.currency; } const rows = mrGetMyTableRows(); const myPrices = rows.map(mrParseRowPriceCents).filter(c => c > 0); let result = null; if (page0 && myPrices.length > 0) { mrState.phase = 'scanning'; mrRenderPanel(); try { result = await mrLocateMine(gen, myPrices, page0); } catch (e) { W('MR binary locate failed:', e.message); result = null; } // 校验:找到数量应 ≥ 表格行数(表格可能滞后);不符则全量兜底 if (result && result.mine.length < myPrices.length) { L(`MR locate found ${result.mine.length} < table ${myPrices.length}, fallback to full scan`); result = null; } } if (!result) { mrState.phase = 'scanning'; mrRenderPanel(); result = await mrScanAll(gen); } if (gen !== mrState.gen) return; if (result) { mrState.total = result.total; mrState.currency = result.currency || mrState.currency; mrState.mine = result.mine; if (result.mine.length === 0) { // v1.5.1 P2-E 修复:API/SSR 的 total_count 与实际 listings 不一致时 // (常见 Steam 数据残留:total=1 但 listings=[]),以本轮实际扫到的数量为准, // 避免面板误显"共 1 件在售"——用户在物品页明明看不到任何挂单 if (result.scanned === 0 && mrState.total > 0) mrState.total = 0; mrState.phase = 'none'; mrState.changeNote = ''; } else { mrState.phase = 'ok'; mrState.lastUpdate = Date.now(); mrState.failStreak = 0; const prev = mrLoadCache(); if (prev && prev.mine && prev.mine.length) { const pf = Math.min(...prev.mine.map(m => m.rank)); const nf = Math.min(...result.mine.map(m => m.rank)); if (pf !== nf) mrState.changeNote = `上次访问 #${pf.toLocaleString()} → 本次 #${nf.toLocaleString()}`; } mrSaveCache(); } mrRenderPanel(); mrInjectDomBadges(); } } } catch (e) { if (gen !== mrState.gen) return; // v1.5.1 P1-A:请求层迁移后,中止/超时统一为 SmeRequestError('Timeout') if (e && (e.name === 'AbortError' || e.code === SME_ERR.TIMEOUT)) return; mrState.failStreak++; mrState.phase = mrState.mine.length ? 'ok' : 'error'; mrState.changeNote = e.message || '网络错误'; mrRenderPanel(); W('MR cycle error:', e.message); } finally { mrState.running = false; if (gen === mrState.gen && mrState.active) mrScheduleNext(); } } function mrFlashRank(dir) { const big = document.getElementById('sme-mr-rank-big'); if (!big) return; big.classList.remove('up', 'down'); void big.offsetWidth; big.classList.add(dir === 'up' ? 'up' : 'down'); const panel = mrState.panelEl; if (panel) { panel.classList.remove('sme-mr-flash'); void panel.offsetWidth; panel.classList.add('sme-mr-flash'); } } // ---- UI:页面 DOM 徽章注入(我的上架表格 + 市场卡片) ---- function mrInjectDomBadges() { if (mrState.phase !== 'ok' || mrState.mine.length === 0) return; const sorted = [...mrState.mine].sort((a, b) => a.rank - b.rank); const front = sorted[0]; const rankForCents = (cents) => { // 同价多个我的挂单:取该价格下最靠前的;返回 {rank, tieCount, ambiguous} const same = sorted.filter(m => m.cents === cents); if (same.length === 0) return null; return { rank: same[0].rank, tieCount: same[0].tieCount, ambiguous: same.length > 1 }; }; // v1.4.8:只设置 data 属性(React diff 不移除非受控属性),徽章由 CSS ::after 渲染, // 不再向 React 管理的容器 appendChild/insertAdjacentElement 插入子节点 const applyBadge = (el, info, labelFmt) => { if (!el || !el.setAttribute) return; if (info) { el.setAttribute('data-sme-rank', labelFmt(info)); if (info.rank === front.rank) el.setAttribute('data-sme-front', '1'); else el.removeAttribute('data-sme-front'); } else { el.setAttribute('data-sme-rank', '顺序计算中…'); el.removeAttribute('data-sme-front'); } }; // 1) "出售 - 我的上架物品" 表格行价格格注入市场顺序 const rows = mrGetMyTableRows(); for (const row of rows) { const cents = mrParseRowPriceCents(row); // 价格格 = 倒数第二格(操作格含按钮) const priceCell = row.children[row.children.length - 2] || row.children[2]; applyBadge(priceCell, cents ? rankForCents(cents) : null, info => `市场第 ${info.rank.toLocaleString()} 位${info.tieCount > 1 ? `(同级 ${info.tieCount} 件)` : ''}`); } // 2) 市场列表卡片上"(我的上架物品)"标记追加顺序徽章 // v1.4.8:先按叶子节点过滤再做文本正则,省去大列表页上万个 span 的 textContent 读取 for (const span of document.querySelectorAll('span')) { if (span.childElementCount > 0) continue; // 纯文本标记必然是叶子 span const txt = (span.textContent || '').trim(); if (!txt || txt.length > 30) continue; if (!MR_MY_MARKER_RE.test(txt)) continue; // 从同级容器文本解析该卡片价格 const holder = span.parentElement; const scope = holder.parentElement || holder; const cents = parsePriceToCents(scope.textContent); applyBadge(span, cents ? rankForCents(cents) : null, info => `第 ${info.rank.toLocaleString()} 位`); } } /** DOM 变化观察:React 重渲染后自动补回徽章(防抖)。 v1.4.8:后台标签页跳过注入(React 大列表重渲染时 observer 触发密集) */ function mrSetupObserver() { if (mrState.observer) return; let timer = null; mrState.observer = new MutationObserver(() => { if (timer) clearTimeout(timer); timer = setTimeout(() => { if (!mrState.active || mrState.phase !== 'ok') return; if (document.visibilityState === 'hidden') return; mrEnsurePanel(); // v1.4.9:如果面板已断开连接,尝试重新嵌入到筛选面板内部,否则回退到 body if (mrState.panelEl && !mrState.panelEl.isConnected) { if (mrState.panelEl.classList.contains('sme-mr-inline')) { const filterInfo = mrFindFilterContainer(); if (filterInfo && filterInfo.container) { if (filterInfo.mode === 'inside') { filterInfo.container.appendChild(mrState.panelEl); } else { filterInfo.container.parentNode.insertBefore(mrState.panelEl, filterInfo.container.nextSibling); } } else { mrState.panelEl.classList.remove('sme-mr-inline'); document.body.appendChild(mrState.panelEl); } } else { document.body.appendChild(mrState.panelEl); } } mrInjectDomBadges(); }, 400); }); mrState.observer.observe(document.body, { childList: true, subtree: true }); } // ---- 初始化与清理 ---- function initMarketRankTracker() { if (!settings.get('marketRankTracker')) return; if (mrState.active && mrState.url === location.href) return; // 仅新版 React 市场页支持(需 renderContext);未登录无法识别 bMine let rc = null; try { rc = unsafeWindow.SSR && unsafeWindow.SSR.renderContext; } catch (e) {} if (!rc) { L('MR: 非 React SSR 市场页,跳过市场顺序跟踪'); return; } let loggedIn = true; try { if (unsafeWindow.UserConfig && unsafeWindow.UserConfig.logged_in === false) loggedIn = false; } catch (e) {} if (!loggedIn) { L('MR: 未登录,跳过市场顺序跟踪'); return; } cleanupMarketRankTracker(); mrState.active = true; mrState.url = location.href; mrState.gen++; const gen = mrState.gen; // 回显上次结果,随后立即开始扫描 const cached = mrLoadCache(); if (cached) { mrState.total = cached.total || 0; mrState.currency = cached.currency || 0; mrState.mine = cached.mine.map(m => ({ ...m })); mrState.phase = 'ok'; mrState.lastUpdate = cached.ts; mrState.changeNote = '上次访问结果,正在刷新…'; } mrEnsurePanel(); mrRenderPanel(); mrSetupObserver(); mrStartCountdown(); L('MR: 市场顺序跟踪已启动', location.pathname, cached ? '(cache hit)' : ''); setTimeout(() => mrRunCycle(gen), 500); } function cleanupMarketRankTracker() { mrState.gen++; mrState.active = false; mrState.running = false; mrState.paused = false; mrState.phase = 'init'; mrState.mine = []; mrState.total = 0; mrState.currency = 0; mrState.failStreak = 0; mrState.changeNote = ''; mrState.countdown = 0; clearTimeout(mrState.timer); mrState.timer = null; clearInterval(mrState.countdownTimer); mrState.countdownTimer = null; if (mrState.abortCtrl) { try { mrState.abortCtrl.abort(); } catch (e) {} mrState.abortCtrl = null; } if (mrState.observer) { try { mrState.observer.disconnect(); } catch (e) {} mrState.observer = null; } if (mrState.panelEl) { try { mrState.panelEl.remove(); } catch (e) {} mrState.panelEl = null; } // v1.4.8:徽章已是 data 属性,移除属性即可(不动 React 节点结构); // 限速器时间窗/429 冷却状态跨清理保留,防止换页后立即再次冲撞限流 document.querySelectorAll('[data-sme-rank]').forEach(el => { el.removeAttribute('data-sme-rank'); el.removeAttribute('data-sme-front'); }); } // ==================== v1.5.0 市场物品页显示更多价格(新版 React 市场页) ==================== // 参考 sffxzzp/marketshowmore:新版市场物品页为 React SSR + 客户端 hydrate,订单表格组件 // 内部只渲染 orders.slice(0, 6)(默认 6 行)。本模块在 document-start 阶段(react-dom // 初始化之前)向 window 注入假的 __REACT_DEVTOOLS_GLOBAL_HOOK__:react-dom 初始化时会 // 调用 hook.inject() 完成绑定,之后每次 commit 都会回调 hook.onCommitFiberRoot;此时遍历 // fiber 树,把接收 orders 数组 prop(带 currency 属性)的组件定义替换为包装函数,包装函数 // 将 props.orders 替换为 Proxy——拦截其 .slice(0, 6) 调用并改写为 .slice(0, N)(N 为设置 // 行数),从而借助页面自身的渲染流程显示更多价格行。 // 关键约束:必须 @run-at document-start,否则 react-dom 已完成初始化,假 hook 无法绑定。 // 若页面已存在真实 hook(用户安装了 React DevTools 扩展),本模块让位不注入。 const SMM_HOOK_NAME = '__REACT_DEVTOOLS_GLOBAL_HOOK__'; const SMM_PATCH_FLAG = '_smeShowMorePatched'; // props 标记:该 props 已被包装组件处理过 const SMM_WRAPPER_FLAG = '_smeShowMoreWrapper'; // 组件标记:已是包装组件(防止重复嵌套包装) const SMM_DEFAULT_ROWS = 20; // 默认显示行数 const SMM_INTERCEPT_END = 6; // Steam 订单表格默认切片终点 slice(0, 6) const SMM_MAX_ROWS = 100; // 行数上限 const smmState = { hook: null, // 我们注入的假 hook 对象(关闭时只置灰不删除,保持 react-dom 已建立的绑定) enabled: false, lastRoot: null, // 最近一次 commit 的 fiber root(关闭时尽力还原组件定义) patched: 0, // 累计 patch 的组件定义数(日志用) walkTimer: null, // 遍历合并定时器(连续 commit 时只跑最新一次) }; /** 读取设置的行数(钳制在 6-100) */ function smmRows() { let n = parseInt(settings.get('showMorePricesRows'), 10); if (isNaN(n)) n = SMM_DEFAULT_ROWS; return Math.min(SMM_MAX_ROWS, Math.max(SMM_INTERCEPT_END, n)); } /** 遍历 fiber 树,patch 接收 orders 数组 prop 的组件定义(orders.slice(0,6) → slice(0,N)) */ function smmProcessFiberRoot(root) { smmState.walkTimer = null; if (!smmState.enabled || !root) return; try { smmState.lastRoot = root; const stack = [root.current]; const visited = new Set(); let patchedNow = 0; while (stack.length > 0) { const node = stack.pop(); if (!node || visited.has(node)) continue; visited.add(node); const { type, memoizedProps } = node; if (memoizedProps && Array.isArray(memoizedProps.orders) && memoizedProps.hasOwnProperty('currency')) { const componentFunction = (type && type.type) ? type.type : type; if (componentFunction && typeof componentFunction === 'function' && !componentFunction[SMM_WRAPPER_FLAG]) { const original = componentFunction; const PatchedComponent = function (props, ...args) { if (!props[SMM_PATCH_FLAG] && props.orders && Array.isArray(props.orders)) { const ordersProxy = new Proxy(props.orders, { get(target, prop, receiver) { if (prop === 'slice') { return function (start, end) { if (start === 0 && end === SMM_INTERCEPT_END) { return target.slice(0, smmRows()); } return target.slice(start, end); }; } return Reflect.get(target, prop, receiver); }, }); return original.apply(this, [{ ...props, orders: ordersProxy, [SMM_PATCH_FLAG]: true }, ...args]); } return original.apply(this, [props, ...args]); }; PatchedComponent[SMM_WRAPPER_FLAG] = true; PatchedComponent._smmOriginal = original; try { if (type && type.type) type.type = PatchedComponent; else if (node.type && typeof node.type === 'function') node.type = PatchedComponent; patchedNow++; } catch (e) { W('SMM: patch 组件定义失败', e); } } } if (node.child) stack.push(node.child); if (node.sibling) stack.push(node.sibling); } if (patchedNow) { smmState.patched += patchedNow; L(`SMM: 已 patch ${patchedNow} 个订单组件(累计 ${smmState.patched}),订单表格将显示 ${smmRows()} 行`); } } catch (e) { W('SMM: 处理 fiber 树异常', e); } } /** * 注入假 React DevTools hook(须在 document-start 调用)。 * 返回:'installed' 新建注入 | 'reused' 复用并重新启用既有假 hook | 'conflict' 已存在外部 hook | 'off' 设置未开启 */ function installShowMoreHook() { if (!settings.get('showMorePrices')) return 'off'; let existing = null; try { existing = unsafeWindow[SMM_HOOK_NAME]; } catch (e) {} if (existing) { if (existing === smmState.hook) { smmState.enabled = true; L('SMM: 复用既有假 hook 并重新启用'); return 'reused'; } W('SMM: 页面已存在 ' + SMM_HOOK_NAME + '(可能安装了 React DevTools 扩展),本模块让位'); return 'conflict'; } const hook = { renderers: new Map(), supportsFiber: true, inject: function (renderer) { const id = (hook.renderers.size || 0) + 1; hook.renderers.set(id, renderer); return id; }, onCommitFiberRoot: function (rendererID, root) { if (!smmState.enabled) return; if (smmState.walkTimer) return; // 连续 commit 合并,只处理最新 root smmState.walkTimer = setTimeout(() => smmProcessFiberRoot(root), 200); }, onCommitFiberUnmount: function () {}, onPostCommitFiberRoot: function () {}, }; try { Object.defineProperty(unsafeWindow, SMM_HOOK_NAME, { value: hook, configurable: true }); } catch (e) { W('SMM: 注入假 hook 失败', e); return 'conflict'; } smmState.hook = hook; smmState.enabled = true; L(`SMM: 已注入假 React DevTools hook,订单表格将显示 ${smmRows()} 行`); return 'installed'; } /** 尽力还原被 patch 的组件定义(关闭功能时调用,返回还原数量) */ function smmRestorePatched() { const root = smmState.lastRoot; if (!root) return 0; let restored = 0; try { const stack = [root.current]; const visited = new Set(); while (stack.length > 0) { const node = stack.pop(); if (!node || visited.has(node)) continue; visited.add(node); const t = node.type; const inner = t && t.type; if (inner && typeof inner === 'function' && inner[SMM_WRAPPER_FLAG] && inner._smmOriginal) { try { t.type = inner._smmOriginal; restored++; } catch (e) {} } else if (typeof t === 'function' && t[SMM_WRAPPER_FLAG] && t._smmOriginal) { try { node.type = t._smmOriginal; restored++; } catch (e) {} } if (node.child) stack.push(node.child); if (node.sibling) stack.push(node.sibling); } } catch (e) {} return restored; } /** 关闭显示更多价格(保留 hook 对象本身以维持 react-dom 绑定,重新开启无需刷新) */ function disableShowMorePrices() { smmState.enabled = false; if (smmState.walkTimer) { clearTimeout(smmState.walkTimer); smmState.walkTimer = null; } const restored = smmRestorePatched(); smmState.patched = 0; L('SMM: 已关闭显示更多价格' + (restored ? `(还原 ${restored} 个组件定义,刷新页面后完全恢复)` : '')); } /** 设置开关统一处理(设置面板与浏览页悬浮按钮共用) */ function onShowMorePricesToggle(val) { settings.set('showMorePrices', val); if (val) { const status = installShowMoreHook(); if (status === 'reused') toast('显示更多价格已开启', 'success'); else if (status === 'installed') toast('已开启,刷新页面后生效', 'info'); else if (status === 'conflict') toast('开启失败:页面已存在 ' + SMM_HOOK_NAME + '(React DevTools 扩展冲突)', 'error'); } else { disableShowMorePrices(); toast('已关闭,刷新页面后完全恢复', 'info'); } } // ==================== v1.6.0 市场交易记录汇总与导出 ==================== // 在"我的市场历史记录"页("出售物品"按钮旁)注入工具条: // 汇总 —— 多维统计(笔数 / 收支 / 估算手续费 / 按物品·游戏·月份分组) // 导出 —— 按下拉选择的 CSV 或 JSON 格式导出全部交易记录 // 刷新 —— 重新分页抓取全量数据 // // 数据源:https://steamcommunity.com/market/myhistory/render/?query=&start=&count= // 单页上限 100 条,返回 { success, results_html, total_count, hovers, assets }。 // Steam 只返回渲染好的 HTML 片段,故需在本地解析行节点。 // // 注意:Steam 的历史记录表格不返回年份(只显示"10 月 18 日"), // mhAssignTimestamps 依据接口的倒序分页做跨年推断,得到近似时间戳。 // // 依赖:h, escapeHtml, toast, sleep, settings, cache, smeRequest, SME_ERR, // friendlyError, parsePriceToCents, formatCentsStr, priceBeforeFees, // getWalletInfo, getSteamId, ICONS, showConfirmDialog, RETRY_BACKOFF_MIN/MAX, L, W。 const MH_PAGE_MAX = 100; // Steam 单页硬上限 const MH_CACHE_PREFIX = 'mh_rows_v1_'; const MH_CACHE_TTL = 12 * 60 * 60 * 1000; // 本地索引缓存 12 小时 const mhState = { active: false, styleAdded: false, panelEl: null, // 独立容器节点(#myMarketTabs 的前一个兄弟) rows: [], // 已索引的交易记录(断点续传时从此继续,不清空) totalCount: 0, // Steam 报告的总数 fetchedAt: 0, // 最近一次成功抓取的时间戳 nextOffset: 0, // 下次请求的 start 偏移(= 已消费到的接口位置) complete: false, // 是否已抓完 paused: false, // 是否因限流暂停 nextAt: 0, // 限流冷却到期时间戳(0 = 不在冷却) fetching: false, abort: false, format: 'csv', }; // v1.6.8:缓存结构版本。v1 是「无进度元数据」的旧结构,读到时降级处理(见 mhLoadCache) const MH_CACHE_V = 2; // 限流冷却时长(毫秒),按连续限流次数递增 const MH_COOLDOWN_BASE = 60 * 1000; const MH_COOLDOWN_MAX = 15 * 60 * 1000; const MH_CSS = ` /* ============================================================================ v1.6.2 视觉系统重构(按 UI Design Master 规范落地) - 8px 基准间距阶梯:4 / 8 / 12 / 16 / 24 / 32 - 圆角收敛为 3 档:6(控件)/ 10(卡片)/ 14(窗口) - 表面层级:bg-1 窗口 → bg-2 卡片 → bg-3 控件,三层各差一档亮度 - 语义色:卖出绿 / 买入红 / 警告琥珀 / 信息蓝 / 强调紫,均满足 WCAG AA 正文对比 - 动效只用 transform / opacity,时长 140–200ms,缓动 cubic-bezier(.4,0,.2,1) - 响应式:栅格 auto-fit,窄屏降列,表格横向滚动 ============================================================================ */ .sme-mh-modal,.sme-mh-panel{ --mh-bg-1:#151a21; --mh-bg-2:#1b222b; --mh-bg-3:#232d39; --mh-line:rgba(255,255,255,.07); --mh-line-2:rgba(255,255,255,.13); --mh-t1:#e9eff5; --mh-t2:#9aa9b8; --mh-t3:#6b7a89; --mh-sell:#4ade80; --mh-buy:#f87171; --mh-warn:#fbbf24; --mh-info:#66c0f4; --mh-purple:#a78bfa; --mh-r-s:6px; --mh-r-m:10px; --mh-r-l:14px; --mh-ease:cubic-bezier(.4,0,.2,1); --mh-shadow-1:0 1px 2px rgba(0,0,0,.30); --mh-shadow-2:0 4px 16px rgba(0,0,0,.28); --mh-shadow-3:0 18px 48px rgba(0,0,0,.55); } /* ---------- 页面工具条容器 ---------- */ /* v1.7.1:市场首页操作栏做成浮动光条,深色页上一眼能定位 */ .sme-mh-panel{ clear:both;position:relative;z-index:4; margin:12px 0 18px; font-family:"Motiva Sans",system-ui,-apple-system,sans-serif; } .sme-mh-panel-inner{ position:relative;overflow:hidden; display:flex;align-items:center;gap:12px;flex-wrap:wrap; padding:15px 18px; background: radial-gradient(130% 180% at 0% 0%, rgba(102,192,244,.42) 0%, rgba(102,192,244,0) 52%), linear-gradient(180deg,#314863 0%,#121922 100%); border:1px solid rgba(196,236,255,.92); border-radius:12px; box-shadow: 0 0 0 1px rgba(102,192,244,.45), 0 12px 32px rgba(0,0,0,.5), 0 0 32px rgba(102,192,244,.62); animation:sme-mh-bar-glow 2.6s ease-in-out infinite; } .sme-mh-panel-inner::before{ content:"";position:absolute;top:-20%;bottom:-20%;left:0;width:38%; background:linear-gradient(100deg,transparent,rgba(214,242,255,.34),transparent); transform:translateX(-130%) skewX(-18deg); animation:sme-mh-bar-sheen 3.6s ease-in-out infinite; pointer-events:none; } .sme-mh-panel-inner::after{ content:"";position:absolute;left:16px;right:16px;top:0;height:2px; background:linear-gradient(90deg,transparent,#fff 20%,#9adfff 50%,#fff 80%,transparent); box-shadow:0 0 14px #66c0f4; pointer-events:none; } .sme-mh-panel-inner > *{position:relative;z-index:1} @keyframes sme-mh-bar-glow{ 0%,100%{ border-color:rgba(168,224,255,.85); box-shadow:0 0 0 1px rgba(102,192,244,.4),0 12px 28px rgba(0,0,0,.48),0 0 28px rgba(102,192,244,.55); } 50%{ border-color:#fff; box-shadow:0 0 0 1px rgba(230,248,255,.7),0 14px 36px rgba(0,0,0,.55),0 0 46px rgba(102,192,244,.85); } } @keyframes sme-mh-bar-sheen{ 0%{transform:translateX(-130%) skewX(-18deg)} 58%,100%{transform:translateX(340%) skewX(-18deg)} } .sme-mh-title{ display:inline-flex;align-items:center;gap:8px; font-size:15px;font-weight:800;color:#eef8ff;letter-spacing:.3px;white-space:nowrap; text-shadow:0 0 14px rgba(102,192,244,.9); padding-right:14px;border-right:1px solid rgba(102,192,244,.4); } .sme-mh-title svg{width:18px;height:18px;filter:drop-shadow(0 0 6px #66c0f4)} .sme-mh-actions{margin-left:auto;display:inline-flex;align-items:center;gap:8px;flex-wrap:wrap} .sme-mh-actions button:disabled{opacity:.4;cursor:not-allowed} .sme-mh-actions .sme-market-btn{ padding:7px 14px;font-size:13px;font-weight:700;border-radius:8px; background:rgba(102,192,244,.18);border:1px solid rgba(168,224,255,.62);color:#f4fbff; box-shadow:0 0 12px rgba(102,192,244,.22); } .sme-mh-actions .sme-market-btn:hover{ background:rgba(102,192,244,.34);border-color:#e7f7ff;color:#fff; box-shadow:0 0 18px rgba(102,192,244,.5); } .sme-mh-actions .sme-market-btn.sme-danger{ background:rgba(226,74,74,.22);border-color:rgba(255,154,154,.78);color:#ffe4e4; box-shadow:0 0 12px rgba(248,113,113,.28); } .sme-mh-actions .sme-market-btn.sme-danger:hover{ background:rgba(226,74,74,.36);border-color:#ffd0d0;color:#fff; } .sme-mh-actions .sme-market-btn .sme-svg{width:15px;height:15px} .sme-mh-format{ padding:7px 12px;min-height:34px;border:1px solid rgba(168,224,255,.62);background:#1b2836; color:#f4fbff;font-size:13px;font-weight:700;border-radius:8px;cursor:pointer; font-family:inherit;transition:border-color .15s var(--mh-ease),background .15s var(--mh-ease),box-shadow .15s var(--mh-ease); box-shadow:0 0 12px rgba(102,192,244,.16); } .sme-mh-format:hover{background:#243446;border-color:#e7f7ff} .sme-mh-format:focus{outline:none;border-color:#fff;box-shadow:0 0 0 3px rgba(102,192,244,.35)} .sme-mh-status{ font-size:13px;font-weight:650;color:#e4eef6;white-space:nowrap; padding:5px 12px;border-radius:999px; background:rgba(0,0,0,.38);border:1px solid rgba(255,255,255,.14); } .sme-mh-status.sme-ok{color:#b6f5cf;border-color:rgba(74,222,128,.45);background:rgba(74,222,128,.12)} .sme-mh-status.sme-err{color:#ffd0d0;border-color:rgba(248,113,113,.5);background:rgba(248,113,113,.12)} .sme-mh-progress{ height:3px;background:rgba(255,255,255,.07);border-radius:999px; margin-top:4px;overflow:hidden;display:none; } .sme-mh-progress-bar{ height:100%;width:0;border-radius:999px; background:linear-gradient(90deg,var(--mh-info) 0%,#a5dcff 100%); transition:width .25s var(--mh-ease); } /* ---------- 分析窗口 ---------- */ .sme-mh-modal{ position:fixed;inset:0;z-index:var(--sme-z-overlay); background:rgba(6,9,13,.72);backdrop-filter:blur(3px); display:none;align-items:center;justify-content:center;padding:3vh 2vw; } .sme-mh-modal.visible{display:flex;animation:sme-mh-in .2s var(--mh-ease)} @keyframes sme-mh-in{from{opacity:0}to{opacity:1}} .sme-mh-win{ display:flex;flex-direction:column;width:100%;max-width:1200px;max-height:94vh; background:linear-gradient(180deg,var(--mh-bg-1) 0%,#11161c 100%); border:1px solid var(--mh-line-2);border-radius:var(--mh-r-l); box-shadow:var(--mh-shadow-3);overflow:hidden; font-family:"Motiva Sans",system-ui,-apple-system,sans-serif;color:var(--mh-t1); animation:sme-mh-pop .24s var(--mh-ease); } @keyframes sme-mh-pop{from{opacity:0;transform:translateY(12px) scale(.985)}to{opacity:1;transform:none}} .sme-mh-win-header{ display:flex;align-items:center;justify-content:space-between;gap:16px; padding:12px 16px;border-bottom:1px solid var(--mh-line); background:linear-gradient(135deg,var(--mh-cb,var(--sme-blue-a08)) 0%,rgba(13,16,21,.6) 100%); flex-shrink:0; } .sme-mh-win-title{display:flex;align-items:center;gap:10px;min-width:0} .sme-mh-win-title #sme-mh-ht{font-size:16px;font-weight:700;letter-spacing:.2px} .sme-mh-win-ico{display:inline-flex;color:var(--mh-c,var(--mh-info))} .sme-mh-win-ico svg{width:20px;height:20px} .sme-mh-win-sub{ font-size:12px;color:var(--mh-t3); border-left:1px solid var(--mh-line-2);padding-left:10px;margin-left:2px; } .sme-mh-win-close{ background:var(--mh-bg-3);border:1px solid var(--mh-line-2);border-radius:var(--mh-r-s); color:var(--mh-t2);cursor:pointer;padding:6px;display:inline-flex;flex-shrink:0; transition:background .15s var(--mh-ease),color .15s var(--mh-ease),transform .15s var(--mh-ease); } .sme-mh-win-close:hover{background:rgba(248,113,113,.16);color:var(--mh-buy);border-color:rgba(248,113,113,.4)} .sme-mh-win-close:active{transform:scale(.94)} .sme-mh-win-close svg{width:16px;height:16px;display:block} .sme-mh-win-body{ display:flex;flex-direction:column;min-height:0;flex:1; padding:12px 16px 14px;overflow:hidden; } /* 顶部标签:胶囊分段控件 */ .sme-mh-tabs{ display:flex;gap:4px;padding:4px;margin-bottom:12px;flex-shrink:0; background:rgba(0,0,0,.28);border:1px solid var(--mh-line);border-radius:var(--mh-r-m); } .sme-mh-tab{ display:inline-flex;align-items:center;gap:6px;padding:6px 14px; background:transparent;border:1px solid transparent;border-radius:var(--mh-r-s); color:var(--mh-t2);font-size:13px;font-weight:500;cursor:pointer;font-family:inherit; transition:background .16s var(--mh-ease),color .16s var(--mh-ease),transform .16s var(--mh-ease); } .sme-mh-tab svg{width:15px;height:15px;opacity:.85} .sme-mh-tab:hover{background:rgba(255,255,255,.06);color:var(--mh-t1)} .sme-mh-tab:active{transform:scale(.97)} .sme-mh-tab.active{ background:var(--mh-cb,var(--sme-blue-a15));border-color:var(--c,var(--mh-info)); color:var(--c,var(--mh-info));font-weight:600; } .sme-mh-tab.active svg{opacity:1} .sme-mh-panels{flex:1;min-height:0;overflow:auto;padding-right:4px} .sme-mh-view{display:none} .sme-mh-view.active{display:block;animation:sme-mh-fade .18s var(--mh-ease)} @keyframes sme-mh-fade{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}} .sme-mh-mf{ display:flex;align-items:flex-start;gap:8px;flex-shrink:0; margin-top:12px;padding-top:10px;border-top:1px solid var(--mh-line); font-size:11px;color:var(--mh-t3);line-height:1.7; } .sme-mh-mf svg{width:14px;height:14px;flex-shrink:0;margin-top:2px;opacity:.7} /* ---------- 面板内工具条 ---------- */ .sme-mh-toolbar{ display:flex;align-items:center;gap:8px;flex-wrap:wrap; padding:10px 12px;margin-bottom:12px;border-radius:var(--mh-r-m); background:var(--mh-bg-2);border:1px solid var(--mh-line); } .sme-mh-tb-label{font-size:11px;font-weight:600;color:var(--mh-t3);letter-spacing:.4px;margin-right:2px} .sme-mh-tb-sep{width:1px;height:16px;background:var(--mh-line-2);margin:0 4px} .sme-mh-tb-range{font-size:11px;color:var(--mh-t3);margin-left:auto} .sme-mh-chip{ display:inline-flex;align-items:center;gap:6px;padding:5px 12px; background:var(--mh-bg-3);border:1px solid transparent;border-radius:var(--mh-r-s); color:var(--mh-t2);font-size:12px;cursor:pointer;font-family:inherit; transition:background .15s var(--mh-ease),color .15s var(--mh-ease), border-color .15s var(--mh-ease),transform .15s var(--mh-ease); } .sme-mh-chip:hover:not(:disabled){background:#2c3745;color:var(--mh-t1)} .sme-mh-chip:active:not(:disabled){transform:scale(.96)} .sme-mh-chip:disabled{opacity:.35;cursor:not-allowed} .sme-mh-chip.active{ background:var(--cb,var(--sme-blue-a15));border-color:var(--c,var(--mh-info)); color:var(--c,var(--mh-info));font-weight:600; } .sme-mh-chip i{width:10px;height:10px;border-radius:3px;flex-shrink:0} /* ---------- 数据卡片 ---------- */ .sme-mh-stats{ display:grid;grid-template-columns:repeat(auto-fit,minmax(168px,1fr));gap:10px;margin-bottom:12px; } .sme-mh-stat{ position:relative;overflow:hidden;min-width:0; background:var(--mh-bg-2);border:1px solid var(--mh-line);border-radius:var(--mh-r-m); padding:12px 14px 12px 16px; transition:transform .16s var(--mh-ease),border-color .16s var(--mh-ease),box-shadow .16s var(--mh-ease); } .sme-mh-stat::before{ content:"";position:absolute;left:0;top:0;bottom:0;width:3px; background:var(--mh-info);opacity:.55; } .sme-mh-stat.is-up::before{background:var(--mh-sell);opacity:1} .sme-mh-stat.is-down::before{background:var(--mh-buy);opacity:1} .sme-mh-stat:hover{ transform:translateY(-2px);border-color:var(--mh-line-2);box-shadow:var(--mh-shadow-2); } .sme-mh-stat-ico{ position:absolute;top:12px;right:12px;display:inline-flex; color:var(--mh-t3);opacity:.45; } .sme-mh-stat-ico svg{width:15px;height:15px;display:block} .sme-mh-stat.is-up .sme-mh-stat-ico{color:var(--mh-sell);opacity:.65} .sme-mh-stat.is-down .sme-mh-stat-ico{color:var(--mh-buy);opacity:.65} .sme-mh-stat-label{ font-size:11px;font-weight:500;color:var(--mh-t3); letter-spacing:.3px;padding-right:22px; overflow:hidden;text-overflow:ellipsis;white-space:nowrap; } .sme-mh-stat-value{ font-size:19px;font-weight:700;color:var(--mh-t1);margin-top:6px;line-height:1.2; font-variant-numeric:tabular-nums; overflow:hidden;text-overflow:ellipsis;white-space:nowrap; } .sme-mh-stat-value.sme-up{color:var(--mh-sell)} .sme-mh-stat-value.sme-down{color:var(--mh-buy)} .sme-mh-stat-hint{font-size:10px;color:var(--mh-t3);margin-top:4px;line-height:1.5} /* ---------- 卡片 / 双栏 ---------- */ .sme-mh-card{ margin:0;padding:14px 16px;border-radius:var(--mh-r-m); background:var(--mh-bg-2);border:1px solid var(--mh-line); } .sme-mh-card-title{ font-size:12px;font-weight:600;color:var(--mh-t2); margin-bottom:12px;display:flex;align-items:center;gap:8px; } .sme-mh-card-title::before{ content:"";width:3px;height:12px;border-radius:2px;background:var(--mh-info); } .sme-mh-split{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px} @media (max-width:900px){.sme-mh-split{grid-template-columns:1fr}} /* v1.6.5:概览页的两块图表重做布局,收紧高度、去掉双滚动条 */ .sme-mh-flowbox{display:flex;align-items:center;gap:18px} .sme-mh-donut{width:130px;height:130px;flex-shrink:0} .sme-mh-flowlist{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px} .sme-mh-flowrow{ display:grid;grid-template-columns:10px 1fr auto minmax(88px,auto); align-items:center;gap:10px;padding:6px 8px;border-radius:var(--mh-r-s); transition:background .15s var(--mh-ease); } .sme-mh-flowrow:hover{background:rgba(255,255,255,.04)} .sme-mh-flowrow.is-strong{ background:var(--mh-bg-3);margin-top:4px; border:1px solid var(--mh-line-2); } .sme-mh-flowdot{width:10px;height:10px;border-radius:3px} .sme-mh-flowlabel{font-size:12px;color:var(--mh-t2);white-space:nowrap} .sme-mh-flowrow.is-strong .sme-mh-flowlabel{color:var(--mh-t1);font-weight:600} .sme-mh-flowsub{font-size:11px;color:var(--mh-t3);text-align:right} .sme-mh-flowval{ font-size:13px;font-weight:700;color:var(--mh-t1);text-align:right; font-variant-numeric:tabular-nums;white-space:nowrap; } .sme-mh-flowval.is-neg{color:var(--mh-buy)} @media (max-width:760px){ .sme-mh-flowbox{flex-direction:column;align-items:stretch} .sme-mh-donut{align-self:center} } /* 价格直方图:对数分档下柱子数量不定,容器高度收敛 */ .sme-mh-hist{ display:flex;align-items:flex-end;gap:3px;height:168px;padding-top:8px; } .sme-mh-hist-col{ flex:1;display:flex;flex-direction:column;justify-content:flex-end; align-items:center;height:100%;min-width:0; } .sme-mh-hist-bar{ width:100%;border-radius:4px 4px 2px 2px;min-height:3px; background:linear-gradient(180deg,#a5dcff,#38bdf8); transition:opacity .15s var(--mh-ease); } .sme-mh-hist-col:hover .sme-mh-hist-bar{opacity:.82} .sme-mh-hist-count{ font-size:10px;color:var(--mh-t2);margin-top:5px; font-variant-numeric:tabular-nums;white-space:nowrap; } .sme-mh-hist-label{ font-size:9px;color:var(--mh-t3);margin-top:2px; max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap; } .sme-mh-hist-axis{font-size:11px;color:var(--mh-t3);margin-top:8px} /* 排行页 */ .sme-mh-barcell{width:130px;min-width:110px} .sme-mh-barfill{ display:block;height:6px;border-radius:999px; background:linear-gradient(90deg,#38bdf8,rgba(56,189,248,.35)); } .sme-mh-sub{ display:block;font-size:10px;color:var(--mh-t3);font-weight:400; max-width:280px;overflow:hidden;text-overflow:ellipsis; } .sme-mh-th-bar{color:var(--mh-t3);font-weight:500;text-align:left} .sme-mh-ranktable td.sme-mh-barcell{padding-right:16px} /* ---------- 图表 / 图例 ---------- */ /* v1.7.1:绘图区按 viewBox 比例撑满卡片宽度。 旧版容器锁死 400px 高 + preserveAspectRatio meet,宽屏两侧会留出大块空白。 */ .sme-mh-chartbox{ width:100%;height:auto;box-sizing:border-box; display:block;padding:4px 6px 8px; background:var(--mh-bg-2);border:1px solid var(--mh-line);border-radius:var(--mh-r-m); } .sme-mh-chart-plot{ position:relative;width:100%;height:0;overflow:visible; } .sme-mh-chart-plot svg{ position:absolute;inset:0;width:100%;height:100%;display:block;overflow:visible; } .sme-mh-clipnote{ margin-top:10px;padding:8px 12px;border-radius:var(--mh-r-s); background:rgba(251,191,36,.10);border:1px solid rgba(251,191,36,.28); font-size:11px;color:var(--mh-warn);line-height:1.6; } .sme-mh-extra{font-size:11px;color:var(--mh-t3);margin-top:10px} /* 明细页的维度候选 chips 行(替代下拉框) */ .sme-mh-valuebar{ display:flex;align-items:center;gap:8px;flex-wrap:wrap; padding:10px 12px;margin-bottom:12px; background:var(--mh-bg-2);border:1px solid var(--mh-line);border-radius:var(--mh-r-m); } .sme-mh-chip-axis{ font-style:normal;font-size:10px;font-weight:400; color:var(--mh-t3);padding-left:2px; } .sme-mh-chip.active .sme-mh-chip-axis{color:var(--sme-blue-a70)} .sme-mh-empty-sub{font-size:11px;color:var(--mh-t3);opacity:.8} /* ---------- 表格 ---------- */ /* v1.6.5:默认只做横向滚动。纵向滚动交给外层 .sme-mh-panels, 否则面板与表格同时可滚会出现双滚动条(v1.6.4 概览页的实际问题)。 明细页表格较长,加 .is-scrollable 单独开启内层纵向滚动。 */ .sme-mh-table-wrap{ max-height:none;overflow-x:auto;overflow-y:hidden; border:1px solid var(--mh-line);border-radius:var(--mh-r-m); background:var(--mh-bg-2); } .sme-mh-table-wrap.is-scrollable{max-height:46vh;overflow-y:auto} .sme-mh-table{width:100%;border-collapse:separate;border-spacing:0;font-size:12px} .sme-mh-table td{ padding:8px 12px;text-align:left; border-bottom:1px solid var(--mh-line);white-space:nowrap; } .sme-mh-table th{ padding:9px 12px;text-align:left;white-space:nowrap; border-bottom:1px solid var(--mh-line-2); background:#1d252f;color:var(--mh-t2);font-weight:600;letter-spacing:.3px; position:sticky;top:0;z-index:2; } .sme-mh-table th:first-child{top:0} .sme-mh-table tbody tr{transition:background .12s var(--mh-ease)} .sme-mh-table tbody tr:hover{background:rgba(255,255,255,.04)} .sme-mh-table tbody tr:last-child td{border-bottom:none} .sme-mh-table td.sme-num{text-align:right;font-variant-numeric:tabular-nums;color:var(--mh-t1)} .sme-mh-table td.sme-mh-dim,.sme-mh-table td.dim{color:var(--mh-t3)} .sme-mh-table td.sme-mh-name{max-width:340px;overflow:hidden;text-overflow:ellipsis;color:var(--mh-t1)} .sme-mh-rank{ width:34px;text-align:center;color:var(--mh-t3); font-variant-numeric:tabular-nums;font-weight:600; } .sme-mh-zero{color:var(--mh-t3);opacity:.6} .sme-mh-th-sort{cursor:pointer;user-select:none;transition:color .12s var(--mh-ease)} .sme-mh-th-sort:hover{color:var(--mh-t1)} /* ---------- 状态标签 ---------- */ .sme-mh-tag{ display:inline-block;padding:2px 9px;border-radius:999px; font-size:10px;font-weight:600;letter-spacing:.3px; } .sme-mh-tag.sold{background:rgba(74,222,128,.16);color:var(--mh-sell)} .sme-mh-tag.bought{background:rgba(248,113,113,.16);color:var(--mh-buy)} .sme-mh-tag.listed{background:rgba(102,192,244,.16);color:var(--mh-info)} .sme-mh-tag.canceled{background:rgba(255,255,255,.07);color:var(--mh-t3)} .sme-mh-tag.other{background:rgba(255,255,255,.07);color:var(--mh-t3)} /* ---------- 分页 / 空态 ---------- */ .sme-mh-pager{ display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:12px 2px 0; font-size:12px;color:var(--mh-t2); } .sme-mh-empty{ display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px; padding:36px 16px;color:var(--mh-t3);font-size:13px;text-align:center; } .sme-mh-empty::before{ content:"";width:40px;height:40px;border-radius:50%; border:2px dashed var(--mh-line-2); } .sme-mh-hint{font-size:11px;color:var(--mh-t3);margin-top:12px;line-height:1.7} .sme-mh-hint-sm{font-size:11px;color:var(--mh-t3);line-height:1.7;margin-top:8px} /* ---------- 日历热力 ---------- */ .sme-mh-cal-container{ display:flex;flex-direction:column;gap:20px; padding:16px;background:var(--mh-bg-2);border:1px solid var(--mh-line);border-radius:var(--mh-r-m); } .sme-mh-cal-year-label{ font-size:14px;font-weight:700;color:var(--mh-t1);margin-bottom:10px; display:flex;align-items:center;gap:8px; } .sme-mh-cal-year-label::before{ content:"";width:4px;height:14px;border-radius:2px;background:var(--mh-purple); } .sme-mh-cal-months{display:flex;gap:3px;padding-left:28px;margin-bottom:5px} .sme-mh-cal-months span{flex:1;text-align:left;font-size:11px;color:var(--mh-t3)} .sme-mh-cal-body{display:flex;gap:5px} .sme-mh-cal-weekdays{display:flex;flex-direction:column;gap:3px;width:22px;flex-shrink:0} .sme-mh-cal-weekdays span{ font-size:10px;color:var(--mh-t3);text-align:center; height:17px;line-height:17px; } .sme-mh-cal-cells{ display:grid;grid-template-columns:repeat(53,1fr);grid-template-rows:repeat(7,1fr); grid-auto-flow:column;gap:3px;flex:1; } /* v1.6.4:格子加大 + 描边,网格结构一眼可辨;颜色由内联 style 逐档给出 */ .sme-mh-cal-cell{ width:17px;height:17px;border-radius:4px;position:relative; display:flex;align-items:center;justify-content:center; border:1px solid rgba(255,255,255,.07); transition:transform .12s var(--mh-ease),box-shadow .12s var(--mh-ease),border-color .12s var(--mh-ease); } .sme-mh-cal-cell.has-data{border-color:rgba(255,255,255,.16)} .sme-mh-cal-cell.is-empty{background:rgba(255,255,255,.028)!important;border-color:rgba(255,255,255,.05)} .sme-mh-cal-cell.sme-mh-cal-pad{background:transparent!important;border-color:transparent} .sme-mh-cal-cell span{font-size:10px;font-weight:700;line-height:1} .sme-mh-cal-cell:hover{ transform:scale(1.45);z-index:3; box-shadow:0 3px 12px rgba(0,0,0,.65);border-color:#fff; } .sme-mh-cal-legend{ display:inline-flex;align-items:center;gap:4px; font-size:11px;color:var(--mh-t3); } .sme-mh-cal-legend i{ width:14px;height:14px;border-radius:4px;display:inline-block; border:1px solid rgba(255,255,255,.16); } .sme-mh-cal-legend i.is-empty{border-color:rgba(255,255,255,.05)} /* ---------- 导出 ---------- */ .sme-mh-fmt{ display:flex;flex-direction:column;align-items:flex-start;gap:3px;text-align:left; padding:8px 14px;min-width:136px; background:var(--mh-bg-3);border:1px solid transparent;border-radius:var(--mh-r-s); color:var(--mh-t2);cursor:pointer;font-family:inherit; transition:all .16s var(--mh-ease); } .sme-mh-fmt:hover{background:#2c3745;color:var(--mh-t1)} .sme-mh-fmt.active{ background:rgba(74,222,128,.14);border-color:var(--mh-sell);color:var(--mh-t1); } .sme-mh-fmt-label{font-size:12px;font-weight:700;letter-spacing:.3px} .sme-mh-fmt-desc{font-size:10px;color:var(--mh-t3);white-space:normal;line-height:1.5} .sme-mh-fmt.active .sme-mh-fmt-desc{color:var(--mh-t2)} .sme-mh-export-body{display:flex;gap:12px;flex-wrap:wrap;align-items:stretch} .sme-mh-export-preview{ flex:1;min-width:300px; padding:14px 16px;background:var(--mh-bg-2); border:1px solid var(--mh-line);border-radius:var(--mh-r-m); } .sme-mh-export-side{ width:232px;flex-shrink:0;display:flex;flex-direction:column;gap:10px; padding:14px 16px;background:var(--mh-bg-2); border:1px solid var(--mh-line);border-radius:var(--mh-r-m); } .sme-mh-pre{ margin:0;padding:12px;border-radius:var(--mh-r-s); background:rgba(0,0,0,.42);border:1px solid var(--mh-line); font-family:Consolas,"SF Mono",Menlo,monospace;font-size:11px;line-height:1.8; color:var(--mh-t2);white-space:pre-wrap;word-break:break-all; max-height:300px;overflow:auto; } .sme-mh-btn-primary{ margin-top:auto;padding:10px 16px;border-radius:var(--mh-r-s); border:1px solid #3f9c5a;background:linear-gradient(180deg,#4ade80,#2f8f52);color:#06210f; font-size:13px;font-weight:700;cursor:pointer;font-family:inherit; transition:transform .15s var(--mh-ease),filter .15s var(--mh-ease); } .sme-mh-btn-primary:hover{filter:brightness(1.08)} .sme-mh-btn-primary:active{transform:scale(.97)} @media (prefers-reduced-motion:reduce){ .sme-mh-modal.visible,.sme-mh-win,.sme-mh-view.active, .sme-mh-panel-inner,.sme-mh-panel-inner::before{ animation:none; } .sme-mh-panel-inner{ border-color:rgba(186,230,255,.9); box-shadow:0 0 0 1px rgba(102,192,244,.4),0 10px 28px rgba(0,0,0,.48),0 0 26px rgba(102,192,244,.5); } } `; // ---------- 缓存(含断点续传元数据) ---------- function mhCacheKey() { return MH_CACHE_PREFIX + (getSteamId() || 'anon'); } /** 行去重键:eventId 优先,退化用 挂单ID+交易时间 */ function mhRowKey(r) { return r.eventId || ((r.listingId || '') + '|' + (r.tradeTs || 0) + '|' + (r.priceCents || 0)); } /** * 读缓存。 * - **未完成**的断点用 7 天 TTL:一次限流不该让断点过期,否则用户被迫从头再来 * - **已完成**的仍按 MH_CACHE_TTL(12h)过期:需要反映新增的交易 */ function mhLoadCache() { const key = mhCacheKey(); const c = cache.get(key, 7 * 24 * 60 * 60 * 1000); if (!c) return false; if (!Array.isArray(c.rows) || c.rows.length === 0) return false; if (c.complete && Date.now() - (c.at || 0) > MH_CACHE_TTL) return false; // v1 旧缓存没有进度元数据:数据仍可用,按「已抓到 rows.length 处」降级恢复 const legacy = c._v !== MH_CACHE_V; mhState.rows = c.rows; mhState.totalCount = c.total || c.rows.length; mhState.fetchedAt = c.at || 0; if (legacy) { mhState.nextOffset = c.rows.length; mhState.complete = !c.total || c.rows.length >= c.total; mhState.paused = false; mhState.nextAt = 0; if (legacy) W('MH cache is legacy format, resuming from row count'); } else { mhState.nextOffset = typeof c.nextOffset === 'number' ? c.nextOffset : c.rows.length; mhState.complete = !!c.complete; mhState.paused = !!c.paused; mhState.nextAt = c.nextAt || 0; // 冷却期已过则解除暂停标记 if (mhState.paused && Date.now() >= mhState.nextAt) { mhState.paused = false; mhState.nextAt = 0; } } // 兜底:任何情况下 nextOffset 都不能超过已有数据量 if (mhState.nextOffset < mhState.rows.length) mhState.nextOffset = mhState.rows.length; return true; } /** 落盘。rows/total/offset 三个参数让调用方显式决定快照内容。 */ function mhSaveCache(rows, total, nextOffset) { try { cache.set(mhCacheKey(), { _v: MH_CACHE_V, rows: rows || mhState.rows, total: total === undefined ? mhState.totalCount : total, at: mhState.fetchedAt || Date.now(), nextOffset: nextOffset === undefined ? mhState.nextOffset : nextOffset, complete: mhState.complete, paused: mhState.paused, nextAt: mhState.nextAt || 0, }); } catch (e) { /* 配额不足由 cache 内部处理并告警 */ } } // ---------- 行解析 ---------- // 表头列序(见 Steam 市场页):价格 → 交易日期 → 上架日期 → 对手方 → 名称 // 只承认两种真实交易:卖出 / 买入。 // Steam 的「我的市场历史记录」只收录已成交记录,「已上架」「已取消」是挂单的动作过程 // 而非交易,既不产生收支也无法按金额汇总(实测该页 listed/canceled 恒为 0), // 因此不再作为独立类型参与分类、筛选与统计;遇到时统一归入 other。 const MH_ACTION_RULES = [ [/已售出|已卖出|售出|\bsold\b/i, 'sold', '卖出'], [/已购买|已买入|购买|\bbought\b|\bpurchased\b/i, 'bought', '买入'], ]; function mhDetectAction(text) { const t = text || ''; for (const rule of MH_ACTION_RULES) { if (rule[0].test(t)) return { key: rule[1], label: rule[2] }; } return { key: 'other', label: '其他' }; } /** 从渲染 HTML 中取 1x 尺寸的经济品图片完整地址 */ function mhPickImage(imgEl) { if (!imgEl) return ''; const srcset = imgEl.getAttribute('srcset') || ''; const m = srcset.match(/^(\S+)\s+1x/); if (m) return m[1]; return imgEl.getAttribute('src') || ''; } function mhParseRow(row) { const idm = (row.id || '').match(/^history_row_(\d+)_(\d+)$/); const nameEl = row.querySelector('.market_listing_item_name'); const name = nameEl ? nameEl.textContent.trim() : ''; if (!name) return null; const gameEl = row.querySelector('.market_listing_game_name'); const priceCellEl = row.querySelector('.market_listing_their_price'); const priceEl = priceCellEl ? priceCellEl.querySelector('.market_listing_price') : null; const combinedEl = row.querySelector('.market_listing_listed_date_combined'); const whoEl = row.querySelector('.market_listing_whoactedwith_name_block'); const imgEl = row.querySelector('.market_listing_item_img'); // 两个 can_combine 日期格按表头顺序为:第一个=交易日期,第二个=上架日期 const dates = row.querySelectorAll('.market_listing_listed_date.can_combine'); const combinedText = combinedEl ? combinedEl.textContent.replace(/\s+/g, ' ').trim() : ''; const priceCellText = priceCellEl ? priceCellEl.textContent.replace(/\s+/g, ' ') : ''; const priceText = priceEl ? priceEl.textContent.replace(/\s+/g, ' ').trim() : ''; return { eventId: idm ? idm[1] : '', listingId: idm ? idm[2] : '', name: name, game: gameEl ? gameEl.textContent.trim() : '', action: mhDetectAction(combinedText).key, actionLabel: mhDetectAction(combinedText).label, priceText: priceText, priceCents: parsePriceToCents(priceText), pending: /待处理|pending/i.test(combinedText + ' ' + priceCellText), tradeDateText: dates.length > 0 ? dates[0].textContent.trim() : '', listedDateText: dates.length > 1 ? dates[1].textContent.trim() : '', counterpart: whoEl ? whoEl.textContent.replace(/\s+/g, ' ').trim() : '', image: mhPickImage(imgEl), appId: '', tradeTs: 0, listedTs: 0, }; } function mhParseRows(html) { if (!html) return []; let doc; try { doc = new DOMParser().parseFromString(html, 'text/html'); } catch (e) { return []; } const out = []; doc.querySelectorAll('.market_listing_row[id^="history_row_"]').forEach(row => { const r = mhParseRow(row); if (r) out.push(r); }); return out; } // ---------- 日期推断 ---------- const MH_MONTH_EN = { jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6, jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12, }; /** 解析 Steam 历史表格的日期文本(中文"10 月 18 日"/英文"Oct 18"/ISO) */ function mhParseDateText(text) { if (!text) return null; const s = String(text).trim(); let m; if ((m = s.match(/(\d{4})[-/](\d{1,2})[-/](\d{1,2})/))) { return { year: +m[1], month: +m[2], day: +m[3] }; } if ((m = s.match(/(\d{1,2})\s*月\s*(\d{1,2})\s*日?/))) { return { year: null, month: +m[1], day: +m[2] }; } if ((m = s.match(/([A-Za-z]{3,9})\.?\s+(\d{1,2})(?:,?\s*(\d{4}))?/))) { const key = m[1].slice(0, 3).toLowerCase(); const mo = MH_MONTH_EN[key]; if (mo) return { year: m[3] ? +m[3] : null, month: mo, day: +m[2] }; } if ((m = s.match(/^(\d{1,2})\/(\d{1,2})(?:\/(\d{2,4}))?$/))) { return { year: m[3] ? +m[3] : null, month: +m[1], day: +m[2] }; } return null; } /** 取当天正午的时间戳,规避时区把日期推到前一天 */ function mhToTs(year, month, day) { const t = new Date(year, month - 1, day, 12, 0, 0).getTime(); return isNaN(t) ? 0 : t; } /** * 为缺失年份的日期补全年份。 * 接口按时间倒序返回,因此"月份比上一条更大"即意味着跨年了。 * ref 是跨页游标,返回下一批需要接着用的参考年月—— * 若每页都重扫全量数组,5000+ 条会退化成 O(n²)。 */ function mhAssignTimestamps(rows, ref) { const now = new Date(); const cursor = ref || { month: now.getMonth() + 1, year: now.getFullYear() }; for (const r of rows) { const p = mhParseDateText(r.tradeDateText); if (p) { let y = p.year || cursor.year; if (!p.year && p.month > cursor.month) y = cursor.year - 1; r.tradeTs = mhToTs(y, p.month, p.day); cursor.month = p.month; cursor.year = y; } else { r.tradeTs = 0; } const lp = mhParseDateText(r.listedDateText); if (lp) { const baseYear = r.tradeTs ? new Date(r.tradeTs).getFullYear() : cursor.year; let lt = mhToTs(lp.year || baseYear, lp.month, lp.day); // 上架日期不可能晚于交易日期,越界则回退一年 if (r.tradeTs && lt > r.tradeTs) { lt = mhToTs((lp.year || baseYear) - 1, lp.month, lp.day); } r.listedTs = lt; } else { r.listedTs = 0; } } return cursor; } function mhFmtTs(ts) { if (!ts) return ''; const d = new Date(ts); const p = (n) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ` + `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`; } function mhMonthKey(ts) { if (!ts) return ''; const d = new Date(ts); return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0'); } // ---------- AppID 尽力推断 ---------- function mhNormalizeName(s) { return String(s || '').toLowerCase().replace(/[™®©]/g, '').replace(/\s+/g, ' ').trim(); } /** * 接口的 assets 字段按 appid → contextid → assetid 组织,内含 market_hash_name。 * 历史表格本身不带 appid,这里用物品名反查,查不到就留空(不影响导出)。 */ function mhBuildAppIdMap(assets) { const map = new Map(); if (!assets || typeof assets !== 'object') return map; const put = (k, v) => { if (k && !map.has(k)) map.set(k, v); }; for (const appid of Object.keys(assets)) { const ctxs = assets[appid]; if (!ctxs || typeof ctxs !== 'object') continue; for (const ctxid of Object.keys(ctxs)) { const items = ctxs[ctxid]; if (!items || typeof items !== 'object') continue; for (const assetid of Object.keys(items)) { const a = items[assetid]; if (!a) continue; put(a.market_hash_name, appid); put(a.name, appid); put(mhNormalizeName(a.market_hash_name), appid); put(mhNormalizeName(a.name), appid); } } } return map; } function mhApplyAppIds(rows, map) { if (!map || map.size === 0) return; for (const r of rows) { if (r.appId) continue; r.appId = map.get(r.name) || map.get(mhNormalizeName(r.name)) || ''; } } // ---------- 分页抓取 ---------- function mhPageSize() { return Math.min(MH_PAGE_MAX, Math.max(10, parseInt(settings.get('marketHistoryPageSize'), 10) || MH_PAGE_MAX)); } function mhPageDelay() { return Math.max(200, parseInt(settings.get('marketHistoryDelay'), 10) || 700); } /** 预估抓取耗时(秒),用于确认弹窗提示 */ function mhEstimateSeconds() { if (!mhState.totalCount) return 0; return Math.ceil(mhState.totalCount / mhPageSize()) * (mhPageDelay() / 1000); } /** * 请求单页。退避改为**指数 + 抖动**: * 原先固定 30–45s 随机,连续限流时会一直踩同一节奏,永远撞不出去。 * attempt 0/1/2 → 30s / 60s / 120s,各加 ±25% 抖动。 */ async function mhFetchPage(start, count, attempt) { attempt = attempt || 0; const url = `https://steamcommunity.com/market/myhistory/render/?query=&start=${start}&count=${count}`; try { const data = await smeRequest(url, { expect: 'json', timeout: 20000 }); if (!data || data.success === false) throw new Error('API success=false'); return data; } catch (err) { const code = err && err.code; const retryable = code === SME_ERR.RATE_LIMITED || code === SME_ERR.SERVER || code === SME_ERR.NETWORK || code === SME_ERR.TIMEOUT; if (retryable && attempt < 3) { const base = RETRY_BACKOFF_MIN * Math.pow(2, attempt); const jitter = Math.floor(base * 0.25 * (Math.random() * 2 - 1)); const wait = Math.max(1000, Math.min(MH_COOLDOWN_MAX, base + jitter)); mhSetStatus(`请求受限,${Math.round(wait / 1000)} 秒后重试(${attempt + 1}/3)…`); await sleep(wait); return mhFetchPage(start, count, attempt + 1); } throw err; } } /** * 抓取交易记录 —— 支持断点续传。 * * v1.6.8 之前有三个致命问题:局部 `const rows = []` 永远从 0 开始; * 刷新按钮无条件清空 mhState.rows;限流重试耗尽后 throw 掉全部进度元数据。 * 现在改为: * - 从 mhState.nextOffset 续传,复用已有 rows * - 限流不再抛错:保存进度 + 进入冷却,状态栏明示「已存 N 条,M 秒后可继续」 * - 按 eventId 去重(见下面 start 推进的注释) * - 每 2 页落盘一次,任何退出路径都会先落盘 */ async function mhFetchAll(opts) { opts = opts || {}; if (mhState.fetching) return; // 已抓完:除非显式要求重建,否则不重复请求 if (mhState.complete && !opts.force) { mhRefreshStatus(); toast(`已索引全部 ${mhState.rows.length} 条记录,无需继续`, 'info'); return; } // 限流冷却未到 if (mhState.paused && mhState.nextAt && Date.now() < mhState.nextAt && !opts.force) { const left = Math.ceil((mhState.nextAt - Date.now()) / 1000); mhSetStatus(`限流冷却中,${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')} 后可继续`, 'err'); toast(`Steam 仍在限流,${left} 秒后再试`, 'warning'); return; } mhState.fetching = true; mhState.abort = false; mhState.paused = false; mhState.nextAt = 0; const size = mhPageSize(); const delay = mhPageDelay(); const rows = mhState.rows.slice(); // ← 续传:复用已有数据 const seen = new Set(rows.map(mhRowKey)); const appIdMap = new Map(); let dateCursor = null; let total = mhState.totalCount || 0; let start = mhState.nextOffset || rows.length; let rateLimited = false; mhSetBusy(true); try { mhSetProgress(total ? rows.length / total : 0, true); if (rows.length) mhSetStatus(`从第 ${rows.length + 1} 条继续索引…`); while (true) { if (mhState.abort) break; if (total && start >= total) break; let data; try { data = await mhFetchPage(start, size); } catch (err) { if (err && err.code === SME_ERR.RATE_LIMITED) { // 限流:保存断点 + 进入冷却,**不抛错**,保住已抓数据 rateLimited = true; mhState.paused = true; const cool = Math.min(MH_COOLDOWN_MAX, MH_COOLDOWN_BASE * Math.pow(2, mhState.coolStreak || 0)); mhState.coolStreak = (mhState.coolStreak || 0) + 1; mhState.nextAt = Date.now() + cool; mhState.nextOffset = start; mhState.rows = rows; mhState.totalCount = total || mhState.totalCount; mhSaveCache(rows, total, start); const left = Math.round(cool / 1000); mhSetStatus(`触发 Steam 限流:已保存 ${rows.length} 条,${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')} 后可继续`, 'err'); toast(`触发限流,已保留 ${rows.length} 条;${left} 秒后点「继续」接着索引`, 'warning'); break; } throw err; } const batch = mhParseRows(data.results_html); const tc = parseInt(data.total_count, 10); if (!total && tc) total = tc; mhBuildAppIdMap(data.assets).forEach((v, k) => { if (!appIdMap.has(k)) appIdMap.set(k, v); }); if (batch.length === 0) { // 接口没有更多数据了:这就是可索引范围的终点。 // Steam 的 total_count 是**历史累计**总数,而该接口只开放有限窗口, // 二者不等;此处以实际抓到的量为准,否则 complete 永远为 false, // 「继续」按钮会陷入点了没进展的死循环。 total = rows.length; break; } dateCursor = mhAssignTimestamps(batch, dateCursor); mhApplyAppIds(batch, appIdMap); // 去重:索引期间 Steam 可能新增交易,倒序列表整体后移, // 同一 start 再取会与已有数据重叠。按 eventId 去重可同时消除 // 「重复 N 条」与「末尾漏 N 条」两个问题。 let added = 0; for (const r of batch) { const k = mhRowKey(r); if (seen.has(k)) continue; seen.add(k); rows.push(r); added++; } // 偏移按**接口返回条数**推进,不能按去重后的条数, // 否则重复一旦出现就会永久性偏移。 start += batch.length; mhState.rows = rows; mhState.totalCount = total || mhState.totalCount; mhState.nextOffset = start; mhState.fetchedAt = Date.now(); mhSetStatus(`已索引 ${rows.length} / ${total || '?'} 条…`); mhSetProgress(total ? rows.length / total : 0); if (total && rows.length >= total) break; if (batch.length < size) { // 末页不足一页 = 接口已到边界,同上以实抓量为准 total = rows.length; break; } // 每 2 页落盘一次(原先 5 页,限流时最多丢 400 条) if (Math.floor(start / size) % 2 === 0) mhSaveCache(rows, total, start); await sleep(delay); } mhState.nextOffset = start; mhState.rows = rows; mhState.totalCount = Math.max(total, rows.length); if (!rateLimited) { mhState.complete = rows.length >= mhState.totalCount; if (mhState.complete) { mhState.coolStreak = 0; mhSetProgress(1, false); } } mhApplyAppIds(rows, appIdMap); mhSaveCache(rows, mhState.totalCount, start); if (!rateLimited && !mhState.abort) { if (mhState.complete) { mhSetStatus(`索引完成,共 ${rows.length} 条记录`, 'ok'); toast(`已索引全部 ${rows.length} 条市场交易记录`, 'success'); } else { mhSetStatus(`已索引 ${rows.length} / ${mhState.totalCount} 条`, 'ok'); } } else if (mhState.abort) { mhSetStatus(`已停止,保留 ${rows.length} / ${mhState.totalCount} 条`, 'ok'); } } catch (err) { // 非限流错误:同样先落盘再上报,已抓数据不丢 W('Market history fetch failed:', err); mhState.nextOffset = start; mhState.rows = rows; mhState.totalCount = total || mhState.totalCount; mhSaveCache(rows, mhState.totalCount, start); mhSetProgress(total ? rows.length / total : 0, false); mhSetStatus(`抓取中断:${friendlyError(err)}(已保留 ${rows.length} 条)`, 'err'); toast('抓取交易记录失败:' + friendlyError(err), 'error'); } finally { mhState.fetching = false; mhSetBusy(false); mhRefreshStatus(); } } // ---------- 汇总 ---------- function mhSummarize(rows) { const s = { count: rows.length, sold: { count: 0, grossCents: 0, feeCents: 0, netCents: 0 }, bought: { count: 0, grossCents: 0 }, other: 0, firstTs: 0, lastTs: 0, byItem: [], byGame: [], byMonth: [], }; const items = new Map(), games = new Map(), months = new Map(); let first = Infinity, last = 0; for (const r of rows) { const ts = r.tradeTs || r.listedTs || 0; if (ts) { if (ts < first) first = ts; if (ts > last) last = ts; } let net = 0; if (r.action === 'sold') { s.sold.count++; s.sold.grossCents += r.priceCents; net = priceBeforeFees(r.priceCents); s.sold.netCents += net; s.sold.feeCents += (r.priceCents - net); } else if (r.action === 'bought') { s.bought.count++; s.bought.grossCents += r.priceCents; } else s.other++; let it = items.get(r.name); if (!it) { it = { name: r.name, game: r.game, count: 0, soldCount: 0, boughtCount: 0, soldCents: 0, boughtCents: 0, netCents: 0 }; items.set(r.name, it); } it.count++; if (r.action === 'sold') { it.soldCount++; it.soldCents += r.priceCents; it.netCents += net; } else if (r.action === 'bought') { it.boughtCount++; it.boughtCents += r.priceCents; } const gk = r.game || '未知游戏'; let g = games.get(gk); if (!g) { g = { game: gk, count: 0, soldCount: 0, boughtCount: 0, soldCents: 0, boughtCents: 0 }; games.set(gk, g); } g.count++; if (r.action === 'sold') { g.soldCount++; g.soldCents += r.priceCents; } else if (r.action === 'bought') { g.boughtCount++; g.boughtCents += r.priceCents; } const mk = mhMonthKey(ts); if (mk) { let mo = months.get(mk); if (!mo) { mo = { month: mk, count: 0, soldCount: 0, boughtCount: 0, soldCents: 0, boughtCents: 0, feeCents: 0, netCents: 0 }; months.set(mk, mo); } mo.count++; if (r.action === 'sold') { mo.soldCount++; mo.soldCents += r.priceCents; mo.feeCents += (r.priceCents - net); } else if (r.action === 'bought') { mo.boughtCount++; mo.boughtCents += r.priceCents; } } } s.firstTs = first === Infinity ? 0 : first; s.lastTs = last; s.grossCents = s.sold.grossCents - s.bought.grossCents; s.netCents = s.sold.netCents - s.bought.grossCents; const topN = Math.max(5, Math.min(100, parseInt(settings.get('marketHistoryTopN'), 10) || 20)); const amt = (x) => (x.soldCents + x.boughtCents); s.byItem = [...items.values()].sort((a, b) => amt(b) - amt(a) || b.count - a.count).slice(0, topN); s.byGame = [...games.values()].sort((a, b) => b.count - a.count); s.byMonth = [...months.values()].sort((a, b) => (a.month < b.month ? 1 : -1)).map(mhFinishMonth); mhDecorate(s, rows); return s; } // ---------- 衍生统计(v1.6.1) ---------- // 参考 Steam-License-Classifier / Steam-Spending-History-Classifier 的分析维度, // 在原有收支汇总之上补充趋势、分布、日历热力所需的派生数据。 function mhMedian(nums) { if (!nums.length) return 0; const a = [...nums].sort((x, y) => x - y); const m = a.length >> 1; return a.length % 2 ? a[m] : Math.round((a[m - 1] + a[m]) / 2); } /** 挂单 → 成交的持有时长(仅统计同时有上架与交易时间的记录) */ function mhHoldStats(rows) { const spans = []; for (const r of rows) { if (r.action !== 'sold') continue; if (!r.tradeTs || !r.listedTs || r.listedTs <= 0) continue; const d = r.tradeTs - r.listedTs; if (d >= 0) spans.push(d); } if (!spans.length) return { count: 0, avgMs: 0, medianMs: 0, minMs: 0, maxMs: 0 }; const total = spans.reduce((a, b) => a + b, 0); return { count: spans.length, avgMs: Math.round(total / spans.length), medianMs: mhMedian(spans), minMs: Math.min(...spans), maxMs: Math.max(...spans), }; } /** 人性化时长,如 "3 天 4 小时" */ function mhFmtDuration(ms) { if (!ms || ms < 0) return '-'; const min = Math.floor(ms / 60000); if (min < 60) return min + ' 分钟'; const h = Math.floor(min / 60); if (h < 24) return h + ' 小时'; const d = Math.floor(h / 24); if (d < 30) return d + ' 天' + (h % 24 ? ' ' + (h % 24) + ' 小时' : ''); const mo = Math.floor(d / 30); if (mo < 12) return mo + ' 个月'; return Math.floor(mo / 12) + ' 年'; } const MH_DAY_MS = 86400000; function mhDayKey(ts) { if (!ts) return ''; const d = new Date(ts); return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); } /** 每日聚合(日历热力图与明细筛选共用) */ function mhDaily(rows) { const map = new Map(); for (const r of rows) { const ts = r.tradeTs || r.listedTs || 0; if (!ts) continue; const key = mhDayKey(ts); let d = map.get(key); if (!d) { d = { day: key, count: 0, soldCount: 0, boughtCount: 0, soldCents: 0, boughtCents: 0, feeCents: 0 }; map.set(key, d); } d.count++; if (r.action === 'sold') { d.soldCount++; d.soldCents += r.priceCents; const net = priceBeforeFees(r.priceCents); d.feeCents += (r.priceCents - net); } else if (r.action === 'bought') { d.boughtCount++; d.boughtCents += r.priceCents; } } return [...map.values()].sort((a, b) => a.day.localeCompare(b.day)); } /** * 月度序列:趋势折线的数据源。 * gap 为 0 时只输出有数据的月份;否则补齐中间空月,折线才不会把 * 跨越半年的两笔交易连成一条误导性的直线。 */ /** 月度桶收尾:净额 = 卖出 - 估算手续费 - 买入 */ function mhFinishMonth(m) { m.netCents = m.soldCents - m.feeCents - m.boughtCents; return m; } function mhMonthlySeries(rows, gap) { const map = new Map(); for (const r of rows) { const ts = r.tradeTs || r.listedTs || 0; if (!ts) continue; const key = mhMonthKey(ts); let m = map.get(key); if (!m) { m = { month: key, count: 0, soldCount: 0, boughtCount: 0, soldCents: 0, boughtCents: 0, feeCents: 0, netCents: 0 }; map.set(key, m); } m.count++; if (r.action === 'sold') { m.soldCount++; m.soldCents += r.priceCents; m.feeCents += (r.priceCents - priceBeforeFees(r.priceCents)); } else if (r.action === 'bought') { m.boughtCount++; m.boughtCents += r.priceCents; } } let list = [...map.values()].sort((a, b) => a.month.localeCompare(b.month)); if (!gap || list.length < 2) { // 稀疏路径同样要给出净额,否则趋势/导出拿到的 byMonth 净额恒为 0 list.forEach(mhFinishMonth); return list; } // 补齐空月 const filled = []; const [y0, m0] = list[0].month.split('-').map(Number); const [y1, m1] = list[list.length - 1].month.split('-').map(Number); let y = y0, mo = m0; const blank = (key) => mhFinishMonth({ month: key, count: 0, soldCount: 0, boughtCount: 0, soldCents: 0, boughtCents: 0, feeCents: 0, netCents: 0, empty: true }); const lookup = new Map(list.map((x) => [x.month, x])); while (y < y1 || (y === y1 && mo <= m1)) { const key = y + '-' + String(mo).padStart(2, '0'); filled.push(lookup.get(key) || blank(key)); mo++; if (mo > 12) { mo = 1; y++; } } filled.forEach(mhFinishMonth); return filled; } /** * 价格区间分布(**对数分档**)。 * 市场成交价通常跨 3–4 个数量级(实测 ₹0.01 ~ ₹1826.81)。 * 等宽分档会把 99.9% 的样本挤进第一档(实测 5263/5266 落在 ₹0.01–₹228), * 直方图完全失真。这里按 10 的幂次分档,每档再按 √10 细分两档(≈半十进位)。 */ function mhPriceBuckets(rows) { const prices = rows.filter((r) => r.priceCents > 0).map((r) => r.priceCents); if (!prices.length) return []; const min = Math.min(...prices), max = Math.max(...prices); if (max === min) return [{ from: min, to: max, count: prices.length }]; const loE = Math.floor(Math.log10(Math.max(1, min))); const hiE = Math.ceil(Math.log10(max)); const SUB = Math.sqrt(10); // ≈3.16,半十进位 const edges = []; for (let e = loE; e <= hiE; e++) { const base = Math.pow(10, e); edges.push(Math.max(1, Math.round(base))); if (e < hiE) edges.push(Math.max(1, Math.round(base * SUB))); } edges.push(Math.round(max)); edges.sort((a, b) => a - b); // 去重后构造分档 const uniq = edges.filter((v, i, arr) => i === 0 || v > arr[i - 1]); const buckets = []; for (let i = 0; i < uniq.length - 1; i++) { buckets.push({ from: uniq[i], to: uniq[i + 1], count: 0 }); } for (const p of prices) { let idx = buckets.length - 1; for (let i = 0; i < buckets.length; i++) { if (p < buckets[i].to) { idx = i; break; } } buckets[idx].count++; } return buckets.filter((b, i) => b.count > 0 || i === 0 || i === buckets.length - 1); } /** 在基础汇总上补充均价/中位价/极值/持有时长/日活等派生指标 */ function mhDecorate(s, rows) { const soldPrices = rows.filter((r) => r.action === 'sold' && r.priceCents > 0).map((r) => r.priceCents); const boughtPrices = rows.filter((r) => r.action === 'bought' && r.priceCents > 0).map((r) => r.priceCents); s.avgSoldCents = soldPrices.length ? Math.round(s.sold.grossCents / soldPrices.length) : 0; s.avgBoughtCents = boughtPrices.length ? Math.round(s.bought.grossCents / boughtPrices.length) : 0; s.medianSoldCents = mhMedian(soldPrices); s.maxSoldCents = soldPrices.length ? Math.max(...soldPrices) : 0; s.minSoldCents = soldPrices.length ? Math.min(...soldPrices) : 0; s.hold = mhHoldStats(rows); s.daily = mhDaily(rows); s.priceBuckets = mhPriceBuckets(rows); // 活跃天数与日均 s.activeDays = s.daily.length; const spanDays = s.firstTs && s.lastTs ? Math.max(1, Math.round((s.lastTs - s.firstTs) / MH_DAY_MS) + 1) : 1; s.spanDays = spanDays; s.perDay = Math.round((s.count / spanDays) * 100) / 100; s.perMonth = Math.round((s.count / Math.max(1, spanDays / 30)) * 100) / 100; // 每物品均价补充到 byItem const priceByItem = new Map(); for (const r of rows) { if (r.action !== 'sold' || r.priceCents <= 0) continue; if (!priceByItem.has(r.name)) priceByItem.set(r.name, []); priceByItem.get(r.name).push(r.priceCents); } for (const it of s.byItem) { const ps = priceByItem.get(it.name) || []; it.avgSoldCents = ps.length ? Math.round(it.soldCents / ps.length) : 0; it.medianSoldCents = mhMedian(ps); } // ROI:卖出净收入 / 买入总额(无买入时为 null,避免除零与误导) s.roi = s.bought.grossCents > 0 ? Math.round((s.sold.netCents / s.bought.grossCents) * 10000) / 100 : null; return s; } // ---------- 导出 ---------- function mhCsvCell(v) { const s = v == null ? '' : String(v); return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; } function mhMarketUrl(r) { if (!r.appId) return ''; return `https://steamcommunity.com/market/listings/${r.appId}/${encodeURIComponent(r.name)}`; } function mhToCsv(rows, summary) { const wi = getWalletInfo(); const code = wi.currency_code || 'USD'; const lines = [[ '序号', '事件ID', '挂单ID', '物品名称', '游戏', 'AppID', '类型', '状态', '价格原文', '价格', '币种', '交易日期原文', '交易时间', '上架日期原文', '上架时间', '对手方', '市场链接', '图片链接', ].map(mhCsvCell).join(',')]; rows.forEach((r, i) => { lines.push([ i + 1, r.eventId, r.listingId, r.name, r.game, r.appId || '', r.actionLabel, r.pending ? '待处理' : '', r.priceText, (r.priceCents / 100).toFixed(2), code, r.tradeDateText, mhFmtTs(r.tradeTs), r.listedDateText, mhFmtTs(r.listedTs), r.counterpart || '', mhMarketUrl(r), r.image || '', ].map(mhCsvCell).join(',')); }); // 末尾附汇总区块,方便直接在表格软件里核对数字 const money = (c) => (c / 100).toFixed(2); lines.push(''); lines.push(['汇总(由 Steam 社区市场增强生成)'].map(mhCsvCell).join(',')); lines.push(['记录总数', rows.length].map(mhCsvCell).join(',')); lines.push(['卖出笔数', summary.sold.count].map(mhCsvCell).join(',')); lines.push(['卖出总额', money(summary.sold.grossCents), code].map(mhCsvCell).join(',')); lines.push(['估算手续费', money(summary.sold.feeCents), code].map(mhCsvCell).join(',')); lines.push(['卖出净收入', money(summary.sold.netCents), code].map(mhCsvCell).join(',')); lines.push(['买入笔数', summary.bought.count].map(mhCsvCell).join(',')); lines.push(['买入总额', money(summary.bought.grossCents), code].map(mhCsvCell).join(',')); lines.push(['净收益', money(summary.netCents), code].map(mhCsvCell).join(',')); // 前置 BOM,保证 Excel 正确识别 UTF-8 中文 return '\ufeff' + lines.join('\r\n'); } function mhToJson(rows, summary) { const wi = getWalletInfo(); const code = wi.currency_code || 'USD'; const money = (c) => Math.round(c) / 100; return JSON.stringify({ meta: { generator: 'Steam 社区市场增强 v' + VERSION, generatedAt: new Date().toISOString(), source: 'https://steamcommunity.com/market/myhistory/render/', steamId: getSteamId() || null, currency: code, walletCurrencyId: wi.wallet_currency, recordCount: rows.length, steamTotalCount: mhState.totalCount, truncated: rows.length < mhState.totalCount, indexedAt: mhState.fetchedAt ? new Date(mhState.fetchedAt).toISOString() : null, note: 'Steam 历史记录表格不返回年份,时间戳由倒序分页跨年推断得出。', }, summary: { total: summary.count, sold: { count: summary.sold.count, gross: money(summary.sold.grossCents), estimatedFee: money(summary.sold.feeCents), net: money(summary.sold.netCents), }, bought: { count: summary.bought.count, gross: money(summary.bought.grossCents) }, other: summary.other, grossProfit: money(summary.grossCents), netProfit: money(summary.netCents), period: { from: summary.firstTs ? new Date(summary.firstTs).toISOString() : null, to: summary.lastTs ? new Date(summary.lastTs).toISOString() : null, spanDays: summary.spanDays, activeDays: summary.activeDays, }, avgSold: money(summary.avgSoldCents), avgBought: money(summary.avgBoughtCents), medianSold: money(summary.medianSoldCents), maxSold: money(summary.maxSoldCents), minSold: money(summary.minSoldCents), perDay: summary.perDay, perMonth: summary.perMonth, roi: summary.roi, holdDuration: summary.hold ? { samples: summary.hold.count, avgMs: summary.hold.avgMs, medianMs: summary.hold.medianMs, minMs: summary.hold.minMs, maxMs: summary.hold.maxMs, } : null, }, byItem: summary.byItem.map(x => ({ name: x.name, game: x.game, count: x.count, soldCount: x.soldCount, boughtCount: x.boughtCount, soldTotal: money(x.soldCents), boughtTotal: money(x.boughtCents), net: money(x.netCents), avgSold: money(x.avgSoldCents), medianSold: money(x.medianSoldCents), })), byGame: summary.byGame.map(x => ({ game: x.game, count: x.count, soldCount: x.soldCount, boughtCount: x.boughtCount, soldTotal: money(x.soldCents), boughtTotal: money(x.boughtCents), })), byMonth: summary.byMonth.map(x => ({ month: x.month, count: x.count, soldCount: x.soldCount, boughtCount: x.boughtCount, soldTotal: money(x.soldCents), boughtTotal: money(x.boughtCents), fee: money(x.feeCents), net: money(x.netCents), })), byDay: summary.daily.map(x => ({ day: x.day, count: x.count, soldCount: x.soldCount, boughtCount: x.boughtCount, soldTotal: money(x.soldCents), boughtTotal: money(x.boughtCents), fee: money(x.feeCents), })), priceBuckets: summary.priceBuckets.map(b => ({ from: money(b.from), to: money(b.to), count: b.count, })), transactions: rows.map(r => ({ eventId: r.eventId, listingId: r.listingId, name: r.name, game: r.game, appId: r.appId || null, action: r.action, actionLabel: r.actionLabel, price: money(r.priceCents), priceText: r.priceText, pending: !!r.pending, tradeDateText: r.tradeDateText, tradeTime: mhFmtTs(r.tradeTs) || null, tradeTimestamp: r.tradeTs || null, listedDateText: r.listedDateText, listedTime: mhFmtTs(r.listedTs) || null, listedTimestamp: r.listedTs || null, counterpart: r.counterpart || null, marketUrl: mhMarketUrl(r) || null, image: r.image || null, })), }, null, 2); } function mhDownload(filename, content, mime) { const blob = new Blob([content], { type: mime + ';charset=utf-8' }); const url = URL.createObjectURL(blob); const a = h('a', { href: url, download: filename }); document.body.appendChild(a); a.click(); setTimeout(() => { a.remove(); URL.revokeObjectURL(url); }, 1500); } // ---------- 附加导出格式(v1.6.1) ---------- /** TSV:制表符分隔,Excel/Sheets 双击直开,字段内换行与制表符会被转义 */ function mhToTsv(rows) { const cell = (v) => { const s = v == null ? '' : String(v); return /[\t\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; }; const head = ['序号', '事件ID', '挂单ID', '物品名称', '游戏', 'AppID', '类型', '状态', '价格原文', '价格', '币种', '交易日期原文', '交易时间', '上架日期原文', '上架时间', '对手方', '市场链接', '图片链接']; const lines = [head.join('\t')]; rows.forEach((r, i) => { lines.push([ i + 1, r.eventId, r.listingId, r.name, r.game, r.appId || '', r.actionLabel, r.pending ? '待处理' : '', r.priceText, (r.priceCents / 100).toFixed(2), mhCurrencyCode(), r.tradeDateText, mhFmtTs(r.tradeTs), r.listedDateText, mhFmtTs(r.listedTs), r.counterpart || '', mhMarketUrl(r), r.image || '', ].map(cell).join('\t')); }); return '\ufeff' + lines.join('\r\n'); } /** Markdown 报告:统计卡 + 榜单 + 月度表 + 明细表,便于贴到笔记/issue */ function mhToMarkdown(rows, summary) { const code = mhCurrencyCode(); const money = (c) => formatCentsStr(c, code); const m = (c) => (Math.round(c) / 100).toFixed(2); const period = summary.firstTs ? `${mhFmtTs(summary.firstTs).slice(0, 10)} ~ ${mhFmtTs(summary.lastTs).slice(0, 10)}` : '未知'; const L = []; L.push(`# Steam 市场交易记录汇总`); L.push(''); L.push(`> 由 Steam 社区市场增强 v${VERSION} 生成 · ${new Date().toLocaleString('zh-CN', { hour12: false })}`); L.push(`> 统计区间:${period} · 币种:${code} · 记录:${rows.length} 条`); L.push(''); L.push('## 总体'); L.push(''); L.push('| 指标 | 数值 |'); L.push('| --- | --- |'); L.push(`| 卖出笔数 | ${summary.sold.count} |`); L.push(`| 卖出总额 | ${money(summary.sold.grossCents)} |`); L.push(`| 估算手续费 | ${money(summary.sold.feeCents)} |`); L.push(`| 卖出净收入 | ${money(summary.sold.netCents)} |`); L.push(`| 买入笔数 | ${summary.bought.count} |`); L.push(`| 买入总额 | ${money(summary.bought.grossCents)} |`); L.push(`| 净收益 | ${money(summary.netCents)} |`); L.push(`| 卖出均价 / 中位价 | ${money(summary.avgSoldCents)} / ${money(summary.medianSoldCents)} |`); L.push(`| 最高 / 最低成交 | ${money(summary.maxSoldCents)} / ${money(summary.minSoldCents)} |`); L.push(`| 平均持有时长 | ${mhFmtDuration(summary.hold.avgMs)}(中位 ${mhFmtDuration(summary.hold.medianMs)},样本 ${summary.hold.count}) |`); L.push(`| 活跃天数 / 日均笔数 | ${summary.activeDays} / ${summary.perDay} |`); if (summary.roi !== null) L.push(`| ROI(净收入 ÷ 买入额) | ${summary.roi}% |`); L.push(''); if (summary.byItem.length) { L.push(`## 按物品 Top ${summary.byItem.length}`); L.push(''); L.push('| # | 物品 | 游戏 | 笔数 | 卖出 | 买入 | 卖出金额 | 均价 |'); L.push('| --- | --- | --- | ---: | ---: | ---: | ---: | ---: |'); summary.byItem.forEach((it, i) => { L.push(`| ${i + 1} | ${mdCell(it.name)} | ${mdCell(it.game)} | ${it.count} | ${it.soldCount} | ${it.boughtCount} | ${it.soldCents ? money(it.soldCents) : ''} | ${it.avgSoldCents ? money(it.avgSoldCents) : ''} |`); }); L.push(''); } if (summary.byMonth.length) { L.push('## 按月份'); L.push(''); L.push('| 月份 | 笔数 | 卖出 | 买入 | 卖出金额 | 手续费 | 净额 |'); L.push('| --- | ---: | ---: | ---: | ---: | ---: | ---: |'); for (const x of summary.byMonth) { L.push(`| ${x.month} | ${x.count} | ${x.soldCount} | ${x.boughtCount} | ${m(x.soldCents)} | ${m(x.feeCents)} | ${m(x.netCents)} |`); } L.push(''); } L.push('## 明细'); L.push(''); // v1.6.6:按 500 行分段输出。5266 行挤在单个 里, // markdown-reader 会一次性生成 4 万多个 ` : ``; return ` ${head} `; }).join(''); return toolbar + `
,预览明显卡顿; // 部分渲染器还会截断超长表格。分段后每段独立成表,观感与性能都更好。 const CHUNK = 500; const headRow = '| # | 物品 | 游戏 | 类型 | 价格 | 交易时间 | 上架时间 |'; const sepRow = '| --- | --- | --- | --- | ---: | --- | --- |'; const totalChunks = Math.max(1, Math.ceil(rows.length / CHUNK)); for (let c = 0; c < rows.length; c += CHUNK) { const part = rows.slice(c, c + CHUNK); if (totalChunks > 1) L.push(`### 第 ${Math.floor(c / CHUNK) + 1} / ${totalChunks} 段(第 ${c + 1} - ${c + part.length} 条)`); L.push(''); L.push(headRow); L.push(sepRow); part.forEach((r, k) => { const i = c + k; L.push(`| ${i + 1} | ${mdCell(r.name)} | ${mdCell(r.game)} | ${r.actionLabel} | ${r.priceCents ? m(r.priceCents) : ''} | ${mhFmtTs(r.tradeTs) || '-'} | ${mhFmtTs(r.listedTs) || '-'} |`); }); L.push(''); } L.push('---'); L.push(''); L.push(`> 手续费按当前钱包费率估算,非 Steam 结算值。年份由倒序分页跨年推断得出,AppID 尽力匹配、可能为空。`); return L.join('\n'); } /** * Markdown 单元格转义 —— 标准 GFM 反斜杠转义。 * * 为什么用标准 `\|` 而不是实体 `|`: * 历史上为了迁就 scripts-tools/markdown-reader 不认反斜杠的缺陷而改用实体, * 代价是源码视图里全是 `|` 这类噪声。v1.6.7 起 reader 已支持标准转义, * 故回归 GFM 标准写法 —— 通用 Markdown 工具链(GitHub / VS Code / Obsidian / * marked.js 等)都认它,源码也更好读。 * * 两条兼容保证: * - 新导出(本函数,标准转义)→ 新 reader ✓ * - v1.6.6 之前的旧导出(实体 |)→ 新 reader ✓(escapeHtml 保留已有实体) * * 字符分工: * & < > → HTML 实体(HTML 上下文,任何渲染器下都安全) * 其余语法符 → 反斜杠转义(Markdown 标准) * * 单次扫描完成映射,避免 & 先被转成 & 后自己插入的实体又被二次编码。 */ const MD_CELL_ENTITIES = { '&': '&', '<': '<', '>': '>', }; const MD_CELL_ESCAPES = { '\\': '\\\\', '|': '\\|', '`': '\\`', '*': '\\*', '_': '\\_', '~': '\\~', '[': '\\[', ']': '\\]', }; const MD_CELL_RE = /[&<>\\|`*_~[\]]/g; function mdCell(s) { const str = String(s == null ? '' : s); if (!str) return ''; return str .replace(/\r\n?|\n/g, ' ') // 换行会截断表格行 .replace(MD_CELL_RE, (c) => MD_CELL_ENTITIES[c] !== undefined ? MD_CELL_ENTITIES[c] : MD_CELL_ESCAPES[c]); } function mhCurrencyCode() { return getWalletInfo().currency_code || 'USD'; } /** * 执行导出。 * @param {object} opt * format: 'csv' | 'tsv' | 'json' | 'md' * rows: 导出的记录集,缺省为全部已索引记录 * label: 文件名后缀标注(如"筛选结果") */ function mhRunExport(opt) { opt = opt || {}; const rows = opt.rows && opt.rows.length ? opt.rows : mhState.rows; if (rows.length === 0) { toast('没有可导出的记录', 'warning'); return false; } const format = opt.format || mhState.format || 'csv'; const summary = mhSummarize(rows); const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-'); const suffix = opt.label ? '-' + opt.label : ''; const base = `steam-market-history${suffix}-${stamp}`; const ext = { csv: 'csv', tsv: 'tsv', json: 'json', md: 'md' }[format] || 'csv'; const mime = { csv: 'text/csv', tsv: 'text/tab-separated-values', json: 'application/json', md: 'text/markdown', }[format] || 'text/csv'; const content = { csv: () => mhToCsv(rows, summary), tsv: () => mhToTsv(rows), json: () => mhToJson(rows, summary), md: () => mhToMarkdown(rows, summary), }[format](); mhDownload(`${base}.${ext}`, content, mime); toast(`已导出 ${rows.length} 条记录(${format.toUpperCase()})`, 'success'); return true; } /** 确保已索引数据:必要时先征得用户同意再抓取 */ async function mhEnsureData(actionName) { if (mhState.fetching) { toast('正在抓取中,请稍候…', 'info'); return false; } if (mhState.rows.length > 0) return true; const total = mhState.totalCount; const est = mhEstimateSeconds(); const detail = total ? `共 ${total} 条记录,每页 ${mhPageSize()} 条逐页抓取,预计约 ${est} 秒。` : '记录较多时可能需要数分钟。抓取期间请勿切换页面。'; const ok = await showConfirmDialog( `${actionName}前需要先向 Steam 抓取交易记录。

` + `${detail}
` + `抓取结果会缓存在本地 12 小时内,导出与汇总都基于该缓存。`, { title: '抓取市场交易记录', okText: '开始抓取', okClass: 'sme-dialog-blue' } ); if (!ok) return false; await mhFetchAll(); return mhState.rows.length > 0; } // ---------- 分析窗口(v1.6.1) ---------- // 结构参考 Steam-License-Classifier 的 ensureAnalysisShell / activateAnalysisPanel // (顶部标签栏 + 记住上次标签 + 标题栏主题色跟随)与 Steam-Spending-History-Classifier // 的 renderModal 通用面板渲染器;图表部分参考其 buildGrid / 折线 / 环形 / 日历热力。 // v1.6.5:榜单从概览页拆出为独立「排行」页。 // 概览页此前把 13+ 张卡 + 两张图表 + 一张可内部滚动的 Top20 表格塞在一起, // 外层 .sme-mh-panels 与内层 .sme-mh-table-wrap 同时可滚,出现双滚动条且页面极长。 const MH_TABS = [ { prefix: 'overview', icon: 'chart', label: '概览', title: '概览', color: '#66c0f4' }, { prefix: 'trend', icon: 'trendingUp', label: '趋势', title: '交易曲线趋势', color: '#4caf50' }, { prefix: 'rank', icon: 'queue', label: '排行', title: '成交排行 Top 20', color: '#38bdf8' }, { prefix: 'calendar', icon: 'calendar', label: '日历', title: '交易日历热力', color: '#8b5cf6' }, { prefix: 'ledger', icon: 'list', label: '明细', title: '交易明细', color: '#3b82f6' }, { prefix: 'export', icon: 'download', label: '导出', title: '导出数据', color: '#10b981' }, ]; const MH_LEDGER_PAGE = 50; const mhUi = { lastTab: 'overview', trendMetric: 'amount', // amount | count trendCumulative: false, // v1.6.5:排行页的分组维度与排序依据 rankDim: 'item', // item | game | month rankSort: 'amount', // amount | count // v1.6.3:默认全选。金额与笔数分列左右双轴,全选也能读 trendSeries: { soldCents: true, boughtCents: true, netCents: true, feeCents: true, count: true }, ledgerType: 'all', ledgerDim: 'all', // all | game | item | month(维度筛选,替代下拉框) ledgerValue: '', ledgerQuery: '', ledgerSort: 'tradeTs', ledgerDesc: true, ledgerPage: 1, exportFormat: null, // null = 跟随工具条选择 }; let _mhShell = null; const _mhMeta = {}; function mhTabIcon(name) { return ICONS[name] || ICONS.chart; } function ensureMhShell() { if (_mhShell) return _mhShell; const modal = document.createElement('div'); modal.id = 'sme-mh-modal'; modal.className = 'sme-mh-modal'; const tabsHtml = MH_TABS.map((t) => ``).join(''); modal.innerHTML = ` `; document.body.appendChild(modal); const hide = () => modal.classList.remove('visible'); modal.addEventListener('click', (e) => { if (e.target === modal) hide(); }); modal.querySelector('#sme-mh-close').addEventListener('click', hide); // 阻止滚轮穿透到页面 modal.querySelector('.sme-mh-win').addEventListener('wheel', (e) => e.stopPropagation(), { passive: true }); modal.querySelectorAll('.sme-mh-tab').forEach((btn) => { btn.addEventListener('click', (e) => { e.stopPropagation(); mhShowPanel(btn.dataset.tab); }); }); const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); hide(); } else if (e.key === 'Tab') { const items = [...modal.querySelectorAll('button')].filter((b) => b.offsetParent !== null); if (!items.length) return; const idx = items.indexOf(document.activeElement); const next = e.shiftKey ? items[(idx <= 0 ? items.length : idx) - 1] : items[(idx + 1) % items.length]; e.preventDefault(); if (next) next.focus(); } }; document.addEventListener('keydown', onKey, true); modal._dispose = () => document.removeEventListener('keydown', onKey, true); _mhShell = { modal, panelsEl: modal.querySelector('#sme-mh-panels'), iconEl: modal.querySelector('#sme-mh-hi'), titleEl: modal.querySelector('#sme-mh-ht'), noteEl: modal.querySelector('#sme-mh-note'), headerEl: modal.querySelector('#sme-mh-header'), }; return _mhShell; } function activateMhPanel(prefix) { const shell = ensureMhShell(); const tab = MH_TABS.find((t) => t.prefix === prefix) || MH_TABS[0]; const meta = _mhMeta[prefix] || {}; shell.modal.classList.add('visible'); shell.panelsEl.querySelectorAll('.sme-mh-view').forEach((p) => p.classList.toggle('active', p.dataset.panel === prefix)); shell.modal.querySelectorAll('.sme-mh-tab').forEach((b) => b.classList.toggle('active', b.dataset.tab === prefix)); shell.headerEl.style.background = `linear-gradient(135deg,${tab.color}26,rgba(13,15,21,0.9))`; shell.headerEl.style.borderBottomColor = `${tab.color}40`; shell.iconEl.style.color = tab.color; shell.iconEl.innerHTML = mhTabIcon(tab.icon); shell.titleEl.textContent = meta.title || tab.title; shell.noteEl.textContent = meta.note || ''; mhUi.lastTab = prefix; } /** 通用面板渲染器:建/复用面板 → 写 HTML → postRender → 显示并激活 */ function mhRenderPane(prefix, note, buildFn, postRender) { const shell = ensureMhShell(); const tab = MH_TABS.find((t) => t.prefix === prefix) || MH_TABS[0]; _mhMeta[prefix] = { title: tab.title, note: note }; let panel = shell.panelsEl.querySelector(`.sme-mh-view[data-panel="${prefix}"]`); if (!panel) { panel = document.createElement('div'); panel.className = 'sme-mh-view'; panel.dataset.panel = prefix; const body = document.createElement('div'); body.className = 'sme-mh-pbody'; body.id = `sme-mh-${prefix}-body`; panel.appendChild(body); shell.panelsEl.appendChild(panel); } const bodyEl = panel.querySelector('.sme-mh-pbody'); // 面板重建前清理上一轮注册的事件 if (bodyEl._cleanup) { try { bodyEl._cleanup(); } catch (e) {} bodyEl._cleanup = null; } let html; try { html = buildFn(); } catch (err) { W('MH pane render failed:', prefix, err); html = `
面板渲染失败:${escapeHtml(err.message)}
`; } bodyEl.innerHTML = html; if (postRender) postRender(bodyEl); activateMhPanel(prefix); return bodyEl; } function mhShowPanel(prefix) { if (mhState.rows.length === 0) return; const s = mhSummarize(mhState.rows); const code = mhCurrencyCode(); const wi = getWalletInfo(); const money = (c) => formatCentsStr(c, code); const truncated = mhState.totalCount > s.count ? ` Steam 报告共 ${mhState.totalCount} 条,当前已索引 ${s.count} 条。` : ''; const baseNote = '手续费按当前钱包费率(Steam ' + Math.round(wi.wallet_fee_percent * 100) + '% + 发行商 ' + Math.round(wi.wallet_publisher_fee_percent_default * 100) + '%)估算,非 Steam 结算值;' + '年份由倒序分页跨年推断得出;AppID 为尽力匹配,可能为空。' + truncated; switch (prefix) { case 'overview': return mhPaneOverview(s, money, baseNote); case 'rank': return mhPaneRank(s, money, baseNote); case 'trend': return mhPaneTrend(s, money, baseNote); case 'calendar': return mhPaneCalendar(s, money, baseNote); case 'ledger': return mhPaneLedger(s, money, baseNote); case 'export': return mhPaneExport(s, money, baseNote); default: return mhPaneOverview(s, money, baseNote); } } function openMhAnalysis() { mhShowPanel(mhUi.lastTab || 'overview'); } // ---------- 通用图表零件 ---------- function mhAxisNum(cents, metric) { if (metric === 'count') return String(Math.round(cents)); const code = mhCurrencyCode(); if (CURRENCY_CODES_TO_ROUND.includes(code)) return String(Math.round(cents / 100)); const n = cents / 100; if (n >= 10000) return (n / 1000).toFixed(0) + 'k'; if (n >= 1000) return (n / 1000).toFixed(1) + 'k'; if (n >= 10) return n.toFixed(0); return n.toFixed(1); } /** 横向网格 + Y 轴刻度(参考 buildGrid) */ function mhGrid(min, max, pL, pR, pT, cH, metric) { let g = ''; for (let i = 0; i <= 5; i++) { const v = min + (max - min) * (i / 5); const y = pT + cH - (cH * i / 5); g += `` + `${mhAxisNum(v, metric)}`; } return g; } function mhStatCard(label, value, cls, icon, hint) { return `
` + (icon ? `${icon}` : '') + `
${escapeHtml(label)}
` + `
${escapeHtml(value)}
` + (hint ? `
${escapeHtml(hint)}
` : '') + '
'; } function mhPaneOverview(s, money, note) { const code = mhCurrencyCode(); const period = s.firstTs ? `${mhFmtTs(s.firstTs).slice(0, 10)} ~ ${mhFmtTs(s.lastTs).slice(0, 10)}` : '未知'; const hold = s.hold; const cards = [ mhStatCard('记录总数', String(s.count), '', ICONS.layers), mhStatCard('统计区间', period, '', ICONS.clock, `${s.spanDays} 天 · 活跃 ${s.activeDays} 天`), mhStatCard('卖出笔数', String(s.sold.count), '', ICONS.trendingUp), mhStatCard('卖出总额', money(s.sold.grossCents), '', ICONS.sell), mhStatCard('估算手续费', money(s.sold.feeCents), '', ICONS.dollarSign), mhStatCard('卖出净收入', money(s.sold.netCents), 'sme-up', ICONS.check), mhStatCard('买入笔数', String(s.bought.count), '', ICONS.trendingDown), mhStatCard('买入总额', money(s.bought.grossCents), '', ICONS.bag), mhStatCard('净收益', money(s.netCents), s.netCents >= 0 ? 'sme-up' : 'sme-down', ICONS.chart), mhStatCard('卖出均价', money(s.avgSoldCents), '', ICONS.tag, `中位 ${money(s.medianSoldCents)}`), mhStatCard('成交极值', `${money(s.minSoldCents)} ~ ${money(s.maxSoldCents)}`, '', ICONS.gem), mhStatCard('平均持有时长', mhFmtDuration(hold.avgMs), '', ICONS.clock, hold.count ? `中位 ${mhFmtDuration(hold.medianMs)} · 样本 ${hold.count}` : '缺少可对比的上下架时间'), mhStatCard('日均 / 月均笔数', `${s.perDay} / ${s.perMonth}`, '', ICONS.chart), ]; if (s.roi !== null) cards.push(mhStatCard('ROI(净收入 ÷ 买入额)', s.roi + '%', s.roi >= 0 ? 'sme-up' : 'sme-down', ICONS.trendingUp)); // v1.6.5:收支对比改为「双占比环 + 关键数字」紧凑布局。 // 原先只是一根细进度条,卡片被撑到 250px 高却只有 40px 内容,右侧价格分布 // 被挤成窄条。现在两侧卡片高度对齐、内容填满。 const inCents = s.sold.netCents, outCents = s.bought.grossCents; const sum = Math.max(1, inCents + outCents); const inPct = (inCents / sum * 100); const donut = (() => { const R = 52, C = 2 * Math.PI * R; const inLen = C * (inPct / 100); return ` ${inPct.toFixed(1)}% 卖出净收入占比 `; })(); const flowRows = [ { label: '卖出总额', v: s.sold.grossCents, c: '#4ade80', sub: `${s.sold.count} 笔` }, { label: '减 估算手续费', v: -s.sold.feeCents, c: '#fbbf24', sub: `${Math.round(s.sold.feeCents / Math.max(1, s.sold.grossCents) * 1000) / 10}%` }, { label: '卖出净收入', v: s.sold.netCents, c: '#4ade80', sub: '', strong: true }, { label: '买入总额', v: s.bought.grossCents, c: '#f87171', sub: `${s.bought.count} 笔` }, { label: '净收益', v: s.netCents, c: s.netCents >= 0 ? '#66c0f4' : '#f87171', sub: '', strong: true }, ]; const bar = `
${donut}
${flowRows.map((r) => `
${escapeHtml(r.label)} ${r.sub ? `${escapeHtml(r.sub)}` : ''} ${r.v < 0 ? '−' : ''}${escapeHtml(money(Math.abs(r.v)))}
`).join('')}
`; // 价格分布:对数分档。价格通常跨 3–4 个数量级(₹0.01 ~ ₹1826), // 等宽分档会把 99% 的样本挤进第一档(实测 5263/5266),直方图完全失真。 let hist = '
暂无价格数据
'; if (s.priceBuckets.length) { const maxC = Math.max(...s.priceBuckets.map((b) => b.count), 1); const totalC = s.priceBuckets.reduce((a, b) => a + b.count, 0); const fmtV = (c) => { const n = c / 100; if (n >= 1000) return (n / 1000).toFixed(n >= 10000 ? 0 : 1) + 'k'; return n >= 100 ? n.toFixed(0) : n.toFixed(n >= 1 ? 2 : 3); }; hist = '
' + s.priceBuckets.map((b) => { const pct = (b.count / maxC) * 100; const share = totalC ? (b.count / totalC * 100) : 0; const range = fmtV(b.from) === fmtV(b.to) ? fmtV(b.from) : `${fmtV(b.from)}~${fmtV(b.to)}`; return `
` + `
` + `
${b.count}
` + `
${escapeHtml(fmtV(b.from))}
`; }).join('') + '
' + `
成交价分布 · ${escapeHtml(code)} · 对数分档 ${s.priceBuckets.length} 档` + ` · 横轴为该档起始价
`; } return mhRenderPane('overview', note, () => `
${cards.join('')}
` + `
` + `
收支对比
${bar}
` + `
价格分布
${hist}
` + `
` ); } // ---------- 面板:排行(v1.6.5 从概览拆出) ---------- const MH_RANK_DIMS = [ { id: 'item', label: '按物品', unit: '件' }, { id: 'game', label: '按游戏', unit: '个' }, { id: 'month', label: '按月份', unit: '个月' }, ]; const MH_RANK_SORTS = [ { id: 'amount', label: '按金额' }, { id: 'count', label: '按笔数' }, ]; const MH_RANK_TOP = 20; /** 汇总三个维度的排行数据(不受设置的 TopN 截断影响,这里要完整 Top20) */ function mhRankGroups(dim) { const map = new Map(); const keyOf = (r) => { if (dim === 'game') return r.game || '未知游戏'; if (dim === 'month') return r.tradeTs ? mhMonthKey(r.tradeTs) : ''; return r.name || ''; }; const extraOf = (r, g) => { if (dim === 'item') g.game = r.game || ''; }; for (const r of mhState.rows) { const k = keyOf(r); if (!k) continue; let g = map.get(k); if (!g) { g = { key: k, game: '', count: 0, soldCount: 0, boughtCount: 0, soldCents: 0, boughtCents: 0, firstTs: 0, lastTs: 0 }; map.set(k, g); } extraOf(r, g); g.count++; if (r.tradeTs) { if (!g.firstTs || r.tradeTs < g.firstTs) g.firstTs = r.tradeTs; if (r.tradeTs > g.lastTs) g.lastTs = r.tradeTs; } if (r.action === 'sold') { g.soldCount++; g.soldCents += r.priceCents; } else if (r.action === 'bought') { g.boughtCount++; g.boughtCents += r.priceCents; } } for (const g of map.values()) { g.totalCents = g.soldCents + g.boughtCents; g.avgCents = g.soldCount ? Math.round(g.soldCents / g.soldCount) : 0; } return [...map.values()]; } function mhPaneRank(s, money, note) { const dim = MH_RANK_DIMS.some((d) => d.id === mhUi.rankDim) ? mhUi.rankDim : 'item'; const sortBy = mhUi.rankSort === 'count' ? 'count' : 'amount'; const all = mhRankGroups(dim); const metric = (g) => (sortBy === 'count' ? g.count : g.totalCents); const list = all.slice().sort((a, b) => metric(b) - metric(a) || b.count - a.count || a.key.localeCompare(b.key)); const shown = list.slice(0, MH_RANK_TOP); const peak = shown.length ? Math.max(...shown.map((g) => metric(g)), 1) : 1; const restCount = Math.max(0, list.length - shown.length); const restMetric = all.slice(MH_RANK_TOP).reduce((a, g) => a + metric(g), 0); const dimDef = MH_RANK_DIMS.find((d) => d.id === dim); return mhRenderPane('rank', note, () => { const dimBtn = (d) => ``; const sortBtn = (x) => ``; const toolbar = `
分组${MH_RANK_DIMS.map(dimBtn).join('')} 排序${MH_RANK_SORTS.map(sortBtn).join('')} 共 ${all.length} ${escapeHtml(dimDef.unit)},显示 Top ${shown.length}${restCount ? `(其余 ${restCount} ${escapeHtml(dimDef.unit)}合计 ${escapeHtml(sortBy === 'count' ? String(restMetric) : money(restMetric))})` : ''}
`; if (!shown.length) return toolbar + '
暂无符合条件的成交记录
'; // 占比条:直观看出每个条目在当前排序依据下的分量 const rows = shown.map((g, i) => { const m = metric(g); const w = Math.max(1.5, (m / peak) * 100); const head = dim === 'item' ? `
${escapeHtml(g.key)}${escapeHtml(g.game || '')}${escapeHtml(g.key)}
${i + 1} ${g.count} ${g.soldCount} ${g.boughtCount} ${g.soldCents ? escapeHtml(money(g.soldCents)) : '—'} ${g.boughtCents ? escapeHtml(money(g.boughtCents)) : '—'} ${g.avgCents ? escapeHtml(money(g.avgCents)) : '—'} ${g.firstTs ? escapeHtml(mhFmtTs(g.firstTs).slice(0, 10)) : '—'}
${rows}
#${escapeHtml(dimDef.label.replace('按', ''))} 占比 笔数卖出买入卖出金额买入金额卖出均价首次成交
`; }, (bodyEl) => { const bar = bodyEl.querySelector('.sme-mh-toolbar'); const onClick = (e) => { const chip = e.target.closest('button.sme-mh-chip'); if (!chip) return; if (chip.dataset.rdim) mhUi.rankDim = chip.dataset.rdim; else if (chip.dataset.rsort) mhUi.rankSort = chip.dataset.rsort; else return; mhShowPanel('rank'); }; bar.addEventListener('click', onClick); bodyEl._cleanup = () => bar.removeEventListener('click', onClick); }); } // ---------- 面板 2:趋势(曲线) ---------- // v1.6.3:金额类与笔数量级相差 4 个数量级,原先共用一个 Y 轴会把笔数压成直线。 // 改为**双 Y 轴**:左轴金额、右轴笔数,各自独立缩放,默认全选也能读。 const MH_TREND_SERIES = [ { key: 'soldCents', label: '卖出金额', color: '#4ade80', axis: 'money' }, { key: 'boughtCents', label: '买入金额', color: '#f87171', axis: 'money' }, { key: 'netCents', label: '净额', color: '#66c0f4', axis: 'money' }, { key: 'feeCents', label: '手续费', color: '#fbbf24', axis: 'money' }, { key: 'count', label: '成交笔数', color: '#a78bfa', axis: 'count' }, ]; /** 分位数裁剪的 Y 轴域:避免个别尖峰把其余数据压成一条线 */ function mhRobustDomain(values, includeZero) { const v = values.filter((x) => Number.isFinite(x)).sort((a, b) => a - b); if (!v.length) return { lo: 0, hi: 1, clipped: 0 }; const at = (q) => v[Math.min(v.length - 1, Math.max(0, Math.round((v.length - 1) * q)))]; let lo = at(0.02), hi = at(0.98); if (includeZero) lo = Math.min(lo, 0); if (hi === lo) hi = lo + Math.max(1, Math.abs(lo) * 0.1); const pad = (hi - lo) * 0.1; const dom = { lo: lo - pad, hi: hi + pad, clipped: 0 }; for (const x of v) if (x < dom.lo || x > dom.hi) dom.clipped++; return dom; } function mhPaneTrend(s, money, note) { const metric = mhUi.trendMetric; const series = mhMonthlySeries(mhState.rows, true); const vis = MH_TREND_SERIES.filter((x) => mhUi.trendSeries[x.key]); const showMoney = vis.some((d) => d.axis === 'money'); const showCount = vis.some((d) => d.axis === 'count'); const build = () => { if (!series.length) return '
暂无可推断日期的记录
'; if (!vis.length) return '
请至少勾选一条曲线
'; // 金额统一换算为货币单位,笔数保持原值 let data = series.map((x) => ({ month: x.month, empty: !!x.empty, money: { soldCents: (x.soldCents || 0) / 100, boughtCents: (x.boughtCents || 0) / 100, netCents: (x.netCents || 0) / 100, feeCents: (x.feeCents || 0) / 100, }, count: x.count || 0, })); if (mhUi.trendCumulative) { const cm = {}, cc = { count: 0 }; data = data.map((o) => { for (const k of ['soldCents', 'boughtCents', 'netCents', 'feeCents']) cm[k] = (cm[k] || 0) + o.money[k]; cc.count += o.count; return { month: o.month, empty: o.empty, money: Object.assign({}, cm), count: cc.count }; }); } const moneyVals = [], countVals = []; for (const o of data) { for (const d of vis) { if (d.axis === 'money') moneyVals.push(o.money[d.key]); else countVals.push(o.count); } } const mDom = showMoney ? mhRobustDomain(moneyVals, true) : null; const cDom = showCount ? mhRobustDomain(countVals, false) : null; const svgW = 1080, svgH = 480, pT = 52, pB = 64; const pL = 78, pR = showCount ? 74 : 28; const cW = svgW - pL - pR, cH = svgH - pT - pB; const step = data.length > 1 ? cW / (data.length - 1) : 0; const xOf = (i) => pL + i * step; const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); const yMoney = (v) => pT + cH - ((clamp(v, mDom.lo, mDom.hi) - mDom.lo) / (mDom.hi - mDom.lo)) * cH; const yCount = (v) => pT + cH - ((clamp(v, cDom.lo, cDom.hi) - cDom.lo) / (cDom.hi - cDom.lo)) * cH; const yOf = (d, o) => (d.axis === 'money' ? yMoney(o.money[d.key]) : yCount(o.count)); // 网格 + 左侧金额刻度 let grid = ''; for (let i = 0; i <= 5; i++) { const y = pT + cH - (cH * i / 5); grid += ``; if (showMoney) { const v = mDom.lo + (mDom.hi - mDom.lo) * (i / 5); grid += `${mhAxisNum(Math.round(v * 100), 'amount')}`; } } // 右侧笔数刻度 if (showCount) { for (let i = 0; i <= 5; i++) { const y = pT + cH - (cH * i / 5); const v = cDom.lo + (cDom.hi - cDom.lo) * (i / 5); grid += `${mhAxisNum(Math.round(v), 'count')}`; } } // 轴标题与时间段同一行:左右是轴名,中间是区间,都落在绘图区上方 const activeMonths = series.filter((x) => !x.empty).length; const rangeLabel = `${series[0].month} – ${series[series.length - 1].month} · ${activeMonths}/${series.length} 月`; const midX = (pL + svgW - pR) / 2; if (showMoney) grid += `金额 ${escapeHtml(mhCurrencyCode())}`; if (showCount) grid += `笔数`; grid += `${escapeHtml(rangeLabel)}`; // 零基线(仅当两轴都跨越 0 时画在金额轴上) let zeroLine = ''; if (showMoney && mDom.lo < 0 && mDom.hi > 0) { const zy = yMoney(0).toFixed(1); zeroLine = ``; } const clipId = 'sme-mh-clip'; const lines = vis.map((d) => { const pts = data.map((o, i) => `${xOf(i).toFixed(1)},${yOf(d, o).toFixed(1)}`).join(' '); const baseY = (d.axis === 'money' ? yMoney(Math.max(mDom.lo, 0)) : yCount(Math.max(cDom.lo, 0))).toFixed(1); const area = `${pL},${baseY} ${pts} ${xOf(data.length - 1).toFixed(1)},${baseY}`; return `` + `` + `` + ``; }).join(''); const dots = data.length <= 40 ? vis.map((d) => data.map((o, i) => `` + `${escapeHtml(o.month)} ${escapeHtml(d.label)}: ${escapeHtml(d.axis === 'money' ? money(Math.round(o.money[d.key] * 100)) : String(o.count))}`).join('')).join('') : ''; // X 轴:稀疏标注年份 + 端点月份 let labels = ''; let lastKey = ''; data.forEach((o, i) => { const x = xOf(i); const parts = o.month.split('-'); const yy = parts[0], mm = parts[1]; if ((mm === '01' || i === 0) && lastKey !== yy) { labels += `${yy}`; lastKey = yy; } if (i === data.length - 1 || (mm !== '01' && data.length <= 14)) { labels += `${+mm}月`; } }); const clipDef = ``; const tip = (mDom && mDom.clipped) || (cDom && cDom.clipped) ? `
Y 轴按 2%–98% 分位裁剪以突出主体走势,超出范围的点贴边显示(共 ${(mDom ? mDom.clipped : 0) + (cDom ? cDom.clipped : 0)} 个)
` : ''; const plotFrame = ``; // padding-bottom 与 viewBox 同比例,SVG 用 none 铺满这块区域,宽屏两侧不再留白 const plotRatio = (svgH / svgW * 100).toFixed(4); return `
` + `
` + `` + clipDef + plotFrame + grid + zeroLine + lines + dots + labels + '
' + tip; }; return mhRenderPane('trend', note, () => { const toggles = MH_TREND_SERIES.map((d) => ``).join(''); const metricBtn = (v, label) => ``; const cumBtn = ``; return `
口径${metricBtn('amount', '金额优先')}${metricBtn('count', '笔数优先')} 曲线${toggles} ${cumBtn}
${build()}
`; }, (bodyEl) => { const chartEl = bodyEl.querySelector('#sme-mh-trend-chart'); const redraw = () => { chartEl.innerHTML = build(); }; const bar = bodyEl.querySelector('.sme-mh-toolbar'); const onClick = (e) => { const btn = e.target.closest('button.sme-mh-chip'); if (!btn) return; if (btn.dataset.metric) { mhUi.trendMetric = btn.dataset.metric; } else if (btn.dataset.cum !== undefined) { mhUi.trendCumulative = !mhUi.trendCumulative; } else if (btn.dataset.series) { const k = btn.dataset.series; mhUi.trendSeries[k] = !mhUi.trendSeries[k]; } bar.innerHTML = bar.innerHTML; // no-op,保持引用 mhShowPanel('trend'); }; bar.addEventListener('click', onClick); bodyEl._cleanup = () => bar.removeEventListener('click', onClick); }); } // ---------- 面板 4:日历热力 ---------- /** * v1.6.4 日历色阶。 * 旧版用 rgba 透明度叠在深底上,最高才到 .95,格子与背景几乎融在一起; * 且格内数字统一深色,在亮底上又看不清。现改为**实色 6 档递进**, * 每档同时给出背景色与配套文字色,保证任意档位下数字都可读。 */ const MH_CAL_SCALE = [ { bg: '#16202b', fg: '#5d6f80' }, // 无记录:可见但明显为空 { bg: '#123b52', fg: '#9fd4ee' }, // 1 { bg: '#175a7d', fg: '#b8e2f7' }, // 2 { bg: '#1a6e94', fg: '#e2f4ff' }, // 3(背景压深一档以满足 AA 对比) { bg: '#28a0cc', fg: '#06202e' }, // 4(转深色字) { bg: '#48c2e8', fg: '#052030' }, // 5 { bg: '#86dcf5', fg: '#062535' }, // 6(峰值) ]; const MH_CAL_EMPTY = { bg: 'transparent', fg: 'transparent' }; function mhCalLevel(v, mx) { if (!v || v <= 0) return -1; const p = v / mx; // 6 档等分;最低档 1,峰值落在最后一档 let lv = Math.ceil(p * 6); if (lv < 1) lv = 1; if (lv > 6) lv = 6; return lv; } function mhPaneCalendar(s, money, note) { const daily = new Map(s.daily.map((d) => [d.day, d])); const years = [...new Set(s.daily.map((d) => d.day.slice(0, 4)))].sort(); const maxCount = Math.max(1, ...s.daily.map((d) => d.count)); const maxCents = Math.max(1, ...s.daily.map((d) => d.soldCents + d.boughtCents)); return mhRenderPane('calendar', note, () => { if (!years.length) return '
暂无可推断日期的记录,无法生成日历
'; const MD = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十', '十一', '十二']; const buildYear = (y) => { const first = new Date(Number(y), 0, 1); const last = new Date(Number(y), 11, 31); const startDay = (first.getDay() + 6) % 7; // 周一为第一列 const totalDays = Math.round((last - first) / 86400000) + 1; let cells = ''; for (let i = 0; i < startDay; i++) cells += '
'; for (let i = 0; i < totalDays; i++) { const cd = new Date(first); cd.setDate(cd.getDate() + i); const key = y + '-' + String(cd.getMonth() + 1).padStart(2, '0') + '-' + String(cd.getDate()).padStart(2, '0'); const d = daily.get(key); const n = d ? d.count : 0; const lv = mhCalLevel(n, maxCount); const c = lv < 0 ? MH_CAL_EMPTY : MH_CAL_SCALE[lv]; const tip = d ? `${key}|${d.count} 笔|卖出 ${money(d.soldCents)}|买入 ${money(d.boughtCents)}` : `${key}|无记录`; cells += `
` + (n ? `${n}` : '') + '
'; } return `
${escapeHtml(y)}
${MD.map((m) => `${m}`).join('')}
${['一', '二', '三', '四', '五', '六', '日'].map((w) => `${w}`).join('')}
${cells}
`; }; const totalCents = s.daily.reduce((a, d) => a + d.soldCents + d.boughtCents, 0); const activeCells = s.daily.length; const legendCells = MH_CAL_SCALE.map((c) => ``).join(''); return `
按笔数着色 少${legendCells}多 单日最多 ${maxCount} 笔 · 有成交 ${activeCells} 天 · ` + `合计成交额 ${escapeHtml(money(totalCents))}(单日峰值 ${escapeHtml(money(maxCents))})
` + `
${years.map(buildYear).join('')}
`; }); } // ---------- 面板 5:明细 ---------- const MH_LEDGER_DIMS = [ { id: 'all', label: '全部' }, { id: 'game', label: '按游戏' }, { id: 'item', label: '按物品' }, { id: 'month', label: '按月份' }, ]; /** 维度 → 行上的取值键 */ function mhLedgerDimKey(dim, r) { if (dim === 'game') return r.game || '未知游戏'; if (dim === 'item') return r.name || ''; if (dim === 'month') return r.tradeTs ? mhMonthKey(r.tradeTs) : ''; return ''; } /** 当前维度下的候选取值,按成交额降序取前 N(供 chips 使用) */ function mhLedgerDimOptions(dim) { if (dim === 'all') return []; const cnt = new Map(); for (const r of mhState.rows) { if (mhUi.ledgerType !== 'all' && r.action !== mhUi.ledgerType) continue; const k = mhLedgerDimKey(dim, r); if (!k) continue; cnt.set(k, (cnt.get(k) || 0) + (r.action === 'sold' ? r.priceCents : 0)); } return [...cnt.entries()] // 成交额降序;金额相同时按名称升序,保证顺序可预期而不是随 Map 插入序漂移 .sort((a, b) => (b[1] - a[1]) || a[0].localeCompare(b[0])) .map(([k]) => k); } function mhFilteredRows() { const q = mhUi.ledgerQuery.trim().toLowerCase(); let rows = mhState.rows.filter((r) => { if (mhUi.ledgerType !== 'all' && r.action !== mhUi.ledgerType) return false; if (mhUi.ledgerDim !== 'all' && mhUi.ledgerValue) { const key = mhLedgerDimKey(mhUi.ledgerDim, r); if (key !== mhUi.ledgerValue) return false; } if (q) { const hay = `${r.name}\n${r.game}\n${r.counterpart || ''}\n${r.actionLabel}`.toLowerCase(); if (!hay.includes(q)) return false; } return true; }); const key = mhUi.ledgerSort; const dir = mhUi.ledgerDesc ? -1 : 1; rows.sort((a, b) => { let va = key === 'price' ? a.priceCents : key === 'name' ? a.name : (a.tradeTs || a.listedTs || 0); let vb = key === 'price' ? b.priceCents : key === 'name' ? b.name : (b.tradeTs || b.listedTs || 0); if (typeof va === 'string') return va.localeCompare(vb) * dir; return (va - vb) * dir; }); return rows; } function mhPaneLedger(s, money, note) { const rows = mhFilteredRows(); const totalPages = Math.max(1, Math.ceil(rows.length / MH_LEDGER_PAGE)); if (mhUi.ledgerPage > totalPages) mhUi.ledgerPage = totalPages; if (mhUi.ledgerPage < 1) mhUi.ledgerPage = 1; const slice = rows.slice((mhUi.ledgerPage - 1) * MH_LEDGER_PAGE, mhUi.ledgerPage * MH_LEDGER_PAGE); const opts = mhLedgerDimOptions(mhUi.ledgerDim); const th = (key, label) => `${escapeHtml(label)}${mhUi.ledgerSort === key ? (mhUi.ledgerDesc ? ' ▾' : ' ▴') : ''}`; const body = slice.length ? slice.map((r) => { const hold = (r.action === 'sold' && r.tradeTs && r.listedTs) ? r.tradeTs - r.listedTs : 0; return ` ${escapeHtml(r.name)} ${escapeHtml(r.game || '')} ${escapeHtml(r.actionLabel)} ${r.priceCents ? escapeHtml(money(r.priceCents)) : '—'} ${r.action === 'sold' && r.priceCents ? escapeHtml(money(priceBeforeFees(r.priceCents))) : '—'} ${escapeHtml(mhFmtTs(r.tradeTs) || r.tradeDateText || '-')} ${hold ? escapeHtml(mhFmtDuration(hold)) : '—'} ${escapeHtml(r.counterpart || '')} `; }).join('') : `
没有符合当前筛选条件的记录
试试切换「类型」或点「重置」
`; return mhRenderPane('ledger', note, () => { const typeBtn = (v, label) => ``; const dimBtn = (v, label) => ``; const pager = totalPages > 1 ? `
第 ${mhUi.ledgerPage} / ${totalPages} 页 共 ${rows.length} 条
` : `
共 ${rows.length} 条
`; // 维度候选用 chips 呈现,不用下拉——高基数下列表会拉得很长且难以扫读 const TOPN = 14; let valueRow = ''; if (mhUi.ledgerDim !== 'all') { const shown = opts.slice(0, TOPN); const chips = [``] .concat(shown.map((k) => { const label = k.length > 16 ? k.slice(0, 15) + '…' : k; return ``; })).join(''); const more = opts.length > TOPN ? `共 ${opts.length} 个选项,已显示成交额最高的 ${TOPN} 个;可用搜索框精确查找` : `共 ${opts.length} 个选项`; valueRow = `
${ escapeHtml((MH_LEDGER_DIMS.find((d) => d.id === mhUi.ledgerDim) || {}).label || '') }${chips}${more}
`; } return `
大类${typeBtn('all', '全部')}${typeBtn('sold', '卖出')}${typeBtn('bought', '买入')} 维度${dimBtn('all', '不限')}${dimBtn('game', '按游戏')}${dimBtn('item', '按物品')}${dimBtn('month', '按月份')} 点击表头排序
${valueRow}
${th('name', '物品')}${th('price', '价格')}${th('ts', '交易时间')}${body}
游戏类型估算到手持有时长对方
${pager}`; }, (bodyEl) => { const bar = bodyEl.querySelector('.sme-mh-toolbar'); const q = bodyEl.querySelector('#sme-mh-ledger-q'); const onClick = (e) => { const chip = e.target.closest('button.sme-mh-chip'); const th = e.target.closest('th.sme-mh-th-sort'); if (chip) { if (chip.dataset.ltype) { mhUi.ledgerType = chip.dataset.ltype; mhUi.ledgerValue = ''; mhUi.ledgerPage = 1; mhShowPanel('ledger'); } else if (chip.dataset.ldim) { mhUi.ledgerDim = chip.dataset.ldim; mhUi.ledgerValue = ''; mhUi.ledgerPage = 1; mhShowPanel('ledger'); } else if (chip.dataset.lvalue !== undefined) { mhUi.ledgerValue = chip.dataset.lvalue; mhUi.ledgerPage = 1; mhShowPanel('ledger'); } else if (chip.dataset.page) { const d = chip.dataset.page; if (d === 'prev' && mhUi.ledgerPage > 1) mhUi.ledgerPage--; else if (d === 'next') mhUi.ledgerPage++; mhShowPanel('ledger'); } else if (chip.id === 'sme-mh-ledger-reset') { mhUi.ledgerType = 'all'; mhUi.ledgerDim = 'all'; mhUi.ledgerValue = ''; mhUi.ledgerQuery = ''; mhUi.ledgerPage = 1; mhShowPanel('ledger'); } return; } if (th) { const k = th.dataset.sort; if (mhUi.ledgerSort === k) mhUi.ledgerDesc = !mhUi.ledgerDesc; else { mhUi.ledgerSort = k; mhUi.ledgerDesc = k !== 'name'; } mhShowPanel('ledger'); } }; bar.addEventListener('click', onClick); bodyEl.addEventListener('click', onClick); let timer = null; const onInput = () => { clearTimeout(timer); timer = setTimeout(() => { mhUi.ledgerQuery = q.value; mhUi.ledgerPage = 1; mhShowPanel('ledger'); }, 260); }; q.addEventListener('input', onInput); bodyEl._cleanup = () => { clearTimeout(timer); bar.removeEventListener('click', onClick); bodyEl.removeEventListener('click', onClick); q.removeEventListener('input', onInput); }; }); } // ---------- 面板 6:导出 ---------- const MH_EXPORT_FORMATS = [ { id: 'csv', label: 'CSV', desc: '逗号分隔 · 带 UTF-8 BOM · Excel 双击直开' }, { id: 'tsv', label: 'TSV', desc: '制表符分隔 · 字段内换行自动转义' }, { id: 'json', label: 'JSON', desc: '结构化 · 含汇总/物品/游戏/月份/每日/价格分档' }, { id: 'md', label: 'Markdown', desc: '可读报告 · 统计卡 + 榜单 + 月度表 + 明细' }, ]; function mhPaneExport(s, money, note) { const fmt = mhUi.exportFormat || mhState.format || 'csv'; const filtered = mhFilteredRows(); const hasFilter = mhUi.ledgerType !== 'all' || (mhUi.ledgerDim !== 'all' && !!mhUi.ledgerValue) || !!mhUi.ledgerQuery.trim(); return mhRenderPane('export', note, () => { const cards = MH_EXPORT_FORMATS.map((f) => ``).join(''); const scopeRows = hasFilter ? filtered : mhState.rows; const preview = fmt === 'json' ? ['{', ' "meta": { "recordCount": ' + scopeRows.length + ', "currency": "' + mhCurrencyCode() + '" },', ' "summary": { "total": ' + s.count + ', "netProfit": ' + (Math.round(s.netCents) / 100).toFixed(2) + ' },', ' "transactions": [ … ' + scopeRows.length + ' 条 … ]', '}'].join('\n') : fmt === 'md' ? '# Steam 市场交易记录汇总\n\n## 总体\n\n| 指标 | 数值 |\n| --- | --- |\n| 记录总数 | ' + s.count + ' |\n…' : '序号,事件ID,挂单ID,物品名称,游戏,AppID,类型,状态,价格原文,价格,币种,…\n' + '1,' + escapeHtml(scopeRows.length ? scopeRows[0].eventId : '') + ',' + escapeHtml(scopeRows.length ? scopeRows[0].listingId : '') + ',' + escapeHtml(scopeRows.length ? scopeRows[0].name : '') + ',…'; return `
格式${cards}
预览(前几行)
${escapeHtml(preview)}
本次导出
记录数
${scopeRows.length}
格式
${escapeHtml(fmt.toUpperCase())}
币种
${escapeHtml(mhCurrencyCode())}
导出会包含全部字段;JSON 与 Markdown 另附多维汇总。时间戳年份为推断值。
`; }, (bodyEl) => { const bar = bodyEl.querySelector('.sme-mh-toolbar'); let scope = hasFilter ? 'filtered' : 'all'; const onBar = (e) => { const chip = e.target.closest('button.sme-mh-fmt, button.sme-mh-chip'); if (!chip) return; if (chip.dataset.fmt) { mhUi.exportFormat = chip.dataset.fmt; mhShowPanel('export'); } else if (chip.dataset.scope === 'all') { scope = 'all'; mhShowPanel('export'); } else if (chip.dataset.scope === 'filtered') { scope = 'filtered'; mhShowPanel('export'); } }; bar.addEventListener('click', onBar); const doBtn = bodyEl.querySelector('#sme-mh-do-export'); const onExport = () => { const rows = scope === 'filtered' ? mhFilteredRows() : mhState.rows; if (!rows.length) { toast('没有可导出的记录', 'warning'); return; } mhRunExport({ format: fmt, rows, label: scope === 'filtered' ? 'filtered' : '' }); }; doBtn.addEventListener('click', onExport); bodyEl._cleanup = () => { bar.removeEventListener('click', onBar); doBtn.removeEventListener('click', onExport); }; }); } // ---------- 工具条 ---------- function mhSetStatus(msg, cls) { const el = document.getElementById('sme-mh-status'); if (!el) return; el.textContent = msg || ''; el.className = 'sme-mh-status' + (cls === 'ok' ? ' sme-ok' : (cls === 'err' ? ' sme-err' : '')); } function mhSetProgress(ratio, show) { const box = document.getElementById('sme-mh-progress'); const bar = document.getElementById('sme-mh-progress-bar'); if (box) box.style.display = show ? '' : 'none'; if (bar) bar.style.width = Math.max(0, Math.min(1, ratio || 0)) * 100 + '%'; } function mhSetBusy(busy) { const panel = document.getElementById('sme-mh-panel'); if (!panel) return; panel.querySelectorAll('button').forEach(b => { if (b.id === 'sme-mh-stop') { b.style.display = busy ? '' : 'none'; b.disabled = !busy; } else { b.disabled = !!busy; } }); } function mhCooldownLeft() { return (mhState.paused && mhState.nextAt) ? Math.max(0, Math.ceil((mhState.nextAt - Date.now()) / 1000)) : 0; } function mhFmtCountdown(sec) { return `${Math.floor(sec / 60)}:${String(sec % 60).padStart(2, '0')}`; } /** 限流冷却期间每秒刷新倒计时文案,到点自动解锁按钮 */ let _mhCoolTimer = null; function mhStartCooldownWatch() { if (_mhCoolTimer) return; _mhCoolTimer = setInterval(() => { if (mhState.fetching || !mhState.paused) { mhStopCooldownWatch(); return; } const left = mhCooldownLeft(); if (left <= 0) { mhState.paused = false; mhState.nextAt = 0; mhState.coolStreak = 0; mhStopCooldownWatch(); mhSetStatus(`冷却结束,可从第 ${mhState.rows.length + 1} 条继续索引`, 'ok'); mhRefreshStatus(); return; } mhSetStatus(`限流冷却中 ${mhFmtCountdown(left)} · 已保留 ${mhState.rows.length} / ${mhState.totalCount || '?'} 条`, 'err'); }, 1000); } function mhStopCooldownWatch() { if (_mhCoolTimer) { clearInterval(_mhCoolTimer); _mhCoolTimer = null; } } function mhRefreshStatus() { if (mhState.fetching) { mhSetProgress(mhState.totalCount ? mhState.rows.length / mhState.totalCount : 0, true); return; } if (mhState.rows.length === 0 && !mhState.paused) { mhSetProgress(0, false); mhSetStatus('尚未索引交易记录'); mhStopCooldownWatch(); return; } if (mhState.paused && mhCooldownLeft() > 0) { mhSetProgress(mhState.totalCount ? mhState.rows.length / mhState.totalCount : 0, false); mhSetStatus(`限流冷却中 ${mhFmtCountdown(mhCooldownLeft())} · 已保留 ${mhState.rows.length} / ${mhState.totalCount || '?'} 条`, 'err'); mhStartCooldownWatch(); return; } mhSetProgress(0, false); mhStopCooldownWatch(); const when = mhState.fetchedAt ? new Date(mhState.fetchedAt).toLocaleString('zh-CN', { hour12: false }) : ''; if (mhState.rows.length === 0) { mhSetStatus('尚未索引交易记录'); return; } // v1.6.8:不再重复报同一个数字。完成 / 未完成两套文案。 const text = mhState.complete ? `已索引全部 ${mhState.rows.length} 条` : `已索引 ${mhState.rows.length} / ${mhState.totalCount || '?'} 条 · 可继续`; mhSetStatus(`${text}${when ? ' · ' + when : ''}`, mhState.complete ? 'ok' : ''); mhSyncFetchButton(); } /** 刷新/继续/重建按钮的语义随索引状态变化,避免误清缓存 */ function mhFetchButtonMeta() { if (mhState.complete) return { label: '重新索引', title: '已索引完整。点击将清空并从头重新抓取' }; if (mhState.rows.length > 0) return { label: '继续', title: `从第 ${mhState.rows.length + 1} 条继续索引(保留已抓取部分)` }; return { label: '索引', title: '开始抓取全量交易记录' }; } function mhUpdateFetchButton(btn) { if (!btn) return; const meta = mhFetchButtonMeta(); const span = btn.querySelector('span'); if (span) span.textContent = meta.label; btn.title = meta.title; btn.setAttribute('aria-label', meta.title); } /** 索引状态变化后刷新按钮文案与冷却计时 */ function mhSyncFetchButton() { mhUpdateFetchButton(document.getElementById('sme-mh-refresh-btn')); } function injectMhPanel() { const tabs = document.getElementById('myMarketTabs'); if (!tabs) return false; if (mhState.panelEl && document.body.contains(mhState.panelEl)) return true; const formatSel = h('select', { id: 'sme-mh-format', class: 'sme-mh-format', 'aria-label': '导出格式', onChange: (e) => { mhState.format = ['csv', 'tsv', 'json', 'md'].includes(e.target.value) ? e.target.value : 'csv'; settings.set('marketHistoryFormat', mhState.format); toast(`导出格式已切换为 ${mhState.format.toUpperCase()}`, 'info'); }, html: '' + '', }); const savedFmt = settings.get('marketHistoryFormat'); formatSel.value = MH_EXPORT_FORMATS.some((f) => f.id === savedFmt) ? savedFmt : 'csv'; mhState.format = formatSel.value; const actions = h('span', { class: 'sme-mh-actions' }, [ h('button', { id: 'sme-mh-summary-btn', class: 'sme-market-btn', type: 'button', html: ICONS.chart + '汇总', title: '查看市场交易记录分析(概览 / 趋势 / 分布 / 日历 / 明细 / 导出)', onClick: async () => { if (await mhEnsureData('查看分析')) openMhAnalysis(); }, }), formatSel, h('button', { id: 'sme-mh-export-btn', class: 'sme-market-btn', type: 'button', html: ICONS.download + '导出', title: '按所选格式导出全部交易记录', onClick: async () => { if (await mhEnsureData('导出')) mhRunExport(); }, }), h('button', { id: 'sme-mh-refresh-btn', class: 'sme-market-btn', type: 'button', html: ICONS.refreshCw + '', title: '', onClick: async (e) => { if (mhState.fetching) return; const btn = e.currentTarget; const meta = mhFetchButtonMeta(); if (meta.force) { // 只有「已完整索引」时才允许从零重来,且二次确认 const ok = await showConfirmDialog( '重新索引会清空当前已抓取的 ' + `${mhState.rows.length} 条记录并从第 1 条重抓。

` + '' + '如果只是没抓完,请用「继续」从断点接着抓。', { title: '重新索引', okText: '清空并重来', okClass: 'sme-dialog-ok' }); if (!ok) return; mhState.rows = []; mhState.totalCount = 0; mhState.fetchedAt = 0; mhState.nextOffset = 0; mhState.complete = false; mhState.paused = false; mhState.nextAt = 0; mhState.coolStreak = 0; mhSetStatus('正在重新索引…'); await mhFetchAll({ force: true }); } else { // 断点续传:保留已有数据 mhSetStatus(`正在从第 ${mhState.rows.length + 1} 条继续索引…`); await mhFetchAll(); } mhUpdateFetchButton(btn); }, }), h('button', { id: 'sme-mh-stop', class: 'sme-market-btn sme-danger', type: 'button', text: '停止', style: { display: 'none' }, onClick: () => { mhState.abort = true; mhSetStatus('正在停止…'); }, }), ]); // 按当前索引状态设定按钮文案(索引 / 继续 / 重新索引) const fetchBtn = actions.querySelector('#sme-mh-refresh-btn'); if (fetchBtn) mhUpdateFetchButton(fetchBtn); // 独立容器,作为 #myMarketTabs 的前一个兄弟节点插入。 // Tab 行内部依赖 float 布局("出售物品"右浮动),同层插入必然重叠; // 放到上一行的块级容器里可完全绕开,且不改动 Steam 原有样式。 const panel = h('div', { class: 'sme-mh-panel', id: 'sme-mh-panel' }, [ h('div', { class: 'sme-mh-panel-inner' }, [ h('span', { class: 'sme-mh-title', html: ICONS.chart + '交易记录' }), h('span', { class: 'sme-mh-status', id: 'sme-mh-status' }), actions, ]), h('div', { class: 'sme-mh-progress', id: 'sme-mh-progress' }, [ h('div', { class: 'sme-mh-progress-bar', id: 'sme-mh-progress-bar' }), ]), ]); mhState.panelEl = panel; const host = tabs.parentNode || tabs; host.insertBefore(panel, tabs); mhRefreshStatus(); return true; } function initMarketHistoryPanel() { if (!settings.get('marketHistoryPanel')) return; if (detectPageType() !== 'browse') return; if (!mhState.styleAdded) { GM_addStyle(MH_CSS); mhState.styleAdded = true; } if (mhState.active) return; // #myMarketTabs 由页面脚本异步渲染,轮询等待 let attempts = 0; const tryStart = () => { if (injectMhPanel()) { mhState.active = true; mhLoadCache(); mhRefreshStatus(); L('Market history panel active, cached rows:', mhState.rows.length); return; } if (attempts++ < 30) setTimeout(tryStart, 500); }; tryStart(); } function cleanupMarketHistoryPanel() { // v1.6.8:切页面前先落盘再中断。 // 原实现直接 abort = true 却不保存,SPA 导航切走时最多丢掉 400 条进度; // 现在确保断点持久化,切回来点「继续」即可接着抓。 if (mhState.fetching && mhState.rows.length) { mhSaveCache(mhState.rows, mhState.totalCount, mhState.nextOffset); } if (mhState.panelEl) { mhState.panelEl.remove(); mhState.panelEl = null; } // 分析窗口挂在 body 上,需单独销毁并解绑全局 keydown if (_mhShell) { if (_mhShell.modal._dispose) _mhShell.modal._dispose(); const bodies = _mhShell.panelsEl.querySelectorAll('.sme-mh-pbody'); bodies.forEach((b) => { if (b._cleanup) { try { b._cleanup(); } catch (e) {} b._cleanup = null; } }); _mhShell.modal.remove(); _mhShell = null; } mhStopCooldownWatch(); mhState.active = false; mhState.abort = true; } // ==================== v1.6.4 市场页 Beta 公告条隐藏 ==================== // 社区市场首页顶部有一条官方公告: // 「我们目前正在对物品页面和高级搜索等市场功能进行 Beta 测试…」 // 该条由服务端渲染在 #marketBetaNoteCtn 内,且市场页是 SPA 导航会重新挂载, // 因此用一条 CSS 规则常驻压制——比逐次 JS 移除更稳,SPA 切回来也不会复现。 const MARKET_BETA_HIDE_CSS = ` #marketBetaNoteCtn{display:none!important} .marketBetaNoteText{display:none!important} #marketBetaNote{display:none!important} `; let _betaNoteStyleAdded = false; function applyMarketBetaNoteHidden() { if (!settings.get('hideMarketBetaNote')) return; if (_betaNoteStyleAdded) return; GM_addStyle(MARKET_BETA_HIDE_CSS); _betaNoteStyleAdded = true; } // ==================== 初始化 ==================== 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 === 'multisell') { initMultisellPage(); } else if (pageType === 'tradeoffer') { initTradeOfferPage(); } else if (pageType === 'booster') { initBoosterPage(); } // 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,原先要空等约 10 秒才会插入顶部面板。 if (shouldUseFloatPanel()) ensureAnalysisFab(); // 等待 SSR 数据可用 const pageHref = location.href; const tryExtract = (attempt) => { if (location.href !== pageHref) return; const ssrData = extractSSRData(); if (ssrData.sellOrders !== null) { const hadSummary = !!tabPanes.summary; allSellOrders = ssrData.sellOrders; // 设置全局 classid,并为缺少 classid 的订单补充 if (ssrData.classid) _globalClassid = ssrData.classid; if (_globalClassid) { for (const o of allSellOrders) { if (!o.classid) o.classid = _globalClassid; } } L(`Found ${allSellOrders.length} sell orders`); presentListingPanel(); if (hadSummary) onListingOrdersReady(); 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, presenting panel anyway'); presentListingPanel(); } }; tryExtract(0); // v1.4.6:我的挂单市场顺序跟踪(延迟等待 SSR renderContext 与 React 挂载) setTimeout(initMarketRankTracker, 800); // v1.4.9:参考货币换算价格注入 + 监听 DOM 变化自动补注 startRefPriceInjection(); } function initBrowsePage() { if (settings.get('ownedBadges')) { setTimeout(processAllBrowseRows, 400); setupBrowseObserver(); } createFab(); initMyListingsEnhancement(); // v1.6.0:市场页"我的市场历史记录"工具条(汇总 / 导出 / 格式下拉) initMarketHistoryPanel(); // v1.6.4:隐藏 Steam 官方的市场 Beta 测试公告条 applyMarketBetaNoteHidden(); // v1.5.7:/market/search(pageType=browse)此前完全没有注入入口, // 「起价:₹xx」卡片换算一直不出现;这里与详情页共用同一入口。 startRefPriceInjection(); } function handleURLChange() { L('URL changed to', location.href); const pageType = detectPageType(); // 清理旧面板(浮窗按钮挂在 body 上,要单独摘掉) removeAnalysisFloat(); 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 (invPriceIO) { invPriceIO.disconnect(); invPriceIO = null; } // v1.5.1 P2-B:断开按需标注观察器 if (tradeObserver) { tradeObserver.disconnect(); tradeObserver = null; } cleanupBoosterPage(); cleanupMyListingsEnhancement(); cleanupMarketRankTracker(); cleanupMarketHistoryPanel(); // v1.4.9:清理参考货币价格标签和观察器 cleanupRefPriceObserver(); allSellOrders = []; priceHistoryData = null; orderBookData = null; currentTab = 'summary'; priceCheckResults = null; priceCheckLoading = false; if (pageType === 'listing') { // v1.5.0:SPA 导航到物品页时补注入假 hook(完整页面加载已在 document-start 注入过,此处幂等) if (settings.get('showMorePrices')) installShowMoreHook(); initListingPage(); } else if (pageType === 'browse') { initBrowsePage(); } else if (pageType === 'inventory') { initInventoryPage(); } else if (pageType === 'multisell') { initMultisellPage(); } else if (pageType === 'tradeoffer') { initTradeOfferPage(); } else if (pageType === 'booster') { initBoosterPage(); } } // ==================== 补充包制作器页面 ==================== // ---- 补充包页面状态 ---- let boosterPanelEl = null; let boosterGameInfo = null; // CBoosterCreatorPage.sm_rgBoosterData let boosterConfig = null; // 用户配置 {queue:[], favorites:[], blacklist:[], pageSize, filter} let boosterPriceData = {}; // {appid: {cardPrice, boosterBuyOrder, hadCard, hadBooster, sold}} let boosterGemsSackPrice = 0; // 市场一袋宝珠价格(分) let boosterCustomSackPrice = 0; // 自定义一袋宝珠价格(分) let boosterPageNum = 1; let boosterPageSize = 15; let boosterSearchKeyword = ''; let boosterFilterType = 'all'; // all|queue|favorites|blacklist|available let boosterSortKey = 'name'; let boosterSortDir = 'asc'; let boosterQueryRunning = false; let boosterCreateRunning = false; let boosterDoneList = []; let boosterGemsDisplay = 0; // 当前用户宝石数量 // 卡牌数量 → 宝石成本映射 const CARD_COUNT_TO_GEMS = { 5:1200, 6:1000, 7:857, 8:750, 9:667, 10:600, 11:545, 12:500, 13:462, 14:429, 15:400 }; /** 加载补充包配置 */ function loadBoosterConfig() { try { const raw = localStorage.getItem('sme_booster_config'); if (raw) { const cfg = JSON.parse(raw); boosterConfig = { queue: cfg.queue || [], favorites: cfg.favorites || [], blacklist: cfg.blacklist || [], pageSize: cfg.pageSize || 15, filter: cfg.filter !== false, customSackPrice: cfg.customSackPrice || 0, autoQueryBooster: cfg.autoQueryBooster || false, }; } } catch (e) {} if (!boosterConfig) { boosterConfig = { queue: [], favorites: [], blacklist: [], pageSize: 15, filter: true, customSackPrice: 0, autoQueryBooster: false }; } boosterPageSize = boosterConfig.pageSize; boosterCustomSackPrice = boosterConfig.customSackPrice || 0; } /** 保存补充包配置 */ function saveBoosterConfig() { try { localStorage.setItem('sme_booster_config', JSON.stringify(boosterConfig)); } catch (e) {} } /** 加载补充包价格缓存 */ function loadBoosterPriceCache() { try { const raw = localStorage.getItem('sme_booster_price_cache'); if (raw) boosterPriceData = JSON.parse(raw) || {}; } catch (e) {} } /** 保存补充包价格缓存 */ function saveBoosterPriceCache() { try { localStorage.setItem('sme_booster_price_cache', JSON.stringify(boosterPriceData)); } catch (e) {} } /** 补充包页面 toast */ let _boosterToastEl = null; function boosterToast(msg, type = 'info') { if (!_boosterToastEl) { _boosterToastEl = h('div', { class: 'sme-booster-toast' }); document.body.appendChild(_boosterToastEl); } _boosterToastEl.textContent = msg; _boosterToastEl.className = 'sme-booster-toast sme-show sme-' + type; clearTimeout(_boosterToastEl._timer); _boosterToastEl._timer = setTimeout(() => { _boosterToastEl.classList.remove('sme-show'); }, 3000); } /** 获取一袋宝珠市场最低售价 */ async function fetchGemsSackPrice() { try { // 尝试从缓存获取 item_nameid let sackItemId = ''; const cacheRaw = localStorage.getItem('sme_booster_sack_cache'); if (cacheRaw) { try { sackItemId = JSON.parse(cacheRaw).itemId || ''; } catch (e) {} } if (!sackItemId) { // 从市场页面获取 item_nameid const html = (await smeRequest('https://steamcommunity.com/market/listings/753/753-Sack%20of%20Gems', { expect: 'text', onNonOk: 'null' })) || ''; const m = html.match(/Market_LoadOrderSpread\(\s*(\d+)/); if (m) { sackItemId = m[1]; localStorage.setItem('sme_booster_sack_cache', JSON.stringify({ itemId: sackItemId, ts: Date.now() })); } } if (!sackItemId) return 0; const wi = getWalletInfo(); const url = `https://steamcommunity.com/market/itemordershistogram?country=${wi.country || 'US'}&language=${wi.language || 'english'}¤cy=${wi.wallet_currency || 1}&item_nameid=${sackItemId}&two_factor=0`; const data = await doFetch(url); if (data && data.sell_order_graph && data.sell_order_graph.length > 0) { return parseInt(data.sell_order_graph[0][0], 10) || 0; } } catch (e) { W('Failed to fetch gems sack price:', e.message); } return 0; } /** 获取补充包市场最低售价和最高买单 */ async function fetchBoosterMarketPrice(appid, name) { try { // 使用英文名称构建 market hash,避免中文名导致链接失效 const englishName = await resolveBoosterEnglishName(appid, name); const marketHash = `${appid}-${englishName} Booster Pack`; // 先获取 item_nameid let boosterItemId = ''; const cacheKey = `booster_itemid_${appid}`; const cacheRaw = localStorage.getItem('sme_booster_item_cache'); if (cacheRaw) { try { const cache = JSON.parse(cacheRaw); boosterItemId = (cache[appid] || {}).itemId || ''; } catch (e) {} } if (!boosterItemId) { const boosterUrl = `https://steamcommunity.com/market/listings/753/${encodeURIComponent(marketHash.replace(/\//g, '-'))}`; const html = (await smeRequest(boosterUrl, { expect: 'text', onNonOk: 'null' })) || ''; const m = html.match(/Market_LoadOrderSpread\(\s*(\d+)/); if (m) { boosterItemId = m[1]; const cache = cacheRaw ? JSON.parse(cacheRaw) : {}; cache[appid] = { itemId: boosterItemId, ts: Date.now() }; localStorage.setItem('sme_booster_item_cache', JSON.stringify(cache)); } } if (!boosterItemId) return null; const wi = getWalletInfo(); const url = `https://steamcommunity.com/market/itemordershistogram?country=${wi.country || 'US'}&language=${wi.language || 'english'}¤cy=${wi.wallet_currency || 1}&item_nameid=${boosterItemId}&two_factor=0`; const data = await doFetch(url); const result = { buyOrder: 0, sellOrder: 0 }; if (data.buy_order_graph && data.buy_order_graph.length > 0) { result.buyOrder = parseInt(data.buy_order_graph[0][0], 10) || 0; } if (data.sell_order_graph && data.sell_order_graph.length > 0) { result.sellOrder = parseInt(data.sell_order_graph[0][0], 10) || 0; } return result; } catch (e) { W(`Failed to fetch booster price for ${appid}:`, e.message); return null; } } /** 查询单个游戏的卡牌均价 */ async function fetchCardPrice(appid) { try { // 搜索该游戏的普通卡牌 const searchUrl = `https://steamcommunity.com/market/search/render/?start=0&count=20&category_753_cardborder[]=tag_cardborder_0&appid=753&category_753_Game[]=tag_app_${appid}&norender=1`; const searchData = await doFetch(searchUrl); if (!searchData || !searchData.total_count) return null; const cardCount = searchData.total_count; let totalPrice = 0; let successCount = 0; // 从搜索结果获取每个卡牌的 market_hash_name const cardNames = []; if (searchData.results && Array.isArray(searchData.results)) { for (const r of searchData.results) { if (r.hash_name) cardNames.push(r.hash_name); } } // 逐个查询卡牌最低售价 for (const hashName of cardNames) { try { // 获取卡牌详情页的 item_nameid let cardItemId = ''; const cardCacheRaw = localStorage.getItem('sme_booster_card_item_cache'); if (cardCacheRaw) { try { const cc = JSON.parse(cardCacheRaw); cardItemId = (cc[hashName] || '').itemId || ''; } catch (e) {} } if (!cardItemId) { const cardUrl = `https://steamcommunity.com/market/listings/753/${encodeURIComponent(hashName)}`; const html = (await smeRequest(cardUrl, { expect: 'text', onNonOk: 'null' })) || ''; const m = html.match(/Market_LoadOrderSpread\(\s*(\d+)/); if (m) { cardItemId = m[1]; const cc = cardCacheRaw ? JSON.parse(cardCacheRaw) : {}; cc[hashName] = { itemId: cardItemId, ts: Date.now() }; localStorage.setItem('sme_booster_card_item_cache', JSON.stringify(cc)); } } if (cardItemId) { const wi = getWalletInfo(); const url = `https://steamcommunity.com/market/itemordershistogram?country=${wi.country || 'US'}&language=${wi.language || 'english'}¤cy=${wi.wallet_currency || 1}&item_nameid=${cardItemId}&two_factor=0`; const data = await doFetch(url); if (data.sell_order_graph && data.sell_order_graph.length > 0) { totalPrice += parseInt(data.sell_order_graph[0][0], 10) || 0; successCount++; } } // 请求间隔,避免限流 await new Promise(r => setTimeout(r, 600)); } catch (e) { W(`Failed to fetch card price for ${hashName}:`, e.message); } } if (successCount > 0) { // 三张卡牌均价 × 税后(× 0.87) const avgCardPrice = totalPrice / successCount; const totalCardValue = avgCardPrice * 3; // 补充包含3张卡牌 return { cardPrice: totalCardValue, cardCount, successCount }; } return null; } catch (e) { W(`Failed to fetch card prices for ${appid}:`, e.message); return null; } } /** 制作补充包 */ async function createBoosterPack(appid, series) { try { const sessionid = await resolveSessionId(); if (!sessionid) throw new Error('无法获取 sessionid'); const body = new URLSearchParams({ sessionid, appid: String(appid), series: String(series), tradability_preference: '1', }); const json = await smeRequest('https://steamcommunity.com/tradingcards/ajaxcreatebooster/', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }, body: body.toString(), }); if (!json || !json.success) throw new Error((json && json.message) || '制作失败'); return json; } catch (e) { W(`Failed to create booster for ${appid}:`, e.message); throw e; } } /** 获取用户当前宝石数量 */ function getBoosterGems() { const el = document.querySelector('.goovalue'); if (el) { const text = el.textContent.replace(/[,,\s]/g, ''); const num = parseInt(text, 10); if (!isNaN(num)) return num; } return 0; } // ---- 英文名称缓存(用于补充包链接生成) ---- let boosterEnglishNameCache = {}; // { appid: 'English Name' } let _boosterEnNameFetching = new Set(); // 正在获取中的 appid,避免重复请求 /** 加载英文名称缓存 */ function loadBoosterEnglishNameCache() { try { const raw = localStorage.getItem('sme_booster_en_name_cache'); if (raw) boosterEnglishNameCache = JSON.parse(raw) || {}; } catch (e) {} } /** 保存英文名称缓存 */ function saveBoosterEnglishNameCache() { try { localStorage.setItem('sme_booster_en_name_cache', JSON.stringify(boosterEnglishNameCache)); } catch (e) {} } /** 去除书名号及特殊符号 */ function pureName(str) { return (str || '').replace(/[《》〈〉「」『』™©®]/g, ''); } /** 异步获取游戏英文名称(带缓存),参考 Steam_Show_English_Name.js 逻辑 */ async function fetchEnglishName(appid) { const id = String(appid); if (!id || id === '0') return null; // 内存缓存 if (boosterEnglishNameCache[id]) return boosterEnglishNameCache[id]; // 防止并发重复请求 if (_boosterEnNameFetching.has(id)) return null; _boosterEnNameFetching.add(id); try { const url = `https://store.steampowered.com/api/appdetails?appids=${id}&l=english`; const data = await gmFetchJson(url, 12000); if (data && data[id] && data[id].success === true && data[id].data && data[id].data.name) { const enName = pureName(data[id].data.name); boosterEnglishNameCache[id] = enName; saveBoosterEnglishNameCache(); return enName; } } catch (e) { W(`Failed to fetch English name for appid ${id}:`, e.message); } finally { _boosterEnNameFetching.delete(id); } return null; } /** 同步获取链接用名称:优先英文缓存,否则回退到去符号的原始名 */ function getBoosterLinkName(appid, fallbackName) { const id = String(appid); if (boosterEnglishNameCache[id]) return boosterEnglishNameCache[id]; return pureName(fallbackName || ''); } /** 异步解析英文名称:缓存命中直接返回,否则请求 API 后返回 */ async function resolveBoosterEnglishName(appid, fallbackName) { const id = String(appid); if (boosterEnglishNameCache[id]) return boosterEnglishNameCache[id]; const en = await fetchEnglishName(id); return en || pureName(fallbackName || ''); } /** 构建补充包市场链接 */ function buildBoosterMarketUrl(appid, name) { const linkName = getBoosterLinkName(appid, name); const safeName = linkName.replace(/\//g, '-'); return `https://steamcommunity.com/market/listings/753/${appid}-${encodeURIComponent(safeName)}%20Booster%20Pack`; } /** 构建卡牌市场搜索链接 */ function buildCardMarketUrl(appid) { return `https://steamcommunity.com/market/search?category_Game=app_${appid}&appid=753`; } /** 获取游戏可用状态 */ function getGameStatus(appid) { const selector = document.getElementById('booster_game_selector'); if (!selector) return 'unknown'; const option = selector.querySelector(`option[value="${appid}"]`); if (!option) return 'unknown'; if (option.classList.contains('available')) return 'available'; if (boosterDoneList.includes(String(appid))) return 'done'; return 'cooldown'; } /** 获取游戏宝石成本 */ function getGameGemsCost(appid) { if (!boosterGameInfo) return 0; const info = boosterGameInfo[appid]; if (!info) return 0; return parseInt(info.price, 10) || 0; } /** 计算利润 */ function calcBoosterProfit(appid) { const gemsCost = getGameGemsCost(appid); if (gemsCost === 0) return null; // 一袋宝珠1000 gems的市场价 → 每gem价值 const sackPrice = boosterCustomSackPrice > 0 ? boosterCustomSackPrice : boosterGemsSackPrice; if (sackPrice === 0) return null; const gemCostCents = Math.round(sackPrice * gemsCost / 1000); // 卡牌收益(税后) const pd = boosterPriceData[appid]; const cardRevenue = pd && pd.cardPrice ? Math.round(pd.cardPrice * 0.87) : 0; // 补充包买单收益 const boosterBuyRevenue = pd && pd.boosterBuyOrder ? pd.boosterBuyOrder : 0; // 取较高者作为预期收益 const expectedRevenue = Math.max(cardRevenue, boosterBuyRevenue); return { cost: gemCostCents, revenue: expectedRevenue, profit: expectedRevenue - gemCostCents }; } /** 格式化价格(分→元) */ function formatBoosterPrice(cents) { if (cents == null || isNaN(cents)) return '--'; const wi = getWalletInfo(); const symbol = (wi.currency_code && wi.currency_code in CURRENCY_ID_TO_CODE) ? '' : ''; return (cents / 100).toFixed(2); } /** 封面图降级链(参考 sglv-cover-fallback.lib.js) */ const COVER_CDN_AKAMAI = 'https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/'; const COVER_CDN_CF = 'https://cdn.cloudflare.steamstatic.com/steam/apps/'; const COVER_CHAIN = ['capsule_231x87.jpg', 'capsule_184x69.jpg', 'capsule_467x181.jpg', 'header.jpg']; const COVER_PLACEHOLDER = "data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 184 69'%3E%3Crect fill='%231a1f3a' width='184' height='69'/%3E%3Ctext x='50%25' y='50%25' text-anchor='middle' dy='.3em' fill='%2364748b' font-family='system-ui' font-size='10'%3ENo Image%3C/text%3E%3C/svg%3E"; const _coverCache = new Map(); function getBoosterCoverUrl(appId) { const id = String(appId); if (_coverCache.has(id)) return _coverCache.get(id); try { const cached = localStorage.getItem('sme_cover_' + id); if (cached) { _coverCache.set(id, cached); return cached; } } catch(e) {} return COVER_CDN_AKAMAI + id + '/' + COVER_CHAIN[0]; } function buildCoverFallbackChain(appId) { const id = String(appId); const chain = []; COVER_CHAIN.forEach(p => chain.push(COVER_CDN_AKAMAI + id + '/' + p)); chain.push(COVER_CDN_CF + id + '/capsule_231x87.jpg'); chain.push(COVER_CDN_CF + id + '/header.jpg'); return chain; } function handleCoverError(img) { const appId = img.dataset.appid; if (!appId) return; const chain = buildCoverFallbackChain(appId); let idx = parseInt(img.dataset.fbIdx || '0', 10); while (idx < chain.length && (img.src === chain[idx] || img.src === decodeURIComponent(chain[idx]))) idx++; if (idx < chain.length) { img.dataset.fbIdx = String(idx + 1); img.src = chain[idx]; } else { img.src = COVER_PLACEHOLDER; img.classList.add('sme-booster-cover-failed'); } } function handleCoverLoad(img) { const appId = img.dataset.appid; if (!appId || !img.src || img.src.indexOf('data:image') === 0) return; const id = String(appId); _coverCache.set(id, img.src); try { localStorage.setItem('sme_cover_' + id, img.src); } catch(e) {} } /** 渲染补充包面板 */ function renderBoosterPanel() { if (!boosterPanelEl) return; const container = boosterPanelEl.querySelector('.sme-booster-content'); if (!container) return; // 获取过滤后的游戏列表 let games = []; for (const key in boosterGameInfo) { const info = boosterGameInfo[key]; if (!info || !info.appid) continue; const appid = String(info.appid); // 搜索过滤 if (boosterSearchKeyword && !(info.name || '').toLowerCase().includes(boosterSearchKeyword.toLowerCase())) continue; // 分类过滤 if (boosterFilterType === 'queue' && !boosterConfig.queue.includes(appid)) continue; if (boosterFilterType === 'favorites' && !boosterConfig.favorites.includes(appid)) continue; if (boosterFilterType === 'blacklist' && !boosterConfig.blacklist.includes(appid)) continue; if (boosterFilterType === 'available' && getGameStatus(appid) !== 'available') continue; if (boosterFilterType === 'all' && boosterConfig.blacklist.includes(appid)) continue; games.push({ appid, name: info.name, series: info.series, price: info.price, status: getGameStatus(appid) }); } // 排序 games.sort((a, b) => { let va, vb; switch (boosterSortKey) { case 'name': va = (a.name || '').toLowerCase(); vb = (b.name || '').toLowerCase(); break; case 'gems': va = parseInt(a.price, 10) || 0; vb = parseInt(b.price, 10) || 0; break; case 'profit': { const pa = calcBoosterProfit(a.appid); const pb = calcBoosterProfit(b.appid); va = pa ? pa.profit : -Infinity; vb = pb ? pb.profit : -Infinity; break; } case 'cardPrice': { va = (boosterPriceData[a.appid] && boosterPriceData[a.appid].cardPrice) || 0; vb = (boosterPriceData[b.appid] && boosterPriceData[b.appid].cardPrice) || 0; break; } case 'status': va = a.status; vb = b.status; break; default: va = a.name; vb = b.name; } if (typeof va === 'string') { return boosterSortDir === 'asc' ? va.localeCompare(vb) : vb.localeCompare(va); } return boosterSortDir === 'asc' ? va - vb : vb - va; }); // 分页 const totalPages = Math.max(1, Math.ceil(games.length / boosterPageSize)); if (boosterPageNum > totalPages) boosterPageNum = totalPages; if (boosterPageNum < 1) boosterPageNum = 1; const startIdx = (boosterPageNum - 1) * boosterPageSize; const pageGames = games.slice(startIdx, startIdx + boosterPageSize); // 构建表格 let tableHtml = ''; if (games.length === 0) { tableHtml = '
没有匹配的游戏
'; } else { const sortArrow = (key) => boosterSortKey === key ? (boosterSortDir === 'asc' ? ' ▲' : ' ▼') : ''; tableHtml = ``; tableHtml += ``; tableHtml += ``; tableHtml += ``; tableHtml += ``; tableHtml += ``; tableHtml += ``; tableHtml += ``; tableHtml += ``; tableHtml += ``; for (const g of pageGames) { const isDone = g.status === 'done'; const isAvailable = g.status === 'available'; const inQueue = boosterConfig.queue.includes(g.appid); const inFav = boosterConfig.favorites.includes(g.appid); const inBlack = boosterConfig.blacklist.includes(g.appid); const profit = calcBoosterProfit(g.appid); const pd = boosterPriceData[g.appid] || {}; const gemsCost = parseInt(g.price, 10) || 0; let statusHtml = ''; if (isDone) statusHtml = '已完成'; else if (isAvailable) statusHtml = '可制作'; else statusHtml = '冷却中'; let profitHtml = '--'; if (profit) { const cls = profit.profit > 0 ? 'sme-booster-profit-positive' : (profit.profit < 0 ? 'sme-booster-profit-negative' : 'sme-booster-profit-neutral'); const sign = profit.profit > 0 ? '+' : ''; profitHtml = `${sign}${formatBoosterPrice(profit.profit)}`; } let cardPriceHtml = '点击查询'; if (pd.cardPrice) { cardPriceHtml = formatBoosterPrice(pd.cardPrice); if (pd.boosterBuyOrder) { cardPriceHtml += `
买:${formatBoosterPrice(pd.boosterBuyOrder)}`; } } else if (pd.hadCard === false) { cardPriceHtml = '不可交易'; } // SVG 图标(使用 ICONS 对象) const _qIcon = inQueue ? ICONS.queueActive : ICONS.queue; const _favIcon = inFav ? ICONS.starFilled : ICONS.starOutline; const _banIcon = inBlack ? ICONS.banActive : ICONS.ban; const _pkgIcon = ICONS.package; const _cardIcon = ICONS.card; tableHtml += ``; // 封面 tableHtml += ``; // 游戏名称 tableHtml += ``; tableHtml += ``; tableHtml += ``; tableHtml += ``; tableHtml += ``; // 操作(纯图标按钮,固定尺寸) tableHtml += ``; // 链接 tableHtml += ``; tableHtml += ``; } tableHtml += `
封面游戏名称${sortArrow('name')}状态${sortArrow('status')}宝石成本${sortArrow('gems')}卡牌均价${sortArrow('cardPrice')}利润${sortArrow('profit')}操作链接
${escapeHtml(g.name)}
${escapeHtml(g.name)}${statusHtml}${gemsCost.toLocaleString()}${cardPriceHtml}${profitHtml}
`; tableHtml += ``; tableHtml += ``; tableHtml += ``; tableHtml += `
`; } // 分页控件 let paginationHtml = ''; if (games.length > boosterPageSize) { paginationHtml = `
`; paginationHtml += ``; paginationHtml += `${boosterPageNum} / ${totalPages}`; paginationHtml += ``; paginationHtml += `
`; } // 统计信息 let availableCount = 0; let queueAvailableCount = 0; let totalGemsCost = 0; let totalProfit = 0; let profitCount = 0; for (const key in boosterGameInfo) { const appid = String(boosterGameInfo[key].appid); if (getGameStatus(appid) === 'available') { availableCount++; if (boosterConfig.queue.includes(appid)) { queueAvailableCount++; totalGemsCost += parseInt(boosterGameInfo[key].price, 10) || 0; const p = calcBoosterProfit(appid); if (p) { totalProfit += p.profit; profitCount++; } } } } const sackPriceDisplay = boosterCustomSackPrice > 0 ? boosterCustomSackPrice : boosterGemsSackPrice; let infoBarHtml = `
`; infoBarHtml += `宝石余额: ${boosterGemsDisplay.toLocaleString()}`; infoBarHtml += `一袋宝珠: ${formatBoosterPrice(sackPriceDisplay)}`; infoBarHtml += `可制作: ${availableCount}`; infoBarHtml += `队列可做: ${queueAvailableCount}`; if (queueAvailableCount > 0) { infoBarHtml += `队列宝石消耗: ${totalGemsCost.toLocaleString()}`; } if (profitCount > 0) { const cls = totalProfit > 0 ? 'sme-booster-profit-positive' : 'sme-booster-profit-negative'; infoBarHtml += `队列预期利润: ${totalProfit > 0 ? '+' : ''}${formatBoosterPrice(totalProfit)}`; } infoBarHtml += `
`; container.innerHTML = tableHtml + paginationHtml + infoBarHtml; // 封面图降级链:error→尝试下一个CDN,load→缓存成功URL container.querySelectorAll('img.sme-booster-cover').forEach(img => { img.addEventListener('error', () => handleCoverError(img)); img.addEventListener('load', () => handleCoverLoad(img)); }); // 绑定表头排序事件 container.querySelectorAll('th[data-sort]').forEach(th => { th.addEventListener('click', () => { const key = th.dataset.sort; if (boosterSortKey === key) { boosterSortDir = boosterSortDir === 'asc' ? 'desc' : 'asc'; } else { boosterSortKey = key; boosterSortDir = 'asc'; } renderBoosterPanel(); }); }); // 绑定分页事件 container.querySelectorAll('.sme-booster-pagination button[data-page]').forEach(btn => { btn.addEventListener('click', () => { if (btn.dataset.page === 'prev') boosterPageNum--; else boosterPageNum++; renderBoosterPanel(); }); }); // 绑定操作按钮事件 container.querySelectorAll('.sme-booster-action-btn').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); const row = btn.closest('tr'); if (!row) return; const appid = row.dataset.appid; const action = btn.dataset.action; handleBoosterAction(appid, action); }); }); // 异步获取英文名称并更新补充包链接(避免中文名导致链接失效) updateBoosterLinksAsync(pageGames); } /** 异步获取当前页游戏的英文名称,更新补充包链接 href */ async function updateBoosterLinksAsync(pageGames) { if (!pageGames || pageGames.length === 0) return; // 筛选需要获取英文名称的游戏 const needFetch = pageGames.filter(g => !boosterEnglishNameCache[String(g.appid)]); if (needFetch.length > 0) { // 并发获取(限制并发数 3),避免触发 Steam API 速率限制 const CONCURRENCY = 3; for (let i = 0; i < needFetch.length; i += CONCURRENCY) { const batch = needFetch.slice(i, i + CONCURRENCY); await Promise.all(batch.map(g => fetchEnglishName(g.appid))); if (i + CONCURRENCY < needFetch.length) await sleep(300); } // 获取完成后更新当前页面中所有补充包链接的 href if (boosterPanelEl) { for (const g of pageGames) { const row = boosterPanelEl.querySelector(`tr[data-appid="${g.appid}"]`); if (row) { const link = row.querySelector('.sme-booster-link'); if (link) link.href = buildBoosterMarketUrl(g.appid, g.name); } } } } } /** 处理游戏操作 */ function handleBoosterAction(appid, action) { appid = String(appid); const idxQueue = boosterConfig.queue.indexOf(appid); const idxFav = boosterConfig.favorites.indexOf(appid); const idxBlack = boosterConfig.blacklist.indexOf(appid); if (action === 'queue') { if (idxQueue > -1) boosterConfig.queue.splice(idxQueue, 1); else { boosterConfig.queue.push(appid); if (idxBlack > -1) boosterConfig.blacklist.splice(idxBlack, 1); } } else if (action === 'fav') { if (idxFav > -1) boosterConfig.favorites.splice(idxFav, 1); else { boosterConfig.favorites.push(appid); if (idxBlack > -1) boosterConfig.blacklist.splice(idxBlack, 1); } } else if (action === 'black') { if (idxBlack > -1) boosterConfig.blacklist.splice(idxBlack, 1); else { boosterConfig.blacklist.push(appid); if (idxQueue > -1) boosterConfig.queue.splice(idxQueue, 1); if (idxFav > -1) boosterConfig.favorites.splice(idxFav, 1); } } saveBoosterConfig(); renderBoosterPanel(); } /** 自动查询卡牌价格 */ async function autoQueryCardPrices() { if (boosterQueryRunning) { boosterToast('查询正在进行中', 'warning'); return; } boosterQueryRunning = true; // 获取当前过滤后的游戏列表 const games = []; for (const key in boosterGameInfo) { const info = boosterGameInfo[key]; if (!info || !info.appid) continue; const appid = String(info.appid); if (boosterSearchKeyword && !(info.name || '').toLowerCase().includes(boosterSearchKeyword.toLowerCase())) continue; if (boosterFilterType === 'queue' && !boosterConfig.queue.includes(appid)) continue; if (boosterFilterType === 'favorites' && !boosterConfig.favorites.includes(appid)) continue; if (boosterFilterType === 'blacklist' && !boosterConfig.blacklist.includes(appid)) continue; if (boosterFilterType === 'available' && getGameStatus(appid) !== 'available') continue; if (boosterFilterType === 'all' && boosterConfig.blacklist.includes(appid)) continue; // 跳过已查询的 if (boosterPriceData[appid] && boosterPriceData[appid].hadCard) continue; games.push({ appid, name: info.name }); } if (games.length === 0) { boosterToast('没有需要查询的游戏', 'info'); boosterQueryRunning = false; return; } boosterToast(`开始查询 ${games.length} 个游戏的卡牌价格...`, 'info'); for (let i = 0; i < games.length; i++) { if (!boosterQueryRunning) { boosterToast('查询已停止', 'warning'); break; } const g = games[i]; try { const result = await fetchCardPrice(g.appid); if (!boosterPriceData[g.appid]) boosterPriceData[g.appid] = {}; if (result) { boosterPriceData[g.appid].cardPrice = result.cardPrice; boosterPriceData[g.appid].cardCount = result.cardCount; boosterPriceData[g.appid].hadCard = true; } else { boosterPriceData[g.appid].hadCard = false; } // 同时查询补充包价格 if (boosterConfig.autoQueryBooster) { const boosterResult = await fetchBoosterMarketPrice(g.appid, g.name); if (boosterResult) { boosterPriceData[g.appid].boosterBuyOrder = boosterResult.buyOrder; boosterPriceData[g.appid].boosterSellOrder = boosterResult.sellOrder; boosterPriceData[g.appid].hadBooster = true; } } saveBoosterPriceCache(); renderBoosterPanel(); boosterToast(`[${i + 1}/${games.length}] ${g.name}`, 'info'); } catch (e) { W(`Query failed for ${g.appid}:`, e.message); } } boosterQueryRunning = false; boosterToast('卡牌价格查询完成', 'success'); renderBoosterPanel(); } /** 一键制作队列中的补充包 */ async function batchCreateBoosters() { if (boosterCreateRunning) { boosterToast('制作正在进行中', 'warning'); return; } // 获取队列中可制作的游戏 const available = []; for (const appid of boosterConfig.queue) { if (getGameStatus(appid) === 'available') { const info = boosterGameInfo[appid]; if (info) available.push({ appid: String(appid), name: info.name, series: info.series, price: info.price }); } } if (available.length === 0) { boosterToast('队列中没有可制作的补充包', 'warning'); return; } // 确认对话框 const totalGems = available.reduce((s, g) => s + (parseInt(g.price, 10) || 0), 0); const confirmed = await showConfirmDialog( `确定要制作 ${available.length} 个补充包吗?
消耗 ${totalGems.toLocaleString()} 颗宝石`, { title: '一键制作补充包', okText: '确认制作', okClass: 'sme-confirm-blue' } ); if (!confirmed) return; boosterCreateRunning = true; const btn = boosterPanelEl.querySelector('#sme-booster-batch-btn'); if (btn) { btn.disabled = true; btn.textContent = '制作中...'; } const progressBar = boosterPanelEl.querySelector('.sme-booster-progress'); const progressBarBar = boosterPanelEl.querySelector('.sme-booster-progress-bar'); if (progressBar) progressBar.classList.add('sme-visible'); let success = 0; let failed = 0; for (let i = 0; i < available.length; i++) { const g = available[i]; if (progressBarBar) progressBarBar.style.width = `${((i) / available.length) * 100}%`; try { await createBoosterPack(g.appid, g.series); success++; boosterDoneList.push(g.appid); boosterToast(`[${i + 1}/${available.length}] ${g.name} 制作成功`, 'success'); L(`Booster created: ${g.appid} (${g.name})`); } catch (e) { failed++; boosterToast(`[${i + 1}/${available.length}] ${g.name} 失败: ${e.message}`, 'error'); W(`Booster create failed: ${g.appid}`, e.message); if (e.message.includes('宝石') || e.message.includes('gem')) break; } // 间隔1.5秒避免限流 if (i < available.length - 1) await new Promise(r => setTimeout(r, 1500)); } if (progressBarBar) progressBarBar.style.width = '100%'; setTimeout(() => { if (progressBar) progressBar.classList.remove('sme-visible'); }, 2000); boosterCreateRunning = false; if (btn) { btn.disabled = false; btn.textContent = '一键制作'; } // 更新宝石显示 const newGems = getBoosterGems(); if (newGems > 0) boosterGemsDisplay = newGems; boosterToast(`制作完成: 成功 ${success}, 失败 ${failed}`, success > 0 ? 'success' : 'error'); renderBoosterPanel(); } /** 初始化补充包制作器页面 */ function initBoosterPage() { L('Initializing booster creator page'); loadBoosterConfig(); loadBoosterPriceCache(); loadBoosterEnglishNameCache(); // 等待页面加载 const tryInit = (attempt) => { const boosterArea = document.querySelector('.booster_creator_area'); if (!boosterArea) { if (attempt < 30) setTimeout(() => tryInit(attempt + 1), 500); return; } // 获取游戏数据 try { boosterGameInfo = unsafeWindow.CBoosterCreatorPage?.sm_rgBoosterData; } catch (e) {} if (!boosterGameInfo) { if (attempt < 30) { W('Booster data not ready, retrying...', attempt); setTimeout(() => tryInit(attempt + 1), 500); return; } L('Booster data not found after timeout'); } // 获取当前宝石数量 boosterGemsDisplay = getBoosterGems(); // 创建面板 boosterPanelEl = h('div', { class: 'sme-booster-panel' }); // 头部 const header = h('div', { class: 'sme-booster-header' }); header.appendChild(h('div', { class: 'sme-booster-title' }, [ h('span', { html: ICONS.package, style: { display: 'inline-flex', alignItems: 'center' } }), '补充包制作器增强', ])); header.appendChild(h('div', { class: 'sme-booster-gems', html: `${ICONS.gem} ${boosterGemsDisplay.toLocaleString()} 宝石` })); // 操作说明提示条 const hints = h('div', { class: 'sme-booster-hints' }); const _hintSvg = (svg) => svg.replace('class="sme-svg"', ''); const hintItems = [ { icon: _hintSvg(ICONS.queue), bg: 'var(--sme-green-a20)', color: 'var(--sme-green)', text: '队列:加入制作队列,一键制作时批量执行' }, { icon: _hintSvg(ICONS.starFilled), bg: 'var(--sme-amber-a20)', color: 'var(--sme-amber)', text: '收藏:标记常玩游戏,方便快速筛选' }, { icon: _hintSvg(ICONS.ban), bg: 'var(--sme-red-a20)', color: 'var(--sme-red)', text: '拉黑:隐藏不感兴趣的游戏' }, ]; hints.innerHTML = hintItems.map(hint => `${hint.icon}${hint.text}` ).join(''); const _infoIcon = ''; hints.innerHTML += `${_infoIcon} 先查询卡牌均价查看利润,再一键制作队列中的游戏`; // 工具栏 const toolbar = h('div', { class: 'sme-booster-toolbar' }); // --- 组1: 搜索 + 过滤 --- const grp1 = h('div', { class: 'sme-booster-tb-group' }); // 搜索框 const searchInput = h('input', { type: 'text', class: 'sme-booster-search', placeholder: '搜索游戏名称…', value: boosterSearchKeyword, 'aria-label': '搜索游戏名称', }); searchInput.addEventListener('input', () => { boosterSearchKeyword = searchInput.value; boosterPageNum = 1; renderBoosterPanel(); }); grp1.appendChild(searchInput); // 过滤下拉 const filterSelect = h('select', { class: 'sme-booster-select', 'aria-label': '过滤游戏' }); const filterOptions = [ { value: 'all', label: '全部' }, { value: 'queue', label: '队列' }, { value: 'favorites', label: '收藏' }, { value: 'available', label: '可制作' }, { value: 'blacklist', label: '黑名单' }, ]; for (const opt of filterOptions) { const o = h('option', { value: opt.value }, opt.label); if (boosterFilterType === opt.value) o.setAttribute('selected', ''); filterSelect.appendChild(o); } filterSelect.addEventListener('change', () => { boosterFilterType = filterSelect.value; boosterPageNum = 1; renderBoosterPanel(); }); grp1.appendChild(filterSelect); toolbar.appendChild(grp1); // 分隔线 toolbar.appendChild(h('div', { class: 'sme-booster-tb-sep' })); // --- 组2: 每页 + 宝珠价 --- const grp2 = h('div', { class: 'sme-booster-tb-group' }); // 每页数量 grp2.appendChild(h('span', { style: { fontSize: '12px', color: 'var(--sme-text-dim)' } }, '每页:')); const pageSizeInput = h('input', { type: 'number', class: 'sme-booster-search', style: { width: '55px', textAlign: 'center', paddingLeft: '5px', backgroundImage: 'none' }, value: String(boosterPageSize), min: '5', max: '100', title: '每页显示数量', 'aria-label': '每页显示数量', }); pageSizeInput.addEventListener('change', () => { const n = parseInt(pageSizeInput.value, 10); if (n >= 5 && n <= 100) { boosterPageSize = n; boosterConfig.pageSize = n; saveBoosterConfig(); boosterPageNum = 1; renderBoosterPanel(); } }); grp2.appendChild(pageSizeInput); // 自定义宝珠价格 grp2.appendChild(h('span', { style: { fontSize: '12px', color: 'var(--sme-text-dim)' } }, '宝珠价:')); const customPriceInput = h('input', { type: 'number', class: 'sme-booster-search', style: { width: '65px', textAlign: 'center', paddingLeft: '5px', backgroundImage: 'none' }, value: boosterCustomSackPrice > 0 ? String((boosterCustomSackPrice / 100).toFixed(2)) : '', placeholder: '自动', step: '0.01', title: '自定义一袋宝珠成本价(留空使用市场价)', 'aria-label': '自定义一袋宝珠价格', }); customPriceInput.addEventListener('change', () => { const v = parseFloat(customPriceInput.value); if (!isNaN(v) && v > 0) { boosterCustomSackPrice = Math.round(v * 100); boosterConfig.customSackPrice = boosterCustomSackPrice; } else { boosterCustomSackPrice = 0; boosterConfig.customSackPrice = 0; } saveBoosterConfig(); renderBoosterPanel(); }); grp2.appendChild(customPriceInput); toolbar.appendChild(grp2); // 分隔线 toolbar.appendChild(h('div', { class: 'sme-booster-tb-sep' })); // --- 组3: 查询 --- const grp3 = h('div', { class: 'sme-booster-tb-group' }); // 查询按钮 const queryBtn = h('button', { class: 'sme-booster-btn sme-booster-btn-amber', 'aria-label': '查询卡牌均价' }); queryBtn.innerHTML = ICONS.search + '查询卡牌均价'; queryBtn.addEventListener('click', () => { if (boosterQueryRunning) { boosterQueryRunning = false; queryBtn.innerHTML = ICONS.search + '查询卡牌均价'; } else { queryBtn.innerHTML = '停止查询'; autoQueryCardPrices().then(() => { queryBtn.innerHTML = ICONS.search + '查询卡牌均价'; }); } }); grp3.appendChild(queryBtn); // 查询补充包复选框 const autoBoosterLabel = h('label', { style: { display: 'flex', alignItems: 'center', gap: '4px', fontSize: '12px', color: 'var(--sme-text-dim)', cursor: 'pointer' } }); const autoBoosterCheckbox = h('input', { type: 'checkbox', 'aria-label': '同时查询补充包价格' }); if (boosterConfig.autoQueryBooster) autoBoosterCheckbox.setAttribute('checked', ''); autoBoosterCheckbox.addEventListener('change', () => { boosterConfig.autoQueryBooster = autoBoosterCheckbox.checked; saveBoosterConfig(); }); autoBoosterLabel.appendChild(autoBoosterCheckbox); autoBoosterLabel.appendChild(document.createTextNode('同时查补充包')); grp3.appendChild(autoBoosterLabel); toolbar.appendChild(grp3); // 分隔线 toolbar.appendChild(h('div', { class: 'sme-booster-tb-sep' })); // --- 组4: 制作 + 清缓存 --- const grp4 = h('div', { class: 'sme-booster-tb-group' }); // 一键制作按钮(主要操作) const batchBtn = h('button', { class: 'sme-booster-btn sme-booster-btn-green', id: 'sme-booster-batch-btn', 'aria-label': '一键制作队列中的补充包' }); batchBtn.innerHTML = ICONS.zap + '一键制作'; batchBtn.addEventListener('click', batchCreateBoosters); grp4.appendChild(batchBtn); // 清空缓存按钮(危险操作,小号字体) const clearCacheBtn = h('button', { class: 'sme-booster-btn sme-booster-btn-red', 'aria-label': '清空所有价格缓存' }, '清空缓存'); clearCacheBtn.addEventListener('click', async () => { if (await showConfirmDialog('确定清空所有卡牌价格缓存?', { title: '清空缓存', okText: '清空', okClass: 'sme-dialog-btn sme-dialog-ok' })) { boosterPriceData = {}; saveBoosterPriceCache(); renderBoosterPanel(); boosterToast('价格缓存已清空', 'success'); } }); grp4.appendChild(clearCacheBtn); toolbar.appendChild(grp4); // 内容区域 const content = h('div', { class: 'sme-booster-content' }); // 进度条 const progress = h('div', { class: 'sme-booster-progress' }, [ h('div', { class: 'sme-booster-progress-bar' }), ]); boosterPanelEl.appendChild(header); boosterPanelEl.appendChild(hints); boosterPanelEl.appendChild(toolbar); boosterPanelEl.appendChild(content); boosterPanelEl.appendChild(progress); // 插入到页面 if (boosterArea && boosterArea.parentNode) { boosterArea.parentNode.insertBefore(boosterPanelEl, boosterArea.nextSibling); } else { const mainContent = document.querySelector('.maincontent'); if (mainContent) mainContent.appendChild(boosterPanelEl); } // 渲染初始表格 renderBoosterPanel(); // 异步获取宝石市场价格 fetchGemsSackPrice().then(price => { boosterGemsSackPrice = price; L('Gems sack price:', price); renderBoosterPanel(); }).catch(e => W('Failed to get gems sack price:', e.message)); }; tryInit(0); } /** 清理补充包页面 */ function cleanupBoosterPage() { if (boosterPanelEl) { boosterPanelEl.remove(); boosterPanelEl = null; } boosterQueryRunning = false; boosterCreateRunning = false; boosterDoneList = []; boosterGameInfo = null; boosterPriceData = {}; boosterEnglishNameCache = {}; _boosterEnNameFetching.clear(); } // ==================== 启动(等待 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) || /^\/tradingcards\/boostercreator/.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); } // v1.5.0:显示更多价格——必须在 document-start、页面 react-dom 初始化前注入假 DevTools hook, // 因此这里随脚本执行同步调用(不能放进 init:init 要等 DOM ready,届时 react-dom 早已初始化) if (settings.get('showMorePrices') && /^\/market\/listings\/\d+\/.+/.test(location.pathname)) { installShowMoreHook(); } if (document.readyState === 'interactive' || document.readyState === 'complete') { onDOMReady(); } else { document.addEventListener('DOMContentLoaded', onDOMReady); } })();