// ==UserScript== // @name Steam Social Core // @namespace https://github.com/SmallRob/steam-userscripts // @version 1.0.0 // @description 社交中心数据层。对照宿主 steam-activity-modern 4.9.0(基线 4.8.1)。零页面 DOM,独立 IndexedDB steam_social_db。 // @author SmallFork & SmallRob // @license MIT // ==/UserScript== /** * window.SteamSocialCore * apiVersion 1 * * 不查询 #blotter_page,不依赖动态插件的 CSS。 * 打开或 init 都不会启动全员等级、全员共同好友、全员游戏库。 */ (function (root) { 'use strict'; const VERSION = '1.0.0'; const API_VERSION = 1; const DB_NAME = 'steam_social_db'; const DB_STORE = 'kv'; const DB_VERSION = 1; const ROSTER_TTL = 15 * 60 * 1000; const MUTUAL_TTL = 12 * 60 * 60 * 1000; const BATCH = 100; const LEVEL_CONCURRENCY = 8; const LIBRARY_CONCURRENCY = 4; const DAY_MS = 86400000; const NEW_FRIEND_DAYS = 30; const mem = Object.create(null); let db = null; let host = null; let readyResolved = false; let readyResolve; const ready = new Promise((resolve) => { readyResolve = resolve; }); const writeQueue = new Map(); let writeTimer = null; const vacJob = { running: false, paused: false, gen: 0 }; const levelJob = { running: false, paused: false, done: 0, total: 0, failed: 0, pending: [], limit: LEVEL_CONCURRENCY, onItem: null, gen: 0, attempts: Object.create(null), }; const libJob = { running: false, paused: false, done: 0, total: 0, failed: 0, pending: [], limit: LIBRARY_CONCURRENCY, onProgress: null, gen: 0, attempts: Object.create(null), }; let rosterInflight = null; function finishReady() { if (readyResolved) return; readyResolved = true; readyResolve(); } function openDb() { const factory = root.indexedDB || (typeof indexedDB !== 'undefined' ? indexedDB : null); if (!factory) return Promise.resolve(null); return new Promise((resolve) => { try { const req = factory.open(DB_NAME, DB_VERSION); req.onerror = () => resolve(null); req.onsuccess = () => resolve(req.result); req.onupgradeneeded = (e) => { const next = e.target.result; if (!next.objectStoreNames.contains(DB_STORE)) next.createObjectStore(DB_STORE); }; } catch (e) { resolve(null); } }); } function loadAll(database) { return new Promise((resolve) => { try { const tx = database.transaction(DB_STORE, 'readonly'); const req = tx.objectStore(DB_STORE).openCursor(); req.onsuccess = () => { const cursor = req.result; if (cursor) { mem[cursor.key] = cursor.value; cursor.continue(); } else resolve(); }; req.onerror = () => resolve(); } catch (e) { resolve(); } }); } function flushWrites() { writeTimer = null; const database = db; if (!database || !writeQueue.size) { writeQueue.clear(); return; } const batch = new Map(writeQueue); writeQueue.clear(); try { const tx = database.transaction(DB_STORE, 'readwrite'); const store = tx.objectStore(DB_STORE); batch.forEach((val, key) => { store.put(val, key); }); } catch (e) { /* 内存镜像仍可用 */ } } function get(key) { return mem[key]; } function set(key, val) { mem[key] = val; writeQueue.set(key, val); if (!writeTimer) writeTimer = setTimeout(flushWrites, 400); return val; } (function hydrate() { openDb().then((database) => { db = database; if (!database) { finishReady(); return; } return loadAll(database).then(finishReady, finishReady); }, finishReady); })(); function attach(nextHost) { if (!nextHost || nextHost.apiVersion !== API_VERSION) return false; host = nextHost; return true; } function hasKey() { try { return !!(host && typeof host.getApiKey === 'function' && host.getApiKey()); } catch (e) { return false; } } function myId() { try { return (host && host.getMySteamId && host.getMySteamId()) || ''; } catch (e) { return ''; } } function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } function apiUrl(path, params) { const url = new URL('https://api.steampowered.com' + path); url.searchParams.set('key', host.getApiKey()); Object.keys(params || {}).forEach((k) => { if (params[k] != null) url.searchParams.set(k, String(params[k])); }); return url.href; } async function fetchJson(url, timeoutMs) { if (!host || typeof host.gmFetchJson !== 'function') { const err = new Error('gmFetchJson missing'); err.status = 0; throw err; } let delay = 800; let last = null; for (let attempt = 0; attempt < 4; attempt++) { try { return await host.gmFetchJson(url, timeoutMs || 15000); } catch (e) { last = e; const status = e && e.status; const transient = status === 429 || status === 403 || status >= 500 || status === 0 || !status; if (!transient || attempt === 3) throw e; if (status === 429) delay = Math.min(delay * 2, 8000); await sleep(delay); delay = Math.min(delay * 2, 8000); } } throw last || new Error('fetch failed'); } function friendDays(friendSince, nowMs) { const ts = Number(friendSince); if (!ts) return null; const now = nowMs == null ? Date.now() : nowMs; return Math.max(0, Math.floor((now - ts * 1000) / DAY_MS)); } function isNewFriend(friend, nowMs) { const days = friendDays(friend && friend.friend_since, nowMs); return days != null && days < NEW_FRIEND_DAYS; } function isInGame(friend) { return !!(friend && friend.personastate > 0 && friend.gameextrainfo); } function isOnlineOnly(friend) { return !!(friend && friend.personastate > 0 && !friend.gameextrainfo); } function statusRank(friend) { if (isInGame(friend)) return 2; if (friend && friend.personastate > 0) return 1; return 0; } function computeKpi(friends, vacById, nowMs) { const list = friends || []; const vac = vacById || {}; let ingame = 0; let online = 0; let fresh = 0; let vacKnown = 0; let vacBanned = 0; const countries = new Set(); list.forEach((friend) => { if (isInGame(friend)) ingame++; else if (isOnlineOnly(friend)) online++; if (isNewFriend(friend, nowMs)) fresh++; if (friend.countryCode) countries.add(friend.countryCode); const ban = vac[friend.steamid]; if (ban) { vacKnown++; if (ban.vac || ban.gameBan || ban.communityBanned) vacBanned++; } }); return { total: list.length, ingame, online, offline: list.length - ingame - online, fresh, countries: countries.size, vacKnown, vacBanned, }; } function filterFriends(friends, query) { const q = String(query || '').trim().toLowerCase(); if (!q) return (friends || []).slice(); return (friends || []).filter((friend) => { return (friend.personaname || '').toLowerCase().includes(q) || (friend.steamid || '').includes(q) || (friend.countryCode || '').toLowerCase().includes(q); }); } function compareNullableDesc(a, b) { if (a == null && b == null) return 0; if (a == null) return 1; if (b == null) return -1; return b - a; } function sortFriends(friends, key, ctx) { const list = (friends || []).slice(); const levels = (ctx && ctx.levels) || {}; const now = ctx && ctx.now; const sortKey = key || 'days'; list.sort((a, b) => { let diff = 0; if (sortKey === 'name') { diff = String(a.personaname || '').localeCompare(String(b.personaname || ''), 'zh'); } else if (sortKey === 'status') { diff = statusRank(b) - statusRank(a); if (!diff) diff = String(a.personaname || '').localeCompare(String(b.personaname || ''), 'zh'); } else if (sortKey === 'level') { diff = compareNullableDesc( levels[a.steamid] == null ? null : Number(levels[a.steamid]), levels[b.steamid] == null ? null : Number(levels[b.steamid]) ); } else if (sortKey === 'country') { const ac = a.countryCode || ''; const bc = b.countryCode || ''; if (!ac && bc) return 1; if (ac && !bc) return -1; diff = ac.localeCompare(bc); } else { diff = compareNullableDesc(friendDays(a.friend_since, now), friendDays(b.friend_since, now)); } if (diff) return diff; return String(a.steamid).localeCompare(String(b.steamid)); }); return list; } function gameMap(games) { const map = new Map(); (games || []).forEach((game) => { if (!game || game.appid == null) return; map.set(Number(game.appid), { name: game.name || ('App ' + game.appid), playtime: game.playtime_forever || 0, lastPlayed: game.rtime_last_played || 0, }); }); return map; } function sumMinutes(games) { return (games || []).reduce((sum, game) => sum + (game.playtime_forever || 0), 0); } function comparePairLists(myGames, friendGames, totals) { const myMap = gameMap(myGames); const friendMap = gameMap(friendGames); const shared = []; const myOnly = []; const friendOnly = []; myMap.forEach((myInfo, appid) => { const friendInfo = friendMap.get(appid); if (friendInfo) { shared.push({ appid, name: myInfo.name || friendInfo.name, myPlaytime: myInfo.playtime, friendPlaytime: friendInfo.playtime, myLast: myInfo.lastPlayed, friendLast: friendInfo.lastPlayed, }); } else { myOnly.push({ appid, name: myInfo.name, myPlaytime: myInfo.playtime, myLast: myInfo.lastPlayed }); } }); friendMap.forEach((friendInfo, appid) => { if (!myMap.has(appid)) { friendOnly.push({ appid, name: friendInfo.name, friendPlaytime: friendInfo.playtime, friendLast: friendInfo.lastPlayed, }); } }); const meta = totals || {}; return { shared, myOnly, friendOnly, sharedCount: shared.length, myOnlyCount: myOnly.length, friendOnlyCount: friendOnly.length, myTotalGames: meta.myTotal != null ? meta.myTotal : myMap.size, friendTotalGames: meta.friendTotal != null ? meta.friendTotal : friendMap.size, myTotalMinutes: sumMinutes(myGames), friendTotalMinutes: sumMinutes(friendGames), }; } function compareManyLists(entries) { const maps = entries.map((entry) => ({ steamid: entry.steamid, name: entry.name || entry.steamid, map: gameMap(entry.games), })); if (maps.length < 2) return { games: [], count: 0, people: maps.map((item) => ({ steamid: item.steamid, name: item.name })) }; const [first, ...rest] = maps; const games = []; first.map.forEach((info, appid) => { if (!rest.every((item) => item.map.has(appid))) return; const playtimes = {}; playtimes[first.steamid] = info.playtime; rest.forEach((item) => { playtimes[item.steamid] = item.map.get(appid).playtime; }); games.push({ appid, name: info.name, playtimes }); }); games.sort((a, b) => { const sa = Object.keys(a.playtimes).reduce((s, k) => s + a.playtimes[k], 0); const sb = Object.keys(b.playtimes).reduce((s, k) => s + b.playtimes[k], 0); return sb - sa; }); return { games, count: games.length, people: maps.map((item) => ({ steamid: item.steamid, name: item.name })), }; } function loadRoster() { const roster = get('roster'); if (!roster || !Array.isArray(roster.friends)) return null; return roster; } function rosterFresh() { const roster = loadRoster(); if (!roster || !roster.updatedAt) return false; return Date.now() - roster.updatedAt < ROSTER_TTL; } function normalizeSummary(player) { return { name: player.personaname || '', avatar: player.avatarfull || player.avatarmedium || '', profileUrl: player.profileurl || '', countryCode: player.loccountrycode || '', gameName: player.gameextrainfo || '', status: player.personastate || 0, lastLogoff: player.lastlogoff || 0, }; } function normalizeBan(player) { return { vac: !!player.VACBanned, gameBan: !!(player.GameBanned || player.NumberOfGameBans), vacCount: player.NumberOfVACBans || 0, gameBanCount: player.NumberOfGameBans || 0, daysSinceLastBan: player.DaysSinceLastBan || 0, communityBanned: !!player.CommunityBanned, tradeBan: player.EconomyBan || 'none', }; } async function fetchSummaries(ids) { const merged = {}; for (let i = 0; i < ids.length; i += BATCH) { const slice = ids.slice(i, i + BATCH); const data = await fetchJson(apiUrl('/ISteamUser/GetPlayerSummaries/v0002/', { steamids: slice.join(','), })); (data.response && data.response.players || []).forEach((player) => { merged[player.steamid] = normalizeSummary(player); }); } return merged; } async function refreshRoster() { if (rosterInflight) return rosterInflight; rosterInflight = (async () => { if (!hasKey()) { const err = new Error('NO_KEY'); err.code = 'NO_KEY'; throw err; } const steamId = myId(); if (!/^\d{17}$/.test(steamId)) { const err = new Error('NO_STEAMID'); err.code = 'NO_STEAMID'; throw err; } const data = await fetchJson(apiUrl('/ISteamUser/GetFriendList/v0001/', { steamid: steamId, relationship: 'friend', format: 'json', })); const rawFriends = data.friendslist && data.friendslist.friends || []; const ids = [steamId].concat(rawFriends.map((friend) => friend.steamid)); const summaries = await fetchSummaries(ids); const ownSummary = summaries[steamId] || {}; const friends = rawFriends.map((friend) => { const summary = summaries[friend.steamid] || {}; return { steamid: friend.steamid, friend_since: friend.friend_since || 0, personaname: summary.name || friend.steamid, avatar: summary.avatar || '', profileurl: summary.profileUrl || ('https://steamcommunity.com/profiles/' + friend.steamid + '/'), countryCode: summary.countryCode || '', personastate: summary.status || 0, lastlogoff: summary.lastLogoff || 0, gameextrainfo: summary.gameName || '', }; }); const roster = { steamId, friends, own: { steamid: steamId, personaname: ownSummary.name || '', avatar: ownSummary.avatar || '', countryCode: ownSummary.countryCode || '', personastate: ownSummary.status || 0, gameextrainfo: ownSummary.gameName || '', }, updatedAt: Date.now(), }; set('roster', roster); return roster; })(); try { return await rosterInflight; } finally { rosterInflight = null; } } function getVac() { return get('vac') || {}; } function getLevels() { return get('level') || {}; } function vacSnapshot() { const roster = loadRoster(); const kpi = computeKpi(roster && roster.friends, getVac(), Date.now()); return { known: kpi.vacKnown, banned: kpi.vacBanned, total: kpi.total, running: vacJob.running }; } async function startVacFill(onBatch) { if (vacJob.running) return vacSnapshot(); const roster = loadRoster(); if (!roster || !hasKey()) return vacSnapshot(); const known = getVac(); const missing = roster.friends.map((friend) => friend.steamid).filter((id) => !known[id]); if (!missing.length) { if (onBatch) onBatch(vacSnapshot()); return vacSnapshot(); } const gen = ++vacJob.gen; vacJob.running = true; vacJob.paused = false; try { let cursor = 0; while (cursor < missing.length && gen === vacJob.gen && !vacJob.paused) { const slice = missing.slice(cursor, cursor + BATCH); let saved = false; for (let attempt = 0; attempt < 4 && !saved && gen === vacJob.gen && !vacJob.paused; attempt++) { try { const data = await fetchJson(apiUrl('/ISteamUser/GetPlayerBans/v1/', { steamids: slice.join(','), })); const map = getVac(); (data.players || []).forEach((player) => { const id = player.SteamId || player.steamid; if (id) map[id] = normalizeBan(player); }); slice.forEach((id) => { if (!map[id]) map[id] = { vac: false, gameBan: false, vacCount: 0, gameBanCount: 0, communityBanned: false, tradeBan: 'none', daysSinceLastBan: 0 }; }); set('vac', map); if (onBatch) onBatch(vacSnapshot()); saved = true; } catch (e) { if ((e && e.status) === 429 && attempt < 3) await sleep(1500 * (attempt + 1)); else break; } } cursor += BATCH; } } finally { if (gen === vacJob.gen) vacJob.running = false; } return vacSnapshot(); } function pauseVacFill() { vacJob.paused = true; vacJob.gen++; vacJob.running = false; } function queryFriends(opts) { const roster = loadRoster(); const filtered = filterFriends(roster && roster.friends, opts && opts.q); return sortFriends(filtered, opts && opts.sort, { levels: getLevels(), now: Date.now() }); } function kpi() { const roster = loadRoster(); return computeKpi(roster && roster.friends, getVac(), Date.now()); } function levelJobState() { return { running: levelJob.running, paused: levelJob.paused, done: levelJob.done, total: levelJob.total, failed: levelJob.failed, }; } function emitLevel(id, level) { if (typeof levelJob.onItem === 'function') { try { levelJob.onItem(id, level, levelJobState()); } catch (e) { /* 界面回调失败不影响任务 */ } } } async function levelOne(id) { try { const data = await fetchJson(apiUrl('/IPlayerService/GetSteamLevel/v1/', { steamid: id })); const level = (data && data.response && data.response.player_level) || 0; const map = getLevels(); map[id] = level; set('level', map); levelJob.done++; emitLevel(id, level); } catch (e) { const tries = (levelJob.attempts[id] || 0) + 1; levelJob.attempts[id] = tries; if ((e && e.status) === 429 && tries <= 3) { levelJob.limit = Math.max(1, Math.floor(levelJob.limit / 2)); levelJob.pending.unshift(id); await sleep(1200 * tries); return; } levelJob.failed++; levelJob.done++; } } async function runLevel(gen) { const active = new Set(); while (gen === levelJob.gen) { while (!levelJob.paused && active.size < levelJob.limit && levelJob.pending.length && gen === levelJob.gen) { const id = levelJob.pending.shift(); const task = levelOne(id).finally(() => active.delete(task)); active.add(task); } if (!levelJob.pending.length && active.size === 0) break; if (active.size) await Promise.race(Array.from(active)); else await sleep(120); } if (gen !== levelJob.gen) return; levelJob.running = false; levelJob.paused = false; emitLevel(null, null); } function startLevelJob(onItem) { if (levelJob.running) return levelJobState(); if (!hasKey()) return levelJobState(); const roster = loadRoster(); const levels = getLevels(); const pending = ((roster && roster.friends) || []).map((friend) => friend.steamid).filter((id) => levels[id] == null); levelJob.pending = pending; levelJob.done = 0; levelJob.total = pending.length; levelJob.failed = 0; levelJob.paused = false; levelJob.limit = LEVEL_CONCURRENCY; levelJob.attempts = Object.create(null); levelJob.onItem = onItem || null; levelJob.running = pending.length > 0; if (pending.length) runLevel(++levelJob.gen); return levelJobState(); } function pauseLevelJob() { if (!levelJob.running) return levelJobState(); levelJob.paused = true; return levelJobState(); } function resumeLevelJob() { if (!levelJob.paused) return levelJobState(); levelJob.paused = false; levelJob.running = true; return levelJobState(); } function getMutual(steamId) { const cached = get('mutual:' + steamId); if (!cached) return null; return cached; } async function fetchMutual(friendSteamId) { const cached = getMutual(friendSteamId); if (cached && Date.now() - cached.updatedAt < MUTUAL_TTL) return cached; if (!hasKey()) { const err = new Error('NO_KEY'); err.code = 'NO_KEY'; throw err; } let roster = loadRoster(); if (!roster) roster = await refreshRoster(); const mine = new Set((roster.friends || []).map((friend) => friend.steamid)); let record; try { const data = await fetchJson(apiUrl('/ISteamUser/GetFriendList/v0001/', { steamid: friendSteamId, relationship: 'friend', format: 'json', })); if (!data || !data.friendslist) { record = { count: 0, ids: [], private: true, friendCount: 0, updatedAt: Date.now() }; } else { const theirs = data.friendslist.friends || []; const ids = theirs.map((friend) => friend.steamid).filter((id) => mine.has(id)); record = { count: ids.length, ids, private: false, friendCount: theirs.length, updatedAt: Date.now() }; } } catch (e) { const status = e && e.status; if (status === 401 || status === 403) { record = { count: 0, ids: [], private: true, friendCount: 0, updatedAt: Date.now() }; } else { throw e; } } set('mutual:' + friendSteamId, record); return record; } function mapGames(raw) { return (raw || []).map((game) => ({ appid: game.appid, name: game.name || ('App ' + game.appid), playtime_forever: game.playtime_forever || 0, playtime_2weeks: game.playtime_2weeks || 0, img_icon_url: game.img_icon_url || '', rtime_last_played: game.rtime_last_played || 0, })); } function peekGames(steamId) { const value = get('games:' + steamId); if (!value || !Array.isArray(value.games)) return null; return value; } function putGames(steamId, games, total) { const list = Array.isArray(games) ? games : []; return set('games:' + steamId, { games: list, total: total != null ? total : list.length, updatedAt: Date.now(), }); } function peekRecent(steamId) { const value = get('recent:' + steamId); if (!value || !Array.isArray(value.games)) return null; return value; } function putRecent(steamId, games) { return set('recent:' + steamId, { games: games || [], updatedAt: Date.now() }); } async function fetchOwned(steamId) { if (!hasKey() || !steamId) return null; const data = await fetchJson(apiUrl('/IPlayerService/GetOwnedGames/v0001/', { steamid: steamId, include_appinfo: 1, include_played_free_games: 1, format: 'json', }), 25000); const response = data && data.response; if (!response || (response.games == null && response.game_count == null)) return null; const games = mapGames(response.games || []); return putGames(steamId, games, response.game_count != null ? response.game_count : games.length); } async function fetchRecent(steamId) { if (!hasKey() || !steamId) return null; const data = await fetchJson(apiUrl('/IPlayerService/GetRecentlyPlayedGames/v0001/', { steamid: steamId, format: 'json', count: 0, })); const response = data && data.response; if (!response) return null; return putRecent(steamId, mapGames(response.games || [])); } function friendName(steamId) { if (steamId === myId()) return '我'; const roster = loadRoster(); const found = roster && (roster.friends || []).find((friend) => friend.steamid === steamId); if (found) return found.personaname || steamId; if (roster && roster.own && roster.own.steamid === steamId) return roster.own.personaname || '我'; return steamId; } function comparePair(leftId, rightId) { const left = peekGames(leftId); const right = peekGames(rightId); if (!left || !right) return null; return comparePairLists(left.games, right.games, { myTotal: left.total, friendTotal: right.total }); } function compareMany(ids) { const unique = []; (ids || []).forEach((id) => { if (id && unique.indexOf(id) === -1) unique.push(id); }); const picked = unique.slice(0, 4); const missing = []; const entries = []; picked.forEach((id) => { const games = peekGames(id); if (!games) missing.push(id); else entries.push({ steamid: id, name: friendName(id), games: games.games }); }); const compared = compareManyLists(entries); compared.missing = missing; return compared; } function indexStats() { const roster = loadRoster(); const friends = (roster && roster.friends) || []; let indexed = 0; let recent = 0; friends.forEach((friend) => { if (peekGames(friend.steamid)) indexed++; if (peekRecent(friend.steamid)) recent++; }); return { indexed, recent, total: friends.length, failed: libJob.failed }; } function libraryJobState() { const stats = indexStats(); return { running: libJob.running, paused: libJob.paused, done: libJob.done, total: libJob.total, failed: libJob.failed, indexed: stats.indexed, rosterTotal: stats.total, recent: stats.recent, }; } function emitLib(phase) { if (typeof libJob.onProgress !== 'function') return; try { libJob.onProgress(Object.assign({ phase }, libraryJobState())); } catch (e) { /* 忽略界面错误 */ } } async function indexOne(id) { let failed = false; if (!peekGames(id)) { try { const saved = await fetchOwned(id); if (!saved) failed = true; } catch (e) { const tries = (libJob.attempts[id] || 0) + 1; libJob.attempts[id] = tries; if ((e && e.status) === 429 && tries <= 3) { libJob.limit = Math.max(1, Math.floor(libJob.limit / 2)); libJob.pending.unshift(id); await sleep(1200 * tries); return; } failed = true; } } if (!failed && !peekRecent(id)) { try { await fetchRecent(id); } catch (e) { if ((e && e.status) === 429) { libJob.limit = Math.max(1, Math.floor(libJob.limit / 2)); await sleep(1200); } } } if (failed) libJob.failed++; libJob.done++; emitLib('tick'); } async function runLibrary(gen) { const active = new Set(); while (gen === libJob.gen) { while (!libJob.paused && active.size < libJob.limit && libJob.pending.length && gen === libJob.gen) { const id = libJob.pending.shift(); const task = indexOne(id).finally(() => active.delete(task)); active.add(task); } if (!libJob.pending.length && active.size === 0) break; if (active.size) await Promise.race(Array.from(active)); else await sleep(120); } if (gen !== libJob.gen) return; libJob.running = false; libJob.paused = false; emitLib('done'); } function libraryTargets() { const roster = loadRoster(); const ids = ((roster && roster.friends) || []) .map((friend) => friend.steamid) .filter((id) => !peekGames(id) || !peekRecent(id)); const self = myId(); if (self && (!peekGames(self) || !peekRecent(self)) && ids.indexOf(self) === -1) ids.unshift(self); return ids; } function startLibraryIndexJob(onProgress) { if (libJob.running) return libraryJobState(); if (!hasKey()) return libraryJobState(); const pending = libraryTargets(); libJob.pending = pending; libJob.done = 0; libJob.total = pending.length; libJob.failed = 0; libJob.paused = false; libJob.limit = LIBRARY_CONCURRENCY; libJob.attempts = Object.create(null); libJob.onProgress = onProgress || libJob.onProgress; libJob.running = pending.length > 0; if (!pending.length) { emitLib('done'); return libraryJobState(); } runLibrary(++libJob.gen); emitLib('tick'); return libraryJobState(); } function pauseLibraryIndexJob() { if (!libJob.running) return libraryJobState(); libJob.paused = true; emitLib('pause'); return libraryJobState(); } function resumeLibraryIndexJob() { if (!libJob.paused) return libraryJobState(); libJob.paused = false; libJob.running = true; emitLib('tick'); return libraryJobState(); } function rank(kind) { const roster = loadRoster(); const friends = (roster && roster.friends) || []; const levels = getLevels(); const now = Date.now(); const stats = indexStats(); if (kind === 'days') { const rows = sortFriends(friends, 'days', { now }).map((friend) => { const days = friendDays(friend.friend_since, now); return rowOf(friend, days, days == null ? '未知' : (days + ' 天')); }); return { kind, rows, indexed: friends.length, total: friends.length }; } if (kind === 'status') { const rows = sortFriends(friends, 'status', { now }).map((friend) => { const label = isInGame(friend) ? ('游戏中 · ' + friend.gameextrainfo) : (friend.personastate > 0 ? '在线' : '离线'); return rowOf(friend, statusRank(friend), label); }); return { kind, rows, indexed: friends.length, total: friends.length }; } if (kind === 'level') { const rows = sortFriends(friends, 'level', { levels, now }).map((friend) => { const level = levels[friend.steamid]; return rowOf(friend, level == null ? null : level, level == null ? '未缓存' : ('Lv.' + level)); }); return { kind, rows, indexed: friends.filter((friend) => levels[friend.steamid] != null).length, total: friends.length }; } if (kind === 'recent2w') { const rows = []; friends.forEach((friend) => { const recent = peekRecent(friend.steamid); if (!recent) return; const minutes = recent.games.reduce((sum, game) => sum + (game.playtime_2weeks || 0), 0); rows.push(rowOf(friend, minutes, formatHours(minutes))); }); rows.sort((a, b) => b.value - a.value); return { kind, rows, indexed: stats.recent, total: stats.total }; } const rows = []; friends.forEach((friend) => { const games = peekGames(friend.steamid); if (!games) return; if (kind === 'playtime') { const minutes = sumMinutes(games.games); rows.push(rowOf(friend, minutes, formatHours(minutes))); } else { const count = games.total != null ? games.total : games.games.length; rows.push(rowOf(friend, count, count + ' 款')); } }); rows.sort((a, b) => b.value - a.value); return { kind: kind === 'playtime' ? 'playtime' : 'gameCount', rows, indexed: stats.indexed, total: stats.total }; } function rowOf(friend, value, label) { return { steamid: friend.steamid, name: friend.personaname || friend.steamid, avatar: friend.avatar || '', value, label, }; } function formatHours(minutes) { return (Math.round((minutes || 0) / 60 * 10) / 10) + ' 小时'; } function rankGame(appid) { const target = Number(appid); const roster = loadRoster(); const friends = (roster && roster.friends) || []; const rows = []; let indexed = 0; let name = ''; friends.forEach((friend) => { const games = peekGames(friend.steamid); if (!games) return; indexed++; const hit = games.games.find((game) => Number(game.appid) === target); if (!hit) return; if (!name && hit.name) name = hit.name; rows.push({ steamid: friend.steamid, name: friend.personaname || friend.steamid, minutes: hit.playtime_forever || 0 }); }); rows.sort((a, b) => b.minutes - a.minutes); return { appid: target, name, rows, indexed, total: friends.length }; } function searchGames(query, limit) { const q = String(query || '').trim().toLowerCase(); if (q.length < 2 && !/^\d+$/.test(q)) return []; const max = limit || 12; const seen = new Map(); const roster = loadRoster(); const friends = (roster && roster.friends) || []; for (let i = 0; i < friends.length && seen.size < max; i++) { const games = peekGames(friends[i].steamid); if (!games) continue; for (let j = 0; j < games.games.length && seen.size < max; j++) { const game = games.games[j]; const appid = Number(game.appid); if (seen.has(appid)) continue; const name = game.name || ''; if (String(appid) === q || name.toLowerCase().includes(q)) seen.set(appid, { appid, name }); } } return Array.from(seen.values()); } root.SteamSocialCore = { version: VERSION, apiVersion: API_VERSION, ROSTER_TTL, ready, attach, loadRoster, rosterFresh, refreshRoster, getVac, getLevels, getMutual, fetchMutual, peekGames, putGames, peekRecent, putRecent, fetchOwned, fetchRecent, kpi, queryFriends, startVacFill, pauseVacFill, startLevelJob, pauseLevelJob, resumeLevelJob, levelJobState, rank, comparePair, compareMany, rankGame, searchGames, indexStats, startLibraryIndexJob, pauseLibraryIndexJob, resumeLibraryIndexJob, libraryJobState, friendName, pure: { friendDays, isNewFriend, computeKpi, filterFriends, sortFriends, comparePairLists, compareManyLists, }, }; })(typeof window !== 'undefined' ? window : globalThis);