// ==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(); })();