// ==UserScript== // @name Steam 社区市场增强 // @name:en Steam Community Market Enhancer // @namespace steam-market-enhancer // @version 2.2.5 // @description Steam 社区市场交易助手:上架汇总、超价/低价检测、一键改价重挂、批量下架与我的挂单市场排名;买卖盘全量档位订单深度(各变体最低价 + 税后到手价)、价格历史趋势、跨平台挂刀比价;库存价格标签、快速出售、批量自主定价与交易冷却角标;成交记录分析与导出(CSV/TSV/JSON/Markdown)、全库存浮盈浮亏、我的池子行情盘、补充包制作利润计算、汇率报价参考;交易报价价值摘要与价格提醒。适合每天改价挂单的卖家、批量清库存的玩家、盯价格趋势与盈亏的收藏者,以及在 Steam 充值或转售前想先看清真实到手价的人——挂单、定价、看盘、清货、复盘,一套脚本走完。 // @description:en Steam Community Market trading assistant: listing summary, overpriced/underpriced detection, one-click relist, batch delisting and market rank of my listings; full-depth order book (per-variant lowest price and net-after-fees), price history, cross-platform price comparison; inventory price labels, quick sell, bulk custom pricing and trade-cooldown badges; transaction history analysis and export (CSV/TSV/JSON/Markdown), portfolio profit and loss, my-pool market dashboard, booster pack profit calculator, FX quote reference; trade offer value summary and price alerts. Built for sellers who relist daily, players clearing inventory in bulk, collectors tracking trends and P&L, and anyone who wants the real take-home figure before topping up or selling elsewhere - list, price, watch, clear and review, all in one script. // @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 // @connect buff.163.com // @connect api.youpin898.com // @connect api.augmentedsteam.com // @connect api.frankfurter.app // @connect api.frankfurter.dev // @connect open.er-api.com // @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.2.1:'use strict' 必须是函数体的**第一条**语句才是指令序言。 // 原实现把它放在 registerPlugin 的 try 块之后 → 只是个无副作用的字符串表达式, // 1.8 万行主体实际一直跑在**非严格模式**(内联的 3 个 lib 自带 'use strict',不受影响)。 // 挪到开头后可能暴露隐式全局变量 / 给只读属性赋值等问题 → 已配静态守卫 // (test/test-strict-mode-static.js)盯住这两类。 'use strict'; // v2.2.3 插件版本清单注册(共享宿主 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 */ } const VERSION = '2.2.5'; const PREFIX = 'sme'; console.log(`%c[Steam 社区市场增强] v${VERSION} 已启动`, 'color:#66c0f4;font-weight:bold;font-size:13px'); /* ===== INLINE-BEGIN: shared/fx-core.lib.js ===== */ /* * fx-core.lib.js —— 双锚点汇率引擎(Steam 充值报价参考的取数地基) * =========================================================================== * canonical 位置:shared/fx-core.lib.js(本仓库) * 消费方式:① Node/测试:require('./fx-core.lib.js') * ② 油猴脚本:原样内联(见 steam-price-converter 的 inline 标记块) * * 为什么不是「一个接口一张表」: * 充值报价评估需要两个口径分开的锚点 —— * steam:Steam 参考结算价(AugmentedSteam 交叉表,跟随 Steam 建议汇率) * spot :即期市场中间价(Frankfurter=ECB → jsDelivr currency-api → er-api) * 两个锚点各自独立取数、独立缓存、独立标注来源与日期;一个挂了不污染另一个。 * * 血的教训(2026-11-05 实测): * open.er-api.com 免费档会返回 result:"success" + HTTP 200,但 time_last_update * 停在 2022-05-09。只看「有没有 rates 字段」就会把 4 年前的牌价当实时汇率。 * → 所有源必须做 payload 级新鲜度校验,陈旧即弃并记录 stale 来源。 * * 表的形状(冻结语义,方向不许含糊): * USD 枢纽表 rates:rates[CODE] = 「1 USD 能换多少 CODE」,rates.USD === 1。 * CODE→CNY 的单价 = rates.CNY / rates.CODE(convert 内部走 USD 枢纽)。 * * 缓存键: * fxcore_spot_v1 / fxcore_steam_v1 带 1h TTL 的锚点缓存 * fxcore_snap_v1 无 TTL 快照 {spot,steam,ts},接口全挂时兜底 * 只读救援键(不写):swe_fx_cache / swe_fx_snap_v1 / spc_fx_snap_v1 / spc_fx_v1 * —— 与愿望单侧边栏、个人日历两个插件的既有 FX 链互救,快照时间如实保留。 * * 宽表模式(2026-11-06 新增,opt-in,默认关): * create({ wideTable: true }) → spot 锚把多个源按优先级**逐币种合并**, * 而不是「取第一个成功的源」。原因:ECB(frankfurter) 只有 ~30 个币种, * **RUB/TRY/UAH/VND/PHP/NOK/SEK/PLN 全都没有**(ECB 2022 年后不公布卢布), * 只取第一个成功的源 → 这些币种一个都拿不到。 * 合并后 rateDate 取各源里最旧的那个(保守),source 标记 merged:<源列表>。 * 默认关 → 其余三个插件(个人日历 / 价格换算 / 市场增强的老调用方)行为完全不变。 * =========================================================================== */ (function () { 'use strict'; var BASE = 'USD'; var TTL_MS = 60 * 60 * 1000; // 锚点缓存 1 小时 var REQ_TIMEOUT = 6000; // 单请求超时 var ANCHOR_BUDGET = 7000; // 单锚点所有源合计预算 var SPOT_MAX_AGE_MS = 5 * 24 * 3600 * 1000; // 即期牌价最多容忍 5 天(ECB 周末/长假不更新) var ERAPI_MAX_AGE_MS = 72 * 3600 * 1000; var K_SPOT = 'fxcore_spot_v1'; var K_STEAM = 'fxcore_steam_v1'; var K_SNAP = 'fxcore_snap_v1'; // 他脚本的缓存/快照:只读,用于接口全挂时的最后一层救援 var RESCUE_KEYS = ['swe_fx_cache', 'swe_fx_snap_v1', 'spc_fx_snap_v1', 'spc_fx_v1']; function isValidTable(rates) { return !!rates && typeof rates === 'object' && Number(rates[BASE]) > 0; } /** * 交叉表 {USD:{CNY:6.67}, INR:{CNY:0.070}, ...}(语义:1 单位 X = ? CNY) * 折成 USD 枢纽表。真实返回里没有查询币种自己,必须补 rates[quote]。 * 缺 USD 基准 → null(不硬凑一张比例错的表)。 */ function ratesFromCross(data, quoteId) { if (!data || typeof data !== 'object') return null; var quote = quoteId || 'CNY'; var baseRow = data[BASE]; var usdToQuote = baseRow ? Number(baseRow[quote]) : 0; if (!usdToQuote || usdToQuote <= 0) return null; var out = {}; Object.keys(data).forEach(function (id) { var row = data[id]; var perQuote = row ? Number(row[quote]) : 0; if (!perQuote || perQuote <= 0) return; out[id] = usdToQuote / perQuote; }); if (!out[quote]) out[quote] = usdToQuote; return Object.keys(out).length ? out : null; } /** 'YYYY-MM-DD'(UTC)→ ms;非法返回 0 */ function dateOnlyToMs(s) { if (typeof s !== 'string') return 0; var m = s.match(/^(\d{4})-(\d{2})-(\d{2})$/); if (!m) return 0; return Date.UTC(+m[1], +m[2] - 1, +m[3]); } function ageOk(dateMs, maxAgeMs, now) { return dateMs > 0 && (now - dateMs) <= maxAgeMs && dateMs <= now + 24 * 3600 * 1000; } /** 小写键表 → 大写键表(currency-api 的 usd 对象键是小写) */ function lowerTableToUpper(lower) { var out = {}; Object.keys(lower).forEach(function (k) { var v = Number(lower[k]); if (Number.isFinite(v) && v > 0) out[k.toUpperCase()] = v; }); out[BASE] = 1; return out; } // ---- 源定义:parse(data, ctx) 返回 {rates, dateMs} | {stale:true, dateMs} | null ---- var STEAM_SOURCES = [ { id: 'augmented', label: 'api.augmentedsteam.com', url: 'https://api.augmentedsteam.com/rates/v1?to=CNY', parse: function (data) { var rates = ratesFromCross(data, 'CNY'); if (!rates) return null; // AS 交叉表不带日期,dateMs 留空(UI 上显示「接口未提供日期」) return { rates: rates, dateMs: 0 }; }, }, ]; var SPOT_SOURCES = [ { id: 'frankfurter', label: 'frankfurter.dev (ECB)', // 2026-11-06 实测:api.frankfurter.app 已 301 迁移到 api.frankfurter.dev/v1/latest // (路径也多了一层 /v1)。直接用新域名,不再靠重定向活着。 url: 'https://api.frankfurter.dev/v1/latest?from=USD', parse: function (data, ctx) { var r = data && data.rates; if (!r) return null; var rates = Object.assign({}, r); rates[BASE] = 1; if (!isValidTable(rates)) return null; var d = dateOnlyToMs(data.date); if (!ageOk(d, SPOT_MAX_AGE_MS, ctx.now)) { return d > 0 ? { stale: true, dateMs: d } : null; } return { rates: rates, dateMs: d }; }, }, { id: 'frankfurter-legacy', label: 'frankfurter.app (ECB, 旧域名)', url: 'https://api.frankfurter.app/latest?from=USD', parse: function (data, ctx) { var r = data && data.rates; if (!r) return null; var rates = Object.assign({}, r); rates[BASE] = 1; if (!isValidTable(rates)) return null; var d = dateOnlyToMs(data.date); if (!ageOk(d, SPOT_MAX_AGE_MS, ctx.now)) { return d > 0 ? { stale: true, dateMs: d } : null; } return { rates: rates, dateMs: d }; }, }, { id: 'jsdelivr', label: 'cdn.jsdelivr.net currency-api', url: 'https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/usd.json', parse: function (data, ctx) { var u = data && data.usd; if (!u) return null; var d = dateOnlyToMs(data.date); if (!ageOk(d, SPOT_MAX_AGE_MS, ctx.now)) { return d > 0 ? { stale: true, dateMs: d } : null; } var rates = lowerTableToUpper(u); return isValidTable(rates) ? { rates: rates, dateMs: d } : null; }, }, { id: 'erapi', label: 'open.er-api.com', url: 'https://open.er-api.com/v6/latest/USD', parse: function (data, ctx) { var rates = data && data.rates; if (!rates) return null; var d = Number(data.time_last_update_unix) * 1000; // 免费档实测会返回 2022 年的陈旧数据却自称 success —— 这里是专门的闸门 if (!ageOk(d, ERAPI_MAX_AGE_MS, ctx.now)) { return d > 0 ? { stale: true, dateMs: d } : null; } return isValidTable(rates) ? { rates: rates, dateMs: d } : null; }, }, ]; function defaultDeps() { return { now: function () { return Date.now(); }, gmGet: function (k, d) { try { return (typeof GM_getValue === 'function') ? GM_getValue(k, d) : d; } catch (e) { return d; } }, gmSet: function (k, v) { try { if (typeof GM_setValue === 'function') GM_setValue(k, v); } catch (e) { /* ignore */ } }, fetchJson: function (url, opts) { return new Promise(function (resolve, reject) { if (typeof GM_xmlhttpRequest !== 'function') { reject(new Error('no GM_xmlhttpRequest')); return; } GM_xmlhttpRequest({ method: 'GET', url: url, timeout: (opts && opts.timeout) || REQ_TIMEOUT, onload: function (res) { if (res.status >= 200 && res.status < 300) { try { resolve(JSON.parse(res.responseText)); } catch (e) { reject(new Error('bad json: ' + url)); } } else reject(new Error('http ' + res.status)); }, onerror: function () { reject(new Error('network error: ' + url)); }, ontimeout: function () { reject(new Error('timeout: ' + url)); }, }); }); }, log: function () {}, // 宽表模式(默认关):spot 锚把多个源按优先级逐币种合并, // 用来补上 ECB 不公布的币种(RUB/TRY/UAH/VND/PHP/NOK/SEK/PLN…)。 // 开了它一次刷新会打多个源,且 rateDate 取各源里最旧的那个。 wideTable: false, }; } // ---- 无状态纯函数(实例与顶层 API 共用,方向语义冻结,见文件头) ---- /** amount 单位 fromCur → toCur,USD 枢纽:from→USD→to;缺项返回 null */ function convert(amount, fromCur, toCur, rates) { var v = Number(amount); if (!Number.isFinite(v)) return null; if (!fromCur || !toCur || fromCur === toCur) return v; if (!isValidTable(rates)) return null; var fr = Number(rates[fromCur]); var tr = Number(rates[toCur]); if (!(fr > 0) || !(tr > 0)) return null; return (v / fr) * tr; } /** * 手动汇率覆盖(购汇成本锚点用),不修改原表。 * per='usd':value = 「1 USD = value 个 currency」 * per='cur':value = 「1 currency = value USD」 * 非法值 → {ok:false},调用方必须回退,绝不拿错的数去算报价。 */ function applyManualRate(rates, manual) { if (!isValidTable(rates)) return { ok: false, reason: 'noTable' }; if (!manual) return { ok: false, reason: 'noManual' }; var currency = manual.currency; var value = Number(manual.value); if (!currency || !Number.isFinite(value) || value <= 0) return { ok: false, reason: 'badValue' }; var next = Object.assign({}, rates); next[currency] = manual.per === 'cur' ? 1 / value : value; return { ok: true, rates: next }; } /** 1 单位 code 合多少 CNY(方向冻结:CNY per unit);CNY/RMB→1;缺项 null */ function cnyPerUnit(code, rates) { if (!code) return null; code = String(code).toUpperCase(); if (code === 'CNY' || code === 'RMB') return 1; if (!isValidTable(rates)) return null; var cny = Number(rates.CNY); var v = Number(rates[code]); if (!(cny > 0) || !(v > 0)) return null; return cny / v; } function create(injected) { var deps = Object.assign(defaultDeps(), injected || {}); var wideTable = !!deps.wideTable; // mem:{ spot:entry|null, steam:entry|null, ts, warnings:[] } // entry = { rates, source, label, rateDate, ts, stale? } var mem = { spot: null, steam: null, ts: 0, warnings: [] }; var inflight = null; // 单飞的 in-flight Promise function readCache(key) { try { var c = deps.gmGet(key, null); if (c && isValidTable(c.rates) && c.ts && (deps.now() - c.ts) < TTL_MS) return c; } catch (e) { /* ignore */ } return null; } function writeCache(key, entry) { try { deps.gmSet(key, { rates: entry.rates, ts: entry.ts, source: entry.source, rateDate: entry.rateDate || 0 }); } catch (e) { /* ignore */ } } function readSnapshotBuckets() { var out = { spot: null, steam: null }; try { var snap = deps.gmGet(K_SNAP, null); if (snap) { if (snap.spot && isValidTable(snap.spot.rates)) out.spot = snap.spot; if (snap.steam && isValidTable(snap.steam.rates)) out.steam = snap.steam; } } catch (e) { /* ignore */ } return out; } function writeSnapshot(spotEntry, steamEntry) { var cur = { spot: null, steam: null, ts: deps.now() }; try { var old = deps.gmGet(K_SNAP, null); if (old) cur = old; } catch (e) { /* ignore */ } if (spotEntry) cur.spot = spotEntry; if (steamEntry) cur.steam = steamEntry; cur.ts = deps.now(); try { deps.gmSet(K_SNAP, cur); } catch (e) { /* ignore */ } } /** 救援:他脚本的通用 USD 表快照(来源无法区分锚点,标记 snapshot) */ function genericRescue(anchorId) { for (var i = 0; i < RESCUE_KEYS.length; i++) { try { var c = deps.gmGet(RESCUE_KEYS[i], null); if (c && isValidTable(c.rates)) { return { rates: c.rates, source: 'snapshot:' + RESCUE_KEYS[i], label: RESCUE_KEYS[i], rateDate: c.rateDate || 0, ts: c.ts || deps.now(), rescued: true, anchor: anchorId, }; } } catch (e) { /* next */ } } return null; } function fetchAnchor(anchorId, sources) { var warnings = []; var deadline = deps.now() + ANCHOR_BUDGET; function next(i) { if (i >= sources.length) return Promise.resolve(null); var src = sources[i]; var left = deadline - deps.now(); if (left <= 0) return Promise.resolve(null); return deps.fetchJson(src.url, { timeout: Math.min(REQ_TIMEOUT, left) }) .then(function (data) { var parsed; try { parsed = src.parse(data, { now: deps.now() }); } catch (e) { parsed = null; } if (parsed && parsed.stale) { warnings.push({ anchor: anchorId, source: src.id, reason: 'stale', dateMs: parsed.dateMs }); return next(i + 1); } if (parsed && parsed.rates) { return { rates: parsed.rates, source: src.id, label: src.label, rateDate: parsed.dateMs || 0, ts: deps.now(), }; } return next(i + 1); }) .catch(function () { return next(i + 1); }); } return next(0).then(function (entry) { return { entry: entry, warnings: warnings }; }); } /** * 宽表模式(opt-in,默认关):不取「第一个成功的源」,而是把**所有**源按优先级 * 逐币种合并 —— 先到的源优先,后来���源只补前面缺的币种。 * * 为什么需要:ECB(frankfurter) 只有 ~30 个币种,**RUB/TRY/UAH/VND/PHP/NOK/SEK/PLN * 全都没有**(ECB 2022 年后不公布卢布);而 currency-api 有 339 个、er-api 有 166 个。 * 只取第一个成功的源 → 这些币种全部拿不到汇率。 * * 语义变化(只影响开了 wideTable 的调用方): * · 一次刷新会打多个源(受 ANCHOR_BUDGET 约束,打不完就只用已到的那些) * · rateDate 取各源里**最旧**的那个(保守:合并表里最不鲜的那个币种决定新鲜度) * · source 标记为 merged:<源1>+<源2>,UI 上要如实显示是宽表 */ function fetchAnchorMerged(anchorId, sources) { var warnings = []; var deadline = deps.now() + ANCHOR_BUDGET; var merged = null; var primary = null; var usedSources = []; var oldestDate = 0; var seenDate = false; function step(i) { if (i >= sources.length) return Promise.resolve(null); var src = sources[i]; var left = deadline - deps.now(); if (left <= 0) return Promise.resolve(null); return deps.fetchJson(src.url, { timeout: Math.min(REQ_TIMEOUT, left) }) .then(function (data) { var parsed; try { parsed = src.parse(data, { now: deps.now() }); } catch (e) { parsed = null; } if (parsed && parsed.stale) { warnings.push({ anchor: anchorId, source: src.id, reason: 'stale', dateMs: parsed.dateMs }); return step(i + 1); } if (parsed && parsed.rates) { if (!merged) { merged = {}; primary = src; } var codes = Object.keys(parsed.rates); for (var k = 0; k < codes.length; k++) { if (merged[codes[k]] === undefined) merged[codes[k]] = parsed.rates[codes[k]]; } usedSources.push(src.id); var d = parsed.dateMs || 0; if (!seenDate || d < oldestDate) { oldestDate = d; seenDate = true; } } return step(i + 1); }) .catch(function () { return step(i + 1); }); } return step(0).then(function () { if (!merged || !Object.keys(merged).length) return { entry: null, warnings: warnings }; return { entry: { rates: merged, source: 'merged:' + usedSources.join('+'), label: (primary ? primary.label : 'merged') + ' + 宽表', rateDate: oldestDate, ts: deps.now(), merged: true, mergedSources: usedSources.slice(), }, warnings: warnings, }; }); } /** * 取双锚点汇率。force=true 无视 TTL 强刷(F3)。 * 返回 { spot, steam, ts, warnings };任一锚点可能为 null(unknown≠0,绝不伪造)。 */ function load(force) { var now = deps.now(); if (!force && mem.ts && (now - mem.ts) < TTL_MS && (mem.spot || mem.steam)) { return Promise.resolve(mem); } // 单飞:并发调用共享同一次刷新(多个页面同时要汇率时不会打出一串重复请求) if (!inflight) { inflight = doLoad(force).then(function (r) { inflight = null; return r; }, function (e) { inflight = null; throw e; }); } return inflight; } function doLoad(force) { var now = deps.now(); var warnings = []; var startSpot = null; var startSteam = null; if (!force) { var cs = readCache(K_SPOT); var ce = readCache(K_STEAM); if (cs) startSpot = cacheToEntry(cs, K_SPOT); if (ce) startSteam = cacheToEntry(ce, K_STEAM); } if (!force && startSpot && startSteam) { mem = { spot: startSpot, steam: startSteam, ts: now, warnings: [] }; return Promise.resolve(mem); } return Promise.all([ startSpot ? Promise.resolve({ entry: startSpot, warnings: [] }) : (wideTable ? fetchAnchorMerged('spot', SPOT_SOURCES) : fetchAnchor('spot', SPOT_SOURCES)), startSteam ? Promise.resolve({ entry: startSteam, warnings: [] }) : fetchAnchor('steam', STEAM_SOURCES), ]).then(function (results) { var spot = results[0].entry; var steam = results[1].entry; warnings = warnings.concat(results[0].warnings, results[1].warnings); // 网络成功 → 写锚点缓存 + 合并写快照 if (spot) writeCache(K_SPOT, spot); if (steam) writeCache(K_STEAM, steam); if (spot || steam) writeSnapshot(spot, steam); // 网络全挂的锚点:先找自己的无 TTL 快照,再找他脚本快照救援 var buckets = readSnapshotBuckets(); if (!spot) spot = buckets.spot || genericRescue('spot'); if (!steam) steam = buckets.steam || genericRescue('steam'); if (spot && spot.rescued) warnings.push({ anchor: 'spot', source: spot.source, reason: 'rescue' }); if (steam && steam.rescued) warnings.push({ anchor: 'steam', source: steam.source, reason: 'rescue' }); if (!spot) warnings.push({ anchor: 'spot', reason: 'unavailable' }); if (!steam) warnings.push({ anchor: 'steam', reason: 'unavailable' }); mem = { spot: spot, steam: steam, ts: now, warnings: warnings }; return mem; }); } function cacheToEntry(c, key) { return { rates: c.rates, source: c.source || 'cache', label: c.source || key, rateDate: c.rateDate || 0, ts: c.ts, cached: true, }; } return { BASE: BASE, load: load, convert: convert, applyManualRate: applyManualRate, cnyPerUnit: cnyPerUnit, ratesFromCross: ratesFromCross, isValidTable: isValidTable, wideTable: wideTable, getMem: function () { return mem; }, /** 同步取汇率(只读内存里的 spot 锚,不发请求);取不到返回 null(unknown≠0) */ getRate: function (fromCur, toCur) { var m = mem.spot; if (!m || !m.rates) return null; var v = convert(1, fromCur, toCur, m.rates); return (v === null || !Number.isFinite(v)) ? null : v; }, // 暴露给测试 _internals: { dateOnlyToMs: dateOnlyToMs, ageOk: ageOk, lowerTableToUpper: lowerTableToUpper, TTL_MS: TTL_MS }, }; } var api = { BASE: BASE, create: create, ratesFromCross: ratesFromCross, isValidTable: isValidTable, convert: convert, applyManualRate: applyManualRate, cnyPerUnit: cnyPerUnit, }; // UMD-lite:Node 测试走 require;油猴内联时挂 window 并由 var 声明直接可见 if (typeof module === 'object' && module.exports) module.exports = api; if (typeof window !== 'undefined') window.FxCore = api; if (typeof self !== 'undefined' && !self.FxCore) self.FxCore = api; })(); /* ===== INLINE-END: shared/fx-core.lib.js ===== */ /* ===== INLINE-BEGIN: shared/topup-quote.lib.js ===== */ /* * topup-quote.lib.js —— Steam 充值报价归一化 / 公平性分层 / 风险计算 / 群报价文本解析 * =========================================================================== * canonical 位置:shared/topup-quote.lib.js(本仓库) * 纯函数、零依赖(不 require fx-core,汇率方向语义在本库内自洽: * USD 枢纽表 rates[CODE]=「1 USD = ? CODE」,1 单位外币的 CNY 单价 = rates.CNY/rates.CODE)。 * * 报价口径(行规):「每 100 美金钱包余额收多少人民币」,不是实时汇率。 * r_impl = payCny / face —— 隐含的 CNY/单位钱包币 * ratio_anchor = r_impl / anchorRate —— 相对某锚点的比例(1=公平,<1=让利,>1=溢价) * 到账率风险(受限区): * eff(%) = payCny / (face × p) —— 按到账率 p 调整后的实际成本(CNY/单位) * p* = payCny / (face × anchorRate) —— 盈亏平衡到账率(实际到账低于它就比牌价贵) * * 风险色带(2026-11-05 校准,可在 evaluate 覆盖): * ratio ≥ 0.93 绿 正常让利(批量/代理 5~7% 折让可解释) * 0.85–0.93 黄 挂刀/折扣带(89 折挂刀=0.89 落在带内;Steam 市场手续费后成本线 ~0.85) * < 0.85 红 异常低价(低于挂刀成本线,只可能是低价区套利或黑卡,红信封号来源) * ≥ 1.00 灰 溢价 * 无汇率数据 灰蓝 unknown —— unknown≠0,缺锚点绝不硬算色带 * =========================================================================== */ (function () { 'use strict'; var DEFAULT_BANDS = { green: 0.93, yellow: 0.85 }; var BAND_INFO = { gray: { id: 'gray', text: '溢价', cls: 'tq-band-gray', hint: '报价不低于参考汇率,没有价格优势' }, green: { id: 'green', text: '正常让利', cls: 'tq-band-green', hint: '5~7% 内的折让,批量/代理渠道可解释' }, yellow: { id: 'yellow', text: '挂刀/折扣带', cls: 'tq-band-yellow', hint: '与市场余额(挂刀)成本线接近,注意渠道资质' }, red: { id: 'red', text: '异常低价·警惕黑卡', cls: 'tq-band-red', hint: '低于挂刀成本线,可能是黑卡扣款/钱包清零/红信封号' }, unknown: { id: 'unknown', text: '缺汇率', cls: 'tq-band-unknown', hint: '缺少该币种的汇率锚点,无法评估' }, }; function num(v) { var n = Number(v); return Number.isFinite(n) ? n : NaN; } /** 归一化报价;非法返回 null(调用方不许拿 null 继续算) */ function normalize(input) { if (!input || typeof input !== 'object') return null; var pay = num(input.pay); if (!(pay > 0)) return null; if (input.kind === 'factor') { var f = num(input.factor); if (!(f > 0) || f > 1) return null; return { kind: 'factor', ccy: 'CNY', face: 1, payCny: f, arriveExp: in01(input.arriveExp), arriveLo: in01(input.arriveLo), arriveHi: in01(input.arriveHi), label: input.label || '', raw: input.raw || '', confidence: input.confidence || 'hi' }; } if (input.kind === 'same') { var faceS = num(input.face); if (!(faceS > 0)) return null; return { kind: 'same', ccy: 'CNY', face: faceS, payCny: pay, arriveExp: in01(input.arriveExp), arriveLo: in01(input.arriveLo), arriveHi: in01(input.arriveHi), label: input.label || '', raw: input.raw || '', confidence: input.confidence || 'hi' }; } // 默认 per:每 face 单位外币收 pay 人民币 var faceP = num(input.face); if (!(faceP > 0)) faceP = 100; var ccy = String(input.ccy || 'USD').toUpperCase(); return { kind: 'per', ccy: ccy, face: faceP, payCny: pay, arriveExp: in01(input.arriveExp), arriveLo: in01(input.arriveLo), arriveHi: in01(input.arriveHi), label: input.label || '', raw: input.raw || '', confidence: input.confidence || 'hi', altCcy: input.altCcy ? String(input.altCcy).toUpperCase() : null }; } function in01(v) { if (v === undefined || v === null || v === '') return null; var n = Number(v); if (!Number.isFinite(n)) return null; if (n > 1) n = n / 100; // 容忍百分数输入 85 → 0.85 return (n > 0 && n <= 1) ? n : null; } /** USD 枢纽表 → 1 单位 code 的 CNY 单价(方向冻结,缺项 null) */ function cnyPerUnit(code, rates) { if (!rates || typeof rates !== 'object') return null; code = String(code || '').toUpperCase(); if (code === 'CNY' || code === 'RMB') return 1; var base = Number(rates.USD); if (!(base > 0)) return null; var cny = Number(rates.CNY); var v = Number(rates[code]); if (!(cny > 0) || !(v > 0)) return null; return cny / v; } function rateSet(anchor) { if (!anchor) return null; return anchor.rates || anchor; } function compareAnchor(q, rate) { if (!(rate > 0)) return null; var ratio = q.rImpl / rate; return { rate: rate, ratio: ratio, fair: q.face * rate, // 按锚点本该付的人民币 diff: q.payCny - q.face * rate, diffPct: ratio - 1, }; } var EPS = 1e-9; // 边界浮点容差(666.92×0.85 这类构造值可能落在 0.8499999…) function bandOf(ratio, bands) { if (!(Number(ratio) > 0)) return { id: 'unknown', basis: null }; var b = bands || DEFAULT_BANDS; if (ratio >= 1 - EPS) return { id: 'gray', basis: true }; if (ratio >= b.green - EPS) return { id: 'green', basis: true }; if (ratio >= b.yellow - EPS) return { id: 'yellow', basis: true }; return { id: 'red', basis: true }; } function arrivalEval(q, cmp) { var hasRange = q.arriveLo !== null && q.arriveHi !== null; var pExp = q.arriveExp !== null ? q.arriveExp : (hasRange ? (q.arriveLo + q.arriveHi) / 2 : null); if (pExp === null) return null; function effAt(p) { return q.payCny / (q.face * p); } function effCmp(rate) { if (!(rate > 0)) return null; return { exp: effAt(pExp) / rate, // 期望到账时实际成本相对锚点 lo: hasRange ? effAt(q.arriveHi) / rate : null, // 到账最多→成本最低 hi: hasRange ? effAt(q.arriveLo) / rate : null, // 到账最少→成本最高 }; } var breakEven = function (rate) { return rate > 0 ? q.payCny / (q.face * rate) : null; }; var beSteam = cmp.steam ? breakEven(cmp.steam.rate) : null; var beSpot = cmp.spot ? breakEven(cmp.spot.rate) : null; // 风险以更严格的平衡到账率为准(哪个锚点低,哪个先触发亏损) var beMax = [beSteam, beSpot].filter(function (x) { return x !== null; }).sort(function (a, b) { return b - a; })[0]; var risk = 'low'; if (beMax !== null) { if (beMax >= 0.95) risk = 'high'; else if (beMax >= 0.85) risk = 'mid'; } return { pExp: pExp, lo: hasRange ? q.arriveLo : null, hi: hasRange ? q.arriveHi : null, effRateExp: effAt(pExp), effRateLo: hasRange ? effAt(q.arriveHi) : null, effRateHi: hasRange ? effAt(q.arriveLo) : null, effSteam: effCmp(cmp.steam ? cmp.steam.rate : 0), effSpot: effCmp(cmp.spot ? cmp.spot.rate : 0), breakEvenSteam: beSteam, breakEvenSpot: beSpot, risk: risk, }; } /** * 评估一条报价。 * @param q normalize() 的产物,或可直接 normalize 的输入 * @param tables {spot:{rates}|rates, steam:{rates}|rates, cost:{rates}|rates} * @param opts {bands:{green,yellow}, costRate?} */ function evaluate(q, tables, opts) { var quote = (q && q.payCny !== undefined && q.face !== undefined) ? q : normalize(q); if (!quote) return null; quote.rImpl = quote.payCny / quote.face; tables = tables || {}; opts = opts || {}; var spotRates = rateSet(tables.spot); var steamRates = rateSet(tables.steam); var costRates = rateSet(tables.cost); var spotRate = cnyPerUnit(quote.ccy, spotRates); var steamRate = cnyPerUnit(quote.ccy, steamRates); var costRate = cnyPerUnit(quote.ccy, costRates); var cmp = { spot: compareAnchor(quote, spotRate), steam: compareAnchor(quote, steamRate), cost: compareAnchor(quote, costRate), }; // 色带以 Steam 参考为优先基准,缺失时退即期牌价;都没有 → unknown var basisRate = steamRate || spotRate; var band = bandOf(basisRate ? quote.rImpl / basisRate : 0, opts.bands); if (!basisRate) band = { id: 'unknown', basis: null }; else band.basis = steamRate ? 'steam' : 'spot'; var arrival = arrivalEval(quote, cmp); return { quote: quote, rImpl: quote.rImpl, spot: cmp.spot, steam: cmp.steam, cost: cmp.cost, band: { id: band.id, basis: band.basis, text: BAND_INFO[band.id].text, hint: BAND_INFO[band.id].hint, cls: BAND_INFO[band.id].cls, }, arrival: arrival, }; } /** 反算:充 amount 单位 ccy,按某 CNY/单位 汇率应付多少人民币 */ function payable(amount, ccy, rates) { var a = num(amount); if (!(a > 0)) return null; var rate = cnyPerUnit(ccy, rates); if (rate === null) return null; return { amount: a, ccy: String(ccy).toUpperCase(), rate: rate, cny: a * rate }; } // ==================== 群报价文本解析 ==================== var TITLE_RE = /【([^】]+)】|\[([^\]]+)\]/; var SAME_RE = /(\d+(?:\.\d+)?)\s*R\s*[==]\s*(\d+(?:\.\d+)?)/i; var FACTOR_RE = /(\d+(?:\.\d+)?)\s*折/; var ARRIVE_RANGE_RE = /(\d+(?:\.\d+)?)\s*[~~\-—至]\s*(\d+(?:\.\d+)?)/; var ARRIVE_CTX_RE = /到|账|只能|未到/; var CTX_KW = /充|折|刀|美金|美元|欧元|乌克|印度|俄区|巴区|区\b|余额|R\s*[==]/; var BARE_LINE_RE = /^\s*(\d+(?:\.\d+)?)\s*$/; var UNIT_AFTER = /^\s*(?:美金|美刀|美元|欧元|欧|刀|R|以上)/; function titleOf(line) { var m = line.match(TITLE_RE); return m ? (m[1] || m[2]) : ''; } /** 行尾候选报价数字:排除「300美金」「100刀」这类面值数字 */ function trailingQuoteNumber(line) { var re = /\d+(?:\.\d+)?/g; var m; var candidates = []; while ((m = re.exec(line)) !== null) { var after = line.slice(m.index + m[0].length); if (UNIT_AFTER.test(after)) continue; candidates.push({ value: parseFloat(m[0]), index: m.index }); } return candidates.length ? candidates[candidates.length - 1].value : null; } function inferCcy(line) { var euro = /欧元|€/.test(line); var usd = /美金|美刀|美元|纯美|\d\s*刀/.test(line); var mixed = /混充|多币?种/.test(line); var region = /乌克|印度|UAH|INR/.test(line); if (euro && mixed) return { ccy: 'USD', confidence: 'lo', altCcy: 'EUR' }; if (euro && !usd) return { ccy: 'EUR', confidence: 'hi' }; if (usd) return { ccy: 'USD', confidence: 'hi' }; if (region) return { ccy: 'USD', confidence: 'lo', region: true }; return { ccy: 'USD', confidence: 'lo' }; } function parseArrival(line) { var m = line.match(ARRIVE_RANGE_RE); if (!m) return null; var lo = parseFloat(m[1]); var hi = parseFloat(m[2]); if (!(lo > 0 && hi > 0 && lo <= 100 && hi <= 100 && lo <= hi)) return null; // 区间前后 10 字内要有到账语境(防止把普通数字范围当成到账率) var before = line.slice(Math.max(0, m.index - 10), m.index); var after = line.slice(m.index + m[0].length, m.index + m[0].length + 10); if (!ARRIVE_CTX_RE.test(before) && !ARRIVE_CTX_RE.test(after)) return null; return { lo: lo / 100, hi: hi / 100, index: m.index, len: m[0].length }; } /** * 解析群发报价文本 → quote 数组(normalize 输入形状,evaluate 可直接消费)。 * 不认识的行不产出(unknown≠0);低置信度行(混充/区域口径)打 confidence:'lo'。 */ function parseQuoteText(text) { if (!text || typeof text !== 'string') return []; var lines = text.split(/\r?\n/); var quotes = []; var ctx = null; // {label, ccy, confidence, altCcy, age} function emit(q) { var lastIdx = quotes.length - 1; if (q.arriveLo === undefined) { q.arriveLo = null; q.arriveHi = null; } quotes.push(q); return quotes[quotes.length - 1]; } for (var i = 0; i < lines.length; i++) { var raw = lines[i]; var line = raw.trim(); if (!line) continue; if (ctx) ctx.age++; var title = titleOf(line); if (title && (!ctx || ctx.title !== title)) { ctx = { title: title, label: title, age: 0 }; } // 1) 国区同币「500R=472」 var same = line.match(SAME_RE); if (same) { var qs = normalize({ kind: 'same', face: parseFloat(same[1]), pay: parseFloat(same[2]), label: title || ctxLabel(ctx) || ('国区 ' + same[0]), raw: line, confidence: 'hi', }); if (qs) { emit(qs); ctx = ctx || { age: 0 }; continue; } } // 2) 折扣「89折」(89折/8.9折/0.89折 都归 0.89) var fac = line.match(FACTOR_RE); if (fac && !/R\s*[==]/.test(line)) { var n = parseFloat(fac[1]); var factor = n >= 10 ? n / 100 : (n >= 1 ? n / 10 : n); var qf = normalize({ kind: 'factor', pay: factor, factor: factor, label: title || ctxLabel(ctx) || (factor * 10 + '折'), raw: line, confidence: 'hi', }); if (qf) { emit(qf); continue; } } // 3) 到账区间提示(70~100):先把区间片段从行里剜掉, // 避免「下单100刀只能到70~100之间」里的数字被误当报价 var arr = parseArrival(line); var stripped = arr ? line.slice(0, arr.index) + line.slice(arr.index + arr.len) : line; var looksChannel = /充|折|刀|美金|美元|欧元|乌克|印度|余额/.test(stripped); if (arr && trailingQuoteNumber(stripped) === null) { var last = quotes[quotes.length - 1]; if (last) { last.arriveLo = arr.lo; last.arriveHi = arr.hi; if (last.arriveExp === null || last.arriveExp === undefined) { last.arriveExp = (arr.lo + arr.hi) / 2; } } continue; } // 4) 行尾自带报价数字(610 / 600 / 590 与描述同行;区间数字已剜除) var n2 = trailingQuoteNumber(stripped); if (n2 !== null && looksChannel) { var inf = inferCcy(line); var qp = normalize({ kind: 'per', pay: n2, face: 100, ccy: inf.ccy, label: title || ctxLabel(ctx) || line.slice(0, 12), raw: line, confidence: inf.confidence, altCcy: inf.altCcy || null, }); if (qp) { if (arr) { qp.arriveLo = arr.lo; qp.arriveHi = arr.hi; qp.arriveExp = (arr.lo + arr.hi) / 2; } emit(qp); if (ctx) ctx.age = 0; continue; } } // 5) 独立数字行(「大额300美金以上起充」换行后的 590) var bare = line.match(BARE_LINE_RE); if (bare && ctx && ctx.age <= 2) { var inf2 = { ccy: ctx.ccy || 'USD', confidence: ctx.confidence || 'lo', altCcy: ctx.altCcy || null }; var qb = normalize({ kind: 'per', pay: parseFloat(bare[1]), face: 100, ccy: inf2.ccy, label: ctx.label || line, raw: line, confidence: inf2.confidence, altCcy: inf2.altCcy, }); if (qb) { emit(qb); ctx.age = 0; continue; } } // 6) 更新上下文(含关键词的描述行) if (looksChannel) { var inf3 = inferCcy(line); ctx = { title: title || (ctx && ctx.title) || '', label: title || (ctx && ctx.label) || line.slice(0, 12), ccy: inf3.ccy, confidence: inf3.confidence, altCcy: inf3.altCcy || null, age: 0, }; } } return quotes; } function ctxLabel(ctx) { return ctx && ctx.label ? ctx.label : ''; } var api = { DEFAULT_BANDS: DEFAULT_BANDS, BAND_INFO: BAND_INFO, normalize: normalize, evaluate: evaluate, parseQuoteText: parseQuoteText, payable: payable, cnyPerUnit: cnyPerUnit, bandOf: bandOf, _internals: { trailingQuoteNumber: trailingQuoteNumber, inferCcy: inferCcy, parseArrival: parseArrival }, }; if (typeof module === 'object' && module.exports) module.exports = api; if (typeof window !== 'undefined') window.TopupQuote = api; if (typeof self !== 'undefined' && !self.TopupQuote) self.TopupQuote = api; })(); /* ===== INLINE-END: shared/topup-quote.lib.js ===== */ /* ===== INLINE-BEGIN: shared/case-fx.lib.js ===== */ /* * case-fx.lib.js —— CS2 武器箱隐含汇率(纯计算,不发请求) * =========================================================================== * canonical 位置:shared/case-fx.lib.js(本仓库) * 消费方式:① Node/测试:require('./case-fx.lib.js') * ② 油猴脚本:原样内联(见 steam-market-enhancer 的 INLINE 标记块) * * 这个库回答的数:花 1 美元 Steam 钱包买箱子,按 BUFF 在售价卖掉能换回多少人民币。 * 毛汇率 = BUFF在售最低价(CNY) / Steam美元区买家价(USD) * 净汇率 = 毛汇率 × (1 - buffFee) * 价差 = 净汇率 - 卡价 正数 = 箱子套现比卡价多出的人民币/美元 * * 美元区买家价是美金卡入账之后真正付出的钱包美元(标价已含 Steam 购买侧费用)。 * buffFee 只扣 BUFF 卖出手续费。不要再乘挂刀比价用的 0.85:那是另一条路径 * (Steam 卖出到手 vs 第三方),乘进来会把价差算没。 * * 标题汇率是有效箱子净汇率的中位数。偏离中位数超过 outlierPct、或 24h 成交量 * 低于 minVolume 的不计入。有效只数 < minCount 时 median 为 null(unknown≠0)。 * * 2026-11-06 改造(v2.2.0,依据见 fxq.lib.js 库头「血的教训」): * ① **离群阈值从 8% 收紧到 3%**,但阈值不是死数: * 实际判定用 `dev > max(outlierPct, 本行误差 + 中位数误差)`。 * 理由:箱子单价只有 $0.12~$1.12,两位小数量化误差就有 ±0.4%~4.2%, * 固定 8% 抓不到量化噪声(实测 Kilowatt lowest 口径偏 5%), * 而固定 3% 又会把纯噪声当离群剔掉 → 必须让阈值跟着本行的精度走。 * ② **价格口径由调用方决定**(本库不猜):调用方可用 steamPrice/buffPrice * 传入稳健统计量(Steam median / BUFF 前三档均价), * 缺省时仍用 steamUsd/buffCny(向后兼容,老调用方不受影响)。 * ③ 每行带 errPct(该行价格的量化误差上界),返回值带 uncertainty * (有效样本里的最大误差,保守取 max)→ 调用方必须把它显示出来, * 精度不够的数字不能显示得像精确值。 * =========================================================================== */ (function () { 'use strict'; var DEFAULTS = { buffFee: 0.025, minVolume: 50, outlierPct: 0.03, minCount: 3, }; /** 两位小数取整的量化步长(本库口径固定 USD/CNY,都是两位小数) */ var PRICE_STEP = 0.005; function num(v) { var n = Number(v); return Number.isFinite(n) ? n : NaN; } /** 价格量化误差上界(百分比)。价格缺失 → null */ function priceErrPct(price) { var p = num(price); if (!(p > 0)) return null; return (PRICE_STEP / p) * 100; } function medianOf(sorted) { var n = sorted.length; if (!n) return null; var mid = Math.floor(n / 2); if (n % 2) return sorted[mid]; return (sorted[mid - 1] + sorted[mid]) / 2; } /** BUFF 人民币在售价 / Steam 美元买家价。缺腿返回 null。 */ function impliedRate(buffCny, steamUsd) { var b = num(buffCny); var s = num(steamUsd); if (!(b > 0) || !(s > 0)) return null; return b / s; } /** * 扣掉 BUFF 卖出手续费后的汇率。 * buffFee 为 0~1 的比例;缺省、非法视为 0(净汇率 = 毛汇率),不拿错的扣减去算。 */ function netRate(buffCny, steamUsd, buffFee) { var gross = impliedRate(buffCny, steamUsd); if (gross === null) return null; var fee = num(buffFee); if (!Number.isFinite(fee) || fee < 0 || fee >= 1) fee = 0; return gross * (1 - fee); } /** 正数:箱子净汇率高于卡价(每 1 美元多出的人民币)。缺任一端返回 null。 */ function spread(caseNet, cardRate) { var c = num(caseNet); var k = num(cardRate); if (!(c > 0) || !(k > 0)) return null; var perUsd = c - k; return { perUsd: perUsd, per100: perUsd * 100 }; } function feeOf(opts) { var fee = opts && opts.buffFee != null ? num(opts.buffFee) : DEFAULTS.buffFee; if (!Number.isFinite(fee) || fee < 0 || fee >= 1) return 0; return fee; } /** * @param items [{name, buffCny, steamUsd, volume, note?}] * 可选:steamPrice / buffPrice(调用方已选好的稳健口径)、 * errPct(调用方算好的误差上界,覆盖本库估算) * @param opts {buffFee, minVolume, outlierPct, minCount} 缺省用 DEFAULTS * @returns {rows, median, min, max, used, total, unknown, uncertainty, rejected} * row.status: used | outlier | thin | missing */ function evaluateBasket(items, opts) { opts = opts || {}; var fee = feeOf(opts); var minVolume = opts.minVolume != null ? opts.minVolume : DEFAULTS.minVolume; var outlierPct = opts.outlierPct != null ? opts.outlierPct : DEFAULTS.outlierPct; var minCount = opts.minCount != null ? opts.minCount : DEFAULTS.minCount; var list = Array.isArray(items) ? items : []; var rows = list.map(function (it) { it = it || {}; var name = it.name ? String(it.name) : ''; var vol = num(it.volume); if (!Number.isFinite(vol) || vol < 0) vol = 0; // 价格口径:调用方给了稳健口径就用稳健口径,否则回落到老字段 var steamPrice = it.steamPrice != null ? num(it.steamPrice) : num(it.steamUsd); var buffPrice = it.buffPrice != null ? num(it.buffPrice) : num(it.buffCny); if (!(steamPrice > 0)) steamPrice = 0; if (!(buffPrice > 0)) buffPrice = 0; var gross = impliedRate(buffPrice, steamPrice); var net = gross === null ? null : gross * (1 - fee); var errPct = it.errPct != null ? num(it.errPct) : null; if (!Number.isFinite(errPct) || errPct < 0) { var e1 = priceErrPct(steamPrice); var e2 = priceErrPct(buffPrice); errPct = e1 === null ? e2 : (e2 === null ? e1 : Math.max(e1, e2)); } var row = { name: name, buffCny: buffPrice, steamUsd: steamPrice, volume: vol, gross: gross, net: net, errPct: errPct, status: 'candidate', reason: '', }; if (gross === null) { row.status = 'missing'; row.reason = it.note ? String(it.note) : '无报价'; return row; } if (vol < minVolume) { row.status = 'thin'; row.reason = '24小时成交 ' + vol + ',低于 ' + minVolume; return row; } return row; }); var candidates = rows.filter(function (r) { return r.status === 'candidate'; }); var candNets = candidates.map(function (r) { return r.net; }).sort(function (a, b) { return a - b; }); var pivot = medianOf(candNets); // 中位数自身的误差:候选行误差的中位数(保守) var pivotErr = medianOf(candidates.map(function (r) { return r.errPct || 0; }).sort(function (a, b) { return a - b; })) || 0; if (pivot > 0) { candidates.forEach(function (r) { var dev = Math.abs(r.net - pivot) / pivot; // 阈值跟着精度走:量化误差能解释的偏离不算离群 // (errPct / pivotErr 是百分数,先折成小数再和 outlierPct 同量纲比) var tol = Math.max(outlierPct, ((r.errPct || 0) + pivotErr) / 100); if (dev > tol) { r.status = 'outlier'; r.reason = '偏离中位数 ' + (dev * 100).toFixed(1) + '%'; } else { r.status = 'used'; } }); } var used = rows.filter(function (r) { return r.status === 'used'; }); var usedNets = used.map(function (r) { return r.net; }).sort(function (a, b) { return a - b; }); var median = used.length >= minCount ? medianOf(usedNets) : null; // 不确定度:有效样本里的最大误差(保守取 max,不假设误差独立) var uncertainty = null; used.forEach(function (r) { var e = r.errPct || 0; if (uncertainty === null || e > uncertainty) uncertainty = e; }); return { rows: rows, median: median, min: median === null ? null : usedNets[0], max: median === null ? null : usedNets[usedNets.length - 1], used: used.length, total: rows.length, rejected: rows.filter(function (r) { return r.status === 'outlier' || r.status === 'thin'; }).length, uncertainty: uncertainty, unknown: median === null, }; } var api = { DEFAULTS: DEFAULTS, impliedRate: impliedRate, netRate: netRate, spread: spread, evaluateBasket: evaluateBasket, priceErrPct: priceErrPct, _internals: { medianOf: medianOf }, }; if (typeof module === 'object' && module.exports) module.exports = api; if (typeof window !== 'undefined') window.CaseFx = api; if (typeof self !== 'undefined' && !self.CaseFx) self.CaseFx = api; })(); /* ===== INLINE-END: shared/case-fx.lib.js ===== */ /* ===== INLINE-BEGIN: shared/vol-metrics.lib.js ===== */ /* * vol-metrics.lib.js —— 价格序列波动率度量(CV 变异系数 + 下标线性回归趋势拟合) * =========================================================================== * canonical 位置:shared/vol-metrics.lib.js(本仓库) * 消费方式:① Node/测试:require('./vol-metrics.lib.js') * ② 油猴脚本:原样内联(见 steam-market-enhancer 的 INLINE 标记块) * * 为什么要有这个库(借鉴 AetherSwap 的 CV + R² 选品思想): * 「史低 / 降价提醒」只有在低波动序列上才可信 —— * CV 0.4 的物品今天史低,明天可能再跌 20%,跌破阈值是家常便饭而非机会; * 而持续阴跌(slope<0 且拟合度高)的「降价」是趋势而非买点。 * 用两个正交维度给序列定性: * CV(变异系数 = 样本标准差 / 均值) —— 震荡幅度,量纲无关,可跨价格量级比较 * R² + slope(对下标的最小二乘回归) —— 趋势方向与置信度 * * 分级语义(classifyVol,阈值冻结): * stable cv ≤ 0.05 波动小,提醒可信度高 * normal 0.05 < cv ≤ 0.15 一般 * volatile cv > 0.15 价格剧烈震荡,阈值提醒参考价值低 * declining slope<0 且 r2 ≥ 0.6 趋势性下跌(覆盖上面的分级,最优先判定) * * 输入约定:prices 为数值数组;空 / 单元素 / 均值≤0 / 含非有限数一律返回 null * —— unknown≠0,算不出就如实说算不出,绝不拿脏数据硬算。 * =========================================================================== */ (function () { 'use strict'; var STABLE_CV_MAX = 0.05; // stable 上界(含) var NORMAL_CV_MAX = 0.15; // normal 上界(含) var DECLINE_R2_MIN = 0.6; // declining 判定要求的拟合度下界(含) /** 数组 → 有限数值数组;非数组 / 含非有限数返回 null */ function toNums(prices) { if (!Array.isArray(prices)) return null; var out = []; for (var i = 0; i < prices.length; i++) { var v = Number(prices[i]); if (!Number.isFinite(v)) return null; out.push(v); } return out; } /** * CV 变异系数 = 样本标准差(n-1 分母)/ 均值。 * 空 / 单元素 / 均值≤0 → null(均值≤0 时 CV 无意义,负价序列本就是脏数据)。 */ function cvOf(prices) { var arr = toNums(prices); if (!arr || arr.length < 2) return null; var n = arr.length; var sum = 0; for (var i = 0; i < n; i++) sum += arr[i]; var mean = sum / n; if (!(mean > 0)) return null; var ss = 0; for (var j = 0; j < n; j++) { var d = arr[j] - mean; ss += d * d; } return Math.sqrt(ss / (n - 1)) / mean; } /** * 对下标 0..n-1 做最小二乘线性回归,返回 {slope, r2}。 * r2 = 1 - SSE/SST;SST=0(常数序列)时 r2=1(完美平稳)。 * 空 / 单元素 / 含非有限数 → null(两点才成线)。 */ function linregOf(prices) { var arr = toNums(prices); if (!arr || arr.length < 2) return null; var n = arr.length; var sumX = 0, sumY = 0, sumXY = 0, sumXX = 0; for (var i = 0; i < n; i++) { sumX += i; sumY += arr[i]; sumXY += i * arr[i]; sumXX += i * i; } var slope = (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX); var intercept = (sumY - slope * sumX) / n; var meanY = sumY / n; var sse = 0, sst = 0; for (var j = 0; j < n; j++) { var fit = intercept + slope * j; var e = arr[j] - fit; sse += e * e; var d = arr[j] - meanY; sst += d * d; } var r2 = sst === 0 ? 1 : 1 - sse / sst; if (r2 < 0) r2 = 0; // 浮点误差兜底(带截距的最小二乘数学上 SSE≤SST) if (r2 > 1) r2 = 1; return { slope: slope, r2: r2 }; } /** * 分级(阈值见文件头;declining 覆盖一切)。任一参数非有限数 → null。 * 返回 {level, cv, r2, slope}。 */ function classifyVol(cv, r2, slope) { // null/undefined 显式拒绝(Number(null)===0,不拦会把缺参当 0 混进分级) if (cv == null || r2 == null || slope == null) return null; var c = Number(cv), r = Number(r2), s = Number(slope); if (!Number.isFinite(c) || !Number.isFinite(r) || !Number.isFinite(s)) return null; var level; if (s < 0 && r >= DECLINE_R2_MIN) level = 'declining'; else if (c <= STABLE_CV_MAX) level = 'stable'; else if (c <= NORMAL_CV_MAX) level = 'normal'; else level = 'volatile'; return { level: level, cv: c, r2: r, slope: s }; } /** 一步到位:cvOf + linregOf + classifyVol;输入非法 → null */ function metricsOf(prices) { var cv = cvOf(prices); if (cv === null) return null; var lr = linregOf(prices); if (!lr) return null; return classifyVol(cv, lr.r2, lr.slope); } var api = { cvOf: cvOf, linregOf: linregOf, classifyVol: classifyVol, metricsOf: metricsOf, }; // UMD-lite:Node 测试走 require;油猴内联时挂 window(主脚本经 window.VolMetrics 取用) if (typeof module === 'object' && module.exports) module.exports = api; if (typeof window !== 'undefined') window.VolMetrics = api; if (typeof self !== 'undefined' && !self.VolMetrics) self.VolMetrics = api; })(); /* ===== INLINE-END: shared/vol-metrics.lib.js ===== */ /* ===== INLINE-BEGIN: shared/fxq.lib.js ===== */ /* * fxq.lib.js —— 汇率报价面板的纯计算层(精度 / 稳健统计量 / Steam 实测反推 / 锚点分歧度) * =========================================================================== * canonical 位置:shared/fxq.lib.js(本仓库) * 消费方式:① Node/测试:require('./fxq.lib.js') * ② 油猴脚本:原样内联(见 steam-market-enhancer 的 INLINE 标记块) * * 这个库只回答「算得准不准」,不发一个请求: * ① 一次价格读数有多准? → quantErrPct / synthUncertainty * ② 该用最低价还是中位价? → pickSteamPrice / pickBuffPrice * ③ Steam 到底按什么汇率定价? → buildLiveRate(双币种反推 + 双读自洽) * ④ 两个锚点差多少、算不算异常?→ divergence / explainDivergence * ⑤ 价差是真的还是在噪声里? → signalOf * * 血的教训(2026-11-06 一线实测,steam-market-enhancer 汇率报价面板 v2.1.0 现役缺陷): * ① **第三方汇率表 ≠ Steam 实际执行价**。AS 的 `rates/v1?to=CNY` 给 6.67098, * 而 Steam 把 $76.01 标成 ¥510.83 → 真实汇率 6.72056,**差 0.78%**。 * 同物品多币种对比 ECB:EUR +0.10% / JPY +0.08% / GBP −0.06% / KRW +0.15%, * 而 **CNY +0.78%** → 这层摩擦是人民币钱包专属,不是全局手续费。 * → 「Steam 参考价」必须实测反推,不能引用第三方表。 * ② **价格量化误差 = ±0.005/price**(两位小数取整)。$76 档 ±0.01%,$0.12 档 ±4.2%。 * 用 lowest_price 算「汇率」会把单个错价挂单放大成 5% 偏差 * (实测 Kilowatt lowest 隐含 6.3333 vs median 隐含 6.6667), * 而 8% 的离群阈值**根本抓不到** → 必须用稳健统计量 + 显式标注不确定度。 * ③ **价格串是本地化的**:EUR 是 "67,79€"、JPY 是 "¥ 12,054"、CNY 是 "¥ 510.83"。 * 用 `/[\d.,]+/` 一把梭会把 "1,234.56" 解析成 1.234(**1000× 错**) * → 必须按币种小数位解析(parseMoney)。 * * 口径纪律(沿用本仓既有哲学): * unknown≠0 —— 缺任何一腿都返回 null,绝不拿 0 或另一个口径的数充数; * 不猜 —— 精度不够时把不确定度显式标出来,而不是把数字显示得像精确值。 * =========================================================================== */ (function () { 'use strict'; var DEFAULTS = { minAnchorUsd: 10, // 高价锚的最低美元价(低于它量化误差太大,不采信) anchorSelfTol: 0.0005, // lowest/median 双读自洽容差(0.05%) minCount: 3, // 有效样本下限(不足不给中位数) divWarn: 0.015, // 锚点分歧:注意线 divAlert: 0.03, // 锚点分歧:告警线 asCrossAlert: 0.02, // 实测值与第三方表的分歧告警线 zeroDecimals: ['JPY', 'KRW', 'CLP', 'VND', 'IDR', 'CRC'], }; /** * 转数字。**null / undefined / 空串 → NaN**(不是 0)。 * 理由:unknown≠0 —— 「没有这个数」和「这个数是 0」是两回事, * 混在一起会让 synthUncertainty 把「全都不知道」报成 0% 误差, * 也会让 signalOf 把「不知道不确定度」当成「零误差 → 有差就是真信号」。 */ function num(v) { if (v === null || v === undefined || v === '') return NaN; var n = Number(v); return Number.isFinite(n) ? n : NaN; } /** 该币种价格串的小数位:0(整数币种)或 2 */ function decimalsOf(code) { var c = String(code || '').toUpperCase(); return DEFAULTS.zeroDecimals.indexOf(c) >= 0 ? 0 : 2; } /** * 解析 Steam 本地化价格串(千分位/小数点/欧式逗号小数/整数币种)。 * 返回 number 或 null(解析不出来就是 null,不返回 0 —— 0 是有效价格边界)。 * * parseMoney('$76.01', 'USD') → 76.01 * parseMoney('¥ 510.83', 'CNY') → 510.83 * parseMoney('67,79€', 'EUR') → 67.79 (欧式逗号小数) * parseMoney('¥ 12,054', 'JPY') → 12054 (整数币种,千分位是分隔符) * parseMoney('1,234.56', 'USD') → 1234.56 */ function parseMoney(s, code) { if (s === null || s === undefined) return null; var raw = String(s); var neg = /^\s*-/.test(raw); var t = raw.replace(/[^\d.,]/g, ''); if (!/\d/.test(t)) return null; if (decimalsOf(code) === 0) { t = t.replace(/[.,]/g, ''); } else if (/^\d{1,3}([.,]\d{3})+$/.test(t)) { // 纯千分位:1,234 / 1.234 t = t.replace(/[.,]/g, ''); } else if (/^\d{1,3}([.,]\d{3})+\.\d{1,2}$/.test(t) || /^\d{1,3}([.,]\d{3})*,\d{1,2}$/.test(t)) { // 混合:1,234.56 / 1.234,56 —— 靠后的那个是小数点 var lastDot = t.lastIndexOf('.'); var lastComma = t.lastIndexOf(','); if (lastComma > lastDot) t = t.replace(/\./g, '').replace(',', '.'); else t = t.replace(/,/g, ''); } else if (/^\d+,\d{1,2}$/.test(t)) { t = t.replace(',', '.'); // 欧式小数逗号 } else if (!/^\d+\.\d{1,2}$/.test(t)) { // 兜底:最后一个分隔符当小数点 var ld = t.lastIndexOf('.'); var lc = t.lastIndexOf(','); if (lc > ld) t = t.replace(/\./g, '').replace(',', '.'); else if (ld >= 0) t = t.replace(/,/g, ''); } var v = parseFloat(t); if (!Number.isFinite(v)) return null; return neg ? -v : v; } /** * 价格量化误差上界(百分比)。 * 两位小数 → 步长 0.005;整数币种 → 步长 0.5。 * $76.01 → 0.007%;$0.12 → 4.17%;$1.98 → 0.25%。 */ function quantErrPct(price, code) { var p = num(price); if (!(p > 0)) return null; var step = decimalsOf(code) === 0 ? 0.5 : 0.005; return (step / p) * 100; } /** * 合成不确定度:保守取各样本的最大值(不假设误差独立同分布)。 * 空样本 / 全非法 → null(unknown≠0)。 */ function synthUncertainty(errs) { var list = (Array.isArray(errs) ? errs : []).map(num) .filter(function (v) { return Number.isFinite(v) && v >= 0; }); if (!list.length) return null; return Math.max.apply(null, list); } /** * Steam 价格取哪个口径:优先 median(抗错价挂单),退回 lowest。 * pov = { lowest, median } */ function pickSteamPrice(pov, code) { pov = pov || {}; var med = num(pov.median); var low = num(pov.lowest); var price = med > 0 ? med : (low > 0 ? low : 0); if (!(price > 0)) return { price: 0, field: null, errPct: null }; return { price: price, field: med > 0 ? 'median' : 'lowest', errPct: quantErrPct(price, code || 'USD') }; } /** * BUFF 价格取哪个口径:优先 avg3Cny(前三档均价,抗单笔异常),退回 lowCny。 * buff = { lowCny, avg3Cny } */ function pickBuffPrice(buff) { buff = buff || {}; var avg = num(buff.avg3Cny); var low = num(buff.lowCny); var price = avg > 0 ? avg : (low > 0 ? low : 0); if (!(price > 0)) return { price: 0, field: null, errPct: null }; return { price: price, field: avg > 0 ? 'avg3' : 'low', errPct: quantErrPct(price, 'CNY') }; } /** * 从候选里挑「高价锚」:美元价 ≥ minAnchorUsd,按价格降序。 * items 形如 { name, lowest, median }(priceoverview 的两个口径) */ function selectAnchorItems(items, opts) { opts = opts || {}; var minUsd = opts.minAnchorUsd != null ? opts.minAnchorUsd : DEFAULTS.minAnchorUsd; var list = (Array.isArray(items) ? items : []).filter(function (it) { if (!it) return false; return pickSteamPrice(it, 'USD').price >= minUsd; }); list.sort(function (a, b) { return pickSteamPrice(b, 'USD').price - pickSteamPrice(a, 'USD').price; }); return list; } /** * 从两个币种的价格列表反推 Steam 实际应用的汇率。 * * povUsd = { lowest, median } currency=1(美元) * povWallet = { lowest, median } currency=<钱包币种> * anchor = { name } * * 双重保险: * ① lowest 与 median 两个独立读数必须自洽(差 ≤ anchorSelfTol), * 否则说明这个物品的报价本身有问题 → 换下一只锚。 * ② 美元价必须 ≥ minAnchorUsd,否则量化误差大到不能当汇率用。 * * 失败返回 { ok:false, reason },成功返回 { ok:true, rate, errPct, ... }。 */ function buildLiveRate(povUsd, povWallet, anchor, opts) { opts = opts || {}; var selfTol = opts.anchorSelfTol != null ? opts.anchorSelfTol : DEFAULTS.anchorSelfTol; var minUsd = opts.minAnchorUsd != null ? opts.minAnchorUsd : DEFAULTS.minAnchorUsd; var walletCode = String((opts && opts.walletCode) || 'CNY').toUpperCase(); var anchorName = (anchor && anchor.name) ? String(anchor.name) : ''; function fail(reason) { return { ok: false, reason: reason, anchorName: anchorName, rate: null, errPct: null }; } var ul = num(povUsd && povUsd.lowest); var um = num(povUsd && povUsd.median); var wl = num(povWallet && povWallet.lowest); var wm = num(povWallet && povWallet.median); if (!(ul > 0)) return fail('noUsdLowest'); if (!(wl > 0)) return fail('noWalletLowest'); if (Math.max(ul, um > 0 ? um : ul) < minUsd) return fail('priceTooLow'); var rateLow = wl / ul; var rateMed = null; if (wm > 0 && um > 0) rateMed = wm / um; var selfDelta = null; var rate; if (rateMed !== null) { selfDelta = Math.abs(rateLow - rateMed) / Math.max(rateLow, rateMed); if (selfDelta > selfTol) return fail('selfInconsistent'); rate = (rateLow + rateMed) / 2; } else { rate = rateLow; } var errPct = synthUncertainty([ quantErrPct(ul, 'USD'), quantErrPct(um > 0 ? um : ul, 'USD'), quantErrPct(wl, walletCode), quantErrPct(wm > 0 ? wm : wl, walletCode), ]); return { ok: true, reason: '', rate: rate, rateLow: rateLow, rateMed: rateMed, selfDelta: selfDelta, errPct: errPct, anchorName: anchorName, priceUsd: ul, priceField: 'lowest+median', walletCode: walletCode, }; } /** 相对分歧度 |a−b| / max(a,b);任一端缺失 → null */ function divergence(a, b) { var x = num(a); var y = num(b); if (!(x > 0) || !(y > 0)) return null; return Math.abs(x - y) / Math.max(x, y); } /** * 给一对锚点分歧定级并写人话。 * opts = { warn, alert, label, hintOk, hintWarn, hintAlert } */ function explainDivergence(a, b, opts) { opts = opts || {}; var warn = opts.warn != null ? opts.warn : DEFAULTS.divWarn; var alert = opts.alert != null ? opts.alert : DEFAULTS.divAlert; var d = divergence(a, b); if (d === null) { return { level: 'unknown', div: null, text: (opts.label || '两个锚点') + ':缺一个可比口径', hint: '' }; } var level = d >= alert ? 'alert' : (d >= warn ? 'warn' : 'ok'); var pct = (d * 100).toFixed(2); var text = (opts.label || '两个锚点') + ' 相差 ' + pct + '%'; var hint; if (level === 'ok') hint = opts.hintOk || '在正常范围内'; else if (level === 'warn') hint = opts.hintWarn || '差异偏大,值得看一眼'; else hint = opts.hintAlert || '差异异常,两个口径可能有一个失效'; return { level: level, div: d, text: text, hint: hint }; } /** * 判断一个价差是真信号还是在噪声里。 * delta —— 价差(与 reference 同量纲) * uncertaintyPct —— 不确定度(百分比) * reference —— 参考量(价差相对于它衡量) * → 'real' | 'noise' | 'unknown' */ function signalOf(delta, uncertaintyPct, reference) { var d = num(delta); var u = num(uncertaintyPct); var ref = num(reference); if (!Number.isFinite(d) || !Number.isFinite(u) || u < 0) return 'unknown'; if (!Number.isFinite(ref) || !(ref > 0)) return 'unknown'; var tol = (u / 100) * ref; if (!(tol > 0)) return 'real'; // 没有不确定度信息 → 有差就是有差 return Math.abs(d) <= tol ? 'noise' : 'real'; } var api = { DEFAULTS: DEFAULTS, decimalsOf: decimalsOf, parseMoney: parseMoney, quantErrPct: quantErrPct, synthUncertainty: synthUncertainty, pickSteamPrice: pickSteamPrice, pickBuffPrice: pickBuffPrice, selectAnchorItems: selectAnchorItems, buildLiveRate: buildLiveRate, divergence: divergence, explainDivergence: explainDivergence, signalOf: signalOf, }; if (typeof module === 'object' && module.exports) module.exports = api; if (typeof window !== 'undefined') window.Fxq = api; if (typeof self !== 'undefined' && !self.Fxq) self.Fxq = api; })(); /* ===== INLINE-END: shared/fxq.lib.js ===== */ // ==================== 常量配置 ==================== 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市场规则变更:部分币种的手续费需要进位取整而非一律舍去。 // v2.0.0:口径迁到纯计算层 SME_FEE_ROUND_DEFAULT(依据 Steamworks 官方 // 「must be charged in increments of N」币种集合),并可在设置里覆写 // (设置键 feeRoundCurrencies)—— 官方再调整时不必改代码。 // 库存/挂单请求间隔 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; } /* 浮动按钮(浏览页设置) */ /* v2.2.5:右下角最底部 16px 让给「常驻汇率徽标」,按钮抬高到它上方,避免被徽标压住 */ .sme-fab{ position:fixed;bottom:64px;right:16px;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:114px;right:16px;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} } /* ================= v2.0.0 变体标注 / 各变体最低价 ================= */ .sme-ob-variant{margin:8px 0 4px;font-size:11px;color:var(--sme-text-dim);padding:5px 8px;border-radius:4px;background:rgba(255,255,255,.04)} .sme-ob-variant.mismatch{color:#e96969;background:rgba(233,105,105,.10);border:1px solid rgba(233,105,105,.25)} .sme-ob-variants{margin:10px 0 4px;border:1px solid rgba(255,255,255,.07);border-radius:6px;overflow:hidden} .sme-ob-variants-title{padding:6px 10px;font-size:11px;color:var(--sme-text-dim);background:rgba(255,255,255,.04);border-bottom:1px solid rgba(255,255,255,.06)} .sme-ob-variants-list{max-height:190px;overflow-y:auto} .sme-ob-variant-row{display:flex;justify-content:space-between;gap:10px;padding:4px 10px;font-size:11px;border-bottom:1px solid rgba(255,255,255,.03)} .sme-ob-variant-row:last-child{border-bottom:none} .sme-ob-variant-row.current{background:rgba(102,192,244,.10)} .sme-ob-variant-name{color:var(--sme-text-dim);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .sme-ob-variant-price{color:var(--sme-text);font-weight:600;flex-shrink:0} /* ================= v2.0.1 交易冷却角标 / 提示 ================= */ .sme-inv-cooldown{display:inline-block;margin-left:4px;padding:1px 5px;border-radius:8px;font-size:10px;font-weight:700;color:#f59e0b;background:rgba(245,158,11,.16);border:1px solid rgba(245,158,11,.35);white-space:nowrap;vertical-align:middle} .sme-cooldown-notice{display:flex;align-items:center;gap:6px;margin:0 0 8px;padding:7px 10px;border-radius:6px;font-size:12px;color:#f59e0b;background:rgba(245,158,11,.10);border:1px solid rgba(245,158,11,.28)} .sme-cooldown-notice svg{width:14px;height:14px;flex-shrink:0} /* ================= v2.1.0 价格提醒波动率徽章 ================= */ .sme-vol-badge{display:inline-block;flex-shrink:0;padding:1px 7px;border-radius:var(--sme-radius-pill);font-size:10px;font-weight:700;white-space:nowrap;cursor:help} .sme-vol-badge--stable{color:var(--sme-green);background:var(--sme-green-a12);border:1px solid var(--sme-green-a40)} .sme-vol-badge--normal{color:var(--sme-text-dim);background:rgba(143,152,160,.12);border:1px solid rgba(143,152,160,.30)} .sme-vol-badge--volatile{color:var(--sme-red);background:var(--sme-red-a12);border:1px solid var(--sme-red-a45)} .sme-vol-badge--declining{color:var(--sme-amber);background:var(--sme-amber-a12);border:1px solid var(--sme-amber-a40)} `; GM_addStyle(CSS); // ==================== v1.4.6 市场顺序跟踪 CSS ==================== GM_addStyle(` /* v2.2.5:右下角底部 16px 让给「常驻汇率徽标」,市场顺序浮窗上移,避免互相遮挡 */ .sme-mr-panel{position:fixed;right:16px;bottom:56px;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])); } // ==================== v2.0.0 统一存储层(IndexedDB) ==================== // 背景(见 docs/market-enhancer-research-2026-11.md §1.3): // 旧版存储是**四轨并存** —— 设置走 GM_getValue、缓存走 localStorage、 // 池子/比价走 GM、补充包/MR/库存又走 localStorage。 // 后果:① localStorage 5MB 配额被缓存吃掉并频繁触发 QuotaExceededError // ② GM 侧无容量管理,池子日快照可能静默撑爆配额 // ③ 无 schema / 无版本,换存储格式无从谈起 // 现在统一到 IndexedDB: // · db `sme_store` v1,三个 object store: // settings —— 键值设置(迁移自 GM_getValue) // cache —— 带 TTL 的缓存条目(迁移自 localStorage 的 sme_cache_*) // kv —— 通用 KV(迁移自 localStorage / GM 的其余键) // · 读:**内存镜像优先**(同步),保证 settings.get / cache.get 的同步语义不变 // · 写:内存同步 + IndexedDB 异步落盘(write-behind),不阻塞 UI // · 兜底:IndexedDB 不可用(隐私模式 / 老浏览器)→ 自动退回 GM + localStorage,功能不挂 const SME_IDB_NAME = 'sme_store'; const SME_IDB_VERSION = 1; const SME_STORE_SETTINGS = 'settings'; const SME_STORE_CACHE = 'cache'; const SME_STORE_KV = 'kv'; const SME_IDB_MIGRATED_KEY = '__sme_migrated_v1'; // GM 侧的静态遗留键(迁移到 kv store 后即可不再写 GM) const SME_LEGACY_GM_KEYS = ['sme_pool_items_v1', 'sme_pool_series_v1', 'sme_ratio_quotes_v1']; const smeStore = { db: null, ready: false, failed: false, mem: { settings: new Map(), cache: new Map(), kv: new Map(), }, /** 打开数据库;失败时置 failed=true,调用方自动退回旧存储 */ async open() { if (this.ready) return this.db; if (this.failed) return null; if (typeof indexedDB === 'undefined') { this.failed = true; return null; } try { this.db = await new Promise((resolve, reject) => { let req; try { req = indexedDB.open(SME_IDB_NAME, SME_IDB_VERSION); } catch (e) { reject(e); return; } req.onupgradeneeded = () => { const db = req.result; for (const name of [SME_STORE_SETTINGS, SME_STORE_CACHE, SME_STORE_KV]) { if (!db.objectStoreNames.contains(name)) { db.createObjectStore(name, { keyPath: 'k' }); } } }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error || new Error('IndexedDB open failed')); req.onblocked = () => reject(new Error('IndexedDB blocked')); }); this.db.onversionchange = () => { try { this.db.close(); } catch (e) {} this.db = null; this.ready = false; }; this.ready = true; return this.db; } catch (e) { W('IndexedDB 不可用,退回旧存储:', e && e.message); this.failed = true; this.db = null; return null; } }, _tx(store, mode) { if (!this.db) return null; try { return this.db.transaction(store, mode).objectStore(store); } catch (e) { return null; } }, /** 读(异步);命中内存镜像时直接返回 */ async get(store, key, fallback) { const m = this.mem[store]; if (m && m.has(key)) return m.get(key); const os = this._tx(store, 'readonly'); if (!os) return fallback; try { const val = await new Promise((resolve, reject) => { const req = os.get(key); req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); if (val && typeof val === 'object' && 'v' in val) { if (m) m.set(key, val.v); return val.v; } return fallback; } catch (e) { return fallback; } }, /** 写(内存同步 + IndexedDB 异步落盘) */ async set(store, key, value) { const m = this.mem[store]; if (m) m.set(key, value); const os = this._tx(store, 'readwrite'); if (!os) return false; try { await new Promise((resolve, reject) => { const req = os.put({ k: key, v: value }); req.onsuccess = () => resolve(); req.onerror = () => reject(req.error); }); return true; } catch (e) { W('IndexedDB 写入失败:', store, key, e && e.message); return false; } }, async del(store, key) { const m = this.mem[store]; if (m) m.delete(key); const os = this._tx(store, 'readwrite'); if (!os) return false; try { await new Promise((resolve, reject) => { const req = os.delete(key); req.onsuccess = () => resolve(); req.onerror = () => reject(req.error); }); return true; } catch (e) { return false; } }, async keys(store) { const os = this._tx(store, 'readonly'); if (!os) return []; try { return await new Promise((resolve, reject) => { const req = os.getAllKeys(); req.onsuccess = () => resolve(req.result || []); req.onerror = () => reject(req.error); }); } catch (e) { return []; } }, async getAll(store) { const os = this._tx(store, 'readonly'); if (!os) return []; try { return await new Promise((resolve, reject) => { const req = os.getAll(); req.onsuccess = () => resolve(req.result || []); req.onerror = () => reject(req.error); }); } catch (e) { return []; } }, async clear(store) { const m = this.mem[store]; if (m) m.clear(); const os = this._tx(store, 'readwrite'); if (!os) return false; try { await new Promise((resolve, reject) => { const req = os.clear(); req.onsuccess = () => resolve(); req.onerror = () => reject(req.error); }); return true; } catch (e) { return false; } }, /** 把某个 store 的全部内容灌进内存镜像(启动时一次性 hydrate) */ async hydrate(store) { const rows = await this.getAll(store); const m = this.mem[store]; if (!m) return 0; for (const r of rows) { if (r && typeof r === 'object' && 'k' in r) m.set(r.k, r.v); } return rows.length; }, }; /** * 一次性迁移:GM 设置 + localStorage 的 sme_* 键 → IndexedDB。 * 幂等:靠 kv 里的 __sme_migrated_v1 标记;迁移成功后**不删**旧数据 *(保留回滚余地,也让老版本脚本仍能读到)。 */ async function smeMigrateToIdb(defaults) { if (smeStore.failed) return false; const db = await smeStore.open(); if (!db) return false; try { const done = await smeStore.get(SME_STORE_KV, SME_IDB_MIGRATED_KEY, null); if (done) return true; // 1) 设置:GM → settings for (const key of Object.keys(defaults)) { const gmKey = 'sme_' + key; let val; try { val = GM_getValue(gmKey, undefined); } catch (e) { val = undefined; } if (val === undefined) continue; await smeStore.set(SME_STORE_SETTINGS, gmKey, val); } // 2) localStorage 的 sme_* 键 → cache / kv let lsKeys = []; try { lsKeys = Object.keys(localStorage); } catch (e) {} for (const k of lsKeys) { if (!k.startsWith('sme_')) continue; let raw = null; try { raw = localStorage.getItem(k); } catch (e) {} if (raw == null) continue; if (k.startsWith('sme_cache_')) { try { const entry = JSON.parse(raw); if (entry && typeof entry === 'object' && 'ts' in entry) { await smeStore.set(SME_STORE_CACHE, k.slice('sme_cache_'.length), entry); continue; } } catch (e) { /* 非结构化 → 落 kv */ } } await smeStore.set(SME_STORE_KV, k, raw); } // 2b) GM 侧的静态遗留键(池子条目/日快照、比价结果)→ kv // 注:GM 没有 listValues 授权,无法枚举全部键; // 动态键(如按 appid 的比价索引)走**惰性回退**读取。 for (const legacyKey of SME_LEGACY_GM_KEYS) { let val = null; try { val = GM_getValue(legacyKey, null); } catch (e) { val = null; } if (val == null) continue; await smeStore.set(SME_STORE_KV, legacyKey, typeof val === 'string' ? val : JSON.stringify(val)); } await smeStore.set(SME_STORE_KV, SME_IDB_MIGRATED_KEY, Date.now()); L('IndexedDB 迁移完成'); return true; } catch (e) { W('IndexedDB 迁移失败(保留旧存储继续工作):', e && e.message); return false; } } /** * 通用 KV(IndexedDB 的 kv store)。 * 值统一按**字符串**存(与旧 localStorage 语义一致,避免各处再各自 JSON 化)。 * IndexedDB 不可用时自动退回 localStorage → 功能不挂。 */ const smeKv = { async get(key, fallback) { if (smeStore.ready) { try { const v = await smeStore.get(SME_STORE_KV, key, undefined); if (v !== undefined && v !== null) return v; } catch (e) { /* 落到 localStorage */ } } try { const raw = localStorage.getItem(key); return raw == null ? fallback : raw; } catch (e) { return fallback; } }, async set(key, value) { if (smeStore.ready) { try { await smeStore.set(SME_STORE_KV, key, value); return; } catch (e) { /* 落到 localStorage */ } } try { localStorage.setItem(key, value); } catch (e) { W('KV 写入失败:', key); } }, async remove(key) { if (smeStore.ready) { try { await smeStore.del(SME_STORE_KV, key); } catch (e) { /* ignore */ } } try { localStorage.removeItem(key); } catch (e) {} }, /** * 同步读(只读内存镜像 + localStorage 兜底),**不发请求**。 * * v2.2.1 修:v2.0.0 从 localStorage 迁到 IndexedDB 时漏了这个方法, * 7 处调用(库存缓存 / 库存价格标签 / 市场排名 / 补充包 3 处 / 封面缓存) * 全被 try/catch 吞掉 → 表现为「只写不读」:数据确实存进了 IndexedDB, * 但每次开页面都读不回来,功能从零开始。 * * 内存镜像靠 init 时的 `smeStore.hydrate(SME_STORE_KV)` 填充 * (smeStore.set 也会同步写 mem,所以同一次会话内写完即可读)。 */ getSync(key, fallback) { const m = smeStore.mem.kv; if (m && m.has(key)) { const v = m.get(key); if (v !== undefined && v !== null) return v; } try { const raw = localStorage.getItem(key); return raw == null ? fallback : raw; } catch (e) { return fallback; } }, }; // ==================== 设置管理 ==================== 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, // v2.0.0 新增:价格提醒 priceAlerts: true, // 详情页「价格提醒」按钮 priceAlertDropPct: 0.1, // 默认跌幅阈值(10%) priceAlertNotify: true, // 同时发浏览器通知 // v1.7.0 新增:我的池子行情 poolPanel: true, // 市场页显示「池子行情」工具条 poolAutoTrack: true, // 浏览市场时自动收录物品进池子 poolWindow: '30d', // 池子行情默认时间窗口 poolMaxItems: 300, // 池子容量上限 // v1.9.0 新增:跨平台挂刀比价 crossPlatformQuote: true, // 详情页「挂刀比价」标签页(仅 CS2/DOTA2) crossPlatformAutoExpand: false, // 打开面板后自动后台加载比价数据(默认点开才请求) platformFeeFactor: 0.85, // 平台扣费系数(平台到手 ≈ 平台价 × 系数) poolRatioScan: false, // 池子明细「挂刀」列与手动扫描(默认关) // v2.0.2:汇率报价参考。buffFee 只扣 BUFF 卖出手续费,与上面的 0.85 不是一回事。 fxqBuffFee: 0.025, fxqCards: [], fxqBadge: true, fxqCaseLang: 'zh', // 箱子列表展示名:zh 中文(默认)| en 英文市场名 // 浏览器:inline 顶部内嵌,float 底部浮窗。Steam 客户端忽略此项,固定浮窗。 listingPanelMode: 'inline', }, // v1.5.1 P1-B:内存缓存。设置在 MutationObserver 回调里高频调用, // 逐次跨脚本边界读取浪费;首次读取后驻留内存,set 时同步更新。 // v2.0.0:存储统一到 IndexedDB —— 读仍是**同步**(内存镜像), // 写是「内存同步 + IndexedDB 异步落盘」;IndexedDB 不可用时退回 GM。 _mem: {}, _ready: false, /** 启动时调用:迁移旧存储 → IndexedDB,并把设置灌进内存镜像 */ async init() { if (this._ready) return; try { await smeMigrateToIdb(this._defaults); await smeStore.hydrate(SME_STORE_SETTINGS); for (const [k, v] of smeStore.mem.settings) { if (typeof k === 'string' && k.startsWith('sme_')) this._mem[k.slice(4)] = v; } // v2.2.1:kv store 的内存镜像 —— smeKv.getSync 的数据来源。 // 不 hydrate 的话 getSync 永远读不到(v2.0.0 迁 IndexedDB 时漏了)。 await smeStore.hydrate(SME_STORE_KV); } catch (e) { W('设置初始化失败,退回 GM 存储:', e && e.message); } // v2.2.5:升级后第一次启动时把「常驻汇率徽标」恢复为开启。 // 旧版这个开关只有高亮/不高亮,用户很容易误点关掉后以为「徽标坏了」; // 只纠正一次,之后用户再关就尊重用户选择。 try { if (GM_getValue('sme_fxqBadgeV225', false) !== true) { if (this.get('fxqBadge') === false) this.set('fxqBadge', true); GM_setValue('sme_fxqBadgeV225', true); } } catch (e) {} this._ready = true; }, get(key) { if (key in this._mem) return this._mem[key]; const gmKey = 'sme_' + key; // 优先 IndexedDB 镜像(init 时已 hydrate),退 GM let v = smeStore.mem.settings.get(gmKey); if (v === undefined) { try { v = GM_getValue(gmKey, this._defaults[key]); } catch (e) { v = this._defaults[key]; } } this._mem[key] = v; return v; }, set(key, val) { const gmKey = 'sme_' + key; this._mem[key] = val; // ★ 同步镜像:GM 写一份。 // 原因:插件 @run-at document-start,「显示更多价格」要在 react-dom 初始化前 // 同步读到设置 —— 那时 IndexedDB 还没 hydrate 完,只有 GM 能同步读到。 // GM 侧设置量极小(几十个键值),不占配额;IndexedDB 仍是**持久化主存储**。 try { GM_setValue(gmKey, val); } catch (e) {} if (smeStore.ready) smeStore.set(SME_STORE_SETTINGS, gmKey, val).catch(() => {}); }, }; // ==================== 缓存管理(v2.0.0:IndexedDB 后端) ==================== // v1.5.0:QuotaExceededError 不再静默失败。 // 原实现 catch(e){} 直接吞掉配额超限,导致用户浏览一段时间后缓存无声失效 // (表现:明明设了 30 分钟 TTL,价格历史却一直不更新),只能手动清缓存恢复。 // v1.9.1 修复:旧版 _handleCacheQuota 的大小判断写反。 // v2.0.0:整体迁到 IndexedDB —— localStorage 只有 5MB 且按 UTF-16 计半字节配额, // 是这个插件最大的存储痛点;IndexedDB 无此限制,且能存结构化对象(免去 // JSON 序列化往返)。读仍是**同步**(内存 LRU 热层),写是异步落盘。 // // 结构:_mem 是 Map,Map 迭代序 = 插入序, // get 命中时移到尾部 → 天然 LRU。 const CACHE_MAX_BYTES = 4 * 1024 * 1024; // v2.2.1:单条目上限从 512KB 提到 2MB。 // 原值下「市场交易记录」缓存(约 200 字节/条,2000~3500 条即 400~700KB) // 会被 cache.set **静默丢弃** —— 交易活跃的用户每次开面板都得从头重抓, // 「断点 7 天 TTL」形同虚设。总预算仍是 4MB(LRU 淘汰照旧),只是允许 // 单条占更多 —— 交易记录本来就比其它缓存更值得留着。 const CACHE_MAX_ITEM_BYTES = 2 * 1024 * 1024; const cache = { _mem: new Map(), // key → {ts, data, bytes} _inflight: new Map(), // key → Promise(同 key 并发请求去重) _totalBytes: 0, /** 启动时调用:把 IndexedDB 里的缓存灌进内存热层 */ async init() { try { const rows = await smeStore.getAll(SME_STORE_CACHE); for (const r of rows) { if (!r || typeof r !== 'object' || !('k' in r)) continue; const entry = r.v; if (!entry || typeof entry !== 'object' || !('ts' in entry)) continue; // v2.2.1:优先用落盘时算好的 bytes;老条目(v2.2.1 之前写的)没有才现算。 const bytes = (typeof entry.bytes === 'number' && entry.bytes > 0) ? entry.bytes : this._estimate({ ts: entry.ts, data: entry.data }); this._mem.set(r.k, { ts: entry.ts, data: entry.data, bytes: bytes }); this._totalBytes += bytes; } this._evict(); L('缓存已从 IndexedDB 载入:', this._mem.size, '条 /', this._totalBytes, '字节'); } catch (e) { W('缓存载入失败:', e && e.message); } }, _estimate(entry) { try { return JSON.stringify(entry).length; } catch (e) { return 0; } }, get(key, ttl) { const hit = this._mem.get(key); if (!hit) return null; if (Date.now() - hit.ts > ttl) { this._remove(key); return null; } // LRU touch:命中后移到尾部 this._mem.delete(key); this._mem.set(key, hit); return hit.data; }, set(key, data) { const ts = Date.now(); const bytes = this._estimate({ ts: ts, data: data }); if (bytes >= CACHE_MAX_ITEM_BYTES) { // v2.2.1:原来这里是静默 _remove + return,条目直接人间蒸发, // 现场表现为「缓存好像没生效」但没有任何线索。现在必须告警。 W('缓存条目过大,已丢弃:', key, Math.round(bytes / 1024), 'KB >', Math.round(CACHE_MAX_ITEM_BYTES / 1024), 'KB'); this._remove(key); return; } const old = this._mem.get(key); if (old) this._totalBytes -= old.bytes; this._mem.delete(key); this._mem.set(key, { ts: ts, data: data, bytes: bytes }); this._totalBytes += bytes; this._evict(); // 异步落盘(不阻塞 UI)。 // v2.2.1:连算好的 bytes 一起存 —— 启动时 cache.init 就不用对每条 // 再 JSON.stringify 一遍(最坏情况 4MB 内容在主线程同步序列化)。 smeStore.set(SME_STORE_CACHE, key, { ts: ts, data: data, bytes: bytes }).catch(() => {}); }, _remove(key) { const old = this._mem.get(key); if (!old) return; this._totalBytes -= old.bytes; this._mem.delete(key); smeStore.del(SME_STORE_CACHE, key).catch(() => {}); }, /** LRU 淘汰:总量超过 4MB 时从最旧条目开始清理 */ _evict() { if (this._totalBytes <= CACHE_MAX_BYTES) return; for (const [k, v] of this._mem) { if (this._totalBytes <= CACHE_MAX_BYTES) break; this._totalBytes -= v.bytes; this._mem.delete(k); smeStore.del(SME_STORE_CACHE, k).catch(() => {}); } 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; }, async clear() { this._mem.clear(); this._totalBytes = 0; this._inflight.clear(); await smeStore.clear(SME_STORE_CACHE); }, /** 诊断:当前缓存统计 */ stats() { return { count: this._mem.size, bytes: this._totalBytes }; }, }; // ==================== 库存缓存(按 SteamID 分离,v2.0.0:改走 IndexedDB) ==================== // 原实现:localStorage 存 Map 序列化 + sessionStorage 判会话。 // v2.0.0:落盘改走 IndexedDB 的 kv store(localStorage 5MB 配额是本插件最大痛点); // 会话判定仍用 sessionStorage(浏览器关闭即清,语义不变)。 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) {} } /** 从存储恢复缓存(正确反序列化 Map 对象) */ function loadInvCache(sid) { try { const raw = smeKv.getSync('sme_inv_' + sid, null); if (!raw) return; const obj = JSON.parse(raw); // v2.2.1:恢复交易冷却限制表(缓存命中时不会重新解析库存响应, // 不恢复的话 invCooldownInfo 拿不到天数 → 冷却角标只在触网那次显示)。 // 只补缺的不覆盖 —— 真实响应解析出来的更权威。 if (obj.restrictions && Array.isArray(obj.restrictions)) { for (const entry of obj.restrictions) { if (!Array.isArray(entry) || entry.length < 2) continue; const name = entry[0]; const rule = entry[1]; if (name && rule && !invRestrictions.has(name)) { invRestrictions.set(name, { marketableDays: Number(rule.marketableDays) || 0, tradableDays: Number(rule.tradableDays) || 0, }); } } } for (const key in obj) { if (key === 'restrictions') continue; // 限制表不是库存条目 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); } } /** 保存缓存(防抖 200ms,正确序列化 Map 对象) */ let _invSaveTimer = null; function saveInvCache(sid) { if (_invSaveTimer) clearTimeout(_invSaveTimer); _invSaveTimer = setTimeout(() => { try { // v2.2.1:交易冷却限制表(market_hash_name → 天数)一起落盘。 // 它只在**解析库存网络响应**时填充(collectInventoryRestrictions), // 缓存命中时不会重新解析 → invRestrictions 空 → invCooldownInfo 返回 null // → 冷却角标只在「真正触网的那次」显示,刷新一下就没了。 const obj = { restrictions: Array.from(invRestrictions.entries()) }; 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 }; } } smeKv.set('sme_inv_' + sid, JSON.stringify(obj)).catch(() => {}); } catch (e) { L('Failed to persist inventory cache:', e.message); } _invSaveTimer = null; }, 200); } /** 清除指定 SteamID 的库存缓存 */ function clearInvCache(sid) { smeKv.remove('sme_inv_' + sid).catch(() => {}); } /** 会话级缓存加载:新会话清除过期缓存,相同会话从存储恢复 */ 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); } /** * 当前页面需要预热哪些库存 → [[appid, ctxId], ...] * v2.2.1:按页面类型取当前 appid,不再无脑拉 5 个游戏的库存。 */ function invWarmTargets() { let appid = null; try { const page = detectPageType(); if (page === 'listing') { const li = getListingInfo(); appid = li && li.appid ? String(li.appid) : null; } else if (page === 'inventory') { const inv = unsafeWindow.g_ActiveInventory; if (inv) appid = String(inv.m_appid || inv.appid || '') || null; } } catch (e) { appid = null; } if (!appid || !/^\d+$/.test(appid)) return []; return [[appid, APP_CONTEXT[appid] || '2']]; } /** * v2.0.1:后台预热库存限制数据(每个会话一次)。 * 目的:市场物品页要显示「该物品是否还在冷却期」,需要 Steam 自己给的限制天数, * 而那只在**用户自己的库存**里 → 需要拉一次库存(走库存限流通道,后台不阻塞 UI)。 * * v2.2.1:只预热**当前页面相关**的库存。 * 原实现固定拉 730/2、730/6、570/2、440/2、753/6 五个库存(每个最多 2000 件、 * 还可能分页),跟当前看的游戏毫无关系 —— 纯浪费请求配额,还更容易撞 Steam 限流。 * 现在按页面类型取当前 appid;取不到就不预热(宁可角标晚点出现,也别打 5 次无用请求)。 */ let _invRestrictionsWarm = false; function ensureInvRestrictionsWarm() { if (_invRestrictionsWarm) return; _invRestrictionsWarm = true; const sid = getSteamId(); if (!sid) return; const pairs = invWarmTargets(); if (!pairs.length) return; (async () => { for (const [appid, ctxId] of pairs) { try { await enqueueInv(() => fetchInventory(appid, ctxId, INVENTORY_COUNT, 'default')); } catch (e) { /* 失败不影响其它 appid */ } } // 预热完成后,若面板在位则刷新冷却提示 if (panelEl) { const notice = panelEl.querySelector('.sme-cooldown-notice'); if (!notice) { const fresh = createPanel(); if (fresh && fresh.parentElement) { // 面板已存在 → 只补提示,不整体重建(避免丢失 Tab 状态) const li = getListingInfo(); const info = li && li.marketHashName ? invCooldownInfo(li.marketHashName) : null; if (info) { const el = h('div', { class: 'sme-cooldown-notice', html: ICONS.alert + '该物品仍在交易冷却期:' + info.daysLeft + ' 天后(' + mhFmtTs(info.untilTs).slice(0, 16) + ')才能上架或交易', }); const content = panelEl.querySelector('.sme-content'); if (content) content.parentElement.insertBefore(el, content); } } } } })().catch(() => {}); } /** * v2.0.1:某个物品当前的冷却状态(**不触网**)。 * 依赖两样本地数据: * ① Steam 自己给的限制天数(inventory API 的 descriptions[])→ invRestrictions * ② 获得时间(市场买入时间,来自本地已索引的成交记录)→ 成本基准 * 两样缺一不可:只有限制天数而没有获得时间,无法判断是否仍在冷却期 * (老物品也会显示 7 天的类级限制值),此时**不显示角标**,避免满屏误报。 * * @param {string} name market_hash_name * @returns {{daysLeft:number, untilTs:number, days:number}|null} */ function invCooldownInfo(name) { if (!name) return null; const rule = invRestrictions.get(name); if (!rule) return null; const days = rule.marketableDays || rule.tradableDays || 0; if (days <= 0) return null; if (!mhState.rows || !mhState.rows.length) return null; // 没有成交记录 → 无法判断 const cb = _invCooldownBasis(); if (!cb) return null; let hold = null; for (const h of cb.holdings) { if (h.name === name) { hold = h; break; } } if (!hold || !hold.newestTs) return null; const untilTs = hold.newestTs + days * 86400000; const daysLeft = Math.ceil((untilTs - Date.now()) / 86400000); if (daysLeft <= 0) return null; return { daysLeft: daysLeft, untilTs: untilTs, days: days }; } /** 成本基准的轻量缓存(按成交记录条数失效) */ let _invCooldownBasisCache = { rowsLen: -1, value: null }; function _invCooldownBasis() { const len = (mhState.rows || []).length; if (_invCooldownBasisCache.rowsLen === len) return _invCooldownBasisCache.value; const value = smeCostBasis(mhState.rows || [], { netOf: (c) => priceBeforeFees(c) }); _invCooldownBasisCache = { rowsLen: len, value: value }; return value; } /** * v2.0.1:汇总用户当前**可市场物品**的持仓(名称 → 件数 + 冷却规则)。 * 数据源:社区库存 API(复用 fetchInventory 的分页/缓存/限流),再合并限制天数。 * @param {Array} pairs [[appid, ctxId], ...] * @returns {Promise>} */ async function fetchInventoryHoldings(pairs) { const sid = getSteamId(); if (!sid) return []; const merged = new Map(); const list = (pairs && pairs.length) ? pairs : [ ['730', '2'], ['730', '6'], ['570', '2'], ['440', '2'], ['753', '6'], ]; for (const [appid, ctxId] of list) { let owned = null; try { owned = await enqueueInv(() => fetchInventory(appid, ctxId, INVENTORY_COUNT, 'default')); } catch (e) { owned = null; } if (!owned || !(owned instanceof Map)) continue; for (const [name, qty] of owned) { const rule = invRestrictions.get(name); const prev = merged.get(name); if (prev) { prev.qty += qty; // 冷却天数取各 appid 中的最大值(保守) if (rule) { prev.marketableDays = Math.max(prev.marketableDays, rule.marketableDays || 0); prev.tradableDays = Math.max(prev.tradableDays, rule.tradableDays || 0); } } else { merged.set(name, { name: name, qty: qty, marketableDays: rule ? (rule.marketableDays || 0) : 0, tradableDays: rule ? (rule.tradableDays || 0) : 0, }); } } } return Array.from(merged.values()).sort((a, b) => b.qty - a.qty); } // ==================== 统一限流调度器(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), }); } /** v1.9.1:Steam 现把市场详情页 URL 302 成哈希 slug(如 /listings/730/G18DA243004), * URL 里的名字不再是 market_hash_name(中文页面连可见名都是本地化名,如「裂空武器箱」); * 皮肤类物品的易读 URL 还会把同皮肤全部磨损 302 到同一个「家族聚合页」,URL 层面 * 已丢失磨损信息,只能跟随页面默认 bucket(initialSelectedBucketID → * initialFallbackBucketID → buckets[0])。规范 market_hash_name 从 * renderContext.queryData(dehydrated React Query 缓存)按 classid 精确关联。 * 解析成功即缓存;失败 2s 内不重试(@run-at document-start 时 SSR 可能尚未就绪)。 */ let _ssrHashName; // undefined=未解析成功,string=已解析 let _ssrHashNameTriedAt = 0; function ssrMarketHashName() { if (_ssrHashName !== undefined) return _ssrHashName; const now = Date.now(); if (now - _ssrHashNameTriedAt < 2000) return null; _ssrHashNameTriedAt = now; try { const SSR = unsafeWindow && unsafeWindow.SSR; if (!SSR || !SSR.loaderData || !SSR.renderContext) return null; const entries = Array.isArray(SSR.loaderData) ? SSR.loaderData : Object.values(SSR.loaderData || {}); // 1) 焦点物品 classid(含 buckets 的那份 loaderData) let focusClassId = null; for (const entry of entries) { try { const d = typeof entry === 'string' ? JSON.parse(entry) : entry; const bs = d && d.buckets; if (!bs || !bs.length) continue; const byId = (id) => bs.find((b) => String(b.bucket_id) === String(id)); const b = (d.initialSelectedBucketID != null && byId(d.initialSelectedBucketID)) || (d.initialFallbackBucketID != null && byId(d.initialFallbackBucketID)) || bs[0]; if (b && b.classid != null) { focusClassId = String(b.classid); break; } } catch (e) { /* 单条解析失败忽略 */ } } if (!focusClassId) return null; // 2) queryData 深度遍历找 classid 匹配的资产描述(queryData 是内嵌 JSON 字符串) let qd = SSR.renderContext.queryData; if (typeof qd === 'string') { try { qd = JSON.parse(qd); } catch (e) { return null; } } let found = null; const walk = (o, depth) => { if (found || depth > 12 || !o || typeof o !== 'object') return; if (Array.isArray(o)) { for (const x of o) { walk(x, depth + 1); if (found) return; } return; } if (o.market_hash_name != null && o.classid != null && String(o.classid) === focusClassId) { found = String(o.market_hash_name); return; } for (const k in o) { walk(o[k], depth + 1); if (found) return; } }; walk(qd, 0); if (found) { _ssrHashName = found; return found; } } catch (e) { /* SSR 不存在或结构变化,静默回退 URL 名 */ } return null; } function getListingInfo() { const m = location.pathname.match(/^\/market\/listings\/(\d+)\/(.+?)(?:\?.*)?$/); if (!m) return null; let name; try { name = decodeURIComponent(m[2]); } catch (e) { name = m[2]; } // v1.9.1:URL 名可能是哈希 slug,优先用页面 SSR 的规范 market_hash_name const ssrName = ssrMarketHashName(); if (ssrName) name = ssrName; return { appid: m[1], marketHashName: name }; } // ==================== 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 上下文(v2.0.0)。 * 新版 React 市场页的内联脚本会做: * window.SSR = {loaderData, clientAssets, renderContext} * window.Config = d.Config; window.UserConfig = d.UserConfig; * (实测 2026-11:window.Config / window.UserConfig 均已挂到全局,可直接读) * @returns {object|null} 与 smeParseValveSsr 同构的上下文对象 */ function getPageSsr() { try { const SSR = unsafeWindow && unsafeWindow.SSR; if (!SSR || (!SSR.loaderData && !SSR.renderContext)) return null; return { loaderData: SSR.loaderData || null, renderContext: SSR.renderContext || null, clientAssets: SSR.clientAssets || null, config: (unsafeWindow && unsafeWindow.Config) || null, userConfig: (unsafeWindow && unsafeWindow.UserConfig) || null, }; } catch (e) { return null; } } /** * 当前页面的同款变体清单(含每个变体的最低价)。 * buckets[] = {bucket_id: , classid, min_price} * min_price 是**分**(实测 MW 变体 min_price=128230 与该变体 orderbook 的 * amtMinSellOrder=128230 一致),是 Steam 免费给出的**各变体最低卖价**。 * @returns {Array<{name, classid, minPriceCents}>} */ function getListingVariants() { const out = []; const ssr = getPageSsr(); if (!ssr) return out; const ld = ssr.loaderData; const entries = Array.isArray(ld) ? ld : (ld && typeof ld === 'object' ? Object.values(ld) : []); for (const entry of entries) { let d = entry; if (typeof d === 'string') { try { d = JSON.parse(d); } catch (e) { continue; } } if (!d || !Array.isArray(d.buckets)) continue; for (const b of d.buckets) { if (!b || !b.bucket_id) continue; if (out.some(v => v.name === b.bucket_id)) continue; const mp = parseInt(b.min_price, 10); out.push({ name: String(b.bucket_id), classid: b.classid != null ? String(b.classid) : '', minPriceCents: isFinite(mp) && mp > 0 ? mp : null, }); } } return out; } /** * 用户**想要**的变体名。 * 新版路由是 `/market/listings/:appid/:name/:bucket?` —— 第 3 段就是变体 * (实测加上该段后 initialSelectedBucketID 变成请求的变体, * 且 market/orderbook 的 queryKey 也随之指向该变体)。 * 没有该段时退回焦点变体(initialSelectedBucketID → fallback → buckets[0])。 */ function getTargetVariantName() { try { const m = location.pathname.match(/^\/market\/listings\/\d+\/[^/]+\/([^/]+)/); if (m) { try { return decodeURIComponent(m[1]); } catch (e) { return m[1]; } } } catch (e) {} return smeSsrFocusVariant(getPageSsr()); } // ==================== SSR 订单簿提取(新版页面,无 item_nameid) ==================== /** * @deprecated v2.0.0 已废弃。旧表按「货币 ID」索引,且与 CURRENCY_ID_TO_CODE 自相矛盾: * 6 标成 BRL(官方 PLN)、7 标成 JPY(官方 BRL)、8 标成 NOK(官方 JPY)、 * 25-28 标成 HKD/TWD/SAR/AED(官方 CLP/PEN/COP/ZAR),且完全缺 29-47。 * 现统一走纯计算层 SME_CURRENCY_ECODE + SME_CURRENCY_SYMBOL(依据 Steamworks 官方表)。 * 保留 Proxy 仅为兼容潜在的外部引用:数字键按 ID 查,字符串键按 ISO 代码查。 */ const CURRENCY_SYMBOLS = new Proxy({}, { get(t, k) { if (typeof k !== 'string') return undefined; const id = parseInt(k, 10); if (isFinite(id)) return smeCurrencySymbol(id); return smeCurrencySymbolByCode(k); }, }); /** * 获取货币前缀/后缀。 * v2.0.0:优先 Steam 原生 v_currencyformat;新版 React 市场页**没有**这个全局 * (实测 count=0),落到纯计算层的官方映射表(单一事实源)。 * ⚠️ 旧版的 CURRENCY_SYMBOLS 表与 CURRENCY_ID_TO_CODE 自相矛盾 * (6 标成 BRL / 7 标成 JPY / 25-28 标成 HKD·TWD·SAR·AED),已废弃。 */ 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 smeCurrencySymbol(currencyCode); } /** 格式化分(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, variantName, matched) { 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, // v2.0.0:该盘所属的磨损变体(来自 queryKey)+ 是否与用户目标变体一致 variantName: variantName || '', variantMatched: matched !== false, _source: sourceTag, }; L(`Order book extracted (${sourceTag}):`, result.sellOrders.length, 'sell,', result.buyOrders.length, 'buy', variantName ? ('variant=' + variantName + (matched === false ? ' [MISMATCH]' : '')) : ''); return result; } /** smeSsrOrderBook 的 levels 结构 → 插件内部的订单簿展示结构 */ function orderBookFromLevels(ob, sourceTag) { if (!ob) return null; const currencyCode = ob.currency || 1; const sym = getCurrencySymbol(currencyCode); return { sellOrders: ob.sellLevels.map(l => ({ price: formatCentsStr(l.cents, currencyCode), qty: l.qty, _cents: l.cents, })), buyOrders: ob.buyLevels.map(l => ({ price: formatCentsStr(l.cents, currencyCode), qty: l.qty, _cents: l.cents, })), sellSummary: '', buySummary: '', highestBuy: ob.highestBuyCents != null ? String(ob.highestBuyCents) : '', lowestSell: ob.lowestSellCents != null ? String(ob.lowestSellCents) : '', prefix: sym.prefix, suffix: sym.suffix, variantName: ob.variantName || '', variantMatched: ob.matched !== false, buyOrderCount: ob.buyOrderCount, sellOrderCount: ob.sellOrderCount, _source: sourceTag, }; } /** * 构造指定变体的市场页 URL。 * 新版路由 `/market/listings/:appid/:name/:bucket?` 的第 3 段是变体 * (实测加上该段后 initialSelectedBucketID 与 market/orderbook 的 queryKey * 都指向请求的变体)。旧式 URL(name 段本身就是 market_hash_name)无此概念。 */ function buildVariantUrl(variantName) { const m = location.pathname.match(/^(\/market\/listings\/\d+\/)([^/]+)(\/([^/]+))?\/?$/); if (!m) return null; const head = m[1]; const seg1 = m[2]; const hasBucket = !!m[4]; // seg1 是家族 slug(如 G1807209A023004)时才需要 bucket 段 const isSlug = /^G[0-9A-Za-z]{8,}$/.test(seg1); if (!isSlug && !hasBucket) return null; // 旧式 URL:单物品页,无变体概念 const base = isSlug ? head + seg1 : head + seg1; return base + '/' + encodeURIComponent(variantName); } /** * 通过抓取物品市场页的 SSR 取订单簿(v2.0.0)。 * 取代「抓详情页 → 正则取 item_nameid → itemordershistogram」三连: * 实测全站已无 item_nameid(Market_LoadOrderSpread / g_item_nameid / "item_nameid" * 在 730 与 753 页面出现次数均为 0),而 SSR 的 market/orderbook 直接给全量档位 * (实测卖侧 792 档 / 买侧 1,802 档)。 */ async function fetchOrderBookByPage(appid, hashName) { const cacheKey = `obp_${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 text = await smeRequest(url, { expect: 'text', onNonOk: 'null' }); if (!text) return null; const parsed = smeParseValveSsr(text); if (!parsed) return null; const ob = smeSsrOrderBook(parsed, hashName); if (!ob) return null; const result = orderBookFromLevels(ob, 'ssr-page'); cache.set(cacheKey, result); return result; } /** * 取指定变体的订单盘(v2.0.0)。 * 同款家族聚合页默认只内嵌**一个**变体(initialFallbackBucketID)的盘, * 用户想要别的变体时必须显式请求 —— 否则会把别的变体的盘当成本变体展示。 */ async function fetchOrderBookForVariant(variantName) { const url = buildVariantUrl(variantName); if (!url) return null; const text = await smeRequest(url, { expect: 'text', cache: 'no-store' }); const parsed = smeParseValveSsr(text); const ob = smeSsrOrderBook(parsed, variantName); if (!ob) return null; return orderBookFromLevels(ob, 'ssr-variant:' + variantName); } /** * 从 SSR 数据中提取订单簿(新版页面,无需 item_nameid)。 * * ★ v2.0.0:改为**变体感知**。同款家族聚合页一次 SSR 里,market/orderbook 只对应同款家族聚合页一次 SSR 里,market/orderbook 只对应 * initialFallbackBucketID 那个变体(实测 URL 是 FT、orderbook 却是 MW), * 盲取会把别的变体的盘当成当前变体展示 → 超价/低价检测与定价建议全部失真。 * 现在从 queryKey 读出盘的归属变体,与用户目标变体比对,结果写进 * variantName / variantMatched,由调用方决定提示还是去取正确变体的盘。 */ function extractOrderBookFromSSR() { // 0. v2.0.0:走纯计算层的结构化解析(变体感知) try { const target = getTargetVariantName(); const ob = smeSsrOrderBook(getPageSsr(), target); if (ob && (ob.sellLevels.length || ob.buyLevels.length || ob.highestBuyCents != null || ob.lowestSellCents != null)) { return orderBookFromLevels(ob, 'ssr-querydata'); } } catch (e) { L('SSR order book parse failed:', e && e.message); } // 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(); const ssrValid = ssrUsable(ssrData); const ssrLevels = ssrData ? Math.max(ssrData.sellOrders.length, ssrData.buyOrders.length) : 0; // 2. v2.0.0:**变体一致性校验**(这是本次止血的核心) // 同款家族聚合页默认内嵌的盘属于 initialFallbackBucketID 那个变体, // 可能不是用户要看的变体。若不一致且用户目标明确 → 显式去取目标变体的盘。 const target = getTargetVariantName(); if (ssrValid && ssrData.variantName && target && ssrData.variantName !== target) { try { const wanted = await fetchOrderBookForVariant(target); if (wanted && ssrUsable(wanted)) { wanted.variantMatched = true; wanted._fetchedForVariant = true; return wanted; } } catch (e) { W('取目标变体订单盘失败,回退页面内嵌盘:', e && e.message); } // 取不到就把「盘属于哪个变体」如实标出来,绝不冒充成目标变体 ssrData.variantMatched = false; ssrData._variantMismatch = true; return ssrData; } if (!force && ssrValid && ssrLevels >= 10) { return ssrData; } if (ssrValid) return ssrData; // 3. 兜底:item_nameid + histogram API。 // ⚠️ v2.0.0:实测全站已无 item_nameid(Market_LoadOrderSpread / g_item_nameid / // "item_nameid" 在 730 与 753 页面出现次数均为 0),该端点实际不可达, // 此路径仅对极老的经典页面有效,保留只为兼容。 const itemNameId = await getItemNameIdAsync(); if (itemNameId) { return await fetchOrderHistogram(itemNameId, force); } if (force) throw new Error('无法强制刷新:新版市场页不再提供 item_nameid(请直接刷新网页)'); 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, retryAfter) { super(message || code); this.name = 'SmeRequestError'; this.code = code; this.status = status || 0; // v2.2.1:429 时带上 Retry-After(秒)—— 调用方据此进入冷却, // 而不是用拍脑袋的短间隔去重试(Steam 的限流窗口以分钟计)。 this.retryAfter = retryAfter || 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) { // v2.2.1:把 Retry-After 带出去(Steam 有时给,有时不给) let retryAfter = 0; try { const ra = Number(resp.headers && resp.headers.get('retry-after')); if (Number.isFinite(ra) && ra > 0) retryAfter = Math.min(ra, 600); } catch (e) { /* 读不到就按缺省冷却 */ } throw new SmeRequestError(SME_ERR.RATE_LIMITED, 429, 'RateLimited', retryAfter); } 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')), }); }); } /** * v2.0.1:库存「交易冷却天数」采集。 * Steam 在 descriptions[] 里给出 market_tradable_restriction / market_marketable_restriction: * · 类级别(GetAssetClassInfo 口径)= **购买后不可交易/上架的天数**,Valve 游戏为 7; * · 注意这不是「剩余天数」——老物品也显示 7(已实测验证)。 * 真正的「解锁日」需要**获得时间**,由成交记录(市场买入)推算,见 smeCooldownList。 * 交易/赠送所得的物品没有买入记录 → 起始时间未知,如实标注而非编造。 */ const invRestrictions = new Map(); // market_hash_name → {marketableDays, tradableDays} function collectInventoryRestrictions(d, name) { if (!d || !name) return; const m = parseInt(d.market_marketable_restriction, 10); const t = parseInt(d.market_tradable_restriction, 10); if (!isFinite(m) && !isFinite(t)) return; invRestrictions.set(name, { marketableDays: isFinite(m) && m > 0 ? m : 0, tradableDays: isFinite(t) && t > 0 ? t : 0, }); } 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); collectInventoryRestrictions(d, name); } } }); } 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); collectInventoryRestrictions(d, name); } } }); } 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; } /** * v2.2.1:库存请求的 429 冷却。 * 原实现:RateLimited 等 800ms / 1600ms 就重试 —— 但 Steam 的限流窗口以**分钟**计, * 这种重试基本必失败,还白白吃掉全局 62s/100 次的请求配额。 * 现在:429 → 进入冷却(优先用 Retry-After,缺省 60s),冷却期内**直接放弃**不再发请求; * 只有网络错误/超时才走短间隔重试。 */ const INV_RATE_LIMIT_COOLDOWN = 60 * 1000; let _invRateLimitedUntil = 0; function fetchOnePage(url, retry) { if (retry === undefined) retry = 0; // 冷却期内:不发请求,直接失败(上层降级用缓存) if (Date.now() < _invRateLimitedUntil) { return Promise.reject(new SmeRequestError( SME_ERR.RATE_LIMITED, 429, 'RateLimited', Math.ceil((_invRateLimitedUntil - Date.now()) / 1000))); } return doFetch(url).catch(err => { const msg = err.message || String(err); if (msg === 'RateLimited') { const cool = (Number(err.retryAfter) > 0 ? Number(err.retryAfter) * 1000 : INV_RATE_LIMIT_COOLDOWN); _invRateLimitedUntil = Date.now() + cool; W('库存请求被限流,进入', Math.round(cool / 1000), 's 冷却(不再重试)'); throw err; // 不再短间隔重试 } 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; // ★ v2.0.0:SSR 优先 —— 零网络请求,且拿到的是**日频中位价 + 成交量** // (旧版 API 是逐笔成交价、无成交量口径,且实测极易 429)。 // 同一次页面加载里 SSR 还带着**同款全部变体**的历史(实测 12 个变体 × 4,995 点)。 try { const h = smeSsrPriceHistory(getPageSsr(), hashName); if (h && h.prices.length) { const wi = getWalletInfo(); const sym = getCurrencySymbol(wi.wallet_currency); const result = { prices: h.prices, prefix: sym.prefix, suffix: sym.suffix, hasVolume: true, _source: 'ssr', }; cache.set(cacheKey, result); return result; } } catch (e) { L('SSR price history unavailable:', e && e.message); } 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() { // 仅缓存成功解析出的钱包信息;完全取不到时的默认值不缓存, // 以便后续调用在页面数据就绪后能拿到正确的货币。 if (_walletInfo) return _walletInfo; // 1) 经典页面的全局变量 g_rgWalletInfo 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 = smeFeeUsesRound(_walletInfo.currency_code); return _walletInfo; } } catch (e) {} // 2) ★ v2.0.0:新版 React 市场页**没有** g_rgWalletInfo / v_currencyformat / // GetCurrencyCode / g_strCountryCode(实测出现次数均为 0), // 旧实现会静默退回硬编码 USD 默认值 → CNY 用户的「税后到手价」按美元算。 // 现在从页面 SSR 上下文取:币种(eCurrency) / 地区与语言(Config) / // 按游戏的发行商费率(listings[].publisherFeePct)。 try { const ssr = getPageSsr(); if (ssr) { const w = smeSsrWallet(ssr); if (w.currency != null || w.country || w.publisherFeePct != null) { const cur = w.currency != null ? w.currency : 1; const code = smeCurrencyCode(cur); _walletInfo = { wallet_fee_percent: 0.05, wallet_fee_base: 0, wallet_fee_minimum: 1, wallet_publisher_fee_percent_default: w.publisherFeePct != null ? w.publisherFeePct : 0.10, wallet_currency: cur, wallet_country: w.country || '', country: w.country || '', language: w.language || '', currency_code: code, use_round: smeFeeUsesRound(code), _fromSsr: true, }; return _walletInfo; } } } catch (e) {} // 默认值:Steam 5% + 发行商 10%(不缓存,下次调用重试) 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: '', country: '', language: '', currency_code: 'USD', use_round: false, }; } /** * Steam 货币 ID → 货币代码映射。 * v2.0.0:旧表与 CURRENCY_SYMBOLS 自相矛盾且缺项(8 漏、38/39/41 写错、43 误写为 JPY), * 现委托纯计算层 SME_CURRENCY_ECODE(依据 Steamworks 官方 ECurrency 表)。 * Proxy 保留 `in` 语义:ISO 代码也能查(旧的 `code in CURRENCY_ID_TO_CODE` 恒为 false)。 */ const CURRENCY_ID_TO_CODE = new Proxy({}, { get(t, k) { if (typeof k !== 'string') return undefined; const id = parseInt(k, 10); if (!isFinite(id)) return undefined; return SME_CURRENCY_ECODE[id] || undefined; }, has(t, k) { if (typeof k !== 'string') return false; for (const code of Object.keys(SME_CURRENCY_SYMBOL)) { if (code === k) return true; } return false; }, }); function getCurrencyCode(currencyId) { try { if (unsafeWindow.GetCurrencyCode) return unsafeWindow.GetCurrencyCode(currencyId); } catch (e) {} // v2.0.0:委托纯计算层的官方映射(Steamworks ECurrency 表) return smeCurrencyCode(currencyId); } // ==================== 汇率换算(v2.2.0 收敛到 shared/fx-core.lib.js) ==================== // v2.2.0 把旧的 _exchangeCache 整层删掉,改走 FxCore。旧层的四个毛病: // ① **没有 payload 日期校验** —— currency-api 的 jsdelivr 副本实测 date=2026-11-05, // pages.dev 副本是 2026-11-06,两个镜像本身差一天,旧层却都当"新鲜"用。 // ② 只有 2 个源(都是 currency-api 镜像),没有 ECB / er-api / AS 冗余。 // ③ 没有无 TTL 快照,也没有他脚本快照救援 —— 接口一挂就只能显示原始货币。 // ④ 交叉汇率 rates[from][to] 在新表落地后不失效,6h TTL 内可能返回旧表算出的值。 // 现在:4 个源 + payload 级新鲜度闸门 + 双锚点隔离 + 快照/救援 + 单飞(不惊群)。 // 宽表模式(wideTable):spot 锚逐币种合并各源,补上 ECB 不公布的币种 //(RUB/TRY/UAH/VND/PHP/NOK/SEK/PLN —— ECB 2022 年后不公布卢布)。 const fxEngine = (window.FxCore && typeof window.FxCore.create === 'function') ? window.FxCore.create({ wideTable: true }) : null; /** * 取汇率(1 fromCode = ? toCode)。 * 走 FxCore 的 spot 锚;取不到就抛(unknown≠0 —— 绝不返回 0,也绝不拿旧值充数)。 */ async function getExchangeRate(fromCode, toCode) { fromCode = String(fromCode || '').toUpperCase(); toCode = String(toCode || '').toUpperCase(); if (!fromCode || !toCode) throw new Error('汇率参数缺失'); if (fromCode === toCode) return 1; if (!fxEngine) throw new Error('汇率库没有载入'); const mem = await fxEngine.load(); const r = window.FxCore.convert(1, fromCode, toCode, mem.spot && mem.spot.rates); if (r === null || !Number.isFinite(r)) throw new Error('汇率不可用:' + fromCode + '→' + toCode); return r; } /** * 同步获取汇率(只读内存里的锚点,缓存未命中返回 null,不发起网络请求) */ function getExchangeRateSync(fromCode, toCode) { fromCode = String(fromCode || '').toUpperCase(); toCode = String(toCode || '').toUpperCase(); if (!fromCode || !toCode) return null; if (fromCode === toCode) return 1; if (!fxEngine) return null; return fxEngine.getRate(fromCode, toCode); } /** * 将指定货币的分值转换为参考货币价格字符串 * @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('宝石'); } /** * 判断是否为箱子/武器箱(v2.2.5,参考 Steam Economy Enhancer 的 getIsCrate) * Steam 库存描述里 tags 是数组,Type 分类的 internal_name 为 Supply Crate; * CS2 武器箱没有这个 tag,只能按名称兜底(Case / 武器箱 / Container)。 */ function isCrate(item) { if (!item) return false; const tags = Array.isArray(item.tags) ? item.tags : []; for (const t of tags) { if (t && /supply crate/i.test(t.internal_name || '')) return true; } const name = (item.market_hash_name || item.name || '').toLowerCase(); return /\bcase\b|\bcrate\b|武器箱|箱子|container/.test(name); } /** * 根据物品类型获取差异化价格范围 * 参考 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 = smeFeeUsesRound(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') }), // v1.9.0:挂刀比价 Tab —— 仅 CS2(730)/DOTA2(570) 详情页且开关开启时渲染 (settings.get('crossPlatformQuote') && (function () { const li = getListingInfo(); return li && (li.appid === '730' || li.appid === '570'); })()) ? h('button', { class: 'sme-tab', id: 'sme-tab-ratio', role: 'tab', 'aria-selected': 'false', html: ICONS.chart + '挂刀比价', onClick: () => switchTab('ratio') }) : null, h('button', { class: 'sme-tab', id: 'sme-tab-settings', role: 'tab', 'aria-selected': 'false', html: ICONS.settings + '设置', onClick: () => switchTab('settings') }), ]), ]), // v2.0.1:交易冷却提示 —— 当前物品若仍在冷却期,在面板顶部明示 // (数据来自本地:Steam 限制天数 + 成交记录里的获得时间) (function () { const li = getListingInfo(); if (!li || !li.marketHashName) return null; const info = invCooldownInfo(li.marketHashName); if (!info) return null; return h('div', { class: 'sme-cooldown-notice', html: ICONS.alert + '该物品仍在交易冷却期:' + info.daysLeft + ' 天后(' + mhFmtTs(info.untilTs).slice(0, 16) + ')才能上架或交易', }); })(), 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: '设置', ratio: '挂刀比价' }[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); else if (tab === 'ratio') renderRatioPane(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'; // ★ v2.0.0:求购单(买盘)摘要 —— 最高求购价之外,再给**总量与档数**, // 并明确「挂到 ≤ 最高求购价即可即时成交」这个可执行结论。 const buyQty = (data.buyOrders || []).reduce((acc, o) => acc + (o.qty || 0), 0); const sellQty = (data.sellOrders || []).reduce((acc, o) => acc + (o.qty || 0), 0); container.appendChild(h('div', { class: 'sme-stats-grid' }, [ makeStatCard(fmtPrice(data.lowestSell), '最低卖单'), makeStatCard(fmtPrice(data.highestBuy), '最高求购价'), makeStatCard(fmtPrice(spread), '买卖价差'), makeStatCard(spreadPct + '%', '价差比'), ])); container.appendChild(h('div', { class: 'sme-stats-grid' }, [ makeStatCard(buyQty.toLocaleString(), '求购总量'), makeStatCard(sellQty.toLocaleString(), '在售总量'), makeStatCard(String((data.buyOrders || []).length), '求购档数'), makeStatCard(String((data.sellOrders || []).length), '在售档数'), ])); if (highestBuy > 0) { container.appendChild(h('div', { class: 'sme-ob-variant', text: '把挂单价压到 ' + fmtPrice(String(highestBuy)) + ' 或更低,即可被求购方直接收走(Steam 按最早挂单优先匹配)。', })); } // ★ v2.0.0:变体归属标注。 // 同款家族聚合页默认内嵌的盘只属于某个磨损变体(实测 URL=FT 时默认盘是 MW), // 不标注的话用户会把别的变体的盘当成本变体 → 定价建议失真。 if (data.variantName) { const isMismatch = data.variantMatched === false; container.appendChild(h('div', { class: 'sme-ob-variant' + (isMismatch ? ' mismatch' : ''), text: isMismatch ? ('⚠ 订单簿属于「' + data.variantName + '」变体,与当前物品不是同一变体,定价建议仅供参考') : ('订单簿变体:' + data.variantName), })); } // ★ v2.0.0:同款各变体最低价一览(buckets[].min_price,零额外请求) const variants = getListingVariants(); if (variants.length > 1) { const target = getTargetVariantName(); const vw = h('div', { class: 'sme-ob-variants' }); vw.appendChild(h('div', { class: 'sme-ob-variants-title', text: '同款各变体最低价' })); const list = h('div', { class: 'sme-ob-variants-list' }); for (const v of variants) { const isTarget = target && v.name === target; list.appendChild(h('div', { class: 'sme-ob-variant-row' + (isTarget ? ' current' : ''), title: v.name, }, [ h('span', { class: 'sme-ob-variant-name', text: v.name }), h('span', { class: 'sme-ob-variant-price', text: v.minPriceCents ? fmtPrice(String(v.minPriceCents)) : '—', }), ])); } vw.appendChild(list); container.appendChild(vw); } 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))), ]); // --- v1.9.0 挂刀比价 --- createGroup(ICONS.chart + '挂刀比价(CS2/DOTA2)', [ makeRow('挂刀比价标签页', '物品详情页显示「挂刀比价」标签:BUFF/悠悠有品在售价 + 挂刀比例 + 转平台到手对比。点开标签才发请求,数据恒为 CNY', makeToggle('crossPlatformQuote', settings.get('crossPlatformQuote'), val => settings.set('crossPlatformQuote', val))), makeRow('自动加载比价', '打开详情页面板后自动在后台加载比价数据(默认关:点开标签才请求,避免不必要的平台流量)', makeToggle('crossPlatformAutoExpand', settings.get('crossPlatformAutoExpand'), val => settings.set('crossPlatformAutoExpand', val))), makeRow('平台扣费系数', '平台到手 ≈ 平台价 × 系数(0.85 为保守估计,含平台成交手续费与提现损耗)', h('select', { class: 'sme-setting-select', html: ['0.80', '0.85', '0.90', '0.95'].map(v => ``).join(''), onChange: e => settings.set('platformFeeFactor', parseFloat(e.target.value)), })), makeRow('池子挂刀列与扫描', '池子明细表显示「挂刀」比例列,并提供手动扫描按钮(仅 BUFF 单源,1500ms/件,单轮 100 件上限,可中断)', makeToggle('poolRatioScan', settings.get('poolRatioScan'), val => settings.set('poolRatioScan', val))), ]); // --- 价格提醒(v2.0.0)--- createGroup('价格提醒', [ makeToggle('priceAlerts', settings.get('priceAlerts'), val => { settings.set('priceAlerts', val); if (val) { initAlertsButton(); requestAlertNotifyPermission(); } else { const b = document.getElementById('sme-alert-btn'); if (b) b.remove(); } }), h('div', { class: 'sme-settings-full', style: { marginTop: '8px', fontSize: '12px' } }, [ h('span', { text: '默认跌幅阈值: ' }), (() => { const inp = h('input', { class: 'sme-qs-input', type: 'number', min: '1', max: '90', step: '1', style: { width: '64px' } }); inp.value = String(Math.round((parseFloat(settings.get('priceAlertDropPct')) || 0.1) * 100)); inp.addEventListener('change', () => { const pct = Math.max(1, Math.min(90, parseInt(inp.value, 10) || 10)) / 100; inp.value = String(Math.round(pct * 100)); settings.set('priceAlertDropPct', pct); }); return inp; })(), h('span', { text: '%' }), ]), makeToggle('priceAlertNotify', settings.get('priceAlertNotify'), val => { settings.set('priceAlertNotify', val); if (val) requestAlertNotifyPermission(); }), h('div', { class: 'sme-settings-hint', style: { fontSize: '11px', color: 'var(--sme-text-muted)', marginTop: '6px' } , text: '提醒只读取价格(priceoverview / 池子日快照),不会代你下单或改价。' }), ]); // --- 数据管理 --- createGroup('数据管理', [ h('div', { class: 'sme-settings-full', style: { marginTop: '12px' } }, [ h('button', { class: 'sme-danger-btn', text: '清除缓存', onClick: async () => { // v2.0.0:缓存已迁到 IndexedDB,清除是异步的 await 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) {} } // v1.9.0:挂刀比价的缓存一并清除(ID 索引 + 比价结果)。 // v2.0.0:已迁到 IndexedDB 的 kv store(GM 侧仅保留同步镜像,不再是数据源) try { await smeKv.remove(RATIO_QUOTES_KEY); for (const appid of RATIO_SUPPORTED_APPS) { await smeKv.remove('sme_ratio_index_v1_' + appid); } ratioState.indexes = {}; ratioState.quotes = null; ratioState.cache = {}; ratioState.circuits = {}; ratioState.itemFails = {}; } 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'); // v2.2.5:只有浮窗「确实可见」时才把按钮排到它左侧;隐藏时排回右下角。 const rankShown = !!rank && rank.style.display !== 'none' && !rank.classList.contains('sme-mr-inline') && getComputedStyle(rank).position === 'fixed'; // 右下角最底部 16px 留给「常驻汇率徽标」,按钮抬高到它上方。 analysisFabEl.style.right = rankShown ? '280px' : '16px'; analysisFabEl.style.bottom = '64px'; } 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 上。 */ /** v1.9.0:设置开启时,详情页面板打开后在后台预取比价数据(不切 Tab) */ function ratioPreloadCurrent() { const li = getListingInfo(); if (!li || !RATIO_SUPPORTED_APPS.includes(li.appid)) return; ratioGetQuotes(li.appid, li.marketHashName).catch(() => {}); } function presentListingPanel(openFloat) { if (detectPageType() !== 'listing') return; if (shouldUseFloatPanel()) { if (panelEl && panelEl.isConnected && !panelEl.closest('#sme-analysis-pop')) panelEl.remove(); ensureAnalysisFab(); if (openFloat) openAnalysisPop(); if (settings.get('crossPlatformAutoExpand')) ratioPreloadCurrent(); return; } removeAnalysisFloat(); injectPanel(); if (settings.get('crossPlatformAutoExpand')) ratioPreloadCurrent(); } 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 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(); } // ---------- 工具栏 ---------- 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 fetchMyListingOrderBook(appid, hash) { // ★ v2.0.0:item_nameid 链路已死(实测全站无此值)→ 改抓物品页 SSR 取订单簿。 await sleep(randInt(INV_REQUEST_DELAY, INV_REQUEST_DELAY + 500)); try { return await withMarketRetry(() => fetchOrderBookByPage(appid, hash), '订单簿'); } 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 = smeKv.getSync(INV_PRICE_STORE_KEY, null); 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; smeKv.set(INV_PRICE_STORE_KEY, JSON.stringify(store)).catch(() => {}); }, 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'); // v2.0.1:后台预热库存限制天数(交易冷却角标需要),不阻塞首屏 ensureInvRestrictionsWarm(); 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(); } // v2.0.1:预热完成后补一次冷却角标(首屏时限制数据可能还没到) setTimeout(() => { for (const el of getInventoryItemElements()) { const data = getRgItemFromElement(el); if (!data || !data.market_hash_name) continue; addCooldownBadgeToItem(el, data.market_hash_name); } }, 1500); } 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 }), // v2.2.5:参考 SEE 的「出售所有箱子」,箱类物品批量清理 h('button', { class: 'sme-inv-btn', html: ICONS.unpack + '出售所有箱子', onclick: onBatchSellCrates }), 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 }), // v1.7.0:把已加载的可市场物品加入「我的池子」,用于池子行情分析 h('button', { class: 'sme-inv-btn', html: ICONS.chart + '加入池子', onclick: poolAddInventoryToPool, title: '把本页已加载的可市场物品加入「我的池子」,用于池子行情分析' }), 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); // v2.0.1:交易冷却角标(仅在能确定仍在冷却期时才显示,避免误报) addCooldownBadgeToItem(el, data.market_hash_name); } } /** * v2.0.1:给库存物品加冷却角标。 * 角标文案「锁 N 天」,title 给出解锁日期与依据。 * 拿不到获得时间(未索引成交记录 / 交易所得)时**不显示** —— 宁可少说,不可乱说。 */ function addCooldownBadgeToItem(itemEl, hashName) { if (!itemEl || itemEl.querySelector('.sme-inv-cooldown')) return; const info = invCooldownInfo(hashName); if (!info) return; const badge = h('span', { class: 'sme-inv-cooldown', text: '锁 ' + info.daysLeft + ' 天', title: '交易冷却中:' + mhFmtTs(info.untilTs).slice(0, 16) + ' 解锁(Steam 限制 ' + info.days + ' 天,自市场买入日起算)', }); itemEl.appendChild(badge); } // 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; } /** 获取库存物品的订单簿(最低卖单 + 最高买单) */ 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) {} // 订单簿(提供最高买单)—— ★ v2.0.0:改抓物品页 SSR。 // 原实现依赖 item_nameid + itemordershistogram,实测全站已无 item_nameid → 恒失败。 try { const ob = await fetchOrderBookByPage(appid, hashName); if (ob) { if (ob.highestBuy) result.highestBuy = parseInt(ob.highestBuy, 10) || 0; if (ob.lowestSell && !result.lowestSell) result.lowestSell = parseInt(ob.lowestSell, 10) || 0; } } catch (e) { W('Inventory order book 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'}, []); // ★ v2.0.0 新增两种定价口径(社区「一键挂卡」标准流程): // median —— 近 N 日中位价(市场真实价,抗单笔异常成交) // ratio —— 挂刀比价目标价(压到「在 Steam 卖 ≈ 在平台卖」) const medianDays = parseInt(settings.get('priceStrategyMedianDays'), 10) || 30; const medianState = { cents: 0, loading: false, done: false }; const ratioStateLocal = { cents: 0, done: false }; // 挂刀目标价:只读已有比价缓存,**绝不主动请求**(跨平台抓取有封 IP 风险) function resolveRatioTarget() { if (ratioStateLocal.done) return ratioStateLocal.cents; ratioStateLocal.done = true; try { const appid = String(data.appid || ''); const name = String(data.market_hash_name || ''); if (!RATIO_SUPPORTED_APPS.includes(appid) || !name) return 0; const cached = ratioState.cache[appid + '::' + name]; if (!cached || !cached.data) return 0; const d = cached.data; const lowCny = (d.buff && d.buff.lowCny) || (d.uuyp && d.uuyp.lowCny); if (!lowCny) return 0; const wi = getWalletInfo(); const code = wi.currency_code || 'USD'; const rate = code === 'CNY' ? 1 : getExchangeRateSync('CNY', code); const fee = parseFloat(settings.get('platformFeeFactor')) || 0.85; ratioStateLocal.cents = smeRatioTargetCents(lowCny, fee, rate); } catch (e) { ratioStateLocal.cents = 0; } return ratioStateLocal.cents; } 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:'median', label:'近 ' + medianDays + ' 日中位价', price: 0, available: true}, {key:'ratio', label:'挂刀比价目标价', price: resolveRatioTarget(), available: resolveRatioTarget() > 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', async () => { modesContainer.querySelectorAll('.sme-qs-mode').forEach(el => el.classList.remove('active')); row.classList.add('active'); // v2.0.0:选中「近 N 日中位价」时才惰性取历史(避免批量出售时打一堆请求) if (m.key === 'median' && !medianState.done && !medianState.loading) { medianState.loading = true; try { const hist = await fetchPriceHistory(String(data.appid || ''), String(data.market_hash_name || '')); medianState.cents = calcWindowMedianCents(hist, medianDays); } catch (e) { medianState.cents = 0; } medianState.loading = false; medianState.done = true; const priceEl = row.querySelector('.sme-qs-mode-price'); if (priceEl) { priceEl.textContent = medianState.cents > 0 ? formatCents(medianState.cents) : '无历史数据'; } updateCustomArea('median'); } 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 === 'median') return medianState.cents; if (mode === 'ratio') return ratioStateLocal.cents; 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, '出售所有卡牌'); } function onBatchSellCrates() { if (invBatchRunning) { toast('已有批量操作进行中', 'warning'); return; } const items = collectSellableItems(isCrate); 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 { // ★ v2.0.0:Steam 新版市场页的 SSR 数据容器是 ← 数据容器 // 页面内联脚本读它后 e.remove(),再赋值 // window.SSR = {loaderData, clientAssets, renderContext} // ⚠️ 旧标记 window.SSR.renderContext=JSON.parse("...) 已彻底消失。 // renderContext.queryData 仍是**嵌套 JSON 字符串**,需二次 parse。 /** 从 HTML 文本中解析 valve-ssr-data → {loaderData, renderContext, config, userConfig} */ function smeParseValveSsr(html) { if (!html || typeof html !== 'string') return null; const m = html.match(/]*id=["']valve-ssr-data["'][^>]*>([\s\S]*?)<\/script>/i); if (!m) return null; let data; try { data = JSON.parse(m[1]); } catch (e) { return null; } if (!data || typeof data !== 'object') return null; return { loaderData: data.loaderData || null, renderContext: data.renderContext || null, clientAssets: data.clientAssets || null, config: data.Config || null, userConfig: data.UserConfig || null, }; } /** 归一化 React Query dehydrated state → [{key, data}](queryData 是嵌套 JSON 字符串) */ function smeSsrQueries(ssr) { const out = []; if (!ssr) return out; let rc = ssr.renderContext; if (typeof rc === 'string') { try { rc = JSON.parse(rc); } catch (e) { rc = null; } } if (!rc || typeof rc !== 'object') return out; let qd = rc.queryData; if (typeof qd === 'string') { try { qd = JSON.parse(qd); } catch (e) { qd = null; } } if (!qd || !Array.isArray(qd.queries)) return out; for (const q of qd.queries) { if (!q || !Array.isArray(q.queryKey)) continue; out.push({ key: q.queryKey, data: q.state ? q.state.data : null }); } return out; } /** * 按 queryKey 前缀查找。 * @param {Array} queries smeSsrQueries 的结果 * @param {Array} prefix 需匹配的前缀片段,如 ['market','orderbook'] * @param {string} [tail] 需完全相等的**末位**元素。 * 市场类 queryKey 形如 ['market','pricehistory',730,'<变体名>'], * 变体名在**最后一位**(不是 prefix.length 位)——故按末位比对。 */ function smeSsrFindQuery(queries, prefix, tail) { for (const q of queries) { const k = q.key; if (k.length < prefix.length) continue; let ok = true; for (let i = 0; i < prefix.length; i++) { if (String(k[i]) !== String(prefix[i])) { ok = false; break; } } if (!ok) continue; if (tail !== undefined && String(k[k.length - 1]) !== String(tail)) continue; return q; } return null; } /** * 解析同款家族聚合页的变体清单。 * loaderData[3].buckets[] = {bucket_id: , classid} * @returns {Array<{name:string, classid:string}>} */ function smeSsrVariants(ssr) { const out = []; if (!ssr) return out; const ld = ssr.loaderData; const entries = Array.isArray(ld) ? ld : (ld && typeof ld === 'object' ? Object.values(ld) : []); for (const entry of entries) { let d = entry; if (typeof d === 'string') { try { d = JSON.parse(d); } catch (e) { continue; } } if (!d || !Array.isArray(d.buckets)) continue; for (const b of d.buckets) { if (!b) continue; const name = b.bucket_id || b.market_hash_name || b.name; if (!name) continue; if (out.some(v => v.name === name)) continue; out.push({ name: String(name), classid: b.classid != null ? String(b.classid) : '' }); } } return out; } /** 焦点变体:initialSelectedBucketID → initialFallbackBucketID → buckets[0] */ function smeSsrFocusVariant(ssr) { if (!ssr) return null; const ld = ssr.loaderData; const entries = Array.isArray(ld) ? ld : (ld && typeof ld === 'object' ? Object.values(ld) : []); for (const entry of entries) { let d = entry; if (typeof d === 'string') { try { d = JSON.parse(d); } catch (e) { continue; } } if (!d || !Array.isArray(d.buckets) || !d.buckets.length) continue; const byId = (id) => d.buckets.find(b => b && String(b.bucket_id) === String(id)); const b = (d.initialSelectedBucketID != null && byId(d.initialSelectedBucketID)) || (d.initialFallbackBucketID != null && byId(d.initialFallbackBucketID)) || d.buckets[0]; if (b && b.bucket_id) return String(b.bucket_id); } return null; } /** * 从 SSR 取订单簿(**变体感知**)。 * * ★ v2.0.0 修复的静默 bug:同款家族聚合页一次 SSR 里混着多个变体的数据 —— * market/orderbook 对应 initialFallbackBucketID(实测 Minimal Wear), * market_item_search 的 listings 对应 buckets[0](实测 Battle-Scarred), * 而 URL 可能 302 自第三个变体(实测 Field-Tested)。 * 旧实现盲取第一个 rgCompactSellOrders → 会把**别的变体的盘**当成当前变体展示, * 导致超价/低价检测与定价建议全部失真。 * 现在:返回盘所属变体名 + 是否与目标一致,由调用方决定提示还是切变体。 * * @param {object} ssr smeParseValveSsr 的结果 * @param {string} [targetName] 目标变体 market_hash_name(用于比对) * @returns {object|null} {sellLevels, buyLevels, highestBuyCents, lowestSellCents, currency, variantName, matched} */ function smeSsrOrderBook(ssr, targetName) { const queries = smeSsrQueries(ssr); const q = smeSsrFindQuery(queries, ['market', 'orderbook']); if (!q || !q.data) return null; const raw = q.data; const sellLevels = smeCompactToLevels(raw.rgCompactSellOrders); const buyLevels = smeCompactToLevels(raw.rgCompactBuyOrders); if (!sellLevels.length && !buyLevels.length && raw.amtMaxBuyOrder == null && raw.amtMinSellOrder == null) { return null; } // queryKey 第 4 位就是该盘所属的变体名 const variantName = q.key.length > 3 ? String(q.key[3]) : ''; const matched = !targetName || !variantName || variantName === String(targetName); return { sellLevels: sellLevels, buyLevels: buyLevels, highestBuyCents: raw.amtMaxBuyOrder != null ? parseInt(raw.amtMaxBuyOrder, 10) : null, lowestSellCents: raw.amtMinSellOrder != null ? parseInt(raw.amtMinSellOrder, 10) : null, currency: raw.eCurrency != null ? parseInt(raw.eCurrency, 10) : null, buyOrderCount: raw.cBuyOrders != null ? parseInt(raw.cBuyOrders, 10) : null, sellOrderCount: raw.cSellOrders != null ? parseInt(raw.cSellOrders, 10) : null, variantName: variantName, matched: matched, }; } /** 压缩数组 [price, qty, price, qty, ...] → [{cents, qty}] */ function smeCompactToLevels(arr) { const out = []; if (!Array.isArray(arr)) return out; for (let i = 0; i + 1 < arr.length; i += 2) { const cents = parseInt(arr[i], 10); const qty = parseInt(arr[i + 1], 10); if (isFinite(cents) && cents > 0 && qty > 0) out.push({ cents: cents, qty: qty }); } return out; } /** * 从 SSR 取价格历史(新版格式:{time, price_median, purchases})。 * 比旧版 API 的 [[date, price, vol]] 多一个**中位价**口径 —— 中位价比裸成交均价 * 更能代表「市场真实价」,抗单笔异常成交。 * @returns {{prices:Array<{t:number, medianCents:number, vol:number}>, ecurrency:number}|null} */ function smeSsrPriceHistory(ssr, name) { const queries = smeSsrQueries(ssr); const q = smeSsrFindQuery(queries, ['market', 'pricehistory'], name !== undefined ? String(name) : undefined); if (!q || !q.data) return null; const rows = q.data.prices; if (!Array.isArray(rows) || !rows.length) return null; const prices = []; for (const r of rows) { if (!r) continue; const t = typeof r.time === 'number' ? r.time : parseInt(r.time, 10); if (!isFinite(t)) continue; const median = typeof r.price_median === 'number' ? r.price_median : parseFloat(r.price_median); if (!isFinite(median)) continue; const vol = parseInt(r.purchases, 10) || 0; // price_median 是**货币单位**(如 194.03)。 // p = 单位金额(与旧版 pricehistory API 的 prices[1] 同口径,消费方无需改) // medianCents = 同一价格的分值(明确口径,避免各处再乘/除 100 出错) prices.push({ t: t * 1000, p: median, medianCents: Math.round(median * 100), vol: vol, v: vol }); } if (!prices.length) return null; return { prices: prices, ecurrency: q.data.ecurrency != null ? parseInt(q.data.ecurrency, 10) : null, }; } /** * 从 SSR 取挂单列表(market_item_search)。 * @returns {{start:number, total:number, listings:Array<{id,cents,bMine,classid,strSubtotal,eCurrency,publisherFeePct}}}|null} */ function smeSsrListings(ssr) { const queries = smeSsrQueries(ssr); const q = smeSsrFindQuery(queries, ['market_item_search']); if (!q || !q.data || !Array.isArray(q.data.pages) || !q.data.pages.length) return null; const page = q.data.pages[q.data.pages.length - 1]; const listings = []; for (const l of (page.listings || [])) { if (!l) continue; listings.push({ id: String(l.listingid || ''), cents: parseInt(l.unPrice, 10) || 0, feeCents: parseInt(l.unFee, 10) || 0, strSubtotal: l.strSubtotal || '', bMine: l.bMine === true, eCurrency: l.eCurrency != null ? parseInt(l.eCurrency, 10) : null, publisherFeePct: typeof l.publisherFeePct === 'number' ? l.publisherFeePct : null, classid: l.description && l.description.classid != null ? String(l.description.classid) : '', }); } return { start: page.start || 0, total: page.total_count || 0, more: page.more === true, listings: listings, }; } /** * 从 SSR 取钱包上下文(新版页不再有 g_rgWalletInfo / v_currencyformat)。 * @returns {{currency:number|null, country:string, language:string, publisherFeePct:number|null}} */ function smeSsrWallet(ssr) { const out = { currency: null, country: '', language: '', publisherFeePct: null }; if (!ssr) return out; if (ssr.config) { out.country = ssr.config.COUNTRY || ''; out.language = ssr.config.LANGUAGE || ''; } const queries = smeSsrQueries(ssr); // 币种:优先 orderbook.eCurrency,其次 market/description.currency const ob = smeSsrFindQuery(queries, ['market', 'orderbook']); if (ob && ob.data && ob.data.eCurrency != null) out.currency = parseInt(ob.data.eCurrency, 10); if (out.currency == null) { const desc = queries.find(q => q.key[0] === 'market' && q.key[1] === 'description' && q.data); if (desc && desc.data && desc.data.currency != null) { // description.currency 是布尔(false = 非特殊货币),不能用;改用 orderbook // 拿不到时退化为从 listings 的 eCurrency 取 } } if (out.currency == null) { const ls = smeSsrFindQuery(queries, ['market_item_search']); if (ls && ls.data && Array.isArray(ls.data.pages) && ls.data.pages.length) { const l0 = (ls.data.pages[ls.data.pages.length - 1].listings || [])[0]; if (l0 && l0.eCurrency != null) out.currency = parseInt(l0.eCurrency, 10); } } // 发行商费率(按游戏):listings[].publisherFeePct(实测 0.10) const ls2 = smeSsrFindQuery(queries, ['market_item_search']); if (ls2 && ls2.data && Array.isArray(ls2.data.pages) && ls2.data.pages.length) { const l0 = (ls2.data.pages[ls2.data.pages.length - 1].listings || [])[0]; if (l0 && typeof l0.publisherFeePct === 'number') out.publisherFeePct = l0.publisherFeePct; } return out; } // ------------------------------------------------------------------ // ③ 纯计算:定价策略 / 价格提醒 / 库存浮盈 / 交易冷却 // ------------------------------------------------------------------ /** * 窗口中位价(v2.0.0):给「近 N 日中位价」定价口径用。 * 兼容两种历史数据结构: * · 新版 SSR:{t, medianCents, vol} —— 每日中位价(已是分) * · 旧版 API:{t, p, v} —— 逐笔成交价(p 是**货币单位**) * @param {object} priceHistory {prices:[...]} * @param {number} days 窗口天数 * @returns {number} 分;无数据返回 0 */ function calcWindowMedianCents(priceHistory, days) { if (!priceHistory || !Array.isArray(priceHistory.prices) || !priceHistory.prices.length) return 0; const win = (days && days > 0) ? days : 30; const cutoff = Date.now() - win * 86400000; const vals = []; for (const p of priceHistory.prices) { if (!p || !(p.t >= 0)) continue; if (p.t < cutoff) continue; let cents = 0; if (p.medianCents != null) cents = p.medianCents; else if (p.p != null) cents = Math.round(p.p * 100); if (cents > 0) vals.push(cents); } if (!vals.length) return 0; vals.sort((a, b) => a - b); const mid = Math.floor(vals.length / 2); return vals.length % 2 ? vals[mid] : Math.round((vals[mid - 1] + vals[mid]) / 2); } /** * 批量定价策略(v2.0.0 新增)。 * 社区「一键挂卡」标准流程的三种口径: * undercut 最低卖价 - N 分(抢单) * median 近 N 日中位价(按市场真实价) * ratio 跨平台目标价(挂刀比价) * @param {object} opt {lowestSellCents, medianCents, targetCents, offsetCents} * @returns {{cents:number, source:string}|null} */ function smeStrategyPrice(opt) { if (!opt) return null; const mode = opt.mode || 'undercut'; const offset = opt.offsetCents || 0; if (mode === 'median') { if (!opt.medianCents) return null; return { cents: Math.max(0, opt.medianCents + offset), source: 'median' }; } if (mode === 'ratio') { if (!opt.targetCents) return null; return { cents: Math.max(0, opt.targetCents + offset), source: 'ratio' }; } if (!opt.lowestSellCents) return null; return { cents: Math.max(0, opt.lowestSellCents - 1 + offset), source: 'undercut' }; } /** * 挂刀目标价(v2.0.0 新增,纯计算): * 平台最低价(CNY 分)× 平台费系数 → 平台到手 → 换算成本地货币的**等效 Steam 售价**。 * 语义:把挂单价压到这个数,在 Steam 卖 ≈ 在那个平台卖。 * @param {number} platformLowCnyCents 平台最低卖价(CNY 分) * @param {number} feeFactor 平台费系数(0~1) * @param {number} cnyToLocal CNY→本地货币汇率(1 = 同币种) * @returns {number} 本地货币分;无法换算返回 0 */ function smeRatioTargetCents(platformLowCnyCents, feeFactor, cnyToLocal) { if (!(platformLowCnyCents > 0)) return 0; const fee = (feeFactor > 0 && feeFactor <= 1) ? feeFactor : 0.85; const netCny = Math.round(platformLowCnyCents * fee); if (!(netCny > 0)) return 0; if (!(cnyToLocal > 0)) return 0; return Math.round(netCny * cnyToLocal); } /** * 价格提醒判定(v2.0.0 新增)。 * 阈值语义:现价 ≤ 基准价 × (1 - dropPct) 即触发。 * 基准默认取 30 日中位价(抗单笔异常)。 * @param {object} o {currentCents, baseCents, dropPct, lastNotifiedCents} * @returns {{hit:boolean, drop:number, reason:string}} */ function smeAlertCheck(o) { if (!o || !o.currentCents || !o.baseCents) return { hit: false, drop: 0, reason: 'no-data' }; const dropPct = o.dropPct != null ? o.dropPct : 0.1; const threshold = o.baseCents * (1 - dropPct); const drop = (o.baseCents - o.currentCents) / o.baseCents; if (o.currentCents > threshold) return { hit: false, drop: drop, reason: 'above' }; // 幂等:同一价位只通知一次(避免每轮轮询重复推送) if (o.lastNotifiedCents != null && o.currentCents >= o.lastNotifiedCents) { return { hit: false, drop: drop, reason: 'already' }; } return { hit: true, drop: drop, reason: 'triggered' }; } /** * 成本基准引擎(FIFO,v2.0.0 新增)。 * * 口径纪律(错一条整张表就歪): * · **成本基准用「卖家到手价」**,不是买家支付价。 * 成交记录里的 priceCents 是买家支付价(含手续费);拿它当成本, * 会让「买入即亏手续费」,成本虚高、浮亏虚高。netOf 负责换算。 * · **卖出按 FIFO 消耗买入批次** → 已实现盈亏才有正确配比。 * · **卖出找不到对应买入记录**(买入发生在历史窗口之外)→ * 单独记 unknownCost 计数,**不猜成本**,UI 如实标注「成本未知」。 * * @param {Array} rows 成交记录 [{name, action:'bought'|'sold', priceCents, tradeTs, game}] * @param {object} [opts] {netOf: (priceCents)=>到手分} * @returns {{holdings:Array, realized:Map, unmatchedSales:number}} */ function smeCostBasis(rows, opts) { const o = opts || {}; const netOf = typeof o.netOf === 'function' ? o.netOf : (c) => Math.round(c / 1.15); // 默认:Steam 5% + 发行商 10% const list = (Array.isArray(rows) ? rows.slice() : []); // 按时间升序;无时间的排到最后(不参与 FIFO 顺序推断) list.sort((a, b) => (a.tradeTs || a.listedTs || 0) - (b.tradeTs || b.listedTs || 0)); const lots = new Map(); // name → [{qty, costCents, ts}] const realized = new Map(); // name → {qty, proceedsCents, costCents, unknownCost} let unmatchedSales = 0; for (const r of list) { const name = String(r.name || ''); if (!name) continue; const ts = r.tradeTs || r.listedTs || 0; if (r.action === 'bought') { if (!lots.has(name)) lots.set(name, []); lots.get(name).push({ qty: 1, costCents: netOf(r.priceCents || 0), ts: ts }); } else if (r.action === 'sold') { const arr = lots.get(name); if (!arr || !arr.length) { // 买入发生在索引窗口之外 → 成本未知,不猜 if (!realized.has(name)) realized.set(name, { qty: 0, proceedsCents: 0, costCents: 0, unknownCost: 0 }); const rec = realized.get(name); rec.qty++; rec.proceedsCents += netOf(r.priceCents || 0); rec.unknownCost++; unmatchedSales++; continue; } const lot = arr.shift(); if (!realized.has(name)) realized.set(name, { qty: 0, proceedsCents: 0, costCents: 0, unknownCost: 0 }); const rec = realized.get(name); rec.qty++; rec.proceedsCents += netOf(r.priceCents || 0); rec.costCents += lot.costCents; } } const holdings = []; for (const [name, arr] of lots) { let qty = 0, totalCost = 0, oldestTs = 0, newestTs = 0; for (const l of arr) { qty += l.qty; totalCost += l.costCents; if (l.ts) { if (!oldestTs || l.ts < oldestTs) oldestTs = l.ts; if (l.ts > newestTs) newestTs = l.ts; } } if (!qty) continue; holdings.push({ name: name, qty: qty, costCents: qty ? Math.round(totalCost / qty) : 0, // 单位成本(分),与 smePnlCalc 同口径 costTotalCents: totalCost, oldestTs: oldestTs, newestTs: newestTs, lots: arr.map(l => ({ qty: l.qty, costCents: l.costCents, ts: l.ts })), }); } holdings.sort((a, b) => (b.costTotalCents - a.costTotalCents)); return { holdings: holdings, realized: realized, unmatchedSales: unmatchedSales }; } /** * 交易冷却清单(v2.0.0 新增)。 * * 规则依据: * · 2024-10-02 起:市场/军械库/交易报价/游戏内商店获得的物品,7 天内不可再交易/上架 * → 冷却天数取自 Steam 自己给的 descriptions[].market_marketable_restriction * (Valve 游戏为 7)与 market_tradable_restriction。 * · 2025-07-16 起:CS2 交易所得物品有 7 天 Trade Protection(可被撤销)。 * * 获得时间来自成交记录(市场买入)。**交易/赠送所得的物品没有买入记录 → * 起始时间未知**,此时只标「锁定中·来源未知」,不编造解锁日期。 * * @param {Array} holdings smeCostBasis().holdings * @param {Object} restrictionDays {name: {marketableDays, tradableDays}} * @param {number} now 当前时间戳 * @returns {Array} 冷却中的持仓 [{name, qty, days, daysLeft, untilTs, earliestUntilTs, source}] */ function smeCooldownList(holdings, restrictionDays, now) { const out = []; const list = Array.isArray(holdings) ? holdings : []; for (const hold of list) { const rule = restrictionDays && restrictionDays[hold.name]; if (!rule) continue; const days = parseInt(rule.marketableDays != null ? rule.marketableDays : rule.tradableDays, 10) || 0; if (days <= 0) continue; if (!hold.newestTs) { // 获得时间未知:无法算解锁日 out.push({ name: hold.name, qty: hold.qty, days: days, daysLeft: null, untilTs: 0, earliestUntilTs: 0, source: 'unknown', }); continue; } const untilTs = hold.newestTs + days * 86400000; const earliestUntilTs = (hold.oldestTs || hold.newestTs) + days * 86400000; const daysLeft = now >= untilTs ? 0 : Math.ceil((untilTs - now) / 86400000); if (daysLeft <= 0) continue; out.push({ name: hold.name, qty: hold.qty, days: days, daysLeft: daysLeft, untilTs: untilTs, earliestUntilTs: earliestUntilTs, source: 'market-buy', }); } out.sort((a, b) => (b.daysLeft || 999) - (a.daysLeft || 999)); return out; } /** * 库存浮盈浮亏(v2.0.0 新增)。 * 成本基准 = 成交记录里的**买入均价**(按 market_hash_name 聚合)。 * @param {Array} holdings [{name, qty, costCents}] * @param {Object} priceMap {name: currentCents} * @returns {{rows:Array, totalCost:number, totalValue:number, totalPnl:number, priced:number, total:number}} */ function smePnlCalc(holdings, priceMap) { const rows = []; let totalCost = 0, totalValue = 0, priced = 0; const list = Array.isArray(holdings) ? holdings : []; for (const h of list) { if (!h || !h.name) continue; const qty = h.qty || 0; const costCents = h.costCents || 0; const cur = priceMap && priceMap[h.name] != null ? priceMap[h.name] : null; const cost = costCents * qty; const value = cur != null ? cur * qty : null; totalCost += cost; if (value != null) { totalValue += value; priced++; } rows.push({ name: h.name, qty: qty, costCents: costCents, currentCents: cur, pnlCents: value != null ? value - cost : null, pnlPct: (value != null && cost > 0) ? (value - cost) / cost : null, }); } return { rows: rows, totalCost: totalCost, totalValue: totalValue, totalPnl: totalValue - totalCost, priced: priced, total: list.length, }; } /** * 交易冷却 / 锁定判定(v2.0.0 新增)。 * 2024-10-02 起:市场/军械库/交易获得的物品 7 天内不可再上架。 * 2025-07-16 起:CS2 交易所得物品 7 天 Trade Protection(可被撤销,撤销者禁交易+市场 30 天)。 * @param {number} acquiredAt 获得时间戳(毫秒);未知传 0 * @param {number} now 当前时间戳 * @param {number} [cooldownDays] 冷却天数,默认 7 * @returns {{locked:boolean, until:number, daysLeft:number}} */ function smeCooldownCalc(acquiredAt, now, cooldownDays) { const days = cooldownDays != null ? cooldownDays : 7; if (!acquiredAt || !now) return { locked: false, until: 0, daysLeft: 0 }; const until = acquiredAt + days * 86400000; if (now >= until) return { locked: false, until: until, daysLeft: 0 }; return { locked: true, until: until, daysLeft: Math.ceil((until - now) / 86400000), }; } // ==================== v2.0.0 纯计算层结束 ==================== // ==================== 启动(等待 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().catch(e => W('初始化失败:', e && e.message)); } }, 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); } })();