// ==UserScript== // @name Steam 免费游戏自动入库 // @namespace steam-auto-free // @version 1.7.0 // @description 自动检索 Steam 免费游戏并添加到库,支持隐藏已拥有、进度控制与权威复核 // @match https://store.steampowered.com/* // @match https://store.steampowered.com/search/* // @grant GM_xmlhttpRequest // @run-at document-idle // ==/UserScript== (function () { 'use strict'; // ==================== 常量与状态 ==================== const SEARCH_URL = 'https://store.steampowered.com/search/?filter=free&hide=hide_owned&sort_by=Price_ASC&category1=2'; const API_USERDATA = 'https://store.steampowered.com/dynamicstore/userdata/'; const STORAGE_KEY = 'safa_task_v2'; // 跨页面任务持久化(搜索页 ↔ 详情页) const INTERVAL_MIN = 1000; // 单款游戏最小间隔(毫秒) const INTERVAL_MAX = 2000; // 单款游戏最大间隔(毫秒) const BATCH_PAGE_SIZE = 100; // 候选列表单页解析上限 const state = { running: false, stopped: false, done: false, candidates: [], // [{appid, title, url}] currentIndex: 0, // 当前处理到第几个(0-based) success: 0, skipped: 0, failed: 0, alreadyOwned: 0, f2pSkipped: 0, sessionid: '', }; let panel = null; let panelBody = null; let btnStart = null; let btnStop = null; // ==================== 工具函数 ==================== /** 等待指定毫秒 */ function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } /** 元素是否可见(排除 display:none / 隐藏容器) */ function isVisible(el) { if (!el) return false; const r = el.getBoundingClientRect(); const s = getComputedStyle(el); return s.display !== 'none' && s.visibility !== 'hidden' && r.width > 0 && r.height > 0; } /** 随机 1~2 秒间隔 */ function randomDelay() { return INTERVAL_MIN + Math.floor(Math.random() * (INTERVAL_MAX - INTERVAL_MIN + 1)); } /** 保存任务状态到 localStorage(跨页面:搜索页 ↔ 详情页) */ function saveTask() { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch (e) { /* 忽略 */ } } /** 读取持久化任务;无任务返回 null */ function loadTask() { try { const raw = localStorage.getItem(STORAGE_KEY); return raw ? JSON.parse(raw) : null; } catch (e) { return null; } } /** 清除持久化任务 */ function clearTask() { try { localStorage.removeItem(STORAGE_KEY); } catch (e) { /* 忽略 */ } } /** 获取页面 sessionid:优先全局变量,其次 cookie,最后从页面内嵌脚本提取 */ async function getSessionID() { if (window.g_sessionID) { return window.g_sessionID; } const m = document.cookie.match(/(?:^|;\s*)sessionid=([^;]+)/); if (m) { return decodeURIComponent(m[1]); } // 兜底:从页面内嵌脚本提取 g_sessionID try { const html = document.documentElement.outerHTML; const g = html.match(/g_sessionID\s*=\s*["']([^"']+)["']/); if (g) return g[1]; } catch (e) { /* 忽略 */ } return ''; } /** 请求用户数据(登录态 + rgOwnedApps 权威复核),带超时防挂起 */ async function fetchUserData() { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 10000); try { const resp = await fetch(API_USERDATA, { method: 'GET', credentials: 'include', signal: ctrl.signal, }); if (!resp.ok) { throw new Error('userdata 请求失败,HTTP ' + resp.status); } return await resp.json(); } finally { clearTimeout(timer); } } /** 判断是否已登录:rgOwnedApps 存在即为登录态 */ async function isLoggedIn() { try { const data = await fetchUserData(); return Array.isArray(data.rgOwnedApps); } catch (e) { return false; } } // ==================== 详情页检测 + 官方按钮同款请求入库 ==================== /** 等待详情页主体就绪(购买区 / 添加到库按钮 / 年龄验证 / 已拥有文本,任一出现即继续;兼容新版 Steam UI) */ function waitForPageReady(timeout) { return new Promise((resolve) => { const start = Date.now(); const timer = setInterval(() => { if (document.querySelector('#game_area_purchase, .game_area_purchase')) { clearInterval(timer); resolve(true); return; } if (findAddToLibraryButton()) { clearInterval(timer); resolve(true); return; } if (document.querySelector('#agecheck_form, form[name="agecheck"]')) { clearInterval(timer); resolve(true); return; } if (detectOwnedTextOnPage()) { clearInterval(timer); resolve(true); return; } if (Date.now() - start > timeout) { clearInterval(timer); resolve(false); } }, 250); }); } /** 第二段等待:重点等"添加到库"按钮 / 已拥有文本出现。注意:不以"开始游戏"按钮作为完成条件(该按钮渲染早于"添加到库",提前结束会导致入库按钮漏检被误判已拥有) */ function waitForAddButton(timeout) { return new Promise((resolve) => { const start = Date.now(); const timer = setInterval(() => { if (findAddToLibraryButton()) { clearInterval(timer); resolve(true); return; } if (detectOwnedTextOnPage()) { clearInterval(timer); resolve(true); return; } if (Date.now() - start > timeout) { clearInterval(timer); resolve(false); } }, 250); }); } /** 仅检测购买区"已在库中"文本(不含按钮推断,避免"开始游戏"按钮导致误判已拥有) */ function detectOwnedTextOnPage() { const area = document.querySelector('#game_area_purchase, .game_area_purchase'); if (!area) return false; const text = (area.innerText || area.textContent || ''); return /已在您的库中|已在库中|在您的库中|已在游戏库中|Already in library|In your library/i.test(text); } /** 检测详情页是否已拥有:①购买区文本匹配"已在库中";②无购买区文本时按按钮状态推断(有"添加到库"→未拥有;有"开始游戏"且无入库按钮→已拥有/纯F2P)。此函数在等待完成后调用,此时按钮已渲染完毕或已超时 */ function detectOwnedOnPage() { if (detectOwnedTextOnPage()) return true; // 无"已在库中"文本:有"添加到库"按钮 → 未拥有,可入库 if (findAddToLibraryButton()) return false; // 无入库按钮但有"开始游戏/安装"按钮 → 已拥有或纯 F2P,跳过 return hasPlayNowButton(); } /** 页面是否存在"开始游戏/安装/下载"类按钮(纯 F2P 或已拥有,均需跳过);限定购买区优先,避免页面其他区域按钮干扰 */ function hasPlayNowButton() { const area = document.querySelector('#game_area_purchase, .game_area_purchase'); const scope = area || document; const nodes = scope.querySelectorAll('a, button, span[class*="btn"], div[class*="btn"], [class*="btn"]'); for (const b of nodes) { const t = (b.innerText || b.textContent || '').trim(); if (/开始游戏|Play Now|马上开玩|立即开玩|免费开玩|安装游戏|Install|Download/i.test(t)) return true; } return false; } /** 在详情页查找"添加到库"按钮(按真实 DOM:新版为 span[role=button][onclick*=AddFreeLicense],避免命中外层容器 div 导致 click 无效) */ function findAddToLibraryButton() { const area = document.querySelector('#game_area_purchase, .game_area_purchase'); const scope = area || document; // 1) 新版官方按钮:onclick 直接携带 AddFreeLicense(span[role=button]),点击即触发官方入库 JS const direct = scope.querySelector('[onclick*="AddFreeLicense"]'); if (direct) return direct; // 2) 老版:a[data-ds-packageid](站内入库/购买按钮) const ds = scope.querySelector('a[data-ds-packageid]'); if (ds) { const t = (ds.innerText || ds.textContent || '').trim(); if (/添加到库|添加至库|加入库|Add to Library|免费/i.test(t)) return ds; } // 3) 文本兜底:仅匹配按钮文案;命中 span/div 容器时优先取其可点击子元素(onclick/a/button),避免点击外层 div 无效 const nodes = scope.querySelectorAll('a, button, span[class*="btn"], div[class*="btn"], [role="button"]'); for (const b of nodes) { const t = (b.innerText || b.textContent || '').trim(); if (!/添加到库|添加至库|加入库|Add to Library/i.test(t)) continue; if (/开始游戏|Play Now|Install|Download/i.test(t)) continue; // 排除混入"开始游戏"的容器 if (b.tagName === 'SPAN' || b.tagName === 'DIV' || b.getAttribute('role') === 'button') { const inner = b.querySelector('[onclick], a, button'); if (inner) return inner; } return b; } return null; } /** 诊断:找不到按钮时输出购买区/相关元素结构摘要,便于定位新版 UI */ function dumpPurchaseDiag() { const area = document.querySelector('#game_area_purchase, .game_area_purchase'); const title = (document.title || '').slice(0, 60); const pkg = document.querySelectorAll('[data-ds-packageid]').length; const hits = [...document.querySelectorAll('a, button, div[class*="btn"], span[class*="btn"]')] .filter(b => /添加到库|开始游戏|Add to Library|Play Now/i.test((b.innerText || b.textContent || '').trim())) .slice(0, 5) .map(b => { const h = b.outerHTML || ''; return (b.tagName + '|' + (b.className || '') + '|' + (b.innerText || '').trim().slice(0, 30) + '|' + h.slice(0, 150)).slice(0, 220); }); return `标题=${title}; 购买区=${area ? '有' : '无'}; data-ds-packageid=${pkg}个; 命中元素=${hits.length ? hits.join(' // ') : '无'}`; } /** 处理详情页年龄验证(选成年日期并提交);返回是否已提交 */ function handleAgeGateOnPage() { const gate = document.querySelector('#agecheck_form, form[name="agecheck"]'); if (!gate) return false; const year = gate.querySelector('select[name="ageYear"]'); if (year) year.value = '1990'; const month = gate.querySelector('select[name="ageMonth"]'); if (month && month.options.length > 1) month.selectedIndex = 1; const day = gate.querySelector('select[name="ageDay"]'); if (day && day.options.length > 1) day.selectedIndex = 1; const submit = gate.querySelector('input[type="submit"], button[type="submit"], button.btn_primary, button.btn_medium'); if (submit) { submit.click(); return true; } return false; } /** 点击"添加到库"后处理可能出现的确认弹窗(EULA / 确认框):轮询等待出现→点击确认→等待关闭 */ async function handleModalConfirm(timeout) { const start = Date.now(); let clicked = false; while (Date.now() - start < timeout) { const visibleModals = [...document.querySelectorAll('.newmodal, #modalContent, .responsive_page_modal_content, .auth-modal')].filter(isVisible); if (visibleModals.length) { if (!clicked) { const btn = visibleModals[0].querySelector('.btn_green_white_innerfade, .btn_primary, input[type="submit"]'); if (btn) { btn.click(); clicked = true; } } else if (![...document.querySelectorAll('.newmodal, #modalContent, .responsive_page_modal_content, .auth-modal')].some(isVisible)) { return; // 弹窗已关闭 } } await sleep(200); } } /** 同步持久化任务到内存 state 并刷新 UI */ function syncStateFromTask(task) { Object.assign(state, task); updateUI(); } /** 规范化任务候选:appid 一律从候选 url 提取,并按 appid 去重(修复旧任务因 data-ds-appid 与 url 不一致导致的跳转错位/同页循环) */ function normalizeCandidates(task) { if (!task || !Array.isArray(task.candidates)) return; const seen = new Set(); const out = []; let changed = false; task.candidates.forEach((c) => { if (!c || !c.url) return; const m = String(c.url).match(/\/app\/(\d+)/); const id = m ? m[1] : String(c.appid || ''); if (seen.has(id)) { changed = true; return; } seen.add(id); if (String(c.appid) !== id) { c.appid = id; changed = true; } out.push(c); }); if (changed || out.length !== task.candidates.length) { task.candidates = out; saveTask(); } } /** 详情页入库主流程:等待购买区 → 年龄验证 → 已拥有检测 → 找到并点击"添加到库" → 复核 → 下一个 */ async function runDetailFlow() { const task = loadTask(); if (!task || !task.running || task.done) return; normalizeCandidates(task); // 旧任务自愈:对齐 appid 并去重,防止跳转错位/同页循环 const m = location.pathname.match(/\/app\/(\d+)/); if (!m) { appendLog('当前 URL 非详情页格式,强制跳转下一个候选。'); task.currentIndex++; saveTask(); syncStateFromTask(task); goNext(task); return; } const appid = m[1]; let cand = task.candidates[task.currentIndex]; if (!cand || String(cand.appid) !== appid) { appendLog('页面 appid 与候选不一致(页面=' + appid + '),按当前页面继续尝试入库。'); cand = { appid: appid, title: (document.title || appid).replace(/Steam 上的 | on Steam| - Steam/gi, '').slice(0, 80), url: location.href }; } syncStateFromTask(task); setMsg(`详情页处理中:${cand.title}`); appendLog('处理中:' + cand.title + '(appid ' + appid + ')'); // 1. 等待详情页主体就绪(新老 UI 兼容;两段等待,避免按钮异步渲染未完成就误判跳过) setMsg('等待详情页按钮渲染...'); await waitForPageReady(6000); await waitForAddButton(10000); if (!findAddToLibraryButton() && !detectOwnedOnPage()) { appendLog('等待后仍未识别到入库按钮/已拥有状态:' + dumpPurchaseDiag()); } // 2. 年龄验证(提交后若整页刷新则脚本重入;若原地处理则等待后继续) if (handleAgeGateOnPage()) { appendLog('已提交年龄验证,继续等待处理完成...'); await sleep(2500); } // 3. 已拥有检测 if (detectOwnedOnPage()) { task.alreadyOwned++; task.skipped++; task.currentIndex++; saveTask(); syncStateFromTask(task); appendLog('已拥有,跳过:' + cand.title); goNext(task); return; } // 4. 查找"添加到库"按钮 const btn = findAddToLibraryButton(); if (!btn) { task.f2pSkipped++; task.skipped++; task.currentIndex++; saveTask(); syncStateFromTask(task); appendLog('跳过(无入库入口/纯 F2P):' + cand.title + ' | 诊断:' + dumpPurchaseDiag()); goNext(task); return; } // 5. 点击"添加到库"(官方页面 JS 发出入库请求) btn.click(); appendLog('已点击"添加到库":' + cand.title); await handleModalConfirm(3500); await sleep(2000); // 6. 权威复核:rgOwnedApps try { const owned = await verifyOwned(appid); if (owned) { task.success++; appendLog('入库成功:' + cand.title); } else { task.failed++; appendLog('已点击但 rgOwnedApps 未见,标记失败:' + cand.title); } } catch (e) { task.failed++; appendLog('复核失败:' + (e.message || e)); } task.currentIndex++; saveTask(); syncStateFromTask(task); goNext(task); } /** 跳转下一个候选;完成则回搜索页汇总(带防循环:跳过与当前页同 appid 的重复候选) */ function goNext(task) { // 停止检查:停止后回搜索页 const latest = loadTask(); if (latest && !latest.running) { location.href = SEARCH_URL; return; } // 防循环:候选 url 与当前页同 appid 时跳过该项,避免重复候选导致同页无限循环 const curAppid = (location.pathname.match(/\/app\/(\d+)/) || [])[1] || ''; let advanced = false; while (task.currentIndex < task.candidates.length) { const c = task.candidates[task.currentIndex]; const cUrl = c && c.url ? String(c.url) : ''; const cAppid = (cUrl.match(/\/app\/(\d+)/) || [])[1] || (c ? String(c.appid || '') : ''); if (!curAppid || cAppid !== curAppid) break; task.currentIndex++; advanced = true; } if (advanced) { saveTask(); syncStateFromTask(task); } if (task.currentIndex >= task.candidates.length) { task.running = false; task.done = true; saveTask(); appendLog(`处理完成:共 ${task.candidates.length} 个候选`); location.href = SEARCH_URL; return; } const next = task.candidates[task.currentIndex]; setMsg(`处理中 ${task.currentIndex + 1} / ${task.candidates.length}`); location.href = next.url; } // ==================== 候选收集 ==================== /** 从当前页面解析免费游戏候选(仅搜索结果容器内、界面可见且未标记已拥有的项) */ function collectCandidatesFromPage() { const items = []; const seen = new Set(); // 只从搜索结果容器解析,避免把推荐栏/横条等界面外游戏抓进来 const container = document.getElementById('search_result_container'); const scope = container || document; const links = scope.querySelectorAll('a[data-ds-appid], a[href*="/app/"]'); links.forEach((a) => { const href = a.href || ''; const m = href.match(/\/app\/(\d+)/); if (!m) return; // appid 一律以跳转 URL 中的 /app/ 为准(跳转用 href,若用 data-ds-appid 会造成详情页 appid 与候选不一致被误判) const appid = m[1]; const finalId = appid; if (seen.has(finalId)) return; // 过滤界面不可见项(display:none / 已被 Steam 隐藏的已拥有项) if (!a.getClientRects || a.getClientRects().length === 0) return; // 过滤带已拥有/隐藏标记的项(部分布局会给已拥有项加标记类) const cls = (a.className || '') + ' ' + (a.parentElement ? a.parentElement.className || '' : ''); if (/owned|ds_owned|hidden/i.test(cls)) return; seen.add(finalId); const title = (a.getAttribute('data-ds-tooltip') || a.innerText || finalId).trim().slice(0, 80); items.push({ appid: finalId, title, url: href }); }); // 去重后返回 const map = new Map(); items.forEach((it) => map.set(it.appid, it)); return Array.from(map.values()); } /** 在当前搜索页收集候选;若不在搜索页则自动跳转 */ async function collectCandidates() { if (!location.pathname.startsWith('/search/')) { // 不在搜索页:跳转前打上自动开始标记,搜索页加载后 init 会自动继续任务 localStorage.setItem('safa_pending_start', '1'); location.href = SEARCH_URL; return null; } let items = collectCandidatesFromPage(); // 若单页数据不完整(搜索页通常只显示首屏),尝试读取页面内嵌的搜索结果 JSON try { const el = document.getElementById('search_result_container'); if (el && el.dataset && el.dataset.total_count) { // 已渲染结果即视为候选 } } catch (e) { /* 忽略 */ } return items.slice(0, BATCH_PAGE_SIZE); } // ==================== 复核逻辑 ==================== /** 权威复核:比对 rgOwnedApps 是否包含 appid */ async function verifyOwned(appid) { const data = await fetchUserData(); return Array.isArray(data.rgOwnedApps) && data.rgOwnedApps.includes(Number(appid)); } // ==================== UI ==================== function ensurePanel() { if (panel) return; const style = document.createElement('style'); style.textContent = ` #safa-panel { position: fixed; right: 16px; top: 120px; z-index: 99999; width: 300px; background: #1b2838; color: #c7d5e0; border: 1px solid #4b6a8f; border-radius: 8px; font-family: "Microsoft YaHei", Arial, sans-serif; font-size: 13px; box-shadow: 0 4px 16px rgba(0,0,0,.5); } #safa-panel .safa-head { display: flex; justify-content: space-between; align-items: center; padding: 10px 12px; border-bottom: 1px solid #4b6a8f; cursor: move; background: #16202d; border-radius: 8px 8px 0 0; } #safa-panel .safa-title { font-weight: bold; color: #fff; } #safa-panel .safa-min { cursor: pointer; color: #67c1f5; background: none; border: none; font-size: 14px; } #safa-panel .safa-body { padding: 12px; } #safa-panel .safa-row { margin: 4px 0; display: flex; justify-content: space-between; } #safa-panel .safa-row span:first-child { color: #8f98a0; } #safa-panel .safa-btns { margin-top: 12px; display: flex; gap: 8px; } #safa-panel button { flex: 1; padding: 6px 0; border: none; border-radius: 4px; font-size: 13px; cursor: pointer; } #safa-start { background: #a4d007; color: #1b2838; font-weight: bold; } #safa-start:disabled { background: #3d4a2a; color: #777; cursor: not-allowed; } #safa-stop { background: #c75050; color: #fff; } #safa-stop:disabled { background: #4a3030; color: #777; cursor: not-allowed; } #safa-msg { margin-top: 10px; font-size: 12px; color: #ffd479; word-break: break-all; } #safa-log { margin-top: 8px; max-height: 140px; overflow-y: auto; font-size: 12px; color: #8f98a0; } `; document.head.appendChild(style); panel = document.createElement('div'); panel.id = 'safa-panel'; panel.innerHTML = `
Steam 免费游戏自动入库
已找到候选0
当前进度- / -
成功入库0
跳过(已拥有/F2P)0
失败0
`; document.body.appendChild(panel); panelBody = document.getElementById('safa-body'); btnStart = document.getElementById('safa-start'); btnStop = document.getElementById('safa-stop'); // 最小化/展开 document.getElementById('safa-min').addEventListener('click', () => { const hidden = panelBody.style.display === 'none'; panelBody.style.display = hidden ? '' : 'none'; }); // 拖动 const head = document.getElementById('safa-head'); let dragging = false, dx = 0, dy = 0; head.addEventListener('mousedown', (e) => { dragging = true; const rect = panel.getBoundingClientRect(); dx = e.clientX - rect.left; dy = e.clientY - rect.top; e.preventDefault(); }); document.addEventListener('mousemove', (e) => { if (!dragging) return; panel.style.left = (e.clientX - dx) + 'px'; panel.style.top = (e.clientY - dy) + 'px'; panel.style.right = 'auto'; }); document.addEventListener('mouseup', () => { dragging = false; }); btnStart.addEventListener('click', startRun); btnStop.addEventListener('click', stopRun); } function updateUI() { if (!panel) return; document.getElementById('safa-total').textContent = state.candidates.length; const cur = state.candidates.length ? (state.currentIndex + 1) : 0; document.getElementById('safa-progress').textContent = state.running ? `${cur} / ${state.candidates.length}` : (state.stopped ? `已停止 ${cur} / ${state.candidates.length}` : '- / -'); document.getElementById('safa-success').textContent = state.success; document.getElementById('safa-skip').textContent = state.skipped; document.getElementById('safa-fail').textContent = state.failed; btnStart.disabled = state.running; btnStop.disabled = !state.running; } function setMsg(text) { const el = document.getElementById('safa-msg'); if (el) el.textContent = text; } function appendLog(text) { const el = document.getElementById('safa-log'); if (!el) return; const line = document.createElement('div'); line.textContent = text; el.appendChild(line); el.scrollTop = el.scrollHeight; } // ==================== 主流程 ==================== // 注:单款处理逻辑已移至 runDetailFlow(详情页逐页点击入库) /** 在搜索页收集候选并启动任务:持久化后跳转第一个详情页逐页入库 */ async function startRun() { if (state.running) return; // 不在搜索页时先跳转到免费列表页,加载后由 init 检测标记自动继续 if (!location.pathname.startsWith('/search/')) { localStorage.setItem('safa_pending_start', '1'); setMsg('正在跳转到免费游戏列表页...'); location.href = SEARCH_URL; return; } // 登录态检查 setMsg('正在检查登录态...'); const logged = await isLoggedIn(); if (!logged) { setMsg('未登录 Steam,请先登录后重试。'); appendLog('未检测到登录态(rgOwnedApps 不可用)。'); return; } // 收集候选 setMsg('正在收集免费游戏候选...'); const candidates = await collectCandidates(); if (candidates === null) return; // 已触发跳转 if (!candidates.length) { setMsg('未找到候选游戏。请确认当前处于免费游戏搜索页。'); return; } state.candidates = candidates; state.currentIndex = 0; state.success = 0; state.skipped = 0; state.failed = 0; state.alreadyOwned = 0; state.f2pSkipped = 0; state.stopped = false; state.running = true; state.done = false; updateUI(); saveTask(); setMsg(`共找到 ${candidates.length} 个候选,开始逐一点开详情页入库...`); appendLog(`开始处理,共 ${candidates.length} 个候选。`); // 跳转第一个候选详情页(后续由 runDetailFlow 逐页推进) location.href = candidates[0].url; } function stopRun() { const task = loadTask(); if (!task || !task.running) return; task.stopped = true; task.running = false; saveTask(); Object.assign(state, task); updateUI(); setMsg('已停止。任务状态已保留,可再次点击"开始"从当前页继续。'); appendLog('用户手动停止。'); } // ==================== 初始化 ==================== function init() { ensurePanel(); updateUI(); const task = loadTask(); // 搜索页:展示任务状态或预载候选 if (location.pathname.startsWith('/search/')) { // 用户在其他页点击"开始"后跳转至此:清除标记并自动继续开始流程 if (localStorage.getItem('safa_pending_start') === '1' && !(task && task.running)) { localStorage.removeItem('safa_pending_start'); setTimeout(() => startRun(), 500); return; } if (task && task.done && task.candidates.length) { // 全部处理完成:展示汇总并保留任务状态,不清除,供查看直至下次开始任务覆盖 Object.assign(state, task); state.running = false; updateUI(); setMsg(`处理完成:成功 ${state.success},跳过 ${state.skipped},失败 ${state.failed}`); appendLog(`处理完成:共 ${state.candidates.length} 个候选`); appendLog(`成功入库 ${state.success} 个`); appendLog(`跳过 ${state.skipped} 个(已拥有 ${state.alreadyOwned},F2P ${state.f2pSkipped})`); appendLog(`失败 ${state.failed} 个`); return; } if (task && task.running) { Object.assign(state, task); updateUI(); setMsg(`任务进行中:已处理 ${task.currentIndex} / ${task.candidates.length},正在详情页入库...`); appendLog(`任务进行中:已处理 ${task.currentIndex} / ${task.candidates.length}。`); return; } if (task && task.candidates && task.candidates.length) { // 任务被停止或中断(未完成):展示已处理进度并保留候选,状态不清除 Object.assign(state, task); updateUI(); setMsg(`任务已停止/待继续:已处理 ${task.currentIndex} / ${task.candidates.length}(成功 ${task.success},跳过 ${task.skipped},失败 ${task.failed})。点击"开始"重新入库。`); appendLog(`任务状态保留:已处理 ${task.currentIndex} / ${task.candidates.length}(成功 ${task.success},跳过 ${task.skipped},失败 ${task.failed})。`); return; } const items = collectCandidatesFromPage(); if (items.length) { state.candidates = items.slice(0, BATCH_PAGE_SIZE); setMsg(`已预载 ${state.candidates.length} 个候选,点击"开始"入库。`); } else { setMsg('当前搜索页未解析到候选,点击"开始"将自动跳转免费列表页。'); } updateUI(); return; } // 详情页:任务运行中则执行入库流程 if (task && task.running && !task.done) { setMsg('检测到进行中的任务,正在处理详情页...'); runDetailFlow(); return; } // 详情页:存在已完成或已停止的任务,保留展示状态 if (task && task.candidates && task.candidates.length) { Object.assign(state, task); updateUI(); if (task.done) { setMsg(`任务已处理完成:成功 ${task.success},跳过 ${task.skipped},失败 ${task.failed}。`); } else { setMsg(`任务已停止/待继续:已处理 ${task.currentIndex} / ${task.candidates.length}(成功 ${task.success},跳过 ${task.skipped},失败 ${task.failed})。`); } appendLog('任务状态保留展示。'); return; } setMsg('不在搜索页。点击"开始"将跳转到免费游戏全量列表并自动入库。'); } // 页面加载完成后初始化(run-at document-idle 已保证 DOM 就绪) init(); })();