// ==UserScript==
// @name Steamdb 增强
// @namespace https://steamdb.info/
// @version 1.1.0
// @description 免费包列表排序(添加顺序跟随)+ 添加队列预览(全部已添加保留 + 待添加分页,可隐藏官方日志)+ sub/app 价格表与货币切换器中文化(可开关)
// @author SmallFork
// @license MIT
// @match https://steamdb.info/freepackages/*
// @match https://steamdb.info/freepackages
// @match https://steamdb.info/sub/*
// @match https://steamdb.info/sub
// @match https://steamdb.info/app/*
// @match https://steamdb.info/app
// @icon data:image/svg+xml,
// @grant none
// @run-at document-start
// @tag Steam
// ==/UserScript==
(function () {
'use strict';
/* ---------- 添加间隔:不注入 URL,仅报告 ----------
* 间隔由站点原生机制决定:默认 180s,URL 手动加 ?delay=秒数 可指定(站点 freepackages.js
* 与本脚本 rateDelayMs() 同源读取,想改间隔改 URL 即可,脚本全部倒计时自动跟随)。
* 已带 country 参数时合并写:?country=IN&delay=120
* 本脚本 @run-at document-start 只在控制台打印当前生效的间隔秒数。 */
console.log(`[SFP] 当前添加间隔:${currentDelaySec()} 秒`);
const PANEL_ID = 'freepackages';
const TOOLBAR_ID = 'sfd-fp-sort-toolbar';
/* ============ 排序模块 ============ */
let currentSort = null;
let isSorting = false;
let observer = null;
let debounceTimer = null;
let nextOrigIdx = 0;
// 元素 → 首次出现时的 DOM 序号(用于"原始"恢复)
const origIdx = new WeakMap();
// toggle 型排序按钮:模式 → 倒序模式(点击循环 正序↔倒序,按钮文案带 ↑/↓)
const TOGGLE_SORTS = { 'name-asc': 'name-desc', 'len-asc': 'len-desc' };
/* 添加监视状态(排序函数需检查添加是否进行中) */
const ACT = {
queue: [], // [{subid, name}] 点击添加时的队列快照
idxOf: new Map(), // subid → 索引
status: new Map(), // subid → {ok, text}
ignored: new Set(), // 本轮经预览 [Ignore] 标记的 subid(跨 pvRender 保持按钮状态)
current: -1, // 正在添加的索引
done: 0, // 已处理数(重试不重复计数)
okCount: 0, // 成功数(分层标题用)
errCount: 0, // 失败数
lastSub: 0,
rateUntil: 0, // 限制恢复时间戳(进度条用)
rateStart: 0, // 限制触发时间戳(进度条总时长)
rateSub: 0, // 触发限制的 subid(进度条只挂在该行)
realTotal: 0, // 站点添加队列真实长度(#js-activate-count)
started: false,
finished: false,
limited: false, // 未点击 view all(快照只是队列前缀,添加时实时补充)
pendPage: 1, // 待添加分页:当前页(1 起,相对待添加区首条)
pendManual: false, // 待添加:用户手动翻页后暂停自动跟随(« 回第 1 页恢复)
donePage: 1, // 已成功分页:当前页
doneManual: false, // 已成功:手动翻页后停在某页(» 跟到最新页恢复)
els: {}, // 预览骨架元素引用
preview: null, progEl: null, segEl: null, hideEl: null,
};
function rememberElements(packages) {
for (const pkg of packages) {
if (!origIdx.has(pkg)) origIdx.set(pkg, nextOrigIdx++);
}
}
/** 行内最后一个非空文本节点 = 游戏名(在 subid 链接后的纯文本里) */
function domName(el) {
const kids = el.childNodes;
for (let i = kids.length - 1; i >= 0; i--) {
const n = kids[i];
if (n.nodeType === Node.TEXT_NODE && n.textContent.trim()) {
return n.textContent.trim();
}
}
return '';
}
/** 节点内容不可变(移动节点不改变内容),直接从 DOM 解析排序键 */
function parsePackage(el) {
const a = el.getElementsByTagName('a')[0];
const subId = a ? parseInt(a.textContent.trim(), 10) : Infinity;
return { subId, name: domName(el).toLowerCase() };
}
/**
* 核心:站点的添加队列 k 只在筛选函数 E() 运行时按 DOM 顺序重建。
* 排序后必须点击当前添加的筛选按钮触发 E(),添加顺序才会跟随排序。
*/
function triggerSiteFilter() {
const active = document.querySelector('.js-filter.btn-info');
if (active) {
active.click();
} else {
console.warn('[SFP] 未找到添加的筛选按钮,添加顺序可能不会跟随排序');
}
}
function finishSort() {
isSorting = false;
setBtnLoading(false);
refreshPreActivation();
if (observer) {
const panel = document.getElementById(PANEL_ID);
if (panel) observer.observe(panel, { childList: true, subtree: false });
}
}
/**
* 移动节点排序(data-i 跟随节点走,站点 v[data-i] 取数始终正确)。
* 分三阶段,每阶段之间让出一帧:内存排序 → 移动节点 → 触发站点 E()。
* 不再使用 visibility 隐藏(避免 48k 节点两次全量样式重算导致的卡顿)。
*/
function sortPackages(mode) {
if (isSorting) return;
if (ACT.started && !ACT.finished) return; // 添加进行中禁止排序
const panel = document.getElementById(PANEL_ID);
if (!panel) return;
const packages = Array.from(panel.querySelectorAll('.package'));
if (packages.length === 0) return;
isSorting = true;
setBtnLoading(true);
if (observer) observer.disconnect();
// 阶段1:内存中排序(同步、快速)
rememberElements(packages);
const items = packages.map((el, i) => {
const { subId, name } = parsePackage(el);
return { el, subId, name, oi: origIdx.get(el) ?? i };
});
if (mode === null) {
items.sort((a, b) => a.oi - b.oi);
} else if (mode === 'id-asc') {
items.sort((a, b) => a.subId - b.subId);
} else if (mode === 'name-desc') {
items.sort((a, b) => b.name.localeCompare(a.name, 'zh-CN') || b.subId - a.subId);
} else if (mode === 'len-asc') {
items.sort((a, b) => a.name.length - b.name.length || a.name.localeCompare(b.name, 'zh-CN') || a.subId - b.subId);
} else if (mode === 'len-desc') {
items.sort((a, b) => b.name.length - a.name.length || b.name.localeCompare(a.name, 'zh-CN') || b.subId - a.subId);
} else {
items.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN') || a.subId - b.subId);
}
const seenIds = new Set();
let dups = 0;
for (const it of items) {
if (it.subId === Infinity) continue;
if (seenIds.has(it.subId)) dups++;
else seenIds.add(it.subId);
}
if (dups > 0) console.warn('[SFP] 检测到重复 subid:', dups, '条(DOM 可能被重复渲染),排序仍会执行');
// 阶段2:下一帧移动节点(先让按钮加载态绘制出来)
requestAnimationFrame(() => {
const p = document.getElementById(PANEL_ID);
if (!p) { finishSort(); return; }
const frag = document.createDocumentFragment();
for (const it of items) frag.appendChild(it.el);
p.appendChild(frag);
// 阶段3:再让一帧(排序列表先绘制),再触发站点 E() 重建添加队列
requestAnimationFrame(() => {
triggerSiteFilter();
finishSort();
});
});
}
function setBtnLoading(loading) {
document.querySelectorAll('#' + TOOLBAR_ID + ' .sfd-fp-sort-btn').forEach(btn => {
btn.disabled = loading;
btn.style.opacity = loading ? '0.5' : '';
btn.style.cursor = loading ? 'wait' : '';
});
}
function updateButtonStates() {
document.querySelectorAll('#' + TOOLBAR_ID + ' .sfd-fp-sort-btn').forEach(btn => {
const mode = btn.getAttribute('data-mode');
// toggle 型按钮(名称/长度):正↔倒循环,选中时文案带方向箭头(↑正序 ↓倒序)
const pair = TOGGLE_SORTS[mode];
if (pair) {
const active = currentSort === mode || currentSort === pair;
const base = btn.dataset.base;
btn.textContent = active ? base + (currentSort === pair ? '↓' : '↑') : base;
btn.classList.toggle('btn-info', active);
btn.classList.toggle('btn-outline', !active);
return;
}
const active = mode === (currentSort === null ? 'original' : currentSort);
btn.classList.toggle('btn-info', active);
btn.classList.toggle('btn-outline', !active);
});
}
function doSort(mode) {
currentSort = mode;
sortPackages(mode);
updateButtonStates();
}
function injectToolbar() {
if (document.getElementById(TOOLBAR_ID)) return true;
const filterBar = document.querySelector('.app-history-filters');
if (!filterBar) return false;
const toolbar = document.createElement('span');
toolbar.id = TOOLBAR_ID;
toolbar.style.cssText = 'display:inline-flex;align-items:center;gap:5px;margin-left:8px;';
const label = document.createElement('span');
label.className = 'b';
label.textContent = '排序:';
toolbar.appendChild(label);
[
{ mode: 'original', label: '原始' },
{ mode: 'id-asc', label: '升序' },
{ mode: 'name-asc', label: '名称' },
{ mode: 'len-asc', label: '长度' },
].forEach(({ mode, label: text }) => {
const btn = document.createElement('button');
btn.className = 'btn btn-sm btn-outline sfd-fp-sort-btn';
btn.textContent = text;
btn.setAttribute('data-mode', mode);
if (TOGGLE_SORTS[mode]) btn.dataset.base = text; // toggle 型按钮的基础文案
btn.addEventListener('click', () => {
if (mode === 'original') { doSort(null); return; }
// toggle 型(名称/长度):再次点击在正/倒序间切换
const pair = TOGGLE_SORTS[mode];
if (pair) {
doSort(currentSort === mode ? pair : mode);
return;
}
doSort(mode);
});
toolbar.appendChild(btn);
});
filterBar.appendChild(toolbar);
return true;
}
/* ============ sub → 名称(仅用页面自身数据,无任何 fetch) ============
页面内嵌 window.SteamDB.FreePackageData = [[subid,[appids],[],[],"名称",1],...]
站点读取后会 delete 该属性(delete 时机不定,注入稍晚一步就会错过属性)。
双路捕获:① document-start 起轮询抢先快照 window 属性;
② 属性已被 delete 时,解析常驻 DOM 的内嵌