出售中...
';
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 = ' 里,
// markdown-reader 会一次性生成 4 万多个 ,预览明显卡顿;
// 部分渲染器还会截断超长表格。分段后每段独立成表,观感与性能都更好。
const CHUNK = 500;
const headRow = '| # | 物品 | 游戏 | 类型 | 价格 | 交易时间 | 上架时间 |';
const sepRow = '| --- | --- | --- | --- | ---: | --- | --- |';
const totalChunks = Math.max(1, Math.ceil(rows.length / CHUNK));
for (let c = 0; c < rows.length; c += CHUNK) {
const part = rows.slice(c, c + CHUNK);
if (totalChunks > 1) L.push(`### 第 ${Math.floor(c / CHUNK) + 1} / ${totalChunks} 段(第 ${c + 1} - ${c + part.length} 条)`);
L.push('');
L.push(headRow);
L.push(sepRow);
part.forEach((r, k) => {
const i = c + k;
L.push(`| ${i + 1} | ${mdCell(r.name)} | ${mdCell(r.game)} | ${r.actionLabel} | ${r.priceCents ? m(r.priceCents) : ''} | ${mhFmtTs(r.tradeTs) || '-'} | ${mhFmtTs(r.listedTs) || '-'} |`);
});
L.push('');
}
L.push('---');
L.push('');
L.push(`> 手续费按当前钱包费率估算,非 Steam 结算值。年份由倒序分页跨年推断得出,AppID 尽力匹配、可能为空。`);
return L.join('\n');
}
/**
* Markdown 单元格转义 —— 标准 GFM 反斜杠转义。
*
* 为什么用标准 `\|` 而不是实体 `|`:
* 历史上为了迁就 scripts-tools/markdown-reader 不认反斜杠的缺陷而改用实体,
* 代价是源码视图里全是 `|` 这类噪声。v1.6.7 起 reader 已支持标准转义,
* 故回归 GFM 标准写法 —— 通用 Markdown 工具链(GitHub / VS Code / Obsidian /
* marked.js 等)都认它,源码也更好读。
*
* 两条兼容保证:
* - 新导出(本函数,标准转义)→ 新 reader ✓
* - v1.6.6 之前的旧导出(实体 |)→ 新 reader ✓(escapeHtml 保留已有实体)
*
* 字符分工:
* & < > → HTML 实体(HTML 上下文,任何渲染器下都安全)
* 其余语法符 → 反斜杠转义(Markdown 标准)
*
* 单次扫描完成映射,避免 & 先被转成 & 后自己插入的实体又被二次编码。
*/
const MD_CELL_ENTITIES = {
'&': '&',
'<': '<',
'>': '>',
};
const MD_CELL_ESCAPES = {
'\\': '\\\\',
'|': '\\|',
'`': '\\`',
'*': '\\*',
'_': '\\_',
'~': '\\~',
'[': '\\[',
']': '\\]',
};
const MD_CELL_RE = /[&<>\\|`*_~[\]]/g;
function mdCell(s) {
const str = String(s == null ? '' : s);
if (!str) return '';
return str
.replace(/\r\n?|\n/g, ' ') // 换行会截断表格行
.replace(MD_CELL_RE, (c) =>
MD_CELL_ENTITIES[c] !== undefined ? MD_CELL_ENTITIES[c] : MD_CELL_ESCAPES[c]);
}
function mhCurrencyCode() {
return getWalletInfo().currency_code || 'USD';
}
/**
* 执行导出。
* @param {object} opt
* format: 'csv' | 'tsv' | 'json' | 'md'
* rows: 导出的记录集,缺省为全部已索引记录
* label: 文件名后缀标注(如"筛选结果")
*/
function mhRunExport(opt) {
opt = opt || {};
const rows = opt.rows && opt.rows.length ? opt.rows : mhState.rows;
if (rows.length === 0) { toast('没有可导出的记录', 'warning'); return false; }
const format = opt.format || mhState.format || 'csv';
const summary = mhSummarize(rows);
const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
const suffix = opt.label ? '-' + opt.label : '';
const base = `steam-market-history${suffix}-${stamp}`;
const ext = { csv: 'csv', tsv: 'tsv', json: 'json', md: 'md' }[format] || 'csv';
const mime = {
csv: 'text/csv', tsv: 'text/tab-separated-values',
json: 'application/json', md: 'text/markdown',
}[format] || 'text/csv';
const content = {
csv: () => mhToCsv(rows, summary),
tsv: () => mhToTsv(rows),
json: () => mhToJson(rows, summary),
md: () => mhToMarkdown(rows, summary),
}[format]();
mhDownload(`${base}.${ext}`, content, mime);
toast(`已导出 ${rows.length} 条记录(${format.toUpperCase()})`, 'success');
return true;
}
/** 确保已索引数据:必要时先征得用户同意再抓取 */
async function mhEnsureData(actionName) {
if (mhState.fetching) { toast('正在抓取中,请稍候…', 'info'); return false; }
if (mhState.rows.length > 0) return true;
const total = mhState.totalCount;
const est = mhEstimateSeconds();
const detail = total
? `共 ${total} 条记录,每页 ${mhPageSize()} 条逐页抓取,预计约 ${est} 秒。`
: '记录较多时可能需要数分钟。抓取期间请勿切换页面。';
const ok = await showConfirmDialog(
`${actionName}前需要先向 Steam 抓取交易记录。
`
+ `${detail} `
+ `抓取结果会缓存在本地 12 小时内,导出与汇总都基于该缓存。`,
{ title: '抓取市场交易记录', okText: '开始抓取', okClass: 'sme-dialog-blue' }
);
if (!ok) return false;
await mhFetchAll();
return mhState.rows.length > 0;
}
// ---------- 分析窗口(v1.6.1) ----------
// 结构参考 Steam-License-Classifier 的 ensureAnalysisShell / activateAnalysisPanel
// (顶部标签栏 + 记住上次标签 + 标题栏主题色跟随)与 Steam-Spending-History-Classifier
// 的 renderModal 通用面板渲染器;图表部分参考其 buildGrid / 折线 / 环形 / 日历热力。
// v1.6.5:榜单从概览页拆出为独立「排行」页。
// 概览页此前把 13+ 张卡 + 两张图表 + 一张可内部滚动的 Top20 表格塞在一起,
// 外层 .sme-mh-panels 与内层 .sme-mh-table-wrap 同时可滚,出现双滚动条且页面极长。
const MH_TABS = [
{ prefix: 'overview', icon: 'chart', label: '概览', title: '概览', color: '#66c0f4' },
{ prefix: 'trend', icon: 'trendingUp', label: '趋势', title: '交易曲线趋势', color: '#4caf50' },
{ prefix: 'rank', icon: 'queue', label: '排行', title: '成交排行 Top 20', color: '#38bdf8' },
{ prefix: 'calendar', icon: 'calendar', label: '日历', title: '交易日历热力', color: '#8b5cf6' },
{ prefix: 'ledger', icon: 'list', label: '明细', title: '交易明细', color: '#3b82f6' },
{ prefix: 'export', icon: 'download', label: '导出', title: '导出数据', color: '#10b981' },
];
const MH_LEDGER_PAGE = 50;
const mhUi = {
lastTab: 'overview',
trendMetric: 'amount', // amount | count
trendCumulative: false,
// v1.6.5:排行页的分组维度与排序依据
rankDim: 'item', // item | game | month
rankSort: 'amount', // amount | count
// v1.6.3:默认全选。金额与笔数分列左右双轴,全选也能读
trendSeries: { soldCents: true, boughtCents: true, netCents: true, feeCents: true, count: true },
ledgerType: 'all',
ledgerDim: 'all', // all | game | item | month(维度筛选,替代下拉框)
ledgerValue: '',
ledgerQuery: '',
ledgerSort: 'tradeTs',
ledgerDesc: true,
ledgerPage: 1,
exportFormat: null, // null = 跟随工具条选择
};
let _mhShell = null;
const _mhMeta = {};
function mhTabIcon(name) {
return ICONS[name] || ICONS.chart;
}
function ensureMhShell() {
if (_mhShell) return _mhShell;
const modal = document.createElement('div');
modal.id = 'sme-mh-modal';
modal.className = 'sme-mh-modal';
const tabsHtml = MH_TABS.map((t) =>
``).join('');
modal.innerHTML = `
${tabsHtml}
${ICONS.alert}
`;
document.body.appendChild(modal);
const hide = () => modal.classList.remove('visible');
modal.addEventListener('click', (e) => { if (e.target === modal) hide(); });
modal.querySelector('#sme-mh-close').addEventListener('click', hide);
// 阻止滚轮穿透到页面
modal.querySelector('.sme-mh-win').addEventListener('wheel', (e) => e.stopPropagation(), { passive: true });
modal.querySelectorAll('.sme-mh-tab').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
mhShowPanel(btn.dataset.tab);
});
});
const onKey = (e) => {
if (e.key === 'Escape') { e.stopPropagation(); hide(); }
else if (e.key === 'Tab') {
const items = [...modal.querySelectorAll('button')].filter((b) => b.offsetParent !== null);
if (!items.length) return;
const idx = items.indexOf(document.activeElement);
const next = e.shiftKey
? items[(idx <= 0 ? items.length : idx) - 1]
: items[(idx + 1) % items.length];
e.preventDefault();
if (next) next.focus();
}
};
document.addEventListener('keydown', onKey, true);
modal._dispose = () => document.removeEventListener('keydown', onKey, true);
_mhShell = {
modal,
panelsEl: modal.querySelector('#sme-mh-panels'),
iconEl: modal.querySelector('#sme-mh-hi'),
titleEl: modal.querySelector('#sme-mh-ht'),
noteEl: modal.querySelector('#sme-mh-note'),
headerEl: modal.querySelector('#sme-mh-header'),
};
return _mhShell;
}
function activateMhPanel(prefix) {
const shell = ensureMhShell();
const tab = MH_TABS.find((t) => t.prefix === prefix) || MH_TABS[0];
const meta = _mhMeta[prefix] || {};
shell.modal.classList.add('visible');
shell.panelsEl.querySelectorAll('.sme-mh-view').forEach((p) => p.classList.toggle('active', p.dataset.panel === prefix));
shell.modal.querySelectorAll('.sme-mh-tab').forEach((b) => b.classList.toggle('active', b.dataset.tab === prefix));
shell.headerEl.style.background = `linear-gradient(135deg,${tab.color}26,rgba(13,15,21,0.9))`;
shell.headerEl.style.borderBottomColor = `${tab.color}40`;
shell.iconEl.style.color = tab.color;
shell.iconEl.innerHTML = mhTabIcon(tab.icon);
shell.titleEl.textContent = meta.title || tab.title;
shell.noteEl.textContent = meta.note || '';
mhUi.lastTab = prefix;
}
/** 通用面板渲染器:建/复用面板 → 写 HTML → postRender → 显示并激活 */
function mhRenderPane(prefix, note, buildFn, postRender) {
const shell = ensureMhShell();
const tab = MH_TABS.find((t) => t.prefix === prefix) || MH_TABS[0];
_mhMeta[prefix] = { title: tab.title, note: note };
let panel = shell.panelsEl.querySelector(`.sme-mh-view[data-panel="${prefix}"]`);
if (!panel) {
panel = document.createElement('div');
panel.className = 'sme-mh-view';
panel.dataset.panel = prefix;
const body = document.createElement('div');
body.className = 'sme-mh-pbody';
body.id = `sme-mh-${prefix}-body`;
panel.appendChild(body);
shell.panelsEl.appendChild(panel);
}
const bodyEl = panel.querySelector('.sme-mh-pbody');
// 面板重建前清理上一轮注册的事件
if (bodyEl._cleanup) { try { bodyEl._cleanup(); } catch (e) {} bodyEl._cleanup = null; }
let html;
try { html = buildFn(); }
catch (err) {
W('MH pane render failed:', prefix, err);
html = `面板渲染失败:${escapeHtml(err.message)} `;
}
bodyEl.innerHTML = html;
if (postRender) postRender(bodyEl);
activateMhPanel(prefix);
return bodyEl;
}
function mhShowPanel(prefix) {
if (mhState.rows.length === 0) return;
const s = mhSummarize(mhState.rows);
const code = mhCurrencyCode();
const wi = getWalletInfo();
const money = (c) => formatCentsStr(c, code);
const truncated = mhState.totalCount > s.count
? ` Steam 报告共 ${mhState.totalCount} 条,当前已索引 ${s.count} 条。` : '';
const baseNote = '手续费按当前钱包费率(Steam '
+ Math.round(wi.wallet_fee_percent * 100) + '% + 发行商 '
+ Math.round(wi.wallet_publisher_fee_percent_default * 100) + '%)估算,非 Steam 结算值;'
+ '年份由倒序分页跨年推断得出;AppID 为尽力匹配,可能为空。' + truncated;
switch (prefix) {
case 'overview': return mhPaneOverview(s, money, baseNote);
case 'rank': return mhPaneRank(s, money, baseNote);
case 'trend': return mhPaneTrend(s, money, baseNote);
case 'calendar': return mhPaneCalendar(s, money, baseNote);
case 'ledger': return mhPaneLedger(s, money, baseNote);
case 'export': return mhPaneExport(s, money, baseNote);
default: return mhPaneOverview(s, money, baseNote);
}
}
function openMhAnalysis() {
mhShowPanel(mhUi.lastTab || 'overview');
}
// ---------- 通用图表零件 ----------
function mhAxisNum(cents, metric) {
if (metric === 'count') return String(Math.round(cents));
const code = mhCurrencyCode();
if (CURRENCY_CODES_TO_ROUND.includes(code)) return String(Math.round(cents / 100));
const n = cents / 100;
if (n >= 10000) return (n / 1000).toFixed(0) + 'k';
if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
if (n >= 10) return n.toFixed(0);
return n.toFixed(1);
}
/** 横向网格 + Y 轴刻度(参考 buildGrid) */
function mhGrid(min, max, pL, pR, pT, cH, metric) {
let g = '';
for (let i = 0; i <= 5; i++) {
const v = min + (max - min) * (i / 5);
const y = pT + cH - (cH * i / 5);
g += ``
+ `${mhAxisNum(v, metric)}`;
}
return g;
}
function mhStatCard(label, value, cls, icon, hint, accent) {
const tone = /^#[0-9a-fA-F]{6}$/.test(accent || '') ? ` style="--accent:${accent}"` : '';
return ``
+ (icon ? ` ${icon}` : '')
+ ` ${escapeHtml(label)} `
+ ` ${escapeHtml(value)} `
+ (hint ? ` ${escapeHtml(hint)} ` : '')
+ ' ';
}
function mhPaneOverview(s, money, note) {
const code = mhCurrencyCode();
const period = s.firstTs ? `${mhFmtTs(s.firstTs).slice(0, 10)} ~ ${mhFmtTs(s.lastTs).slice(0, 10)}` : '未知';
const hold = s.hold;
const activeShare = s.spanDays ? Math.round(s.activeDays / s.spanDays * 1000) / 10 : 0;
const avgBuyCents = s.bought.count ? Math.round(s.bought.grossCents / s.bought.count) : 0;
// 阅读顺序:汇总 → 买入 → 卖出。同类指标排在一起。
const cards = [
mhStatCard('记录总数', String(s.count), '', ICONS.layers, '', '#f5a524'),
mhStatCard('统计天数', `${s.spanDays} 天`, '', ICONS.clock, period, '#38bdf8'),
mhStatCard('活跃天数', `${s.activeDays} 天`, '', ICONS.check, s.spanDays ? `占统计天数 ${activeShare}%` : '', '#22d3ee'),
mhStatCard('日均 / 月均笔数', `${s.perDay} / ${s.perMonth}`, '', ICONS.chart, '', '#60a5fa'),
mhStatCard('净收益', money(s.netCents), s.netCents >= 0 ? 'sme-up' : 'sme-down', ICONS.trendingUp, '', '#2dd4bf'),
mhStatCard('ROI(净收入 ÷ 买入额)', s.roi === null ? '—' : s.roi + '%', s.roi === null ? '' : (s.roi >= 0 ? 'sme-up' : 'sme-down'), ICONS.trendingUp, '', s.roi !== null && s.roi < 0 ? '#f472b6' : '#4ade80'),
mhStatCard('买入笔数', String(s.bought.count), '', ICONS.trendingDown, '', '#fb7185'),
mhStatCard('买入总额', money(s.bought.grossCents), '', ICONS.bag, '', '#f87171'),
mhStatCard('买入均价', money(avgBuyCents), '', ICONS.tag, s.bought.count ? `${s.bought.count} 笔` : '暂无买入', '#f472b6'),
mhStatCard('卖出笔数', String(s.sold.count), '', ICONS.trendingUp, '', '#4ade80'),
mhStatCard('卖出总额', money(s.sold.grossCents), '', ICONS.sell, '', '#34d399'),
mhStatCard('估算手续费', money(s.sold.feeCents), '', ICONS.dollarSign, '', '#fbbf24'),
mhStatCard('卖出净收入', money(s.sold.netCents), 'sme-up', ICONS.check, '', '#10b981'),
mhStatCard('卖出均价', money(s.avgSoldCents), '', ICONS.tag, `中位 ${money(s.medianSoldCents)}`, '#a78bfa'),
mhStatCard('最高成交', money(s.maxSoldCents), '', ICONS.gem, `区间 ${money(s.minSoldCents)} ~ ${money(s.maxSoldCents)}`, '#818cf8'),
mhStatCard('平均持有时长', mhFmtDuration(hold.avgMs), '', ICONS.clock, hold.count ? `中位 ${mhFmtDuration(hold.medianMs)} · 样本 ${hold.count}` : '缺少可对比的上下架时间', '#c084fc'),
];
// v1.6.5:收支对比改为「双占比环 + 关键数字」紧凑布局。
// 原先只是一根细进度条,卡片被撑到 250px 高却只有 40px 内容,右侧价格分布
// 被挤成窄条。现在两侧卡片高度对齐、内容填满。
const inCents = s.sold.netCents, outCents = s.bought.grossCents;
const sum = Math.max(1, inCents + outCents);
const inPct = (inCents / sum * 100);
const donut = (() => {
const R = 52, C = 2 * Math.PI * R;
const inLen = C * (inPct / 100);
return ``;
})();
const flowRows = [
{ label: '卖出总额', v: s.sold.grossCents, c: '#4ade80', sub: `${s.sold.count} 笔` },
{ label: '减 估算手续费', v: -s.sold.feeCents, c: '#fbbf24', sub: `${Math.round(s.sold.feeCents / Math.max(1, s.sold.grossCents) * 1000) / 10}%` },
{ label: '卖出净收入', v: s.sold.netCents, c: '#4ade80', sub: '', strong: true },
{ label: '买入总额', v: s.bought.grossCents, c: '#f87171', sub: `${s.bought.count} 笔` },
{ label: '净收益', v: s.netCents, c: s.netCents >= 0 ? '#66c0f4' : '#f87171', sub: '', strong: true },
];
const bar = `
${donut}
${flowRows.map((r) => `
${escapeHtml(r.label)}
${r.sub ? `${escapeHtml(r.sub)}` : ''}
${r.v < 0 ? '−' : ''}${escapeHtml(money(Math.abs(r.v)))}
`).join('')}
`;
// 价格分布:对数分档。价格通常跨 3–4 个数量级(₹0.01 ~ ₹1826),
// 等宽分档会把 99% 的样本挤进第一档(实测 5263/5266),直方图完全失真。
let hist = '暂无价格数据 ';
if (s.priceBuckets.length) {
const maxC = Math.max(...s.priceBuckets.map((b) => b.count), 1);
const totalC = s.priceBuckets.reduce((a, b) => a + b.count, 0);
const fmtV = (c) => {
const n = c / 100;
if (n >= 1000) return (n / 1000).toFixed(n >= 10000 ? 0 : 1) + 'k';
return n >= 100 ? n.toFixed(0) : n.toFixed(n >= 1 ? 2 : 3);
};
hist = '' + s.priceBuckets.map((b) => {
const pct = (b.count / maxC) * 100;
const share = totalC ? (b.count / totalC * 100) : 0;
const range = fmtV(b.from) === fmtV(b.to) ? fmtV(b.from) : `${fmtV(b.from)}~${fmtV(b.to)}`;
return ` `
+ ` `
+ ` ${b.count} `
+ ` ${escapeHtml(fmtV(b.from))} `;
}).join('') + ' '
+ `成交价分布 · ${escapeHtml(code)} · 对数分档 ${s.priceBuckets.length} 档`
+ ` · 横轴为该档起始价 `;
}
return mhRenderPane('overview', note, () =>
`${cards.join('')} `
+ ``
);
}
// ---------- 面板:排行(v1.6.5 从概览拆出) ----------
const MH_RANK_DIMS = [
{ id: 'item', label: '按物品', unit: '件' },
{ id: 'game', label: '按游戏', unit: '个' },
{ id: 'month', label: '按月份', unit: '个月' },
];
const MH_RANK_SORTS = [
{ id: 'amount', label: '按金额' },
{ id: 'count', label: '按笔数' },
];
const MH_RANK_TOP = 20;
/** 汇总三个维度的排行数据(不受设置的 TopN 截断影响,这里要完整 Top20) */
function mhRankGroups(dim) {
const map = new Map();
const keyOf = (r) => {
if (dim === 'game') return r.game || '未知游戏';
if (dim === 'month') return r.tradeTs ? mhMonthKey(r.tradeTs) : '';
return r.name || '';
};
const extraOf = (r, g) => { if (dim === 'item') g.game = r.game || ''; };
for (const r of mhState.rows) {
const k = keyOf(r);
if (!k) continue;
let g = map.get(k);
if (!g) { g = { key: k, game: '', count: 0, soldCount: 0, boughtCount: 0, soldCents: 0, boughtCents: 0, firstTs: 0, lastTs: 0 }; map.set(k, g); }
extraOf(r, g);
g.count++;
if (r.tradeTs) { if (!g.firstTs || r.tradeTs < g.firstTs) g.firstTs = r.tradeTs; if (r.tradeTs > g.lastTs) g.lastTs = r.tradeTs; }
if (r.action === 'sold') { g.soldCount++; g.soldCents += r.priceCents; }
else if (r.action === 'bought') { g.boughtCount++; g.boughtCents += r.priceCents; }
}
for (const g of map.values()) {
g.totalCents = g.soldCents + g.boughtCents;
g.avgCents = g.soldCount ? Math.round(g.soldCents / g.soldCount) : 0;
}
return [...map.values()];
}
function mhPaneRank(s, money, note) {
const dim = MH_RANK_DIMS.some((d) => d.id === mhUi.rankDim) ? mhUi.rankDim : 'item';
const sortBy = mhUi.rankSort === 'count' ? 'count' : 'amount';
const all = mhRankGroups(dim);
const metric = (g) => (sortBy === 'count' ? g.count : g.totalCents);
const list = all.slice().sort((a, b) => metric(b) - metric(a) || b.count - a.count || a.key.localeCompare(b.key));
const shown = list.slice(0, MH_RANK_TOP);
const peak = shown.length ? Math.max(...shown.map((g) => metric(g)), 1) : 1;
const restCount = Math.max(0, list.length - shown.length);
const restMetric = all.slice(MH_RANK_TOP).reduce((a, g) => a + metric(g), 0);
const dimDef = MH_RANK_DIMS.find((d) => d.id === dim);
return mhRenderPane('rank', note, () => {
const dimBtn = (d) => ``;
const sortBtn = (x) => ``;
const toolbar = `
分组${MH_RANK_DIMS.map(dimBtn).join('')}
排序${MH_RANK_SORTS.map(sortBtn).join('')}
共 ${all.length} ${escapeHtml(dimDef.unit)},显示 Top ${shown.length}${restCount ? `(其余 ${restCount} ${escapeHtml(dimDef.unit)}合计 ${escapeHtml(sortBy === 'count' ? String(restMetric) : money(restMetric))})` : ''}
`;
if (!shown.length) return toolbar + '暂无符合条件的成交记录 ';
// 占比条:直观看出每个条目在当前排序依据下的分量
const rows = shown.map((g, i) => {
const m = metric(g);
const w = Math.max(1.5, (m / peak) * 100);
const head = dim === 'item'
? ` | ${escapeHtml(g.key)}${escapeHtml(g.game || '')} | `
: `${escapeHtml(g.key)} | `;
return `
| ${i + 1} |
${head}
|
${g.count} |
${g.soldCount} |
${g.boughtCount} |
${g.soldCents ? escapeHtml(money(g.soldCents)) : '—'} |
${g.boughtCents ? escapeHtml(money(g.boughtCents)) : '—'} |
${g.avgCents ? escapeHtml(money(g.avgCents)) : '—'} |
${g.firstTs ? escapeHtml(mhFmtTs(g.firstTs).slice(0, 10)) : '—'} |
`;
}).join('');
return toolbar
+ `
| # | ${escapeHtml(dimDef.label.replace('按', ''))} |
占比 |
笔数 | 卖出 | 买入 | 卖出金额 | 买入金额 | 卖出均价 | 首次成交 |
${rows}
`;
}, (bodyEl) => {
const bar = bodyEl.querySelector('.sme-mh-toolbar');
const onClick = (e) => {
const chip = e.target.closest('button.sme-mh-chip');
if (!chip) return;
if (chip.dataset.rdim) mhUi.rankDim = chip.dataset.rdim;
else if (chip.dataset.rsort) mhUi.rankSort = chip.dataset.rsort;
else return;
mhShowPanel('rank');
};
bar.addEventListener('click', onClick);
bodyEl._cleanup = () => bar.removeEventListener('click', onClick);
});
}
// ---------- 面板 2:趋势(曲线) ----------
// v1.6.3:金额类与笔数量级相差 4 个数量级,原先共用一个 Y 轴会把笔数压成直线。
// 改为**双 Y 轴**:左轴金额、右轴笔数,各自独立缩放,默认全选也能读。
const MH_TREND_SERIES = [
{ key: 'soldCents', label: '卖出金额', color: '#4ade80', axis: 'money' },
{ key: 'boughtCents', label: '买入金额', color: '#f87171', axis: 'money' },
{ key: 'netCents', label: '净额', color: '#66c0f4', axis: 'money' },
{ key: 'feeCents', label: '手续费', color: '#fbbf24', axis: 'money' },
{ key: 'count', label: '成交笔数', color: '#a78bfa', axis: 'count' },
];
/** 分位数裁剪的 Y 轴域:避免个别尖峰把其余数据压成一条线 */
function mhRobustDomain(values, includeZero) {
const v = values.filter((x) => Number.isFinite(x)).sort((a, b) => a - b);
if (!v.length) return { lo: 0, hi: 1, clipped: 0 };
const at = (q) => v[Math.min(v.length - 1, Math.max(0, Math.round((v.length - 1) * q)))];
let lo = at(0.02), hi = at(0.98);
if (includeZero) lo = Math.min(lo, 0);
if (hi === lo) hi = lo + Math.max(1, Math.abs(lo) * 0.1);
const pad = (hi - lo) * 0.1;
const dom = { lo: lo - pad, hi: hi + pad, clipped: 0 };
for (const x of v) if (x < dom.lo || x > dom.hi) dom.clipped++;
return dom;
}
function mhPaneTrend(s, money, note) {
const metric = mhUi.trendMetric;
const series = mhMonthlySeries(mhState.rows, true);
const vis = MH_TREND_SERIES.filter((x) => mhUi.trendSeries[x.key]);
const showMoney = vis.some((d) => d.axis === 'money');
const showCount = vis.some((d) => d.axis === 'count');
const build = () => {
if (!series.length) return '暂无可推断日期的记录
';
if (!vis.length) return '请至少勾选一条曲线
';
// 金额统一换算为货币单位,笔数保持原值
let data = series.map((x) => ({
month: x.month,
empty: !!x.empty,
money: {
soldCents: (x.soldCents || 0) / 100,
boughtCents: (x.boughtCents || 0) / 100,
netCents: (x.netCents || 0) / 100,
feeCents: (x.feeCents || 0) / 100,
},
count: x.count || 0,
}));
if (mhUi.trendCumulative) {
const cm = {}, cc = { count: 0 };
data = data.map((o) => {
for (const k of ['soldCents', 'boughtCents', 'netCents', 'feeCents']) cm[k] = (cm[k] || 0) + o.money[k];
cc.count += o.count;
return { month: o.month, empty: o.empty, money: Object.assign({}, cm), count: cc.count };
});
}
const moneyVals = [], countVals = [];
for (const o of data) {
for (const d of vis) {
if (d.axis === 'money') moneyVals.push(o.money[d.key]);
else countVals.push(o.count);
}
}
const mDom = showMoney ? mhRobustDomain(moneyVals, true) : null;
const cDom = showCount ? mhRobustDomain(countVals, false) : null;
const svgW = 1080, svgH = 480, pT = 52, pB = 64;
const pL = 78, pR = showCount ? 74 : 28;
const cW = svgW - pL - pR, cH = svgH - pT - pB;
const step = data.length > 1 ? cW / (data.length - 1) : 0;
const xOf = (i) => pL + i * step;
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const yMoney = (v) => pT + cH - ((clamp(v, mDom.lo, mDom.hi) - mDom.lo) / (mDom.hi - mDom.lo)) * cH;
const yCount = (v) => pT + cH - ((clamp(v, cDom.lo, cDom.hi) - cDom.lo) / (cDom.hi - cDom.lo)) * cH;
const yOf = (d, o) => (d.axis === 'money' ? yMoney(o.money[d.key]) : yCount(o.count));
// 网格 + 左侧金额刻度
let grid = '';
for (let i = 0; i <= 5; i++) {
const y = pT + cH - (cH * i / 5);
grid += ``;
if (showMoney) {
const v = mDom.lo + (mDom.hi - mDom.lo) * (i / 5);
grid += `${mhAxisNum(Math.round(v * 100), 'amount')}`;
}
}
// 右侧笔数刻度
if (showCount) {
for (let i = 0; i <= 5; i++) {
const y = pT + cH - (cH * i / 5);
const v = cDom.lo + (cDom.hi - cDom.lo) * (i / 5);
grid += `${mhAxisNum(Math.round(v), 'count')}`;
}
}
// 轴标题与时间段同一行:左右是轴名,中间是区间,都落在绘图区上方
const activeMonths = series.filter((x) => !x.empty).length;
const rangeLabel = `${series[0].month} – ${series[series.length - 1].month} · ${activeMonths}/${series.length} 月`;
const midX = (pL + svgW - pR) / 2;
if (showMoney) grid += `金额 ${escapeHtml(mhCurrencyCode())}`;
if (showCount) grid += `笔数`;
grid += `${escapeHtml(rangeLabel)}`;
// 零基线(仅当两轴都跨越 0 时画在金额轴上)
let zeroLine = '';
if (showMoney && mDom.lo < 0 && mDom.hi > 0) {
const zy = yMoney(0).toFixed(1);
zeroLine = ``;
}
const clipId = 'sme-mh-clip';
const lines = vis.map((d) => {
const pts = data.map((o, i) => `${xOf(i).toFixed(1)},${yOf(d, o).toFixed(1)}`).join(' ');
const baseY = (d.axis === 'money' ? yMoney(Math.max(mDom.lo, 0)) : yCount(Math.max(cDom.lo, 0))).toFixed(1);
const area = `${pL},${baseY} ${pts} ${xOf(data.length - 1).toFixed(1)},${baseY}`;
return ``
+ ``
+ ``
+ ``;
}).join('');
const dots = data.length <= 40 ? vis.map((d) =>
data.map((o, i) => ``
+ `${escapeHtml(o.month)} ${escapeHtml(d.label)}: ${escapeHtml(d.axis === 'money' ? money(Math.round(o.money[d.key] * 100)) : String(o.count))}`).join('')).join('') : '';
// X 轴:稀疏标注年份 + 端点月份
let labels = '';
let lastKey = '';
data.forEach((o, i) => {
const x = xOf(i);
const parts = o.month.split('-');
const yy = parts[0], mm = parts[1];
if ((mm === '01' || i === 0) && lastKey !== yy) {
labels += `${yy}`;
lastKey = yy;
}
if (i === data.length - 1 || (mm !== '01' && data.length <= 14)) {
labels += `${+mm}月`;
}
});
const clipDef = ``;
const tip = (mDom && mDom.clipped) || (cDom && cDom.clipped)
? `Y 轴按 2%–98% 分位裁剪以突出主体走势,超出范围的点贴边显示(共 ${(mDom ? mDom.clipped : 0) + (cDom ? cDom.clipped : 0)} 个)
`
: '';
const plotFrame = ``;
// padding-bottom 与 viewBox 同比例,SVG 用 none 铺满这块区域,宽屏两侧不再留白
const plotRatio = (svgH / svgW * 100).toFixed(4);
return ``
+ `
`
+ `
'
+ tip;
};
return mhRenderPane('trend', note, () => {
const totals = { soldCents: 0, boughtCents: 0, netCents: 0, feeCents: 0, count: 0 };
for (const x of series) {
if (x.empty) continue;
totals.soldCents += x.soldCents || 0;
totals.boughtCents += x.boughtCents || 0;
totals.netCents += x.netCents || 0;
totals.feeCents += x.feeCents || 0;
totals.count += x.count || 0;
}
const hintOf = (d) => d.axis === 'count'
? `${totals.count}笔`
: money(Math.round(totals[d.key] || 0));
const toggles = MH_TREND_SERIES.map((d) =>
``).join('');
const metricBtn = (v, label) =>
``;
const cumBtn = ``;
return `
口径${metricBtn('amount', '金额优先')}${metricBtn('count', '笔数优先')}
曲线${toggles}
${cumBtn}
${build()}
`;
}, (bodyEl) => {
const chartEl = bodyEl.querySelector('#sme-mh-trend-chart');
const redraw = () => { chartEl.innerHTML = build(); };
const bar = bodyEl.querySelector('.sme-mh-toolbar');
const onClick = (e) => {
const btn = e.target.closest('button.sme-mh-chip');
if (!btn) return;
if (btn.dataset.metric) { mhUi.trendMetric = btn.dataset.metric; }
else if (btn.dataset.cum !== undefined) { mhUi.trendCumulative = !mhUi.trendCumulative; }
else if (btn.dataset.series) {
const k = btn.dataset.series;
mhUi.trendSeries[k] = !mhUi.trendSeries[k];
}
bar.innerHTML = bar.innerHTML; // no-op,保持引用
mhShowPanel('trend');
};
bar.addEventListener('click', onClick);
bodyEl._cleanup = () => bar.removeEventListener('click', onClick);
});
}
// ---------- 面板 4:日历热力 ----------
/**
* v1.6.4 日历色阶。
* 旧版用 rgba 透明度叠在深底上,最高才到 .95,格子与背景几乎融在一起;
* 且格内数字统一深色,在亮底上又看不清。现改为**实色 6 档递进**,
* 每档同时给出背景色与配套文字色,保证任意档位下数字都可读。
*/
const MH_CAL_SCALE = [
{ bg: '#16202b', fg: '#5d6f80' }, // 无记录:可见但明显为空
{ bg: '#123b52', fg: '#9fd4ee' }, // 1
{ bg: '#175a7d', fg: '#b8e2f7' }, // 2
{ bg: '#1a6e94', fg: '#e2f4ff' }, // 3(背景压深一档以满足 AA 对比)
{ bg: '#28a0cc', fg: '#06202e' }, // 4(转深色字)
{ bg: '#48c2e8', fg: '#052030' }, // 5
{ bg: '#86dcf5', fg: '#062535' }, // 6(峰值)
];
const MH_CAL_EMPTY = { bg: 'transparent', fg: 'transparent' };
function mhCalLevel(v, mx) {
if (!v || v <= 0) return -1;
const p = v / mx;
// 6 档等分;最低档 1,峰值落在最后一档
let lv = Math.ceil(p * 6);
if (lv < 1) lv = 1;
if (lv > 6) lv = 6;
return lv;
}
function mhPaneCalendar(s, money, note) {
const daily = new Map(s.daily.map((d) => [d.day, d]));
const years = [...new Set(s.daily.map((d) => d.day.slice(0, 4)))].sort();
const maxCount = Math.max(1, ...s.daily.map((d) => d.count));
const maxCents = Math.max(1, ...s.daily.map((d) => d.soldCents + d.boughtCents));
return mhRenderPane('calendar', note, () => {
if (!years.length) return '暂无可推断日期的记录,无法生成日历
';
const MD = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十', '十一', '十二'];
const buildYear = (y) => {
const first = new Date(Number(y), 0, 1);
const last = new Date(Number(y), 11, 31);
const startDay = (first.getDay() + 6) % 7; // 周一为第一列
const totalDays = Math.round((last - first) / 86400000) + 1;
let cells = '';
for (let i = 0; i < startDay; i++) cells += '';
for (let i = 0; i < totalDays; i++) {
const cd = new Date(first);
cd.setDate(cd.getDate() + i);
const key = y + '-' + String(cd.getMonth() + 1).padStart(2, '0') + '-' + String(cd.getDate()).padStart(2, '0');
const d = daily.get(key);
const n = d ? d.count : 0;
const lv = mhCalLevel(n, maxCount);
const c = lv < 0 ? MH_CAL_EMPTY : MH_CAL_SCALE[lv];
const tip = d
? `${key}|${d.count} 笔|卖出 ${money(d.soldCents)}|买入 ${money(d.boughtCents)}`
: `${key}|无记录`;
cells += ``
+ (n ? `${n}` : '') + '
';
}
return `
${escapeHtml(y)}
${MD.map((m) => `${m}`).join('')}
${['一', '二', '三', '四', '五', '六', '日'].map((w) => `${w}`).join('')}
${cells}
`;
};
const totalCents = s.daily.reduce((a, d) => a + d.soldCents + d.boughtCents, 0);
const activeCells = s.daily.length;
const legendCells = MH_CAL_SCALE.map((c) =>
``).join('');
return `
按笔数着色
少${legendCells}多
单日最多 ${maxCount} 笔 · 有成交 ${activeCells} 天 · `
+ `合计成交额 ${escapeHtml(money(totalCents))}(单日峰值 ${escapeHtml(money(maxCents))})
`
+ `${years.map(buildYear).join('')}
`;
});
}
// ---------- 面板 5:明细 ----------
const MH_LEDGER_DIMS = [
{ id: 'all', label: '全部' },
{ id: 'game', label: '按游戏' },
{ id: 'item', label: '按物品' },
{ id: 'month', label: '按月份' },
];
/** 维度 → 行上的取值键 */
function mhLedgerDimKey(dim, r) {
if (dim === 'game') return r.game || '未知游戏';
if (dim === 'item') return r.name || '';
if (dim === 'month') return r.tradeTs ? mhMonthKey(r.tradeTs) : '';
return '';
}
/** 当前维度下的候选取值,按成交额降序取前 N(供 chips 使用) */
function mhLedgerDimOptions(dim) {
if (dim === 'all') return [];
const cnt = new Map();
for (const r of mhState.rows) {
if (mhUi.ledgerType !== 'all' && r.action !== mhUi.ledgerType) continue;
const k = mhLedgerDimKey(dim, r);
if (!k) continue;
cnt.set(k, (cnt.get(k) || 0) + (r.action === 'sold' ? r.priceCents : 0));
}
return [...cnt.entries()]
// 成交额降序;金额相同时按名称升序,保证顺序可预期而不是随 Map 插入序漂移
.sort((a, b) => (b[1] - a[1]) || a[0].localeCompare(b[0]))
.map(([k]) => k);
}
function mhFilteredRows() {
const q = mhUi.ledgerQuery.trim().toLowerCase();
let rows = mhState.rows.filter((r) => {
if (mhUi.ledgerType !== 'all' && r.action !== mhUi.ledgerType) return false;
if (mhUi.ledgerDim !== 'all' && mhUi.ledgerValue) {
const key = mhLedgerDimKey(mhUi.ledgerDim, r);
if (key !== mhUi.ledgerValue) return false;
}
if (q) {
const hay = `${r.name}\n${r.game}\n${r.counterpart || ''}\n${r.actionLabel}`.toLowerCase();
if (!hay.includes(q)) return false;
}
return true;
});
const key = mhUi.ledgerSort;
const dir = mhUi.ledgerDesc ? -1 : 1;
rows.sort((a, b) => {
let va = key === 'price' ? a.priceCents : key === 'name' ? a.name : (a.tradeTs || a.listedTs || 0);
let vb = key === 'price' ? b.priceCents : key === 'name' ? b.name : (b.tradeTs || b.listedTs || 0);
if (typeof va === 'string') return va.localeCompare(vb) * dir;
return (va - vb) * dir;
});
return rows;
}
function mhPaneLedger(s, money, note) {
const rows = mhFilteredRows();
const totalPages = Math.max(1, Math.ceil(rows.length / MH_LEDGER_PAGE));
if (mhUi.ledgerPage > totalPages) mhUi.ledgerPage = totalPages;
if (mhUi.ledgerPage < 1) mhUi.ledgerPage = 1;
const slice = rows.slice((mhUi.ledgerPage - 1) * MH_LEDGER_PAGE, mhUi.ledgerPage * MH_LEDGER_PAGE);
const opts = mhLedgerDimOptions(mhUi.ledgerDim);
const th = (key, label) =>
`${escapeHtml(label)}${mhUi.ledgerSort === key ? (mhUi.ledgerDesc ? ' ▾' : ' ▴') : ''} | `;
const body = slice.length ? slice.map((r) => {
const hold = (r.action === 'sold' && r.tradeTs && r.listedTs) ? r.tradeTs - r.listedTs : 0;
return `
| ${escapeHtml(r.name)} |
${escapeHtml(r.game || '')} |
${escapeHtml(r.actionLabel)} |
${r.priceCents ? escapeHtml(money(r.priceCents)) : '—'} |
${r.action === 'sold' && r.priceCents ? escapeHtml(money(priceBeforeFees(r.priceCents))) : '—'} |
${escapeHtml(mhFmtTs(r.tradeTs) || r.tradeDateText || '-')} |
${hold ? escapeHtml(mhFmtDuration(hold)) : '—'} |
${escapeHtml(r.counterpart || '')} |
`;
}).join('') : `没有符合当前筛选条件的记录 试试切换「类型」或点「重置」 |
`;
return mhRenderPane('ledger', note, () => {
const typeBtn = (v, label) => ``;
const dimBtn = (v, label) => ``; const pager = totalPages > 1
? `` : ``;
// 维度候选用 chips 呈现,不用下拉——高基数下列表会拉得很长且难以扫读
const TOPN = 14;
let valueRow = '';
if (mhUi.ledgerDim !== 'all') {
const shown = opts.slice(0, TOPN);
const chips = [``]
.concat(shown.map((k) => {
const label = k.length > 16 ? k.slice(0, 15) + '…' : k;
return ``;
})).join('');
const more = opts.length > TOPN
? `共 ${opts.length} 个选项,已显示成交额最高的 ${TOPN} 个;可用搜索框精确查找`
: `共 ${opts.length} 个选项`;
valueRow = `${
escapeHtml((MH_LEDGER_DIMS.find((d) => d.id === mhUi.ledgerDim) || {}).label || '')
}${chips}${more}
`;
}
return `
大类${typeBtn('all', '全部')}${typeBtn('sold', '卖出')}${typeBtn('bought', '买入')}
维度${dimBtn('all', '不限')}${dimBtn('game', '按游戏')}${dimBtn('item', '按物品')}${dimBtn('month', '按月份')}
点击表头排序
${valueRow}
${pager}`;
}, (bodyEl) => {
const bar = bodyEl.querySelector('.sme-mh-toolbar');
const q = bodyEl.querySelector('#sme-mh-ledger-q');
const onClick = (e) => {
const chip = e.target.closest('button.sme-mh-chip');
const th = e.target.closest('th.sme-mh-th-sort');
if (chip) {
if (chip.dataset.ltype) { mhUi.ledgerType = chip.dataset.ltype; mhUi.ledgerValue = ''; mhUi.ledgerPage = 1; mhShowPanel('ledger'); }
else if (chip.dataset.ldim) { mhUi.ledgerDim = chip.dataset.ldim; mhUi.ledgerValue = ''; mhUi.ledgerPage = 1; mhShowPanel('ledger'); }
else if (chip.dataset.lvalue !== undefined) { mhUi.ledgerValue = chip.dataset.lvalue; mhUi.ledgerPage = 1; mhShowPanel('ledger'); }
else if (chip.dataset.page) {
const d = chip.dataset.page;
if (d === 'prev' && mhUi.ledgerPage > 1) mhUi.ledgerPage--;
else if (d === 'next') mhUi.ledgerPage++;
mhShowPanel('ledger');
} else if (chip.id === 'sme-mh-ledger-reset') {
mhUi.ledgerType = 'all'; mhUi.ledgerDim = 'all';
mhUi.ledgerValue = ''; mhUi.ledgerQuery = ''; mhUi.ledgerPage = 1;
mhShowPanel('ledger');
}
return;
}
if (th) {
const k = th.dataset.sort;
if (mhUi.ledgerSort === k) mhUi.ledgerDesc = !mhUi.ledgerDesc;
else { mhUi.ledgerSort = k; mhUi.ledgerDesc = k !== 'name'; }
mhShowPanel('ledger');
}
};
bar.addEventListener('click', onClick);
bodyEl.addEventListener('click', onClick);
let timer = null;
const onInput = () => {
clearTimeout(timer);
timer = setTimeout(() => {
mhUi.ledgerQuery = q.value;
mhUi.ledgerPage = 1;
mhShowPanel('ledger');
}, 260);
};
q.addEventListener('input', onInput);
bodyEl._cleanup = () => {
clearTimeout(timer);
bar.removeEventListener('click', onClick);
bodyEl.removeEventListener('click', onClick);
q.removeEventListener('input', onInput);
};
});
}
// ---------- 面板 6:导出 ----------
const MH_EXPORT_FORMATS = [
{ id: 'csv', label: 'CSV', desc: '逗号分隔 · 带 UTF-8 BOM · Excel 双击直开' },
{ id: 'tsv', label: 'TSV', desc: '制表符分隔 · 字段内换行自动转义' },
{ id: 'json', label: 'JSON', desc: '结构化 · 含汇总/物品/游戏/月份/每日/价格分档' },
{ id: 'md', label: 'Markdown', desc: '可读报告 · 统计卡 + 榜单 + 月度表 + 明细' },
];
function mhPaneExport(s, money, note) {
const fmt = mhUi.exportFormat || mhState.format || 'csv';
const filtered = mhFilteredRows();
const hasFilter = mhUi.ledgerType !== 'all'
|| (mhUi.ledgerDim !== 'all' && !!mhUi.ledgerValue)
|| !!mhUi.ledgerQuery.trim();
return mhRenderPane('export', note, () => {
const cards = MH_EXPORT_FORMATS.map((f) =>
``).join('');
const scopeRows = hasFilter ? filtered : mhState.rows;
const preview = fmt === 'json'
? ['{', ' "meta": { "recordCount": ' + scopeRows.length + ', "currency": "' + mhCurrencyCode() + '" },',
' "summary": { "total": ' + s.count + ', "netProfit": ' + (Math.round(s.netCents) / 100).toFixed(2) + ' },',
' "transactions": [ … ' + scopeRows.length + ' 条 … ]', '}'].join('\n')
: fmt === 'md'
? '# Steam 市场交易记录汇总\n\n## 总体\n\n| 指标 | 数值 |\n| --- | --- |\n| 记录总数 | ' + s.count + ' |\n…'
: '序号,事件ID,挂单ID,物品名称,游戏,AppID,类型,状态,价格原文,价格,币种,…\n'
+ '1,' + escapeHtml(scopeRows.length ? scopeRows[0].eventId : '') + ',' + escapeHtml(scopeRows.length ? scopeRows[0].listingId : '') + ','
+ escapeHtml(scopeRows.length ? scopeRows[0].name : '') + ',…';
return `
格式${cards}
预览(前几行)
${escapeHtml(preview)}
本次导出
格式
${escapeHtml(fmt.toUpperCase())}
币种
${escapeHtml(mhCurrencyCode())}
导出会包含全部字段;JSON 与 Markdown 另附多维汇总。时间戳年份为推断值。
`;
}, (bodyEl) => {
const bar = bodyEl.querySelector('.sme-mh-toolbar');
let scope = hasFilter ? 'filtered' : 'all';
const onBar = (e) => {
const chip = e.target.closest('button.sme-mh-fmt, button.sme-mh-chip');
if (!chip) return;
if (chip.dataset.fmt) { mhUi.exportFormat = chip.dataset.fmt; mhShowPanel('export'); }
else if (chip.dataset.scope === 'all') { scope = 'all'; mhShowPanel('export'); }
else if (chip.dataset.scope === 'filtered') { scope = 'filtered'; mhShowPanel('export'); }
};
bar.addEventListener('click', onBar);
const doBtn = bodyEl.querySelector('#sme-mh-do-export');
const onExport = () => {
const rows = scope === 'filtered' ? mhFilteredRows() : mhState.rows;
if (!rows.length) { toast('没有可导出的记录', 'warning'); return; }
mhRunExport({ format: fmt, rows, label: scope === 'filtered' ? 'filtered' : '' });
};
doBtn.addEventListener('click', onExport);
bodyEl._cleanup = () => {
bar.removeEventListener('click', onBar);
doBtn.removeEventListener('click', onExport);
};
});
}
// ---------- 工具条 ----------
function mhSetStatus(msg, cls) {
const el = document.getElementById('sme-mh-status');
if (!el) return;
el.textContent = msg || '';
el.className = 'sme-mh-status' + (cls === 'ok' ? ' sme-ok' : (cls === 'err' ? ' sme-err' : ''));
}
function mhSetProgress(ratio, show) {
const box = document.getElementById('sme-mh-progress');
const bar = document.getElementById('sme-mh-progress-bar');
if (box) box.style.display = show ? '' : 'none';
if (bar) bar.style.width = Math.max(0, Math.min(1, ratio || 0)) * 100 + '%';
}
function mhSetBusy(busy) {
const panel = document.getElementById('sme-mh-panel');
if (!panel) return;
panel.querySelectorAll('button').forEach(b => {
if (b.id === 'sme-mh-stop') {
b.style.display = busy ? '' : 'none';
b.disabled = !busy;
} else {
b.disabled = !!busy;
}
});
}
function mhCooldownLeft() {
return (mhState.paused && mhState.nextAt) ? Math.max(0, Math.ceil((mhState.nextAt - Date.now()) / 1000)) : 0;
}
function mhFmtCountdown(sec) {
return `${Math.floor(sec / 60)}:${String(sec % 60).padStart(2, '0')}`;
}
/** 限流冷却期间每秒刷新倒计时文案,到点自动解锁按钮 */
let _mhCoolTimer = null;
function mhStartCooldownWatch() {
if (_mhCoolTimer) return;
_mhCoolTimer = setInterval(() => {
if (mhState.fetching || !mhState.paused) { mhStopCooldownWatch(); return; }
const left = mhCooldownLeft();
if (left <= 0) {
mhState.paused = false;
mhState.nextAt = 0;
mhState.coolStreak = 0;
mhStopCooldownWatch();
mhSetStatus(`冷却结束,可从第 ${mhState.rows.length + 1} 条继续索引`, 'ok');
mhRefreshStatus();
return;
}
mhSetStatus(`限流冷却中 ${mhFmtCountdown(left)} · 已保留 ${mhState.rows.length} / ${mhState.totalCount || '?'} 条`, 'err');
}, 1000);
}
function mhStopCooldownWatch() {
if (_mhCoolTimer) { clearInterval(_mhCoolTimer); _mhCoolTimer = null; }
}
function mhRefreshStatus() {
if (mhState.fetching) {
mhSetProgress(mhState.totalCount ? mhState.rows.length / mhState.totalCount : 0, true);
return;
}
if (mhState.rows.length === 0 && !mhState.paused) {
mhSetProgress(0, false);
mhSetStatus('尚未索引交易记录');
mhStopCooldownWatch();
return;
}
if (mhState.paused && mhCooldownLeft() > 0) {
mhSetProgress(mhState.totalCount ? mhState.rows.length / mhState.totalCount : 0, false);
mhSetStatus(`限流冷却中 ${mhFmtCountdown(mhCooldownLeft())} · 已保留 ${mhState.rows.length} / ${mhState.totalCount || '?'} 条`, 'err');
mhStartCooldownWatch();
return;
}
mhSetProgress(0, false);
mhStopCooldownWatch();
const when = mhState.fetchedAt
? new Date(mhState.fetchedAt).toLocaleString('zh-CN', { hour12: false }) : '';
if (mhState.rows.length === 0) { mhSetStatus('尚未索引交易记录'); return; }
// v1.6.8:不再重复报同一个数字。完成 / 未完成两套文案。
const text = mhState.complete
? `已索引全部 ${mhState.rows.length} 条`
: `已索引 ${mhState.rows.length} / ${mhState.totalCount || '?'} 条 · 可继续`;
mhSetStatus(`${text}${when ? ' · ' + when : ''}`, mhState.complete ? 'ok' : '');
mhSyncFetchButton();
}
/** 刷新/继续/重建按钮的语义随索引状态变化,避免误清缓存 */
function mhFetchButtonMeta() {
if (mhState.complete) return { label: '重新索引', title: '已索引完整。点击将清空并从头重新抓取' };
if (mhState.rows.length > 0) return { label: '继续', title: `从第 ${mhState.rows.length + 1} 条继续索引(保留已抓取部分)` };
return { label: '索引', title: '开始抓取全量交易记录' };
}
function mhUpdateFetchButton(btn) {
if (!btn) return;
const meta = mhFetchButtonMeta();
const span = btn.querySelector('span');
if (span) span.textContent = meta.label;
btn.title = meta.title;
btn.setAttribute('aria-label', meta.title);
}
/** 索引状态变化后刷新按钮文案与冷却计时 */
function mhSyncFetchButton() {
mhUpdateFetchButton(document.getElementById('sme-mh-refresh-btn'));
}
function injectMhPanel() {
const tabs = document.getElementById('myMarketTabs');
if (!tabs) return false;
if (mhState.panelEl && document.body.contains(mhState.panelEl)) return true;
const formatSel = h('select', {
id: 'sme-mh-format', class: 'sme-mh-format', 'aria-label': '导出格式',
onChange: (e) => {
mhState.format = ['csv', 'tsv', 'json', 'md'].includes(e.target.value) ? e.target.value : 'csv';
settings.set('marketHistoryFormat', mhState.format);
toast(`导出格式已切换为 ${mhState.format.toUpperCase()}`, 'info');
},
html: ''
+ '',
});
const savedFmt = settings.get('marketHistoryFormat');
formatSel.value = MH_EXPORT_FORMATS.some((f) => f.id === savedFmt) ? savedFmt : 'csv';
mhState.format = formatSel.value;
const actions = h('span', { class: 'sme-mh-actions' }, [
h('button', {
id: 'sme-mh-summary-btn', class: 'sme-market-btn', type: 'button',
html: ICONS.chart + '汇总',
title: '查看市场交易记录分析(概览 / 趋势 / 分布 / 日历 / 明细 / 导出)',
onClick: async () => { if (await mhEnsureData('查看分析')) openMhAnalysis(); },
}),
formatSel,
h('button', {
id: 'sme-mh-export-btn', class: 'sme-market-btn', type: 'button',
html: ICONS.download + '导出',
title: '按所选格式导出全部交易记录',
onClick: async () => { if (await mhEnsureData('导出')) mhRunExport(); },
}),
h('button', {
id: 'sme-mh-refresh-btn', class: 'sme-market-btn', type: 'button',
html: ICONS.refreshCw + '',
title: '',
onClick: async (e) => {
if (mhState.fetching) return;
const btn = e.currentTarget;
const meta = mhFetchButtonMeta();
if (meta.force) {
// 只有「已完整索引」时才允许从零重来,且二次确认
const ok = await showConfirmDialog(
'重新索引会清空当前已抓取的 '
+ `${mhState.rows.length} 条记录并从第 1 条重抓。
`
+ ''
+ '如果只是没抓完,请用「继续」从断点接着抓。',
{ title: '重新索引', okText: '清空并重来', okClass: 'sme-dialog-ok' });
if (!ok) return;
mhState.rows = [];
mhState.totalCount = 0;
mhState.fetchedAt = 0;
mhState.nextOffset = 0;
mhState.complete = false;
mhState.paused = false;
mhState.nextAt = 0;
mhState.coolStreak = 0;
mhSetStatus('正在重新索引…');
await mhFetchAll({ force: true });
} else {
// 断点续传:保留已有数据
mhSetStatus(`正在从第 ${mhState.rows.length + 1} 条继续索引…`);
await mhFetchAll();
}
mhUpdateFetchButton(btn);
},
}),
h('button', {
id: 'sme-mh-stop', class: 'sme-market-btn sme-danger', type: 'button',
text: '停止', style: { display: 'none' },
onClick: () => { mhState.abort = true; mhSetStatus('正在停止…'); },
}),
]);
// 按当前索引状态设定按钮文案(索引 / 继续 / 重新索引)
const fetchBtn = actions.querySelector('#sme-mh-refresh-btn');
if (fetchBtn) mhUpdateFetchButton(fetchBtn);
// 独立容器,作为 #myMarketTabs 的前一个兄弟节点插入。
// Tab 行内部依赖 float 布局("出售物品"右浮动),同层插入必然重叠;
// 放到上一行的块级容器里可完全绕开,且不改动 Steam 原有样式。
const panel = h('div', { class: 'sme-mh-panel', id: 'sme-mh-panel' }, [
h('div', { class: 'sme-mh-panel-inner' }, [
h('span', { class: 'sme-mh-title', html: ICONS.chart + '交易记录' }),
h('span', { class: 'sme-mh-status', id: 'sme-mh-status' }),
actions,
]),
h('div', { class: 'sme-mh-progress', id: 'sme-mh-progress' }, [
h('div', { class: 'sme-mh-progress-bar', id: 'sme-mh-progress-bar' }),
]),
]);
mhState.panelEl = panel;
const host = tabs.parentNode || tabs;
host.insertBefore(panel, tabs);
mhRefreshStatus();
return true;
}
function initMarketHistoryPanel() {
if (!settings.get('marketHistoryPanel')) return;
if (detectPageType() !== 'browse') return;
if (!mhState.styleAdded) {
GM_addStyle(MH_CSS);
mhState.styleAdded = true;
}
if (mhState.active) return;
// #myMarketTabs 由页面脚本异步渲染,轮询等待
let attempts = 0;
const tryStart = () => {
if (injectMhPanel()) {
mhState.active = true;
mhLoadCache();
mhRefreshStatus();
L('Market history panel active, cached rows:', mhState.rows.length);
return;
}
if (attempts++ < 30) setTimeout(tryStart, 500);
};
tryStart();
}
function cleanupMarketHistoryPanel() {
// v1.6.8:切页面前先落盘再中断。
// 原实现直接 abort = true 却不保存,SPA 导航切走时最多丢掉 400 条进度;
// 现在确保断点持久化,切回来点「继续」即可接着抓。
if (mhState.fetching && mhState.rows.length) {
mhSaveCache(mhState.rows, mhState.totalCount, mhState.nextOffset);
}
if (mhState.panelEl) { mhState.panelEl.remove(); mhState.panelEl = null; }
// 分析窗口挂在 body 上,需单独销毁并解绑全局 keydown
if (_mhShell) {
if (_mhShell.modal._dispose) _mhShell.modal._dispose();
const bodies = _mhShell.panelsEl.querySelectorAll('.sme-mh-pbody');
bodies.forEach((b) => { if (b._cleanup) { try { b._cleanup(); } catch (e) {} b._cleanup = null; } });
_mhShell.modal.remove();
_mhShell = null;
}
mhStopCooldownWatch();
mhState.active = false;
mhState.abort = true;
}
// ==================== v1.6.4 市场页 Beta 公告条隐藏 ====================
// 社区市场首页顶部有一条官方公告:
// 「我们目前正在对物品页面和高级搜索等市场功能进行 Beta 测试…」
// 该条由服务端渲染在 #marketBetaNoteCtn 内,且市场页是 SPA 导航会重新挂载,
// 因此用一条 CSS 规则常驻压制——比逐次 JS 移除更稳,SPA 切回来也不会复现。
const MARKET_BETA_HIDE_CSS = `
#marketBetaNoteCtn{display:none!important}
.marketBetaNoteText{display:none!important}
#marketBetaNote{display:none!important}
`;
let _betaNoteStyleAdded = false;
function applyMarketBetaNoteHidden() {
if (!settings.get('hideMarketBetaNote')) return;
if (_betaNoteStyleAdded) return;
GM_addStyle(MARKET_BETA_HIDE_CSS);
_betaNoteStyleAdded = true;
}
// ==================== v1.7.0 我的池子行情 ====================
/**
* 「我的池子行情」
* =========================================================================
* 把「我关心的那一批社区市场物品」聚合成一张行情盘,对标参考图那类行情 App 的形态
* (指标卡 / 指数曲线 / 板块行情 / 市场热图 / 我的池子明细),
* 但数据域是 **Steam 社区市场**,不是商店折扣。
*
* 为什么是「我的池子」而不是「全市场」:
* 社区市场**没有全市场 API**——Steam 只提供逐物品的
* pricehistory / itemordershistogram,没有任何"全市场行情"端点。
* 纯客户端做不出全市场指数。能做且有意义的是「我关心的这一批」的指数。
* 池子来源:① 浏览市场时自动收录(可关)② 详情页手动关注 ③ 库存页批量收录。
*
* 数据模型(落在 GM 存储,不占 localStorage 的 5MB 配额):
* poolState.items Map<`appid::hashName`, {appid,hashName,name,game,kind,sources,qty,addedAt,lastSeen}>
* poolState.series Map<`appid::hashName`, [[dayIdx, cents, vol], ...]>
* dayIdx = Math.floor(ts / 86400000) —— 相对 1970-01-01 的天数,5 位数,比时间戳省一半
*
* 口径纪律(每条都是"错一条整张盘就歪"的级别,务必保持):
* · 「当前价」= 当日成交价按成交量加权(VWAP);无成交量退化为当日收盘价。
* ★ pricehistory 返回的 p 是**单位金额**(如 20.70),不是分 —— 入库要 ×100。
* · 「指数」= 基期(窗口内首个有价日)= 100 的成交量加权价格指数。
* 权重 = 物品在窗口内的总成交量(固定),这样逐日变化只反映价格变化、不受权重漂移影响。
* · 「前值延续」= 物品当日无成交时沿用最近一次有价,但最多延续 7 天,超过则视为无价。
* · 「历史新低」= 当前价 ≤ 窗口内最低价(容差 0.5%)。
* · 所有占比类指标都要写明分母(参与计算的物品数),不能只给一个百分比。
*/
// ---------- 常量 ----------
const POOL_MAX_ITEMS = 300; // 池子容量上限(超出后新物品不再收录)
const POOL_SNAP_DAYS = 180; // 每个物品保留的日快照天数
const POOL_CARRY_DAYS = 7; // 前值延续上限(天)
const POOL_REFRESH_DELAY = 600; // 采集请求间隔(毫秒),过小易触发 Steam 限流
const POOL_REFRESH_CONCURRENCY = 2; // 采集并发数
const POOL_LOW_TOLERANCE = 0.005; // 历史新低容差(0.5%)
const POOL_ITEMS_KEY = 'sme_pool_items_v1';
const POOL_SERIES_KEY = 'sme_pool_series_v1';
const POOL_WINDOWS = [
{ id: '7d', label: '7日', days: 7 },
{ id: '30d', label: '30日', days: 30 },
{ id: '90d', label: '90日', days: 90 },
{ id: 'all', label: '全部', days: Infinity },
];
// ---------- 状态 ----------
const poolState = {
items: new Map(), // key -> 池子条目
series: new Map(), // key -> [[dayIdx, cents, vol], ...]
lastUpdated: 0, // 最近一次成功采集的时间戳
refreshing: false, // 采集中
abort: false, // 中断采集
active: false, // 市场页工具条已注入
styleAdded: false, // 样式已注入
panelEl: null, // 市场页工具条节点
modalEl: null, // 分析弹窗节点
browseObserver: null, // 浏览页自动收录观察器
scanTimer: null, // 浏览页扫描节流
lastTab: 'overview', // 记住上次标签
window: '30d', // 记住时间窗口
sectorDim: 'kind', // 板块维度:kind 品类 | game 游戏
};
// ===== v1.7.0 纯计算层开始(可离线测试,勿在此引入 DOM / GM 依赖) =====
/** 天序号:相对 1970-01-01 的天数 */
function poolDayIndex(ts) { return Math.floor(ts / 86400000); }
/** 天序号 → 该日起点的 UTC 时间戳 */
function poolDayStart(dayIdx) { return dayIdx * 86400000; }
/** 取序列中落在 [cutoff, nowDay] 的点(序列按 dayIdx 升序) */
function poolSlice(series, days, nowDay) {
const cutoff = days === Infinity ? -Infinity : nowDay - days + 1;
const out = [];
for (const p of series) {
if (p[0] >= cutoff && p[0] <= nowDay) out.push(p);
}
return out;
}
/** 物品在给定时刻的"有效当前价":最近一次有价点,前值延续最多 carryDays 天 */
function poolCurrentPoint(series, nowDay, carryDays) {
const maxAge = (carryDays === undefined ? POOL_CARRY_DAYS : carryDays);
for (let i = series.length - 1; i >= 0; i--) {
const p = series[i];
if (p[0] > nowDay) continue;
if (!(p[1] > 0)) continue;
if (maxAge >= 0 && nowDay - p[0] > maxAge) return null;
return p;
}
return null;
}
/** 窗口内成交量合计 */
function poolVolume(points) {
let v = 0;
for (const p of points) v += (p[2] || 0);
return v;
}
/**
* 池子价格指数(基期 = 100)
* @param {Map} seriesMap 物品 -> 日序列
* @param {number} days 窗口天数(Infinity = 全部)
* @param {number} nowDay 当前天序号
* @returns {{index:number, priced:number, total:number}} priced = 参与计算的物品数
*/
function poolComputeIndex(seriesMap, days, nowDay) {
let wsum = 0, rsum = 0, priced = 0, total = 0;
for (const series of seriesMap.values()) {
total++;
const cur = poolCurrentPoint(series, nowDay);
const base = poolBasePoint(series, days, nowDay);
if (!cur || !base || !(base.price > 0) || !(cur[1] > 0)) continue;
// 单点物品没有变化信息 —— 不参与,否则 ratio=1 会稀释真实涨跌
if (base.count < 2) continue;
priced++;
const w = base.weight > 0 ? base.weight : 1;
wsum += w;
rsum += w * (cur[1] / base.price);
}
return {
index: wsum > 0 ? (100 * rsum) / wsum : 100,
priced,
total,
};
}
/**
* 基期点:窗口内第一个有价点 + 该物品在窗口内的总成交量(作为固定权重)
* 同时回传窗口内有价点数 count —— 单点物品没有"变化"信息,
* 让它以 ratio=1 参与指数会把指数往 100 拽(稀释真实涨跌),必须排除。
*/
function poolBasePoint(series, days, nowDay) {
const cutoff = days === Infinity ? -Infinity : nowDay - days + 1;
let base = null, vol = 0, count = 0;
for (const p of series) {
if (p[0] < cutoff || p[0] > nowDay) continue;
vol += (p[2] || 0);
if (!(p[1] > 0)) continue;
count++;
if (!base) base = p;
}
return base ? { day: base[0], price: base[1], weight: vol, count } : null;
}
/**
* 指数日序列:逐日重算"基期固定"的加权指数
* @returns {Array<[dayIdx, index, hasData]>}
*/
function poolComputeIndexSeries(seriesMap, days, nowDay) {
const cutoff = days === Infinity ? -Infinity : nowDay - days + 1;
const bases = new Map();
for (const [key, series] of seriesMap) {
const b = poolBasePoint(series, days, nowDay);
if (b && b.count >= 2) bases.set(key, b); // 单点物品不参与(与 poolComputeIndex 口径一致)
}
if (!bases.size) return [];
// 起点取「窗口起点」与「最早有价日」的较晚者:
// 否则窗口前半段全是"无数据占位点"(index=100),画出来是一段假的平线。
let minDay = nowDay;
for (const [key, series] of seriesMap) {
if (!bases.has(key)) continue;
for (const p of series) {
if (p[0] >= cutoff && p[0] <= nowDay && p[1] > 0 && p[0] < minDay) minDay = p[0];
}
}
const start = days === Infinity ? Math.max(minDay, nowDay - POOL_SNAP_DAYS + 1) : Math.max(cutoff, minDay);
const out = [];
for (let d = start; d <= nowDay; d++) {
let wsum = 0, rsum = 0;
for (const [key, series] of seriesMap) {
const b = bases.get(key);
if (!b) continue;
const cur = poolCurrentPoint(series, d);
if (!cur || !(cur[1] > 0)) continue;
const w = b.weight > 0 ? b.weight : 1;
wsum += w;
rsum += w * (cur[1] / b.price);
}
out.push([d, wsum > 0 ? (100 * rsum) / wsum : 100, wsum > 0]);
}
return out;
}
/**
* 历史新低:当前价 ≤ 窗口内最低价(容差 tol)
* @returns {Array<{key:string, price:number, low:number, lowDay:number}>}
*/
function poolComputeRecordLows(seriesMap, days, nowDay, tol) {
const t = tol === undefined ? POOL_LOW_TOLERANCE : tol;
const hits = [];
for (const [key, series] of seriesMap) {
const cur = poolCurrentPoint(series, nowDay);
if (!cur || !(cur[1] > 0)) continue;
const pts = poolSlice(series, days, nowDay);
if (!pts.length) continue;
let low = Infinity, lowDay = 0;
for (const p of pts) {
if (p[1] > 0 && p[1] < low) { low = p[1]; lowDay = p[0]; }
}
if (!isFinite(low)) continue;
if (cur[1] <= low * (1 + t)) hits.push({ key, price: cur[1], low, lowDay });
}
return hits;
}
/**
* 池子市值:Σ 当前价 × 数量
* @param {Map} itemsMap 池子条目(提供 qty)
*/
function poolComputeValue(seriesMap, itemsMap, nowDay) {
let cents = 0, priced = 0, count = 0;
for (const [key, series] of seriesMap) {
const cur = poolCurrentPoint(series, nowDay);
if (!cur || !(cur[1] > 0)) continue;
const it = itemsMap.get(key);
const qty = it && it.qty > 0 ? it.qty : 1;
cents += cur[1] * qty;
count += qty;
priced++;
}
return { cents, priced, count };
}
/**
* 价格覆盖:当日有有效价的物品数 / 池子物品数
*/
function poolComputeCoverage(seriesMap, nowDay) {
let priced = 0, total = 0;
for (const series of seriesMap.values()) {
total++;
if (poolCurrentPoint(series, nowDay)) priced++;
}
return { priced, total };
}
/**
* 分组聚合:按 dim(kind / game)把物品分组,各组算指数、涨跌、新低、市值
* @param {Map} itemsMap 池子条目
* @param {Map} seriesMap 日序列
* @param {string} dim 'kind' | 'game'
* @param {number} days
* @param {number} nowDay
*/
function poolGroupStats(itemsMap, seriesMap, dim, days, nowDay) {
const groups = new Map();
for (const [key, it] of itemsMap) {
const gname = dim === 'game' ? (it.game || '未知游戏') : (it.kind || '其他');
let g = groups.get(gname);
if (!g) {
g = { name: gname, items: new Map() };
groups.set(gname, g);
}
g.items.set(key, it);
}
const lows = new Set(poolComputeRecordLows(seriesMap, days, nowDay).map((h) => h.key));
const out = [];
for (const g of groups.values()) {
const sub = new Map();
for (const [key, it] of g.items) {
const s = seriesMap.get(key);
if (s) sub.set(key, s);
}
const idx = poolComputeIndex(sub, days, nowDay);
const val = poolComputeValue(sub, g.items, nowDay);
let lowCount = 0;
for (const key of g.items.keys()) if (lows.has(key)) lowCount++;
out.push({
name: g.name,
count: g.items.size,
priced: idx.priced,
index: idx.index,
changePct: idx.index - 100,
lowCount,
valueCents: val.cents,
items: g.items,
series: sub,
});
}
out.sort((a, b) => b.count - a.count || b.valueCents - a.valueCents);
return out;
}
/**
* 把 pricehistory 的原始点折叠成"每日一行":[dayIdx, cents(分), vol]
* ★ pricehistory 的 p 是**单位金额**(20.70),不是分 —— 这里统一 ×100 存分。
* @param {Array<{t:number,p:number,v:number}>} prices
*/
function poolFoldHistory(prices) {
if (!Array.isArray(prices)) return [];
const buckets = new Map();
for (const p of prices) {
if (!p || !(p.t > 0) || !(p.p > 0)) continue;
const d = poolDayIndex(p.t);
let b = buckets.get(d);
if (!b) { b = { sum: 0, vol: 0, last: 0, lastT: 0 }; buckets.set(d, b); }
const v = p.v > 0 ? p.v : 0;
b.sum += p.p * v;
b.vol += v;
if (p.t >= b.lastT) { b.lastT = p.t; b.last = p.p; }
}
const out = [];
for (const [d, b] of buckets) {
const cents = b.vol > 0 ? Math.round((b.sum / b.vol) * 100) : Math.round(b.last * 100);
if (cents > 0) out.push([d, cents, b.vol]);
}
out.sort((a, b) => a[0] - b[0]);
return out;
}
/** 合并日序列(同一天以新数据为准),保持升序 */
function poolMergeSeries(oldSeries, fresh) {
const map = new Map();
for (const p of oldSeries || []) map.set(p[0], p);
for (const p of fresh || []) map.set(p[0], p);
return [...map.values()].sort((a, b) => a[0] - b[0]);
}
/** 取窗口内某分组每天的指数(用于热图) */
function poolGroupIndexByDay(groups, days, nowDay) {
const map = new Map(); // groupName -> Map
for (const g of groups) {
const series = poolComputeIndexSeries(g.series, days, nowDay);
const m = new Map();
for (const [d, v] of series) m.set(d, v);
map.set(g.name, m);
}
return map;
}
// ===== v1.7.0 纯计算层结束 =====
// ---------- 池子存取 ----------
function poolKey(appid, hashName) { return `${appid}::${hashName}`; }
function poolMaxItems() {
const n = parseInt(settings.get('poolMaxItems'), 10);
return Math.max(20, Math.min(POOL_MAX_ITEMS, n || POOL_MAX_ITEMS));
}
function poolSave() {
try {
const items = [];
for (const entry of poolState.items) items.push(entry);
GM_setValue(POOL_ITEMS_KEY, JSON.stringify(items));
const series = [];
for (const entry of poolState.series) series.push(entry);
GM_setValue(POOL_SERIES_KEY, JSON.stringify(series));
} catch (e) {
W('Pool save failed:', e);
}
}
function poolLoad() {
try {
const rawItems = GM_getValue(POOL_ITEMS_KEY, '[]');
const items = typeof rawItems === 'string' ? JSON.parse(rawItems || '[]') : (rawItems || []);
if (Array.isArray(items)) {
for (const pair of items) {
if (!Array.isArray(pair) || pair.length < 2) continue;
const key = String(pair[0]);
const it = pair[1] || {};
if (!it.appid || !it.hashName) continue;
poolState.items.set(key, {
appid: String(it.appid),
hashName: String(it.hashName),
name: String(it.name || it.hashName),
game: String(it.game || ''),
kind: String(it.kind || '其他'),
sources: Array.isArray(it.sources) ? it.sources.slice() : [],
qty: Math.max(1, parseInt(it.qty, 10) || 1),
addedAt: it.addedAt || Date.now(),
lastSeen: it.lastSeen || 0,
});
}
}
const rawSeries = GM_getValue(POOL_SERIES_KEY, '[]');
const series = typeof rawSeries === 'string' ? JSON.parse(rawSeries || '[]') : (rawSeries || []);
if (Array.isArray(series)) {
for (const pair of series) {
if (!Array.isArray(pair) || pair.length < 2) continue;
const pts = Array.isArray(pair[1]) ? pair[1].filter((p) => Array.isArray(p) && p.length >= 2) : [];
if (pts.length) poolState.series.set(String(pair[0]), pts);
}
}
} catch (e) {
W('Pool load failed:', e);
}
poolTrimSeries();
}
/** 只保留最近 POOL_SNAP_DAYS 天,控制 GM 存储体积 */
function poolTrimSeries() {
const cutoff = poolDayIndex(Date.now()) - POOL_SNAP_DAYS;
for (const [key, s] of poolState.series) {
const kept = s.filter((p) => p[0] >= cutoff);
if (kept.length !== s.length) poolState.series.set(key, kept);
}
}
// ---------- 池子条目维护 ----------
function poolClassify(name, appid) {
const n = String(name || '');
const app = String(appid || '');
if (app === '753') return '卡牌';
if (app === '730') {
if (/钥匙/.test(n)) return '钥匙';
if (/武器箱/.test(n)) return '武器箱';
if (/印花|贴纸/.test(n)) return '印花';
if (/挂件/.test(n)) return '挂件';
if (/布章/.test(n)) return '布章';
if (/胸章/.test(n)) return '胸章';
if (/音乐盒/.test(n)) return '音乐盒';
if (/涂鸦/.test(n)) return '涂鸦';
if (/特工/.test(n)) return '特工';
if (/\|/.test(n)) return '皮肤';
return '其他';
}
if (app === '570') {
if (/载入画面|载入屏/.test(n)) return '载入画面';
if (/贴纸|印花/.test(n)) return '印花';
if (/至宝/.test(n)) return '至宝';
if (/不朽/.test(n)) return '不朽';
if (/宝箱/.test(n)) return '宝箱';
if (/饰品|护腕|挂饰/.test(n)) return '饰品';
if (/嘲讽|语音/.test(n)) return '嘲讽';
if (/音乐/.test(n)) return '音乐';
return '其他';
}
return '其他';
}
function poolAddItem(data, source) {
if (!data || !data.appid || !data.hashName) return false;
const key = poolKey(data.appid, data.hashName);
const existing = poolState.items.get(key);
if (existing) {
existing.lastSeen = Date.now();
if (source && !existing.sources.includes(source)) existing.sources.push(source);
if (data.name && !existing.name) existing.name = data.name;
if (data.game && !existing.game) existing.game = data.game;
return false;
}
if (poolState.items.size >= poolMaxItems()) return false;
const label = data.name || data.hashName;
poolState.items.set(key, {
appid: String(data.appid),
hashName: String(data.hashName),
name: String(label),
game: String(data.game || ''),
kind: poolClassify(label, data.appid),
sources: [source].filter(Boolean),
qty: Math.max(1, parseInt(data.qty, 10) || 1),
addedAt: Date.now(),
lastSeen: Date.now(),
});
poolSave();
return true;
}
function poolRemoveItem(key) {
if (!poolState.items.delete(key)) return false;
poolState.series.delete(key);
poolSave();
return true;
}
function poolHasItem(appid, hashName) {
return poolState.items.has(poolKey(appid, hashName));
}
function poolToggleWatch() {
if (!listingInfo || !listingInfo.appid || !listingInfo.marketHashName) {
toast('未能识别当前物品', 'warning');
return;
}
const key = poolKey(listingInfo.appid, listingInfo.marketHashName);
if (poolState.items.has(key)) {
poolRemoveItem(key);
toast('已从池子移除', 'info');
} else {
const added = poolAddItem({
appid: listingInfo.appid,
hashName: listingInfo.marketHashName,
name: listingInfo.marketHashName,
game: '',
}, 'watch');
toast(added ? '已加入池子行情' : '池子已满,未能加入', added ? 'success' : 'warning');
}
poolSyncWatchButton();
poolRefreshPanelStatus();
}
// ---------- 采集 ----------
async function poolRefreshQuotes() {
if (poolState.refreshing) return;
const items = [...poolState.items.values()];
if (!items.length) {
toast('池子为空:浏览市场或点击物品页「关注」加入几件物品', 'warning');
return;
}
poolState.refreshing = true;
poolState.abort = false;
let done = 0, ok = 0, fail = 0;
poolSetStatus(`正在采集 ${items.length} 件物品的行情…`);
try {
const queue = items.slice();
const worker = async () => {
while (queue.length) {
if (poolState.abort) return;
const it = queue.shift();
const key = poolKey(it.appid, it.hashName);
try {
const hist = await fetchPriceHistory(it.appid, it.hashName);
const folded = poolFoldHistory(hist.prices || []);
if (folded.length) {
poolState.series.set(key, poolMergeSeries(poolState.series.get(key) || [], folded));
ok++;
} else {
fail++;
}
} catch (e) {
fail++;
}
done++;
poolSetStatus(`采集中 ${done}/${items.length}(成功 ${ok},失败 ${fail})`);
poolSetProgress(done / items.length);
await sleep(POOL_REFRESH_DELAY);
}
};
const workers = [];
for (let i = 0; i < POOL_REFRESH_CONCURRENCY; i++) workers.push(worker());
await Promise.all(workers);
// 中断也要落盘:已经采到的日快照是有效数据,丢了等于白跑
poolTrimSeries();
poolSave();
if (poolState.abort) {
poolSetStatus(`已中断:已采集 ${done}/${items.length}(已保存)`);
toast(`采集已中断,已保存 ${ok} 件的行情`, 'warning');
} else {
poolState.lastUpdated = Date.now();
poolSetStatus(`采集完成:成功 ${ok},失败 ${fail}`);
toast(`池子行情已更新(${ok} 件成功)`, 'success');
}
} finally {
poolState.refreshing = false;
poolSetProgress(0);
poolRefreshPanelStatus();
if (poolState.modalEl && poolState.modalEl.classList.contains('visible')) poolShowPanel(poolState.lastTab);
}
}
// ---------- 图表零件 ----------
/** 池子模块的货币代码(不依赖 mh 模块,保持自持) */
function poolCurrencyCode() {
return getWalletInfo().currency_code || 'USD';
}
/**
* 指标卡(池子模块自持版,不复用 mhStatCard —— 那个的样式依赖
* 作用域限定在 .sme-mh-modal/.sme-mh-panel 上的 --mh-* token,
* 交易记录面板关闭时 MH_CSS 不会注入,卡片会掉样式)
*/
function poolStatCard(label, value, cls, icon, hint, accent) {
const tone = /^#[0-9a-fA-F]{6}$/.test(accent || '') ? ` style="--accent:${accent}"` : '';
return ``
+ (icon ? `
${icon}` : '')
+ `
${escapeHtml(label)}
`
+ `
${escapeHtml(value)}
`
+ (hint ? `
${escapeHtml(hint)}
` : '')
+ '
';
}
/** 指数轴刻度:100 附近用两位小数,大数值用整数 */
function poolAxisNum(v) {
const a = Math.abs(v);
if (a >= 1000) return (v / 1000).toFixed(1) + 'k';
if (a >= 100) return v.toFixed(0);
return v.toFixed(2);
}
/**
* 指数曲线(SVG):折线 + 渐变面积 + Y 轴刻度 + 峰值标注 + 期初标注
* @param {Array<[number, number, boolean]>} points [dayIdx, value, hasData]
*/
function poolBuildChart(points, opts) {
opts = opts || {};
const W = 760, H = 240, pL = 52, pR = 16, pT = 18, pB = 30;
const cw = W - pL - pR, ch = H - pT - pB;
const valid = points.filter((p) => p[2]);
if (valid.length < 2) return '数据点不足,无法绘制曲线
';
let data = valid;
if (data.length > 180) {
const step = Math.ceil(data.length / 180);
data = data.filter((_, i) => i % step === 0);
data.push(valid[valid.length - 1]);
}
const n = data.length;
const vals = data.map((d) => d[1]);
let minV = Math.min(...vals), maxV = Math.max(...vals);
if (maxV - minV < 0.5) { const mid = (maxV + minV) / 2; minV = mid - 0.5; maxV = mid + 0.5; }
const range = maxV - minV;
const pts = data.map((d, i) => ({
x: pL + (n === 1 ? cw / 2 : (i / (n - 1)) * cw),
y: pT + ch - ((d[1] - minV) / range) * ch,
v: d[1],
day: d[0],
}));
const line = pts.map((pt, i) => (i === 0 ? 'M' : 'L') + pt.x.toFixed(1) + ',' + pt.y.toFixed(1)).join(' ');
const area = line + ` L ${pts[n - 1].x.toFixed(1)},${(pT + ch).toFixed(1)} L ${pts[0].x.toFixed(1)},${(pT + ch).toFixed(1)} Z`;
let grid = '';
for (let i = 0; i <= 4; i++) {
const y = pT + (ch / 4) * i;
const val = maxV - (range / 4) * i;
grid += ``;
grid += `${poolAxisNum(val)}`;
}
let xaxis = '';
for (let i = 0; i <= 4; i++) {
const idx = Math.min(Math.floor((n - 1) * i / 4), n - 1);
const d = new Date(poolDayStart(data[idx][0]));
const label = `${d.getMonth() + 1}/${d.getDate()}`;
xaxis += `${label}`;
}
// 峰值标注
let peakSvg = '';
let peakIdx = 0;
for (let i = 1; i < n; i++) if (pts[i].v > pts[peakIdx].v) peakIdx = i;
const pk = pts[peakIdx];
const pkDate = new Date(poolDayStart(data[peakIdx][0]));
peakSvg += ``
+ `峰值 ${poolAxisNum(pk.v)}`
+ `${pkDate.getMonth() + 1}/${pkDate.getDate()}`;
// 期初标注
const first = pts[0];
const firstDate = new Date(poolDayStart(data[0][0]));
const firstSvg = ``
+ `期初 ${poolAxisNum(first.v)}`
+ `${firstDate.getMonth() + 1}/${firstDate.getDate()}`;
const last = pts[n - 1];
const dot = `当前 ${poolAxisNum(last.v)}`;
const color = opts.color || '#66c0f4';
return ``;
}
/** 涨跌色:涨绿跌红(沿用插件既有约定) */
function poolChangeColor(pct) {
if (Math.abs(pct) < 0.005) return 'var(--sme-text-dim)';
return pct > 0 ? 'var(--sme-green)' : 'var(--sme-red)';
}
function poolFmtPct(pct, decimals) {
const d = decimals === undefined ? 2 : decimals;
const sign = pct > 0 ? '+' : '';
return sign + pct.toFixed(d) + '%';
}
/** 热图色阶:涨绿跌红,按绝对幅度分 5 档 */
function poolHeatColor(pct, maxAbs) {
if (!isFinite(pct) || maxAbs <= 0) return { bg: 'transparent', fg: 'transparent' };
const level = Math.min(5, Math.max(1, Math.ceil((Math.abs(pct) / maxAbs) * 5)));
if (pct > 0) {
const scale = [
null,
{ bg: '#123b2a', fg: '#a8e6c0' },
{ bg: '#175a3d', fg: '#c8f2da' },
{ bg: '#1d8055', fg: '#e2fbee' },
{ bg: '#26a86f', fg: '#052e1c' },
{ bg: '#48c98f', fg: '#04231a' },
];
return scale[level];
}
const scale = [
null,
{ bg: '#3b1d1d', fg: '#f0b8b8' },
{ bg: '#5a2323', fg: '#f7d2d2' },
{ bg: '#7d2b2b', fg: '#ffe4e4' },
{ bg: '#a63434', fg: '#fff0f0' },
{ bg: '#c94c4c', fg: '#2a0505' },
];
return scale[level];
}
// ---------- 模态框 ----------
const POOL_TABS = [
{ prefix: 'overview', icon: 'chart', label: '总览', title: '池子行情总览', color: '#66c0f4' },
{ prefix: 'sector', icon: 'layers', label: '板块', title: '板块行情', color: '#4caf50' },
{ prefix: 'heat', icon: 'calendar', label: '热图', title: '市场热图', color: '#8b5cf6' },
{ prefix: 'items', icon: 'list', label: '池子', title: '我的池子', color: '#38bdf8' },
];
function poolEnsureModal() {
if (poolState.modalEl && document.body.contains(poolState.modalEl)) return poolState.modalEl;
const modal = document.createElement('div');
modal.id = 'sme-pool-modal';
modal.className = 'sme-pool-modal';
const tabsHtml = POOL_TABS.map((t) =>
``).join('');
modal.innerHTML = `
${tabsHtml}
${ICONS.alert}
`;
document.body.appendChild(modal);
poolState.modalEl = modal;
const hide = () => modal.classList.remove('visible');
modal.addEventListener('click', (e) => { if (e.target === modal) hide(); });
modal.querySelector('#sme-pool-close').addEventListener('click', hide);
modal.querySelector('.sme-pool-win').addEventListener('wheel', (e) => e.stopPropagation(), { passive: true });
modal.querySelectorAll('.sme-pool-tab').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
poolShowPanel(btn.dataset.tab);
});
});
const onKey = (e) => {
if (e.key === 'Escape') { e.stopPropagation(); hide(); }
};
document.addEventListener('keydown', onKey, true);
modal._dispose = () => document.removeEventListener('keydown', onKey, true);
return modal;
}
function poolRenderPane(prefix) {
const modal = poolEnsureModal();
const tab = POOL_TABS.find((t) => t.prefix === prefix) || POOL_TABS[0];
const panelsEl = modal.querySelector('#sme-pool-panels');
let panel = panelsEl.querySelector(`.sme-pool-view[data-panel="${prefix}"]`);
if (!panel) {
panel = document.createElement('div');
panel.className = 'sme-pool-view';
panel.dataset.panel = prefix;
const body = document.createElement('div');
body.className = 'sme-pool-pbody';
panel.appendChild(body);
panelsEl.appendChild(panel);
}
const bodyEl = panel.querySelector('.sme-pool-pbody');
let html = '';
try {
html = prefix === 'overview' ? poolPaneOverview()
: prefix === 'sector' ? poolPaneSector()
: prefix === 'heat' ? poolPaneHeat()
: poolPaneItems();
} catch (err) {
W('Pool pane render failed:', prefix, err);
html = `面板渲染失败:${escapeHtml(err.message)}
`;
}
bodyEl.innerHTML = html;
const header = modal.querySelector('#sme-pool-header');
header.style.background = `linear-gradient(135deg,${tab.color}26,rgba(13,15,21,0.9))`;
header.style.borderBottomColor = `${tab.color}40`;
modal.querySelector('#sme-pool-hi').style.color = tab.color;
modal.querySelector('#sme-pool-hi').innerHTML = ICONS[tab.icon] || ICONS.chart;
modal.querySelector('#sme-pool-ht').textContent = tab.title;
panelsEl.querySelectorAll('.sme-pool-view').forEach((p) => p.classList.toggle('active', p.dataset.panel === prefix));
modal.querySelectorAll('.sme-pool-tab').forEach((b) => b.classList.toggle('active', b.dataset.tab === prefix));
modal.classList.add('visible');
poolState.lastTab = prefix;
// 交互绑定
modal.querySelectorAll('[data-pool-window]').forEach((b) => {
b.addEventListener('click', () => {
poolState.window = b.dataset.poolWindow;
settings.set('poolWindow', poolState.window);
poolShowPanel(prefix);
});
});
modal.querySelectorAll('[data-pool-dim]').forEach((b) => {
b.addEventListener('click', () => {
poolState.sectorDim = b.dataset.poolDim;
poolShowPanel(prefix);
});
});
modal.querySelectorAll('[data-pool-remove]').forEach((b) => {
b.addEventListener('click', () => {
poolRemoveItem(b.dataset.poolRemove);
poolShowPanel('items');
poolRefreshPanelStatus();
});
});
return bodyEl;
}
function poolShowPanel(prefix) {
poolEnsureModal();
poolRenderToolbar();
poolRenderPane(prefix);
}
function poolRenderToolbar() {
const modal = poolEnsureModal();
const bar = modal.querySelector('#sme-pool-toolbar');
if (!bar) return;
const win = poolState.window;
const winHtml = POOL_WINDOWS.map((w) =>
``).join('');
const upd = poolState.lastUpdated
? new Date(poolState.lastUpdated).toLocaleString('zh-CN', { hour12: false })
: '未采集';
bar.innerHTML = `
${winHtml}
池子 ${poolState.items.size} 件 · 更新于 ${escapeHtml(upd)}
`;
const btn = bar.querySelector('#sme-pool-refresh');
// 采集中再点一次 = 中断(池子可能有几百件,逐件请求要跑一两分钟)
if (btn) btn.addEventListener('click', () => {
if (poolState.refreshing) {
poolState.abort = true;
poolSetStatus('正在停止…');
btn.disabled = true;
} else {
poolRefreshQuotes();
}
});
}
/** 窗口 → 天数 */
function poolWindowDays() {
const w = POOL_WINDOWS.find((x) => x.id === poolState.window);
return w ? w.days : 30;
}
/** 当前窗口内实际有数据的物品数(用于"数据不足"提示) */
function poolDataSpan(seriesMap, days, nowDay) {
let min = null, max = null, priced = 0;
for (const series of seriesMap.values()) {
const cur = poolCurrentPoint(series, nowDay);
if (!cur) continue;
priced++;
if (min === null || cur[0] < min) min = cur[0];
if (max === null || cur[0] > max) max = cur[0];
}
return { min, max, priced };
}
// ---------- 面板:总览 ----------
function poolPaneOverview() {
const nowDay = poolDayIndex(Date.now());
const days = poolWindowDays();
const seriesMap = poolState.series;
const w = POOL_WINDOWS.find((x) => x.id === poolState.window) || POOL_WINDOWS[1];
if (!poolState.items.size) {
return '池子还是空的。浏览市场时会自动收录,也可以点物品页的「关注」按钮加入。
';
}
if (!seriesMap.size) {
return '还没有采集过行情。点右上角「刷新行情」开始采集(每个物品一次请求,会慢一些)。
';
}
const idx = poolComputeIndex(seriesMap, days, nowDay);
const idxSeries = poolComputeIndexSeries(seriesMap, days, nowDay);
const cov = poolComputeCoverage(seriesMap, nowDay);
const lows = poolComputeRecordLows(seriesMap, days, nowDay);
const val = poolComputeValue(seriesMap, poolState.items, nowDay);
const span = poolDataSpan(seriesMap, days, nowDay);
const code = poolCurrencyCode();
// 日变动:最后一个有数据的点 - 前一个有数据的点
const valid = idxSeries.filter((p) => p[2]);
let dayDelta = null;
if (valid.length >= 2) dayDelta = valid[valid.length - 1][1] - valid[valid.length - 2][1];
const cards = [
poolStatCard('池子价格指数', idx.index.toFixed(2), dayDelta !== null ? (dayDelta >= 0 ? 'sme-up' : 'sme-down') : '', ICONS.chart,
`较期初 ${poolFmtPct(idx.index - 100)}(${w.label})`, '#66c0f4'),
poolStatCard('日变动', dayDelta === null ? '—' : (dayDelta >= 0 ? '+' : '') + dayDelta.toFixed(2) + ' 点', '', ICONS.trendingUp,
dayDelta === null ? '数据不足' : '较上一有效观测日', '#4caf50'),
poolStatCard('价格覆盖', `${cov.priced}/${cov.total}`, '', ICONS.layers,
`${cov.total ? Math.round(cov.priced / cov.total * 100) : 0}% 池子有有效价`, '#38bdf8'),
poolStatCard('历史新低', String(lows.length) + ' 款', '', ICONS.trendingDown,
`窗口内最低价(容差 0.5%)`, '#e24a4a'),
poolStatCard('池子市值', formatCentsStr(val.cents, code), '', ICONS.dollarSign,
`${val.priced} 款可定价 / ${val.count} 件`, '#f59e0b'),
].join('');
const spanText = span.min !== null
? `数据覆盖 ${new Date(poolDayStart(span.min)).getMonth() + 1}/${new Date(poolDayStart(span.min)).getDate()} ~ ${new Date(poolDayStart(span.max)).getMonth() + 1}/${new Date(poolDayStart(span.max)).getDate()},参与计算 ${idx.priced}/${idx.total} 款`
: '暂无可用数据';
const lowList = lows.slice(0, 8).map((hit) => {
const it = poolState.items.get(hit.key);
return `${escapeHtml(it ? it.name : hit.key)}`
+ `${formatCentsStr(hit.price, code)} · 窗口最低 ${formatCentsStr(hit.low, code)}`;
}).join('');
return `
${cards}
价格指数曲线 ${escapeHtml(spanText)}
${poolBuildChart(idxSeries, { color: '#66c0f4' })}
${lows.length ? `
历史新低清单 共 ${lows.length} 款
` : ''}
`;
}
// ---------- 面板:板块 ----------
function poolPaneSector() {
const nowDay = poolDayIndex(Date.now());
const days = poolWindowDays();
const dim = poolState.sectorDim;
const w = POOL_WINDOWS.find((x) => x.id === poolState.window);
if (!poolState.series.size) {
return '还没有采集过行情,先点「刷新行情」。
';
}
const groups = poolGroupStats(poolState.items, poolState.series, dim, days, nowDay);
if (!groups.length) return '暂无可分组的物品。
';
const code = poolCurrencyCode();
const dimHtml = `
`;
const rows = groups.map((g) => {
const color = poolChangeColor(g.changePct);
return `
| ${escapeHtml(g.name)} |
${g.count} |
${g.priced} |
${g.index.toFixed(2)} |
${poolFmtPct(g.changePct)} |
${g.lowCount} |
${formatCentsStr(g.valueCents, code)} |
`;
}).join('');
return `
板块行情 ${escapeHtml(w.label)}窗口 · ${dim === 'kind' ? '按品类' : '按游戏'}
${dimHtml}
`;
}
// ---------- 面板:热图 ----------
function poolPaneHeat() {
const nowDay = poolDayIndex(Date.now());
const days = poolWindowDays();
if (!poolState.series.size) {
return '还没有采集过行情,先点「刷新行情」。
';
}
const groups = poolGroupStats(poolState.items, poolState.series, poolState.sectorDim, days, nowDay);
if (!groups.length) return '暂无可分组的物品。
';
// 只画前 12 个分组,避免热图过高
const top = groups.slice(0, 12);
const byDay = poolGroupIndexByDay(top, days, nowDay);
// 收集全部格子,算出最大绝对涨跌幅
let minDay = Infinity, maxDay = -Infinity;
for (const m of byDay.values()) {
for (const d of m.keys()) {
if (d < minDay) minDay = d;
if (d > maxDay) maxDay = d;
}
}
if (!isFinite(minDay)) return '窗口内没有足够的日序列,无法生成热图。
';
const daysList = [];
for (let d = minDay; d <= maxDay; d++) daysList.push(d);
// 列太多时抽样(保留首尾)
let cols = daysList;
if (daysList.length > 40) {
const step = Math.ceil(daysList.length / 40);
cols = daysList.filter((_, i) => i % step === 0);
if (cols[cols.length - 1] !== daysList[daysList.length - 1]) cols.push(daysList[daysList.length - 1]);
}
let maxAbs = 0;
const cellMap = new Map();
for (const g of top) {
const m = byDay.get(g.name);
for (const d of cols) {
const idx = m ? m.get(d) : undefined;
if (idx === undefined) { cellMap.set(g.name + '|' + d, null); continue; }
const pct = idx - 100;
if (Math.abs(pct) > maxAbs) maxAbs = Math.abs(pct);
cellMap.set(g.name + '|' + d, pct);
}
}
const head = ' | '
+ cols.map((d) => {
const dt = new Date(poolDayStart(d));
return `${dt.getMonth() + 1}/${dt.getDate()} | `;
}).join('') + '
';
const body = top.map((g) => {
const cells = cols.map((d) => {
const pct = cellMap.get(g.name + '|' + d);
if (pct === null || pct === undefined) return ' | ';
const c = poolHeatColor(pct, maxAbs);
return `${pct.toFixed(0)} | `;
}).join('');
return `| ${escapeHtml(g.name)} | ${cells}
`;
}).join('');
return `
市场热图 分组 × 日期 · 数值=较期初涨跌幅(%)
跌
${[5, 4, 3, 2, 1].map((l) => {
const c = poolHeatColor(-l, 5);
return ``;
}).join('')}
0
${[1, 2, 3, 4, 5].map((l) => {
const c = poolHeatColor(l, 5);
return ``;
}).join('')}
涨
最大幅度 ${maxAbs.toFixed(1)}%
`;
}
// ---------- 面板:池子明细 ----------
function poolPaneItems() {
const nowDay = poolDayIndex(Date.now());
const days = poolWindowDays();
const code = poolCurrencyCode();
const lows = new Set(poolComputeRecordLows(poolState.series, days, nowDay).map((h) => h.key));
if (!poolState.items.size) {
return '池子还是空的。浏览市场时会自动收录,也可以点物品页的「关注」按钮加入。
';
}
const rows = [];
for (const [key, it] of poolState.items) {
const series = poolState.series.get(key) || [];
const cur = poolCurrentPoint(series, nowDay);
const base = poolBasePoint(series, days, nowDay);
let changePct = null;
if (cur && base && base[1] > 0) changePct = (cur[1] / base[1] - 1) * 100;
const isLow = lows.has(key);
// v1.9.0:挂刀列需要 appid + market_hash_name(poolKey 前2段)
const [itAppid, ...itNameParts] = key.split('::');
const itName = it.name || itNameParts.join('::');
rows.push({
key, it, price: cur ? cur[1] : 0, hasPrice: !!cur,
changePct, isLow,
src: (it.sources || []).join('/') || '—',
ratioCell: (settings.get('poolRatioScan') && settings.get('crossPlatformQuote'))
? ratioPoolCellHtml(itAppid, itName) : null,
});
}
rows.sort((a, b) => (b.hasPrice - a.hasPrice) || (b.price - a.price));
const tr = rows.map((r) => {
const color = r.changePct === null ? 'var(--sme-text-muted)' : poolChangeColor(r.changePct);
return `
| ${escapeHtml(r.it.name)}${r.isLow ? '新低' : ''} |
${escapeHtml(r.it.game || '—')} |
${escapeHtml(r.it.kind || '其他')} |
${r.hasPrice ? formatCentsStr(r.price, code) : '无价'} |
${r.changePct === null ? '—' : poolFmtPct(r.changePct)} |
${r.ratioCell !== null ? `${r.ratioCell} | ` : ''}
${escapeHtml(r.src)} |
|
`;
}).join('');
return `
我的池子 ${rows.length} 款 · 有价 ${rows.filter((r) => r.hasPrice).length} 款
| 物品 | 游戏 | 品类 | 当前价 | 较期初 | ${rows.some((r) => r.ratioCell !== null) ? '挂刀 | ' : ''}来源 | |
${tr}
`;
}
// ---------- 市场页工具条 ----------
function poolSetStatus(text) {
const el = document.getElementById('sme-pool-status');
if (el) el.textContent = text;
}
function poolSetProgress(ratio) {
const bar = document.getElementById('sme-pool-progress-bar');
if (bar) bar.style.width = Math.max(0, Math.min(1, ratio)) * 100 + '%';
}
function poolRefreshPanelStatus() {
const el = document.getElementById('sme-pool-status');
if (!el) return;
if (poolState.refreshing) return;
const n = poolState.items.size;
const upd = poolState.lastUpdated
? new Date(poolState.lastUpdated).toLocaleString('zh-CN', { hour12: false })
: '未采集';
el.textContent = n ? `池子 ${n} 件 · 更新于 ${upd}` : '池子为空';
}
function poolInjectPanel() {
const tabs = document.getElementById('myMarketTabs');
if (!tabs) return false;
if (poolState.panelEl && document.body.contains(poolState.panelEl)) return true;
const panel = h('div', { class: 'sme-pool-panel', id: 'sme-pool-panel' }, [
h('div', { class: 'sme-pool-panel-inner' }, [
h('span', { class: 'sme-pool-title', html: ICONS.chart + '池子行情' }),
h('span', { class: 'sme-pool-status', id: 'sme-pool-status' }),
h('span', { class: 'sme-pool-actions' }, [
h('button', {
id: 'sme-pool-refresh-btn', class: 'sme-pool-btn', type: 'button',
html: ICONS.refreshCw + '刷新行情',
title: '逐件采集池子内物品的价格历史并落成日快照(采集中再点一次可中断)',
onClick: async () => {
if (poolState.refreshing) {
poolState.abort = true;
poolSetStatus('正在停止…');
return;
}
await poolRefreshQuotes();
},
}),
h('button', {
id: 'sme-pool-open-btn', class: 'sme-pool-btn', type: 'button',
html: ICONS.layers + '行情分析',
title: '打开池子行情分析(总览 / 板块 / 热图 / 池子)',
onClick: () => poolShowPanel(poolState.lastTab || 'overview'),
}),
// v1.9.0:挂刀扫描(默认关,设置 poolRatioScan 控制;仅 BUFF 单源)
...(settings.get('poolRatioScan') ? [h('button', {
id: 'sme-pool-ratio-btn', class: 'sme-pool-btn', type: 'button',
html: ICONS.chart + '扫描挂刀',
title: '对池子内 CS2/DOTA2 物品逐件请求 BUFF 在售价并计算挂刀比例(限速 1.5s/件,单轮 100 件,可中断)',
onClick: () => poolRatioScan(),
})] : []),
]),
]),
h('div', { class: 'sme-pool-progress' }, [
h('div', { class: 'sme-pool-progress-bar', id: 'sme-pool-progress-bar' }),
]),
]);
poolState.panelEl = panel;
const host = tabs.parentNode || tabs;
host.insertBefore(panel, tabs);
poolRefreshPanelStatus();
return true;
}
function initPoolPanel() {
if (!settings.get('poolPanel')) return;
if (detectPageType() !== 'browse') return;
if (!poolState.styleAdded) {
GM_addStyle(POOL_CSS);
poolState.styleAdded = true;
}
let attempts = 0;
const tryStart = () => {
if (poolInjectPanel()) {
poolState.active = true;
poolRefreshPanelStatus();
return;
}
if (attempts++ < 30) setTimeout(tryStart, 500);
};
tryStart();
}
function cleanupPoolPanel() {
if (poolState.panelEl) { poolState.panelEl.remove(); poolState.panelEl = null; }
if (poolState.browseObserver) { poolState.browseObserver.disconnect(); poolState.browseObserver = null; }
if (poolState.scanTimer) { clearTimeout(poolState.scanTimer); poolState.scanTimer = null; }
if (poolState.modalEl) {
if (poolState.modalEl._dispose) poolState.modalEl._dispose();
poolState.modalEl.remove();
poolState.modalEl = null;
}
poolState.refreshing = false;
poolState.abort = true;
}
// ---------- 浏览页自动收录 ----------
function poolInitBrowseTracker() {
if (!settings.get('poolAutoTrack')) return;
if (poolState.browseObserver) return;
const seen = new WeakSet();
const scan = () => {
const rows = document.querySelectorAll('a.market_listing_row_link');
for (const row of rows) {
if (seen.has(row)) continue;
seen.add(row);
const href = row.getAttribute('href') || row.href || '';
const m = href.match(/\/market\/listings\/(\d+)\/(.+?)(?:\?.*)?$/);
if (!m) continue;
let appid, hashName;
try { appid = m[1]; hashName = decodeURIComponent(m[2]); } catch (e) { continue; }
const nameEl = row.querySelector('.market_listing_item_name');
const gameEl = row.querySelector('.market_listing_game_name');
poolAddItem({
appid, hashName,
name: nameEl ? nameEl.textContent.trim() : hashName,
game: gameEl ? gameEl.textContent.trim() : '',
}, 'browse');
}
};
const schedule = () => {
if (poolState.scanTimer) return;
poolState.scanTimer = setTimeout(() => { poolState.scanTimer = null; scan(); }, 600);
};
scan();
const mo = new MutationObserver(schedule);
try { mo.observe(document.body, { childList: true, subtree: true }); } catch (e) { return; }
poolState.browseObserver = mo;
}
// ---------- 详情页「关注」按钮 ----------
function poolSyncWatchButton() {
const btn = document.getElementById('sme-pool-watch-btn');
if (!btn) return;
const has = listingInfo && listingInfo.appid && listingInfo.marketHashName
? poolHasItem(listingInfo.appid, listingInfo.marketHashName)
: false;
btn.innerHTML = (has ? ICONS.starFilled : ICONS.starOutline) + `${has ? '已关注' : '关注'}`;
btn.classList.toggle('active', has);
}
function initPoolWatchButton() {
if (!settings.get('poolPanel')) return;
let tries = 0;
const tryInject = () => {
const host = document.querySelector('.sme-panel .sme-header-title');
if (host && !document.getElementById('sme-pool-watch-btn')) {
const btn = h('button', {
id: 'sme-pool-watch-btn', class: 'sme-pool-watch', type: 'button',
html: ICONS.starOutline + '关注',
title: '把这件物品加入「我的池子」,用于池子行情分析',
onClick: () => poolToggleWatch(),
});
host.appendChild(btn);
poolSyncWatchButton();
return;
}
if (tries++ < 40) setTimeout(tryInject, 300);
};
setTimeout(tryInject, 300);
}
// ---------- 库存页:批量收录 ----------
function poolCollectInventoryItems() {
const out = [];
const els = document.querySelectorAll('.inventory_ctn .item');
for (const el of els) {
const d = getRgItemFromElement(el);
if (!d || !isInvMarketable(d)) continue;
const hash = d.market_hash_name;
if (!hash) continue;
out.push({
appid: d.appid,
hashName: hash,
name: d.name || hash,
qty: d.amount,
});
}
return out;
}
function poolAddInventoryToPool() {
const items = poolCollectInventoryItems();
if (!items.length) {
toast('未找到可市场物品(库存可能尚未加载)', 'warning');
return;
}
let added = 0;
for (const it of items) {
if (poolAddItem(it, 'inventory')) added++;
}
poolSave();
poolRefreshPanelStatus();
toast(added ? `已加入 ${added} 款到池子` : '池子已满或物品已在池中', added ? 'success' : 'warning');
}
// ---------- v1.7.0 样式 ----------
const POOL_CSS = `
.sme-pool-panel{display:block;margin:8px 0 4px;padding:0;border-radius:6px;background:var(--sme-bg-card);border:1px solid rgba(255,255,255,0.06)}
.sme-pool-panel-inner{display:flex;align-items:center;gap:10px;padding:6px 10px;flex-wrap:wrap}
.sme-pool-title{display:inline-flex;align-items:center;gap:6px;font-size:13px;color:var(--sme-text-bright);font-weight:600}
.sme-pool-title .sme-svg{width:15px;height:15px;color:var(--sme-blue)}
.sme-pool-status{font-size:12px;color:var(--sme-text-muted);flex:1;min-width:120px}
.sme-pool-actions{display:inline-flex;align-items:center;gap:6px;margin-left:auto}
.sme-pool-btn{display:inline-flex;align-items:center;gap:5px;padding:4px 10px;font-size:12px;color:var(--sme-text);
background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.09);border-radius:4px;cursor:pointer;line-height:1.4}
.sme-pool-btn:hover{background:rgba(255,255,255,0.1);color:var(--sme-text-bright)}
.sme-pool-btn:disabled{opacity:.5;cursor:default}
.sme-pool-btn .sme-svg{width:13px;height:13px}
.sme-pool-progress{height:2px;background:var(--sme-track);border-radius:0 0 6px 6px;overflow:hidden}
.sme-pool-progress-bar{height:100%;width:0;background:var(--sme-blue);transition:width .2s}
.sme-pool-watch{display:inline-flex;align-items:center;gap:4px;margin-left:auto;padding:3px 9px;font-size:12px;
color:var(--sme-text);background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.09);border-radius:4px;cursor:pointer}
.sme-pool-watch:hover{background:rgba(255,255,255,0.1)}
.sme-pool-watch.active{color:var(--sme-amber);border-color:rgba(245,158,11,0.4);background:rgba(245,158,11,0.1)}
.sme-pool-watch .sme-svg{width:13px;height:13px}
.sme-pool-modal{position:fixed;inset:0;z-index:10050;display:none;align-items:center;justify-content:center;
background:rgba(0,0,0,0.62);backdrop-filter:blur(3px)}
.sme-pool-modal.visible{display:flex}
.sme-pool-modal{
/* 池子模块自持的 token:指标卡样式不依赖 mh 模块的 --mh-* (那个作用域限定在 .sme-mh-*) */
--pool-line:rgba(255,255,255,.07);
--pool-line-2:rgba(255,255,255,.13);
--pool-t1:#e9eff5;
--pool-t2:#9aa9b8;
--pool-t3:#6b7a89;
--pool-up:#4ade80;
--pool-down:#f87171;
--pool-ease:cubic-bezier(.4,0,.2,1);
}
.sme-pool-stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(168px,1fr));gap:10px;margin-bottom:12px}
.sme-pool-stat{
--accent:#66c0f4;
position:relative;overflow:hidden;min-width:0;
padding:11px 13px 10px;border-radius:10px;
background:linear-gradient(180deg,rgba(35,45,57,.72) 0%,rgba(27,34,43,.72) 100%);
border:1px solid var(--pool-line);
box-shadow:0 1px 2px rgba(0,0,0,.30);
transition:transform .16s var(--pool-ease),border-color .16s var(--pool-ease),box-shadow .16s var(--pool-ease);
}
.sme-pool-stat::before{
content:"";position:absolute;left:16px;right:16px;top:0;height:2px;width:auto;
background:linear-gradient(90deg,transparent,var(--accent) 16%,var(--accent) 84%,transparent);
box-shadow:0 0 8px var(--accent);
}
.sme-pool-stat:hover{
transform:translateY(-2px);
border-color:color-mix(in srgb,var(--accent) 62%,transparent);
box-shadow:0 8px 20px rgba(0,0,0,.28),0 0 16px color-mix(in srgb,var(--accent) 22%,transparent);
}
.sme-pool-stat-ico{position:absolute;top:12px;right:12px;display:inline-flex;color:var(--accent);opacity:.72}
.sme-pool-stat-ico svg{width:15px;height:15px;display:block}
.sme-pool-stat-label{
font-size:11px;font-weight:500;color:var(--pool-t3);letter-spacing:.3px;padding-right:22px;
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
}
.sme-pool-stat-value{
font-size:19px;font-weight:700;color:var(--pool-t1);margin-top:6px;line-height:1.2;
font-variant-numeric:tabular-nums;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
}
.sme-pool-stat-value.is-up{color:var(--pool-up)}
.sme-pool-stat-value.is-down{color:var(--pool-down)}
.sme-pool-stat-hint{font-size:10px;color:var(--pool-t3);margin-top:4px;line-height:1.5}
.sme-pool-win{width:min(980px,94vw);max-height:88vh;display:flex;flex-direction:column;overflow:hidden;
background:var(--sme-bg);border:1px solid rgba(255,255,255,0.1);border-radius:8px;box-shadow:0 12px 48px rgba(0,0,0,.55)}
.sme-pool-win-header{display:flex;align-items:center;justify-content:space-between;padding:10px 14px;border-bottom:1px solid rgba(255,255,255,0.08)}
.sme-pool-win-title{display:flex;align-items:center;gap:8px;font-size:14px;font-weight:600;color:var(--sme-text-bright)}
.sme-pool-win-ico .sme-svg{width:18px;height:18px}
.sme-pool-win-sub{font-size:11px;color:var(--sme-text-muted);font-weight:400;margin-left:4px}
.sme-pool-win-close{background:none;border:none;cursor:pointer;color:var(--sme-text-dim);padding:4px;line-height:0}
.sme-pool-win-close:hover{color:var(--sme-text-bright)}
.sme-pool-win-close .sme-svg{width:16px;height:16px}
.sme-pool-win-body{display:flex;flex-direction:column;min-height:0;flex:1}
.sme-pool-toolbar{display:flex;align-items:center;gap:10px;padding:8px 14px;border-bottom:1px solid rgba(255,255,255,0.05);flex-wrap:wrap}
.sme-pool-tool-right{display:flex;align-items:center;gap:10px;margin-left:auto}
.sme-pool-tool-meta{font-size:11px;color:var(--sme-text-muted)}
.sme-pool-segs{display:inline-flex;gap:2px;padding:2px;background:rgba(255,255,255,0.05);border-radius:5px}
.sme-pool-seg{padding:3px 10px;font-size:12px;color:var(--sme-text-dim);background:none;border:none;border-radius:3px;cursor:pointer}
.sme-pool-seg.active{background:var(--sme-blue-a20);color:var(--sme-blue)}
.sme-pool-tabs{display:flex;gap:2px;padding:6px 14px 0;border-bottom:1px solid rgba(255,255,255,0.06)}
.sme-pool-tab{display:inline-flex;align-items:center;gap:5px;padding:6px 12px;font-size:12px;color:var(--sme-text-dim);
background:none;border:none;border-bottom:2px solid transparent;cursor:pointer}
.sme-pool-tab .sme-svg{width:13px;height:13px}
.sme-pool-tab.active{color:var(--c);border-bottom-color:var(--c)}
.sme-pool-panels{flex:1;min-height:0;overflow-y:auto;padding:12px 14px}
.sme-pool-view{display:none}
.sme-pool-view.active{display:block}
.sme-pool-empty{padding:26px 10px;text-align:center;color:var(--sme-text-muted);font-size:13px}
.sme-pool-block{margin-bottom:18px}
.sme-pool-block-title{font-size:13px;font-weight:600;color:var(--sme-text-bright);margin-bottom:8px;display:flex;align-items:baseline;gap:8px}
.sme-pool-block-sub{font-size:11px;font-weight:400;color:var(--sme-text-muted)}
.sme-pool-chart{width:100%;height:auto;display:block}
.sme-pool-li{list-style:none;margin:0;padding:0}
.sme-pool-li li{display:flex;justify-content:space-between;gap:12px;padding:5px 8px;border-bottom:1px solid rgba(255,255,255,0.04);font-size:12px}
.sme-pool-li-name{color:var(--sme-text)}
.sme-pool-li-meta{color:var(--sme-text-muted);white-space:nowrap}
.sme-pool-table-wrap{overflow-x:auto;border:1px solid rgba(255,255,255,0.06);border-radius:5px}
.sme-pool-table{width:100%;border-collapse:collapse;font-size:12px}
.sme-pool-table th{position:sticky;top:0;background:rgba(23,26,33,0.98);color:var(--sme-text-dim);font-weight:600;
text-align:left;padding:7px 9px;border-bottom:1px solid rgba(255,255,255,0.08);white-space:nowrap}
.sme-pool-table td{padding:6px 9px;border-bottom:1px solid rgba(255,255,255,0.04);color:var(--sme-text);white-space:nowrap}
.sme-pool-table tr:last-child td{border-bottom:none}
.sme-pool-td-name{color:var(--sme-text-bright);max-width:280px;overflow:hidden;text-overflow:ellipsis}
.sme-pool-td-dim{color:var(--sme-text-dim)}
.sme-pool-td-none{color:var(--sme-text-muted)}
.sme-pool-low-flag{display:inline-block;margin-left:6px;padding:0 5px;font-size:10px;color:#2a0505;background:#c94c4c;border-radius:3px}
.sme-pool-del{background:none;border:none;cursor:pointer;color:var(--sme-text-muted);padding:2px;line-height:0}
.sme-pool-del:hover{color:var(--sme-red)}
.sme-pool-del .sme-svg{width:13px;height:13px}
.sme-pool-heat-wrap{overflow:auto;border:1px solid rgba(255,255,255,0.06);border-radius:5px;max-height:60vh}
.sme-pool-heat{border-collapse:separate;border-spacing:2px;font-size:11px}
.sme-pool-heat th{color:var(--sme-text-dim);font-weight:600;padding:3px 5px;white-space:nowrap}
.sme-pool-th-name{position:sticky;left:0;background:var(--sme-bg);z-index:1;text-align:right;max-width:130px;overflow:hidden;text-overflow:ellipsis}
.sme-pool-th-day{font-size:10px}
.sme-pool-th-corner{position:sticky;left:0;background:var(--sme-bg);z-index:2}
.sme-pool-heat-cell{width:26px;height:20px;text-align:center;border-radius:2px;padding:0}
.sme-pool-heat-cell.empty{background:transparent}
.sme-pool-legend{display:flex;align-items:center;gap:3px;margin-top:8px;font-size:11px;color:var(--sme-text-muted)}
.sme-pool-legend i{width:16px;height:10px;border-radius:2px;display:inline-block}
.sme-pool-legend-meta{margin-left:10px}
.sme-pool-mf{display:flex;align-items:center;gap:6px;padding:6px 14px;font-size:11px;color:var(--sme-text-muted);border-top:1px solid rgba(255,255,255,0.05)}
.sme-pool-mf .sme-svg{width:12px;height:12px;flex:none}
`;
// ==================== 初始化 ====================
let initialized = false;
let lastURL = '';
let urlCheckTimer = null;
function init() {
if (initialized) return;
initialized = true;
L('Initializing on', location.href);
const sid = getSteamId();
if (sid) loadCacheForSession(sid);
else W('Steam ID not detected, some features may not work');
// v1.7.0:载入「我的池子」条目与日快照
poolLoad();
poolState.window = POOL_WINDOWS.some((w) => w.id === settings.get('poolWindow'))
? settings.get('poolWindow') : '30d';
const pageType = detectPageType();
L('Page type:', pageType);
if (pageType === 'listing') {
initListingPage();
} else if (pageType === 'browse') {
initBrowsePage();
} else if (pageType === 'inventory') {
initInventoryPage();
} else if (pageType === 'multisell') {
initMultisellPage();
} else if (pageType === 'tradeoffer') {
initTradeOfferPage();
} else if (pageType === 'booster') {
initBoosterPage();
}
// URL 变化检测(Steam 市场使用 AJAX 导航)
lastURL = location.href;
urlCheckTimer = setInterval(() => {
if (location.href !== lastURL) {
lastURL = location.href;
handleURLChange();
}
}, 800);
}
function initListingPage() {
listingInfo = getListingInfo();
L('Listing info:', listingInfo);
// 详情页已拥有物品标识
if (settings.get('ownedBadges')) {
setTimeout(processDetailPageBadge, 600);
}
// 浮窗模式先放出按钮。经典商品页没有 SSR,原先要空等约 10 秒才会插入顶部面板。
if (shouldUseFloatPanel()) ensureAnalysisFab();
// 等待 SSR 数据可用
const pageHref = location.href;
const tryExtract = (attempt) => {
if (location.href !== pageHref) return;
const ssrData = extractSSRData();
if (ssrData.sellOrders !== null) {
const hadSummary = !!tabPanes.summary;
allSellOrders = ssrData.sellOrders;
// v1.9.2:SSR 就绪后重解析条目信息 —— 脚本启动时 SSR 可能尚未注入,
// 那时拿到的只是 URL 里的哈希 slug(Steam 已 302 成 slug),名字一错全盘皆错。
listingInfo = getListingInfo();
// 设置全局 classid,并为缺少 classid 的订单补充
if (ssrData.classid) _globalClassid = ssrData.classid;
if (_globalClassid) {
for (const o of allSellOrders) {
if (!o.classid) o.classid = _globalClassid;
}
}
L(`Found ${allSellOrders.length} sell orders`);
presentListingPanel();
if (hadSummary) onListingOrdersReady();
if (settings.get('autoLoadHistory')) {
// 预加载价格历史
if (listingInfo) {
fetchPriceHistory(listingInfo.appid, listingInfo.marketHashName)
.then(data => { priceHistoryData = data; })
.catch(() => {});
}
}
} else if (attempt < 20) {
setTimeout(() => tryExtract(attempt + 1), 500);
} else {
L('No sell orders found after timeout, presenting panel anyway');
presentListingPanel();
}
};
tryExtract(0);
// v1.4.6:我的挂单市场顺序跟踪(延迟等待 SSR renderContext 与 React 挂载)
setTimeout(initMarketRankTracker, 800);
// v1.4.9:参考货币换算价格注入 + 监听 DOM 变化自动补注
startRefPriceInjection();
// v1.7.0:详情页「关注」按钮(加入我的池子)
initPoolWatchButton();
}
function initBrowsePage() {
if (settings.get('ownedBadges')) {
setTimeout(processAllBrowseRows, 400);
setupBrowseObserver();
}
createFab();
initMyListingsEnhancement();
// v1.6.0:市场页"我的市场历史记录"工具条(汇总 / 导出 / 格式下拉)
initMarketHistoryPanel();
// v1.7.0:池子行情工具条 + 浏览页自动收录
initPoolPanel();
poolInitBrowseTracker();
// v1.6.4:隐藏 Steam 官方的市场 Beta 测试公告条
applyMarketBetaNoteHidden();
// v1.5.7:/market/search(pageType=browse)此前完全没有注入入口,
// 「起价:₹xx」卡片换算一直不出现;这里与详情页共用同一入口。
startRefPriceInjection();
}
function handleURLChange() {
L('URL changed to', location.href);
const pageType = detectPageType();
// 清理旧面板(浮窗按钮挂在 body 上,要单独摘掉)
removeAnalysisFloat();
if (panelEl) { panelEl.remove(); panelEl = null; }
if (fabEl) { fabEl.remove(); fabEl = null; }
if (fabPopup) { fabPopup.remove(); fabPopup = null; }
if (browseObserver) { browseObserver.disconnect(); browseObserver = null; }
if (invObserver) { invObserver.disconnect(); invObserver = null; }
if (invToolbarEl) { invToolbarEl.remove(); invToolbarEl = null; }
if (invPriceLabels) { invPriceLabels.clear(); }
if (invPriceIO) { invPriceIO.disconnect(); invPriceIO = null; } // v1.5.1 P2-B:断开按需标注观察器
if (tradeObserver) { tradeObserver.disconnect(); tradeObserver = null; }
cleanupBoosterPage();
cleanupMyListingsEnhancement();
cleanupMarketRankTracker();
cleanupMarketHistoryPanel();
cleanupPoolPanel();
// v1.4.9:清理参考货币价格标签和观察器
cleanupRefPriceObserver();
allSellOrders = [];
priceHistoryData = null;
orderBookData = null;
currentTab = 'summary';
priceCheckResults = null;
priceCheckLoading = false;
// v1.9.2:换物品后旧的规范 market_hash_name 必须作废,
// 否则 SPA 跳到另一件物品时仍会用上一件的名字去查 SSR / 比价索引。
_ssrHashName = undefined;
_ssrHashNameTriedAt = 0;
if (pageType === 'listing') {
// v1.5.0:SPA 导航到物品页时补注入假 hook(完整页面加载已在 document-start 注入过,此处幂等)
if (settings.get('showMorePrices')) installShowMoreHook();
initListingPage();
} else if (pageType === 'browse') {
initBrowsePage();
} else if (pageType === 'inventory') {
initInventoryPage();
} else if (pageType === 'multisell') {
initMultisellPage();
} else if (pageType === 'tradeoffer') {
initTradeOfferPage();
} else if (pageType === 'booster') {
initBoosterPage();
}
}
// ==================== 补充包制作器页面 ====================
// ---- 补充包页面状态 ----
let boosterPanelEl = null;
let boosterGameInfo = null; // CBoosterCreatorPage.sm_rgBoosterData
let boosterConfig = null; // 用户配置 {queue:[], favorites:[], blacklist:[], pageSize, filter}
let boosterPriceData = {}; // {appid: {cardPrice, boosterBuyOrder, hadCard, hadBooster, sold}}
let boosterGemsSackPrice = 0; // 市场一袋宝珠价格(分)
let boosterCustomSackPrice = 0; // 自定义一袋宝珠价格(分)
let boosterPageNum = 1;
let boosterPageSize = 15;
let boosterSearchKeyword = '';
let boosterFilterType = 'all'; // all|queue|favorites|blacklist|available
let boosterSortKey = 'name';
let boosterSortDir = 'asc';
let boosterQueryRunning = false;
let boosterCreateRunning = false;
let boosterDoneList = [];
let boosterGemsDisplay = 0; // 当前用户宝石数量
// 卡牌数量 → 宝石成本映射
const CARD_COUNT_TO_GEMS = { 5:1200, 6:1000, 7:857, 8:750, 9:667, 10:600, 11:545, 12:500, 13:462, 14:429, 15:400 };
/** 加载补充包配置 */
function loadBoosterConfig() {
try {
const raw = localStorage.getItem('sme_booster_config');
if (raw) {
const cfg = JSON.parse(raw);
boosterConfig = {
queue: cfg.queue || [],
favorites: cfg.favorites || [],
blacklist: cfg.blacklist || [],
pageSize: cfg.pageSize || 15,
filter: cfg.filter !== false,
customSackPrice: cfg.customSackPrice || 0,
autoQueryBooster: cfg.autoQueryBooster || false,
};
}
} catch (e) {}
if (!boosterConfig) {
boosterConfig = { queue: [], favorites: [], blacklist: [], pageSize: 15, filter: true, customSackPrice: 0, autoQueryBooster: false };
}
boosterPageSize = boosterConfig.pageSize;
boosterCustomSackPrice = boosterConfig.customSackPrice || 0;
}
/** 保存补充包配置 */
function saveBoosterConfig() {
try { localStorage.setItem('sme_booster_config', JSON.stringify(boosterConfig)); } catch (e) {}
}
/** 加载补充包价格缓存 */
function loadBoosterPriceCache() {
try {
const raw = localStorage.getItem('sme_booster_price_cache');
if (raw) boosterPriceData = JSON.parse(raw) || {};
} catch (e) {}
}
/** 保存补充包价格缓存 */
function saveBoosterPriceCache() {
try { localStorage.setItem('sme_booster_price_cache', JSON.stringify(boosterPriceData)); } catch (e) {}
}
/** 补充包页面 toast */
let _boosterToastEl = null;
function boosterToast(msg, type = 'info') {
if (!_boosterToastEl) {
_boosterToastEl = h('div', { class: 'sme-booster-toast' });
document.body.appendChild(_boosterToastEl);
}
_boosterToastEl.textContent = msg;
_boosterToastEl.className = 'sme-booster-toast sme-show sme-' + type;
clearTimeout(_boosterToastEl._timer);
_boosterToastEl._timer = setTimeout(() => {
_boosterToastEl.classList.remove('sme-show');
}, 3000);
}
/** 获取一袋宝珠市场最低售价 */
async function fetchGemsSackPrice() {
try {
// 尝试从缓存获取 item_nameid
let sackItemId = '';
const cacheRaw = localStorage.getItem('sme_booster_sack_cache');
if (cacheRaw) {
try { sackItemId = JSON.parse(cacheRaw).itemId || ''; } catch (e) {}
}
if (!sackItemId) {
// 从市场页面获取 item_nameid
const html = (await smeRequest('https://steamcommunity.com/market/listings/753/753-Sack%20of%20Gems', { expect: 'text', onNonOk: 'null' })) || '';
const m = html.match(/Market_LoadOrderSpread\(\s*(\d+)/);
if (m) {
sackItemId = m[1];
localStorage.setItem('sme_booster_sack_cache', JSON.stringify({ itemId: sackItemId, ts: Date.now() }));
}
}
if (!sackItemId) return 0;
const wi = getWalletInfo();
const url = `https://steamcommunity.com/market/itemordershistogram?country=${wi.country || 'US'}&language=${wi.language || 'english'}¤cy=${wi.wallet_currency || 1}&item_nameid=${sackItemId}&two_factor=0`;
const data = await doFetch(url);
if (data && data.sell_order_graph && data.sell_order_graph.length > 0) {
return parseInt(data.sell_order_graph[0][0], 10) || 0;
}
} catch (e) {
W('Failed to fetch gems sack price:', e.message);
}
return 0;
}
/** 获取补充包市场最低售价和最高买单 */
async function fetchBoosterMarketPrice(appid, name) {
try {
// 使用英文名称构建 market hash,避免中文名导致链接失效
const englishName = await resolveBoosterEnglishName(appid, name);
const marketHash = `${appid}-${englishName} Booster Pack`;
// 先获取 item_nameid
let boosterItemId = '';
const cacheKey = `booster_itemid_${appid}`;
const cacheRaw = localStorage.getItem('sme_booster_item_cache');
if (cacheRaw) {
try {
const cache = JSON.parse(cacheRaw);
boosterItemId = (cache[appid] || {}).itemId || '';
} catch (e) {}
}
if (!boosterItemId) {
const boosterUrl = `https://steamcommunity.com/market/listings/753/${encodeURIComponent(marketHash.replace(/\//g, '-'))}`;
const html = (await smeRequest(boosterUrl, { expect: 'text', onNonOk: 'null' })) || '';
const m = html.match(/Market_LoadOrderSpread\(\s*(\d+)/);
if (m) {
boosterItemId = m[1];
const cache = cacheRaw ? JSON.parse(cacheRaw) : {};
cache[appid] = { itemId: boosterItemId, ts: Date.now() };
localStorage.setItem('sme_booster_item_cache', JSON.stringify(cache));
}
}
if (!boosterItemId) return null;
const wi = getWalletInfo();
const url = `https://steamcommunity.com/market/itemordershistogram?country=${wi.country || 'US'}&language=${wi.language || 'english'}¤cy=${wi.wallet_currency || 1}&item_nameid=${boosterItemId}&two_factor=0`;
const data = await doFetch(url);
const result = { buyOrder: 0, sellOrder: 0 };
if (data.buy_order_graph && data.buy_order_graph.length > 0) {
result.buyOrder = parseInt(data.buy_order_graph[0][0], 10) || 0;
}
if (data.sell_order_graph && data.sell_order_graph.length > 0) {
result.sellOrder = parseInt(data.sell_order_graph[0][0], 10) || 0;
}
return result;
} catch (e) {
W(`Failed to fetch booster price for ${appid}:`, e.message);
return null;
}
}
/** 查询单个游戏的卡牌均价 */
async function fetchCardPrice(appid) {
try {
// 搜索该游戏的普通卡牌
const searchUrl = `https://steamcommunity.com/market/search/render/?start=0&count=20&category_753_cardborder[]=tag_cardborder_0&appid=753&category_753_Game[]=tag_app_${appid}&norender=1`;
const searchData = await doFetch(searchUrl);
if (!searchData || !searchData.total_count) return null;
const cardCount = searchData.total_count;
let totalPrice = 0;
let successCount = 0;
// 从搜索结果获取每个卡牌的 market_hash_name
const cardNames = [];
if (searchData.results && Array.isArray(searchData.results)) {
for (const r of searchData.results) {
if (r.hash_name) cardNames.push(r.hash_name);
}
}
// 逐个查询卡牌最低售价
for (const hashName of cardNames) {
try {
// 获取卡牌详情页的 item_nameid
let cardItemId = '';
const cardCacheRaw = localStorage.getItem('sme_booster_card_item_cache');
if (cardCacheRaw) {
try {
const cc = JSON.parse(cardCacheRaw);
cardItemId = (cc[hashName] || '').itemId || '';
} catch (e) {}
}
if (!cardItemId) {
const cardUrl = `https://steamcommunity.com/market/listings/753/${encodeURIComponent(hashName)}`;
const html = (await smeRequest(cardUrl, { expect: 'text', onNonOk: 'null' })) || '';
const m = html.match(/Market_LoadOrderSpread\(\s*(\d+)/);
if (m) {
cardItemId = m[1];
const cc = cardCacheRaw ? JSON.parse(cardCacheRaw) : {};
cc[hashName] = { itemId: cardItemId, ts: Date.now() };
localStorage.setItem('sme_booster_card_item_cache', JSON.stringify(cc));
}
}
if (cardItemId) {
const wi = getWalletInfo();
const url = `https://steamcommunity.com/market/itemordershistogram?country=${wi.country || 'US'}&language=${wi.language || 'english'}¤cy=${wi.wallet_currency || 1}&item_nameid=${cardItemId}&two_factor=0`;
const data = await doFetch(url);
if (data.sell_order_graph && data.sell_order_graph.length > 0) {
totalPrice += parseInt(data.sell_order_graph[0][0], 10) || 0;
successCount++;
}
}
// 请求间隔,避免限流
await new Promise(r => setTimeout(r, 600));
} catch (e) {
W(`Failed to fetch card price for ${hashName}:`, e.message);
}
}
if (successCount > 0) {
// 三张卡牌均价 × 税后(× 0.87)
const avgCardPrice = totalPrice / successCount;
const totalCardValue = avgCardPrice * 3; // 补充包含3张卡牌
return { cardPrice: totalCardValue, cardCount, successCount };
}
return null;
} catch (e) {
W(`Failed to fetch card prices for ${appid}:`, e.message);
return null;
}
}
/** 制作补充包 */
async function createBoosterPack(appid, series) {
try {
const sessionid = await resolveSessionId();
if (!sessionid) throw new Error('无法获取 sessionid');
const body = new URLSearchParams({
sessionid,
appid: String(appid),
series: String(series),
tradability_preference: '1',
});
const json = await smeRequest('https://steamcommunity.com/tradingcards/ajaxcreatebooster/', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: body.toString(),
});
if (!json || !json.success) throw new Error((json && json.message) || '制作失败');
return json;
} catch (e) {
W(`Failed to create booster for ${appid}:`, e.message);
throw e;
}
}
/** 获取用户当前宝石数量 */
function getBoosterGems() {
const el = document.querySelector('.goovalue');
if (el) {
const text = el.textContent.replace(/[,,\s]/g, '');
const num = parseInt(text, 10);
if (!isNaN(num)) return num;
}
return 0;
}
// ---- 英文名称缓存(用于补充包链接生成) ----
let boosterEnglishNameCache = {}; // { appid: 'English Name' }
let _boosterEnNameFetching = new Set(); // 正在获取中的 appid,避免重复请求
/** 加载英文名称缓存 */
function loadBoosterEnglishNameCache() {
try {
const raw = localStorage.getItem('sme_booster_en_name_cache');
if (raw) boosterEnglishNameCache = JSON.parse(raw) || {};
} catch (e) {}
}
/** 保存英文名称缓存 */
function saveBoosterEnglishNameCache() {
try { localStorage.setItem('sme_booster_en_name_cache', JSON.stringify(boosterEnglishNameCache)); } catch (e) {}
}
/** 去除书名号及特殊符号 */
function pureName(str) {
return (str || '').replace(/[《》〈〉「」『』™©®]/g, '');
}
/** 异步获取游戏英文名称(带缓存),参考 Steam_Show_English_Name.js 逻辑 */
async function fetchEnglishName(appid) {
const id = String(appid);
if (!id || id === '0') return null;
// 内存缓存
if (boosterEnglishNameCache[id]) return boosterEnglishNameCache[id];
// 防止并发重复请求
if (_boosterEnNameFetching.has(id)) return null;
_boosterEnNameFetching.add(id);
try {
const url = `https://store.steampowered.com/api/appdetails?appids=${id}&l=english`;
const data = await gmFetchJson(url, 12000);
if (data && data[id] && data[id].success === true && data[id].data && data[id].data.name) {
const enName = pureName(data[id].data.name);
boosterEnglishNameCache[id] = enName;
saveBoosterEnglishNameCache();
return enName;
}
} catch (e) {
W(`Failed to fetch English name for appid ${id}:`, e.message);
} finally {
_boosterEnNameFetching.delete(id);
}
return null;
}
/** 同步获取链接用名称:优先英文缓存,否则回退到去符号的原始名 */
function getBoosterLinkName(appid, fallbackName) {
const id = String(appid);
if (boosterEnglishNameCache[id]) return boosterEnglishNameCache[id];
return pureName(fallbackName || '');
}
/** 异步解析英文名称:缓存命中直接返回,否则请求 API 后返回 */
async function resolveBoosterEnglishName(appid, fallbackName) {
const id = String(appid);
if (boosterEnglishNameCache[id]) return boosterEnglishNameCache[id];
const en = await fetchEnglishName(id);
return en || pureName(fallbackName || '');
}
/** 构建补充包市场链接 */
function buildBoosterMarketUrl(appid, name) {
const linkName = getBoosterLinkName(appid, name);
const safeName = linkName.replace(/\//g, '-');
return `https://steamcommunity.com/market/listings/753/${appid}-${encodeURIComponent(safeName)}%20Booster%20Pack`;
}
/** 构建卡牌市场搜索链接 */
function buildCardMarketUrl(appid) {
return `https://steamcommunity.com/market/search?category_Game=app_${appid}&appid=753`;
}
/** 获取游戏可用状态 */
function getGameStatus(appid) {
const selector = document.getElementById('booster_game_selector');
if (!selector) return 'unknown';
const option = selector.querySelector(`option[value="${appid}"]`);
if (!option) return 'unknown';
if (option.classList.contains('available')) return 'available';
if (boosterDoneList.includes(String(appid))) return 'done';
return 'cooldown';
}
/** 获取游戏宝石成本 */
function getGameGemsCost(appid) {
if (!boosterGameInfo) return 0;
const info = boosterGameInfo[appid];
if (!info) return 0;
return parseInt(info.price, 10) || 0;
}
/** 计算利润 */
function calcBoosterProfit(appid) {
const gemsCost = getGameGemsCost(appid);
if (gemsCost === 0) return null;
// 一袋宝珠1000 gems的市场价 → 每gem价值
const sackPrice = boosterCustomSackPrice > 0 ? boosterCustomSackPrice : boosterGemsSackPrice;
if (sackPrice === 0) return null;
const gemCostCents = Math.round(sackPrice * gemsCost / 1000);
// 卡牌收益(税后)
const pd = boosterPriceData[appid];
const cardRevenue = pd && pd.cardPrice ? Math.round(pd.cardPrice * 0.87) : 0;
// 补充包买单收益
const boosterBuyRevenue = pd && pd.boosterBuyOrder ? pd.boosterBuyOrder : 0;
// 取较高者作为预期收益
const expectedRevenue = Math.max(cardRevenue, boosterBuyRevenue);
return { cost: gemCostCents, revenue: expectedRevenue, profit: expectedRevenue - gemCostCents };
}
/** 格式化价格(分→元) */
function formatBoosterPrice(cents) {
if (cents == null || isNaN(cents)) return '--';
const wi = getWalletInfo();
const symbol = (wi.currency_code && wi.currency_code in CURRENCY_ID_TO_CODE) ? '' : '';
return (cents / 100).toFixed(2);
}
/** 封面图降级链(参考 sglv-cover-fallback.lib.js) */
const COVER_CDN_AKAMAI = 'https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/';
const COVER_CDN_CF = 'https://cdn.cloudflare.steamstatic.com/steam/apps/';
const COVER_CHAIN = ['capsule_231x87.jpg', 'capsule_184x69.jpg', 'capsule_467x181.jpg', 'header.jpg'];
const COVER_PLACEHOLDER = "data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 184 69'%3E%3Crect fill='%231a1f3a' width='184' height='69'/%3E%3Ctext x='50%25' y='50%25' text-anchor='middle' dy='.3em' fill='%2364748b' font-family='system-ui' font-size='10'%3ENo Image%3C/text%3E%3C/svg%3E";
const _coverCache = new Map();
function getBoosterCoverUrl(appId) {
const id = String(appId);
if (_coverCache.has(id)) return _coverCache.get(id);
try {
const cached = localStorage.getItem('sme_cover_' + id);
if (cached) { _coverCache.set(id, cached); return cached; }
} catch(e) {}
return COVER_CDN_AKAMAI + id + '/' + COVER_CHAIN[0];
}
function buildCoverFallbackChain(appId) {
const id = String(appId);
const chain = [];
COVER_CHAIN.forEach(p => chain.push(COVER_CDN_AKAMAI + id + '/' + p));
chain.push(COVER_CDN_CF + id + '/capsule_231x87.jpg');
chain.push(COVER_CDN_CF + id + '/header.jpg');
return chain;
}
function handleCoverError(img) {
const appId = img.dataset.appid;
if (!appId) return;
const chain = buildCoverFallbackChain(appId);
let idx = parseInt(img.dataset.fbIdx || '0', 10);
while (idx < chain.length && (img.src === chain[idx] || img.src === decodeURIComponent(chain[idx]))) idx++;
if (idx < chain.length) {
img.dataset.fbIdx = String(idx + 1);
img.src = chain[idx];
} else {
img.src = COVER_PLACEHOLDER;
img.classList.add('sme-booster-cover-failed');
}
}
function handleCoverLoad(img) {
const appId = img.dataset.appid;
if (!appId || !img.src || img.src.indexOf('data:image') === 0) return;
const id = String(appId);
_coverCache.set(id, img.src);
try { localStorage.setItem('sme_cover_' + id, img.src); } catch(e) {}
}
/** 渲染补充包面板 */
function renderBoosterPanel() {
if (!boosterPanelEl) return;
const container = boosterPanelEl.querySelector('.sme-booster-content');
if (!container) return;
// 获取过滤后的游戏列表
let games = [];
for (const key in boosterGameInfo) {
const info = boosterGameInfo[key];
if (!info || !info.appid) continue;
const appid = String(info.appid);
// 搜索过滤
if (boosterSearchKeyword && !(info.name || '').toLowerCase().includes(boosterSearchKeyword.toLowerCase())) continue;
// 分类过滤
if (boosterFilterType === 'queue' && !boosterConfig.queue.includes(appid)) continue;
if (boosterFilterType === 'favorites' && !boosterConfig.favorites.includes(appid)) continue;
if (boosterFilterType === 'blacklist' && !boosterConfig.blacklist.includes(appid)) continue;
if (boosterFilterType === 'available' && getGameStatus(appid) !== 'available') continue;
if (boosterFilterType === 'all' && boosterConfig.blacklist.includes(appid)) continue;
games.push({ appid, name: info.name, series: info.series, price: info.price, status: getGameStatus(appid) });
}
// 排序
games.sort((a, b) => {
let va, vb;
switch (boosterSortKey) {
case 'name': va = (a.name || '').toLowerCase(); vb = (b.name || '').toLowerCase(); break;
case 'gems': va = parseInt(a.price, 10) || 0; vb = parseInt(b.price, 10) || 0; break;
case 'profit': {
const pa = calcBoosterProfit(a.appid);
const pb = calcBoosterProfit(b.appid);
va = pa ? pa.profit : -Infinity;
vb = pb ? pb.profit : -Infinity;
break;
}
case 'cardPrice': {
va = (boosterPriceData[a.appid] && boosterPriceData[a.appid].cardPrice) || 0;
vb = (boosterPriceData[b.appid] && boosterPriceData[b.appid].cardPrice) || 0;
break;
}
case 'status': va = a.status; vb = b.status; break;
default: va = a.name; vb = b.name;
}
if (typeof va === 'string') {
return boosterSortDir === 'asc' ? va.localeCompare(vb) : vb.localeCompare(va);
}
return boosterSortDir === 'asc' ? va - vb : vb - va;
});
// 分页
const totalPages = Math.max(1, Math.ceil(games.length / boosterPageSize));
if (boosterPageNum > totalPages) boosterPageNum = totalPages;
if (boosterPageNum < 1) boosterPageNum = 1;
const startIdx = (boosterPageNum - 1) * boosterPageSize;
const pageGames = games.slice(startIdx, startIdx + boosterPageSize);
// 构建表格
let tableHtml = '';
if (games.length === 0) {
tableHtml = '没有匹配的游戏
';
} else {
const sortArrow = (key) => boosterSortKey === key ? (boosterSortDir === 'asc' ? ' ▲' : ' ▼') : '';
tableHtml = ``;
tableHtml += `| 封面 | `;
tableHtml += `游戏名称${sortArrow('name')} | `;
tableHtml += `状态${sortArrow('status')} | `;
tableHtml += `宝石成本${sortArrow('gems')} | `;
tableHtml += `卡牌均价${sortArrow('cardPrice')} | `;
tableHtml += `利润${sortArrow('profit')} | `;
tableHtml += `操作 | `;
tableHtml += `链接 | `;
tableHtml += `
`;
for (const g of pageGames) {
const isDone = g.status === 'done';
const isAvailable = g.status === 'available';
const inQueue = boosterConfig.queue.includes(g.appid);
const inFav = boosterConfig.favorites.includes(g.appid);
const inBlack = boosterConfig.blacklist.includes(g.appid);
const profit = calcBoosterProfit(g.appid);
const pd = boosterPriceData[g.appid] || {};
const gemsCost = parseInt(g.price, 10) || 0;
let statusHtml = '';
if (isDone) statusHtml = '已完成';
else if (isAvailable) statusHtml = '可制作';
else statusHtml = '冷却中';
let profitHtml = '--';
if (profit) {
const cls = profit.profit > 0 ? 'sme-booster-profit-positive' : (profit.profit < 0 ? 'sme-booster-profit-negative' : 'sme-booster-profit-neutral');
const sign = profit.profit > 0 ? '+' : '';
profitHtml = `${sign}${formatBoosterPrice(profit.profit)}`;
}
let cardPriceHtml = '点击查询';
if (pd.cardPrice) {
cardPriceHtml = formatBoosterPrice(pd.cardPrice);
if (pd.boosterBuyOrder) {
cardPriceHtml += `
买:${formatBoosterPrice(pd.boosterBuyOrder)}`;
}
} else if (pd.hadCard === false) {
cardPriceHtml = '不可交易';
}
// SVG 图标(使用 ICONS 对象)
const _qIcon = inQueue ? ICONS.queueActive : ICONS.queue;
const _favIcon = inFav ? ICONS.starFilled : ICONS.starOutline;
const _banIcon = inBlack ? ICONS.banActive : ICONS.ban;
const _pkgIcon = ICONS.package;
const _cardIcon = ICONS.card;
tableHtml += ``;
// 封面
tableHtml += ` | `;
// 游戏名称
tableHtml += `${escapeHtml(g.name)} | `;
tableHtml += `${statusHtml} | `;
tableHtml += `${gemsCost.toLocaleString()} | `;
tableHtml += `${cardPriceHtml} | `;
tableHtml += `${profitHtml} | `;
// 操作(纯图标按钮,固定尺寸)
tableHtml += ``;
tableHtml += ``;
tableHtml += ``;
tableHtml += ``;
tableHtml += ` | `;
// 链接
tableHtml += ` | `;
tableHtml += `
`;
}
tableHtml += `
`;
}
// 分页控件
let paginationHtml = '';
if (games.length > boosterPageSize) {
paginationHtml = ``;
}
// 统计信息
let availableCount = 0;
let queueAvailableCount = 0;
let totalGemsCost = 0;
let totalProfit = 0;
let profitCount = 0;
for (const key in boosterGameInfo) {
const appid = String(boosterGameInfo[key].appid);
if (getGameStatus(appid) === 'available') {
availableCount++;
if (boosterConfig.queue.includes(appid)) {
queueAvailableCount++;
totalGemsCost += parseInt(boosterGameInfo[key].price, 10) || 0;
const p = calcBoosterProfit(appid);
if (p) { totalProfit += p.profit; profitCount++; }
}
}
}
const sackPriceDisplay = boosterCustomSackPrice > 0 ? boosterCustomSackPrice : boosterGemsSackPrice;
let infoBarHtml = ``;
infoBarHtml += `宝石余额: ${boosterGemsDisplay.toLocaleString()}`;
infoBarHtml += `一袋宝珠: ${formatBoosterPrice(sackPriceDisplay)}`;
infoBarHtml += `可制作: ${availableCount}`;
infoBarHtml += `队列可做: ${queueAvailableCount}`;
if (queueAvailableCount > 0) {
infoBarHtml += `队列宝石消耗: ${totalGemsCost.toLocaleString()}`;
}
if (profitCount > 0) {
const cls = totalProfit > 0 ? 'sme-booster-profit-positive' : 'sme-booster-profit-negative';
infoBarHtml += `队列预期利润: ${totalProfit > 0 ? '+' : ''}${formatBoosterPrice(totalProfit)}`;
}
infoBarHtml += `
`;
container.innerHTML = tableHtml + paginationHtml + infoBarHtml;
// 封面图降级链:error→尝试下一个CDN,load→缓存成功URL
container.querySelectorAll('img.sme-booster-cover').forEach(img => {
img.addEventListener('error', () => handleCoverError(img));
img.addEventListener('load', () => handleCoverLoad(img));
});
// 绑定表头排序事件
container.querySelectorAll('th[data-sort]').forEach(th => {
th.addEventListener('click', () => {
const key = th.dataset.sort;
if (boosterSortKey === key) {
boosterSortDir = boosterSortDir === 'asc' ? 'desc' : 'asc';
} else {
boosterSortKey = key;
boosterSortDir = 'asc';
}
renderBoosterPanel();
});
});
// 绑定分页事件
container.querySelectorAll('.sme-booster-pagination button[data-page]').forEach(btn => {
btn.addEventListener('click', () => {
if (btn.dataset.page === 'prev') boosterPageNum--;
else boosterPageNum++;
renderBoosterPanel();
});
});
// 绑定操作按钮事件
container.querySelectorAll('.sme-booster-action-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const row = btn.closest('tr');
if (!row) return;
const appid = row.dataset.appid;
const action = btn.dataset.action;
handleBoosterAction(appid, action);
});
});
// 异步获取英文名称并更新补充包链接(避免中文名导致链接失效)
updateBoosterLinksAsync(pageGames);
}
/** 异步获取当前页游戏的英文名称,更新补充包链接 href */
async function updateBoosterLinksAsync(pageGames) {
if (!pageGames || pageGames.length === 0) return;
// 筛选需要获取英文名称的游戏
const needFetch = pageGames.filter(g => !boosterEnglishNameCache[String(g.appid)]);
if (needFetch.length > 0) {
// 并发获取(限制并发数 3),避免触发 Steam API 速率限制
const CONCURRENCY = 3;
for (let i = 0; i < needFetch.length; i += CONCURRENCY) {
const batch = needFetch.slice(i, i + CONCURRENCY);
await Promise.all(batch.map(g => fetchEnglishName(g.appid)));
if (i + CONCURRENCY < needFetch.length) await sleep(300);
}
// 获取完成后更新当前页面中所有补充包链接的 href
if (boosterPanelEl) {
for (const g of pageGames) {
const row = boosterPanelEl.querySelector(`tr[data-appid="${g.appid}"]`);
if (row) {
const link = row.querySelector('.sme-booster-link');
if (link) link.href = buildBoosterMarketUrl(g.appid, g.name);
}
}
}
}
}
/** 处理游戏操作 */
function handleBoosterAction(appid, action) {
appid = String(appid);
const idxQueue = boosterConfig.queue.indexOf(appid);
const idxFav = boosterConfig.favorites.indexOf(appid);
const idxBlack = boosterConfig.blacklist.indexOf(appid);
if (action === 'queue') {
if (idxQueue > -1) boosterConfig.queue.splice(idxQueue, 1);
else {
boosterConfig.queue.push(appid);
if (idxBlack > -1) boosterConfig.blacklist.splice(idxBlack, 1);
}
} else if (action === 'fav') {
if (idxFav > -1) boosterConfig.favorites.splice(idxFav, 1);
else {
boosterConfig.favorites.push(appid);
if (idxBlack > -1) boosterConfig.blacklist.splice(idxBlack, 1);
}
} else if (action === 'black') {
if (idxBlack > -1) boosterConfig.blacklist.splice(idxBlack, 1);
else {
boosterConfig.blacklist.push(appid);
if (idxQueue > -1) boosterConfig.queue.splice(idxQueue, 1);
if (idxFav > -1) boosterConfig.favorites.splice(idxFav, 1);
}
}
saveBoosterConfig();
renderBoosterPanel();
}
/** 自动查询卡牌价格 */
async function autoQueryCardPrices() {
if (boosterQueryRunning) { boosterToast('查询正在进行中', 'warning'); return; }
boosterQueryRunning = true;
// 获取当前过滤后的游戏列表
const games = [];
for (const key in boosterGameInfo) {
const info = boosterGameInfo[key];
if (!info || !info.appid) continue;
const appid = String(info.appid);
if (boosterSearchKeyword && !(info.name || '').toLowerCase().includes(boosterSearchKeyword.toLowerCase())) continue;
if (boosterFilterType === 'queue' && !boosterConfig.queue.includes(appid)) continue;
if (boosterFilterType === 'favorites' && !boosterConfig.favorites.includes(appid)) continue;
if (boosterFilterType === 'blacklist' && !boosterConfig.blacklist.includes(appid)) continue;
if (boosterFilterType === 'available' && getGameStatus(appid) !== 'available') continue;
if (boosterFilterType === 'all' && boosterConfig.blacklist.includes(appid)) continue;
// 跳过已查询的
if (boosterPriceData[appid] && boosterPriceData[appid].hadCard) continue;
games.push({ appid, name: info.name });
}
if (games.length === 0) {
boosterToast('没有需要查询的游戏', 'info');
boosterQueryRunning = false;
return;
}
boosterToast(`开始查询 ${games.length} 个游戏的卡牌价格...`, 'info');
for (let i = 0; i < games.length; i++) {
if (!boosterQueryRunning) { boosterToast('查询已停止', 'warning'); break; }
const g = games[i];
try {
const result = await fetchCardPrice(g.appid);
if (!boosterPriceData[g.appid]) boosterPriceData[g.appid] = {};
if (result) {
boosterPriceData[g.appid].cardPrice = result.cardPrice;
boosterPriceData[g.appid].cardCount = result.cardCount;
boosterPriceData[g.appid].hadCard = true;
} else {
boosterPriceData[g.appid].hadCard = false;
}
// 同时查询补充包价格
if (boosterConfig.autoQueryBooster) {
const boosterResult = await fetchBoosterMarketPrice(g.appid, g.name);
if (boosterResult) {
boosterPriceData[g.appid].boosterBuyOrder = boosterResult.buyOrder;
boosterPriceData[g.appid].boosterSellOrder = boosterResult.sellOrder;
boosterPriceData[g.appid].hadBooster = true;
}
}
saveBoosterPriceCache();
renderBoosterPanel();
boosterToast(`[${i + 1}/${games.length}] ${g.name}`, 'info');
} catch (e) {
W(`Query failed for ${g.appid}:`, e.message);
}
}
boosterQueryRunning = false;
boosterToast('卡牌价格查询完成', 'success');
renderBoosterPanel();
}
/** 一键制作队列中的补充包 */
async function batchCreateBoosters() {
if (boosterCreateRunning) { boosterToast('制作正在进行中', 'warning'); return; }
// 获取队列中可制作的游戏
const available = [];
for (const appid of boosterConfig.queue) {
if (getGameStatus(appid) === 'available') {
const info = boosterGameInfo[appid];
if (info) available.push({ appid: String(appid), name: info.name, series: info.series, price: info.price });
}
}
if (available.length === 0) {
boosterToast('队列中没有可制作的补充包', 'warning');
return;
}
// 确认对话框
const totalGems = available.reduce((s, g) => s + (parseInt(g.price, 10) || 0), 0);
const confirmed = await showConfirmDialog(
`确定要制作 ${available.length} 个补充包吗?
消耗 ${totalGems.toLocaleString()} 颗宝石`,
{ title: '一键制作补充包', okText: '确认制作', okClass: 'sme-confirm-blue' }
);
if (!confirmed) return;
boosterCreateRunning = true;
const btn = boosterPanelEl.querySelector('#sme-booster-batch-btn');
if (btn) { btn.disabled = true; btn.textContent = '制作中...'; }
const progressBar = boosterPanelEl.querySelector('.sme-booster-progress');
const progressBarBar = boosterPanelEl.querySelector('.sme-booster-progress-bar');
if (progressBar) progressBar.classList.add('sme-visible');
let success = 0;
let failed = 0;
for (let i = 0; i < available.length; i++) {
const g = available[i];
if (progressBarBar) progressBarBar.style.width = `${((i) / available.length) * 100}%`;
try {
await createBoosterPack(g.appid, g.series);
success++;
boosterDoneList.push(g.appid);
boosterToast(`[${i + 1}/${available.length}] ${g.name} 制作成功`, 'success');
L(`Booster created: ${g.appid} (${g.name})`);
} catch (e) {
failed++;
boosterToast(`[${i + 1}/${available.length}] ${g.name} 失败: ${e.message}`, 'error');
W(`Booster create failed: ${g.appid}`, e.message);
if (e.message.includes('宝石') || e.message.includes('gem')) break;
}
// 间隔1.5秒避免限流
if (i < available.length - 1) await new Promise(r => setTimeout(r, 1500));
}
if (progressBarBar) progressBarBar.style.width = '100%';
setTimeout(() => { if (progressBar) progressBar.classList.remove('sme-visible'); }, 2000);
boosterCreateRunning = false;
if (btn) { btn.disabled = false; btn.textContent = '一键制作'; }
// 更新宝石显示
const newGems = getBoosterGems();
if (newGems > 0) boosterGemsDisplay = newGems;
boosterToast(`制作完成: 成功 ${success}, 失败 ${failed}`, success > 0 ? 'success' : 'error');
renderBoosterPanel();
}
/** 初始化补充包制作器页面 */
function initBoosterPage() {
L('Initializing booster creator page');
loadBoosterConfig();
loadBoosterPriceCache();
loadBoosterEnglishNameCache();
// 等待页面加载
const tryInit = (attempt) => {
const boosterArea = document.querySelector('.booster_creator_area');
if (!boosterArea) {
if (attempt < 30) setTimeout(() => tryInit(attempt + 1), 500);
return;
}
// 获取游戏数据
try {
boosterGameInfo = unsafeWindow.CBoosterCreatorPage?.sm_rgBoosterData;
} catch (e) {}
if (!boosterGameInfo) {
if (attempt < 30) {
W('Booster data not ready, retrying...', attempt);
setTimeout(() => tryInit(attempt + 1), 500);
return;
}
L('Booster data not found after timeout');
}
// 获取当前宝石数量
boosterGemsDisplay = getBoosterGems();
// 创建面板
boosterPanelEl = h('div', { class: 'sme-booster-panel' });
// 头部
const header = h('div', { class: 'sme-booster-header' });
header.appendChild(h('div', { class: 'sme-booster-title' }, [
h('span', { html: ICONS.package, style: { display: 'inline-flex', alignItems: 'center' } }),
'补充包制作器增强',
]));
header.appendChild(h('div', { class: 'sme-booster-gems', html: `${ICONS.gem} ${boosterGemsDisplay.toLocaleString()} 宝石` }));
// 操作说明提示条
const hints = h('div', { class: 'sme-booster-hints' });
const _hintSvg = (svg) => svg.replace('class="sme-svg"', '');
const hintItems = [
{ icon: _hintSvg(ICONS.queue), bg: 'var(--sme-green-a20)', color: 'var(--sme-green)', text: '队列:加入制作队列,一键制作时批量执行' },
{ icon: _hintSvg(ICONS.starFilled), bg: 'var(--sme-amber-a20)', color: 'var(--sme-amber)', text: '收藏:标记常玩游戏,方便快速筛选' },
{ icon: _hintSvg(ICONS.ban), bg: 'var(--sme-red-a20)', color: 'var(--sme-red)', text: '拉黑:隐藏不感兴趣的游戏' },
];
hints.innerHTML = hintItems.map(hint =>
`${hint.icon}${hint.text}`
).join('');
const _infoIcon = '';
hints.innerHTML += `${_infoIcon} 先查询卡牌均价查看利润,再一键制作队列中的游戏`;
// 工具栏
const toolbar = h('div', { class: 'sme-booster-toolbar' });
// --- 组1: 搜索 + 过滤 ---
const grp1 = h('div', { class: 'sme-booster-tb-group' });
// 搜索框
const searchInput = h('input', {
type: 'text',
class: 'sme-booster-search',
placeholder: '搜索游戏名称…',
value: boosterSearchKeyword,
'aria-label': '搜索游戏名称',
});
searchInput.addEventListener('input', () => {
boosterSearchKeyword = searchInput.value;
boosterPageNum = 1;
renderBoosterPanel();
});
grp1.appendChild(searchInput);
// 过滤下拉
const filterSelect = h('select', { class: 'sme-booster-select', 'aria-label': '过滤游戏' });
const filterOptions = [
{ value: 'all', label: '全部' },
{ value: 'queue', label: '队列' },
{ value: 'favorites', label: '收藏' },
{ value: 'available', label: '可制作' },
{ value: 'blacklist', label: '黑名单' },
];
for (const opt of filterOptions) {
const o = h('option', { value: opt.value }, opt.label);
if (boosterFilterType === opt.value) o.setAttribute('selected', '');
filterSelect.appendChild(o);
}
filterSelect.addEventListener('change', () => {
boosterFilterType = filterSelect.value;
boosterPageNum = 1;
renderBoosterPanel();
});
grp1.appendChild(filterSelect);
toolbar.appendChild(grp1);
// 分隔线
toolbar.appendChild(h('div', { class: 'sme-booster-tb-sep' }));
// --- 组2: 每页 + 宝珠价 ---
const grp2 = h('div', { class: 'sme-booster-tb-group' });
// 每页数量
grp2.appendChild(h('span', { style: { fontSize: '12px', color: 'var(--sme-text-dim)' } }, '每页:'));
const pageSizeInput = h('input', {
type: 'number',
class: 'sme-booster-search',
style: { width: '55px', textAlign: 'center', paddingLeft: '5px', backgroundImage: 'none' },
value: String(boosterPageSize),
min: '5',
max: '100',
title: '每页显示数量',
'aria-label': '每页显示数量',
});
pageSizeInput.addEventListener('change', () => {
const n = parseInt(pageSizeInput.value, 10);
if (n >= 5 && n <= 100) {
boosterPageSize = n;
boosterConfig.pageSize = n;
saveBoosterConfig();
boosterPageNum = 1;
renderBoosterPanel();
}
});
grp2.appendChild(pageSizeInput);
// 自定义宝珠价格
grp2.appendChild(h('span', { style: { fontSize: '12px', color: 'var(--sme-text-dim)' } }, '宝珠价:'));
const customPriceInput = h('input', {
type: 'number',
class: 'sme-booster-search',
style: { width: '65px', textAlign: 'center', paddingLeft: '5px', backgroundImage: 'none' },
value: boosterCustomSackPrice > 0 ? String((boosterCustomSackPrice / 100).toFixed(2)) : '',
placeholder: '自动',
step: '0.01',
title: '自定义一袋宝珠成本价(留空使用市场价)',
'aria-label': '自定义一袋宝珠价格',
});
customPriceInput.addEventListener('change', () => {
const v = parseFloat(customPriceInput.value);
if (!isNaN(v) && v > 0) {
boosterCustomSackPrice = Math.round(v * 100);
boosterConfig.customSackPrice = boosterCustomSackPrice;
} else {
boosterCustomSackPrice = 0;
boosterConfig.customSackPrice = 0;
}
saveBoosterConfig();
renderBoosterPanel();
});
grp2.appendChild(customPriceInput);
toolbar.appendChild(grp2);
// 分隔线
toolbar.appendChild(h('div', { class: 'sme-booster-tb-sep' }));
// --- 组3: 查询 ---
const grp3 = h('div', { class: 'sme-booster-tb-group' });
// 查询按钮
const queryBtn = h('button', { class: 'sme-booster-btn sme-booster-btn-amber', 'aria-label': '查询卡牌均价' });
queryBtn.innerHTML = ICONS.search + '查询卡牌均价';
queryBtn.addEventListener('click', () => {
if (boosterQueryRunning) {
boosterQueryRunning = false;
queryBtn.innerHTML = ICONS.search + '查询卡牌均价';
} else {
queryBtn.innerHTML = '停止查询';
autoQueryCardPrices().then(() => {
queryBtn.innerHTML = ICONS.search + '查询卡牌均价';
});
}
});
grp3.appendChild(queryBtn);
// 查询补充包复选框
const autoBoosterLabel = h('label', { style: { display: 'flex', alignItems: 'center', gap: '4px', fontSize: '12px', color: 'var(--sme-text-dim)', cursor: 'pointer' } });
const autoBoosterCheckbox = h('input', { type: 'checkbox', 'aria-label': '同时查询补充包价格' });
if (boosterConfig.autoQueryBooster) autoBoosterCheckbox.setAttribute('checked', '');
autoBoosterCheckbox.addEventListener('change', () => {
boosterConfig.autoQueryBooster = autoBoosterCheckbox.checked;
saveBoosterConfig();
});
autoBoosterLabel.appendChild(autoBoosterCheckbox);
autoBoosterLabel.appendChild(document.createTextNode('同时查补充包'));
grp3.appendChild(autoBoosterLabel);
toolbar.appendChild(grp3);
// 分隔线
toolbar.appendChild(h('div', { class: 'sme-booster-tb-sep' }));
// --- 组4: 制作 + 清缓存 ---
const grp4 = h('div', { class: 'sme-booster-tb-group' });
// 一键制作按钮(主要操作)
const batchBtn = h('button', { class: 'sme-booster-btn sme-booster-btn-green', id: 'sme-booster-batch-btn', 'aria-label': '一键制作队列中的补充包' });
batchBtn.innerHTML = ICONS.zap + '一键制作';
batchBtn.addEventListener('click', batchCreateBoosters);
grp4.appendChild(batchBtn);
// 清空缓存按钮(危险操作,小号字体)
const clearCacheBtn = h('button', { class: 'sme-booster-btn sme-booster-btn-red', 'aria-label': '清空所有价格缓存' }, '清空缓存');
clearCacheBtn.addEventListener('click', async () => {
if (await showConfirmDialog('确定清空所有卡牌价格缓存?', { title: '清空缓存', okText: '清空', okClass: 'sme-dialog-btn sme-dialog-ok' })) {
boosterPriceData = {};
saveBoosterPriceCache();
renderBoosterPanel();
boosterToast('价格缓存已清空', 'success');
}
});
grp4.appendChild(clearCacheBtn);
toolbar.appendChild(grp4);
// 内容区域
const content = h('div', { class: 'sme-booster-content' });
// 进度条
const progress = h('div', { class: 'sme-booster-progress' }, [
h('div', { class: 'sme-booster-progress-bar' }),
]);
boosterPanelEl.appendChild(header);
boosterPanelEl.appendChild(hints);
boosterPanelEl.appendChild(toolbar);
boosterPanelEl.appendChild(content);
boosterPanelEl.appendChild(progress);
// 插入到页面
if (boosterArea && boosterArea.parentNode) {
boosterArea.parentNode.insertBefore(boosterPanelEl, boosterArea.nextSibling);
} else {
const mainContent = document.querySelector('.maincontent');
if (mainContent) mainContent.appendChild(boosterPanelEl);
}
// 渲染初始表格
renderBoosterPanel();
// 异步获取宝石市场价格
fetchGemsSackPrice().then(price => {
boosterGemsSackPrice = price;
L('Gems sack price:', price);
renderBoosterPanel();
}).catch(e => W('Failed to get gems sack price:', e.message));
};
tryInit(0);
}
/** 清理补充包页面 */
function cleanupBoosterPage() {
if (boosterPanelEl) { boosterPanelEl.remove(); boosterPanelEl = null; }
boosterQueryRunning = false;
boosterCreateRunning = false;
boosterDoneList = [];
boosterGameInfo = null;
boosterPriceData = {};
boosterEnglishNameCache = {};
_boosterEnNameFetching.clear();
}
// ==================== v1.9.0 跨平台挂刀比价 ====================
// 数据源(2026-10-28 实测匿名可用;IGXE/C5/ECO 已要求登录,不接):
// BUFF: GET buff.163.com/api/market/goods/sell_order?game=csgo|dota2&goods_id=X
// 价格字段 data.items[].price(字符串 CNY),code==='OK'
// UUYP: POST api.youpin898.com/api/homepage/es/commodity/GetCsGoPagedList
// 价格字段 Data.CommodityList[].Price(字符串 CNY),Code===0(仅 730)
// ID 映射:SmallRob/steam-namespace data/market-ratio-index-v1.{730,570}.json
// (源=EricZhu-42/SteamTradingSite-ID-Mapper,market_hash_name → {b: buffId, u?: uuypId})
// 护栏:平台级熔断(429/403 → 30 分钟);单物品连败 2 次 → 10 分钟退避;
// 渠道限速 1500ms/件;数据永远带时间戳展示,熔断/失败如实降级,不用旧数据冒充。
const RATIO_INDEX_BASE = 'https://cdn.jsdelivr.net/gh/SmallRob/steam-namespace@main/data/';
const RATIO_INDEX_TTL = 30 * 24 * 3600 * 1000; // ID 索引缓存 30 天
const RATIO_QUOTES_TTL = 7 * 24 * 3600 * 1000; // 比价结果缓存(池子列用)7 天
const RATIO_FRESH_MS = 10 * 60 * 1000; // 详情页 10 分钟内直接用缓存
const RATIO_CIRCUIT_MS = 30 * 60 * 1000; // 平台熔断时长
const RATIO_ITEM_BACKOFF_MS = 10 * 60 * 1000; // 单物品连败退避
const RATIO_ITEM_MAX_FAILS = 2;
const RATIO_SCAN_GAP_MS = 1500; // 扫描节奏(渠道层也限速)
const RATIO_SCAN_BATCH = 100; // 单轮扫描上限
const RATIO_QUOTES_KEY = 'sme_ratio_quotes_v1'; // GM 存储:比价结果缓存
const RATIO_SUPPORTED_APPS = ['730', '570'];
const ratioState = {
indexes: {}, // appid -> { digest, items }(内存)
indexLoading: {}, // appid -> Promise
circuits: {}, // 'buff'/'uuyp' -> failUntil 时间戳
itemFails: {}, // 'buff|appid::name' -> { n, until }
quotes: null, // GM 存储的比价结果缓存(懒加载)
cache: {}, // 'appid::name' -> { at, data }(内存,含 in-flight 复用)
inflight: {}, // 'appid::name' -> Promise
scanning: false,
scanAbort: false,
styleAdded: false,
};
const chBuffRatio = new SmeRateChannel('ratio-buff', { gapAfter: RATIO_SCAN_GAP_MS });
const chUuypRatio = new SmeRateChannel('ratio-uuyp', { gapAfter: RATIO_SCAN_GAP_MS });
const RATIO_CSS = `
.sme-ratio-note{font-size:11px;color:var(--sme-text-muted);line-height:1.5;margin-bottom:8px}
.sme-ratio-table{width:100%;border-collapse:collapse;font-size:12px;margin:4px 0 10px}
.sme-ratio-table th{text-align:left;color:var(--sme-text-dim);font-weight:600;padding:4px 8px;border-bottom:1px solid rgba(255,255,255,0.08);white-space:nowrap}
.sme-ratio-table td{padding:5px 8px;border-bottom:1px solid rgba(255,255,255,0.04);white-space:nowrap}
.sme-ratio-table tr:last-child td{border-bottom:none}
.sme-ratio-badge{display:inline-block;padding:1px 7px;border-radius:9px;font-size:11px;font-weight:600}
.sme-ratio-badge.good{background:rgba(108,220,132,0.16);color:#6cdc84}
.sme-ratio-badge.mid{background:rgba(255,255,255,0.08);color:var(--sme-text-dim)}
.sme-ratio-badge.bad{background:rgba(233,105,105,0.14);color:#e96969}
.sme-ratio-platform{font-weight:600;color:var(--sme-text)}
.sme-ratio-sub{color:var(--sme-text-muted);font-size:11px}
.sme-ratio-err{color:#e96969;font-size:11px}
.sme-ratio-actions{display:flex;gap:8px;align-items:center;margin:6px 0 2px}
`;
function ratioEnsureStyle() {
if (ratioState.styleAdded) return;
GM_addStyle(RATIO_CSS);
ratioState.styleAdded = true;
}
function ratioQuotesStore() {
if (ratioState.quotes) return ratioState.quotes;
try { ratioState.quotes = GM_getValue(RATIO_QUOTES_KEY, {}); }
catch (e) { ratioState.quotes = {}; }
return ratioState.quotes;
}
function ratioSaveQuote(key, data) {
const store = ratioQuotesStore();
// 顺手清理过期条目,防止 GM 存储无界膨胀
const now = Date.now();
for (const k of Object.keys(store)) {
if (!store[k] || now - store[k].at > RATIO_QUOTES_TTL) delete store[k];
}
store[key] = { at: now, data };
ratioState.quotes = store;
try { GM_setValue(RATIO_QUOTES_KEY, store); } catch (e) { /* 配额满时放弃持久化,内存仍可用 */ }
}
/** 懒加载 ID 索引(GM 缓存 30 天 + digest 复用内存) */
async function ratioLoadIndex(appid) {
if (ratioState.indexes[appid]) return ratioState.indexes[appid];
if (ratioState.indexLoading[appid]) return ratioState.indexLoading[appid];
const task = (async () => {
let cached = null;
try { cached = GM_getValue('sme_ratio_index_v1_' + appid, null); } catch (e) {}
if (cached && cached.items && Date.now() - cached.at < RATIO_INDEX_TTL) {
ratioState.indexes[appid] = cached;
return cached;
}
const data = await gmFetchJson(RATIO_INDEX_BASE + 'market-ratio-index-v1.' + appid + '.json', 30000);
if (!data || !data.items || !data.digest) throw new Error('ID 索引格式异常');
const entry = { at: Date.now(), digest: data.digest, items: data.items };
try { GM_setValue('sme_ratio_index_v1_' + appid, entry); } catch (e) { /* 超配额时仅内存 */ }
ratioState.indexes[appid] = entry;
return entry;
})();
ratioState.indexLoading[appid] = task;
try { return await task; } finally { delete ratioState.indexLoading[appid]; }
}
function ratioCircuitOpen(platform) {
const until = ratioState.circuits[platform] || 0;
return until > Date.now();
}
function ratioRecordFailure(platform, cacheKey, err) {
L('Ratio: ' + platform + ' 请求失败', cacheKey, err && err.message);
if (err && (/HTTP 429|HTTP 403/.test(err.message))) {
ratioState.circuits[platform] = Date.now() + RATIO_CIRCUIT_MS;
(platform === 'buff' ? chBuffRatio : chUuypRatio).reset();
L('Ratio: ' + platform + ' 触发限流,熔断 ' + (RATIO_CIRCUIT_MS / 60000) + ' 分钟');
return;
}
const fk = platform + '|' + cacheKey;
const cur = ratioState.itemFails[fk] || { n: 0, until: 0 };
cur.n += 1;
cur.until = cur.n >= RATIO_ITEM_MAX_FAILS ? Date.now() + RATIO_ITEM_BACKOFF_MS : 0;
ratioState.itemFails[fk] = cur;
}
function ratioItemInBackoff(platform, cacheKey) {
const fk = platform + '|' + cacheKey;
const cur = ratioState.itemFails[fk];
return !!(cur && cur.until > Date.now());
}
function gmPostJson(url, body, timeout = 15000) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'POST',
url: url,
headers: { 'Content-Type': 'application/json', 'Referer': 'https://www.youpin898.com/' },
data: JSON.stringify(body),
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')),
});
});
}
/** BUFF 在售前 10 档 → { lowCny, avg3Cny, count }(价格单位:元,float) */
async function ratioFetchBuff(buffId, game) {
const url = 'https://buff.163.com/api/market/goods/sell_order?game=' + game
+ '&goods_id=' + encodeURIComponent(buffId) + '&page_num=1&page_size=10';
const j = await gmFetchJson(url, 15000);
if (!j || j.code !== 'OK' || !j.data || !Array.isArray(j.data.items)) throw new Error('BUFF 响应异常');
const prices = j.data.items.map(i => parseFloat(i.price)).filter(p => p > 0);
if (!prices.length) return { lowCny: 0, avg3Cny: 0, count: j.data.total_count || 0, empty: true };
return {
lowCny: prices[0],
avg3Cny: prices.slice(0, 3).reduce((a, b) => a + b, 0) / Math.min(3, prices.length),
count: j.data.total_count || prices.length,
};
}
/** 悠悠有品在售前 10 档 → { lowCny, avg3Cny, count }(仅 730) */
async function ratioFetchUuyp(uuypId) {
const j = await gmPostJson('https://api.youpin898.com/api/homepage/es/commodity/GetCsGoPagedList', {
listSortType: 1, listType: 10, pageIndex: 1, pageSize: 10, sortType: 1, templateId: String(uuypId),
}, 15000);
if (!j || j.Code !== 0 || !j.Data || !Array.isArray(j.Data.CommodityList)) throw new Error('悠悠有品响应异常');
const prices = j.Data.CommodityList.map(i => parseFloat(i.Price)).filter(p => p > 0);
if (!prices.length) return { lowCny: 0, avg3Cny: 0, count: 0, empty: true };
return {
lowCny: prices[0],
avg3Cny: prices.slice(0, 3).reduce((a, b) => a + b, 0) / Math.min(3, prices.length),
count: prices.length,
};
}
/**
* 由买家支付价反推卖家到手(分)。Steam 手续费 = max(5%, 1分) + max(10%, 1分),
* 与 calcReceivedAmount 同口径但独立实现(这里的锚定价是 CNY,与钱包币种无关)。
*/
function ratioSteamNetCents(grossCents) {
if (!(grossCents > 0)) return 0;
const feeOf = (recv) => {
const sf = Math.max(Math.floor(recv * 0.05), 1);
const pf = Math.max(Math.floor(recv * 0.10), 1);
return recv + sf + pf;
};
let est = Math.floor(grossCents / 1.15);
let undershot = false;
for (let i = 0; i < 10; i++) {
const total = feeOf(est);
if (total === grossCents) return est;
if (total > grossCents) {
if (undershot) return est - 1; // 尾差并入手续费,到手取 est-1
est -= 1;
} else {
undershot = true;
est += 1;
}
}
return est;
}
/** priceoverview(currency=23 强制 CNY)→ { medianCny, lowestCny, volume },单位:分 */
async function ratioFetchSteamAnchor(appid, name) {
const url = 'https://steamcommunity.com/market/priceoverview/?appid=' + appid
+ '¤cy=23&market_hash_name=' + encodeURIComponent(name);
const j = await gmFetchJson(url, 15000);
if (!j || !j.success) throw new Error('priceoverview 响应异常');
const toCents = (s) => {
if (!s) return 0;
const m = String(s).match(/([\d.,]+)/);
if (!m) return 0;
return Math.round(parseFloat(m[1].replace(/,/g, '')) * 100) || 0;
};
return {
medianCny: toCents(j.median_price),
lowestCny: toCents(j.lowest_price),
volume: parseInt(String(j.volume || '0').replace(/,/g, ''), 10) || 0,
};
}
/**
* 拉取一件物品的完整比价数据(缓存 10 分钟;in-flight 复用)。
* 返回 null 表示:非 730/570、未映射、熔断中或退避中(调用方如实展示「无数据」)。
* @returns {Promise<{unmapped?:boolean, steam?:object, buff?:object, uuyp?:object}|null>}
*/
async function ratioGetQuotes(appid, name) {
if (!RATIO_SUPPORTED_APPS.includes(appid)) return null;
const cacheKey = appid + '::' + name;
const cached = ratioState.cache[cacheKey];
if (cached && Date.now() - cached.at < RATIO_FRESH_MS) return cached.data;
if (ratioState.inflight[cacheKey]) return ratioState.inflight[cacheKey];
const task = (async () => {
let entry = null;
try { entry = await ratioLoadIndex(appid); } catch (e) {
L('Ratio: ID 索引加载失败', appid, e && e.message);
return null;
}
const ids = entry.items[name];
if (!ids) return { unmapped: true };
const game = appid === '730' ? 'csgo' : 'dota2';
const wantBuff = typeof ids.b === 'number' && ids.b > 0;
const wantUuyp = appid === '730' && typeof ids.u === 'number' && ids.u > 0;
if (!wantBuff && !wantUuyp) return { unmapped: true };
const out = { };
// Steam 锚点(CNY):24h 中位优先,最低价兜底
try {
out.steam = await ratioFetchSteamAnchor(appid, name);
} catch (e) {
ratioRecordFailure('steam', cacheKey, e);
}
if (wantBuff && !ratioCircuitOpen('buff') && !ratioItemInBackoff('buff', cacheKey)) {
try {
out.buff = await chBuffRatio.run(() => ratioFetchBuff(ids.b, game));
} catch (e) { ratioRecordFailure('buff', cacheKey, e); }
}
if (wantUuyp && !ratioCircuitOpen('uuyp') && !ratioItemInBackoff('uuyp', cacheKey)) {
try {
out.uuyp = await chUuypRatio.run(() => ratioFetchUuyp(ids.u));
} catch (e) { ratioRecordFailure('uuyp', cacheKey, e); }
}
const data = (out.steam || out.buff || out.uuyp) ? out : null;
if (data) ratioSaveQuote(cacheKey, data);
ratioState.cache[cacheKey] = { at: Date.now(), data };
return data;
})();
ratioState.inflight[cacheKey] = task;
try { return await task; } finally { delete ratioState.inflight[cacheKey]; }
}
/** 挂刀比例(行情站口径)= 平台最低价 ÷ Steam 24h 中位价;>1 Steam 划算,<1 平台价更低 */
function ratioComputeRatio(platformLowCnyCents, medianCnyCents) {
if (!(platformLowCnyCents > 0) || !(medianCnyCents > 0)) return null;
return platformLowCnyCents / medianCnyCents;
}
/** 转平台到手差(分,CNY)= 平台最低×feeFactor − Steam 最低卖单税后到手;正数=转平台多赚 */
function ratioComputeNetDelta(platformLowCnyCents, steamLowestCnyCents, feeFactor) {
if (!(platformLowCnyCents > 0) || !(steamLowestCnyCents > 0)) return null;
const netPlatform = Math.round(platformLowCnyCents * feeFactor);
const netSteam = ratioSteamNetCents(steamLowestCnyCents);
return { netPlatform, netSteam, delta: netPlatform - netSteam };
}
function ratioCnyFmt(cents) {
if (!(cents > 0)) return '—';
return '¥' + (cents / 100).toFixed(2);
}
function ratioBadge(ratio) {
if (ratio === null) return '—';
const pct = Math.round(ratio * 100);
// ≥1:Steam 价不高于平台,挂单划算;<0.85:平台明显更便宜(买刀好时机);中间灰色带
const cls = ratio >= 1 ? 'good' : (ratio < 0.85 ? 'bad' : 'mid');
return `${pct}%`;
}
/** 详情页「挂刀比价」标签页渲染(switchTab 调用;点开才发请求) */
function renderRatioPane(pane) {
ratioEnsureStyle();
const info = getListingInfo();
if (!info || !RATIO_SUPPORTED_APPS.includes(info.appid)) {
pane.appendChild(makeError('挂刀比价仅支持 CS2(730) / DOTA2(570) 市场物品。'));
return;
}
const feeFactor = settings.get('platformFeeFactor') || 0.85;
const root = h('div', {});
pane.appendChild(root);
let rendering = false;
async function draw() {
if (rendering) return;
rendering = true;
const paint = (html) => { root.innerHTML = html; };
const cacheKey = info.appid + '::' + info.marketHashName;
const cached = ratioState.cache[cacheKey];
const ageMin = cached ? Math.round((Date.now() - cached.at) / 60000) : null;
paint(`拉取中…(BUFF / 悠悠有品 + Steam CNY 锚点,各平台限速 1.5s/件)
`);
const data = await ratioGetQuotes(info.appid, info.marketHashName);
let html = ''
+ ``
+ `平台价恒为 CNY${ageMin !== null ? ' · 数据 ' + ageMin + ' 分钟前' : ''} · 扣费系数 ${(feeFactor * 100).toFixed(0)}%
`;
html += '挂刀比例 = 平台最低价 ÷ Steam 24h 中位价(>100% 说明 Steam 更贵,挂 Steam 划算;<85% 说明平台明显更便宜)。到手对比按「平台最低×扣费系数 vs Steam 最低卖单税后(≈87%)」估算,仅供决策参考。
';
if (data === null) {
html += '比价数据不可用:' + (
ratioCircuitOpen('buff') || ratioCircuitOpen('uuyp')
? '平台限流熔断中(约 ' + Math.ceil(((Math.max(ratioState.circuits.buff || 0, ratioState.circuits.uuyp || 0)) - Date.now()) / 60000) + ' 分钟后恢复)'
: '请求失败,可稍后点「刷新」重试'
) + '。
';
} else if (data.unmapped) {
html += '该物品没有第三方平台映射(ID 索引未收录,如涂鸦/徽章/新饰品)。
';
} else {
const st = data.steam;
html += '| 市场 | 最低价 | 前3档均价 | 在售 | 挂刀比例 | 说明 |
';
if (st && (st.medianCny > 0 || st.lowestCny > 0)) {
html += `| Steam(CNY 锚点) | `
+ `${ratioCnyFmt(st.lowestCny)} | — | `
+ `${st.volume > 0 ? st.volume + ' 件/24h' : '—'} | — | `
+ `24h 中位 ${ratioCnyFmt(st.medianCny)} |
`;
} else {
// 区分两种「没有数据」:请求本身失败(st 为空) vs 请求成功但该物品当前无报价。
// Steam 对无挂单的物品会返回 success:true 但不带价格,混为一谈会被当成 bug。
const why = st
? '无报价(该物品当前无在售或无 24h 成交)'
: '获取失败(请求出错,可点「刷新」重试)';
html += `| Steam(CNY 锚点) | ${why} |
`;
}
for (const [pk, label] of [['buff', 'BUFF'], ['uuyp', '悠悠有品']]) {
const p = data[pk];
if (!p) {
// 未请求(未映射到该平台 / UUYP 仅 730)时不占行,保持表格干净
continue;
}
if (p.empty) {
html += `| ${label} | 平台暂无在售 |
`;
continue;
}
const ratio = st ? ratioComputeRatio(Math.round(p.lowCny * 100), st.medianCny) : null;
let deltaTxt = '—';
if (st && st.lowestCny > 0) {
const d = ratioComputeNetDelta(Math.round(p.lowCny * 100), st.lowestCny, feeFactor);
if (d) {
deltaTxt = d.delta >= 0
? `转平台多 ¥${(d.delta / 100).toFixed(2)}`
: `转平台少 ¥${(-d.delta / 100).toFixed(2)}`;
}
}
html += `| ${label} | `
+ `${ratioCnyFmt(Math.round(p.lowCny * 100))} | `
+ `${ratioCnyFmt(Math.round(p.avg3Cny * 100))} | `
+ `${p.count || '—'} | `
+ `${ratioBadge(ratio)} | `
+ `${deltaTxt} |
`;
}
html += '
';
html += '「转平台多/少」= 把该物品拿到第三方平台卖 vs 在 Steam 挂单的到手价差(估算)。
';
}
paint(html);
const btn = document.getElementById('sme-ratio-refresh');
if (btn) btn.addEventListener('click', () => {
delete ratioState.cache[cacheKey]; // 强制刷新(绕过 10 分钟新鲜期)
draw();
});
rendering = false;
}
draw().catch(e => {
L('Ratio: 渲染失败', e);
root.innerHTML = '比价数据加载失败:' + escapeHtml(String(e && e.message || e)) + '
';
rendering = false;
});
}
// ---------- v1.9.0 池子挂刀扫描(仅 BUFF 单源;默认关) ----------
function ratioPoolScanCandidates() {
const out = [];
for (const [key, it] of poolState.items) {
const appid = String(it.appid || (key.split('::')[0] || ''));
if (!RATIO_SUPPORTED_APPS.includes(appid)) continue;
const name = it.name || key.split('::').slice(1).join('::');
if (!name) continue;
out.push({ key, appid, name });
}
return out;
}
async function poolRatioScan() {
if (ratioState.scanning) { ratioState.scanAbort = true; poolSetStatus('正在停止扫描…'); return; }
if (!settings.get('crossPlatformQuote')) { poolSetStatus('请先在设置开启「挂刀比价」'); return; }
const cands = ratioPoolScanCandidates();
if (!cands.length) { poolSetStatus('池子里没有 CS2/DOTA2 物品'); return; }
const ok = confirm(`将对池子内 ${Math.min(cands.length, RATIO_SCAN_BATCH)} 件 CS2/DOTA2 物品逐件请求 BUFF 在售价(约 ${(Math.min(cands.length, RATIO_SCAN_BATCH) * RATIO_SCAN_GAP_MS / 1000 / 60).toFixed(1)} 分钟),限速 1.5s/件。继续?`);
if (!ok) return;
ratioState.scanning = true;
ratioState.scanAbort = false;
// 预热所需 appid 的 ID 索引(懒加载)
const apps = [...new Set(cands.map(c => c.appid))];
try { await Promise.all(apps.map(a => ratioLoadIndex(a))); } catch (e) {
poolSetStatus('ID 索引加载失败:' + (e && e.message || e));
ratioState.scanning = false;
return;
}
let done = 0, hit = 0;
for (const c of cands.slice(0, RATIO_SCAN_BATCH)) {
if (ratioState.scanAbort) break;
try {
const data = await ratioGetQuotes(c.appid, c.name);
if (data && !data.unmapped && (data.buff || data.uuyp)) hit++;
} catch (e) { /* 失败计数/熔断由 ratioGetQuotes 内部处理 */ }
done++;
poolSetStatus(`挂刀扫描 ${done}/${Math.min(cands.length, RATIO_SCAN_BATCH)} · 命中 ${hit}`);
poolSetProgress(done / Math.min(cands.length, RATIO_SCAN_BATCH));
}
const aborted = ratioState.scanAbort;
ratioState.scanning = false;
ratioState.scanAbort = false;
poolSetProgress(0);
poolSetStatus(aborted ? `挂刀扫描已停止(完成 ${done} 件)` : `挂刀扫描完成:${hit}/${done} 件有平台数据`);
}
/**
* 池子明细行「挂刀」列单元格(有缓存才显示,绝不主动请求)。
* @returns {string} HTML
*/
function ratioPoolCellHtml(appid, name) {
if (!RATIO_SUPPORTED_APPS.includes(String(appid || ''))) return '—';
const store = ratioQuotesStore();
const q = store[appid + '::' + name];
if (!q || !q.data) return '—';
const low = (q.data.buff && q.data.buff.lowCny) || (q.data.uuyp && q.data.uuyp.lowCny);
const median = q.data.steam && q.data.steam.medianCny;
const ratio = ratioComputeRatio(Math.round(low * 100), median);
if (ratio === null) return '—';
const ageH = Math.round((Date.now() - q.at) / 3600000);
const cls = ratio >= 1 ? 'good' : (ratio < 0.85 ? 'bad' : 'mid');
return `${Math.round(ratio * 100)}%`;
}
// ==================== 启动(等待 URL 稳定) ====================
let stableSince = 0;
let lastCheck = location.href;
let domReady = false;
function isOnMarket() {
return /^\/market(?:\/|$)/.test(location.pathname)
|| /^\/(?:id|profiles)\/[^/]+\/inventory/.test(location.pathname)
|| /^\/tradeoffer/.test(location.pathname)
|| /^\/tradingcards\/boostercreator/.test(location.pathname);
}
function tryStart() {
if (!domReady) return;
const now = Date.now();
const curURL = location.href;
if (curURL !== lastCheck) {
lastCheck = curURL;
stableSince = now;
return;
}
if (now - stableSince < 800) return;
if (!isOnMarket()) return;
clearInterval(stableTimer);
init();
}
let stableTimer;
function onDOMReady() {
domReady = true;
stableSince = Date.now();
lastCheck = location.href;
stableTimer = setInterval(tryStart, 250);
setTimeout(() => {
if (domReady && isOnMarket() && !initialized) {
clearInterval(stableTimer);
init();
}
}, 2000);
}
// v1.5.0:显示更多价格——必须在 document-start、页面 react-dom 初始化前注入假 DevTools hook,
// 因此这里随脚本执行同步调用(不能放进 init:init 要等 DOM ready,届时 react-dom 早已初始化)
if (settings.get('showMorePrices') && /^\/market\/listings\/\d+\/.+/.test(location.pathname)) {
installShowMoreHook();
}
if (document.readyState === 'interactive' || document.readyState === 'complete') {
onDOMReady();
} else {
document.addEventListener('DOMContentLoaded', onDOMReady);
}
})();