// ==UserScript== // @name Steamdb 增强 // @namespace https://steamdb.info/ // @version 1.0.0 // @description 对 SteamDB 免费包列表按 SubID 升序或名称排序,激活顺序跟随排序结果 // @author SmallFork // @match https://steamdb.info/freepackages/* // @match https://steamdb.info/freepackages // @icon data:image/svg+xml, // @grant none // @run-at document-idle // @tag Steam // ==/UserScript== (function () { 'use strict'; 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(); function rememberElements(packages) { for (const pkg of packages) { if (!origIdx.has(pkg)) origIdx.set(pkg, nextOrigIdx++); } } /** 节点内容不可变(移动节点不改变内容),直接从 DOM 解析排序键 */ function parsePackage(el) { const a = el.getElementsByTagName('a')[0]; const subId = a ? parseInt(a.textContent.trim(), 10) : Infinity; let name = ''; 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()) { name = n.textContent.trim(); break; } } return { subId, name: name.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); if (observer) { const panel = document.getElementById(PANEL_ID); if (panel) observer.observe(panel, { childList: true, subtree: false }); } } /** 移动节点排序(data-i 跟随节点走,站点 v[data-i] 取数始终正确) */ function sortPackages(mode) { if (isSorting) 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(); setTimeout(() => { const p = document.getElementById(PANEL_ID); if (!p) { finishSort(); return; } 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 { items.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN') || a.subId - b.subId); } const prevVis = p.style.visibility; p.style.visibility = 'hidden'; const frag = document.createDocumentFragment(); for (const it of items) frag.appendChild(it.el); p.appendChild(frag); p.style.visibility = prevVis; // 触发站点 E() 重建激活队列 k(同时重算 hidden 和计数) triggerSiteFilter(); console.log('[SFP] 排序:', mode === null ? '原始' : mode, '| 总数:', items.length, '| 首条 subId:', items[0].subId); finishSort(); }, 0); } 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'); 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 panel = document.getElementById(PANEL_ID); if (!panel) return false; const packages = panel.querySelectorAll('.package'); if (packages.length === 0) return false; rememberElements(packages); 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: '名称排序' }, ].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); btn.addEventListener('click', () => doSort(mode === 'original' ? null : mode)); toolbar.appendChild(btn); }); filterBar.appendChild(toolbar); return true; } function waitForPackages() { let lastCount = 0; const check = () => { const panel = document.getElementById(PANEL_ID); if (!panel) return; const count = panel.querySelectorAll('.package').length; if (count === 0) return; if (!injectToolbar()) return; updateButtonStates(); if (count !== lastCount) { // 数量变化([Ignore] 移除节点等):重排并重建激活队列 lastCount = count; if (currentSort) sortPackages(currentSort); } }; const debouncedCheck = () => { clearTimeout(debounceTimer); debounceTimer = setTimeout(check, 300); }; const panel = document.getElementById(PANEL_ID); if (panel) { observer = new MutationObserver(debouncedCheck); observer.observe(panel, { childList: true, subtree: false }); } let retries = 0; const poll = () => { check(); if (retries++ < 60 && !document.getElementById(TOOLBAR_ID)) { setTimeout(poll, 500); } }; poll(); } waitForPackages(); })();