// ==UserScript== // @name Steamdb 增强 // @namespace https://steamdb.info/ // @version 1.0.8 // @description 免费包列表排序(添加顺序跟随)+ 添加队列预览(全部已添加保留 + 待添加分页,可隐藏官方日志) // @author SmallFork // @license MIT // @match https://steamdb.info/freepackages/* // @match https://steamdb.info/freepackages // @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 只在控制台打印当前生效的间隔秒数。 */ try { const d = parseInt(new URLSearchParams(location.search).get('delay'), 10); const sec = isNaN(d) || d < 10 ? 180 : d; console.log(`[SFP] 当前激活间隔:${sec} 秒`); } catch (e) { /* URL 不可读时跳过报告,站点仍按默认 180s */ } 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++); } } /** 节点内容不可变(移动节点不改变内容),直接从 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); 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 的内嵌