// ==UserScript== // @name Steam Social Core // @namespace https://github.com/SmallRob/steam-userscripts // @version 1.1.0 // @description 社交中心数据层。对照宿主 steam-activity-modern 4.10.0。零页面 DOM,独立 IndexedDB steam_social_db。1.1.0 新增 dashAggregate 深度大盘聚合、游玩快照(热力图)、私密游戏库标记、等级任务含本人。 // @author SmallFork & SmallRob // @license MIT // ==/UserScript== /** * window.SteamSocialCore * apiVersion 1 * * 不查询 #blotter_page,不依赖动态插件的 CSS。 * 打开或 init 都不会启动全员等级、全员共同好友、全员游戏库。 */ (function (root) { 'use strict'; const VERSION = '1.1.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); // 自己的等级也补上(好友上限估算需要本人等级) const self = myId(); if (/^\d{17}$/.test(self || '') && levels[self] == null && pending.indexOf(self) === -1) pending.unshift(self); 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() }); } function dayKeyOf(ts) { const d = new Date(ts); return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); } // 游玩快照:每人每天最多一条(当日累计总时长),热力图用相邻两天差值还原每日增量 function recordSnap(steamId) { const games = peekGames(steamId); if (!games) return; const total = games.games.reduce((sum, game) => sum + (game.playtime_forever || 0), 0); const snaps = get('snaps') || {}; const arr = snaps[steamId] || (snaps[steamId] = []); const key = dayKeyOf(Date.now()); const last = arr[arr.length - 1]; if (last && last.d === key) last.m = total; else { arr.push({ d: key, m: total }); if (arr.length > 21) arr.splice(0, arr.length - 21); } set('snaps', snaps); } // 游戏库私密/拉取失败标记:GetOwnedGames 对私密资料返回空 response function markLibPrivate(steamId, isPrivate) { const map = get('libpriv') || {}; if (isPrivate) { if (!map[steamId]) { map[steamId] = 1; set('libpriv', map); } } else if (map[steamId]) { delete map[steamId]; set('libpriv', map); } } function ownLevelCached() { const id = myId(); if (!/^\d{17}$/.test(id || '')) return null; const level = getLevels()[id]; return level == null ? null : Number(level); } function friendLimitEstimate(level) { if (level == null) return null; return Math.min(2000, 300 + level * 5); } function gameIconUrl(appid, icon) { return icon ? 'https://media.steampowered.com/steamcommunity/public/images/apps/' + appid + '/' + icon + '.jpg' : ''; } 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; const saved = putRecent(steamId, mapGames(response.games || [])); recordSnap(steamId); return saved; } 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; } markLibPrivate(id, failed); } 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()); } // ==================== 深度大盘聚合(只读缓存,不发请求)==================== const DASH_HEAT_DAYS = 14; const DASH_HEAT_ROWS = 12; const DASH_HOT_TOP = 8; const DASH_RECENT_TOP = 10; function dashHeatSeries(snapsArr, days) { // 每天的累计值 = 当天或之前最近一次快照;相邻差值即当日增量(首日无基线为 null) const cum = days.map((day) => { let value = null; for (let i = 0; i < snapsArr.length; i++) { if (snapsArr[i].d <= day.key) value = snapsArr[i].m; else break; } return value; }); return cum.map((cur, i) => { if (cur == null) return null; const prev = i > 0 ? cum[i - 1] : null; if (prev == null) return null; return Math.max(0, cur - prev); }); } function dashAggregate() { const roster = loadRoster(); const friends = (roster && roster.friends) || []; const now = Date.now(); const kpis = computeKpi(friends, getVac(), now); const ownLevel = ownLevelCached(); const limit = friendLimitEstimate(ownLevel); const priv = get('libpriv') || {}; let recent2wMinutes = 0; let publicCount = 0; let privateCount = 0; const recentRank = []; const hotMap = new Map(); const heatPool = []; friends.forEach((friend) => { const games = peekGames(friend.steamid); if (games) publicCount++; else if (priv[friend.steamid]) privateCount++; const recent = peekRecent(friend.steamid); if (!recent) return; let minutes = 0; const perGame = new Map(); (recent.games || []).forEach((game) => { const m2 = game.playtime_2weeks || 0; if (!m2) return; minutes += m2; const hit = perGame.get(game.appid) || { appid: game.appid, name: game.name, minutes: 0, players: 0, icon: game.img_icon_url || '', }; hit.minutes += m2; perGame.set(game.appid, hit); }); if (!minutes) return; recent2wMinutes += minutes; recentRank.push({ steamid: friend.steamid, name: friend.personaname || friend.steamid, avatar: friend.avatar || '', minutes, topGames: Array.from(perGame.values()).sort((a, b) => b.minutes - a.minutes).slice(0, 3) .map((game) => ({ appid: game.appid, name: game.name, minutes: game.minutes, icon: gameIconUrl(game.appid, game.icon) })), }); perGame.forEach((game) => { const hot = hotMap.get(game.appid) || { appid: game.appid, name: game.name, minutes: 0, players: 0, icon: gameIconUrl(game.appid, game.icon), }; hot.minutes += game.minutes; hot.players += 1; hotMap.set(game.appid, hot); }); heatPool.push({ steamid: friend.steamid, name: friend.personaname || friend.steamid, avatar: friend.avatar || '', minutes, }); }); recentRank.sort((a, b) => b.minutes - a.minutes); const hotRows = Array.from(hotMap.values()).sort((a, b) => b.minutes - a.minutes); const hotGames = hotRows.slice(0, DASH_HOT_TOP).map((game) => ({ appid: game.appid, name: game.name, minutes: game.minutes, players: game.players, icon: gameIconUrl(game.appid, game.icon), })); if (hotRows.length > DASH_HOT_TOP) { const rest = hotRows.slice(DASH_HOT_TOP); hotGames.push({ appid: 0, name: '其他 ' + rest.length + ' 款', minutes: rest.reduce((sum, game) => sum + game.minutes, 0), players: rest.reduce((max, game) => Math.max(max, game.players), 0), icon: '', }); } const days = []; for (let i = DASH_HEAT_DAYS - 1; i >= 0; i--) { const ts = now - i * DAY_MS; const d = new Date(ts); days.push({ key: dayKeyOf(ts), label: (d.getMonth() + 1) + '/' + d.getDate() }); } const snaps = get('snaps') || {}; const heatRows = heatPool.sort((a, b) => b.minutes - a.minutes).slice(0, DASH_HEAT_ROWS) .map((friend) => ({ steamid: friend.steamid, name: friend.name, avatar: friend.avatar, cells: dashHeatSeries(snaps[friend.steamid] || [], days), })); let heatMax = 0; heatRows.forEach((row) => row.cells.forEach((value) => { if (value != null && value > heatMax) heatMax = value; })); const friendsRank = []; friends.forEach((friend) => { const mutual = getMutual(friend.steamid); if (mutual && !mutual.private && mutual.friendCount > 0) { friendsRank.push({ steamid: friend.steamid, name: friend.personaname || friend.steamid, avatar: friend.avatar || '', count: mutual.friendCount, }); } }); friendsRank.sort((a, b) => b.count - a.count); return { kpis, limit, ownLevel, recent2wMinutes, publicCount, privateCount, coverage: indexStats(), recentRank: recentRank.slice(0, DASH_RECENT_TOP), hotGames, hotTotalMinutes: recent2wMinutes, hotKinds: hotMap.size, heat: { days, rows: heatRows, max: heatMax }, friendsRank: friendsRank.slice(0, DASH_RECENT_TOP), generatedAt: now, }; } 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, dashAggregate, 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);