// ==UserScript== // @name Steam Social Core // @namespace https://github.com/SmallRob/steam-userscripts // @version 2.2.0 // @description 社交中心数据层。对照宿主 steam-activity-modern 4.18.0。零页面 DOM,独立 IndexedDB steam_social_db。2.2.0 compareManyLists 补 people[].total(每人游戏库规模),供界面计算「共同游戏占各人库的比例」。2.1.0 新增 gameOwnershipRank(游戏拥有数排行:按好友拥有数排游戏,范围=好友,只统计已索引好友,记忆化避免重复聚合)。2.0.0 新增玩家画像引擎:价格层(IStoreBrowseService/GetItems 批量)、评测层(appreviews 按需补全)、profileAggregate/profileHeat/profileDustRank 纯缓存聚合,口径移植自 data-hub/src/profile.js §4.10。1.2.0 好友数永久缓存(fcount)+ 后台增量任务。1.1.0 新增 dashAggregate 深度大盘聚合、游玩快照、私密游戏库标记、等级任务含本人。 // @author SmallFork & SmallRob // @license MIT // ==/UserScript== /** * window.SteamSocialCore * apiVersion 1 * * 不查询 #blotter_page,不依赖动态插件的 CSS。 * init 不会启动全员等级、全员游戏库。好友数由界面在打开大盘后调用增量任务,只补缓存里没有的人。 */ (function (root) { 'use strict'; const VERSION = '2.2.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 FCOUNT_CONCURRENCY = 2; const FCOUNT_STALE_MS = 7 * 86400000; const FCOUNT_STALE_BATCH = 20; 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; // 游戏库缓存的版本号:每次 putGames 自增,用来给「游戏拥有数排行」聚合结果做记忆化失效 let libVersion = 0; let ownershipMemo = 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), }; const fcountJob = { running: false, paused: false, done: 0, total: 0, failed: 0, pending: [], inflight: Object.create(null), limit: FCOUNT_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, total: item.map.size })) }; 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, // total = 这个人的库规模,界面用它算「共同游戏占 TA 库的百分之几」 people: maps.map((item) => ({ steamid: item.steamid, name: item.name, total: item.map.size })), }; } 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; } // 好友总数永久缓存:不设 TTL。增量任务只补缺失,并每轮顺延刷新最旧的一小批。 function writeFriendCount(steamId, record) { if (!steamId || !record) return; const map = get('fcount') || {}; const next = { n: record.private ? 0 : (Number(record.friendCount) || 0), p: record.private ? 1 : 0, t: record.updatedAt || Date.now(), }; const prev = map[steamId]; if (prev && prev.n === next.n && prev.p === next.p && prev.t === next.t) return; map[steamId] = next; set('fcount', map); } function seedFriendCounts() { const roster = loadRoster(); if (!roster || !Array.isArray(roster.friends)) return get('fcount') || {}; const friends = roster.friends; const map = get('fcount') || {}; let dirty = false; const alive = Object.create(null); friends.forEach((friend) => { const id = friend.steamid; alive[id] = 1; if (map[id]) return; const mutual = getMutual(id); if (!mutual) return; map[id] = { n: mutual.private ? 0 : (Number(mutual.friendCount) || 0), p: mutual.private ? 1 : 0, t: mutual.updatedAt || Date.now(), }; dirty = true; }); Object.keys(map).forEach((id) => { if (!alive[id]) { delete map[id]; dirty = true; } }); if (dirty) set('fcount', map); return map; } function friendCountCoverage() { const roster = loadRoster(); const friends = (roster && roster.friends) || []; const map = get('fcount') || {}; let known = 0; friends.forEach((friend) => { if (map[friend.steamid]) known++; }); return { known, total: friends.length }; } async function fetchMutual(friendSteamId, force) { const cached = getMutual(friendSteamId); if (!force && cached && Date.now() - cached.updatedAt < MUTUAL_TTL) { writeFriendCount(friendSteamId, cached); 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); writeFriendCount(friendSteamId, record); return record; } function friendCountJobState() { const cov = friendCountCoverage(); return { running: fcountJob.running, paused: fcountJob.paused, done: fcountJob.done, total: fcountJob.total, failed: fcountJob.failed, known: cov.known, rosterTotal: cov.total, }; } function emitFcount(phase) { if (typeof fcountJob.onProgress !== 'function') return; try { fcountJob.onProgress(Object.assign({ phase }, friendCountJobState())); } catch (e) { /* 忽略界面错误 */ } } function friendCountTargets() { seedFriendCounts(); const roster = loadRoster(); const friends = (roster && roster.friends) || []; const map = get('fcount') || {}; const missing = []; const stale = []; const now = Date.now(); friends.forEach((friend) => { const hit = map[friend.steamid]; if (!hit) missing.push(friend.steamid); else if (!hit.t || now - hit.t >= FCOUNT_STALE_MS) stale.push({ id: friend.steamid, t: hit.t || 0 }); }); stale.sort((a, b) => a.t - b.t); return missing.concat(stale.slice(0, FCOUNT_STALE_BATCH).map((row) => row.id)); } async function countOne(id, gen) { fcountJob.inflight[id] = 1; try { await fetchMutual(id, true); if (gen !== fcountJob.gen) return; fcountJob.done++; emitFcount('tick'); } catch (e) { if (gen !== fcountJob.gen) return; const tries = (fcountJob.attempts[id] || 0) + 1; fcountJob.attempts[id] = tries; if ((e && e.status) === 429 && tries <= 3) { fcountJob.limit = Math.max(1, Math.floor(fcountJob.limit / 2)); fcountJob.pending.unshift(id); await sleep(1200 * tries); return; } fcountJob.failed++; fcountJob.done++; emitFcount('tick'); } finally { delete fcountJob.inflight[id]; } if (gen === fcountJob.gen) await sleep(180); } async function runFriendCount(gen) { const active = new Set(); while (gen === fcountJob.gen) { while (!fcountJob.paused && active.size < fcountJob.limit && fcountJob.pending.length && gen === fcountJob.gen) { const id = fcountJob.pending.shift(); const task = countOne(id, gen).finally(() => active.delete(task)); active.add(task); } if (!fcountJob.pending.length && active.size === 0) break; if (active.size) await Promise.race(Array.from(active)); else await sleep(120); } if (gen !== fcountJob.gen) return; fcountJob.running = false; fcountJob.paused = false; emitFcount('done'); } function startFriendCountJob(onProgress) { if (typeof onProgress === 'function') fcountJob.onProgress = onProgress; if (!hasKey()) return friendCountJobState(); if (fcountJob.running) { const queued = Object.create(null); fcountJob.pending.forEach((id) => { queued[id] = 1; }); Object.keys(fcountJob.inflight).forEach((id) => { queued[id] = 1; }); friendCountTargets().forEach((id) => { if (!queued[id]) fcountJob.pending.push(id); }); fcountJob.total = fcountJob.done + fcountJob.pending.length + Object.keys(fcountJob.inflight).length; return friendCountJobState(); } const pending = friendCountTargets().filter((id) => !fcountJob.inflight[id]); fcountJob.pending = pending; fcountJob.done = 0; fcountJob.total = pending.length; fcountJob.failed = 0; fcountJob.paused = false; fcountJob.limit = FCOUNT_CONCURRENCY; fcountJob.attempts = Object.create(null); fcountJob.running = pending.length > 0; if (!pending.length) { emitFcount('done'); return friendCountJobState(); } runFriendCount(++fcountJob.gen); return friendCountJobState(); } function pauseFriendCountJob() { if (!fcountJob.running) return friendCountJobState(); fcountJob.paused = true; fcountJob.gen++; fcountJob.running = false; fcountJob.pending = []; emitFcount('pause'); return friendCountJobState(); } 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 : []; libVersion++; 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, avatar: friend.avatar || '', 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; const DASH_FCOUNT_TOP = 12; 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; })); seedFriendCounts(); const counts = get('fcount') || {}; const friendsRank = []; friends.forEach((friend) => { const hit = counts[friend.steamid]; if (!hit || hit.p || !(hit.n > 0)) return; friendsRank.push({ steamid: friend.steamid, name: friend.personaname || friend.steamid, avatar: friend.avatar || '', count: hit.n, }); }); 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_FCOUNT_TOP), generatedAt: now, }; } // ==================== 玩家画像引擎(v2.0.0)==================== // // 口径移植自 data-hub/src/profile.js(规范见 docs/private-database-design.md §4.10)。 // 浏览器端与库端的三处差异——都写在输出的 basis 字段里,不藏: // ① 时长证据:库端要从多个快照源里挑「有时长证据」的子集;浏览器端 GetOwnedGames // 是 Steam 权威单源,库内条目天然带时长 → 分母 = 游戏条目数(类型未知时按游戏算)。 // ② 消费指数:库端用 spend 表(真实消费流水);浏览器端拿不到流水,改用 // 「库存挂牌原价合计」做 proxy → 分数口径不同,标 proxy。 // ③ 被冷落的神作:库端好评率来自 appdetails 全量快照;浏览器端按需拉 appreviews, // 只给吃灰榜头部补全 → 仍是「已覆盖部分的榜单」,并给出覆盖率。 // // 三条总纪律(与库端一致): // ① 时长证据不足时返回 null + reason,绝不把「数据缺失」当成「一次没玩」; // ② 价格类指标用当前挂牌价(proxy),不是历史实付价; // ③ 时间基准是快照时刻,不是墙上时钟。 const PLAYTIME_TIERS = [ { key: 'unopened', label: '原封不动', emoji: '📦', minMin: 0, maxMin: 0, hint: '0 小时 · 买来没打开过' }, { key: 'unsealed', label: '拆了个封', emoji: '🔧', minMin: 1, maxMin: 59, hint: '不足 1 小时 · 开了没玩透' }, { key: 'dipped', label: '试了下水', emoji: '💧', minMin: 60, maxMin: 299, hint: '1 ~ 5 小时 · 试过味道' }, { key: 'serious', label: '认真谈过', emoji: '🤝', minMin: 300, maxMin: 1199, hint: '5 ~ 20 小时 · 认真投入过' }, { key: 'regular', label: '正经玩家', emoji: '🎯', minMin: 1200, maxMin: 5999, hint: '20 ~ 100 小时 · 常规操作' }, { key: 'resident', label: '过日子', emoji: '🏠', minMin: 6000, maxMin: 29999, hint: '100 ~ 500 小时 · 长线常驻' }, { key: 'immersed', label: '住进去了', emoji: '🛋️', minMin: 30000, maxMin: Infinity, hint: '500 小时以上 · 彻底住下' }, ]; /** 稀有度分档(行为极值分,不是人群百分位) */ const RARITY_TIERS = [ { key: 'SSR', min: 85, label: '传说' }, { key: 'SR', min: 70, label: '史诗' }, { key: 'R', min: 55, label: '稀有' }, { key: 'N', min: 0, label: '普通' }, ]; /** 画像 5 维:行为维(囤货/深度/活跃)决定称号与稀有度;规模维与钱包维只做参考标签 */ const PERSONA_DIMENSIONS = [ { key: 'hoarding', label: '囤货指数', kind: 'behavior' }, { key: 'depth', label: '深度指数', kind: 'behavior' }, { key: 'activity', label: '活跃指数', kind: 'behavior' }, { key: 'breadth', label: '广度指数', kind: 'scale' }, { key: 'spend', label: '消费指数', kind: 'wallet' }, ]; /** 广度指数锚点:库规模 → 0~100 */ const BREADTH_ANCHORS = [[30, 10], [100, 30], [300, 50], [1000, 70], [3000, 85], [10000, 100]]; /** 消费指数锚点:金额 → 0~100(浏览器端用库存原价合计做 proxy) */ const SPEND_ANCHORS = [[500, 10], [2000, 30], [5000, 50], [15000, 70], [40000, 85], [100000, 100]]; const PROFILE_WINDOW_DAYS = 14; const PROFILE_ACTIVITY_FULL_HOURS = 40; const NEGLECTED_RATING_MIN = 90; const NEGLECTED_REVIEWS_MIN = 1000; const PROFILE_TOP_N = 15; const PRICE_TTL = 24 * 60 * 60 * 1000; const PRICE_BATCH = 100; const PRICE_CONCURRENCY = 3; const REVIEW_TTL = 7 * 24 * 60 * 60 * 1000; const REVIEW_CONCURRENCY = 4; const REVIEW_CAP = 60; function r1(v) { return Math.round(Number(v) * 10) / 10; } function r2(v) { return Math.round(Number(v) * 100) / 100; } function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); } /** 中位数(线性插值;输入需已排序) */ function medianOf(sorted) { if (!sorted.length) return null; if (sorted.length === 1) return sorted[0]; const idx = (sorted.length - 1) * 0.5; const lo = Math.floor(idx); const hi = Math.ceil(idx); if (lo === hi) return sorted[lo]; return sorted[lo] + (sorted[hi] - sorted[lo]) * (idx - lo); } /** 分段线性插值(log10 轴);anchors = [[x, score], ...] 按 x 递增 */ function logAnchor(value, anchors) { if (!(value > 0)) return anchors[0][1]; const lv = Math.log10(value); for (let i = 0; i < anchors.length - 1; i++) { const x0 = anchors[i][0]; const s0 = anchors[i][1]; const x1 = anchors[i + 1][0]; const s1 = anchors[i + 1][1]; if (lv >= Math.log10(x0) && lv <= Math.log10(x1)) { const t = (lv - Math.log10(x0)) / (Math.log10(x1) - Math.log10(x0)); return s0 + (s1 - s0) * t; } } return value <= anchors[0][0] ? anchors[0][1] : anchors[anchors.length - 1][1]; } // ------------------------- 价格层 ------------------------- // IStoreBrowseService/GetItems:100 appid/批、匿名可用,一次拿到名称/现价/原价/类型。 // 缓存键 'prices' → { [appid]: { n, f, o, d, t, fr, ts } }(n=名称 f=现价 o=原价 d=折扣 t=类型 fr=是否免费) const priceJob = { running: false, paused: false, done: 0, total: 0, failed: 0, pending: [], limit: PRICE_CONCURRENCY, onProgress: null, gen: 0, }; const reviewJob = { running: false, paused: false, done: 0, total: 0, failed: 0, pending: [], limit: REVIEW_CONCURRENCY, onProgress: null, gen: 0, }; function priceMap() { return get('prices') || {}; } function priceOf(appid) { const hit = priceMap()[String(appid)]; return hit || null; } function putPrices(patch) { const map = priceMap(); let changed = false; Object.keys(patch || {}).forEach((key) => { map[key] = patch[key]; changed = true; }); if (changed) set('prices', map); return map; } function priceCoverage(appids) { const map = priceMap(); let priced = 0; let total = 0; (appids || []).forEach((appid) => { total++; const hit = map[String(appid)]; if (hit && (hit.o > 0 || hit.fr)) priced++; }); return { priced, total, pct: total ? r1((priced / total) * 100) : 0 }; } /** 需要拉价的 appid(缺缓存或过期) */ function priceTargets(steamId) { const record = peekGames(steamId); if (!record) return []; const map = priceMap(); const now = Date.now(); const out = []; const seen = Object.create(null); (record.games || []).forEach((game) => { const key = String(game.appid); if (seen[key]) return; seen[key] = 1; const hit = map[key]; if (hit && (now - (hit.ts || 0)) < PRICE_TTL) return; out.push(Number(game.appid)); }); return out; } /** 解析 GetItems 单个 store_item */ function parseStoreItem(item) { if (!item || !item.appid) return null; const bpo = item.best_purchase_option || null; let final = 0; let original = 0; let discount = 0; if (bpo) { final = (Number(bpo.final_price_in_cents) || 0) / 100; original = (Number(bpo.original_price_in_cents) || 0) / 100; discount = Number(bpo.discount_pct || bpo.bundle_discount_pct || 0); if (!isFinite(final) || final < 0) final = 0; if (!isFinite(original) || original < 0) original = 0; if (!isFinite(discount) || discount < 0 || discount > 100) discount = 0; if (discount > 0 && discount < 100 && original <= final) original = final / (1 - discount / 100); if (original <= 0) original = final; } const isFree = item.is_free === true || (final === 0 && discount === 0); return { n: item.name ? String(item.name) : '', f: r2(final), o: r2(original), d: discount, t: typeof item.type === 'number' ? item.type : null, fr: isFree, ts: Date.now(), }; } /** 拉一批价格(100/批) */ async function fetchPriceBatch(appids) { const input = { ids: appids.map((id) => ({ appid: Number(id) })), context: { language: 'schinese', country_code: 'cn', steam_realm: 1 }, data_request: { include_release: false, include_all_purchase_options: true }, }; const url = 'https://api.steampowered.com/IStoreBrowseService/GetItems/v1?input_json=' + encodeURIComponent(JSON.stringify(input)); const resp = await fetchJson(url, 20000); const items = (resp && resp.response && resp.response.store_items) || []; const patch = {}; items.forEach((item) => { if (!item || !item.appid) return; if (item.success !== undefined && item.success !== 1 && item.success !== true) return; const parsed = parseStoreItem(item); if (parsed) patch[String(item.appid)] = parsed; }); if (Object.keys(patch).length) putPrices(patch); return Object.keys(patch).length; } function emitPrice(phase) { if (priceJob.onProgress) { try { priceJob.onProgress(priceJobState(), phase); } catch (e) { /* 进度回调失败不阻断 */ } } } function priceJobState() { return { running: priceJob.running, paused: priceJob.paused, done: priceJob.done, total: priceJob.total, failed: priceJob.failed, pending: priceJob.pending.length, }; } async function priceOne(chunk, gen) { if (gen !== priceJob.gen) return; try { await fetchPriceBatch(chunk); } catch (e) { priceJob.failed += chunk.length; } priceJob.done += chunk.length; if (gen === priceJob.gen) emitPrice('progress'); } async function runPrice(gen) { while (priceJob.pending.length && gen === priceJob.gen) { const chunk = priceJob.pending.shift(); await priceOne(chunk, gen); } if (gen !== priceJob.gen) return; priceJob.running = false; emitPrice('done'); } /** 开始补价格(用户动作,不在打开时自动跑) */ function startPriceJob(steamId, onProgress) { const targets = priceTargets(steamId); if (!targets.length) { priceJob.onProgress = onProgress || null; priceJobState(); if (priceJob.onProgress) priceJob.onProgress(priceJobState(), 'done'); return priceJobState(); } const chunks = []; for (let i = 0; i < targets.length; i += PRICE_BATCH) { chunks.push(targets.slice(i, i + PRICE_BATCH)); } priceJob.running = true; priceJob.paused = false; priceJob.done = 0; priceJob.failed = 0; priceJob.total = targets.length; priceJob.pending = chunks; priceJob.onProgress = onProgress || null; emitPrice('start'); runPrice(++priceJob.gen); return priceJobState(); } function pausePriceJob() { if (!priceJob.running) return priceJobState(); priceJob.paused = true; priceJob.gen++; priceJob.running = false; priceJob.pending = []; emitPrice('pause'); return priceJobState(); } // ------------------------- 评测层 ------------------------- // store.steampowered.com/appreviews/?json=1 —— 拿好评率与评测总量。 // 只给吃灰榜头部补全(全库逐个拉太重),因此榜单永远是「已覆盖部分的榜单」。 function reviewMap() { return get('reviews') || {}; } function reviewOf(appid) { const hit = reviewMap()[String(appid)]; return hit || null; } function putReviews(patch) { const map = reviewMap(); let changed = false; Object.keys(patch || {}).forEach((key) => { map[key] = patch[key]; changed = true; }); if (changed) set('reviews', map); return map; } async function fetchReviewOne(appid) { const url = 'https://store.steampowered.com/appreviews/' + appid + '?json=1&language=all&purchase_type=all&num_per_page=0&filter=summary'; const resp = await fetchJson(url, 15000); const summary = (resp && resp.query_summary) || null; if (!summary) return null; const positive = Number(summary.total_positive) || 0; const negative = Number(summary.total_negative) || 0; const total = positive + negative; if (!total) return null; return { r: Math.round((positive / total) * 1000) / 10, c: Number(summary.total_reviews) || total, ts: Date.now(), }; } function emitReview(phase) { if (reviewJob.onProgress) { try { reviewJob.onProgress(reviewJobState(), phase); } catch (e) { /* 进度回调失败不阻断 */ } } } function reviewJobState() { return { running: reviewJob.running, paused: reviewJob.paused, done: reviewJob.done, total: reviewJob.total, failed: reviewJob.failed, pending: reviewJob.pending.length, }; } async function reviewOne(appid, gen) { if (gen !== reviewJob.gen) return; try { const hit = await fetchReviewOne(appid); if (hit) putReviews({ [String(appid)]: hit }); else reviewJob.failed++; } catch (e) { reviewJob.failed++; } reviewJob.done++; if (gen === reviewJob.gen) emitReview('progress'); } async function runReview(gen) { while (reviewJob.pending.length && gen === reviewJob.gen) { const batch = reviewJob.pending.splice(0, reviewJob.limit); await Promise.all(batch.map((appid) => reviewOne(appid, gen))); } if (gen !== reviewJob.gen) return; reviewJob.running = false; emitReview('done'); } /** 评测缓存是否新鲜 */ function reviewFresh(appid) { const hit = reviewOf(appid); if (!hit) return false; return (Date.now() - (hit.ts || 0)) < REVIEW_TTL; } /** 补评测(默认只补吃灰榜头部 REVIEW_CAP 条) */ function startReviewJob(appids, onProgress, cap) { const limit = Math.max(1, cap || REVIEW_CAP); const map = reviewMap(); const now = Date.now(); const todo = []; const seen = Object.create(null); (appids || []).forEach((appid) => { const key = String(appid); if (seen[key]) return; seen[key] = 1; const hit = map[key]; if (hit && (now - (hit.ts || 0)) < REVIEW_TTL) return; todo.push(Number(appid)); if (todo.length >= limit) return; }); if (!todo.length) { reviewJob.onProgress = onProgress || null; if (reviewJob.onProgress) reviewJob.onProgress(reviewJobState(), 'done'); return reviewJobState(); } reviewJob.running = true; reviewJob.paused = false; reviewJob.done = 0; reviewJob.failed = 0; reviewJob.total = todo.length; reviewJob.pending = todo; reviewJob.onProgress = onProgress || null; emitReview('start'); runReview(++reviewJob.gen); return reviewJobState(); } function pauseReviewJob() { if (!reviewJob.running) return reviewJobState(); reviewJob.paused = true; reviewJob.gen++; reviewJob.running = false; reviewJob.pending = []; emitReview('pause'); return reviewJobState(); } // ------------------------- 画像聚合(纯读缓存,不发请求)------------------------- /** * 库基线:把 games: 缓存 + 价格缓存拼成画像引擎的输入。 * @returns {object|null} null = 这个人还没有游戏库缓存 */ function profileBaseline(steamId) { const record = peekGames(steamId); if (!record) return null; const prices = priceMap(); const entries = record.games || []; const rows = []; let typedKnown = 0; let freeGames = 0; let minutes = 0; let minutes2weeks = 0; let has2weeks = 0; let played = 0; let neverPlayed = 0; entries.forEach((game) => { const price = prices[String(game.appid)] || null; const type = price && price.t != null ? price.t : null; if (type != null) typedKnown++; // 类型未知时按「游戏」处理(宁可多算一条,也不能凭空少一条) const isGame = type == null ? true : (type === 0 || type === 4); const row = { appid: Number(game.appid), name: (price && price.n) || game.name || ('App ' + game.appid), minutes: Number(game.playtime_forever) || 0, minutes2w: Number(game.playtime_2weeks) || 0, lastPlayed: Number(game.rtime_last_played) || 0, icon: game.img_icon_url || '', type, isGame, priceFinal: price ? price.f : null, priceOriginal: price ? price.o : null, isFree: price ? !!price.fr : null, }; rows.push(row); if (!isGame) return; if (row.isFree) freeGames++; minutes += row.minutes; if (row.minutes2w > 0) { minutes2weeks += row.minutes2w; has2weeks++; } if (row.minutes > 0) played++; else neverPlayed++; }); const gameRows = rows.filter((row) => row.isGame); return { steamid: steamId, name: friendName(steamId), asOf: record.updatedAt || Date.now(), ownedEntries: entries.length, gameRows, typedKnown, typeCoveragePct: entries.length ? r1((typedKnown / entries.length) * 100) : 0, minutes, minutes2weeks, has2weeks, played, neverPlayed, freeGames, hours: r1(minutes / 60), }; } /** P2 时长阶梯:只统计游戏条目,分母 = 有效条目数 */ function computeLadder(baseline) { const buckets = PLAYTIME_TIERS.map(() => ({ count: 0, minutes: 0 })); baseline.gameRows.forEach((row) => { for (let i = 0; i < PLAYTIME_TIERS.length; i++) { const tier = PLAYTIME_TIERS[i]; if (row.minutes >= tier.minMin && (tier.maxMin === Infinity || row.minutes <= tier.maxMin)) { buckets[i].count++; buckets[i].minutes += row.minutes; break; } } }); const denom = baseline.gameRows.length || 1; const tiers = PLAYTIME_TIERS.map((tier, i) => ({ key: tier.key, label: tier.label, emoji: tier.emoji, hint: tier.hint, minMinutes: tier.minMin, maxMinutes: tier.maxMin === Infinity ? null : tier.maxMin, count: buckets[i].count, pct: r1((buckets[i].count / denom) * 100), hours: r1(buckets[i].minutes / 60), })); const never = tiers[0].count; return { tiers, totalGames: baseline.gameRows.length, ownedEntries: baseline.ownedEntries, measuredGames: baseline.gameRows.length, unknownGames: 0, totalHours: baseline.hours, neverPlayed: never, neverPlayedPct: r1((never / denom) * 100), openedEver: baseline.played, openedEverPct: r1((baseline.played / denom) * 100), basis: '分母 = 库内 ' + baseline.gameRows.length + ' 款游戏条目' + (baseline.typedKnown < baseline.ownedEntries ? '(总条目 ' + baseline.ownedEntries + ',已识别类型 ' + baseline.typedKnown + ',DLC 等已剔除)' : '') + ';Steam 的 GetOwnedGames 是权威单源,库内条目天然带时长证据,不存在「时长未知」档', evidence: baseline.gameRows.length ? 'complete' : 'missing', }; } /** 总体库存概览:原价 / 现价 / 每小时价值 */ function computeStock(baseline) { let originalSum = 0; let currentSum = 0; let priced = 0; let freeGames = 0; baseline.gameRows.forEach((row) => { if (row.priceOriginal != null && row.priceOriginal > 0) { originalSum += row.priceOriginal; priced++; } if (row.priceFinal != null && row.priceFinal > 0) currentSum += row.priceFinal; if (row.isFree) freeGames++; }); const total = baseline.gameRows.length || 0; const originalTotal = r2(originalSum); const hours = baseline.minutes / 60; const coveragePct = total ? r1((priced / total) * 100) : 0; return { stockValueCny: originalTotal, stockValueBasis: '库内游戏「当前挂牌原价」之和(proxy,非历史实付价)', pricedGames: priced, pricedCoveragePct: coveragePct, currentValueCny: r2(currentSum), freeGames, totalHours: r1(hours), valuePerHourCny: hours > 0 ? r2(originalTotal / hours) : null, valuePerHourBasis: coveragePct < 100 ? '分母含全部 ' + total + ' 款游戏时长,分子只覆盖 ' + priced + ' 款(' + coveragePct + '%)—— 是**下界**' : '全部在库游戏均有原价记录', }; } /** P3 清灰倒计时:三级降级 exact → estimated → unavailable */ function computeCountdown(baseline) { const windowDays = PROFILE_WINDOW_DAYS; const asOf = baseline.asOf; const out = { windowDays, asOf: new Date(asOf).toISOString(), status: 'unavailable', method: null, confidence: null, dailyMinutes: null, demandHours: null, remainingHours: null, remainingGames: baseline.neverPlayed, etaDays: null, etaDate: null, hoursPerUnopenedGame: null, demandCoveragePct: null, reason: null, }; if (!baseline.gameRows.length) { out.reason = '库为空'; return out; } if (!baseline.neverPlayed) { out.status = 'done'; out.reason = '库内游戏全部都打开过,没有待清的灰'; return out; } // ── 需求侧 D ── 浏览器端没有 HLTB 主线时长,用「已玩游戏时长中位数」兜底 const playedHours = baseline.gameRows .filter((row) => row.minutes > 0) .map((row) => row.minutes / 60) .sort((a, b) => a - b); const medianPlayed = medianOf(playedHours); const fallbackHours = medianPlayed && medianPlayed > 0 ? medianPlayed : 10; const demandHours = baseline.neverPlayed * fallbackHours; out.demandHours = r1(demandHours); out.hoursPerUnopenedGame = r1(demandHours / Math.max(1, baseline.neverPlayed)); out.demandBasis = '浏览器端没有 HLTB 主线时长 —— 需求侧按「已玩游戏的时长中位数 × 未开封款数」估算'; // ── 速度侧 v ── let dailyMinutes = null; const ratio2w = baseline.gameRows.length ? baseline.has2weeks / baseline.gameRows.length : 0; if (baseline.minutes2weeks > 0 && ratio2w >= 0.5) { dailyMinutes = baseline.minutes2weeks / windowDays; out.status = 'exact'; out.method = 'playtime_2weeks'; out.confidence = 'high'; } else { const cutoff = asOf - windowDays * DAY_MS; const active = baseline.gameRows.filter((row) => row.lastPlayed > 0 && row.lastPlayed * 1000 >= cutoff); if (active.length > 0 && baseline.minutes > 0) { dailyMinutes = baseline.minutes / windowDays; out.status = 'estimated'; out.method = 'last_played_window'; out.confidence = 'low'; out.reason = '快照前 ' + windowDays + ' 天有 ' + active.length + ' 款玩过,但 playtime_2weeks 覆盖仅 ' + r1(ratio2w * 100) + '% —— 用「总时长 ÷ 窗口天数」估算,**偏乐观**'; } else { out.reason = '缺 playtime_2weeks(覆盖 ' + r1(ratio2w * 100) + '%)且快照前 ' + windowDays + ' 天无 last_played 记录 —— 无法估算速度'; out.status = 'unavailable'; return out; } } out.dailyMinutes = r1(dailyMinutes); if (!dailyMinutes || dailyMinutes <= 0) { out.status = 'unavailable'; out.reason = '速度为 0 —— 无法推算倒计时'; return out; } const dailyHours = dailyMinutes / 60; const etaDays = demandHours / dailyHours; out.etaDays = Math.round(etaDays); out.etaDate = new Date(asOf + etaDays * DAY_MS).toISOString().slice(0, 10); out.remainingHours = r1(demandHours); if (out.status === 'exact') out.reason = null; return out; } /** P4 被冷落的神作:未开封 ∧ 好评率 ≥ 90 ∧ 评测数 ≥ 1000 */ function computeNeglected(baseline, limit) { const cap = limit || 60; let rated = 0; const hits = []; baseline.gameRows.forEach((row) => { const review = reviewOf(row.appid); if (!review) return; rated++; if (row.minutes > 0) return; if (review.r < NEGLECTED_RATING_MIN) return; if (review.c < NEGLECTED_REVIEWS_MIN) return; hits.push({ row, review }); }); hits.sort((a, b) => (b.review.r - a.review.r) || (b.review.c - a.review.c)); const total = baseline.gameRows.length || 0; return { criteria: { ratingMin: NEGLECTED_RATING_MIN, reviewCountMin: NEGLECTED_REVIEWS_MIN, mustBeNeverPlayed: true, }, count: hits.length, shownCount: Math.min(hits.length, cap), truncated: hits.length > cap, items: hits.slice(0, cap).map((hit) => ({ appid: hit.row.appid, name: hit.row.name, rating: hit.review.r, reviewCount: hit.review.c, priceCny: hit.row.priceFinal, originalPriceCny: hit.row.priceOriginal, icon: hit.row.icon, })), ratingCoveragePct: total ? r1((rated / total) * 100) : 0, ratingCoverageNote: total ? '好评率按需拉 appreviews,库内仅 ' + rated + '/' + total + ' 款有值(' + r1((rated / total) * 100) + '%)—— 本榜是**已覆盖部分的榜单**,不是全库榜单' : '库为空', }; } /** P5 最亏 / 最值 / 沉默库存(cost per hour) */ function computeCph(baseline, limit) { const topN = limit || PROFILE_TOP_N; const played = []; const silent = []; baseline.gameRows.forEach((row) => { const price = row.priceFinal != null && row.priceFinal > 0 ? row.priceFinal : null; const original = row.priceOriginal != null && row.priceOriginal > 0 ? row.priceOriginal : null; if (row.minutes > 0) { if (price == null) return; played.push({ appid: row.appid, name: row.name, priceCny: price, originalPriceCny: original, playtimeMinutes: row.minutes, playtimeHours: r1(row.minutes / 60), costPerHourCny: r2(price / (row.minutes / 60)), icon: row.icon, }); return; } if (original == null) return; silent.push({ appid: row.appid, name: row.name, priceCny: price, originalPriceCny: original, playtimeMinutes: 0, playtimeHours: 0, costPerHourCny: null, icon: row.icon, }); }); played.sort((a, b) => b.costPerHourCny - a.costPerHourCny); silent.sort((a, b) => (b.originalPriceCny || 0) - (a.originalPriceCny || 0)); return { basis: 'cph = 当前折后价 ÷ (playtime_min ÷ 60),价格是**当前挂牌价**(proxy,非实付价)', worst: played.slice(0, topN), best: played.slice(-topN).reverse(), silentStock: silent.slice(0, topN), silentStockCount: silent.length, silentStockValueCny: r2(silent.reduce((sum, row) => sum + (row.originalPriceCny || 0), 0)), pricedPlayedGames: played.length, }; } /** P7 五维人格 */ function scoreHoarding(ladder) { if (ladder.evidence === 'missing') { return { score: null, personaTag: '无数据', emoji: '❔', desc: '库内没有游玩时长数据,无法判定囤货率', detail: '有时长证据的游戏 0 款 —— 不把「数据缺失」当成「一次没玩」', }; } const pct = ladder.neverPlayedPct || 0; let personaTag; let emoji; let desc; if (pct >= 60) { personaTag = '电子囤货王'; emoji = '📦'; desc = '未开封率 ' + pct + '%,仓库比游戏库更壮观'; } else if (pct >= 35) { personaTag = '收藏囤积者'; emoji = '🗃️'; desc = '未开封率 ' + pct + '%,买得多玩得少'; } else if (pct >= 15) { personaTag = '均衡型玩家'; emoji = '⚖️'; desc = '未开封率 ' + pct + '%,买与玩大致匹配'; } else { personaTag = '行动派'; emoji = '⚡'; desc = '未开封率 ' + pct + '%,几乎买了就玩'; } return { score: Math.round(clamp(pct, 0, 100)), personaTag, emoji, desc, detail: '未开封 ' + ladder.neverPlayed + '/' + ladder.measuredGames + '(有效条目)', }; } function scoreDepth(baseline) { const rows = baseline.gameRows .filter((row) => row.minutes > 0) .map((row) => row.minutes) .sort((a, b) => b - a); const total = rows.reduce((sum, value) => sum + value, 0); if (!rows.length || !total) { return { score: null, personaTag: '无数据', emoji: '❔', desc: '没有可用的游玩时长', detail: '时长样本为 0' }; } if (rows.length < 10) { return { score: null, personaTag: '无数据', emoji: '❔', desc: '玩过的游戏太少,时长集中度不可靠', detail: '只有 ' + rows.length + ' 款游戏有时长(门槛 10 款)—— Top 10% 占比在这个样本下没有意义', }; } const take = Math.max(1, Math.ceil(rows.length * 0.1)); const top = rows.slice(0, take).reduce((sum, value) => sum + value, 0); const concentration = (top / total) * 100; let personaTag; let emoji; let desc; if (concentration >= 60) { personaTag = '沉浸叙事者'; emoji = '🎭'; desc = 'Top 10% 游戏吃掉 ' + r1(concentration) + '% 的时长'; } else if (concentration >= 35) { personaTag = '均衡体验派'; emoji = '🧭'; desc = 'Top 10% 游戏占 ' + r1(concentration) + '% 时长'; } else { personaTag = '广撒网型'; emoji = '🕸️'; desc = 'Top 10% 游戏只占 ' + r1(concentration) + '% 时长'; } return { score: Math.round(clamp(concentration, 0, 100)), personaTag, emoji, desc, detail: 'Top ' + take + '/' + rows.length + ' 款占 ' + r1(concentration) + '%,总时长 ' + r1(total / 60) + 'h', }; } function scoreBreadth(baseline) { const games = baseline.gameRows.length || 0; if (!games) return { score: null, personaTag: '无数据', emoji: '❔', desc: '库为空', detail: '0 款' }; const score = Math.round(clamp(logAnchor(games, BREADTH_ANCHORS), 0, 100)); let personaTag; let emoji; let desc; if (score >= 80) { personaTag = '万机阁主'; emoji = '🏛️'; desc = '库里有 ' + games + ' 款游戏'; } else if (score >= 50) { personaTag = '藏书家'; emoji = '📚'; desc = '库里有 ' + games + ' 款游戏'; } else { personaTag = '精选党'; emoji = '💎'; desc = '库里有 ' + games + ' 款游戏'; } return { score, personaTag, emoji, desc, detail: games + ' 款' }; } function scoreSpend(stock) { if (!stock.pricedGames) { return { score: null, personaTag: '无数据', emoji: '❔', desc: '还没有价格数据', detail: '补齐价格后才有消费指数', }; } const score = Math.round(clamp(logAnchor(stock.stockValueCny, SPEND_ANCHORS), 0, 100)); let personaTag; let emoji; let desc; if (score >= 75) { personaTag = '氪金大佬'; emoji = '🐋'; desc = '库存挂牌原价 ¥' + r2(stock.stockValueCny); } else if (score >= 45) { personaTag = '精打细算'; emoji = '💰'; desc = '库存挂牌原价 ¥' + r2(stock.stockValueCny); } else { personaTag = '等等党'; emoji = '⏳'; desc = '库存挂牌原价 ¥' + r2(stock.stockValueCny); } return { score, personaTag, emoji, desc, detail: 'proxy:库存挂牌原价合计(非历史实付价)', proxy: true, }; } function scoreActivity(countdown) { if (countdown.status === 'unavailable' || !countdown.dailyMinutes) { return { score: null, personaTag: '无数据', emoji: '❔', desc: '缺近期活跃数据', detail: countdown.reason || '无 playtime_2weeks / last_played', }; } const windowHours = (countdown.dailyMinutes * countdown.windowDays) / 60; const score = Math.round(clamp((windowHours / PROFILE_ACTIVITY_FULL_HOURS) * 100, 0, 100)); let personaTag; let emoji; let desc; if (score >= 70) { personaTag = '现役玩家'; emoji = '🔥'; desc = '近 ' + countdown.windowDays + ' 天投入 ' + r1(windowHours) + ' 小时'; } else if (score >= 30) { personaTag = '间歇上线'; emoji = '🌗'; desc = '近 ' + countdown.windowDays + ' 天投入 ' + r1(windowHours) + ' 小时'; } else { personaTag = '休眠玩家'; emoji = '💤'; desc = '近 ' + countdown.windowDays + ' 天投入 ' + r1(windowHours) + ' 小时'; } return { score, personaTag, emoji, desc, detail: '约 ' + countdown.dailyMinutes + ' 分钟/天(来源 ' + countdown.method + (countdown.status === 'estimated' ? ',估算' : '') + ')', }; } const PERSONA_QUOTES = { 电子囤货王: '「仓库是我的 Steam,游戏只是库存的边角料」', 收藏囤积者: '「打折的时候手比脑子快」', 均衡型玩家: '「买多少玩多少,账算得清」', 行动派: '「买来三分钟内开机」', 沉浸叙事者: '「别人看剧,我通关」', 均衡体验派: '「什么 genre 都来一点」', 广撒网型: '「每个游戏三小时,见面就散」', 万机阁主: '「库的存在本身就是收藏」', 藏书家: '「有点选择困难」', 精选党: '「只留真正想玩的」', 氪金大佬: '「钱只是换了种方式陪在我身边」', 精打细算: '「每一分钱都花在了刀刃上」', 等等党: '「好游戏值得等待,好价格更值得等待」', 现役玩家: '「这周肝到关」', 间歇上线: '「上线看一眼就下」', 休眠玩家: '「库还在,人也还在,就是不玩」', }; function computePersona(baseline, ladder, countdown, stock) { const byKey = { hoarding: Object.assign({ key: 'hoarding', label: '囤货指数', kind: 'behavior' }, scoreHoarding(ladder)), depth: Object.assign({ key: 'depth', label: '深度指数', kind: 'behavior' }, scoreDepth(baseline)), activity: Object.assign({ key: 'activity', label: '活跃指数', kind: 'behavior' }, scoreActivity(countdown)), breadth: Object.assign({ key: 'breadth', label: '广度指数', kind: 'scale' }, scoreBreadth(baseline)), spend: Object.assign({ key: 'spend', label: '消费指数', kind: 'wallet' }, scoreSpend(stock)), }; const dims = PERSONA_DIMENSIONS.map((def) => byKey[def.key]); const behavior = dims.filter((dim) => dim.kind === 'behavior' && dim.score != null); const top = behavior.slice().sort((a, b) => b.score - a.score)[0] || null; const peak = behavior.length ? Math.max.apply(null, behavior.map((dim) => dim.score)) : null; let rarity = null; if (peak !== null) { for (let i = 0; i < RARITY_TIERS.length; i++) { if (peak >= RARITY_TIERS[i].min) { rarity = RARITY_TIERS[i].key; break; } } } const traits = behavior.filter((dim) => dim.score >= 60).map((dim) => dim.personaTag); const missing = dims.filter((dim) => dim.score == null).map((dim) => dim.label); return { title: top ? top.personaTag : '数据不足', subtitle: top ? top.desc : '没有足够的可算数据给出一个称号', emoji: top ? top.emoji : '❔', rarity, rarityLabel: rarity ? (RARITY_TIERS.find((r) => r.key === rarity) || {}).label : null, peakBehaviorScore: peak === null ? null : peak, rarityBasis: '稀有度 = 行为维度最高分分档(≥85 SSR / ≥70 SR / ≥55 R / 其余 N);衡量的是「行为有多极端」,不是人群百分位', scaleTag: byKey.breadth.personaTag || null, scaleScore: byKey.breadth.score == null ? null : byKey.breadth.score, walletTag: byKey.spend.personaTag || null, walletScore: byKey.spend.score == null ? null : byKey.spend.score, scoreBasis: '称号 = 行为维度(囤货/深度/活跃)中的最高分;规模与钱包维度不参与称号竞争', dimensions: dims.map((dim) => ({ key: dim.key, dimension: dim.label, personaTag: dim.personaTag || null, kind: dim.kind, score: dim.score == null ? null : dim.score, desc: dim.desc, detail: dim.detail, proxy: !!dim.proxy, })), traits, missingDimensions: missing, quote: top ? (PERSONA_QUOTES[top.personaTag] || '「游戏人生,乐在其中」') : null, }; } /** 画像总聚合:只读缓存,不发任何请求 */ function profileAggregate(steamId) { const id = steamId || myId(); const baseline = profileBaseline(id); if (!baseline) { return { ok: false, steamid: id, reason: '还没有这个人的游戏库缓存 —— 先在好友排行页补齐索引', generatedAt: Date.now(), }; } const ladder = computeLadder(baseline); const stock = computeStock(baseline); const countdown = computeCountdown(baseline); const neglected = computeNeglected(baseline); const cph = computeCph(baseline); const persona = computePersona(baseline, ladder, countdown, stock); const appids = baseline.gameRows.map((row) => row.appid); return { ok: true, steamid: id, name: baseline.name, generatedAt: Date.now(), asOf: baseline.asOf, baseline: { ownedEntries: baseline.ownedEntries, games: baseline.gameRows.length, typeCoveragePct: baseline.typeCoveragePct, typedKnown: baseline.typedKnown, played: baseline.played, neverPlayed: baseline.neverPlayed, minutes: baseline.minutes, hours: baseline.hours, minutes2weeks: baseline.minutes2weeks, has2weeks: baseline.has2weeks, freeGames: baseline.freeGames, }, ladder, stock, countdown, neglected, cph, persona, coverage: { price: priceCoverage(appids), review: (function () { const map = reviewMap(); let rated = 0; appids.forEach((appid) => { if (map[String(appid)]) rated++; }); return { rated, total: appids.length, pct: appids.length ? r1((rated / appids.length) * 100) : 0 }; }()), }, }; } /** 热力分析:阶梯热力格(每款游戏一格)+ 每日活跃热力(近 14 天) */ function profileHeat(steamId) { const id = steamId || myId(); const baseline = profileBaseline(id); if (!baseline) return { ok: false, cells: [], days: [], series: [], max: 0 }; const tierOf = (minutes) => { for (let i = 0; i < PLAYTIME_TIERS.length; i++) { const tier = PLAYTIME_TIERS[i]; if (minutes >= tier.minMin && (tier.maxMin === Infinity || minutes <= tier.maxMin)) return i; } return 0; }; const cells = baseline.gameRows .map((row) => ({ appid: row.appid, name: row.name, minutes: row.minutes, tier: tierOf(row.minutes), price: row.priceOriginal, })) .sort((a, b) => (a.tier - b.tier) || (b.minutes - a.minutes) || (a.price || 0) - (b.price || 0)); const days = []; const now = Date.now(); 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 series = dashHeatSeries(snaps[id] || [], days); let max = 0; series.forEach((value) => { if (value != null && value > max) max = value; }); return { ok: true, steamid: id, cells, tierMeta: PLAYTIME_TIERS.map((tier, index) => ({ index, key: tier.key, label: tier.label, emoji: tier.emoji, hint: tier.hint })), days, series, max, total: cells.length, }; } /** 吃灰榜(未开封 × 原价降序)—— 画像页的主榜 */ function profileDustRank(steamId, limit) { const id = steamId || myId(); const baseline = profileBaseline(id); if (!baseline) return { ok: false, items: [], totalValueCny: 0, count: 0 }; const items = baseline.gameRows .filter((row) => row.minutes === 0 && row.priceOriginal != null && row.priceOriginal > 0) .map((row) => ({ appid: row.appid, name: row.name, originalPriceCny: row.priceOriginal, priceCny: row.priceFinal, icon: row.icon, review: reviewOf(row.appid) || null, })) .sort((a, b) => b.originalPriceCny - a.originalPriceCny); return { ok: true, items: items.slice(0, limit || PROFILE_TOP_N), count: items.length, totalValueCny: r2(items.reduce((sum, row) => sum + row.originalPriceCny, 0)), }; } /** 可画像对象列表(本人 + 已有游戏库缓存的好友) */ function profileSubjects() { const out = []; const roster = loadRoster(); const own = myId(); if (own) { const ownRow = (roster && roster.own) || null; out.push({ steamid: own, name: (ownRow && ownRow.personaname) || '我', avatar: (ownRow && ownRow.avatar) || '', self: true, indexed: !!peekGames(own), }); } ((roster && roster.friends) || []).forEach((friend) => { out.push({ steamid: friend.steamid, name: friend.personaname || friend.steamid, avatar: friend.avatar || '', self: false, indexed: !!peekGames(friend.steamid), }); }); return out; } /** * 游戏拥有数排行 —— 「排行」的转置视角。 * * 现有 rank(kind) 是**按指标排好友**(谁好友天数多、谁游戏多); * 这里反过来:**按好友拥有数排游戏**(CS2 有多少好友拥有)。 * * ⚠️ 口径与纪律(与本库其他排行一致): * · 范围 = **好友**(不含本人),用户明确要的是「你的好友内」; * · 只统计**已索引游戏库**的好友 → 必须同时给出「已索引 N / M」覆盖率, * 否则「CS2 有 100 人拥有」会被误读成全部好友里的 100 人; * · 每人一款游戏只计一次(seen 去重),重复的 appid 不重复计数; * · 同一游戏名以先遇到的为准(不同好友的库里名字一致)。 * * 纯读缓存,不发请求。聚合量级 = 已索引好友数 × 平均库规模,记忆化后只在索引变化时重算。 */ function gameOwnershipRank(opts) { const options = opts || {}; const minOwners = Math.max(1, Number(options.minOwners) || 2); const limit = Math.max(1, Number(options.limit) || 120); const roster = loadRoster(); const friends = (roster && roster.friends) || []; const rosterAt = (roster && roster.updatedAt) || 0; const memoKey = libVersion + ':' + rosterAt + ':' + minOwners; if (ownershipMemo && ownershipMemo.key === memoKey) return ownershipMemo.result; const byApp = new Map(); let indexed = 0; let totalEntries = 0; friends.forEach((friend) => { const games = peekGames(friend.steamid); if (!games) return; indexed++; const seen = new Set(); const list = games.games || []; totalEntries += list.length; for (let i = 0; i < list.length; i++) { const game = list[i]; const appid = Number(game && game.appid); if (!appid || seen.has(appid)) continue; seen.add(appid); let row = byApp.get(appid); if (!row) { row = { appid, name: '', icon: '', owners: 0, minutes: 0, top: [] }; byApp.set(appid, row); } if (!row.name && game.name) row.name = game.name; if (!row.icon && game.img_icon_url) row.icon = game.img_icon_url; const minutes = Number(game.playtime_forever) || 0; row.owners++; row.minutes += minutes; row.top.push({ steamid: friend.steamid, name: friend.personaname || friend.steamid, avatar: friend.avatar || '', minutes }); } }); const rows = []; byApp.forEach((row) => { if (row.owners < minOwners) return; row.top.sort((a, b) => b.minutes - a.minutes); rows.push({ appid: row.appid, name: row.name || ('App ' + row.appid), icon: row.icon, owners: row.owners, minutes: row.minutes, // 只带前 8 位(头像条预览用);完整拥有者列表用 rankGame(appid) 按需取 top: row.top.slice(0, 8), }); }); rows.sort((a, b) => (b.owners - a.owners) || (b.minutes - a.minutes) || (a.name > b.name ? 1 : -1)); const result = { rows: rows.slice(0, limit), total: rows.length, distinctGames: byApp.size, indexed, friendTotal: friends.length, coveragePct: friends.length ? Math.round((indexed / friends.length) * 1000) / 10 : 0, minOwners, basis: '范围 = 好友(不含本人);只统计已索引游戏库的 ' + indexed + ' / ' + friends.length + ' 位好友', generatedAt: Date.now(), }; ownershipMemo = { key: memoKey, result }; return result; } root.SteamSocialCore = { version: VERSION, apiVersion: API_VERSION, ROSTER_TTL, ready, attach, loadRoster, rosterFresh, refreshRoster, getVac, getLevels, getMutual, fetchMutual, seedFriendCounts, startFriendCountJob, pauseFriendCountJob, friendCountJobState, peekGames, putGames, peekRecent, putRecent, fetchOwned, fetchRecent, kpi, dashAggregate, queryFriends, startVacFill, pauseVacFill, startLevelJob, pauseLevelJob, resumeLevelJob, levelJobState, rank, gameOwnershipRank, comparePair, compareMany, rankGame, searchGames, indexStats, startLibraryIndexJob, pauseLibraryIndexJob, resumeLibraryIndexJob, libraryJobState, friendName, gameIconUrl, // ---- 玩家画像(v2.0.0)---- profileAggregate, profileHeat, profileDustRank, profileSubjects, priceMap, priceOf, priceCoverage, priceTargets, startPriceJob, pausePriceJob, priceJobState, reviewMap, reviewOf, reviewFresh, startReviewJob, pauseReviewJob, reviewJobState, pure: { friendDays, isNewFriend, computeKpi, filterFriends, sortFriends, comparePairLists, compareManyLists, // 画像纯函数(口径见 docs/private-database-design.md §4.10) computeLadder, computeStock, computeCountdown, computeNeglected, computeCph, computePersona, profileBaseline, logAnchor, medianOf, }, tiers: { PLAYTIME_TIERS, RARITY_TIERS, PERSONA_DIMENSIONS, BREADTH_ANCHORS, SPEND_ANCHORS, }, }; })(typeof window !== 'undefined' ? window : globalThis);