// ==UserScript==
// @name Steamdb 增强
// @namespace https://steamdb.info/
// @version 1.0.3
// @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';
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();
/* 激活监视状态(排序函数需检查激活是否进行中) */
const ACT = {
queue: [], // [{subid, name}] 点击激活时的队列快照
idxOf: new Map(), // subid → 索引
status: new Map(), // subid → {ok, text}
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(快照只是队列前缀,激活时实时补充)
preview: null, progEl: null, hintEl: 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 {
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 && !seenIds.add(it.subId)) dups++;
}
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();
console.log('[SFP] 排序:', mode === null ? '原始' : mode,
'| 总数:', items.length, '| 首条 subId:', items[0].subId);
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');
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: '名称排序' },
].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;
}
/* ============ sub → 名称(仅用页面自身数据,无任何 fetch) ============
页面内嵌 window.SteamDB.FreePackageData = [[subid,[appids],[],[],"名称",1],...]
站点渲染完成后会 delete 该属性,document-start 起轮询抢先捕获。
*/
const SUB_NAMES = new Map(); // subid → 名称
let fpCaptured = false;
function tryCaptureFp() {
if (fpCaptured) return true;
const d = window.SteamDB && window.SteamDB.FreePackageData;
if (!Array.isArray(d) || d.length === 0) return false;
for (const row of d) {
if (Array.isArray(row) && row[0] && typeof row[4] === 'string' && row[4]) {
SUB_NAMES.set(row[0], row[4]);
}
}
fpCaptured = true;
console.log('[SFP] FreePackageData 快照:', SUB_NAMES.size, '条 sub→名称');
return true;
}
(function pollFp(n) {
if (tryCaptureFp()) return;
// 站点已渲染包列表说明数据已被 delete,放弃捕获
if (document.querySelector('#freepackages .package')) return;
if (n > 0) setTimeout(() => pollFp(n - 1), 60);
})(1000);
/* ============ 样式注入 ============ */
function injectStyles() {
if (document.getElementById('sfp-style') || !document.head) return;
const style = document.createElement('style');
style.id = 'sfp-style';
style.textContent = `
/* 激活队列预览(位于原生激活框上方) */
#sfp-pv{margin:0 0 8px;border:1px solid rgba(255,255,255,.12);border-radius:6px;background:rgba(0,0,0,.3);font-size:12px;overflow:hidden}
#sfp-pv .sfp-pv-head{display:flex;align-items:center;gap:10px;padding:5px 10px;border-bottom:1px solid rgba(255,255,255,.08)}
#sfp-pv .sfp-pv-title{font-weight:700;color:#c7d5e0;flex-shrink:0}
#sfp-pv .sfp-pv-hint{color:#8f98a0;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:0 1 auto;min-width:0}
#sfp-pv .sfp-pv-prog{color:#66c0f4;font-weight:700;margin-left:auto;flex-shrink:0}
#sfp-pv .sfp-pv-prog.done{color:#2ed573}
#sfp-pv .sfp-pv-list{padding:3px 0;max-height:300px;overflow-y:auto;display:grid;grid-template-columns:1fr 1fr;column-gap:14px;align-content:start;position:relative}
#sfp-pv.big .sfp-pv-list{max-height:900px}
#sfp-pv .sfp-pv-row{display:flex;align-items:center;gap:8px;padding:2px 10px;white-space:nowrap;min-height:22px}
#sfp-pv .sfp-pv-row.cur{background:rgba(102,192,244,.15);box-shadow:inset 2px 0 0 #66c0f4}
#sfp-pv .sfp-pv-row.ok{background:rgba(46,213,115,.1);box-shadow:inset 2px 0 0 #2ed573}
#sfp-pv .sfp-pv-row.err{background:rgba(255,107,107,.08);box-shadow:inset 2px 0 0 #ff6b6b}
#sfp-pv .sfp-pv-ico{width:14px;text-align:center;flex-shrink:0;color:#8f98a0}
#sfp-pv .sfp-pv-row.cur .sfp-pv-ico{color:#66c0f4}
#sfp-pv .sfp-pv-row.ok .sfp-pv-ico{color:#2ed573}
#sfp-pv .sfp-pv-row.err .sfp-pv-ico{color:#ff6b6b}
#sfp-pv .sfp-pv-no{color:#8f98a0;flex-shrink:0}
#sfp-pv .sfp-pv-row a{color:#66c0f4;flex-shrink:0}
#sfp-pv .sfp-pv-name{color:#c7d5e0;overflow:hidden;text-overflow:ellipsis;flex:0 1 auto;min-width:0}
#sfp-pv .sfp-pv-errtext{color:#ff6b6b;overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0}
#sfp-pv .sfp-pv-cd{display:inline-flex;align-items:center;gap:6px;flex-shrink:0;margin-left:auto}
#sfp-pv .sfp-cd-bar{width:450px;height:6px;border-radius:3px;background:rgba(255,184,77,.18);overflow:hidden;flex-shrink:0}
#sfp-pv .sfp-cd-bar i{display:block;height:100%;border-radius:3px;background:linear-gradient(90deg,#ffb84d,#ff9f43)}
#sfp-pv .sfp-cd-txt{color:#ffb84d;font-weight:700;min-width:42px;text-align:right;font-variant-numeric:tabular-nums;flex-shrink:0}
#sfp-pv .sfp-pv-row.full{grid-column:1 / -1}
#sfp-pv .sfp-pv-sec-h{grid-column:1 / -1;padding:3px 10px 1px;color:#8f98a0;font-weight:700;font-size:11px;background:rgba(255,255,255,.04);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
/* 隐藏官方激活框按钮(激活进行中显示) */
#sfp-pv .sfp-pv-hide{display:none;align-items:center;gap:4px;padding:2px 10px;border:1px solid rgba(255,255,255,.28);border-radius:4px;background:rgba(255,255,255,.05);color:#8f98a0;font-weight:700;font-size:11px;cursor:pointer;font-family:inherit;line-height:1.4;flex-shrink:0}
#sfp-pv .sfp-pv-hide:hover{background:rgba(255,255,255,.14)}
#sfp-pv .sfp-pv-hide.on{border-color:rgba(102,192,244,.5);background:rgba(102,192,244,.08);color:#66c0f4}
#sfp-pv .sfp-pv-hide.on:hover{background:rgba(102,192,244,.22)}
/* 加载态科技动效 */
#sfp-pv.loading{border-color:rgba(102,192,244,.35);animation:sfp-glow 2.4s ease-in-out infinite}
@keyframes sfp-glow{0%,100%{box-shadow:0 0 6px rgba(102,192,244,.08)}50%{box-shadow:0 0 16px rgba(102,192,244,.28)}}
#sfp-pv .sfp-pv-load{grid-column:1 / -1;display:flex;align-items:center;gap:12px;padding:16px 12px;min-height:56px}
#sfp-pv .sfp-pv-ring{width:15px;height:15px;border-radius:50%;border:2px solid rgba(102,192,244,.18);border-top-color:#66c0f4;animation:sfp-spin .8s linear infinite;flex-shrink:0}
@keyframes sfp-spin{to{transform:rotate(360deg)}}
#sfp-pv .sfp-pv-loadtxt{font-weight:700;letter-spacing:3px;flex-shrink:0;background:linear-gradient(90deg,rgba(102,192,244,.25) 0%,#66c0f4 50%,rgba(102,192,244,.25) 100%);background-size:200% 100%;-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;animation:sfp-shimmer 1.5s linear infinite}
@keyframes sfp-shimmer{0%{background-position:200% 0}100%{background-position:-200% 0}}
#sfp-pv .sfp-pv-scan{position:relative;flex:1;height:2px;border-radius:1px;background:rgba(102,192,244,.14);overflow:hidden}
#sfp-pv .sfp-pv-scan i{position:absolute;top:0;left:-35%;width:35%;height:100%;border-radius:1px;background:linear-gradient(90deg,transparent,#66c0f4,transparent);animation:sfp-scan 1.2s linear infinite}
@keyframes sfp-scan{to{left:100%}}
`;
document.head.appendChild(style);
}
/* ============ 激活监视 ============
站点激活逻辑(freepackages.js):
- 点击 #js-activate-now:loading.textContent="Activating N packages…"(清空日志框)
→ 逐个发激活请求 → 收到响应才追加一行日志并 setTimeout 调度下一发
- 请求:window.postMessage({type:"steamdb:extension-query",
contentScriptQuery:"StoreAddFreeLicense", subid}, origin)
- 响应:扩展 content script window.postMessage → 站点 global.js 转发 BroadcastChannel
("steamdb-extension") → freepackages.js 处理结果
本模块零全局污染(不包装任何原生方法,避免 SteamDB 反篡改检测导致卡死):
- 点击时(capture)快照可见队列
- 站点清空日志后,预览内嵌进原生 #loading 顶部
- window message 事件监听发出的请求(同源自派发)标记"正在激活"
- BroadcastChannel 监听响应标记结果
*/
/**
* 站点 E() 把全部匹配包压入队列 k(k.push),hidden 只是显示限制——
* "Showing first 500" 不影响激活范围,#js-activate-count 即队列真实长度。
* 可见节点快照只是队列前缀,后续条目在激活请求到达时实时补充。
*/
function readRealTotal() {
const el = document.getElementById('js-activate-count');
const n = el ? parseInt(el.textContent, 10) : NaN;
return (isNaN(n) || n < 1) ? 0 : n;
}
/** 点击激活时快照当前可见队列(DOM 顺序 = 站点激活队列顺序) */
function actSnapshot() {
const g = document.getElementById(PANEL_ID);
if (!g) return { queue: [], limited: false };
const queue = [];
const seen = new Set();
let limited = false;
for (const el of g.children) {
if (el.classList.contains('package')) {
if (el.hidden) continue;
const a = el.getElementsByTagName('a')[0];
if (!a) continue;
const subid = parseInt(a.textContent.trim(), 10);
if (!subid || seen.has(subid)) continue;
seen.add(subid);
let name = SUB_NAMES.get(subid) || '';
if (!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;
}
}
}
queue.push({ subid, name });
} else if (el.tagName === 'DIV' && el.textContent.includes('Showing first')) {
// 未点击 "view all":可见列表只是队列前缀,队列全长看 #js-activate-count
limited = true;
}
}
return { queue, limited };
}
/* ---------- 原生激活框上方的队列预览 ---------- */
let userScrolled = false; // 用户手动滚动查看历史时暂停自动跟随
let autoScrolling = false; // 程序滚动中(不误判为用户滚动)
function wireListScroll(list) {
if (list.dataset.sfpWired) return;
list.dataset.sfpWired = '1';
list.addEventListener('scroll', () => {
if (autoScrolling) return;
const atBottom = list.scrollTop + list.clientHeight >= list.scrollHeight - 4;
userScrolled = !atBottom;
}, { passive: true });
}
function pvBuild() {
if (ACT.preview && ACT.preview.isConnected) return;
const loading = document.getElementById('loading');
if (!loading || !loading.parentElement) return;
const box = document.createElement('div');
box.id = 'sfp-pv';
const head = document.createElement('div');
head.className = 'sfp-pv-head';
const title = document.createElement('span');
title.className = 'sfp-pv-title';
title.textContent = '激活队列预览';
const hint = document.createElement('span');
hint.className = 'sfp-pv-hint';
const prog = document.createElement('span');
prog.className = 'sfp-pv-prog';
const hideBtn = document.createElement('button');
hideBtn.type = 'button';
hideBtn.className = 'sfp-pv-hide';
hideBtn.addEventListener('click', () => toggleLoading());
head.append(title, hint, prog, hideBtn);
const list = document.createElement('div');
list.className = 'sfp-pv-list';
box.append(head, list);
// 放在原生激活框(#loading)上方,激活前后位置不变
loading.parentElement.insertBefore(box, loading);
wireListScroll(list);
ACT.preview = box;
ACT.progEl = prog;
ACT.hintEl = hint;
ACT.hideEl = hideBtn;
}
/** 数据未到位时的加载态(页面一加载就显示,带科技感动效) */
function pvShowLoading() {
if (!ACT.started && ACT.queue.length > 0) return; // 已有数据,无需加载态
pvBuild();
if (!ACT.preview || !ACT.preview.isConnected) return;
if (ACT.preview.classList.contains('loading')) return; // 轮询不重置动画
ACT.preview.classList.add('loading');
ACT.progEl.textContent = '';
ACT.hintEl.textContent = '';
const row = document.createElement('div');
row.className = 'sfp-pv-load';
const ring = document.createElement('span');
ring.className = 'sfp-pv-ring';
const txt = document.createElement('span');
txt.className = 'sfp-pv-loadtxt';
txt.textContent = '正在加载队列数据';
const scan = document.createElement('span');
scan.className = 'sfp-pv-scan';
scan.appendChild(document.createElement('i'));
row.append(ring, txt, scan);
ACT.preview.querySelector('.sfp-pv-list').replaceChildren(row);
}
const PV_ROWS = 100; // 激活前:待激活预览条数
const DONE_KEEP = 40; // 已成功层保留条数(2列)
const PEND_ROWS = 100; // 待激活层显示条数(与激活前预览一致)
/** 扩展 background.js StoreAddFreeLicense 的错误文案 → 中文(title 保留原文) */
function zhErr(text) {
const t = text || '';
if (!t || t === 'OK') return t;
const map = [
[/invalid package/i, '无效的包'],
[/already available in your (Steam )?library/i, '已在游戏库中'],
[/do not own the required app/i, '缺少前置应用'],
[/rate limited/i, '被限制,稍后重试'],
[/Are you logged on the Steam Store/i, '浏览器未登录 Steam 商店'],
[/Are you logged in on the Steam store/i, '未登录或区域受限'],
[/Family View restrictions/i, '家庭视图限制'],
[/Is Steam okay\?/i, 'Steam 服务无响应'],
[/HTTP (\d+)/i, 'HTTP 错误 $1'],
[/Unknown query/i, '未知请求'],
];
for (const [re, zh] of map) {
if (re.test(t)) return zh;
}
if (/There was a problem adding/.test(t)) {
return '添加失败' + (t.includes('PurchaseResultDetail=') ? t.slice(t.indexOf('=')) : '');
}
return t;
}
const NAME_MAX = 60;
/** 游戏名过长截断(完整名称见悬停 title) */
function truncName(n) {
if (!n || n.length <= NAME_MAX) return n || '';
return n.slice(0, NAME_MAX).trimEnd() + ' ...';
}
/** 构建单行(st 为结果状态,isCur 为当前激活项) */
function pvRow(it, i, st, isCur) {
const row = document.createElement('div');
row.className = 'sfp-pv-row'
+ ((st && !st.ok) || isCur ? ' full' : '')
+ (isCur ? ' cur' : (st ? (st.ok ? ' ok' : ' err') : ''));
const ico = document.createElement('span');
ico.className = 'sfp-pv-ico';
ico.textContent = st ? (st.ok ? '✔' : '✖') : (isCur ? '▶' : '·');
const no = document.createElement('span');
no.className = 'sfp-pv-no';
no.textContent = '#' + (i + 1);
const link = document.createElement('a');
link.href = '/sub/' + it.subid + '/';
link.textContent = it.subid;
link.target = '_blank';
link.rel = 'noopener';
const name = document.createElement('span');
name.className = 'sfp-pv-name';
name.textContent = truncName(it.name);
name.title = it.name;
row.append(ico, no, link, name);
if (st && !st.ok) {
const err = document.createElement('span');
err.className = 'sfp-pv-errtext';
err.textContent = zhErr(st.text);
err.title = st.text;
row.appendChild(err);
// 触发限制:带时间的进度条(只挂在触发限制的那一行)
if (/rate limited/i.test(st.text) && ACT.rateSub === it.subid && ACT.rateUntil > Date.now()) {
row.appendChild(buildRateBar());
startCountdown();
}
}
return row;
}
function pvSecHead(text) {
const h = document.createElement('div');
h.className = 'sfp-pv-sec-h';
h.textContent = text;
return h;
}
function pvMsgRow(text) {
const row = document.createElement('div');
row.className = 'sfp-pv-row full';
row.innerHTML = '·' + text + '';
return row;
}
function pvRender() {
if (!ACT.preview || !ACT.preview.isConnected) return;
ACT.preview.classList.remove('loading');
const total = ACT.queue.length;
// 进度与待激活计数以站点真实队列长度为准(未点 view all 时快照只是前缀)
const realTotal = ACT.realTotal || total;
let progText = `${ACT.done} / ${realTotal}`;
if (ACT.finished) {
progText += ' · 结束';
ACT.progEl.classList.add('done');
}
ACT.progEl.textContent = progText;
// 未点 view all 提示(挂在标题右侧)
let hint = '';
if (ACT.limited) {
if (!ACT.started) {
if (total < realTotal) hint = `未点 view all:此处仅预览前 ${total} 条,激活范围不受影响`;
} else if (!ACT.finished) {
const ps = ACT.current + 1;
if (ps >= total && ps < realTotal) hint = '未点 view all:后续条目激活时实时显示';
}
}
ACT.hintEl.textContent = hint;
// 隐藏官方日志按钮(仅激活进行中可见;显式 inline-flex 覆盖样式表默认 display:none)
ACT.hideEl.style.display = (ACT.started && !ACT.finished) ? 'inline-flex' : 'none';
updateHideBtn();
const list = ACT.preview.querySelector('.sfp-pv-list');
const frag = document.createDocumentFragment();
if (!ACT.started) {
// 激活前:待激活预览(真实总数 + 前缀条目)
frag.appendChild(pvSecHead(`待激活 ${realTotal}`));
const end = Math.min(total, PV_ROWS);
for (let i = 0; i < end; i++) {
frag.appendChild(pvRow(ACT.queue[i], i, null, false));
}
} else {
// 激活后:已成功 / 激活中 / 待激活 三层
const cur = ACT.current;
const active = !ACT.finished && cur >= 0 && cur < total;
const doneEnd = ACT.finished ? total : Math.max(0, cur);
if (doneEnd > 0) {
const ds = Math.max(0, doneEnd - DONE_KEEP);
frag.appendChild(pvSecHead(`已成功 ${ACT.okCount}` + (ACT.errCount ? ` · 失败 ${ACT.errCount}` : '')));
for (let i = ds; i < doneEnd; i++) {
frag.appendChild(pvRow(ACT.queue[i], i, ACT.status.get(ACT.queue[i].subid), false));
}
}
if (active) {
frag.appendChild(pvSecHead('激活中'));
frag.appendChild(pvRow(ACT.queue[cur], cur, ACT.status.get(ACT.queue[cur].subid), true));
}
const ps = ACT.finished ? total : cur + 1;
const remain = ACT.finished ? 0 : Math.max(0, realTotal - ps);
if (remain > 0) {
frag.appendChild(pvSecHead(`待激活 ${remain}`));
const pe = Math.min(total, ps + PEND_ROWS);
for (let i = ps; i < pe; i++) {
frag.appendChild(pvRow(ACT.queue[i], i, null, false));
}
} else if (ACT.finished) {
frag.appendChild(pvMsgRow('队列已处理完毕'));
}
}
list.replaceChildren(frag);
// "激活中"行保持列表正中间(平滑滚动;人工向上滚动查看历史时暂不抢滚动条)
const curEl = list.querySelector('.cur');
if (curEl) {
const target = Math.max(0, curEl.offsetTop - (list.clientHeight - curEl.offsetHeight) / 2);
if (Math.abs(list.scrollTop - target) > 2) {
if (userScrolled) {
// 用户在查看历史:若激活中行已远离可视区(超过1屏),恢复跟随
const dist = curEl.offsetTop - list.scrollTop;
if (dist < -list.clientHeight || dist > list.clientHeight * 2) {
userScrolled = false;
} else {
return;
}
}
autoScrolling = true;
list.scrollTo({ top: target, behavior: 'smooth' });
setTimeout(() => { autoScrolling = false; }, 400);
}
}
}
/* ---------- 限制等待进度条(站点默认 delay=180s,可由 ?delay= 参数覆盖) ---------- */
/** 与站点 freepackages.js 完全一致的 delay 计算 */
function rateDelayMs() {
const d = parseInt(new URLSearchParams(location.search).get('delay'), 10);
return (isNaN(d) || d < 10 ? 180 : d) * 1000;
}
/** 限制进度:已等待占比(0-100)与剩余秒数 */
function rateProgress() {
const total = Math.max(1, ACT.rateUntil - ACT.rateStart);
const remainMs = ACT.rateUntil - Date.now();
const pct = Math.min(100, Math.max(0, Math.round((1 - remainMs / total) * 100)));
const left = Math.max(0, Math.ceil(remainMs / 1000));
return { pct, left };
}
function buildRateBar() {
const { pct, left } = rateProgress();
const cd = document.createElement('span');
cd.className = 'sfp-pv-cd';
const bar = document.createElement('span');
bar.className = 'sfp-cd-bar';
const fill = document.createElement('i');
fill.style.width = pct + '%';
bar.appendChild(fill);
const txt = document.createElement('span');
txt.className = 'sfp-cd-txt';
txt.textContent = left + 's';
cd.append(bar, txt);
return cd;
}
let cdTimer = 0;
function startCountdown() {
if (cdTimer) return;
cdTimer = setInterval(() => {
const els = document.querySelectorAll('#sfp-pv .sfp-pv-cd');
if (ACT.rateUntil <= Date.now() || ACT.finished || els.length === 0) {
clearInterval(cdTimer);
cdTimer = 0;
els.forEach(el => el.remove());
return;
}
const { pct, left } = rateProgress();
for (const el of els) {
const fill = el.querySelector('.sfp-cd-bar i');
if (fill) fill.style.width = pct + '%';
const txt = el.querySelector('.sfp-cd-txt');
if (txt) txt.textContent = left + 's';
}
}, 1000);
}
/* ---------- 暂停 / 继续 ---------- */
/* 隐藏/恢复官方激活框(#loading) */
let loadingHidden = false;
function toggleLoading() {
const loading = document.getElementById('loading');
if (!loading) return;
loadingHidden = !loadingHidden;
loading.style.display = loadingHidden ? 'none' : '';
// 联动预览高度:官方日志让位后预览撑到 900px,恢复则回落 300px
if (ACT.preview) ACT.preview.classList.toggle('big', loadingHidden);
updateHideBtn();
}
function updateHideBtn() {
if (!ACT.hideEl) return;
ACT.hideEl.textContent = loadingHidden ? '显示官方日志' : '隐藏官方日志';
ACT.hideEl.classList.toggle('on', loadingHidden);
}
function actSetSortDisabled(dis) {
if (isSorting) return; // 排序流程自身管理按钮状态
document.querySelectorAll('#' + TOOLBAR_ID + ' .sfd-fp-sort-btn').forEach(btn => {
btn.disabled = dis;
btn.style.opacity = dis ? '0.5' : '';
btn.style.cursor = dis ? 'not-allowed' : '';
});
}
/* ---------- 监视初始化 ---------- */
/** 激活前:队列就绪即显示预览(跟随排序/筛选实时刷新) */
function refreshPreActivation() {
if (ACT.started) return;
const snap = actSnapshot();
if (snap.queue.length === 0) return;
ACT.queue = snap.queue;
ACT.limited = snap.limited;
ACT.realTotal = Math.max(readRealTotal(), snap.queue.length);
ACT.current = -1;
ACT.done = 0;
ACT.okCount = 0;
ACT.errCount = 0;
ACT.status.clear();
ACT.rateUntil = 0;
ACT.rateStart = 0;
ACT.rateSub = 0;
pvBuild();
pvRender();
}
function onActivateClick() {
if (ACT.started) return;
const snap = actSnapshot();
if (snap.queue.length === 0) return;
ACT.started = true;
ACT.queue = snap.queue;
ACT.limited = snap.limited;
ACT.realTotal = Math.max(readRealTotal(), snap.queue.length);
ACT.idxOf.clear();
ACT.done = 0;
ACT.okCount = 0;
ACT.errCount = 0;
ACT.status.clear();
ACT.rateUntil = 0;
ACT.rateStart = 0;
ACT.rateSub = 0;
snap.queue.forEach((it, i) => ACT.idxOf.set(it.subid, i));
// 站点 handler 同步清空 #loading 后重建预览(此时位置已移到 #loading 上方)
setTimeout(() => {
pvBuild();
pvRender();
}, 0);
actSetSortDisabled(true);
console.log('[SFP] 激活队列:', ACT.realTotal, '条(可见前缀', snap.queue.length, '条)',
snap.limited ? ',未点 view all' : '');
}
function onMessage(e) {
if (e.origin !== window.location.origin) return;
const msg = e.data;
if (!msg || typeof msg !== 'object') return;
if (!ACT.started || ACT.finished) return;
if (msg.type !== 'steamdb:extension-query' || msg.contentScriptQuery !== 'StoreAddFreeLicense') return;
const idx = ACT.idxOf.get(msg.subid);
if (idx !== undefined) {
ACT.current = idx;
} else if (ACT.limited) {
// 越过可见前缀:激活请求到达时实时补充进队列
ACT.queue.push({ subid: msg.subid, name: SUB_NAMES.get(msg.subid) || '' });
ACT.idxOf.set(msg.subid, ACT.queue.length - 1);
ACT.current = ACT.queue.length - 1;
} else {
return;
}
pvRender();
}
function onResponse(d) {
if (!ACT.started || ACT.finished) return;
if (!d || d.type !== 'steamdb:extension-response') return;
const req = d.request;
if (!req || req.contentScriptQuery !== 'StoreAddFreeLicense') return;
// 站点串行激活,同一 subid 连续出现 = rate limit 重试,不重复计数
if (req.subid !== ACT.lastSub) {
ACT.done++;
ACT.lastSub = req.subid;
}
const err = (d.response && d.response.error) || '';
// 重试会覆盖旧状态,先回退旧计数
const prev = ACT.status.get(req.subid);
if (prev) prev.ok ? ACT.okCount-- : ACT.errCount--;
ACT.status.set(req.subid, { ok: !err, text: err || 'OK' });
err ? ACT.errCount++ : ACT.okCount++;
if (/rate limited/i.test(err)) {
ACT.rateStart = Date.now();
ACT.rateUntil = ACT.rateStart + rateDelayMs();
ACT.rateSub = req.subid;
startCountdown();
}
pvRender();
}
let loadingObsWired = false;
function wireLoadingObserver() {
const loading = document.getElementById('loading');
if (!loading || loadingObsWired) return;
loadingObsWired = true;
const obs = new MutationObserver((muts) => {
// 站点日志追加:只检查本次新增节点(全量扫描 loading.children 是 O(n²),4.8万条日志会拖死页面)
if (ACT.started && !ACT.finished) {
outer:
for (const mut of muts) {
for (const el of mut.addedNodes) {
if (!(el instanceof HTMLElement) || el.tagName !== 'H4') continue;
const txt = el.textContent || '';
if (txt.includes('Script finished') || txt.includes('Received license activation')) {
ACT.finished = true;
actSetSortDisabled(false);
pvRender();
break outer;
}
}
}
}
if (ACT.started && ACT.preview && !ACT.preview.isConnected) {
pvBuild();
pvRender();
}
});
obs.observe(loading, { childList: true });
}
function initActMonitor() {
// 1. 捕获激活按钮点击(capture 先于站点 handler,此时列表尚未隐藏)
document.addEventListener('click', (e) => {
const t = e.target instanceof Element ? e.target : null;
if (t && t.closest('#js-activate-now')) onActivateClick();
// 站点筛选按钮点击后刷新预览(E() 同步执行,稍等一拍确保 hidden 全部写完)
if (t && t.closest('.js-filter') && !ACT.started) setTimeout(refreshPreActivation, 600);
}, true);
// 2. window message 事件监听激活请求 + 暂停时扣下响应(同源自派发,零全局污染)
window.addEventListener('message', onMessage);
// 3. BroadcastChannel 监听响应 → 标记结果
try {
const bc = new BroadcastChannel('steamdb-extension');
bc.addEventListener('message', (c) => onResponse(c.data));
} catch (e) {
console.warn('[SFP] BroadcastChannel 不可用,预览将无结果标记');
}
}
/* ============ 启动(document-start:抢在站点 delete 前捕获 FreePackageData) ============ */
function waitForPackages() {
let lastCount = 0;
let dataReady = false;
const check = () => {
const panel = document.getElementById(PANEL_ID);
if (!panel) return;
// 早期:工具栏 + 预览加载态先行(按钮禁用,等数据到位)
if (!injectToolbar()) return;
if (!dataReady) {
actSetSortDisabled(true);
pvShowLoading();
}
const count = panel.querySelectorAll('.package').length;
if (count === 0) return;
if (!dataReady) {
dataReady = true;
actSetSortDisabled(false);
rememberElements(panel.querySelectorAll('.package'));
}
updateButtonStates();
if (count !== lastCount) {
// 仅节点新增时才需重排;[Ignore] 等移除操作不破坏现有顺序(避免每次忽略都全量重排 4.8万节点)
const grew = count > lastCount;
lastCount = count;
if (currentSort && grew) sortPackages(currentSort);
}
// 激活前:列表变化(排序/筛选/view all)后刷新预览队列
refreshPreActivation();
};
const debouncedCheck = () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(check, 300);
};
const attach = () => {
const panel = document.getElementById(PANEL_ID);
if (panel && !observer) {
observer = new MutationObserver(debouncedCheck);
observer.observe(panel, { childList: true, subtree: false });
return true;
}
return !!observer;
};
let retries = 0;
const poll = () => {
injectStyles();
wireLoadingObserver();
attach();
check();
// 数据到位即停轮询(observer 兜底后续变化)
if (retries++ < 200 && !dataReady) {
setTimeout(poll, 300);
}
};
poll();
}
initActMonitor();
waitForPackages();
})();