>
// 契约:成功时 out[idx] = mapper 返回值,失败时 out[idx] = reason 对象(不抛,调用方走降级)
// 收益:删 ~50 行重复代码;SGLVCore.gmFetchJson/Text 已带 2 次重试 + 状态码校验(v1.0.2 修复过同款 bug)
function httpGet(url, opts) {
return sglvGmFetchTextRetry(url, opts).then(text => ({ status: 200, responseText: text }));
}
function fetchJson(url, opts) { return sglvGmFetchRetry(url, opts); }
function fetchText(url, opts) { return sglvGmFetchTextRetry(url, opts); }
// 委托 SGLVCore.concurrentPool:包装 tasks + 还原"失败塞 reason"契约,调用方不变
function mapLimit(items, limit, mapper) {
const tasks = items.map((item, idx) => () => mapper(item, idx));
return C.concurrentPool(tasks, limit).then(results =>
results.map(r => r.status === 'fulfilled' ? r.value : r.reason)
);
}
// ---- 汇率 ----
function getRates() { try { return GM_getValue('sgis-rates', null); } catch { return null; } }
function getRatesTs() { try { return GM_getValue('sgis-rates-ts', 0); } catch { return 0; } }
function setRates(r) {
try { GM_setValue('sgis-rates', r); GM_setValue('sgis-rates-ts', Date.now()); } catch { /* ignore */ }
}
// v2.9.54 优化: refreshRates 优先委托 sglv-app-detail.getRates() (双源降级 + 1h 内存缓存)
// 库未加载时降级到本地 AugmentedSteam / open.er-api 双源拉取
// rates 表语义: SGIS 内部保持"1 外币 = X CNY"倒数形式 (与 toCNY 算法匹配),
// 库返回的是"1 CNY = X 外币"原值形式, 故此转换
function _convertToSgisRatesFormat(rates) {
const out = { CNY: 1 };
for (const [code, val] of Object.entries(rates || {})) {
if (typeof val === 'number' && val > 0) out[code] = 1 / val;
}
return out;
}
async function refreshRates() {
if (Date.now() - getRatesTs() < 3600000 && getRates()) { SGIS.rateReady = true; return; }
// 优先委托 sglv-app-detail 拉取 (双源降级 + 自动重试 + 状态码校验)
try {
const A = unsafeWindow.SGLVAppDetail;
if (A && typeof A.getRates === 'function') {
const info = await A.getRates();
if (info && info.ready && info.rates) {
setRates(_convertToSgisRatesFormat(info.rates));
SGIS.rateReady = true;
return;
}
}
} catch (e) { /* ignore - fallback below */ }
// 降级路径 1: AugmentedSteam 直拉
try {
const r = await httpGet('https://api.augmentedsteam.com/rates/v1', { timeout: 10000 });
const data = JSON.parse(r.responseText);
if (data?.data?.CNY) {
const cny = data.data.CNY;
const rates = {};
for (const [code, val] of Object.entries(data.data)) {
if (typeof val === 'number' && val > 0) rates[code] = cny / val;
}
setRates(rates); SGIS.rateReady = true; return;
}
} catch (e) { /* ignore */ }
// 降级路径 2: open.er-api 兜底
try {
const r = await httpGet('https://open.er-api.com/v6/latest/CNY', { timeout: 10000 });
const data = JSON.parse(r.responseText);
if (data?.rates) {
const rates = { CNY: 1 };
for (const [code, val] of Object.entries(data.rates)) {
if (typeof val === 'number' && val > 0) rates[code] = 1 / val;
}
setRates(rates); SGIS.rateReady = true;
}
} catch (e) { /* ignore */ }
}
function toCNY(amount, currency) {
if (currency === 'CNY' || currency === 'RMB') return amount;
const rates = getRates();
if (!rates || !rates[currency]) return null;
return amount * rates[currency];
}
// ---- 卡牌价格(整合 card-prices 核心) ----
const MARKET_SEARCH_API = 'https://steamcommunity.com/market/search/render/';
const MARKET_LISTING_PAGE = 'https://steamcommunity.com/market/listings/753/';
const BUY_ORDER_CONCURRENCY = 3;
// v2.9.32: 可变发行商费率手续费计算 (与 steam-badges-card-view 保持一致)
// 替代 v2.9.31 的固定费率 calcfee, 从市场 API 读取每张卡的实际发行商费率
const STEAM_TX_FEE_PERCENT = 0.05; // Steam 交易手续费 5%
const DEFAULT_PUBLISHER_FEE_PERCENT = 0.10; // 默认发行商手续费 10%
// 从市场搜索结果读取发行商手续费比例
// Steam 市场 API 的 asset_description.owner_actions 中包含 publisherFeePercentDbl 参数
function _readPublisherFeeFromItem(item) {
const desc = item && item.asset_description ? item.asset_description : {};
const ownerActions = desc.owner_actions || [];
for (let i = 0; i < ownerActions.length; i++) {
const link = ownerActions[i].link || '';
const m = link.match(/publisherFeePercentDbl=([0-9.]+)/);
if (m) { const v = Number(m[1]); if (Number.isFinite(v) && v >= 0) return v / 100; }
}
return DEFAULT_PUBLISHER_FEE_PERCENT;
}
// 计算单笔费用 (Steam 5% + 发行商 fee, 每项最低 1 分)
function _calcFees(receivedAmount, publisherFee, steamFeePercent) {
const steamFee = Math.max(Math.floor(receivedAmount * steamFeePercent), 1);
const publisherFeeAmount = publisherFee > 0 ? Math.max(Math.floor(receivedAmount * publisherFee), 1) : 0;
return {
steamFee, publisherFee: publisherFeeAmount,
fees: steamFee + publisherFeeAmount,
amount: receivedAmount + steamFee + publisherFeeAmount,
};
}
// 计算卖家到手价 (扣除 Steam 交易手续费 + 发行商手续费)
// 迭代逼近法与 Steam 官方算法一致, 支持可变发行商费率
function _calculateSellerReceives(buyerPaysCents, publisherFeePercent) {
const amount = Math.round(Number(buyerPaysCents));
if (!Number.isFinite(amount) || amount <= 0) return null;
publisherFeePercent = publisherFeePercent == null ? DEFAULT_PUBLISHER_FEE_PERCENT : publisherFeePercent;
const steamFeePercent = STEAM_TX_FEE_PERCENT;
let estimatedReceived = parseInt(amount / (steamFeePercent + publisherFeePercent + 1), 10);
if (!Number.isFinite(estimatedReceived)) estimatedReceived = amount;
estimatedReceived = Math.max(1, estimatedReceived);
let iterations = 0, everUndershot = false;
let fees = _calcFees(estimatedReceived, publisherFeePercent, steamFeePercent);
while (fees.amount !== amount && iterations < 100) {
if (fees.amount > amount) {
if (everUndershot) {
fees = _calcFees(estimatedReceived - 1, publisherFeePercent, steamFeePercent);
fees.steamFee += amount - fees.amount;
fees.fees += amount - fees.amount;
fees.amount = amount;
break;
}
estimatedReceived--;
} else {
everUndershot = true;
estimatedReceived++;
}
estimatedReceived = Math.max(1, estimatedReceived);
fees = _calcFees(estimatedReceived, publisherFeePercent, steamFeePercent);
iterations++;
}
return amount > fees.fees ? amount - fees.fees : 1;
}
async function fetchCardGroup(appId, foil) {
const cards = [];
let total = Infinity, start = 0, count = 100;
while (start < total && cards.length < 200) {
const params = new URLSearchParams();
params.set('query', ''); params.set('start', String(start)); params.set('count', String(count));
params.set('search_descriptions', '0'); params.set('sort_column', 'name'); params.set('sort_dir', 'asc');
params.set('appid', '753'); params.set('norender', '1');
params.append('category_753_Game[]', 'tag_app_' + appId);
params.append('category_753_item_class[]', 'tag_item_class_2');
params.append('category_753_cardborder[]', foil ? 'tag_cardborder_1' : 'tag_cardborder_0');
const data = await fetchJson(MARKET_SEARCH_API + '?' + params.toString(), { timeout: 12000 });
if (data?.success === false) throw new Error('Steam 市场返回失败');
const results = Array.isArray(data?.results) ? data.results : [];
total = Number.isFinite(Number(data?.total_count)) ? Number(data.total_count) : results.length;
for (const item of results) {
const desc = item.asset_description || {};
const hashName = String(item.hash_name || desc.market_hash_name || '').trim();
if (!hashName) continue;
// sell_price 单位是 用户的币种最小单位 (分/cent), 已经是 sell_price_text 的数值形式
// 例如 CNY: sell_price=6 表示 6 分 = ¥0.06, sell_price_text="¥0.06"
// 保留原始数值, 由显示层决定如何格式化
const sellPrice = Number.isFinite(Number(item.sell_price)) ? Number(item.sell_price) : null;
// v2.9.32: 可变发行商费率 — 从市场 API 读取每张卡的实际发行商费率
// 不同游戏的发行商费率可能不同 (默认 10%, 部分游戏为 5% 或其他)
const publisherFeePercent = _readPublisherFeeFromItem(item);
// v2.9.32: netPrice 用可变费率精确计算 (_calculateSellerReceives 迭代逼近法)
// 相比旧公式 sellPrice * 0.95 * 0.9, 低价卡牌 (如 3 分) 误差可从 1+ 分降至 0
const netPrice = sellPrice != null ? _calculateSellerReceives(sellPrice, publisherFeePercent) : null;
// 卡牌图片 URL (与 steam-store-card-prices 保持一致)
const iconPath = desc.icon_url_large || desc.icon_url || '';
const iconUrl = iconPath ? `https://community.fastly.steamstatic.com/economy/image/${iconPath}/64fx64f` : '';
cards.push({
foil,
name: String(item.name || desc.market_name || hashName).trim(),
hashName,
classId: String(desc.classid || '').trim(),
type: String(desc.type || '').trim(),
listings: parseInt(item.sell_listings, 10) || 0,
sellPrice, netPrice,
publisherFeePercent,
sellPriceText: String(item.sell_price_text || '').trim(),
salePriceText: String(item.sale_price_text || '').trim(),
iconUrl,
marketUrl: MARKET_LISTING_PAGE + encodeURIComponent(hashName),
});
}
if (results.length < count) break;
start += count;
}
return cards;
}
async function enrichBuyOrder(card) {
try {
const html = await fetchText(card.marketUrl, { timeout: 8000 });
// 求购价单位也是最小单位 (分)
const m1 = html.match(/"amtMaxBuyOrder"\s*:\s*(\d+)/);
const m2 = html.match(/"cBuyOrders"\s*:\s*(\d+)/);
return Object.assign({}, card, {
buyPrice: m1 ? parseInt(m1[1], 10) : null,
buyOrderCount: m2 ? parseInt(m2[1], 10) : 0,
});
} catch (e) {
return Object.assign({}, card, { buyPrice: null, buyOrderCount: 0, buyError: e.message });
}
}
// 最小单位 -> 主单位 (分 -> 元)
function toMajorUnit(minor) {
return Number.isFinite(minor) ? minor / 100 : null;
}
async function fetchCardPrices(appId) {
const result = { regular: [], foil: [], regularQueried: false, foilQueried: false };
await Promise.all([
fetchCardGroup(appId, false).then(c => { result.regular = c; result.regularQueried = true; }).catch(e => { result.regularError = e.message; }),
fetchCardGroup(appId, true).then(c => { result.foil = c; result.foilQueried = true; }).catch(e => { result.foilError = e.message; }),
]);
const tasks = [];
if (result.regular.length) tasks.push(mapLimit(result.regular, BUY_ORDER_CONCURRENCY, c => enrichBuyOrder(c)));
if (result.foil.length) tasks.push(mapLimit(result.foil, BUY_ORDER_CONCURRENCY, c => enrichBuyOrder(c)));
await Promise.all(tasks);
return result;
}
// ---- v2.3.1.1: 徽章市场数据轻量抓取 (用于价值分析仪表板) ----
// 只抓 1 页(最多 100 张), 不查 buy orders, 适合批量调用
async function fetchCardMarketLight(appId) {
const cacheKey = 'sgis_badge_market_' + appId;
const cached = cacheGet(cacheKey);
if (cached) return cached;
const params = new URLSearchParams();
params.set('query', ''); params.set('start', '0'); params.set('count', '100');
params.set('search_descriptions', '0'); params.set('sort_column', 'name'); params.set('sort_dir', 'asc');
params.set('appid', '753'); params.set('norender', '1');
params.append('category_753_Game[]', 'tag_app_' + appId);
params.append('category_753_item_class[]', 'tag_item_class_2');
params.append('category_753_cardborder[]', 'tag_cardborder_0'); // 只要普通卡
const url = MARKET_SEARCH_API + '?' + params.toString();
const data = await fetchJson(url, { timeout: 10000 });
const results = Array.isArray(data?.results) ? data.results : [];
let totalCost = 0, priceCount = 0;
const seenHash = new Set();
const cards = [];
for (const item of results) {
const desc = item.asset_description || {};
const hashName = String(item.hash_name || desc.market_hash_name || '').trim();
if (!hashName || seenHash.has(hashName)) continue;
seenHash.add(hashName);
const sellPrice = Number.isFinite(Number(item.sell_price)) ? Number(item.sell_price) : null;
if (sellPrice != null) { totalCost += sellPrice; priceCount++; }
// v2.9.32: 读取可变发行商费率 (与 fetchCardGroup 保持一致)
const publisherFeePercent = _readPublisherFeeFromItem(item);
const iconPath = desc.icon_url_large || desc.icon_url || '';
const iconUrl = iconPath ? `https://community.fastly.steamstatic.com/economy/image/${iconPath}/64fx64f` : '';
cards.push({
name: String(item.name || desc.market_name || hashName).replace(/\s*\(Foil\)\s*$/i, '').trim(),
hashName, iconUrl, sellPrice, publisherFeePercent,
sellPriceText: String(item.sell_price_text || '').trim(),
listings: parseInt(item.sell_listings, 10) || 0,
marketUrl: MARKET_LISTING_PAGE + encodeURIComponent(hashName),
});
}
const summary = {
appId,
cards,
cardCount: cards.length,
totalCost, // 单位: 分 (cent)
priceCount,
avgPrice: priceCount > 0 ? Math.round(totalCost / priceCount) : 0,
// v2.9.31: 增加中位数和忽略最高价统计 (借鉴 Steam Get Trading Card Info 脚本)
medianPrice: 0, // 中位数价格 (分), 抗极端高价干扰
avgNoMax: 0, // 忽略最高价后的均价 (分)
netIncome: 0, // 预计税后总收入 (分), 使用 _calculateSellerReceives 可变费率计算
updatedAt: Date.now(),
};
// v2.9.31: 计算中位数和忽略最高价均价
if (priceCount > 0) {
const prices = cards.filter(c => c.sellPrice != null).map(c => c.sellPrice).sort((a, b) => a - b);
if (prices.length > 0) {
// 中位数: 偶数个取中间两个的平均值
const mid = Math.floor(prices.length / 2);
summary.medianPrice = prices.length % 2 !== 0
? prices[mid]
: Math.round((prices[mid - 1] + prices[mid]) / 2);
// 忽略最高价均价 (至少 2 张卡才有意义)
if (prices.length > 1) {
const sumNoMax = prices.slice(0, -1).reduce((s, p) => s + p, 0);
summary.avgNoMax = Math.round(sumNoMax / (prices.length - 1));
} else {
summary.avgNoMax = summary.avgPrice;
}
// v2.9.32: 预计税后总收入: 均价 * ceil(卡牌数/2) * 可变费率系数
// Steam 每套卡牌掉落 ceil(卡牌数/2) 张, 合成徽章需集齐全套
// 汇总层面使用默认发行商费率 (与 steam-badges-card-view 保持一致)
const dropCount = Math.ceil(cards.length / 2);
const grossIncome = summary.avgPrice * dropCount;
summary.netIncome = _calculateSellerReceives(grossIncome, DEFAULT_PUBLISHER_FEE_PERCENT) || 0;
}
}
cacheSet(cacheKey, summary, 10 * 60 * 1000); // 10 分钟缓存
return summary;
}
// 格式化分(cent) -> 本地化货币字符串
function formatMarketPrice(cents) {
if (cents == null) return '—';
try {
// 尝试从页面抓取钱包货币
const m = document.cookie.match(/steamCountry=(\w{2})/) || [];
const cc = (m[1] || 'CN').toLowerCase();
const symbols = { cn: '¥', us: '$', eu: '€', uk: '£', jp: '¥', kr: '₩', ru: '₽' };
const symbol = symbols[cc] || '¥';
return symbol + (cents / 100).toFixed(2);
} catch (e) { return '¥' + (cents / 100).toFixed(2); }
}
// ---- v2.3.1.1: 价值分析引擎 (参考 SBC Pro) ----
const BADGE_VALUE_WEIGHTS = {
xp: 0.25,
level: 0.20,
cardPrice: 0.30,
completionBonus: 0.15,
dropBonus: 0.10,
};
// 计算徽章价值评分
// badges: enriched badges (with _gameName), markets: { appId: marketSummary }
function computeBadgeValueScores(badges, markets) {
const scores = {};
const gameBadges = badges.filter(b => b.appid > 0);
if (gameBadges.length === 0) return scores;
const maxXp = Math.max(...gameBadges.map(b => b.xp || 0), 1);
gameBadges.forEach(badge => {
const appId = String(badge.appid);
const xpScore = (badge.xp || 0) / maxXp;
// v2.3.6: 用 SteamCardExchange API 的真实 maxLevel 计算等级进度
const realMaxLevel = badge._maxLevel || getCardDbMaxLevel(badge.appid) || 5;
const levelScore = realMaxLevel > 1 ? ((badge.level || 1) - 1) / (realMaxLevel - 1) : 0;
// 卡价评分: 平均卡价越低, 评分越高 (说明这套卡便宜)
const marketData = markets[appId];
let cardPriceScore = 0;
if (marketData && marketData.avgPrice > 0) {
cardPriceScore = Math.max(0, 1 - (marketData.avgPrice / 50000)); // 5 元 = 50000 分为中等
}
// 完成度: 1 表示已合成, 0 表示未开始
const completionBonus = badge.completion_time ? 1 : 0;
// v2.3.6: 用真实 maxLevel 替代硬编码 5
const dropBonus = Math.min(1, (badge.level || 1) / realMaxLevel);
const w = BADGE_VALUE_WEIGHTS;
const score = Math.round(
(w.xp * xpScore + w.level * levelScore +
w.cardPrice * cardPriceScore + w.completionBonus * completionBonus +
w.dropBonus * dropBonus) * 100
);
scores[appId] = {
score, appId,
gameName: badge._gameName || '',
level: badge.level || 1,
maxLevel: realMaxLevel, // v2.3.6: 游戏最高可达等级
xp: badge.xp || 0,
completed: !!badge.completion_time,
marketData,
breakdown: {
xp: Math.round(xpScore * 100),
level: Math.round(levelScore * 100),
cardPrice: Math.round(cardPriceScore * 100),
completion: Math.round(completionBonus * 100),
level_max: Math.round(dropBonus * 100),
},
};
});
return scores;
}
// 获取高价值徽章 (Top N)
function getTopValueBadges(scores, n) {
n = n || 6;
return Object.values(scores).sort((a, b) => b.score - a.score).slice(0, n);
}
// 等级分布
function getBadgeLevelDistribution(badges) {
const dist = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 };
badges.filter(b => b.appid > 0).forEach(b => {
const lv = Math.min(5, Math.max(1, b.level || 1));
dist[lv] = (dist[lv] || 0) + 1;
});
return dist;
}
// 抓取徽章市场数据 (异步, 限制并发)
async function fetchBadgeMarkets(appIds, opts) {
opts = opts || {};
const concurrency = opts.concurrency || 3;
const maxApps = opts.maxApps || 20;
const targets = appIds.slice(0, maxApps);
const results = {};
for (let i = 0; i < targets.length; i += concurrency) {
const batch = targets.slice(i, i + concurrency);
await Promise.all(batch.map(async (appId) => {
try {
results[String(appId)] = await fetchCardMarketLight(appId);
} catch (e) {
results[String(appId)] = { appId, cardCount: 0, totalCost: 0, avgPrice: 0, cards: [], error: e.message };
}
}));
if (i + concurrency < targets.length) {
await new Promise(r => setTimeout(r, 500));
}
}
return results;
}
// ---- 多地区价格(整合 game-prices-tools 核心) ----
// 旗帜图用 flagcdn.com (无 key, PNG 24x18), 欧元区用 eu.png
function flagUrl(code) {
const cc = String(code || '').toLowerCase();
if (cc === 'eu') return 'https://flagcdn.com/w40/eu.png';
return `https://flagcdn.com/w40/${cc}.png`;
}
const REGIONS = [
{ code: 'CN', name: '中国', currency: 'CNY' },
{ code: 'US', name: '美国', currency: 'USD' },
{ code: 'AR', name: '阿根廷', currency: 'ARS' },
{ code: 'TR', name: '土耳其', currency: 'TRY' },
{ code: 'KZ', name: '哈萨克斯坦', currency: 'KZT' },
{ code: 'UA', name: '乌克兰', currency: 'UAH' },
{ code: 'IN', name: '印度', currency: 'INR' },
{ code: 'BR', name: '巴西', currency: 'BRL' },
{ code: 'CL', name: '智利', currency: 'CLP' },
{ code: 'CO', name: '哥伦比亚', currency: 'COP' },
{ code: 'PL', name: '波兰', currency: 'PLN' },
{ code: 'MX', name: '墨西哥', currency: 'MXN' },
{ code: 'RU', name: '俄罗斯', currency: 'RUB' },
{ code: 'JP', name: '日本', currency: 'JPY' },
{ code: 'KR', name: '韩国', currency: 'KRW' },
{ code: 'GB', name: '英国', currency: 'GBP' },
{ code: 'EU', name: '欧元区', currency: 'EUR' },
];
const PRIORITY_REGIONS = ['CN', 'US', 'TR', 'AR', 'RU', 'IN', 'BR', 'UA', 'KZ'];
// v2.9.79: ITAD 增强模式扩展区域 (15+ 区域)
// v2.9.82: 移除 'EU' (非有效 ISO 3166-1 alpha-2 国家代码, 导致 ITAD API HTTP 400)
const EXTENDED_REGIONS = ['CN', 'US', 'TR', 'AR', 'RU', 'IN', 'BR', 'UA', 'KZ', 'CL', 'CO', 'PL', 'MX', 'JP', 'KR', 'GB'];
// Steam 官方 API (在已登录 session 下可能忽略 cc= 参数, 用作兜底)
async function fetchSteamRegionPrice(appId, countryCode) {
try {
// 加 cache-buster 减少 Steam 缓存干扰
const url = `https://store.steampowered.com/api/appdetails?appids=${appId}&cc=${countryCode}&l=english&_=${Date.now()}`;
const data = await fetchJson(url, { timeout: 12000 });
if (data[appId] && data[appId].success) {
const g = data[appId].data;
if (g.is_free) return { success: true, data: { region: countryCode, price: 0, currency: 'FREE', discount: 0, initial: 0, source: 'steam' } };
if (g.price_overview) {
const actualCurrency = g.price_overview.currency;
// v2.3.7: 检测币种不匹配 — Steam API 对登录用户可能忽略 cc= 参数,
// 返回用户实际所在地区的价格(如请求 CN 但返回 INR), 这种数据是错误的, 应标记为失败
const expectedRegion = REGIONS.find(r => r.code === countryCode);
if (expectedRegion && expectedRegion.currency && expectedRegion.currency !== actualCurrency) {
return { success: false, error: `币种不匹配(期望${expectedRegion.currency}, 实际${actualCurrency}, 疑似Steam返回用户本区数据)` };
}
return { success: true, data: {
region: countryCode,
price: g.price_overview.final / 100,
currency: actualCurrency,
discount: g.price_overview.discount_percent || 0,
initial: g.price_overview.initial / 100,
source: 'steam',
}};
}
}
return { success: false, error: '无价格数据' };
} catch (e) { return { success: false, error: e.message }; }
}
// AugmentedSteam API (按 country 返回该地区价格, 不受用户登录影响, 首选)
async function fetchAugRegionPrice(appId, countryCode) {
try {
const data = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'POST',
url: 'https://api.augmentedsteam.com/prices/v2',
headers: { 'Content-Type': 'application/json' },
data: JSON.stringify({
country: countryCode,
apps: [parseInt(appId, 10)],
subs: [],
bundles: [],
voucher: true,
shops: [],
}),
timeout: 12000,
onload(r) {
if (r.status >= 200 && r.status < 300) {
try { resolve(JSON.parse(r.responseText)); }
catch { reject(new Error('JSON parse fail')); }
} else reject(new Error('HTTP ' + r.status));
},
onerror: () => reject(new Error('网络错误')),
ontimeout: () => reject(new Error('请求超时')),
});
});
const key = `app/${appId}`;
const aug = data?.prices?.[key];
if (!aug?.current?.price) return { success: false, error: 'Aug 无数据' };
const cur = aug.current;
const cur2 = cur.price;
const regular = cur.regular || cur2;
const discount = (regular.amount > cur2.amount)
? Math.round((1 - cur2.amount / regular.amount) * 100)
: 0;
return { success: true, data: {
region: countryCode,
price: cur2.amount,
currency: cur2.currency,
discount,
initial: regular.amount,
source: 'aug',
}};
} catch (e) { return { success: false, error: 'Aug: ' + e.message }; }
}
// 单个地区价格 (Aug 优先 -> Steam 兜底)
async function fetchOneRegionPrice(appId, countryCode) {
const aug = await fetchAugRegionPrice(appId, countryCode);
if (aug.success) return aug;
return await fetchSteamRegionPrice(appId, countryCode);
}
// v2.8.0: Steam API 限速器("预留位"模式)
// 在 sleep 前更新 lastRequestTime,防止并发 worker 竞态导致请求间距不足
let _steamApiLastReq = 0;
const STEAM_API_MIN_INTERVAL = 1000; // 请求间隔 1 秒
async function steamApiRateLimit() {
const now = Date.now();
const wait = STEAM_API_MIN_INTERVAL - (now - _steamApiLastReq);
// 预留位:先更新时间戳再 sleep,后续并发的 worker 看到的是已预留的时间
_steamApiLastReq = now + Math.max(0, wait);
if (wait > 0) await new Promise(r => setTimeout(r, wait));
}
// v2.8.0: 带限速的单地区价格获取(用于 9 区并发对比,3 worker + 1s 间隔)
async function fetchOneRegionPriceRateLimited(appId, countryCode) {
await steamApiRateLimit();
return await fetchOneRegionPrice(appId, countryCode);
}
// 多地区价格: 并发获取, 去重 (Steam 官方 API 对登录用户会返回相同的国家价格)
// v2.8.0: 改用 3 个并发 worker + 1s 请求间隔(预留位限速模式)
async function fetchMultiRegionPrices(appId) {
const results = await mapLimit(PRIORITY_REGIONS, 3, r => fetchOneRegionPriceRateLimited(appId, r));
const prices = [];
const failed = [];
const seenKey = new Set();
// 第一次扫描: 收集成功的结果, 按 "币种 + 金额" 去重
const successList = [];
results.forEach((r, i) => {
if (r.success) successList.push({ data: r.data, idx: i });
else failed.push({ region: PRIORITY_REGIONS[i], error: r.error });
});
// 检测是否所有结果都"看起来一样" (币种 + 价格一致), 这种情况说明 API 被缓存, 全部失败
const allSameCurrency = successList.length > 1
&& successList.every(s => s.data.currency === successList[0].data.currency)
&& successList.every(s => Math.abs(s.data.price - successList[0].data.price) < 0.01);
if (allSameCurrency && successList.length > 1) {
// 全部当失败处理
successList.forEach(s => failed.push({ region: PRIORITY_REGIONS[s.idx], error: '区域数据相同(疑似被缓存)' }));
} else {
successList.forEach(s => {
const k = `${s.data.currency}:${Number(s.data.price).toFixed(2)}`;
if (!seenKey.has(k)) {
seenKey.add(k);
prices.push(s.data);
} else {
// 重复价格 -> 视为失败
failed.push({ region: PRIORITY_REGIONS[s.idx], error: '与已有地区价格重复' });
}
});
}
return { prices, failed };
}
// ---- 图标 v2.9.15: 重新设计精致 SVG 库 ----
// 设计规范:统一 stroke-width 1.8 / currentColor / 圆角连接 / 0.1-0.18 透明填充做"水彩"层次
// viewBox 固定 0 0 24 24,14px 渲染下细节清晰
const _S = 'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"';
const SGIS_ICONS = {
// ===== 导航 tab 图标(重新设计,6 款核心)=====
// 概览 - 4 块仪表板 + 中心装饰点
overview: ``,
// 勋章 - 六角星 + 双飘带
medal: ``,
// 价格 - 价签 + $ 标志
price: ``,
// 评测 - 聊天气泡 + 引号
review: ``,
// 成就 - 奖杯 + 双手 + 底座
trophy: ``,
// 动态 - 喇叭 + 声波
activity: ``,
// v2.9.60: 趋势 - 折线图 + 坐标轴
trend: ``,
// ===== 用户档案 / 社交 tab 图标 =====
// 用户 - 头部 + 肩部(更精致)
user: ``,
// 社交 - 双人组合 + 闪光背景
social: ``,
// 洞察 - 雷达扫描(眼+十字线)
insight: ``,
// ===== 通用工具图标 =====
close: ``,
refresh: ``,
settings: ``,
check: ``,
star: ``,
share: ``,
news: ``,
info: ``,
// ====== 媒体/工具 ======
game: ``,
card: ``,
library: ``,
// 通用
chevronRight: ``,
sparkle: ``,
brain: ``,
clock: ``,
users: ``,
package: ``,
gift: ``,
link: ``,
// 工具/统计
barChart: ``,
trend: ``,
// 维度评分图标
target: ``,
// 市场洞察
market: ``,
// 闪电
zap: ``,
// 盾牌(VAC / 年龄限制共用,v2.3.8 重复定义已合并)
shield: ``,
// 外链
external: ``,
// 心愿
heart: ``,
// 复制
copy: ``,
// 创意工坊
workshop: ``,
// 截图
image: ``,
// 标签
tag: ``,
// 视频
video: ``,
// 地球
globe: ``,
// 手柄
gamepad: ``,
// 扩展包 (DLC)
puzzle: ``,
// 火焰(热卖/趋势)
fire: ``,
// 火箭(速度/即将推出)
rocket: ``,
// 旗帜(特性/标记)
flag: ``,
// v2.9.93: 资料受限 — 用户轮廓 + 警告三角
profileLimited: ``,
// ====== 默认勋章 SVG (加载失败兜底)======
medalFallback: ``,
};
// ---- UI 构建 ----
function ensureFab() {
// v2.3.26: 愿望单页面不创建浮动按钮,让 Wishlist Exporter 侧边栏独占
if (IS_WISHLIST_PAGE) return;
if (document.getElementById('sgis-fab')) return;
const btn = document.createElement('button');
btn.id = 'sgis-fab';
btn.title = '个人信息面板';
btn.setAttribute('aria-label', '个人信息面板');
btn.addEventListener('click', togglePanel);
document.body.appendChild(btn);
}
function ensurePanel() {
if (document.getElementById('sgis-panel')) return;
// v2.3.2: 动态标题/图标 - 游戏页(带 appid)显示"游戏信息面板",
// 其他页面(商店主页等)显示"个人信息面板"
const titleIcon = HAS_APP_ID ? SGIS_ICONS.game : SGIS_ICONS.user;
const titleText = HAS_APP_ID ? '游戏信息面板' : '个人信息面板';
const panel = document.createElement('div');
panel.id = 'sgis-panel';
panel.innerHTML = `
${HAS_APP_ID ? `
` : `
`}
`;
document.body.appendChild(panel);
panel.querySelector('#sgis-close-btn').addEventListener('click', closePanel);
panel.querySelector('#sgis-settings-btn').addEventListener('click', () => openGlobalSettings());
panel.querySelector('#sgis-refresh-btn').addEventListener('click', () => {
const refreshBtn = panel.querySelector('#sgis-refresh-btn');
refreshBtn.classList.add('sgis-spin');
// v2.9.60: 刷新时也触发采样,确保趋势数据最新
if (HAS_APP_ID) _samplePlayTime(APP_ID);
Promise.resolve(renderTab(SGIS.tab, true)).finally(() => refreshBtn.classList.remove('sgis-spin'));
});
panel.querySelectorAll('.sgis-tab').forEach(tabBtn => {
tabBtn.addEventListener('click', () => {
panel.querySelectorAll('.sgis-tab').forEach(b => b.classList.remove('active'));
tabBtn.classList.add('active');
SGIS.tab = tabBtn.dataset.tab;
// v2.9.15: 切换 tab 时滚动条复位到顶部,避免新内容在屏幕外
const body = document.getElementById('sgis-body');
if (body) body.scrollTop = 0;
renderTab(SGIS.tab, false);
});
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && SGIS.open) closePanel();
});
}
function openPanel() {
const overlay = document.getElementById('sf-wf-overlay');
if (overlay && overlay.classList.contains('sf-wf-open')) return;
SGIS.open = true;
document.body.classList.add('sgis-open');
document.getElementById('sgis-panel').classList.add('sgis-open');
const f = document.getElementById('sgis-fab'); if (f) f.classList.add('sgis-open');
renderTab(SGIS.tab, false);
// v2.9.60: 后台静默采样游玩时长(30分钟节流,不阻塞渲染)
if (HAS_APP_ID) _samplePlayTime(APP_ID);
}
function closePanel() {
SGIS.open = false;
document.body.classList.remove('sgis-open');
const p = document.getElementById('sgis-panel'); if (p) p.classList.remove('sgis-open');
const f = document.getElementById('sgis-fab'); if (f) f.classList.remove('sgis-open');
}
function togglePanel() {
if (SGIS.open) closePanel(); else openPanel();
}
function setBody(html) {
const b = document.getElementById('sgis-body');
if (b) b.innerHTML = html;
}
function renderLoading(text) {
// v2.9.15: 状态点 + 文字 + spinner 组合,精致化加载态
setBody(``);
}
// v2.8.1: 增强版加载——带阶段进度条 + 游戏骨架占位(参考 steam-friend-manager 加载体验)
// 调用方:renderProgressLoading({ stage, totalStages, text, skeletonCount }) 初始化,
// 后续用返回的 update({ percent, stage, text, counter }) 增量更新。
// 目的:消除"卡死"感,让用户在等待中看到结构与进度。
function renderProgressLoading(opts) {
const cfg = Object.assign({
stage: 1,
totalStages: 4,
text: '正在加载…',
skeletonCount: 8,
}, opts || {});
const stagesHtml = Array.from({ length: cfg.totalStages }, (_, i) =>
``
).join('');
const skelHtml = Array.from({ length: Math.max(0, cfg.skeletonCount) }, () =>
``
).join('');
setBody(`
${stagesHtml}
${skelHtml}
`);
// 返回更新器
return {
update(u) {
if (!u) return;
const fill = document.getElementById('sgis-load-fill');
const pct = document.getElementById('sgis-load-pct');
const text = document.getElementById('sgis-load-stage-text');
const counter = document.getElementById('sgis-load-counter');
const stagesWrap = document.getElementById('sgis-load-stages');
if (u.percent != null) {
const p = Math.max(0, Math.min(100, Math.round(u.percent)));
if (fill) fill.style.width = p + '%';
if (pct) pct.textContent = p + '%';
}
if (u.text != null && text) text.textContent = u.text;
if (u.counter != null) {
if (counter) { counter.textContent = u.counter; counter.style.display = ''; }
}
if (u.stage != null && stagesWrap) {
const bars = stagesWrap.querySelectorAll('.sgis-load-stage-bar');
bars.forEach((b, i) => {
b.classList.remove('active', 'done');
if (i + 1 < u.stage) b.classList.add('done');
else if (i + 1 === u.stage) b.classList.add('active');
});
}
},
remove() {
const blk = document.getElementById('sgis-load-block');
if (blk) blk.remove();
},
};
}
function renderError(text) {
// v2.9.15: 错误状态点 + 大字提示 + 重试按钮
setBody(`
${text || '加载失败'}
请检查网络连接或 Steam 登录状态后重试
`);
const retryBtn = document.querySelector('#sgis-body .sgis-retry-btn');
if (retryBtn) {
retryBtn.addEventListener('click', () => {
// 清除当前标签页缓存后重新渲染
if (SGIS.tab === 'overview') { SGIS.overview = null; SGIS.familyShareSupported = null; SGIS.appDetailsExtra = null; SGIS.dlcNames = null; SGIS.drmInfo = null; SGIS.priceChartRange = 'all'; SGIS.gameStatusInfo = null; SGIS.profileFeaturesStatus = null; SGIS.currentPlayers = null; SGIS.reviewSummary = null; }
else if (SGIS.tab === 'medals') SGIS.cards = null;
else if (SGIS.tab === 'prices') { SGIS.prices = null; SGIS.historyPrices = null; SGIS.prediction = null; SGIS.predictionError = null; SGIS.giftRec = null; SGIS.priceChartRange = 'all'; }
else if (SGIS.tab === 'reviews') { SGIS.reviews = null; SGIS.reviewsExpanded = new Set(); SGIS.reviewsFilter = { rec: 'all', playtime: 'all', language: 'all', purchase: 'all', keyword: '', regexMode: false }; }
else if (SGIS.tab === 'achievements') { SGIS.achievements = null; SGIS.globalAchievements = null; }
else if (SGIS.tab === 'dynamics') { SGIS.dynamics = null; SGIS.aiSummary = null; }
else if (SGIS.tab === 'profile') { SGIS.profile = null; SGIS.profileError = null; }
else if (SGIS.tab === 'activity') { SGIS.activity = null; SGIS.personalTimeline = null; SGIS.familyTimeline = null; }
else if (SGIS.tab === 'userBadges') SGIS.userBadges = null;
else if (SGIS.tab === 'social') { SGIS.friendsList = null; SGIS.friendsListError = null; SGIS.friendsLevels = null; }
else if (SGIS.tab === 'userAchievements') { SGIS.userAchievements = null; SGIS.aiPersona = null; SGIS.aiPersonaError = null; SGIS.insightData = null; SGIS.aiInsight = null; SGIS.aiInsightError = null; SGIS.aiMarketInsight = null; SGIS.aiMarketInsightError = null; }
renderTab(SGIS.tab, true);
});
}
}
// ---- 概览标签 (整合 DOM 信息 + 共享游戏库数据) ----
function extractOverviewFromDOM() {
const data = {
appId: APP_ID,
name: '', cover: '',
developer: '', publisher: '', releaseDate: '',
platforms: [], tags: [], genres: [],
priceCurrent: '', priceOriginal: '', discountPercent: 0,
isFree: false,
reviews: { recent: { label: '', summary: '', count: '', pos: 0 }, all: { label: '', summary: '', count: '', pos: 0 } },
shortDesc: '',
};
const nameEl = document.getElementById('appHubAppName');
if (nameEl) data.name = nameEl.textContent.trim();
if (!data.name) {
const og = document.querySelector('meta[property="og:title"]');
if (og) data.name = og.content.replace(/\s+on Steam$/i, '').trim();
}
const ogImg = document.querySelector('meta[property="og:image"]');
if (ogImg) data.cover = ogImg.content;
const devLink = document.querySelector('#developers_list a, .glance_details a[href*="/developer/"], .game_details a.dev_link');
if (devLink) data.developer = devLink.textContent.trim();
const pubLink = document.querySelector('.glance_details a[href*="/publisher/"]');
if (pubLink) data.publisher = pubLink.textContent.trim();
const releaseEl = document.querySelector('.release_date .date');
if (releaseEl) data.releaseDate = releaseEl.textContent.trim();
// 用 Set 去重平台 (页面中可能有多组 .game_area_purchase_game 对应游戏+DLCs)
const platformsSet = new Set();
// 兼容多种 selector: 优先 buy 区, 备用 game details
const platformNodes = document.querySelectorAll(
'.game_area_purchase_game .platform_img, .game_details .platform_img, .game_purchase_platform .platform_img'
);
platformNodes.forEach(img => {
const cls = (img.className || '').toLowerCase();
if (cls.includes('win')) platformsSet.add('Windows');
else if (cls.includes('mac')) platformsSet.add('macOS');
else if (cls.includes('linux') || cls.includes('steamos')) platformsSet.add('SteamOS + Linux');
});
data.platforms = Array.from(platformsSet);
// 兜底: 如果没找到, 从 game details 文本提取
if (data.platforms.length === 0) {
const detailsText = (document.querySelector('#game_details, .game_details')?.textContent || '').toLowerCase();
if (/win/i.test(detailsText)) platformsSet.add('Windows');
if (/mac/i.test(detailsText)) platformsSet.add('macOS');
if (/linux/i.test(detailsText)) platformsSet.add('SteamOS + Linux');
data.platforms = Array.from(platformsSet);
}
document.querySelectorAll('.glance_tags .app_tag, .popular_tags .app_tag').forEach(t => {
const txt = t.textContent.trim();
if (txt) data.tags.push(txt);
});
data.tags = [...new Set(data.tags)].slice(0, 12);
document.querySelectorAll('.glance_details a[href*="/genre/"]').forEach(a => {
const g = a.textContent.trim();
if (g) data.genres.push(g);
});
// v2.9.84: 从第一个购买区域(基础游戏)提取价格,避免抓取捆绑包价格
// Steam 页面中 .game_area_purchase_game 按 基础游戏→捆绑包→DLC 顺序排列
// 旧代码使用全局 querySelector 会命中捆绑包的折扣价格
const basePurchase = document.querySelector('.game_area_purchase_game');
const qs = sel => (basePurchase || document).querySelector(sel);
const freeText = basePurchase
? Array.from(basePurchase.querySelectorAll('.game_purchase_price')).find(p => /free/i.test(p.textContent))
: Array.from(document.querySelectorAll('.game_purchase_price')).find(p => /free/i.test(p.textContent));
const finalPrice = qs('.discount_final_price');
const originalPrice = qs('.discount_original_price');
const discountPct = qs('.discount_pct');
if (freeText) { data.isFree = true; data.priceCurrent = '免费'; }
else if (finalPrice) {
data.priceCurrent = finalPrice.textContent.trim();
if (originalPrice) data.priceOriginal = originalPrice.textContent.trim();
if (discountPct) { const m = discountPct.textContent.match(/(\d+)/); if (m) data.discountPercent = parseInt(m[1], 10); }
} else {
const pn = qs('.game_purchase_price');
if (pn) data.priceCurrent = pn.textContent.trim();
}
document.querySelectorAll('.user_reviews_summary_row').forEach(row => {
const subtitle = row.querySelector('.subtitle')?.textContent?.trim() || '';
const summary = row.querySelector('.game_review_summary')?.textContent?.trim() || '';
const countText = row.querySelector('.responsive_reviewdesc_summary, .review_summary_with_link')?.textContent?.trim() || '';
const countMatch = countText.match(/(\d+(?:[,.]\d+)*)\s*user reviews/);
const count = countMatch ? countMatch[1] : '';
const pctMatch = countText.match(/\((\d+)%\)/);
const pos = pctMatch ? parseInt(pctMatch[1], 10) : 0;
const target = subtitle.includes('All') ? 'all' : 'recent';
data.reviews[target] = { label: subtitle, summary, count, pos };
});
const shortDesc = document.querySelector('.game_description_snippet');
if (shortDesc) data.shortDesc = shortDesc.textContent.trim();
return data;
}
// ==================== v2.3.19: morelike 页面专用模块 ====================
// ---- 概览提取:morelike 页适配 ----
// morelike 页 DOM 与 /app/ 页不同:源游戏信息在 .recommendation_highlight,
// 而非 #appHubAppName / .game_area_purchase_game 等。其余字段留给 appdetails API 补全。
function extractMoreLikeOverview() {
const headerImg = document.querySelector('.recommendation_highlight .header_image');
const tagIdsAttr = headerImg?.getAttribute('data-ds-tagids');
const priceEl = document.querySelector('.highlight_description .regular_price, .highlight_description .discount_final_price');
const priceText = priceEl?.textContent.trim() || '';
const isFree = /免费|free/i.test(priceText);
const data = {
appId: APP_ID,
name: document.querySelector('h2.pageheader')?.textContent.trim() || '',
cover: headerImg?.querySelector('img')?.src || '',
developer: '', publisher: '', releaseDate: '',
platforms: [], tags: [], genres: [],
priceCurrent: priceText,
priceOriginal: '', discountPercent: 0,
isFree,
reviews: { recent: { label: '', summary: '', count: '', pos: 0 }, all: { label: '', summary: '', count: '', pos: 0 } },
shortDesc: '',
// morelike 专用:源游戏标签 ID,驱动类型扩展推荐
sourceTagIds: tagIdsAttr ? (() => { try { return JSON.parse(tagIdsAttr); } catch { return []; } })() : [],
pageType: 'morelike',
};
return data;
}
// ---- 相似游戏采集:从 DOM 读取 .similar_grid_capsule ----
function extractSimilarGames() {
const items = Array.from(document.querySelectorAll('.similar_grid_capsule')).map(a => {
const item = a.closest('.similar_grid_item');
const href = a.getAttribute('href') || '';
const nameSlug = (href.match(/\/app\/\d+\/([^/?]+)/) || [])[1] || '';
const priceEl = item?.querySelector('.similar_grid_price .regular_price, .similar_grid_price .discount_final_price, .similar_grid_price .discount_original_price');
const priceText = priceEl?.textContent.trim() || '';
const tagIdsAttr = a.getAttribute('data-ds-tagids');
return {
appId: Number(a.getAttribute('data-ds-appid')),
tagIds: tagIdsAttr ? (() => { try { return JSON.parse(tagIdsAttr); } catch { return []; } })() : [],
href: href.split('?snr=')[0],
capsule: a.querySelector('img')?.getAttribute('src') || '',
nameSlug: decodeURIComponent(nameSlug).replace(/_/g, ' ').trim() || ('App ' + a.getAttribute('data-ds-appid')),
priceText,
isFree: /免费|free/i.test(priceText),
status: null, // 'owned' | 'wishlist' | 'ignored' | null,由 annotateSimilarStatus 填充
};
}).filter(g => g.appId && g.appId !== Number(APP_ID)); // 排除源游戏自身
return items;
}
// ---- 库存状态获取:移植自 SteamPeek 1.9 getSteamData() ----
// 读取 unsafeWindow.GDynamicStore 三集合,兼容数组/对象两种格式
function getDynamicStoreStatus() {
if (SGIS.dynamicStoreChecked) return SGIS.dynamicStore;
SGIS.dynamicStoreChecked = true;
const win = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;
const store = win.GDynamicStore;
if (!store) { SGIS.dynamicStore = null; return null; }
const toSet = (raw) => {
if (!raw) return new Set();
const arr = Array.isArray(raw) ? raw : Object.keys(raw);
return new Set(arr.map(Number));
};
const result = {
owned: toSet(store.s_rgOwnedApps),
wishlist: toSet(store.s_rgWishlist || win.g_rgWishlist),
ignored: toSet(store.s_rgIgnoredApps || win.g_rgIgnoredApps),
};
SGIS.dynamicStore = result;
return result;
}
// v2.3.29: 异步获取 dynamicstore/userdata API——GDynamicStore 不可用时的回退
// 该端点返回完整的 owned apps 列表(含 CD key 激活、促销许可等),比 GetOwnedGames 更可靠
// 参考 Steam-License-Classifier 的 license 数据获取思路,确保 key 激活的游戏能被正确识别
// 委托给外层 fetchDynamicStoreOwnedAppIds(),避免重复请求和代码重复
async function fetchDynamicStoreUserData() {
if (SGIS.dynamicStoreOwnedApps || SGIS.dynamicStoreFetching) return SGIS.dynamicStoreOwnedApps;
SGIS.dynamicStoreFetching = true;
try {
const ownedApps = await fetchDynamicStoreOwnedAppIds();
SGIS.dynamicStoreOwnedApps = ownedApps;
} catch (e) {
console.warn('[SGLV] 侧边栏 dynamicstore/userdata 获取失败:', e);
} finally {
SGIS.dynamicStoreFetching = false;
}
return SGIS.dynamicStoreOwnedApps;
}
// ---- 将库存状态回填到相似游戏数据 ----
// 优先级:owned > wishlist > ignored(与 SteamPeek 1.9 一致)
// 未登录兜底:GDynamicStore 三集合为空时,回退 state.ownedAppIds 判定 owned
function annotateSimilarStatus() {
if (!SGIS.similarGames) return;
const ds = getDynamicStoreStatus();
const hasDs = ds && (ds.owned.size > 0 || ds.wishlist.size > 0 || ds.ignored.size > 0);
// 未登录兜底:用本地扫描的游戏库
const localOwned = (!hasDs || ds.owned.size === 0) ? state.ownedAppIds : null;
for (const g of SGIS.similarGames) {
if (ds) {
if (ds.owned.has(g.appId)) { g.status = 'owned'; continue; }
if (ds.wishlist.has(g.appId)) { g.status = 'wishlist'; continue; }
if (ds.ignored.has(g.appId)) { g.status = 'ignored'; continue; }
}
if (localOwned && localOwned.has(g.appId)) { g.status = 'owned'; continue; }
g.status = null;
}
}
// ---- 渲染相似游戏区块 HTML ----
function renderSimilarGamesSection() {
if (!IS_MORELIKE) return '';
if (!SGIS.similarGames) return '';
const games = SGIS.similarGames;
if (!games.length) return '';
annotateSimilarStatus();
// 筛选
let filtered = games.slice();
if (SGIS.similarFilter === 'owned') filtered = filtered.filter(g => g.status === 'owned');
else if (SGIS.similarFilter === 'unowned') filtered = filtered.filter(g => g.status !== 'owned');
else if (SGIS.similarFilter === 'wishlist') filtered = filtered.filter(g => g.status === 'wishlist');
// 排序
const parsePrice = (text) => {
if (!text || /免费|free/i.test(text)) return 0;
const m = text.match(/([\d,.]+)/);
return m ? parseFloat(m[1].replace(/,/g, '')) : 0;
};
if (SGIS.similarSort === 'priceAsc') filtered.sort((a, b) => parsePrice(a.priceText) - parsePrice(b.priceText));
else if (SGIS.similarSort === 'priceDesc') filtered.sort((a, b) => parsePrice(b.priceText) - parsePrice(a.priceText));
else if (SGIS.similarSort === 'name') filtered.sort((a, b) => a.nameSlug.localeCompare(b.nameSlug));
// 展示数量
const limit = SGIS.similarExpanded ? filtered.length : SGIS.similarDisplayLimit;
const shown = filtered.slice(0, limit);
// 统计
const ownedCount = games.filter(g => g.status === 'owned').length;
const wishlistCount = games.filter(g => g.status === 'wishlist').length;
const unownedCount = games.length - ownedCount;
// 登录提示
const ds = SGIS.dynamicStore;
const noDsData = !ds || (ds.owned.size === 0 && ds.wishlist.size === 0 && ds.ignored.size === 0);
const loginHint = noDsData
? `⚠ 未检测到 Steam 登录态的愿望单/忽略数据,仅显示本地游戏库标记。登录 Steam 商店后刷新页面可获取完整库存标记。
`
: '';
// 工具栏
const toolbar = `
${wishlistCount ? `` : ''}
`;
// 卡片网格
const cards = shown.map(g => {
const tagHtml = g.status === 'owned'
? '在库中'
: g.status === 'wishlist'
? '愿望单'
: g.status === 'ignored'
? '已忽略'
: '';
const priceClass = g.isFree ? 'free' : (g.priceText ? '' : 'unknown');
const priceDisplay = g.priceText || '价格未知';
return `
${tagHtml}
${g.capsule ? `
` : ''}
${g.nameSlug}
${priceDisplay}
`;
}).join('');
const moreHtml = filtered.length > limit
? `显示 ${shown.length}/${filtered.length} ·
查看全部 → `
: filtered.length > SGIS.similarDisplayLimit && SGIS.similarExpanded
? ``
: '';
return `
${SGIS_ICONS.game} 相似游戏推荐 (来自本页 · ${games.length})
${loginHint}
${toolbar}
${cards}
${moreHtml}
`;
}
// ---- 绑定相似游戏区块事件 ----
function bindSimilarGamesEvents() {
if (!IS_MORELIKE || !SGIS.similarGames) return;
const body = document.getElementById('sgis-body');
if (!body) return;
// 筛选按钮
body.querySelectorAll('[data-sim-filter]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.preventDefault();
SGIS.similarFilter = btn.getAttribute('data-sim-filter');
renderOverview();
});
});
// 排序按钮(循环切换)
const sortBtn = body.querySelector('[data-sim-sort]');
if (sortBtn) {
sortBtn.addEventListener('click', (e) => {
e.preventDefault();
const order = ['default', 'priceAsc', 'priceDesc', 'name'];
const idx = order.indexOf(SGIS.similarSort);
SGIS.similarSort = order[(idx + 1) % order.length];
renderOverview();
});
}
// 展开/收起
const expandBtn = body.querySelector('[data-sim-expand]');
if (expandBtn) {
expandBtn.addEventListener('click', (e) => {
e.preventDefault();
SGIS.similarExpanded = true;
renderOverview();
});
}
const collapseBtn = body.querySelector('[data-sim-collapse]');
if (collapseBtn) {
collapseBtn.addEventListener('click', (e) => {
e.preventDefault();
SGIS.similarExpanded = false;
renderOverview();
});
}
}
// ---- 标签名反查:tagid → name ----
// 策略:① 从 appdetails 的 categories/tags 匹配 ② 从源游戏相似游戏的 tagids 交叉 ③ 本地高频映射表
const TAG_ID_NAME_MAP = {
19: '动作', 21: '冒险', 122: 'RPG', 128: '单人', 1695: '开放世界',
1754: '大型多人在线', 3859: '免费开玩', 3870: '俯视角', 3964: '像素图形',
4135: '重玩价值', 4168: '视觉小说', 4231: '角色扮演', 4345: '生存',
4747: '多人', 492: '独立', 493: '早期访问', 599: '策略',
597: '休闲', 5350: '类魂', 4625: '派对', 4444: '叙事',
1719: '奇幻', 1720: '科幻', 1742: '射击', 1775: '军事',
1665: '生存', 1646: 'Roguelike', 1684: '合作', 3843: '农场模拟',
4106: '时间管理', 4172: '2D', 4182: '3D', 4663: '卡牌',
4684: '在线对战', 6730: '蒸汽工作室', 723991: '收纳',
};
function resolveTagName(tagId) {
if (TAG_ID_NAME_MAP[tagId]) return TAG_ID_NAME_MAP[tagId];
// 从 appdetails categories 中查找
const extra = SGIS.appDetailsExtra;
if (extra) {
const cat = extra.categories.find(c => c.id === tagId);
if (cat) return cat.description;
// v2.9.57: genres 从 genreObjs 查找(库返回的 genres 是字符串数组,无 id)
const genre = (extra.genreObjs || []).find(g => g.id === tagId);
if (genre) return genre.description;
}
return '标签 ' + tagId;
}
// ---- 类型扩展推荐:调用 Steam 标签 API ----
async function fetchTagRecommendations(tagIds) {
if (!tagIds || !tagIds.length) return [];
const cacheKey = 'tagRecs_' + tagIds.join(',');
const cached = cacheGet(cacheKey);
if (cached) return cached;
SGIS.tagRecsLoading = true;
SGIS.tagRecsError = null;
try {
const results = await mapLimit(tagIds, 4, async (tagId) => {
try {
const url = `https://steamcommunity.com/actions/QueryAppsWithTag/${tagId}`;
const data = await fetchJson(url, { timeout: 10000 });
const appIds = (data.appids || data || []).slice(0, 50).map(Number);
return {
tagId,
tagName: resolveTagName(tagId),
appIds,
};
} catch (e) {
return { tagId, tagName: resolveTagName(tagId), appIds: [], error: e.message };
}
});
const merged = results.filter(r => r.appIds.length);
// 排除已在相似游戏列表中的 appid,避免重复
const similarSet = new Set((SGIS.similarGames || []).map(g => g.appId));
for (const r of merged) {
r.appIds = r.appIds.filter(id => !similarSet.has(id) && id !== Number(APP_ID));
}
// 过滤掉去重后为空的标签
const final = merged.filter(r => r.appIds.length);
cacheSet(cacheKey, final, 6 * 3600 * 1000); // 6h 缓存
return final;
} catch (e) {
SGIS.tagRecsError = e.message || '标签推荐获取失败';
return [];
} finally {
SGIS.tagRecsLoading = false;
}
}
// ---- 渲染类型扩展推荐区块 HTML ----
function renderTagRecsSection() {
if (!IS_MORELIKE) return '';
const o = SGIS.overview;
if (!o || !o.sourceTagIds || !o.sourceTagIds.length) return '';
// 加载中
if (SGIS.tagRecsLoading && !SGIS.tagRecs) {
return `
${SGIS_ICONS.tag} 同类型游戏 (按标签扩展)
正在从 Steam 标签 API 拉取同类型游戏…
`;
}
// 加载失败
if (SGIS.tagRecsError && !SGIS.tagRecs) {
return `
${SGIS_ICONS.tag} 同类型游戏
⚠ ${SGIS.tagRecsError}
`;
}
const recs = SGIS.tagRecs;
if (!recs || !recs.length) return '';
// 标签切换栏
const activeTag = SGIS.activeTagId || recs[0].tagId;
SGIS.activeTagId = activeTag;
const tabsHtml = recs.map(r => {
const active = r.tagId === activeTag;
return ``;
}).join('');
// 当前标签下的游戏(需要 appid → 卡片,但只有 appid,无封面/价格)
// 展示为文本链接列表,点击跳转商店页
const activeRec = recs.find(r => r.tagId === activeTag);
if (!activeRec) return '';
const limit = SGIS.tagRecDisplayLimit;
const shownIds = activeRec.appIds.slice(0, limit);
const total = activeRec.appIds.length;
const ds = SGIS.dynamicStore;
const cards = shownIds.map(appId => {
const status = ds && ds.owned.has(appId) ? 'owned'
: ds && ds.wishlist.has(appId) ? 'wishlist'
: ds && ds.ignored.has(appId) ? 'ignored'
: null;
const tagHtml = status === 'owned'
? '在库中'
: status === 'wishlist'
? '愿望单'
: status === 'ignored'
? '已忽略'
: '';
return `
${tagHtml}
App ${appId}
`;
}).join('');
const moreHtml = total > limit
? `该标签下 ${total} 款 · 仅展示评分较高 ${limit} 款 ·
查看全部 → `
: ``;
return `
${SGIS_ICONS.tag} 同类型游戏 (按标签扩展)
${tabsHtml}
${cards}
${moreHtml}
`;
}
// ---- 绑定类型推荐区块事件 ----
function bindTagRecsEvents() {
if (!IS_MORELIKE || !SGIS.tagRecs) return;
const body = document.getElementById('sgis-body');
if (!body) return;
body.querySelectorAll('[data-tagrec-tab]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.preventDefault();
SGIS.activeTagId = Number(btn.getAttribute('data-tagrec-tab'));
renderOverview();
});
});
}
function reviewBar(posPercent) {
const color = posPercent >= 80 ? 'var(--sgis-green)' : posPercent >= 50 ? 'var(--sgis-amber)' : 'var(--sgis-rose)';
return ``;
}
// 共享数据 -> 渲染个人游戏状态卡片
function renderMyGameStatus() {
const info = getCurrentGameInfo();
if (!info.found) {
// v2.3.29: 显示正在检查动态数据的提示
const checkingHint = (!SGIS.dynamicStoreOwnedApps && !SGIS.dynamicStoreFetching)
? '正在检查 Steam 商店动态数据(CD Key 激活的游戏可能需要此检测)…
'
: '';
return `
${SGIS_ICONS.library} 我的游戏库
未在你的游戏库中找到此游戏。
打开导航栏【游戏库】面板,点击「刷新」按钮,即可获取完整的个人游戏库 + 家庭组共享数据。
${checkingHint}
`;
}
const playtimeHours = info.playtime > 0 ? (info.playtime / 60).toFixed(1) : '0';
const acquiredTime = formatAcquiredTime(info.acquiredTime);
// 顶部 hero (状态/分类)
let heroClass = '', heroIcon = SGIS_ICONS.check, heroMain = '已拥有', heroSub = '';
if (info.isOwnedByMe) {
heroClass = '';
heroMain = '你已拥有';
// v2.3.29: 新增 dynamicstore / dynamicstore_api 数据源说明
const sourceLabels = {
'api': 'Web API',
'family': '家庭组',
'scrape': '页面抓取',
'dynamicstore': '商店动态数据',
'dynamicstore_api': '商店动态API',
};
heroSub = `来源: ${sourceLabels[info._source] || '缓存'} · ${playtimeHours}h · 入库 ${acquiredTime}`;
} else if (info.isSharedOnly) {
heroClass = 'shared';
heroIcon = SGIS_ICONS.share;
heroMain = '家庭组共享';
const sharer = info.owners.find(o => !o.isMe);
heroSub = sharer ? `${sharer.name} 已拥有 · 共享给你 · 入库 ${acquiredTime}` : '由家庭组成员购买';
}
// 所有者列表 (家庭组共享 / 多成员拥有)
let ownerListHtml = '';
if (info.owners.length > 0) {
ownerListHtml = `
${SGIS_ICONS.share} 家庭组购买情况
${info.owners.map(o => {
const tag = o.isMe ? '
你'
: (info.isOwnedByMe ? '' : '
共享人');
const cls = o.isMe ? 'me' : (info.isOwnedByMe ? '' : 'sharer');
const avatarText = (o.name || '?').slice(0, 1).toUpperCase();
return `
${avatarText}
${o.name} ${tag}
${info.acquiredTime ? `
入库 ${acquiredTime}
` : ''}
${o.playtime != null ? `
${o.playtime > 0 ? `已游玩 ${(o.playtime / 60).toFixed(1)} 小时` : '未游玩'}
` : ''}
`;
}).join('')}
${info.owners.filter(o => !o.isMe).length > 0 ? `
共 ${info.owners.length} 位家庭组成员拥有${(!SGIS.familyShareSupported || SGIS.familyShareSupported.supported !== false) ? `,${info.owners.filter(o => !o.isMe).length} 位可共享` : ''}
` : ''}
`;
}
return `
${heroIcon}
${heroMain}${heroSub}
商店页
${renderFamilyShareIndicator(info)}
${ownerListHtml}
`;
}
// ---- 获取家庭成员游玩时长 ----
async function fetchOwnersPlaytime() {
const apiKey = storage.getApiKey();
if (!apiKey) return;
if (SGIS.ownersPlaytimeFetching) return;
const info = getCurrentGameInfo();
if (!info.found || !info.owners.length) return;
const ownersToFetch = info.owners.filter(o => !o.isMe && SGIS.ownersPlaytime[o.steamId] == null);
if (!ownersToFetch.length) return;
SGIS.ownersPlaytimeFetching = true;
try {
const results = {};
await Promise.all(ownersToFetch.map(async o => {
try {
const data = await fetchJson(`https://api.steampowered.com/IPlayerService/GetOwnedGames/v0001/?key=${apiKey}&steamid=${o.steamId}&include_played_free_games=1&format=json`, { timeout: 12000 });
const games = data?.response?.games || [];
const target = games.find(g => String(g.appid) === String(APP_ID));
results[o.steamId] = target ? (target.playtime_forever || 0) : 0;
} catch {
results[o.steamId] = 0;
}
}));
Object.assign(SGIS.ownersPlaytime, results);
if (SGIS.tab === 'overview') renderOverview();
else if (SGIS.tab === 'playtrend') renderPlayTrend();
} finally {
SGIS.ownersPlaytimeFetching = false;
}
}
// ==================== v2.9.83: 在线玩家数 + 评测摘要增强 ====================
// 获取当前在线玩家数 (ISteamUserStats/GetNumberOfCurrentPlayers/v1)
// 5秒超时,失败/超时不显示在线玩家区块
async function fetchCurrentPlayers() {
if (SGIS.currentPlayers !== null || SGIS.currentPlayersLoading) return;
SGIS.currentPlayersLoading = true;
try {
const url = `https://api.steampowered.com/ISteamUserStats/GetNumberOfCurrentPlayers/v1/?appid=${APP_ID}`;
const data = await sglvGmFetchRetry(url, { timeout: 5000, retries: 0 });
if (data && data.response && data.response.result === 1 && data.response.player_count != null) {
SGIS.currentPlayers = { count: data.response.player_count };
} else {
SGIS.currentPlayers = null; // 无数据,隐藏区块
}
} catch (e) {
console.warn('[SGIS] fetchCurrentPlayers failed:', e.message);
SGIS.currentPlayers = null; // 超时/错误,隐藏区块
} finally {
SGIS.currentPlayersLoading = false;
if (SGIS.tab === 'overview') renderOverview();
}
}
// 获取评测摘要 (store.steampowered.com/appreviews/{appid})
// 返回总体+近期好评率、评测数、评测描述
async function fetchReviewSummary() {
if (SGIS.reviewSummary !== null || SGIS.reviewSummaryLoading) return;
SGIS.reviewSummaryLoading = true;
try {
const baseUrl = `https://store.steampowered.com/appreviews/${APP_ID}?json=1&purchase_type=all&num_per_page=0`;
// 并发获取总体和近期评测
const [allData, recentData] = await Promise.all([
sglvGmFetchRetry(baseUrl + '&language=all', { timeout: 8000, retries: 0 }),
sglvGmFetchRetry(baseUrl + '&language=all&filter=recent', { timeout: 8000, retries: 0 }),
]);
const summary = {};
if (allData && allData.query_summary) {
const qs = allData.query_summary;
summary.totalPositive = qs.total_positive || 0;
summary.totalNegative = qs.total_negative || 0;
summary.totalReviews = qs.total_reviews || 0;
summary.reviewScore = qs.review_score || 0;
summary.reviewScoreDesc = qs.review_score_desc || '';
}
if (recentData && recentData.query_summary) {
const rqs = recentData.query_summary;
summary.recentPositive = rqs.total_positive || 0;
summary.recentNegative = rqs.total_negative || 0;
summary.recentReviews = rqs.total_reviews || 0;
summary.recentScoreDesc = rqs.review_score_desc || '';
}
if (summary.totalReviews > 0) {
SGIS.reviewSummary = summary;
} else {
SGIS.reviewSummary = null;
}
} catch (e) {
console.warn('[SGIS] fetchReviewSummary failed:', e.message);
SGIS.reviewSummary = null;
} finally {
SGIS.reviewSummaryLoading = false;
if (SGIS.tab === 'overview') renderOverview();
}
}
function renderOverview() {
// v2.3.19: morelike 页面使用专用概览提取器
if (!SGIS.overview) SGIS.overview = IS_MORELIKE ? extractMoreLikeOverview() : extractOverviewFromDOM();
// v2.3.19: morelike 页面采集相似游戏 + 库存标记
if (IS_MORELIKE && !SGIS.similarGames) {
SGIS.similarGames = extractSimilarGames();
annotateSimilarStatus();
}
const o = SGIS.overview;
const extra = SGIS.appDetailsExtra; // v2.3.16: appdetails 增强信息
// v2.3.16: 用 API 数据补全 DOM 缺失字段
const devName = o.developer || (extra && extra.developers.length ? extra.developers.join(', ') : '');
const pubName = o.publisher || (extra && extra.publishers.length ? extra.publishers.join(', ') : '');
const releaseDate = o.releaseDate || (extra && extra.releaseDate) || '';
const coverUrl = o.cover || (extra && extra.headerImage) || '';
const genres = o.genres.length ? o.genres : (extra && extra.genres) || [];
const platforms = o.platforms.length ? o.platforms : (extra && extra.platforms) || [];
const reviewRowHtml = (rev) => {
if (!rev.count) return '';
const cls = rev.pos >= 80 ? 'pos' : rev.pos >= 50 ? 'mix' : 'neg';
const thumb = rev.pos >= 70 ? 'up' : 'down';
return `
${rev.label || '评测'}
${rev.summary || ''}
${rev.count}
${reviewBar(rev.pos)}`;
};
const tagHtml = o.tags.length
? `${o.tags.map(t => `${t}`).join('')}
`
: '无标签
';
const priceLabel = isZh ? '基础价格' : 'Base Price';
const priceHtml = o.isFree
? `${priceLabel}免费
`
: o.discountPercent > 0
? `${priceLabel}${o.priceCurrent} -${o.discountPercent}%${o.priceOriginal}
`
: (o.priceCurrent ? `${priceLabel}${o.priceCurrent}
` : '');
const linkHtml = ``;
// v2.3.16: 基本信息附加芯片 (Metacritic / 评测数 / 成就数 / DLC 数)
const metaChipsHtml = (() => {
const chips = [];
if (extra) {
if (extra.metacritic && extra.metacritic.score) {
const cls = extra.metacritic.score >= 75 ? 'pos' : extra.metacritic.score >= 50 ? 'warn' : 'neg';
const url = extra.metacritic.url || '';
const inner = `${SGIS_ICONS.star} Metacritic ${extra.metacritic.score}`;
chips.push(url
? `${inner}`
: `${inner}`);
}
if (extra.recommendations > 0) {
chips.push(`${SGIS_ICONS.barChart} ${formatBigNumber(extra.recommendations)} 评测`);
}
if (extra.achievementsTotal > 0) {
chips.push(`${SGIS_ICONS.trophy} ${extra.achievementsTotal} 成就`);
}
if (extra.dlc.length > 0) {
const dlcUrl = `https://store.steampowered.com/dlc/${APP_ID}/`;
chips.push(`${SGIS_ICONS.puzzle} ${extra.dlc.length} 个 DLC`);
}
// v2.3.17: 控制器支持芯片
if (extra.controllerSupport === 'full') {
chips.push(`${SGIS_ICONS.gamepad} 完全支持控制器`);
} else if (extra.controllerSupport === 'partial') {
chips.push(`${SGIS_ICONS.gamepad} 部分支持控制器`);
}
if (extra.website) {
chips.push(`${SGIS_ICONS.external} 官网`);
}
}
return chips.length ? `${chips.join('')}
` : '';
})();
// v2.3.17: 年龄限制徽章 + 内容警告
// v2.9.84: 拆分为紧凑徽章和内容描述符,徽章与其他标签合并到同一行
const ageBadgeHtml = (() => {
if (!extra) return '';
const info = ageBadgeInfo(extra.requiredAge);
let html = `${info.icon} ${info.text}`;
// 免费游戏标识
if (extra.isFree) {
html += `免费游戏`;
}
return html;
})();
// 内容描述符警告 (单独成块,文字较长不适合内联)
const contentNoteHtml = (extra && extra.contentDescriptors && extra.contentDescriptors.notes)
? `${SGIS_ICONS.shield} ${extra.contentDescriptors.notes}
`
: '';
// v2.9.8: 跨平台订阅状态展示
const subStatusHtml = (() => {
const badges = [];
const appIdNum = Number(APP_ID);
// PS会免 / Epic 状态从 GM 缓存检查
try {
const psplusCache = cacheGet('psplus');
if (psplusCache && Array.isArray(psplusCache) && psplusCache.includes(appIdNum)) {
badges.push(`${ICONS.playstation} ${isZh ? 'PS会免' : 'PS Plus'}`);
}
} catch (e) { /* ignore */ }
try {
const epicCache = cacheGet('epic');
if (epicCache && Array.isArray(epicCache) && epicCache.includes(appIdNum)) {
badges.push(`${ICONS.epic} ${isZh ? 'Epic赠送' : 'Epic Free'}`);
}
} catch (e) { /* ignore */ }
if (badges.length === 0) return '';
return `${badges.join('')}
`;
})();
// v2.3.17: DLC 列表区块
const dlcListHtml = (() => {
if (!extra || !extra.dlc.length) return '';
const dlcNames = SGIS.dlcNames || {};
const items = extra.dlc.map(dlcId => {
const info = dlcNames[dlcId];
const nameClass = info && info.name ? '' : 'loading';
const nameText = info && info.name ? info.name : '加载中…';
const url = `https://store.steampowered.com/app/${dlcId}/`;
return `
${dlcId}
${nameText}
${info && info.isFree ? '免费' : ''}
→
`;
}).join('');
const dlcAllUrl = `https://store.steampowered.com/dlc/${APP_ID}/`;
return `
${SGIS_ICONS.puzzle} DLC 列表 (${extra.dlc.length})
${items}
查看全部 DLC →
`;
})();
// v2.3.17: 支持语言区块
const languagesHtml = (() => {
if (!extra || !extra.supportedLanguages || !extra.supportedLanguages.list.length) return '';
const langs = extra.supportedLanguages.list;
// 最多展示 12 个, 其余折叠
const shown = langs.slice(0, 12).map(l =>
`${l.name}${l.audio ? ' ♪' : ''}`
).join('');
const more = langs.length > 12 ? `+${langs.length - 12}` : '';
const noteHtml = extra.supportedLanguages.note
? `♪ ${extra.supportedLanguages.note}
`
: '';
return `
${SGIS_ICONS.globe} 支持语言 (${langs.length})
${shown}${more}
${noteHtml}
`;
})();
// v2.3.17: 预告片区块
const moviesHtml = (() => {
if (!extra || !extra.movies.length) return '';
const storeUrl = `https://store.steampowered.com/app/${APP_ID}/`;
const items = extra.movies.map(m => {
const playIcon = ``;
// 流媒体清单 URL 供 title 显示 (高级用户可复制到 VLC 播放)
const streamUrl = m.hls_h264 || m.dash_h264 || m.dash_av1 || '';
const titleParts = [m.name];
if (streamUrl) titleParts.push(`\n流媒体: ${streamUrl}\n(需 VLC/mpv 等播放器打开)`);
return `
${playIcon}
${m.name}
`;
}).join('');
return `
${SGIS_ICONS.video} 预告片 (${extra.movies.length})
${items}
点击缩略图在 Steam 商店页观看 · 悬停可查看流媒体清单 URL
`;
})();
// v2.3.16: 游戏特性 (分类标签, 工坊高亮)
const categoriesHtml = (() => {
if (!extra || !extra.categories.length) return '';
const pills = extra.categories.map(c => {
const isWorkshop = c.id === 30 || /创意工坊|steam\s*workshop/i.test(c.description);
return `${c.description}`;
}).join('');
return `
${SGIS_ICONS.tag} 游戏特性
${pills}
`;
})();
// v2.3.16: 捆绑包/购买选项
const packagesHtml = (() => {
if (!extra || !extra.packages.length) return '';
// 单个购买选项且为免费/原价时不展示 (避免冗余)
if (extra.packages.length === 1 && !extra.packages[0].discount) return '';
const items = extra.packages.map(p => {
const flagHtml = p.isFree
? '免费'
: (p.discount > 0 ? `-${p.discount}%` : '');
const priceHtml = p.isFree
? '免费'
: (p.priceText ? `${p.priceText}` : '');
return `
${flagHtml}
${p.name}
${priceHtml}
`;
}).join('');
return `
${SGIS_ICONS.package} 购买选项
${items}
`;
})();
// v2.3.16: 游戏截图 (3 列网格, 最多 9 张)
const screenshotsHtml = (() => {
if (!extra || !extra.screenshots.length) return '';
const items = extra.screenshots.map(s => `
`).join('');
return `
${SGIS_ICONS.image} 游戏截图
${items}
`;
})();
setBody(`
${coverUrl ? `

` : ''}
${o.name || ('游戏 ' + APP_ID)}
${devName ? `${SGIS_ICONS.package} 开发商 ${devName}` : ''}
${pubName && pubName !== devName ? `${SGIS_ICONS.share} 发行 ${pubName}` : ''}
${releaseDate ? `${releaseDate}` : ''}
${renderMyGameStatus()}
${subStatusHtml}
${(() => {
// v2.9.84: DRM/年龄/语音/免费等标签合并到同一行紧凑展示,避免逐行换行浪费空间
const badges = [renderDRMBadge(), renderChineseAudioBadge(), ageBadgeHtml, renderProfileStatusBadge()].filter(Boolean).join('');
if (!badges) return '';
return `${badges}
`;
})()}
${contentNoteHtml}
${renderCrackStatusBadge()}
${renderWorkshopIndicator()}
${SGIS_ICONS.info} 基本信息
${priceHtml}
${genres.length ? `
类型${genres.join(' / ')}
` : ''}
${(extra && extra.appType) ? `
应用类型${localizeAppType(extra.appType)}
` : ''}
${platforms.length ? `
平台${platforms.join(' / ')}
` : ''}
AppID${APP_ID}
${metaChipsHtml}
${categoriesHtml}
${languagesHtml}
${o.tags.length ? `
${SGIS_ICONS.info} 热门标签
${tagHtml}
` : ''}
${packagesHtml}
${dlcListHtml}
${moviesHtml}
${screenshotsHtml}
${o.shortDesc ? `${SGIS_ICONS.info} 简介
${o.shortDesc}
` : ''}
${(() => {
// v2.9.83: 在线玩家数区块 (仅在数据可用时显示,超时/失败则隐藏)
const cp = SGIS.currentPlayers;
if (!cp || cp.count == null) return '';
const countStr = cp.count >= 1000 ? (cp.count / 1000).toFixed(1) + 'k' : String(cp.count);
return `
${SGIS_ICONS.users || '👥'}
${countStr} ${isZh ? '人正在游玩' : 'in-game now'}
${isZh ? '当前在线玩家数' : 'Current players'}
`;
})()}
${(() => {
// v2.9.83: 评测摘要增强 (DOM + API 数据合并)
const rs = SGIS.reviewSummary;
const hasDomReviews = o.reviews.recent.count || o.reviews.all.count;
if (!hasDomReviews && !rs) return '';
// 构建 API 评测摘要
let apiReviewHtml = '';
if (rs && rs.totalReviews > 0) {
const posRate = rs.totalReviews > 0 ? Math.round(rs.totalPositive / rs.totalReviews * 100) : 0;
const cls = posRate >= 80 ? 'pos' : posRate >= 50 ? 'mix' : 'neg';
const thumb = posRate >= 70 ? 'up' : 'down';
// 近期评测
let recentHtml = '';
if (rs.recentReviews > 0) {
const rPosRate = Math.round(rs.recentPositive / rs.recentReviews * 100);
const rCls = rPosRate >= 80 ? 'pos' : rPosRate >= 50 ? 'mix' : 'neg';
const rThumb = rPosRate >= 70 ? 'up' : 'down';
recentHtml = `
${isZh ? '近期' : 'Recent'}
${rs.recentScoreDesc || ''}
${rs.recentReviews.toLocaleString()}
${reviewBar(rPosRate)}`;
}
apiReviewHtml = `
${isZh ? '全部' : 'All'}
${rs.reviewScoreDesc || ''}
${rs.totalReviews.toLocaleString()} ${isZh ? '篇评测' : 'reviews'}
${reviewBar(posRate)}${recentHtml}`;
}
// DOM 评测 (如果 API 数据未加载则用 DOM 数据)
const domReviewHtml = apiReviewHtml ? '' : (reviewRowHtml(o.reviews.recent) + reviewRowHtml(o.reviews.all));
return `
${SGIS_ICONS.star} 评测摘要
${apiReviewHtml || domReviewHtml}
`;
})()}
${renderSimilarGamesSection()}
${renderTagRecsSection()}
${SGIS_ICONS.info} 快捷链接
${linkHtml}
`);
// v2.3.16: 截图懒加载 (data-src -> src)
document.querySelectorAll('#sgis-body .sgis-screenshot-item img[data-src]').forEach(img => {
img.src = img.dataset.src;
});
// v2.3.17: 预告片缩略图懒加载
document.querySelectorAll('#sgis-body .sgis-movie-item img[data-src]').forEach(img => {
img.src = img.dataset.src;
});
// v2.3.19: 绑定相似游戏 + 类型推荐区块事件
bindSimilarGamesEvents();
bindTagRecsEvents();
// v2.3.19: 异步拉取类型扩展推荐(不阻塞首屏渲染)
if (IS_MORELIKE && o.sourceTagIds && o.sourceTagIds.length && !SGIS.tagRecs && !SGIS.tagRecsLoading) {
fetchTagRecommendations(o.sourceTagIds).then(recs => {
SGIS.tagRecs = recs;
if (SGIS.tab === 'overview') renderOverview();
}).catch(() => {});
}
// 异步获取家庭成员游玩时长,不阻塞渲染
fetchOwnersPlaytime().catch(() => {});
// v2.9.83: 异步获取在线玩家数 + 评测摘要 (不阻塞首屏渲染, 超时/失败则隐藏对应区块)
if (HAS_APP_ID) {
fetchCurrentPlayers().catch(() => {});
fetchReviewSummary().catch(() => {});
}
// v2.3.29: 异步回退——游戏未在 GetOwnedGames 中找到时,尝试 dynamicstore/userdata API
// 该 API 返回完整的 owned apps 列表(含 CD key 激活的游戏),比 GetOwnedGames 更可靠
if (HAS_APP_ID && !SGIS.dynamicStoreOwnedApps && !SGIS.dynamicStoreFetching) {
const info = getCurrentGameInfo();
if (!info.found) {
fetchDynamicStoreUserData().then(ownedApps => {
if (ownedApps && ownedApps.has(Number(APP_ID)) && SGIS.tab === 'overview') {
renderOverview();
}
}).catch(() => {});
}
}
}
// ---- 勋章标签 ----
// v2.9.77: 重写卡牌获取流程 — 移除有缺陷的社区登录前置检测,
// 改为直接请求市场数据 + SGLVBadge 多重降级检测卡牌支持状态。
// 旧版 checkCommunityLogin 误判 (网络超时/页面结构变更/Cloudflare) 会导致
// "Steam 社区未登录" 错误提示, 但 Steam 账户已登录即社区已登录, 不存在此问题。
// v2.9.77: 获取 SGLVBadge 库实例 (sglv-badge.lib.js)
function _getBadgeLib() {
return (typeof unsafeWindow !== 'undefined' && unsafeWindow.SGLVBadge)
|| (typeof window !== 'undefined' && window.SGLVBadge);
}
// v2.9.77: 将主脚本已加载的 SteamCardExchange 卡牌数据库注入 SGLVBadge,
// 使 L2 检测免额外网络请求 (复用 loadCardDatabase 的 24h 缓存)
function _injectCardDbToBadgeLib() {
const B = _getBadgeLib();
if (B && typeof B.setCardDb === 'function' && cardDbData) {
B.setCardDb(cardDbData);
}
}
function renderMedals() {
if (SGIS.cardsLoading) return;
if (SGIS.cards) { renderMedalsContent(); return; }
SGIS.cardsLoading = true;
renderLoading('正在获取集换式卡牌价格…');
// v2.9.77: 注入卡牌数据库, 直接请求市场数据 (无登录前置阻断)
_injectCardDbToBadgeLib();
fetchCardPrices(APP_ID).then(data => {
const allCards = [...(data.regular || []), ...(data.foil || [])];
if (allCards.length === 0) {
// 市场返回 0 张卡牌 — 检查游戏是否实际支持卡牌 (可能是市场 API 问题)
_handleEmptyMarketCards();
} else {
SGIS.cards = data;
SGIS.cardsLoading = false;
renderMedalsContent();
}
}).catch(e => {
// 市场请求失败 — 检查游戏是否支持卡牌, 区分网络错误 vs 无卡牌
_handleCardFetchError(e);
});
}
// v2.9.77: 市场返回 0 卡牌 — 多重降级检测卡牌支持状态
async function _handleEmptyMarketCards() {
const B = _getBadgeLib();
if (!B || typeof B.checkCardSupport !== 'function') {
// 库未加载 — 显示"无卡牌"消息 (保守处理)
SGIS.cards = { regular: [], foil: [], _source: 'market-empty' };
SGIS.cardsLoading = false;
renderMedalsContent();
return;
}
renderLoading('正在检测卡牌支持状态…');
try {
const categories = (SGIS.appDetailsExtra && SGIS.appDetailsExtra.categories) || [];
const support = await B.checkCardSupport(APP_ID, { categories });
if (support.supported) {
// 游戏有卡牌但市场未返回 — 使用 SCE 降级数据
_renderSCEFallback(support, 'empty');
} else {
// 游戏不支持卡牌
SGIS.cards = { regular: [], foil: [], _source: 'no-cards' };
SGIS.cardsLoading = false;
renderMedalsContent();
}
} catch (e) {
SGIS.cards = { regular: [], foil: [], _source: 'market-empty' };
SGIS.cardsLoading = false;
renderMedalsContent();
}
}
// v2.9.77: 市场请求失败 — 多重降级检测卡牌支持状态
async function _handleCardFetchError(error) {
const B = _getBadgeLib();
if (!B || typeof B.checkCardSupport !== 'function') {
// 库未加载 — 显示网络错误 (不再误判为"社区未登录")
SGIS.cardsLoading = false;
renderError('卡牌价格获取失败: ' + error.message);
return;
}
renderLoading('正在检测卡牌支持状态…');
try {
const categories = (SGIS.appDetailsExtra && SGIS.appDetailsExtra.categories) || [];
const support = await B.checkCardSupport(APP_ID, { categories });
if (support.supported) {
// 游戏有卡牌但市场请求失败 — 使用 SCE 降级数据
_renderSCEFallback(support, 'error', error.message);
} else if (support.source === 'none') {
// 无法确定是否支持卡牌 — 显示网络错误
SGIS.cardsLoading = false;
setBody(`
⚠️
网络错误,无法获取卡牌数据
${error.message}
请检查网络连接后重试
`);
} else {
// 游戏不支持卡牌
SGIS.cards = { regular: [], foil: [], _source: 'no-cards' };
SGIS.cardsLoading = false;
renderMedalsContent();
}
} catch (e) {
SGIS.cardsLoading = false;
renderError('卡牌价格获取失败: ' + error.message);
}
}
// v2.9.77: 使用 SteamCardExchange 降级数据渲染卡牌信息 (无价格数据)
async function _renderSCEFallback(support, reason, errorMsg) {
const B = _getBadgeLib();
let sceData = null;
// 如果 checkCardSupport 已返回页面数据 (L3), 直接使用
if (support.cards && support.cards.regular) {
sceData = support.cards;
} else {
// 否则单独获取 SCE 页面数据
try {
const pageData = await B.fetchCardExchangePage(APP_ID);
if (pageData && pageData.supported) {
sceData = { regular: pageData.regular, foil: pageData.foil };
}
} catch (e) {
// SCE 页面也获取失败
SGIS.cardsLoading = false;
if (reason === 'error') {
renderError('卡牌价格获取失败: ' + (errorMsg || ''));
} else {
SGIS.cards = { regular: [], foil: [], _source: 'market-empty' };
renderMedalsContent();
}
return;
}
}
if (!sceData || (!sceData.regular.length && !sceData.foil.length)) {
SGIS.cards = { regular: [], foil: [], _source: 'no-cards' };
SGIS.cardsLoading = false;
renderMedalsContent();
return;
}
// 构造兼容 renderMedalsContent 的数据结构
SGIS.cards = {
regular: sceData.regular || [],
foil: sceData.foil || [],
regularQueried: false,
foilQueried: false,
_source: 'steamcardexchange',
_reason: reason, // 'empty' 或 'error'
_errorMsg: errorMsg || '',
_supportSource: support.source,
};
SGIS.cardsLoading = false;
renderMedalsContent();
}
function renderMedalsContent() {
const data = SGIS.cards;
// v2.9.77: SCE 降级数据 — 有卡牌名称但无价格, 走专用渲染
if (data._source === 'steamcardexchange') {
renderMedalsSCEContent(data);
return;
}
const allCards = [...(data.regular || []), ...(data.foil || [])];
if (allCards.length === 0) {
// v2.9.77: 区分"确认无卡牌"和"市场返回空(未检测)"
const isConfirmedNoCards = data._source === 'no-cards';
setBody(`
🎴
${isConfirmedNoCards ? '该游戏没有可交易的集换式卡牌' : '未获取到卡牌数据'}
${isConfirmedNoCards ? '可能不支持 Steam 集换式卡牌' : '可能是网络问题或该游戏不支持卡牌,请重试'}
`);
return;
}
// 从卡牌价格文本中提取用户钱包货币符号 (如 ¥ ₹ $ 等),避免硬编码 CNY
const samplePriceText = allCards.find(c => c.sellPriceText)?.sellPriceText || '';
const curSymbol = (samplePriceText.match(/^([^\d.,\s]+)/) || [])[1] || '¥';
// sellPrice/buyPrice 都是最小单位 (分), 显示时 /100 换算到主单位
const sellTotal = (data.regular || []).reduce((s, c) => s + (c.sellPrice || 0), 0) / 100;
const foilSellTotal = (data.foil || []).reduce((s, c) => s + (c.sellPrice || 0), 0) / 100;
const buyTotal = (data.regular || []).reduce((s, c) => s + (c.buyPrice || 0), 0) / 100;
const netTotal = (data.regular || []).reduce((s, c) => s + (c.netPrice || 0), 0) / 100;
// v2.9.31: 计算中位数和忽略最高价均价 (借鉴 Trading Card Info 脚本统计模式)
const regPrices = (data.regular || []).filter(c => c.sellPrice != null).map(c => c.sellPrice).sort((a, b) => a - b);
let medianPrice = 0, avgNoMax = 0;
if (regPrices.length > 0) {
const mid = Math.floor(regPrices.length / 2);
medianPrice = regPrices.length % 2 !== 0 ? regPrices[mid] : Math.round((regPrices[mid - 1] + regPrices[mid]) / 2);
if (regPrices.length > 1) {
avgNoMax = Math.round(regPrices.slice(0, -1).reduce((s, p) => s + p, 0) / (regPrices.length - 1));
} else {
avgNoMax = regPrices[0];
}
}
const medianDisplay = medianPrice > 0 ? `${curSymbol}${(medianPrice / 100).toFixed(2)}` : '—';
const avgNoMaxDisplay = avgNoMax > 0 ? `${curSymbol}${(avgNoMax / 100).toFixed(2)}` : '—';
const showing = SGIS.medalTab;
const cards = showing === 'foil' ? (data.foil || []) : (data.regular || []);
const cardItemHtml = (c) => {
const sellMajor = toMajorUnit(c.sellPrice);
const buyMajor = toMajorUnit(c.buyPrice);
// 显示价格: 优先用 Steam 给的本地化文本, 其次用数值 (转主单位)
const sellDisplay = c.sellPriceText || (sellMajor != null ? curSymbol + sellMajor.toFixed(2) : '');
const buyDisplay = c.buyPrice != null ? curSymbol + buyMajor.toFixed(2) : (c.buyError ? '—' : '');
// v2.3.1: 卡牌图作为背景 (勋章页面), 缩略图同时保留; 失败时用精美 SVG 兜底
// 由于 CSS background-image 加载失败无事件, 用一个隐藏的 img 预加载检测, 成功后才启用背景
const iconHtml = c.iconUrl
? `
`
: `${SGIS_ICONS.medalFallback}
`;
// 卡牌图作为背景: 仅当有 iconUrl 时启用, 加载失败时移除 sgis-card-with-bg
const hasBg = !!c.iconUrl;
return `
${iconHtml}
${c.name.replace(/\(Foil\)$/i, '').trim()}${c.foil ? '闪' : ''}
${sellDisplay ? `出售 ${sellDisplay}` : ''}
${buyDisplay ? `求购 ${buyDisplay}` : ''}
${c.listings ? `${c.listings}件` : ''}
`;
};
setBody(`
${curSymbol}${sellTotal.toFixed(2)}
普通出售合计
${curSymbol}${netTotal.toFixed(2)}
到手价合计
${curSymbol}${buyTotal.toFixed(2)}
求购合计
中位价 (抗极端高价)${medianDisplay}
均价 (忽略最高价)${avgNoMaxDisplay}
${SGIS_ICONS.card} ${showing === 'foil' ? '闪卡' : '普通卡'} 列表
${cards.length ? cards.map(cardItemHtml).join('') : '
无此类卡牌
'}
${(data.foil || []).length ? `${SGIS_ICONS.info} 闪卡市场
闪卡出售合计${curSymbol}${foilSellTotal.toFixed(2)}
闪卡数量${data.foil.length}
` : ''}
出价数据来自 Steam 社区市场 · ${data.regularQueried ? '已查询' : '查询中'} · v2.9.32 到手价采用可变发行商费率精确计算
`);
const body = document.getElementById('sgis-body');
if (body) {
body.querySelectorAll('.sgis-card-toggle button').forEach(btn => {
btn.addEventListener('click', () => {
SGIS.medalTab = btn.dataset.group;
renderMedalsContent();
});
});
// v2.3.1: 异步预加载卡牌图, 成功则启用背景图, 失败则保持默认(不显示背景, 用 SVG 兜底已生效)
body.querySelectorAll('.sgis-card-pending-bg[data-bg-url]').forEach(card => {
const url = card.dataset.bgUrl;
if (!url) return;
const probe = new Image();
probe.onload = () => {
// 加载成功, 启用背景图 + 切换 class
card.style.setProperty('--sgis-card-bg', `url('${url}')`);
card.classList.remove('sgis-card-pending-bg');
card.classList.add('sgis-card-with-bg');
};
probe.onerror = () => {
// 加载失败, 移除待定状态, 保持纯缩略图 (fallback SVG)
card.classList.remove('sgis-card-pending-bg');
};
probe.src = url;
});
}
}
// v2.9.77: SteamCardExchange 降级渲染 — 有卡牌名称但无价格数据
// 当市场 API 失败或返回空, 但 SGLVBadge 确认游戏有卡牌时使用
function renderMedalsSCEContent(data) {
const showing = SGIS.medalTab;
const cards = showing === 'foil' ? (data.foil || []) : (data.regular || []);
const regCount = (data.regular || []).length;
const foilCount = (data.foil || []).length;
const reasonText = data._reason === 'error'
? `市场数据获取失败(${data._errorMsg || '网络错误'})`
: '市场未返回卡牌数据';
const sourceText = data._supportSource === 'appdetails' ? 'Steam 应用详情'
: data._supportSource === 'steamcardexchange-api' ? 'SteamCardExchange API'
: data._supportSource === 'steamcardexchange-page' ? 'SteamCardExchange 页面'
: '卡牌数据库';
const cardItemHtml = (c) => {
const iconHtml = `${SGIS_ICONS.medalFallback}
`;
return `
${iconHtml}
${c.name.replace(/\(Foil\)$/i, '').trim()}${c.foil ? '闪' : ''}
价格数据不可用
`;
};
setBody(`
⚠️ ${reasonText}
卡牌列表来自 ${sourceText} · 市场价格需访问 Steam 社区市场获取
${regCount + foilCount}
总计
${SGIS_ICONS.card} ${showing === 'foil' ? '闪卡' : '普通卡'} 列表
${cards.length ? cards.map(cardItemHtml).join('') : '
无此类卡牌
'}
`);
const body = document.getElementById('sgis-body');
if (body) {
body.querySelectorAll('.sgis-card-toggle button').forEach(btn => {
btn.addEventListener('click', () => {
SGIS.medalTab = btn.dataset.group;
renderMedalsContent();
});
});
}
}
// ---- 价格标签 (多地区价格 + ITAD 历史价格 + AI预测) ----
function appendHistoryPricesToBody(historyData) {
const bodyEl = document.getElementById('sgis-body');
if (!bodyEl || SGIS.tab !== 'prices') return;
// v2.9.82: 历史价格数据为空/加载失败时仍渲染 AI 预测按钮 (置灰状态)
const hasData = !!(historyData && (historyData.history?.length || historyData.lowest || historyData.discounts?.length));
if (hasData) {
const temp = document.createElement('div');
temp.innerHTML = renderHistoryPricesSection(historyData);
while (temp.firstChild) bodyEl.appendChild(temp.firstChild);
// v2.9.9: 绑定价格图表时间范围按钮事件
bindPriceChartRangeEvents(historyData);
}
// 追加 AI 预测按钮 (有数据=可点击, 无数据=置灰)
appendPredictButton(bodyEl, hasData);
// 追加已有预测结果
if (SGIS.prediction) appendPredictionResult(bodyEl);
}
function appendPredictButton(bodyEl, hasHistoryData) {
const enabledModes = storage.getPredictModes();
const modesLabel = enabledModes.map(m => {
const mode = PREDICT_MODES.find(p => p.key === m);
return mode ? mode.name : '';
}).filter(Boolean).join(' / ');
// v2.9.82: 无历史价格数据时按钮置灰 + 提示文案
const disabled = !hasHistoryData;
const disabledText = isZh ? '暂无历史价格数据,无法AI预测' : 'No price history, AI prediction unavailable';
const btnHtml = `
${SGIS.predictionLoading ? `
` : ''}
${SGIS.predictionError ? `
⚠️ ${T.aiPredictFail}: ${SGIS.predictionError}
` : ''}
`;
const temp = document.createElement('div');
temp.innerHTML = btnHtml;
while (temp.firstChild) bodyEl.appendChild(temp.firstChild);
const btn = document.getElementById('sgis-predict-trigger');
if (btn && !disabled) {
btn.addEventListener('click', () => triggerPrediction());
}
}
function triggerPrediction() {
// v2.9.82: 防重复点击 + 完整日志 + 超时保护 + 按钮等待状态
if (SGIS.predictionLoading) {
console.log('[SGIS] AI预测已在进行中, 忽略重复点击');
return;
}
if (!SGIS.historyPrices || (!SGIS.historyPrices.discounts?.length && !SGIS.historyPrices.history?.length)) {
console.log('[SGIS] AI预测取消: 无历史价格数据');
showToast(T.aiPredictNoData);
return;
}
const apiKey = storage.getAiApiKey();
if (!apiKey) {
console.log('[SGIS] AI预测取消: 未配置 API Key');
showToast(T.aiPredictNoKey);
return;
}
console.log('[SGIS] AI预测按钮点击, 开始流程');
SGIS.predictionLoading = true;
SGIS.predictionError = null;
SGIS.prediction = null;
// 按钮置于等待状态
const btn = document.getElementById('sgis-predict-trigger');
if (btn) {
btn.disabled = true;
btn.style.opacity = '0.5';
btn.style.cursor = 'wait';
btn.innerHTML = `${T.aiPredicting}`;
}
// 插入loading区域
let loadingEl = document.getElementById('sgis-predict-loading');
if (!loadingEl) {
const bodyEl = document.getElementById('sgis-body');
if (bodyEl) {
loadingEl = document.createElement('div');
loadingEl.id = 'sgis-predict-loading';
loadingEl.className = 'sgis-state';
loadingEl.style.cssText = 'padding:12px';
loadingEl.innerHTML = `${T.aiPredicting}`;
bodyEl.appendChild(loadingEl);
}
}
// 获取当前价格信息
const overview = SGIS.overview || {};
const currentPrice = overview.priceCurrent ? parseFloat(overview.priceCurrent.replace(/[^0-9.]/g, '')) : null;
const originalPrice = overview.priceOriginal ? parseFloat(overview.priceOriginal.replace(/[^0-9.]/g, '')) : null;
const gameName = overview.name || '';
// v2.9.82: 超时保护 (120s, 比 callAiApi 的 90s 稍长, 覆盖提示词加载时间)
let timeoutReached = false;
const timeoutId = setTimeout(() => {
if (SGIS.predictionLoading) {
timeoutReached = true;
console.warn('[SGIS] AI预测超时 (120s无响应)');
SGIS.predictionLoading = false;
const ld = document.getElementById('sgis-predict-loading');
if (ld) ld.remove();
if (btn) {
btn.disabled = false;
btn.style.opacity = '';
btn.style.cursor = '';
btn.innerHTML = ` ${T.aiPredictBtn}`;
}
const bodyEl = document.getElementById('sgis-body');
if (bodyEl) {
const errDiv = document.createElement('div');
errDiv.className = 'sgis-state sgis-error';
errDiv.style.cssText = 'padding:8px';
errDiv.textContent = `⚠️ ${T.aiPredictFail}: ${isZh ? '请求超时(120s无响应), 请检查网络或API配置' : 'Request timeout (120s), check network or API config'}`;
bodyEl.appendChild(errDiv);
}
}
}, 120000);
callAiPricePredict(SGIS.historyPrices, gameName, APP_ID, currentPrice, originalPrice)
.then(result => {
clearTimeout(timeoutId);
if (timeoutReached) return; // 超时已处理, 丢弃结果
console.log('[SGIS] AI预测成功, result:', result);
SGIS.prediction = result;
SGIS.predictionLoading = false;
// 移除loading
const ld = document.getElementById('sgis-predict-loading');
if (ld) ld.remove();
if (btn) {
btn.disabled = false;
btn.style.opacity = '';
btn.style.cursor = '';
btn.innerHTML = ` ${T.aiPredictBtn}`;
}
// 追加结果
const bodyEl = document.getElementById('sgis-body');
if (bodyEl) appendPredictionResult(bodyEl);
})
.catch(e => {
clearTimeout(timeoutId);
if (timeoutReached) return; // 超时已处理, 丢弃错误
console.error('[SGIS] AI预测失败:', e);
SGIS.predictionError = e.message;
SGIS.predictionLoading = false;
const ld = document.getElementById('sgis-predict-loading');
if (ld) ld.remove();
if (btn) {
btn.disabled = false;
btn.style.opacity = '';
btn.style.cursor = '';
btn.innerHTML = ` ${T.aiPredictBtn}`;
}
const bodyEl = document.getElementById('sgis-body');
if (bodyEl) {
const errDiv = document.createElement('div');
errDiv.className = 'sgis-state sgis-error';
errDiv.style.cssText = 'padding:8px';
errDiv.textContent = `⚠️ ${T.aiPredictFail}: ${e.message}`;
bodyEl.appendChild(errDiv);
}
});
}
function appendPredictionResult(bodyEl) {
if (!SGIS.prediction) return;
const temp = document.createElement('div');
temp.innerHTML = renderPredictionSection(SGIS.prediction);
while (temp.firstChild) bodyEl.appendChild(temp.firstChild);
}
function renderPredictionSection(prediction) {
const models = prediction.models || [];
const bestBuy = prediction.best_buy || {};
const enabledModes = storage.getPredictModes();
const modesLabel = enabledModes.map(m => {
const mode = PREDICT_MODES.find(p => p.key === m);
return mode ? mode.name : '';
}).filter(Boolean).join(' / ');
const modelsHtml = models.map(m => {
const confClass = m.confidence >= 70 ? 'high' : (m.confidence >= 45 ? 'mid' : 'low');
const dateStr = m.target_date ? new Date(m.target_date).toLocaleDateString('zh-CN') : '—';
const saleBadge = m.sale_event ? `${m.sale_event}` : '';
const indicatorsHtml = m.indicators ? Object.entries(m.indicators).map(([k, v]) => `${k}: ${v}
`).join('') : '';
return `
${m.mode_name || m.mode}
${T.aiPredictConfidence} ${m.confidence}%
${m.prediction || ''}
预测折扣: -${m.discount_percent || 0}%
· 到手: ${m.currency || ''}${m.predicted_price != null ? Number(m.predicted_price).toFixed(2) : '—'}
· 时间: ${m.days_until != null ? m.days_until + T.aiPredictDays : '—'}${dateStr !== '—' ? ` (${dateStr})` : ''}
${saleBadge}
${m.detail ? `
${m.detail}
` : ''}
${indicatorsHtml ? `
${indicatorsHtml}
` : ''}
`;
}).join('');
const urgencyLabel = bestBuy.urgency === 'high' ? '🔴 尽快入手' : (bestBuy.urgency === 'medium' ? '🟡 可以等待' : '🟢 不急');
const bestBuyHtml = bestBuy.recommendation ? `
${T.aiPredictRecommendation} · ${urgencyLabel}
${bestBuy.recommendation}
${bestBuy.best_time ? `
${T.aiPredictBestTime}: ${bestBuy.best_time}
` : ''}
${bestBuy.best_price ? `
预测最佳价格: ${Number(bestBuy.best_price).toFixed(2)}
` : ''}
${bestBuy.wait_days != null ? `
建议等待: ${bestBuy.wait_days}天
` : ''}
` : '';
return `
${SGIS_ICONS.trend || '🔮'} ${T.aiPredictResult}
${modelsHtml}
${bestBuyHtml}
分析模式: ${modesLabel}
`;
}
// v2.9.79: ITAD 增强模式 — 将 ITAD 区域价格归一化为现有格式
function normalizeItadRegionPrices(regionPrices) {
const prices = [];
const failed = [];
const seenKey = new Set();
for (const [country, data] of Object.entries(regionPrices)) {
if (!data || !data.deals || !data.deals.length) {
failed.push({ region: country, error: 'ITAD 无数据' });
continue;
}
const deal = data.deals[0]; // 取最优价格
const priceObj = deal.price || {};
const regularObj = deal.regular || {};
const k = `${priceObj.currency}:${Number(priceObj.amount || 0).toFixed(2)}`;
if (seenKey.has(k)) { failed.push({ region: country, error: '与已有地区价格重复' }); continue; }
seenKey.add(k);
prices.push({
region: country,
price: priceObj.amount || 0,
currency: priceObj.currency || '',
discount: deal.cut || 0,
initial: regularObj.amount || 0,
source: 'itad',
});
}
return { prices, failed };
}
// v2.9.79: ITAD 增强模式 — 多商店比价 + 活跃 Bundle 展示
function appendItadOverviewToBody() {
const overview = SGIS.itadOverview;
if (!overview || !overview.prices.length) return;
const num = (v) => { const n = Number(v); return isNaN(n) ? 0 : n; };
const fmt = (v) => num(v).toFixed(2);
const item = overview.prices[0]; // 单游戏
// 多商店比价表 (from overview.current)
let storeCompareHtml = '';
if (item.current) {
const cur = item.current;
const curPrice = cur.price || {};
const curRegular = cur.regular || {};
const curCut = cur.cut || 0;
const isLowest = item.lowest && curPrice.amount <= (item.lowest.price || {}).amount;
const cny = curPrice.currency === 'CNY' ? num(curPrice.amount) : (SGIS.rateReady ? toCNY(num(curPrice.amount), curPrice.currency) : null);
storeCompareHtml = `
${SGIS_ICONS.price} ${isZh ? '最优商店价格' : 'Best Store Price'} ${isLowest ? '史低' : ''}
${isZh ? '商店' : 'Store'}${(cur.shop || {}).name || '—'}
${isZh ? '当前价' : 'Current'}${fmt(curPrice.amount)} ${curPrice.currency || ''} ${curCut > 0 ? '-' + curCut + '%' : ''}
${curCut > 0 ? `
${isZh ? '原价' : 'Regular'}${fmt(curRegular.amount)} ${curRegular.currency || ''}
` : ''}
${cny != null ? `
≈ CNY¥${fmt(cny)}
` : ''}
`;
}
// 历史最低 (from overview.lowest)
let histLowHtml = '';
if (item.lowest) {
const low = item.lowest;
const lowPrice = low.price || {};
const lowCny = lowPrice.currency === 'CNY' ? num(lowPrice.amount) : (SGIS.rateReady ? toCNY(num(lowPrice.amount), lowPrice.currency) : null);
const lowDate = low.timestamp ? new Date(low.timestamp).toLocaleDateString(isZh ? 'zh-CN' : 'en-US') : '—';
histLowHtml = `${isZh ? '历史最低' : 'Historical Low'}${fmt(lowPrice.amount)} ${lowPrice.currency || ''} ${low.cut ? '(-' + low.cut + '%)' : ''} · ${(low.shop || {}).name || ''} · ${lowDate}
`;
if (lowCny != null) histLowHtml += `≈ CNY¥${fmt(lowCny)}
`;
}
// 活跃 Bundle
let bundleHtml = '';
if (overview.bundles && overview.bundles.length) {
const bundleItems = overview.bundles.map(b => {
const tier = b.tiers && b.tiers[0];
const tierPrice = tier && tier.price ? tier.price : {};
const gameCount = b.tiers ? b.tiers.reduce((s, t) => s + (t.games ? t.games.length : 0), 0) : 0;
const cny = tierPrice.currency === 'CNY' ? num(tierPrice.amount) : (SGIS.rateReady ? toCNY(num(tierPrice.amount), tierPrice.currency) : null);
return `${b.title || 'Bundle'}${tierPrice.amount ? fmt(tierPrice.amount) + ' ' + (tierPrice.currency || '') : '—'}${cny != null ? ' · ≈¥' + fmt(cny) : ''}${gameCount ? ' · ' + gameCount + (isZh ? '款' : ' games') : ''}
`;
}).join('');
bundleHtml = `
${SGIS_ICONS.trend} ${isZh ? '活跃 Bundle' : 'Active Bundles'} (${overview.bundles.length})
${bundleItems}
`;
}
// ITAD 增强标识
const badge = `⚡ ${isZh ? 'ITAD 增强模式' : 'ITAD Enhanced'} · ${isZh ? '数据来自 IsThereAnyDeal API' : 'Data from IsThereAnyDeal API'}
`;
const body = document.getElementById('sgis-body');
if (body) body.insertAdjacentHTML('beforeend', storeCompareHtml + histLowHtml + bundleHtml + badge);
}
function renderPrices() {
if (SGIS.pricesLoading) return;
if (SGIS.prices) {
renderPricesContent();
if (SGIS.itadOverview) appendItadOverviewToBody();
appendHistoryPricesToBody(SGIS.historyPrices);
appendGiftRecommendationToBody();
return;
}
SGIS.pricesLoading = true;
// v2.9.79: ITAD 增强模式 (用户配置了 ITAD Key 时启用)
const useItadEnhanced = _itad && storage.getItadApiKey() && _itad.hasApiKey();
if (useItadEnhanced) {
renderLoading(isZh ? '正在获取 ITAD 增强价格…' : 'Fetching ITAD enhanced prices…');
refreshRates().catch(() => {});
(async () => {
try {
const itadId = await _itad.lookupItadId(APP_ID);
if (!itadId) throw new Error('ITAD lookup 失败');
// 并行: 价格概览(多商店+史低+Bundle) + 多区域Steam价格(15+区域)
const [overview, regionPrices] = await Promise.all([
_itad.fetchPriceOverview([itadId], { country: 'US' }),
_itad.fetchMultiRegionPrices(itadId, EXTENDED_REGIONS, { shops: [_itad.SHOP_STEAM], concurrency: 3 }),
]);
// 归一化区域价格为现有格式
const normalized = normalizeItadRegionPrices(regionPrices);
SGIS.prices = Object.assign(normalized, { ratesReady: SGIS.rateReady });
SGIS.itadOverview = overview;
renderPricesContent();
appendItadOverviewToBody();
SGIS.giftRec = calculateGiftRecommendation(SGIS.prices);
appendGiftRecommendationToBody();
// 异步获取历史价格
fetchHistoryPrices(APP_ID).then(historyData => {
SGIS.historyPrices = historyData;
appendHistoryPricesToBody(historyData);
}).catch(() => {
// v2.9.82: 历史价格加载失败时仍渲染 AI 预测按钮 (置灰)
appendHistoryPricesToBody(null);
});
} catch (e) {
console.warn('[SGIS] ITAD 增强失败, 回退基础模式:', e.message);
// 回退到基础模式 (AugmentedSteam + Steam appdetails)
fetchMultiRegionPrices(APP_ID)
.then(priceData => {
SGIS.prices = Object.assign(priceData, { ratesReady: SGIS.rateReady });
renderPricesContent();
SGIS.giftRec = calculateGiftRecommendation(SGIS.prices);
appendGiftRecommendationToBody();
fetchHistoryPrices(APP_ID).then(historyData => {
SGIS.historyPrices = historyData;
appendHistoryPricesToBody(historyData);
}).catch(() => {
// v2.9.82: 历史价格加载失败时仍渲染 AI 预测按钮 (置灰)
appendHistoryPricesToBody(null);
});
})
.catch(err => renderError(isZh ? '价格获取失败: ' + err.message : 'Price fetch failed: ' + err.message));
} finally {
SGIS.pricesLoading = false;
}
})();
} else {
// 基础模式 (原有逻辑)
renderLoading(isZh ? '正在获取多地区价格…' : 'Fetching multi-region prices…');
refreshRates().catch(() => {});
fetchMultiRegionPrices(APP_ID)
.then(priceData => {
SGIS.prices = Object.assign(priceData, { ratesReady: SGIS.rateReady });
renderPricesContent();
SGIS.giftRec = calculateGiftRecommendation(SGIS.prices);
appendGiftRecommendationToBody();
fetchHistoryPrices(APP_ID).then(historyData => {
SGIS.historyPrices = historyData;
appendHistoryPricesToBody(historyData);
}).catch(() => {
// v2.9.82: 历史价格加载失败时仍渲染 AI 预测按钮 (置灰)
appendHistoryPricesToBody(null);
});
})
.catch(e => renderError(isZh ? '价格获取失败: ' + e.message : 'Price fetch failed: ' + e.message))
.finally(() => { SGIS.pricesLoading = false; });
}
}
function renderPricesContent() {
const data = SGIS.prices;
const userRegion = (document.cookie.match(/steamCountry=(\w{2})/) || [])[1] || 'CN';
if (!data.prices || !data.prices.length) {
const failText = data.failed && data.failed.length
? data.failed.map(f => `${f.region}: ${f.error}`).slice(0, 5).join(' / ')
: '未知原因';
setBody(`
💱
未获取到任何地区的价格数据
失败: ${failText}
可能是 Steam 限流或网络问题,稍后重试
`);
return;
}
// v2.3.7: 安全数值转换 (修复 price?.toFixed is not a function, Aug API 可能返回字符串)
const num = (v) => { const n = Number(v); return isNaN(n) ? 0 : n; };
const fmt = (v) => num(v).toFixed(2);
const enriched = data.prices.map(p => {
const safePrice = num(p.price);
const cny = p.currency === 'CNY' ? safePrice : (SGIS.rateReady ? toCNY(safePrice, p.currency) : null);
const info = REGIONS.find(r => r.code === p.region) || { name: p.region, code: p.region };
return Object.assign({}, p, {
price: safePrice,
initial: num(p.initial),
discount: num(p.discount),
cny: cny != null ? num(cny) : null,
regionName: info,
});
}).filter(p => p.cny != null && p.currency !== 'FREE' && p.price > 0);
if (!enriched.length) {
setBody(`
💱
未获取到有效的价格数据 (${data.prices.length} 个地区失败)
`);
return;
}
enriched.sort((a, b) => a.cny - b.cny);
const lowest = enriched[0];
const highest = enriched[enriched.length - 1];
// 旗帜图: 用 flagcdn.com, 加载失败 fallback 到文本代码
const flagHtml = (p) => {
const url = flagUrl(p.region);
return `
`;
};
// 顶部 featured card (最低价特别展示)
const lowestDiscount = lowest.discount > 0
? `-${lowest.discount}%`
: '';
const lowestOriginal = lowest.discount > 0
? `${fmt(lowest.initial)}`
: '';
const featuredHtml = `
${lowest.regionName.name} ${lowest.region}
${lowestOriginal}${fmt(lowest.price)} ${lowest.currency}${lowestDiscount}
≈ ¥${fmt(lowest.cny)}
`;
// v2.8.0: 完整对比表格(区域名称 | 原价 | 折后价 | 折扣率 | 换算后CNY价格)
// 按换算后CNY价格升序,最低价高亮绿色,最高价标记红色
const priceTableRowHtml = (p) => {
const isUser = p.region === userRegion;
const isLowest = p.region === lowest.region;
const isHighest = p.region === highest.region && enriched.length > 1;
const rowCls = isLowest ? 'sgis-price-tbl-row-lowest' : (isHighest ? 'sgis-price-tbl-row-highest' : '');
const userTag = isUser ? '本区' : '';
const badge = isLowest ? '最低' : (isHighest ? '最高' : '');
const discountCell = p.discount > 0
? `-${p.discount}%`
: '—';
const initialCell = p.discount > 0
? `${fmt(p.initial)}`
: `—`;
return `
${flagHtml(p)}
${p.regionName.name} ${userTag}${badge}
${initialCell}
${fmt(p.price)} ${p.currency}
${discountCell}
¥${fmt(p.cny)}
`;
};
// v2.8.0: 当前区域价格 vs 历史最低价对比
const userPrice = enriched.find(p => p.region === userRegion);
let historyCompareHtml = '';
if (SGIS.historyPrices && SGIS.historyPrices.lowest && userPrice) {
const histLow = SGIS.historyPrices.lowest;
const histCny = histLow.currency === 'CNY' ? num(histLow.price) : (SGIS.rateReady ? toCNY(num(histLow.price), histLow.currency) : null);
if (histCny != null) {
const diff = userPrice.cny - num(histCny);
const diffPct = (diff / userPrice.cny) * 100;
const isHigher = diff > 0;
historyCompareHtml = `
${SGIS_ICONS.trend} 当前价格 vs 历史最低
本区当前¥${fmt(userPrice.cny)}
历史最低¥${fmt(histCny)}${histLow.cut ? ` (-${histLow.cut}%)` : ''}
价差${isHigher ? '↑' : '↓'} ¥${fmt(Math.abs(diff))} (${Math.abs(diffPct).toFixed(1)}%)
`;
}
}
const sources = [...new Set(enriched.map(p => p.source))];
const sourceText = sources.map(s => s === 'aug' ? 'AugmentedSteam' : s === 'steam' ? 'Steam 官方' : s).join(' + ');
setBody(`
${SGIS_ICONS.price} 多地区低价排行
${featuredHtml}
${SGIS_ICONS.barChart} 9区完整价格对比
${enriched.map(priceTableRowHtml).join('')}
${historyCompareHtml}
${SGIS_ICONS.info} 比价概览
最低价¥${fmt(lowest.cny)} (${lowest.regionName.name})
最高价¥${fmt(highest.cny)} (${highest.regionName.name})
价差幅度¥${fmt(highest.cny - lowest.cny)} (${((highest.cny - lowest.cny) / lowest.cny * 100).toFixed(1)}%)
本地区${userRegion}${userPrice ? ` · ¥${fmt(userPrice.cny)}` : ''}
有效价格${enriched.length} / ${PRIORITY_REGIONS.length} 区
失败/重复${(data.failed || []).length}
数据源${sourceText}
旗帜来自 flagcdn.com · 价格仅供参考 · 3并发+1s限速
`);
}
// ---- v2.9.9: DRM 警告检测 (参考 Steam_Buff drm-warning.js) ----
// 检测 Denuvo 等第三方 DRM, 从 DOM 和 appdetails 描述中扫描
function detectDRM() {
if (SGIS.drmInfo) return SGIS.drmInfo;
const results = [];
// 1. DOM 扫描: 购买区域和游戏描述区域
const purchaseSections = document.querySelectorAll('.game_area_purchase_game, .game_area_description, .game_details');
const drmPatterns = [
{ regex: /denuvo/i, label: 'Denuvo Anti-Tamper', severity: 'warn' },
{ regex: /securom/i, label: 'SecuROM', severity: 'warn' },
{ regex: /games for windows live|GFWL/i, label: 'Games for Windows Live', severity: 'warn' },
{ regex: /uplay/i, label: 'Ubisoft Connect (Uplay)', severity: 'info' },
{ regex: /rockstar.*launcher|RGL/i, label: 'Rockstar Launcher', severity: 'info' },
{ regex: /ea.*app|origin.*client/i, label: 'EA App', severity: 'info' },
{ regex: /bethesda.*launcher/i, label: 'Bethesda Launcher', severity: 'info' },
{ regex: /3rd.party.drm|third.party.drm/i, label: '第三方 DRM', severity: 'warn' },
];
const found = new Set();
purchaseSections.forEach(sec => {
const text = sec.textContent || '';
drmPatterns.forEach(p => {
if (p.regex.test(text) && !found.has(p.label)) {
found.add(p.label);
results.push({ label: p.label, severity: p.severity });
}
});
});
// 2. appdetails 缓存补充检测
const extra = SGIS.appDetailsExtra;
if (extra && extra.contentDescriptors && extra.contentDescriptors.notes) {
const notes = extra.contentDescriptors.notes;
if (/denuvo/i.test(notes) && !found.has('Denuvo Anti-Tamper')) {
results.push({ label: 'Denuvo Anti-Tamper', severity: 'warn' });
}
}
SGIS.drmInfo = { list: results, hasDRM: results.length > 0 };
return SGIS.drmInfo;
}
function renderDRMBadge() {
const drm = detectDRM();
if (!drm.hasDRM) return '';
return drm.list.map(d => {
const cls = d.severity === 'warn' ? 'warn' : '';
return `${SGIS_ICONS.shield} ${d.label}`;
}).join('');
}
// ---- v2.9.9: 中文语音支持醒目标记 (参考 Steam_Buff audio-check.js) ----
function renderChineseAudioBadge() {
const extra = SGIS.appDetailsExtra;
if (!extra || !extra.supportedLanguages || !extra.supportedLanguages.list.length) return '';
const langs = extra.supportedLanguages.list;
// 匹配简体中文/繁体中文/Simplified Chinese/Traditional Chinese
const chineseAudio = langs.find(l =>
/chinese|中文/i.test(l.name) && l.audio
);
if (!chineseAudio) return '';
return `${SGIS_ICONS.globe} ${chineseAudio.name} 语音支持`;
}
// ---- v2.9.93: 资料受限状态检测 (参考 Nailoooooong/steam-restricted-game-realtime-userscript) ----
// Steam 商店页通过 .game_area_details_specs_ctn.learning_about 标记资料受限状态:
// - .label 含 "Profile Features Limited" → 资料受限 (卡片/成就/时长不显示在个人资料页)
// - 存在 learning_about 但不是 Profile Features Limited → Steam 正在了解
// - 不存在 learning_about → 非受限
// /app/* 页面直接从 DOM 检测 (零网络开销); 非游戏页通过 fetchProfileFeaturesStatus 异步获取
function detectProfileFeaturesStatus() {
if (SGIS.profileFeaturesStatus) return SGIS.profileFeaturesStatus;
// /app/* 页面直接从 DOM 检测
const learningEl = document.querySelector('.game_area_details_specs_ctn.learning_about');
let result;
if (!learningEl) {
result = { status: 'normal', label: '资料正常', source: 'dom' };
} else {
const labelEl = learningEl.querySelector('.label');
const labelText = (labelEl ? labelEl.textContent : '') || learningEl.textContent || '';
if (/Profile\s+Features\s+Limited/i.test(labelText)) {
result = { status: 'restricted', label: '资料受限', source: 'dom' };
} else {
result = { status: 'learning', label: 'Steam正在了解', source: 'dom' };
}
}
SGIS.profileFeaturesStatus = result;
return result;
}
// 异步获取资料受限状态 (用于搜索详情浮窗等非 /app/* 页面场景)
// 通过 GM_xmlhttpRequest 获取商店页 HTML,解析 learning_about 元素
async function fetchProfileFeaturesStatus(appId) {
if (!appId) return null;
// 1. 先看内存缓存
if (SGIS.profileFeaturesStatus && SGIS.profileFeaturesStatus._appId === String(appId)) {
return SGIS.profileFeaturesStatus;
}
// 2. 再看 GM 持久化缓存
const cached = cacheGet('profileFeatures_' + appId);
if (cached) {
SGIS.profileFeaturesStatus = { ...cached, _appId: String(appId) };
return SGIS.profileFeaturesStatus;
}
// 3. GM_xmlhttpRequest 获取商店页 HTML
const url = `https://store.steampowered.com/app/${appId}/?l=english&cc=us`;
try {
const html = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url,
headers: {
'Accept': 'text/html',
'Accept-Language': 'en-US,en;q=0.9',
'Cookie': 'mature_content=1; birthtime=441734400; lastagecheckage=1-January-1900; Steam_Language=english',
},
timeout: 15000,
onload(r) {
if (r.status < 200 || r.status >= 300) { reject(new Error(`HTTP ${r.status}`)); return; }
resolve(r.responseText || '');
},
onerror: () => reject(new Error('Network error')),
ontimeout: () => reject(new Error('Timeout')),
});
});
// 解析 HTML
const doc = new DOMParser().parseFromString(html, 'text/html');
const learningEl = doc.querySelector('.game_area_details_specs_ctn.learning_about');
let result;
if (!learningEl) {
result = { status: 'normal', label: '资料正常', source: 'fetch' };
} else {
const labelEl = learningEl.querySelector('.label');
const labelText = (labelEl ? labelEl.textContent : '') || learningEl.textContent || '';
if (/Profile\s+Features\s+Limited/i.test(labelText)) {
result = { status: 'restricted', label: '资料受限', source: 'fetch' };
} else {
result = { status: 'learning', label: 'Steam正在了解', source: 'fetch' };
}
}
result._appId = String(appId);
SGIS.profileFeaturesStatus = result;
cacheSet('profileFeatures_' + appId, result, CACHE_TTL.profileFeatures);
return result;
} catch (e) {
console.warn('[SGIS] 资料受限状态获取失败:', appId, e.message);
return null;
}
}
function renderProfileStatusBadge() {
const info = detectProfileFeaturesStatus();
// 仅在受限/正在了解时显示徽章,正常状态不显示 (避免视觉噪声)
if (!info || info.status === 'normal') return '';
const cls = info.status || 'normal';
const title = info.status === 'restricted'
? 'Profile Features Limited:该游戏的卡片、成就、游玩时长等资料不会显示在 Steam 个人资料页'
: 'Steam 正在了解该游戏,部分资料功能可能暂时受限';
return `${SGIS_ICONS.profileLimited} ${escHtml(info.label)}`;
}
// ---- v2.9.10: gamestatus.info 破解状态集成 (参考 steam-game-status.js) ----
// 数据源: https://gamestatus.info/back/api/gameinfo/game/{slug}/
// slug 解析: URL 路径提取 → 游戏标题 slugify → steam_prod_id 验证
const GAMESTATUS_API = 'https://gamestatus.info/back/api/gameinfo/game';
const GAMESTATUS_MAX_SLUGS = 2;
function slugifyGameStatus(text) {
return String(text || '')
.toLowerCase()
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[™®©'':"]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/-+/g, '-');
}
function buildGameStatusSlugs(appId, gameName) {
const candidates = [];
const add = (slug) => {
if (slug && slug.length > 1 && !candidates.includes(slug)) candidates.push(slug);
};
// 1. 从 URL 路径提取 slug (如 /app/1245620/ELDEN_RING/)
const pathMatch = location.pathname.match(/\/app\/\d+\/([^/?#]+)/i);
if (pathMatch) add(slugifyGameStatus(pathMatch[1].replace(/_/g, '-')));
// 2. 从游戏标题生成 slug
const name = String(gameName || '').replace(/\s+/g, ' ').trim();
if (name) {
add(slugifyGameStatus(name));
add(slugifyGameStatus(name.replace(/\s*[-–—:|].*$/, '')));
}
return candidates.slice(0, GAMESTATUS_MAX_SLUGS);
}
async function fetchGameStatus(appId, gameName) {
// 1. 先看 SGIS 内存缓存
if (SGIS.gameStatusInfo) return SGIS.gameStatusInfo;
// 2. 再看 GM 持久化缓存
const cached = cacheGet('gameStatus_' + appId);
if (cached) { SGIS.gameStatusInfo = cached; return cached; }
// 3. 构建 slug 候选并逐个尝试 API
const slugs = buildGameStatusSlugs(appId, gameName);
if (!slugs.length) return null;
for (const slug of slugs) {
try {
const url = `${GAMESTATUS_API}/${encodeURIComponent(slug)}/`;
const data = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url,
headers: { 'Accept': 'application/json', 'Accept-Language': 'zh-CN' },
timeout: 10000,
onload(r) {
if (r.status === 404) { resolve(null); return; }
if (r.status < 200 || r.status >= 300) { reject(new Error(`HTTP ${r.status}`)); return; }
try { resolve(JSON.parse(r.responseText)); }
catch { reject(new Error('JSON parse fail')); }
},
onerror: () => reject(new Error('Network error')),
ontimeout: () => reject(new Error('Timeout')),
});
});
// 验证 steam_prod_id 匹配 (防误匹配)
if (data && (!data.steam_prod_id || String(data.steam_prod_id) === String(appId))) {
// v2.9.11: 增强数据捕获 — 硬件需求 + Metacritic 评分 + 发售日
const hw = [
data.cpu_info && { k: 'CPU', v: data.cpu_info },
data.ram_info && { k: 'RAM', v: data.ram_info },
data.gpu_info && { k: 'GPU', v: data.gpu_info },
data.os_info && { k: 'OS', v: data.os_info },
].filter(Boolean);
const result = {
status: data.readable_status || '',
protections: data.protections || '',
hackedGroups: data.hacked_groups_en || data.hacked_groups || '',
crackDate: data.crack_date || '',
isAAA: !!data.is_AAA,
userScore: data.user_score || null,
metacriticScore: data.mata_score || null, // API 字段名为 mata_score
releaseDate: data.release_date || '',
hardware: hw.length ? hw : null,
slug: data.slug || slug,
};
SGIS.gameStatusInfo = result;
cacheSet('gameStatus_' + appId, result, CACHE_TTL.gameStatus);
return result;
}
} catch { /* try next slug */ }
}
// 4. 全部 slug 未命中, 缓存 null 避免反复请求
SGIS.gameStatusInfo = { notFound: true };
cacheSet('gameStatus_' + appId, { notFound: true }, CACHE_TTL.gameStatus);
return SGIS.gameStatusInfo;
}
function renderCrackStatusBadge() {
const info = SGIS.gameStatusInfo;
if (!info || info.notFound) return '';
// 状态分类 → 颜色/图标
const status = String(info.status || '').toLowerCase();
let cls = '', label = info.status || '未知';
if (/cracked|взлом/.test(status)) { cls = 'cracked'; label = info.status; }
else if (/bypass|обход|hypervisor/.test(status) || /bypass|обход/.test(String(info.hackedGroups).toLowerCase())) { cls = 'bypass'; }
else if (/not cracked|не взлом|unbroken|unreleased/.test(status)) { cls = 'not-cracked'; }
else if (/release today|релиз сегодня|выходит сегодня/.test(status)) { cls = 'release-today'; }
// 保护机制 chips
const protections = String(info.protections || '').split(/[,;/|]+/).map(s => s.trim()).filter(Boolean);
const protChips = protections.map(p => `${p}`).join('');
// 破解组织
const groups = String(info.hackedGroups || '').split(/[,;/|]+/).map(s => s.trim()).filter(Boolean);
const groupChips = groups.map(g => `${g}`).join('');
// 破解日期
const crackDateStr = info.crackDate
? new Date(info.crackDate).toLocaleDateString('zh-CN')
: '';
// v2.9.11: 用户评分 + Metacritic 评分 chips
const scoreChips = [
info.userScore ? `${SGIS_ICONS.star} ${info.userScore}` : '',
info.metacriticScore ? `Meta ${info.metacriticScore}` : '',
].filter(Boolean).join('');
const chips = [
`${SGIS_ICONS.shield} ${label}`,
info.isAAA ? 'AAA' : '',
protChips,
groupChips,
crackDateStr ? `破解于 ${crackDateStr}` : '',
scoreChips,
].filter(Boolean).join('');
if (!chips && !info.hardware) return '';
// v2.9.11: 硬件需求区块
const escHw = (s) => String(s || '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');
const hwHtml = info.hardware && info.hardware.length
? `
${SGIS_ICONS.info} 硬件需求 (gamestatus.info)
${info.hardware.map(h => `
${h.k}${escHw(h.v)}
`).join('')}
`
: '';
return `
${chips ? `
${chips}
` : ''}
${hwHtml}
`;
}
// ---- 家庭组共享支持检测 ----
function detectFamilyShareSupport() {
// 从 DOM 检测 Steam 原生家庭共享提示
// Steam 在 /app/* 页面会显示 .game_area_purchase_game 内的共享信息
const shareEl = document.querySelector('.game_area_purchase_game .game_area_purchase_family_share, .family_sharing_notice');
if (shareEl) return { supported: true, source: 'dom' };
// 检测页面中是否有 "Steam Family Sharing" 相关文本
const purchaseSection = document.querySelector('.game_area_purchase_game, #purchaseOptions');
if (purchaseSection) {
const text = purchaseSection.textContent || '';
if (/family sharing|家庭共享|Steam Family/i.test(text)) return { supported: true, source: 'dom-text' };
if (/not eligible for family sharing|不支持家庭共享/i.test(text)) return { supported: false, source: 'dom-text' };
}
// 从 appdetails API 缓存检测
const cached = cacheGet('familyShare_' + APP_ID);
if (cached !== null) return cached;
return null;
}
async function fetchFamilyShareSupport() {
// 先看 DOM
const domResult = detectFamilyShareSupport();
if (domResult) return domResult;
// 从本地缓存检测
const cached = cacheGet('familyShare_' + APP_ID);
if (cached !== null) return cached;
// v2.9.57: 委托 SGLVAppDetail.loadDetail 共享缓存(与 fetchAppDetailsExtra 复用同一请求)
// category 41 = 家庭共享
try {
const A = (typeof unsafeWindow !== 'undefined' && unsafeWindow.SGLVAppDetail)
|| (typeof window !== 'undefined' && window.SGLVAppDetail);
if (A && typeof A.loadDetail === 'function') {
const detail = await A.loadDetail(APP_ID, { useCache: true });
if (detail && detail.categoryObjs) {
const hasFamilyShare = detail.categoryObjs.some(c =>
c.id === 41 || /family sharing/i.test(c.description || ''));
const result = { supported: !!hasFamilyShare, source: 'api' };
cacheSet('familyShare_' + APP_ID, result, CACHE_TTL.familyShare);
return result;
}
}
} catch { /* ignore */ }
const fallback = { supported: null, source: 'unknown' };
return fallback;
}
function renderFamilyShareIndicator(info) {
// v2.9.62: 若该游戏已通过家庭组共享给当前用户(isSharedOnly),实际共享状态优先于
// 能力检测,避免出现"共享给你"与"不支持家庭组共享"自相矛盾的描述
if (info && info.isSharedOnly) {
return `
✓
家庭组共享中
`;
}
if (!SGIS.familyShareSupported) return '';
const s = SGIS.familyShareSupported;
if (s.supported === true) {
return `
✓
支持家庭组共享
${s.source}
`;
} else if (s.supported === false) {
return `
✕
不支持家庭组共享
`;
}
return '';
}
// ---- v2.3.16: appdetails API 增强信息 (创意工坊/截图/分类/捆绑包等) ----
// 数据源:https://store.steampowered.com/api/appdetails?appids={APP_ID}&l=schinese
// 工坊识别:categories 中 id=30 或描述包含 "创意工坊"/"Steam Workshop"
// 附加信息:screenshots / categories / platforms / packages / genres / metacritic / recommendations 等
// v2.9.57: 委托 SGLVAppDetail.loadDetail() 获取 appdetails 数据,
// 消除与 sglv-app-detail.lib.js 的重复 API 调用+解析+缓存逻辑(净减约 120 行)
// 库提供 3 级缓存(内存 5min + 磁盘 24h + 失败黑名单)+ 中文→英文→DOM 三级降级 + 429 退避
async function fetchAppDetailsExtra() {
const A = (typeof unsafeWindow !== 'undefined' && unsafeWindow.SGLVAppDetail)
|| (typeof window !== 'undefined' && window.SGLVAppDetail);
if (!A || typeof A.loadDetail !== 'function') {
console.warn('[SGIS] SGLVAppDetail 库未加载,概览增强信息不可用');
return null;
}
try {
const detail = await A.loadDetail(APP_ID, { useCache: true });
if (!detail) return null;
// 映射库返回结构到 SGIS 概览期望的结构
const platforms = [];
if (detail.platforms) {
if (detail.platforms.win) platforms.push('Windows');
if (detail.platforms.mac) platforms.push('macOS');
if (detail.platforms.linux) platforms.push('SteamOS + Linux');
}
return {
workshopSupported: (detail.categoryObjs || []).some(c =>
c.id === 30 || /steam\s*workshop|steam\s*创意工坊|创意工坊/i.test(c.description || '')
),
workshopUrl: `https://steamcommunity.com/workshop/browse/?appid=${APP_ID}`,
categories: detail.categoryObjs || [],
platforms,
packages: detail.packages || [],
screenshots: detail.screenshots || [],
movies: detail.movies || [],
supportedLanguages: detail.supportedLanguages || { list: [], note: '' },
controllerSupport: detail.controllerSupport || null,
requiredAge: detail.requiredAge || 0,
contentDescriptors: detail.contentDescriptors || null,
appType: detail.type || '',
background: detail.background || '',
genres: detail.genres || [],
genreObjs: detail.genreObjs || [],
developers: detail.developers || [],
publishers: detail.publishers || [],
releaseDate: detail.releaseDate || '',
recommendations: detail.recommendations || 0,
metacritic: detail.metacritic || null,
achievementsTotal: detail.achievementsTotal || 0,
dlc: detail.dlc || [],
isFree: detail.isFree || false,
website: detail.website || '',
headerImage: detail.cover || '',
shortDesc: detail.shortDesc || '',
source: detail.source || 'api',
fetchedAt: Date.now(),
};
} catch (e) {
console.warn('[SGIS] appdetails 增强信息获取失败:', e.message);
return null;
}
}
// 工坊标记渲染 (类似家庭共享指示器)
function renderWorkshopIndicator() {
const extra = SGIS.appDetailsExtra;
if (!extra || extra.workshopSupported === undefined || extra.workshopSupported === null) return '';
if (extra.workshopSupported) {
return `
${SGIS_ICONS.workshop}
支持 Steam 创意工坊
浏览工坊 →
`;
}
return `
${SGIS_ICONS.workshop}
不支持 Steam 创意工坊
`;
}
// 工具函数: 大数字简写 (如 5178439 -> 517.8万)
function formatBigNumber(n) {
if (!n || n <= 0) return '';
if (n < 10000) return String(n);
if (n < 100000000) return (n / 10000).toFixed(1) + '万';
return (n / 100000000).toFixed(1) + '亿';
}
// v2.3.17: 应用类型本地化映射
function localizeAppType(t) {
const map = {
game: '游戏', dlc: 'DLC', music: '音乐', series: '系列',
mod: '模组', demo: '试玩版', advertising: '宣传',
series_episode: '系列剧集', tool: '工具', video: '视频',
hardware: '硬件', episode: '剧集',
};
return map[t] || t || '';
}
// v2.3.17: 年龄限制等级文案
function ageBadgeInfo(age) {
if (!age || age <= 0) return { cls: 'unrestricted', text: '全年龄', icon: SGIS_ICONS.shield };
if (age >= 18) return { cls: 'restricted', text: age + '+ 限制级', icon: SGIS_ICONS.shield };
if (age >= 16) return { cls: 'mature', text: age + '+ 成熟内容', icon: SGIS_ICONS.shield };
if (age >= 13) return { cls: 'mature', text: age + '+ 青少年', icon: SGIS_ICONS.shield };
return { cls: 'unrestricted', text: age + '+', icon: SGIS_ICONS.shield };
}
// v2.3.17: 异步获取 DLC 名称映射
// 调用 appdetails?appids={dlcId}&filters=basic 获取每个 DLC 的名称
// 使用并发限制 (mapLimit 模式), 缓存 24h
async function fetchDlcNames(dlcIds) {
if (!dlcIds || !dlcIds.length) return {};
// 先看缓存
const cacheKey = 'dlcNames_' + dlcIds.join(',');
const cached = cacheGet(cacheKey);
if (cached) return cached;
const result = {};
const concurrency = 4; // 并发 4 个
const queue = dlcIds.slice();
const workers = [];
const fetchOne = async (dlcId) => {
try {
const url = `https://store.steampowered.com/api/appdetails?appids=${dlcId}&filters=basic&l=schinese&_=${Date.now()}`;
const data = await fetchJson(url, { timeout: 8000 });
const d = data?.[dlcId]?.data;
if (d) {
result[dlcId] = {
name: d.name || ('DLC ' + dlcId),
isFree: !!d.is_free,
headerImage: d.header_image || '',
shortDesc: d.short_description || '',
};
}
} catch (e) {
// 单个 DLC 失败不阻塞其他
}
};
// 简单的并发池
while (queue.length > 0) {
const batch = queue.splice(0, concurrency);
await Promise.all(batch.map(fetchOne));
}
cacheSet(cacheKey, result, CACHE_TTL.appDetailsExtra);
return result;
}
// ---- ITAD 历史价格 (v2.9.79: 改用 SGLVITAD 独立库) ----
// 原 lookupItadId / fetchItadGameInfo / fetchItadPriceHistory 已迁移至 sglv-itad.lib.js
// 硬编码 Key 作为兜底, 用户配置的 Key 优先 (通过 SGLVITAD.setApiKey 动态切换)
const _itad = window.SGLVITAD;
async function fetchHistoryPrices(appId) {
const cacheKey = 'historyPrices_' + appId;
const cached = cacheGet(cacheKey);
if (cached) return cached;
// 使用 SGLVITAD 库 (用户 Key 优先, 兜底 Key 兜底)
if (_itad && _itad.hasApiKey()) {
// 步骤1: Steam AppID → ITAD GameID
const itadId = await _itad.lookupItadId(appId);
if (!itadId) {
return await fetchHistoryPricesCheapShark(appId, 'ITAD lookup 失败');
}
// 步骤2: 并行获取游戏信息和价格历史 (库内部已处理归一化和缓存)
const [gameInfo, historyData] = await Promise.all([
_itad.fetchGameInfo(itadId),
_itad.fetchPriceHistory(itadId, { country: 'CN', shops: [_itad.SHOP_STEAM] }),
]);
if (!historyData || !historyData.discounts.length) {
return await fetchHistoryPricesCheapShark(appId, 'ITAD 无价格历史');
}
// 步骤3: 补充 daysFromRelease (业务逻辑, 不在库中)
const releaseDate = gameInfo?.releaseDate || null;
if (releaseDate) {
for (const d of historyData.discounts) {
if (d.date) {
try {
d.daysFromRelease = Math.floor((new Date(d.date) - new Date(releaseDate)) / 86400000);
} catch { /* ignore */ }
}
}
}
const result = {
history: historyData.history,
discounts: historyData.discounts,
lowest: historyData.lowest,
releaseDate: releaseDate,
gameTitle: gameInfo?.title || '',
source: 'itad',
};
cacheSet(cacheKey, result, CACHE_TTL.historyPrices);
return result;
}
// 库未加载或无 Key: 回退到 CheapShark → AugmentedSteam
return await fetchHistoryPricesCheapShark(appId, 'ITAD 库未加载');
}
// Fallback: 使用 AugmentedSteam API 获取历史价格(原 v2.2 逻辑)
async function fetchHistoryPricesFallback(appId, reason) {
try {
const data = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'POST',
url: 'https://api.augmentedsteam.com/prices/v2',
headers: { 'Content-Type': 'application/json' },
data: JSON.stringify({
country: 'US',
apps: [parseInt(appId, 10)],
subs: [],
bundles: [],
voucher: false,
shops: [],
}),
timeout: 12000,
onload(r) {
if (r.status >= 200 && r.status < 300) {
try { resolve(JSON.parse(r.responseText)); }
catch { reject(new Error('JSON parse fail')); }
} else reject(new Error('HTTP ' + r.status));
},
onerror: () => reject(new Error('网络错误')),
ontimeout: () => reject(new Error('请求超时')),
});
});
const key = `app/${appId}`;
const aug = data?.prices?.[key];
if (!aug) return { history: [], discounts: [], lowest: null, source: 'aug', error: reason };
const lowest = aug.lowest || (aug.historic?.[0] ? { price: aug.historic[0].price, currency: aug.historic[0].currency, store: aug.historic[0].store, date: aug.historic[0].date } : null);
const history = (aug.historic || []).map(h => ({
price: h.price, currency: h.currency, cut: 0, store: h.store || '', date: h.date || '',
})).sort((a, b) => new Date(b.date) - new Date(a.date)).slice(0, 20);
const result = { history, discounts: [], lowest, releaseDate: null, source: 'aug', error: reason };
cacheSet('historyPrices_' + appId, result, CACHE_TTL.historyPrices);
return result;
} catch (e) {
return { history: [], discounts: [], lowest: null, source: 'error', error: reason + ' / ' + e.message };
}
}
// 折扣趋势曲线图: 折扣记录 > 2 个时绘制, 无数据或 <= 2 个时隐藏
function renderDiscountTrendChart(discounts) {
const pts = (discounts || [])
.filter(d => d && d.date && d.cut > 0)
.map(d => ({ t: new Date(d.date).getTime(), cut: Number(d.cut) || 0, date: d.date }))
.filter(p => !isNaN(p.t))
.sort((a, b) => a.t - b.t);
if (pts.length <= 2) return '';
const W = 340, H = 120, PL = 22, PR = 10, PT = 14, PB = 16;
const iw = W - PL - PR, ih = H - PT - PB;
const t0 = pts[0].t, t1 = pts[pts.length - 1].t;
const span = Math.max(t1 - t0, 86400000);
const maxCut = Math.max(...pts.map(p => p.cut), 10);
const px = t => PL + ((t - t0) / span) * iw;
const py = c => PT + ih - (c / maxCut) * ih;
const linePath = pts.map((p, i) => `${i ? 'L' : 'M'}${px(p.t).toFixed(1)},${py(p.cut).toFixed(1)}`).join(' ');
const areaPath = `${linePath} L${px(t1).toFixed(1)},${(PT + ih).toFixed(1)} L${px(t0).toFixed(1)},${(PT + ih).toFixed(1)} Z`;
const grid = [0.25, 0.5, 0.75, 1].map(f => {
const gy = (PT + ih - f * ih).toFixed(1);
return `` +
`${Math.round(f * maxCut)}%`;
}).join('');
const dots = pts.map(p => {
const cx = px(p.t).toFixed(1), cy = py(p.cut).toFixed(1);
const dateFull = new Date(p.date).toLocaleDateString('zh-CN');
const dateShort = new Date(p.date).toLocaleDateString('zh-CN', { year: '2-digit', month: '2-digit' });
return `${dateFull} · -${p.cut}%` +
`-${p.cut}%` +
`${dateShort}`;
}).join('');
return `
折扣趋势 (按首次到达日期)
`;
}
// v2.9.9: SVG 价格历史走势图 (零依赖, 支持时间范围筛选, 参考 Steam_Buff charts.js)
function renderPriceHistoryChart(historyData) {
const allPoints = (historyData?.history || [])
.filter(h => h && h.date && h.price > 0)
.map(h => ({
t: new Date(h.date).getTime(),
price: Number(h.price) || 0,
cut: Number(h.cut) || 0,
currency: h.currency || '',
store: h.store || '',
date: h.date,
}))
.filter(p => !isNaN(p.t))
.sort((a, b) => a.t - b.t);
if (allPoints.length < 2) return '';
const now = Date.now();
const ranges = [
{ key: '6m', label: '6月', ms: 180 * 86400000 },
{ key: '1y', label: '12月', ms: 365 * 86400000 },
{ key: 'all', label: '全部', ms: Infinity },
];
const activeRange = SGIS.priceChartRange || 'all';
const filterByRange = (rangeKey) => {
if (rangeKey === 'all') return allPoints;
const r = ranges.find(x => x.key === rangeKey);
if (!r) return allPoints;
const filtered = allPoints.filter(p => p.t >= now - r.ms);
return filtered.length >= 2 ? filtered : allPoints;
};
const points = filterByRange(activeRange);
const W = 340, H = 140, PL = 34, PR = 10, PT = 14, PB = 20;
const iw = W - PL - PR, ih = H - PT - PB;
const t0 = points[0].t, t1 = points[points.length - 1].t;
const span = Math.max(t1 - t0, 86400000);
const prices = points.map(p => p.price);
const minP = Math.min(...prices);
const maxP = Math.max(...prices);
const padP = (maxP - minP) * 0.15 || maxP * 0.1 || 1;
const yMin = Math.max(0, minP - padP);
const yMax = maxP + padP;
const ySpan = Math.max(yMax - yMin, 0.01);
const px = t => PL + ((t - t0) / span) * iw;
const py = p => PT + ih - ((p - yMin) / ySpan) * ih;
const linePath = points.map((p, i) =>
`${i ? 'L' : 'M'}${px(p.t).toFixed(1)},${py(p.price).toFixed(1)}`
).join(' ');
const areaPath = `${linePath} L${px(t1).toFixed(1)},${(PT + ih).toFixed(1)} L${px(t0).toFixed(1)},${(PT + ih).toFixed(1)} Z`;
// Y-axis grid (4 steps)
const gridSteps = 4;
const grid = Array.from({ length: gridSteps + 1 }, (_, i) => {
const f = i / gridSteps;
const val = yMin + f * ySpan;
const gy = (PT + ih - f * ih).toFixed(1);
const label = val >= 100 ? Math.round(val).toString() : val.toFixed(1);
return `` +
`${label}`;
}).join('');
// X-axis date labels (max 5, smart placement)
const labelCount = Math.min(5, points.length);
const xLabels = labelCount > 1 ? Array.from({ length: labelCount }, (_, i) => {
const idx = Math.floor(i * (points.length - 1) / (labelCount - 1));
const p = points[idx];
const dateStr = new Date(p.t).toLocaleDateString('zh-CN', { year: '2-digit', month: '2-digit' });
return `${dateStr}`;
}).join('') : '';
// Data points with tooltips — lowest point highlighted
const lowestIdx = prices.indexOf(minP);
const dots = points.map((p, i) => {
const cx = px(p.t).toFixed(1), cy = py(p.price).toFixed(1);
const isLowest = i === lowestIdx;
const r = isLowest ? 4 : 2.5;
const fill = isLowest ? '#a4d007' : '#66c0f4';
const dateFull = new Date(p.date).toLocaleDateString('zh-CN');
const tip = `${dateFull} · ${p.currency}${p.price.toFixed(2)}${p.cut > 0 ? ` (-${p.cut}%)` : ''}${p.store ? ' @ ' + p.store : ''}`;
const lowLabel = isLowest
? `${p.currency}${p.price.toFixed(2)}`
: '';
return `${tip}${lowLabel}`;
}).join('');
const rangeBtns = ranges.map(r =>
``
).join('');
const currency = points[0]?.currency || '';
return `
`;
}
// v2.9.9: 绑定价格图表时间范围按钮事件
function bindPriceChartRangeEvents(historyData) {
const wrap = document.getElementById('sgis-price-chart-wrap');
if (!wrap) return;
wrap.querySelectorAll('.sgis-chart-range-btn').forEach(btn => {
btn.addEventListener('click', () => {
SGIS.priceChartRange = btn.dataset.range;
const newChartHtml = renderPriceHistoryChart(historyData);
if (newChartHtml) {
const temp = document.createElement('div');
temp.innerHTML = newChartHtml;
const newWrap = temp.firstChild;
wrap.replaceWith(newWrap);
bindPriceChartRangeEvents(historyData);
}
});
});
}
function renderHistoryPricesSection(historyData) {
if (!historyData || (!historyData.history?.length && !historyData.lowest && !historyData.discounts?.length)) {
return `
${SGIS_ICONS.price} 历史价格
暂无历史价格数据${historyData?.error ? '(' + historyData.error + ')' : ''}
`;
}
const lowest = historyData.lowest;
const discounts = historyData.discounts || [];
const releaseDate = historyData.releaseDate;
// 史低价格卡片
// v2.3.7: 安全数值转换, 修复 price?.toFixed is not a function
const _f = (v) => { const n = Number(v); return isNaN(n) ? '—' : n.toFixed(2); };
const lowestHtml = lowest ? `
史低价格 ${lowest.cut ? `(-${lowest.cut}%)` : ''}
${lowest.currency || '$'}${_f(lowest.price)}
${lowest.store ? `
@ ${lowest.store}
` : ''}
${lowest.date ? `
${new Date(lowest.date).toLocaleDateString('zh-CN')}
` : ''}
` : '';
// 折扣记录摘要(每个折扣百分比第一次到达的天数,参考 Python seen_cuts 逻辑)
const discountSummaryHtml = discounts.length ? `
${discounts.map(d => {
const daysStr = d.daysFromRelease != null ? `第${d.daysFromRelease}天` : '';
return `-${d.cut}%${daysStr}`;
}).join('')}
` : '';
// 完整价格历史列表(显示折扣百分比)
const historyRows = (historyData.history || []).slice(0, 10).map(h => {
const dateStr = h.date ? new Date(h.date).toLocaleDateString('zh-CN', { year: '2-digit', month: '2-digit', day: '2-digit' }) : '—';
const cutBadge = h.cut > 0 ? `-${h.cut}%` : '';
// 计算距发行天数
let daysStr = '';
if (releaseDate && h.date) {
try {
const days = Math.floor((new Date(h.date) - new Date(releaseDate)) / 86400000);
if (days >= 0) daysStr = `+${days}天`;
} catch { /* ignore */ }
}
return `
${dateStr}
${cutBadge}
${h.cut > 0 && h.regular > 0 ? `${h.currency || '$'}${_f(h.regular)}` : ''}${h.currency || '$'}${_f(h.price)}
${h.store || ''}
${daysStr}
`;
}).join('');
const sourceLabel = historyData.source === 'itad' ? 'ITAD' : historyData.source === 'aug' ? 'AugmentedSteam' : historyData.source;
return `
${SGIS_ICONS.price} 历史价格走势
${lowestHtml}
${discountSummaryHtml}
${renderPriceHistoryChart(historyData)}
${historyRows ? `
${historyRows}
` : ''}
${renderDiscountTrendChart(discounts)}
${releaseDate ? `
发行日期: ${new Date(releaseDate).toLocaleDateString('zh-CN')}
` : ''}
数据源: ${sourceLabel}${discounts.length ? ` · ${discounts.length} 个折扣记录` : ''}
`;
}
// ---- 成就标签 ----
async function fetchPlayerAchievements(appId) {
const apiKey = storage.getApiKey();
const steamId = getActiveSteamId();
if (!apiKey || !steamId) return { error: 'NO_KEY', achievements: [], total: 0, unlocked: 0 };
const cacheKey = 'achievements_' + appId;
const cached = cacheGet(cacheKey);
if (cached) return cached;
const url = `https://api.steampowered.com/ISteamUserStats/GetPlayerAchievements/v0001/?appid=${appId}&key=${apiKey}&steamid=${steamId}&l=schinese`;
const data = await fetchJson(url, { timeout: 15000 });
if (!data?.playerstats?.success) return { error: 'API_FAIL', achievements: [], total: 0, unlocked: 0 };
const ach = data.playerstats.achievements || [];
const result = {
achievements: ach.map(a => ({
name: a.name || '',
apiname: a.apiname || '',
achieved: a.achieved === 1,
unlocktime: a.unlocktime || 0,
})),
total: ach.length,
unlocked: ach.filter(a => a.achieved === 1).length,
gameName: data.playerstats.gameName || '',
};
cacheSet(cacheKey, result, CACHE_TTL.achievements);
return result;
}
async function fetchGlobalAchievements(appId) {
const cacheKey = 'globalAchievements_' + appId;
const cached = cacheGet(cacheKey);
if (cached) return cached;
const url = `https://api.steampowered.com/ISteamUserStats/GetGlobalAchievementPercentagesForApp/v0002/?gameid=${appId}&l=schinese`;
const data = await fetchJson(url, { timeout: 15000 });
const ach = data?.achievementpercentages?.achievements || [];
const result = ach.map(a => ({
name: a.name || '',
percent: a.percent || 0,
})).sort((a, b) => b.percent - a.percent);
cacheSet(cacheKey, result, CACHE_TTL.globalAchievements);
return result;
}
function renderAchievements() {
if (SGIS.achievementsLoading) return;
if (SGIS.achievements && SGIS.globalAchievements) { renderAchievementsContent(); return; }
SGIS.achievementsLoading = true;
renderLoading('正在获取成就数据…');
Promise.all([
fetchPlayerAchievements(APP_ID).catch(e => ({ error: e.message, achievements: [], total: 0, unlocked: 0 })),
fetchGlobalAchievements(APP_ID).catch(e => []),
]).then(([playerData, globalData]) => {
SGIS.achievements = playerData;
SGIS.globalAchievements = globalData;
renderAchievementsContent();
}).catch(e => {
renderError('成就获取失败: ' + e.message);
}).finally(() => { SGIS.achievementsLoading = false; });
}
function renderAchievementsContent() {
const player = SGIS.achievements;
const global = SGIS.globalAchievements || [];
if (player?.error === 'NO_KEY') {
setBody(``);
return;
}
if (player?.error && player.error !== 'API_FAIL') {
setBody(`🏆
成就数据获取失败: ${player.error}
`);
return;
}
if (!player || player.total === 0) {
// 仅展示全球成就
if (global.length === 0) {
setBody(``);
return;
}
// 仅全球数据
const globalList = global.slice(0, 20).map(a => {
const barColor = a.percent >= 50 ? 'var(--sgis-green)' : a.percent >= 20 ? 'var(--sgis-amber)' : 'var(--sgis-rose)';
return ``;
}).join('');
setBody(`
${SGIS_ICONS.star} 全球成就达成率
该游戏不在你的库中,仅展示全球数据
${globalList}
`);
return;
}
// 玩家成就 + 全球对比
const unlocked = player.unlocked;
const total = player.total;
const pct = total > 0 ? (unlocked / total * 100) : 0;
// 合并玩家和全球数据
const globalMap = {};
global.forEach(g => { globalMap[g.name] = g.percent; });
const merged = player.achievements.map(a => ({
name: a.name,
achieved: a.achieved,
unlocktime: a.unlocktime,
globalPct: globalMap[a.name] || null,
}));
const unlockedList = merged.filter(a => a.achieved).sort((a, b) => (b.unlocktime || 0) - (a.unlocktime || 0));
const lockedList = merged.filter(a => !a.achieved).sort((a, b) => (b.globalPct || 0) - (a.globalPct || 0));
const formatUnlockTime = (ts) => {
if (!ts) return '';
try { return new Date(ts * 1000).toLocaleDateString('zh-CN', { year: '2-digit', month: '2-digit', day: '2-digit' }); } catch { return ''; }
};
const unlockedHtml = unlockedList.slice(0, 15).map(a => `
✓
${a.name || '(隐藏成就)'}
${a.unlocktime ? `${formatUnlockTime(a.unlocktime)}` : ''}
${a.globalPct != null ? `全球 ${a.globalPct.toFixed(1)}%` : ''}
`).join('');
const lockedHtml = lockedList.slice(0, 15).map(a => {
const barColor = (a.globalPct || 0) >= 50 ? 'var(--sgis-green)' : (a.globalPct || 0) >= 20 ? 'var(--sgis-amber)' : 'var(--sgis-rose)';
return `
○
${a.name || '(隐藏成就)'}
${a.globalPct != null ? `
全球 ${a.globalPct.toFixed(1)}%
` : ''}
`;
}).join('');
const donutR = 52, donutSw = 10, donutCx = 60, donutCy = 60;
const donutC = 2 * Math.PI * donutR;
const donutDash = (pct / 100) * donutC;
// v2.3.3: 彩虹强度 (0.25 ~ 1.0) - 完成度越高, 彩虹越鲜艳 + glow 越强
// 0-20% → 强度 0.30 (灰淡)
// 20-50% → 强度 0.50 (中等)
// 50-80% → 强度 0.75 (鲜艳)
// 80-100% → 强度 1.00 (炫彩 + 强烈发光)
const rainbowIntensity = Math.max(0.3, Math.min(1.0, 0.25 + pct / 100 * 0.85));
const rainbowId = `sgis-rainbow-${APP_ID || 'p'}`;
const rainbowGlowId = `${rainbowId}-glow`;
// 7 色彩虹渐变 (红橙黄绿青蓝紫)
const rainbowStops = [
[0.00, '#ff0080'], // 玫红
[0.17, '#ff4500'], // 橙红
[0.34, '#ffb300'], // 琥珀
[0.50, '#00ff7f'], // 翠绿
[0.67, '#00bfff'], // 天蓝
[0.84, '#7b2cff'], // 紫罗兰
[1.00, '#ff00d4'], // 品红
].map(([off, c]) => ``).join('');
setBody(`
${unlocked}已解锁
${total - unlocked}未解锁
${total}总成就
${unlockedHtml ? `
${SGIS_ICONS.check} 已解锁 (${unlockedList.length})
${unlockedHtml}
` : ''}
${lockedHtml ? `
${SGIS_ICONS.star} 未解锁 (${lockedList.length})
${lockedHtml}
` : ''}
玩家成就来自 Steam Web API · 全球数据每12小时更新
`);
}
// ---- 动态标签 (游戏新闻/更新) ----
async function fetchGameNews(appId) {
const cacheKey = 'news_' + appId;
const cached = cacheGet(cacheKey);
if (cached) return cached;
const url = `https://api.steampowered.com/ISteamNews/GetNewsForApp/v0002/?appid=${appId}&count=20&maxlength=500&l=schinese&feeds=steam_community_announcements`;
const data = await fetchJson(url, { timeout: 15000 });
const newsItems = data?.appnews?.newsitems || [];
const result = newsItems.map(n => ({
title: n.title || '',
url: n.url || '',
contents: (n.contents || '').replace(/\\[a-zA-Z]/g, '').replace(/\\n/g, '\n').trim().slice(0, 300),
date: n.date || 0,
feedname: n.feedname || '',
author: n.author || '',
}));
cacheSet(cacheKey, result, CACHE_TTL.news);
return result;
}
// ==================== v2.9.60: 游玩时长趋势标签页 ====================
// ---- ISO 周编号 → "YYYY-Www" key ----
function _ptGetWeekKey(date) {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
const dayNum = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
const weekNum = Math.ceil(((d - yearStart) / 86400000 + 1) / 7);
return `${d.getUTCFullYear()}-W${String(weekNum).padStart(2, '0')}`;
}
// ---- IDB key ----
function _ptHistKey(appId) { return nsKey('pt_hist_' + appId); }
// ---- 后台静默采样:记录当前 playtime_forever 到 IDB ----
async function _samplePlayTime(appId) {
if (!appId || SGIS.playTrendSampling) return;
SGIS.playTrendSampling = true;
try {
const info = getCurrentGameInfo();
if (!info.found || info.playtime == null) return;
const now = Math.floor(Date.now() / 1000);
const key = _ptHistKey(appId);
let hist = sglvIDB.get(key) || { schema: 1, samples: [] };
if (!hist.samples) hist = { schema: 1, samples: [] };
// 节流:同一游戏 30 分钟内不重复采样
const last = hist.samples[hist.samples.length - 1];
if (last && (now - last.ts) < 1800) return;
// playtime 未变化也不记录(避免冗余采样点)
if (last && last.totalMin === info.playtime && (!info.owners || Object.keys(info.owners).length === 0)) return;
const sample = { ts: now, totalMin: info.playtime };
// 附加家庭成员 playtime(如果已获取)
if (info.owners && info.owners.length > 0) {
const ownersSnap = {};
info.owners.forEach(o => {
if (o.playtime != null) ownersSnap[o.steamId] = o.playtime;
});
if (Object.keys(ownersSnap).length > 0) sample.owners = ownersSnap;
}
hist.samples.push(sample);
// 限制最多保留 500 个采样点(约 1 年每周多次采样)
if (hist.samples.length > 500) hist.samples = hist.samples.slice(-500);
sglvIDB.set(key, hist);
} catch (e) {
console.warn('[SGIS] playtime sampling failed:', e);
} finally {
SGIS.playTrendSampling = false;
}
}
// ---- 从 IDB 加载采样历史 ----
function _loadPtHistory(appId) {
try {
const hist = sglvIDB.get(_ptHistKey(appId));
return (hist && hist.samples) ? hist.samples : [];
} catch { return []; }
}
// ---- 周聚合差分算法 ----
// 输入: samples[] (按 ts 升序)
// 输出: { weeks: [{ weekKey, myMin, owners: {sid: min} }], ownerIds: [sid...], weekKeys: [key...] }
function _aggregateWeekly(samples) {
if (!samples || samples.length < 2) return { weeks: [], ownerIds: [], weekKeys: [] };
const sorted = [...samples].sort((a, b) => a.ts - b.ts);
const ownerIds = new Set();
// 1) 计算相邻差分
const diffs = [];
for (let i = 1; i < sorted.length; i++) {
const prev = sorted[i - 1];
const curr = sorted[i];
const myDiff = Math.max(0, (curr.totalMin || 0) - (prev.totalMin || 0));
const ownerDiffs = {};
if (curr.owners && prev.owners) {
Object.keys(curr.owners).forEach(sid => {
ownerIds.add(sid);
const d = (curr.owners[sid] || 0) - (prev.owners[sid] || 0);
if (d > 0) ownerDiffs[sid] = d;
});
} else if (curr.owners) {
Object.keys(curr.owners).forEach(sid => ownerIds.add(sid));
}
// 差分归入后一个采样点所在的自然周
const weekKey = _ptGetWeekKey(new Date(curr.ts * 1000));
diffs.push({ weekKey, myMin: myDiff, owners: ownerDiffs });
}
// 2) 按周分组累加
const weekMap = new Map();
diffs.forEach(d => {
if (!weekMap.has(d.weekKey)) {
weekMap.set(d.weekKey, { weekKey: d.weekKey, myMin: 0, owners: {} });
}
const w = weekMap.get(d.weekKey);
w.myMin += d.myMin;
Object.keys(d.owners).forEach(sid => {
w.owners[sid] = (w.owners[sid] || 0) + d.owners[sid];
});
});
// 3) 填充空周(连续周序列中间的空周显示 0)
const sortedWeeks = [...weekMap.values()].sort((a, b) => a.weekKey.localeCompare(b.weekKey));
const filledWeeks = [];
for (let i = 0; i < sortedWeeks.length; i++) {
filledWeeks.push(sortedWeeks[i]);
if (i < sortedWeeks.length - 1) {
// 检查是否有空周间隔
const curr = sortedWeeks[i].weekKey;
const next = sortedWeeks[i + 1].weekKey;
const gap = _weekGap(curr, next);
for (let g = 1; g < gap; g++) {
const fillKey = _addWeek(curr, g);
filledWeeks.push({ weekKey: fillKey, myMin: 0, owners: {} });
}
}
}
// 4) 截取最近 12 周
const recent = filledWeeks.slice(-12);
const weekKeys = recent.map(w => w.weekKey);
return { weeks: recent, ownerIds: [...ownerIds], weekKeys };
}
// 计算两个 ISO week key 之间的间隔周数
function _weekGap(currKey, nextKey) {
const [cy, cw] = currKey.split('-W').map(Number);
const [ny, nw] = nextKey.split('-W').map(Number);
const totalCurr = cy * 52 + cw;
const totalNext = ny * 52 + nw;
return totalNext - totalCurr;
}
// ISO week key 加 N 周
function _addWeek(weekKey, n) {
const [y, w] = weekKey.split('-W').map(Number);
let total = y * 52 + w + n;
let ny = Math.floor(total / 52);
let nw = total % 52;
if (nw === 0) { nw = 52; ny--; }
return `${ny}-W${String(nw).padStart(2, '0')}`;
}
// ---- 周标签格式化 ----
function _formatWeekLabel(weekKey) {
const parts = weekKey.split('-W');
return `W${parts[1]}`;
}
// ---- SVG 折线图渲染(个人单线) ----
function _renderPtTrendChart(weekKeys, values, color, unit) {
const W = 400, H = 200, PAD = 30;
const maxV = Math.max(...values, 1);
const n = values.length;
if (n === 0) return '';
const stepX = n > 1 ? (W - PAD * 2) / (n - 1) : 0;
const pts = values.map((v, i) => {
const x = n > 1 ? PAD + i * stepX : W / 2;
const y = H - PAD - (v / maxV) * (H - PAD * 2);
return [x, y];
});
const areaPath = n > 1
? `M ${pts[0][0]} ${H - PAD} ` + pts.map(p => `L ${p[0]} ${p[1]}`).join(' ') + ` L ${pts[n-1][0]} ${H - PAD} Z`
: '';
const linePath = `M ${pts.map(p => `${p[0]} ${p[1]}`).join(' L ')}`;
const labelStep = Math.max(1, Math.floor(n / 6));
const labels = weekKeys.map((p, i) => {
if (i % labelStep !== 0 && i !== n - 1) return '';
return `${_formatWeekLabel(p)}`;
}).join('');
const yLabels = [0, 0.25, 0.5, 0.75, 1].map(r => {
const v = maxV * r;
const y = H - PAD - r * (H - PAD * 2);
return `${v.toFixed(0)}${unit}`;
}).join('');
const avgV = values.reduce((s, v) => s + v, 0) / n;
const avgY = H - PAD - (avgV / maxV) * (H - PAD * 2);
const avgLine = `${isZh ? '均' : 'avg'} ${avgV.toFixed(1)}`;
// v2.9.78: 修复 maxIdx=-1 崩溃 — Math.max(...values, 1) 的 fallback 值 1
// 可能不在 values 数组中 (如所有周时长 <1 小时), indexOf 返回 -1 导致 pts[-1] 崩溃。
// 改用 reduce 始终返回有效索引。
const maxIdx = values.reduce((mi, v, i, arr) => v > arr[mi] ? i : mi, 0);
const peakLabel = maxV > 0 ? `${isZh ? '峰' : 'peak'} ${maxV.toFixed(1)}` : '';
return ``;
}
// ---- SVG 多线折线图渲染(家庭组) ----
function _renderPtMultiLineChart(weekKeys, series, unit) {
const W = 400, H = 220, PAD = 30, LEGEND_H = 20;
const allVals = series.flatMap(s => s.values);
const maxV = Math.max(...allVals, 1);
const n = weekKeys.length;
if (n === 0 || series.length === 0) return '';
const colors = ['#66c0f4', '#a78bfa', '#22d3ee', '#fbbf24', '#4ade80', '#f87171'];
const stepX = n > 1 ? (W - PAD * 2) / (n - 1) : 0;
const labelStep = Math.max(1, Math.floor(n / 6));
const labels = weekKeys.map((p, i) => {
if (i % labelStep !== 0 && i !== n - 1) return '';
return `${_formatWeekLabel(p)}`;
}).join('');
const yLabels = [0, 0.25, 0.5, 0.75, 1].map(r => {
const v = maxV * r;
const y = H - PAD - LEGEND_H - r * (H - PAD * 2 - LEGEND_H);
return `${v.toFixed(0)}${unit}`;
}).join('');
const lines = series.map((s, si) => {
const color = colors[si % colors.length];
const pts = s.values.map((v, i) => {
const x = n > 1 ? PAD + i * stepX : W / 2;
const y = H - PAD - LEGEND_H - (v / maxV) * (H - PAD * 2 - LEGEND_H);
return [x, y];
});
const linePath = `M ${pts.map(p => `${p[0]} ${p[1]}`).join(' L ')}`;
const dots = pts.map((p, i) => `${s.label} ${weekKeys[i]}: ${s.values[i].toFixed(1)}${unit}`).join('');
return `${dots}`;
}).join('');
const legend = series.map((s, si) => {
const color = colors[si % colors.length];
return `${s.label.slice(0, 8)}`;
}).join('');
return ``;
}
// ---- SVG 堆叠面积图渲染(总时长) ----
function _renderPtStackedChart(weekKeys, layers, unit) {
const W = 400, H = 220, PAD = 30, LEGEND_H = 20;
const n = weekKeys.length;
if (n === 0 || layers.length === 0) return '';
// 计算每周总值
const totals = weekKeys.map((_, i) => layers.reduce((s, l) => s + (l.values[i] || 0), 0));
const maxV = Math.max(...totals, 1);
const colors = ['#66c0f4', '#a78bfa', '#22d3ee', '#fbbf24', '#4ade80', '#f87171'];
const stepX = n > 1 ? (W - PAD * 2) / (n - 1) : 0;
const labelStep = Math.max(1, Math.floor(n / 6));
const labels = weekKeys.map((p, i) => {
if (i % labelStep !== 0 && i !== n - 1) return '';
return `${_formatWeekLabel(p)}`;
}).join('');
const yLabels = [0, 0.25, 0.5, 0.75, 1].map(r => {
const v = maxV * r;
const y = H - PAD - LEGEND_H - r * (H - PAD * 2 - LEGEND_H);
return `${v.toFixed(0)}${unit}`;
}).join('');
// 堆叠计算
let cumulative = new Array(n).fill(0);
const areas = layers.map((layer, li) => {
const color = colors[li % colors.length];
const topPts = [];
const botPts = [];
for (let i = 0; i < n; i++) {
const x = n > 1 ? PAD + i * stepX : W / 2;
const botVal = cumulative[i];
const topVal = botVal + (layer.values[i] || 0);
cumulative[i] = topVal;
const yBot = H - PAD - LEGEND_H - (botVal / maxV) * (H - PAD * 2 - LEGEND_H);
const yTop = H - PAD - LEGEND_H - (topVal / maxV) * (H - PAD * 2 - LEGEND_H);
topPts.push([x, yTop]);
botPts.push([x, yBot]);
}
const areaPath = `M ${topPts.map(p => `${p[0]} ${p[1]}`).join(' L ')} L ${botPts.reverse().map(p => `${p[0]} ${p[1]}`).join(' L ')} Z`;
return ``;
}).join('');
const legend = layers.map((l, li) => {
const color = colors[li % colors.length];
return `${l.label.slice(0, 8)}`;
}).join('');
return ``;
}
// ---- v2.9.62: 家庭成员该游戏游玩时长横向对比条 ----
// 数据源: getCurrentGameInfo().owners (每位家庭组成员对本游戏的 playtime_forever, 分钟)
// 我的时长取 info.playtime; 其他成员取 SGIS.ownersPlaytime[sid] (由 fetchOwnersPlaytime 异步填充)
function _renderPtFamilyBars() {
const info = getCurrentGameInfo();
if (!info.found) return '';
const mySteamId = getActiveSteamId();
const familyInfo = storage.getFamilyInfo() || {};
const nameMap = familyInfo.steamIdtoName || {};
// 构建成员列表(我 + 家庭组拥有该游戏的成员)
const members = [{
name: nameMap[String(mySteamId)] || (isZh ? '我' : 'Me'),
isMe: true,
playtime: info.playtime || 0,
}];
(info.owners || []).filter(o => !o.isMe).forEach(o => {
members.push({
name: o.name || nameMap[o.steamId] || ('ID:' + String(o.steamId).slice(-4)),
isMe: false,
playtime: o.playtime, // null = 尚未获取
});
});
// 只有"我"一人,无需对比
if (members.length < 2) return '';
const hasNull = members.some(m => m.playtime == null);
// 触发未获取成员时长的异步拉取(完成后由 fetchOwnersPlaytime 自动重渲染当前标签)
if (hasNull && !SGIS.ownersPlaytimeFetching) {
fetchOwnersPlaytime().catch(() => {});
}
// 加载中: 有成员时长未获取
if (hasNull) {
const apiKey = storage.getApiKey();
const loadingText = apiKey ? T.ptFamilyBarsLoading : T.ptFamilyBarsNoKey;
return `
${SGIS_ICONS.share} ${T.ptFamilyCompare}${loadingText}
${loadingText}
`;
}
// 全部就绪: 按时长降序, 横向进度条按最大值等比
const sorted = [...members].sort((a, b) => (b.playtime || 0) - (a.playtime || 0));
const maxMin = Math.max(1, ...sorted.map(m => m.playtime || 0));
const totalMin = sorted.reduce((s, m) => s + (m.playtime || 0), 0);
const rows = sorted.map(m => {
const hours = ((m.playtime || 0) / 60).toFixed(1);
const pct = ((m.playtime || 0) / maxMin * 100).toFixed(1);
const meCls = m.isMe ? ' is-me' : '';
return `
${escHtml(m.name)}
${hours}h
`;
}).join('');
return `
${SGIS_ICONS.share} ${T.ptFamilyCompare}${T.ptFamilyBarsTotal} ${(totalMin / 60).toFixed(1)}h
${rows}
`;
}
// ---- 主渲染函数 ----
function renderPlayTrend() {
const samples = _loadPtHistory(APP_ID);
const agg = _aggregateWeekly(samples);
// 空状态
if (samples.length === 0) {
setBody(`
${SGIS_ICONS.trend}
${T.ptEmptyNoData}
${_renderPtFamilyBars()}`);
return;
}
if (samples.length < 2) {
setBody(`
${SGIS_ICONS.trend}
${T.ptEmptyNeedTwo}
${isZh ? '当前采样点: ' + samples.length : 'Current samples: ' + samples.length}
${_renderPtFamilyBars()}`);
return;
}
const weeks = agg.weeks;
const weekKeys = weeks.map(w => w.weekKey);
const myValues = weeks.map(w => w.myMin / 60); // 分钟→小时
// KPI 计算
const thisWeekH = myValues.length > 0 ? myValues[myValues.length - 1] : 0;
const avgWeekH = myValues.length > 0 ? myValues.reduce((s, v) => s + v, 0) / myValues.length : 0;
// v2.9.78: 防御 peakIdx=-1 (Math.max 对空数组返回 -Infinity, indexOf 返回 -1)
const peakIdx = myValues.length > 0
? myValues.reduce((mi, v, i, arr) => v > arr[mi] ? i : mi, 0)
: 0;
const peakH = myValues.length > 0 ? myValues[peakIdx] : 0;
const peakLabel = weekKeys.length > 0 && peakIdx >= 0 ? _formatWeekLabel(weekKeys[peakIdx]) : '';
const sampleWeeks = weekKeys.length;
// 检查数据稀疏
const hasSparseGap = weeks.some((w, i) => {
if (i === 0) return false;
return _weekGap(weeks[i - 1].weekKey, w.weekKey) > 2;
});
const subTab = SGIS.playTrendSubTab || 'personal';
const subTabs = [
{ key: 'personal', icon: SGIS_ICONS.trend, label: T.ptSubPersonal },
{ key: 'family', icon: SGIS_ICONS.social, label: T.ptSubFamily },
{ key: 'total', icon: SGIS_ICONS.barChart || SGIS_ICONS.trend, label: T.ptSubTotal },
];
// 图表内容
let chartHtml = '';
if (subTab === 'personal') {
chartHtml = _renderPtTrendChart(weekKeys, myValues, '#66c0f4', T.ptHours[0] || 'h');
} else if (subTab === 'family') {
const ownerIds = agg.ownerIds;
if (ownerIds.length === 0) {
chartHtml = `${SGIS_ICONS.social}
${T.ptTrendNoFamily}
`;
} else {
const familyInfo = storage.getFamilyInfo() || {};
const nameMap = familyInfo.steamIdtoName || {};
const mySteamId = getActiveSteamId();
// 限制最多 6 条线
const displayIds = ownerIds.slice(0, 6);
const series = displayIds.map(sid => {
const label = sid === mySteamId ? (isZh ? '我' : 'Me') : (nameMap[sid] || ('ID:' + String(sid).slice(-4)));
const values = weeks.map(w => (w.owners[sid] || 0) / 60);
return { label, values };
});
chartHtml = _renderPtMultiLineChart(weekKeys, series, T.ptHours[0] || 'h');
}
} else if (subTab === 'total') {
// 堆叠:个人层 + 家庭组成员层
const ownerIds = agg.ownerIds;
const familyInfo = storage.getFamilyInfo() || {};
const nameMap = familyInfo.steamIdtoName || {};
const mySteamId = getActiveSteamId();
const layers = [];
// 个人层
layers.push({ label: isZh ? '我' : 'Me', values: myValues });
// 家庭成员层(排除自己)
const familyIds = ownerIds.filter(sid => sid !== mySteamId).slice(0, 5);
familyIds.forEach(sid => {
const label = nameMap[sid] || ('ID:' + String(sid).slice(-4));
const values = weeks.map(w => (w.owners[sid] || 0) / 60);
layers.push({ label, values });
});
chartHtml = _renderPtStackedChart(weekKeys, layers, T.ptHours[0] || 'h');
// v2.9.62: 家庭成员该游戏总时长横向对比条
chartHtml += _renderPtFamilyBars();
}
const kpiCardHtml = (label, value, unit, color) => `
${label}
${value}
${unit}
`;
const subTabHtml = subTabs.map(st => `
`).join('');
setBody(`
${kpiCardHtml(T.ptKpiThisWeek, thisWeekH.toFixed(1), T.ptHours, '#66c0f4')}
${kpiCardHtml(T.ptKpiAvgWeek, avgWeekH.toFixed(1), T.ptHours, '#a78bfa')}
${kpiCardHtml(T.ptKpiPeakWeek, peakH.toFixed(1), `${T.ptHours} · ${peakLabel}`, '#fbbf24')}
${kpiCardHtml(T.ptKpiSampleWeeks, sampleWeeks, T.ptWeeks, '#22d3ee')}
${hasSparseGap ? `
⚠️ ${T.ptSparseWarning}
` : ''}
${subTabHtml}
${chartHtml}
`);
// 绑定子标签切换
document.querySelectorAll('.sgis-pt-subtab').forEach(btn => {
btn.addEventListener('click', () => {
SGIS.playTrendSubTab = btn.dataset.subtab;
renderPlayTrend();
});
});
}
function renderDynamics() {
if (SGIS.dynamicsLoading) return;
if (SGIS.dynamics) { renderDynamicsContent(); return; }
SGIS.dynamicsLoading = true;
renderLoading('正在获取游戏动态…');
fetchGameNews(APP_ID).then(news => {
SGIS.dynamics = news;
renderDynamicsContent();
}).catch(e => {
renderError('动态获取失败: ' + e.message);
}).finally(() => { SGIS.dynamicsLoading = false; });
}
function renderDynamicsContent() {
const news = SGIS.dynamics || [];
if (news.length === 0) {
setBody(`
📢
暂无游戏动态
该游戏近期没有发布新闻或更新公告
`);
return;
}
const formatDate = (ts) => {
if (!ts) return '';
try {
const d = new Date(ts * 1000);
const now = new Date();
const diff = (now - d) / 1000;
if (diff < 3600) return Math.floor(diff / 60) + ' 分钟前';
if (diff < 86400) return Math.floor(diff / 3600) + ' 小时前';
if (diff < 30 * 86400) return Math.floor(diff / 86400) + ' 天前';
return d.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' });
} catch { return ''; }
};
const feedLabels = {
steam_community_announcements: '社区公告',
steam_blog: '官方博客',
patch_notes: '更新日志',
product_updates: '产品更新',
};
// v2.2: 原文/AI翻译总结 切换按钮
const isAiMode = SGIS.dynamicsMode === 'ai';
// v2.3.18: 检测截断部分恢复
const hasTruncationFlag = SGIS.aiSummary && SGIS.aiSummary.some(it => it._truncated);
const aiStatusText = isAiMode
? (SGIS.aiSummary
? (hasTruncationFlag
? `⚠️ 已恢复 ${SGIS.aiSummary.length} 条 (部分结果因长度限制被截断)`
: `✅ ${SGIS.aiSummary.length} 条已总结`)
: T.aiTranslating)
: '';
const toggleBar = `
${aiStatusText}
`;
// 根据 mode 渲染不同内容
let contentHtml = '';
if (isAiMode) {
// AI 翻译总结模式
if (SGIS.aiSummaryLoading) {
contentHtml = ``;
} else if (SGIS.aiSummaryError) {
contentHtml = `⚠️ ${T.aiTranslateFail}: ${SGIS.aiSummaryError}
`;
} else if (SGIS.aiSummary) {
// v2.3.18: 截断部分恢复提示横幅
const truncationBanner = hasTruncationFlag
? `
⚠️ AI 响应因长度限制被截断, 已通过修复算法恢复 ${SGIS.aiSummary.length} 条结果。切换到"原文"可查看完整新闻。
`
: '';
contentHtml = truncationBanner + SGIS.aiSummary.map((item, i) => {
const origNews = news[i] || {};
const feedLabel = feedLabels[origNews.feedname] || origNews.feedname || '动态';
const badge = item.is_translated ? `已翻译` : '';
return `
${item.title || origNews.title || ''}${badge}
${item.summary || ''}
`;
}).join('');
} else {
// 触发 AI 翻译
contentHtml = ``;
// 异步调用 AI 翻译
SGIS.aiSummaryLoading = true;
const gameName = (document.getElementById('appHubAppName') || {}).textContent || '';
callAiTranslateSummary(news, gameName, APP_ID).then(result => {
SGIS.aiSummary = result;
SGIS.aiSummaryLoading = false;
if (SGIS.tab === 'dynamics') renderDynamicsContent();
}).catch(e => {
SGIS.aiSummaryLoading = false;
SGIS.aiSummaryError = e.message;
if (SGIS.tab === 'dynamics' && SGIS.dynamicsMode === 'ai') {
renderDynamicsContent();
}
});
}
} else {
// 原文模式
contentHtml = news.map(n => {
const feedLabel = feedLabels[n.feedname] || n.feedname || '动态';
const desc = n.contents ? n.contents.slice(0, 200) + (n.contents.length > 200 ? '...' : '') : '';
return `
${n.title}
${desc ? `${desc}
` : ''}
${n.author ? `— ${n.author}
` : ''}
`;
}).join('');
}
setBody(`
${SGIS_ICONS.news} 游戏动态与新闻
共 ${news.length} 条动态 · 来自 Steam 社区公告
${toggleBar}
${contentHtml}
数据来自 Steam News API · 每30分钟刷新缓存${isAiMode ? ' · AI翻译总结由用户配置的模型提供' : ''}
`);
// 绑定切换按钮事件
const body = document.getElementById('sgis-body');
if (body) {
body.querySelectorAll('.sgis-ai-toggle-btn').forEach(btn => {
btn.addEventListener('click', () => {
const mode = btn.dataset.mode;
if (SGIS.dynamicsMode === mode) return;
SGIS.dynamicsMode = mode;
// 切换模式时清除错误状态
if (mode === 'original') SGIS.aiSummaryError = null;
renderDynamicsContent();
});
});
// AI 翻译重试按钮
const aiRetryBtn = body.querySelector('#sgis-ai-retry');
if (aiRetryBtn) {
aiRetryBtn.addEventListener('click', () => {
SGIS.aiSummaryError = null;
SGIS.aiSummary = null;
renderDynamicsContent();
});
}
}
}
// ==================== 用户档案浮窗 (v2.3) ====================
// ---- 获取用户摘要 (GetPlayerSummaries) ----
async function fetchPlayerSummaries(steamId) {
const apiKey = storage.getApiKey();
if (!apiKey || !steamId) return null;
const cacheKey = 'playerSummary_' + steamId;
const cached = cacheGet(cacheKey);
if (cached) return cached;
try {
const url = `https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/?key=${apiKey}&steamids=${steamId}`;
const data = await fetchJson(url, { timeout: 12000 });
const player = data?.response?.players?.[0];
if (player) {
cacheSet(cacheKey, player, 30 * 60 * 1000);
return player;
}
return null;
} catch { return null; }
}
// v2.3.13: 获取最近游玩(参考 steam-friend-manager 个人库浮窗)
async function fetchRecentlyPlayedGames(steamId) {
const apiKey = storage.getApiKey();
if (!apiKey || !steamId) return [];
try {
const url = `https://api.steampowered.com/IPlayerService/GetRecentlyPlayedGames/v0001/?key=${apiKey}&steamid=${steamId}&format=json`;
const data = await fetchJson(url, { timeout: 12000 });
return (data?.response?.games || []).map(g => ({
appid: g.appid,
name: g.name || `App ${g.appid}`,
playtime_2weeks: g.playtime_2weeks || 0,
playtime_forever: g.playtime_forever || 0,
img_icon_url: g.img_icon_url || '',
}));
} catch { return []; }
}
// 格式化游玩时长(分钟→小时)
function formatPlaytimeShort(minutes) {
if (!minutes || minutes === 0) return '0h';
return Math.floor(minutes / 60) + 'h';
}
// ---- 获取 Steam 等级 (从社区页面抓取) ----
async function fetchSteamLevel(steamId) {
if (!steamId) return null;
const cacheKey = 'steamLevel_' + steamId;
const cached = cacheGet(cacheKey);
if (cached) return cached;
try {
const html = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: `https://steamcommunity.com/profiles/${steamId}/`,
timeout: 12000,
onload(r) { resolve(r.responseText); },
onerror: () => reject(new Error('网络错误')),
ontimeout: () => reject(new Error('超时')),
});
});
const m = html.match(/"player_level":(\d+)/) || html.match(/class="friendPlayerLevelNum"[^>]*>(\d+)/);
const level = m ? parseInt(m[1], 10) : null;
if (level != null) cacheSet(cacheKey, level, 60 * 60 * 1000);
return level;
} catch { return null; }
}
// ---- 获取好友数量 ----
async function fetchFriendCount(steamId) {
if (!steamId) return null;
const cacheKey = 'friendCount_' + steamId;
const cached = cacheGet(cacheKey);
if (cached != null) return cached;
try {
const auth = await sgisAuthParams(); // v2.3.24: API Key 缺省时回退 access_token
if (!auth) return null;
const url = `https://api.steampowered.com/ISteamUser/GetFriendList/v0001/?${auth}&steamid=${steamId}&relationship=friend`;
const data = await fetchJson(url, { timeout: 12000 });
const count = data?.friendslist?.friends?.length || 0;
cacheSet(cacheKey, count, 60 * 60 * 1000);
return count;
} catch { return null; }
}
// v2.3.27: 轻量愿望单计数(参考 Steam-Wishlist-Exporter 的获取链路,仅供概览 KPI 卡片展示)
// 优先级: 会话已加载 → GDynamicStore(零请求) → 6h缓存 → GetWishlist API(单请求)
async function fetchWishlistCount(steamId) {
// 1. 中央面板本次会话已加载的愿望单列表
if (Array.isArray(state.wishlistGames) && state.wishlistGames.length > 0) {
return state.wishlistGames.length;
}
// 2. GDynamicStore 即时读取(商店页面,零请求且数据最新)
try {
const dsIds = getDynamicStoreAppIds('wishlist');
if (dsIds.size > 0) return dsIds.size;
} catch { /* ignore */ }
// 3. 与愿望单标签页共享的 6h 全量缓存
const cachedList = cacheGet('wishlistGames');
if (Array.isArray(cachedList) && cachedList.length > 0) return cachedList.length;
// 4. 计数缓存(避免重复 API 请求)
const cacheKey = 'wishlistCount_' + steamId;
const cachedCount = cacheGet(cacheKey);
if (cachedCount != null) return cachedCount;
// 5. GetWishlist API 单请求兜底
if (steamId) {
try {
const entries = await fetchWishlistFromApi(steamId);
if (entries.length > 0) {
cacheSet(cacheKey, entries.length, 6 * 3600 * 1000);
return entries.length;
}
} catch { /* ignore */ }
}
return null;
}
// ==================== v2.3.8: 社交标签页 - 好友列表完整数据 ====================
// 参考 steam-friend-manager-1.0.16.js 的批量获取+缓存策略
// 流程: GetFriendList → GetPlayerSummaries(批量100) → GetPlayerBans(批量100)
// 缓存: 完整数据 6h, VAC 独立 24h, 等级独立 7天
const SUMMARY_BATCH = 100; // GetPlayerSummaries 单次上限
const VAC_BATCH = 100; // GetPlayerBans 单次上限
// ==================== SVG 国旗 (移植自 steam-friend-manager-1.0.16) ====================
// 内联 SVG 国旗, 不依赖外部 CDN (flagcdn.com), 加载更快/不跨域/失败时降级为文本代码徽章
const SGIS_FLAGS = {
CN:'',
US:'',
JP:'',
KR:'',
TW:'',
HK:'',
MO:'',
GB:'',
DE:'',
FR:'',
RU:'',
AU:'',
CA:'',
BR:'',
IN:'',
SG:'',
MY:'',
TH:'',
VN:'',
ID:'',
PH:'',
NZ:'',
SE:'',
NO:'',
FI:'',
DK:'',
NL:'',
IT:'',
ES:'',
PL:'',
UA:'',
TR:'',
SA:'',
AE:'',
EG:'',
ZA:'',
IL:'',
MX:'',
AR:'',
CL:'',
AT:'',
BE:'',
CH:'',
PT:'',
GR:'',
CZ:'',
HU:'',
RO:'',
IE:'',
EU:''
};
// ==================== 国家/地区名称映射 (zh-CN) ====================
const SGIS_COUNTRY_MAP = {
CN:'中国',US:'美国',JP:'日本',KR:'韩国',TW:'中国台湾',HK:'中国香港',MO:'中国澳门',
SG:'新加坡',MY:'马来西亚',TH:'泰国',VN:'越南',ID:'印度尼西亚',PH:'菲律宾',
IN:'印度',AU:'澳大利亚',NZ:'新西兰',GB:'英国',DE:'德国',FR:'法国',IT:'意大利',
ES:'西班牙',RU:'俄罗斯',BR:'巴西',CA:'加拿大',MX:'墨西哥',AR:'阿根廷',CL:'智利',
SE:'瑞典',NO:'挪威',FI:'芬兰',DK:'丹麦',NL:'荷兰',BE:'比利时',AT:'奥地利',
CH:'瑞士',PL:'波兰',CZ:'捷克',PT:'葡萄牙',GR:'希腊',TR:'土耳其',
SA:'沙特阿拉伯',AE:'阿联酋',EG:'埃及',ZA:'南非',IL:'以色列',UA:'乌克兰',
RO:'罗马尼亚',HU:'匈牙利',SK:'斯洛伐克',BG:'保加利亚',HR:'克罗地亚',
SI:'斯洛文尼亚',LT:'立陶宛',LV:'拉脱维亚',EE:'爱沙尼亚',IE:'爱尔兰',
IS:'冰岛',LU:'卢森堡',MT:'马耳他',CY:'塞浦路斯',EU:'欧盟'
};
// 生成 inline SVG 国旗 (默认 18x13), 失败回退文本代码徽章
function flagSvg(cc, w, h) {
if (!cc || cc.length < 2) return '';
const uc = cc.toUpperCase();
const svg = SGIS_FLAGS[uc];
const ww = w || 18, hh = h || 13;
const style = `display:inline-block;width:${ww}px;height:${hh}px;vertical-align:middle;flex-shrink:0;border-radius:1px;box-shadow:0 0 0 1px rgba(255,255,255,0.08)`;
if (svg) return svg.replace('`;
}
// 获取国家信息 { name, flag }
function getCountryInfo(code) {
if (!code) return null;
const uc = code.toUpperCase();
return { code: uc, name: SGIS_COUNTRY_MAP[uc] || uc, flag: flagSvg(uc) };
}
// 计算好友天数 (friend_since 是 unix 秒)
function calcFriendDays(friendSince) {
if (!friendSince) return { days: 0, text: '未知', cls: 'd-old' };
const days = Math.floor((Date.now() / 1000 - friendSince) / 86400);
let text, cls;
if (days < 30) { text = `${days}天`; cls = 'd-new'; }
else if (days < 365) { text = `${Math.floor(days / 30)}个月`; cls = 'd-mid'; }
else { text = `${(days / 365).toFixed(1)}年`; cls = 'd-old'; }
return { days, text, cls };
}
// 获取在线状态文字
function getPersonaStateText(state) {
const map = ['离线', '在线', '忙碌', '离开', '休眠', '交易中', '游戏中'];
return map[state] || '离线';
}
// v2.3.24: 统一鉴权参数——优先 API Key,缺省时回退页面 access_token(免配置也可获取好友数据)
async function sgisAuthParams() {
const apiKey = storage.getApiKey();
if (apiKey) return `key=${apiKey}`;
try {
const token = await getAccessToken();
if (token) return `access_token=${token}`;
} catch { /* ignore */ }
return '';
}
// 获取好友完整数据 (列表+摘要+VAC), 带缓存与增量更新
async function fetchFriendsList(steamId, opts = {}) {
// v2.3.24: 鉴权优化——API Key 或 access_token 任一可用即可
const auth = await sgisAuthParams();
if (!auth || !steamId) return { error: 'NO_KEY', friends: [] };
const { force = false } = opts;
// 1. 检查完整缓存 (6h TTL)
const listCacheKey = 'friendsList_' + steamId;
if (!force) {
const cached = cacheGet(listCacheKey);
if (cached && cached.friends && cached.friends.length > 0) {
return cached;
}
}
// 2. 获取好友列表 (含 friend_since)
const listUrl = `https://api.steampowered.com/ISteamUser/GetFriendList/v0001/?${auth}&steamid=${steamId}&relationship=friend&format=json`;
const listData = await fetchJson(listUrl, { timeout: 15000 });
const rawFriends = listData?.friendslist?.friends || [];
if (rawFriends.length === 0) {
return { error: 'EMPTY', friends: [], steamId };
}
const steamIds = rawFriends.map(f => f.steamid);
// 3. 批量获取摘要 (每批 100)
const summaries = [];
for (let i = 0; i < steamIds.length; i += SUMMARY_BATCH) {
const batch = steamIds.slice(i, i + SUMMARY_BATCH);
try {
const sumUrl = `https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/?${auth}&steamids=${batch.join(',')}`;
const sumData = await fetchJson(sumUrl, { timeout: 15000 });
if (sumData?.response?.players) summaries.push(...sumData.response.players);
} catch (e) { console.warn('[SGIS] GetPlayerSummaries batch failed:', e); }
}
const sumMap = new Map(summaries.map(s => [s.steamid, s]));
// 4. 批量获取 VAC 封禁 (每批 100), 优先用独立缓存
const vacCacheKey = 'friendsVac_' + steamId;
const vacCached = cacheGet(vacCacheKey) || {};
const vacMap = { ...vacCached };
const needVacIds = steamIds.filter(sid => !vacMap[sid]);
for (let i = 0; i < needVacIds.length; i += VAC_BATCH) {
const batch = needVacIds.slice(i, i + VAC_BATCH);
try {
const banUrl = `https://api.steampowered.com/ISteamUser/GetPlayerBans/v1/?${auth}&steamids=${batch.join(',')}`;
const banData = await fetchJson(banUrl, { timeout: 15000 });
if (banData?.players) {
for (const p of banData.players) {
vacMap[p.SteamId] = {
VACBanned: !!p.VACBanned,
DaysSinceLastBan: p.DaysSinceLastBan || 0,
NumberOfVACBans: p.NumberOfVACBans || 0,
NumberOfGameBans: p.NumberOfGameBans || 0,
};
}
}
} catch (e) { console.warn('[SGIS] GetPlayerBans batch failed:', e); }
}
// VAC 独立缓存 24h
cacheSet(vacCacheKey, vacMap, CACHE_TTL.friendsVac);
// 5. 合并数据 (参考 friend-manager fetchAllData 的 enriched 逻辑)
const friends = rawFriends.map(f => {
const s = sumMap.get(f.steamid) || {};
const daysInfo = calcFriendDays(f.friend_since);
const vacInfo = vacMap[f.steamid] || {};
const countryInfo = getCountryInfo(s.loccountrycode);
return {
steamid: f.steamid,
friend_since: f.friend_since || 0,
friend_days: daysInfo.days,
friend_days_text: daysInfo.text,
friend_days_class: daysInfo.cls,
personaname: s.personaname || '匿名玩家',
avatar: s.avatar || '',
avatarmedium: s.avatarmedium || s.avatar || '',
avatarfull: s.avatarfull || s.avatar || '',
personastate: s.personastate !== undefined ? s.personastate : 0,
lastlogoff: s.lastlogoff || 0,
gameextrainfo: s.gameextrainfo || '',
gameid: s.gameid || '',
loccountrycode: s.loccountrycode || '',
// v2.3.8 修复: 预填国家名+SVG国旗, 渲染时直接用, 不再依赖外部 CDN
country_name: countryInfo ? countryInfo.name : '',
country_flag: countryInfo ? countryInfo.flag : '',
profileurl: s.profileurl || `https://steamcommunity.com/profiles/${f.steamid}/`,
// VAC 信息
vac_banned: !!vacInfo.VACBanned,
vac_days_since_last_ban: vacInfo.DaysSinceLastBan || 0,
vac_ban_count: vacInfo.NumberOfVACBans || 0,
game_ban_count: vacInfo.NumberOfGameBans || 0,
// 等级 (从独立缓存读取, fetchFriendsLevels 填充)
level: null,
};
});
// 6. 读取等级缓存 (独立 7天 TTL, 可能不完整)
const levelCacheKey = 'friendsLevels_' + steamId;
const levelCached = cacheGet(levelCacheKey) || {};
for (const f of friends) {
if (levelCached[f.steamid] != null) f.level = levelCached[f.steamid];
}
const result = { friends, steamId, total: friends.length, _ts: Date.now() };
// 完整数据缓存 6h
cacheSet(listCacheKey, result, CACHE_TTL.friendsList);
// 同步更新好友数量缓存
cacheSet('friendCount_' + steamId, friends.length, 60 * 60 * 1000);
return result;
}
// 懒加载好友等级 (GetSteamLevel 单次只接受一个 steamid, 使用 mapLimit 并发)
// 参考 friend-manager 的并发池思想:v2.3.12 改为 100 并发,点击一次即可自动同步全部未知等级
async function fetchFriendsLevels(steamId, opts = {}) {
const auth = await sgisAuthParams(); // v2.3.24: API Key 缺省时回退 access_token
if (!auth || !steamId) return null;
const { force = false, maxCount = Infinity } = opts;
const levelCacheKey = 'friendsLevels_' + steamId;
const levelCached = force ? {} : (cacheGet(levelCacheKey) || {});
let changed = false;
const friends = (SGIS.friendsList && SGIS.friendsList.friends) || [];
let toFetch = friends.filter(f => f.level == null).map(f => f.steamid);
if (maxCount !== Infinity && maxCount > 0) {
toFetch = toFetch.slice(0, maxCount);
}
if (toFetch.length === 0) {
SGIS.friendsLevels = levelCached;
return levelCached;
}
const LEVEL_CONCURRENCY = 100;
const RENDER_INTERVAL = 800;
let lastRenderTime = 0;
// 仅在用户主动触发(按钮点击)时显示实时进度;后台预加载不干扰按钮状态
if (SGIS.friendsLevelsLoading) {
SGIS.friendsLevelsTotal = toFetch.length;
SGIS.friendsLevelsProgress = 0;
}
async function fetchOne(sid, attempt = 0) {
const url = `https://api.steampowered.com/IPlayerService/GetSteamLevel/v1/?${auth}&steamid=${sid}`;
try {
const data = await fetchJson(url, { timeout: 8000 });
return data?.response?.player_level ?? 0;
} catch (e) {
if (attempt < 2) {
await new Promise(r => setTimeout(r, (attempt + 1) * 800));
return fetchOne(sid, attempt + 1);
}
return undefined;
}
}
await mapLimit(toFetch, LEVEL_CONCURRENCY, async (sid) => {
const level = await fetchOne(sid);
if (level !== undefined) {
levelCached[sid] = level;
const f = friends.find(x => x.steamid === sid);
if (f) f.level = level;
changed = true;
}
if (SGIS.friendsLevelsLoading) {
SGIS.friendsLevelsProgress++;
const now = Date.now();
if (now - lastRenderTime >= RENDER_INTERVAL) {
lastRenderTime = now;
if (SGIS.tab === 'social') renderSocialContent();
}
}
});
if (changed) {
cacheSet(levelCacheKey, levelCached, CACHE_TTL.friendsLevels);
for (const f of friends) {
if (levelCached[f.steamid] != null) f.level = levelCached[f.steamid];
}
}
}
// v2.3.13: 手动同步全部好友 VAC/游戏封禁状态 (GetPlayerBans 支持批量 100)
async function fetchFriendsBans(steamId, opts = {}) {
const auth = await sgisAuthParams(); // v2.3.24: API Key 缺省时回退 access_token
if (!auth || !steamId) return null;
const { force = false } = opts;
const friends = (SGIS.friendsList && SGIS.friendsList.friends) || [];
const steamIds = friends.map(f => f.steamid);
if (steamIds.length === 0) return null;
const vacCacheKey = 'friendsVac_' + steamId;
const vacCached = cacheGet(vacCacheKey) || {};
const vacMap = { ...vacCached };
let toFetch = steamIds;
if (!force) {
toFetch = steamIds.filter(sid => !vacMap[sid]);
}
if (toFetch.length === 0) return vacMap;
const BAN_BATCH = 100;
const RENDER_INTERVAL = 800;
let lastRenderTime = 0;
if (SGIS.friendsBansLoading) {
SGIS.friendsBansTotal = toFetch.length;
SGIS.friendsBansProgress = 0;
}
for (let i = 0; i < toFetch.length; i += BAN_BATCH) {
const batch = toFetch.slice(i, i + BAN_BATCH);
let retry = 0;
while (retry < 3) {
try {
const banUrl = `https://api.steampowered.com/ISteamUser/GetPlayerBans/v1/?${auth}&steamids=${batch.join(',')}`;
const banData = await fetchJson(banUrl, { timeout: 15000 });
if (banData?.players) {
for (const p of banData.players) {
vacMap[p.SteamId] = {
VACBanned: !!p.VACBanned,
DaysSinceLastBan: p.DaysSinceLastBan || 0,
NumberOfVACBans: p.NumberOfVACBans || 0,
NumberOfGameBans: p.NumberOfGameBans || 0,
};
}
}
break;
} catch (e) {
retry++;
console.warn(`[SGIS] GetPlayerBans batch retry ${retry}:`, e);
if (retry < 3) await new Promise(r => setTimeout(r, retry * 600));
}
}
for (const sid of batch) {
const info = vacMap[sid];
const f = friends.find(x => x.steamid === sid);
if (f && info) {
f.vac_banned = info.VACBanned;
f.vac_days_since_last_ban = info.DaysSinceLastBan || 0;
f.vac_ban_count = info.NumberOfVACBans || 0;
f.game_ban_count = info.NumberOfGameBans || 0;
}
}
if (SGIS.friendsBansLoading) {
SGIS.friendsBansProgress += batch.length;
const now = Date.now();
if (now - lastRenderTime >= RENDER_INTERVAL) {
lastRenderTime = now;
if (SGIS.tab === 'social') renderSocialContent();
}
}
}
cacheSet(vacCacheKey, vacMap, CACHE_TTL.friendsVac);
SGIS.friendsVac = vacMap;
if (SGIS.friendsBansLoading) SGIS.friendsBansProgress = toFetch.length;
return vacMap;
}
// v2.3.24: 好友游戏数量懒加载(参考 steam-friend-manager 社交仪表盘"游戏总数排行")
// GetOwnedGames include_appinfo=0 轻量请求(不拉游戏名,响应更小);mapLimit 6 并发;12h 缓存;私密资料跳过
async function fetchFriendsGameCounts(steamId, opts = {}) {
const auth = await sgisAuthParams();
if (!auth || !steamId) return null;
const { force = false } = opts;
const GC_TTL = 12 * 3600 * 1000;
const cacheKey = 'friendsGameCounts_' + steamId;
const gcMap = force ? {} : (cacheGet(cacheKey) || {});
const friends = (SGIS.friendsList && SGIS.friendsList.friends) || [];
if (friends.length === 0) return gcMap;
const now = Date.now();
const toFetch = friends.map(f => f.steamid).filter(sid => !(gcMap[sid] && now - gcMap[sid].ts < GC_TTL));
if (toFetch.length === 0) { SGIS.friendsGameCounts = gcMap; return gcMap; }
if (SGIS.friendsGameCountsLoading) {
SGIS.friendsGameCountsTotal = toFetch.length;
SGIS.friendsGameCountsProgress = 0;
}
let done = 0;
await mapLimit(toFetch, 6, async (sid) => {
try {
const data = await fetchJson(`https://api.steampowered.com/IPlayerService/GetOwnedGames/v0001/?${auth}&steamid=${sid}&include_appinfo=0&include_played_free_games=1&format=json`, { timeout: 15000 });
const resp = data && data.response;
if (resp && typeof resp.game_count === 'number' && resp.game_count > 0) {
let tm = 0;
(resp.games || []).forEach(g => { tm += g.playtime_forever || 0; });
gcMap[sid] = { gc: resp.game_count, tm, ts: now };
} else {
// 私密资料或无游戏
gcMap[sid] = { gc: 0, tm: 0, ts: now, priv: true };
}
} catch { /* 单个失败跳过,下轮再补 */ }
done++;
if (SGIS.friendsGameCountsLoading) {
SGIS.friendsGameCountsProgress = done;
// 直接更新按钮文案,避免整页重绘导致搜索框失焦
const btn = document.getElementById('sgis-social-load-gc');
if (btn) btn.textContent = `${isZh ? '加载中' : 'Loading'} ${done}/${toFetch.length}`;
}
});
cacheSet(cacheKey, gcMap, GC_TTL);
SGIS.friendsGameCounts = gcMap;
return gcMap;
}
// ---- 获取 Steam 官方新闻 (促销/更新/活动) ----
// v2.3.25: 重写官方动态数据获取——接入 featuredcategories API 获取结构化特惠/促销数据
// 数据源:
// 1. store.steampowered.com/api/featuredcategories — 当前特惠/每日优惠/聚光灯(含促销活动页URL)
// 2. api.steampowered.com/ISteamNews/GetNewsForApp — Steam 官方新闻公告
// 返回结构: { specials:[], dailyDeals:[], spotlights:[], news:[], totalCount:N, fetchedAt:ts }
async function fetchSteamOfficialNews() {
const cacheKey = 'steamOfficialNews';
const cached = cacheGet(cacheKey);
// v2.3.25: 兼容旧版缓存(旧版返回数组,新版返回对象)
if (cached && !Array.isArray(cached) && cached.totalCount !== undefined) return cached;
const result = { specials: [], dailyDeals: [], spotlights: [], news: [], totalCount: 0, fetchedAt: Date.now() };
// ---- 1. featuredcategories API:获取当前特惠/每日优惠/聚光灯 ----
try {
const fcUrl = 'https://store.steampowered.com/api/featuredcategories?cc=us&l=schinese';
const fcData = await fetchJson(fcUrl, { timeout: 15000 });
// 1a. 特惠游戏 (specials)
if (fcData.specials && Array.isArray(fcData.specials.items)) {
result.specials = fcData.specials.items
.filter(it => it && it.discounted && Number(it.discount_percent) > 0)
.slice(0, 20)
.map(it => {
const discount = Number(it.discount_percent) || 0;
const original = Number(it.original_price) || 0;
const final = Number(it.final_price) || 0;
const exp = Number(it.discount_expiration) || 0;
return {
appid: it.id,
name: it.name || '',
discount,
originalPrice: original, // 分
finalPrice: final, // 分
currency: it.currency || 'USD',
expiration: exp, // Unix 时间戳
headerImage: it.header_image || it.large_capsule_image || '',
url: `https://store.steampowered.com/app/${it.id}`,
};
});
}
// 1b. 每日优惠 (cat_dailydeal) — key 不固定,可能是 cat_dailydeal 或数字索引
const dailyDealKey = Object.keys(fcData).find(k => {
const v = fcData[k];
return v && v.id === 'cat_dailydeal' && Array.isArray(v.items);
});
if (dailyDealKey) {
result.dailyDeals = fcData[dailyDealKey].items
.filter(it => it && it.discounted)
.slice(0, 5)
.map(it => {
const discount = Number(it.discount_percent) || 0;
const original = Number(it.original_price) || 0;
const final = Number(it.final_price) || 0;
return {
appid: it.id,
name: it.name || '',
discount,
originalPrice: original,
finalPrice: final,
currency: it.currency || 'USD',
headerImage: it.header_image || '',
url: `https://store.steampowered.com/app/${it.id}`,
};
});
}
// 1c. 聚光灯/促销活动 (cat_spotlight) — 含促销活动页 URL
const spotlightKeys = Object.keys(fcData).filter(k => {
const v = fcData[k];
return v && v.id === 'cat_spotlight' && Array.isArray(v.items);
});
for (const sk of spotlightKeys) {
for (const sp of fcData[sk].items) {
if (sp && sp.url && sp.url.includes('/sale/')) {
// 从 URL 提取活动名称,如 /sale/Games-Composed-in-Germany
const saleNameMatch = sp.url.match(/\/sale\/([^\/\?]+)/);
const saleId = saleNameMatch ? saleNameMatch[1] : '';
// 去重:同一 saleId 只保留一条
if (saleId && result.spotlights.some(s => s.saleId === saleId)) continue;
result.spotlights.push({
name: sp.name || '促销活动',
url: sp.url,
saleId,
headerImage: sp.header_image || sp.large_capsule_image || '',
body: sp.body || '',
// v2.9.70: 捕获 discount_expiration 时间戳(部分 spotlight 有此字段)
discountExpiration: Number(sp.discount_expiration) || 0,
});
}
}
}
} catch (e) {
console.warn('[SGIS] featuredcategories 获取失败:', e);
}
// ---- 2. ISteamNews API:获取 Steam 官方新闻公告 ----
try {
const newsAppIds = [593050]; // Steam Client News
const allNews = [];
for (const aid of newsAppIds) {
try {
const newsUrl = 'https://api.steampowered.com/ISteamNews/GetNewsForApp/v2/?appid=' + aid + '&count=20&maxlength=500&format=json';
const data = await fetchJson(newsUrl, { timeout: 12000 });
const items = (data && data.appnews && data.appnews.newsitems) || [];
allNews.push(...items.map(n => ({
title: n.title || '',
url: n.url || '',
date: n.date || 0,
contents: (n.contents || '').slice(0, 300),
feedname: n.feedname || 'steam',
author: n.author || '',
})));
} catch { /* ignore individual appid failures */ }
}
allNews.sort((a, b) => b.date - a.date);
result.news = allNews.slice(0, 15);
} catch (e) {
console.warn('[SGIS] ISteamNews 获取失败:', e);
}
result.totalCount = result.specials.length + result.dailyDeals.length + result.spotlights.length + result.news.length;
cacheSet(cacheKey, result, 30 * 60 * 1000);
return result;
}
// ---- v2.3.6: SteamCardExchange 卡牌数据库 (全部有交易卡牌的游戏) ----
// 数据源: https://www.steamcardexchange.net/api/request.php?GetInventory
// 返回格式: { data: [ [[appid, name], ..., [size]], ... ] }
// 解析为: { appId: { name, maxLevel } } (maxLevel = size, 即徽章最高可达等级)
const CARD_DB_API_URL = 'https://www.steamcardexchange.net/api/request.php?GetInventory';
const CARD_DB_CACHE_KEY = 'sgis_card_db_cache';
const CARD_DB_CACHE_TTL = 24 * 60 * 60 * 1000; // 24小时
let cardDbData = null; // { appId: { name, maxLevel } }
let cardDbLoading = false;
function getCardDbMaxLevel(appId) {
if (!cardDbData || !appId) return 0;
const info = cardDbData[String(appId)];
return info ? info.maxLevel : 0;
}
function getCardDbGameName(appId) {
if (!cardDbData || !appId) return '';
const info = cardDbData[String(appId)];
return info ? info.name : '';
}
async function loadCardDatabase() {
if (cardDbData || cardDbLoading) return cardDbData;
cardDbLoading = true;
try {
// 检查本地缓存
const cached = GM_getValue(CARD_DB_CACHE_KEY, null);
if (cached && cached.timestamp && (Date.now() - cached.timestamp < CARD_DB_CACHE_TTL)) {
cardDbData = cached.data;
console.log(`[SGIS] 卡牌数据库缓存命中: ${Object.keys(cardDbData).length} 款游戏`);
return cardDbData;
}
console.log('[SGIS] 正在从 SteamCardExchange 获取卡牌数据库...');
const resp = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: CARD_DB_API_URL,
timeout: 30000,
onload: (r) => resolve(r),
onerror: () => reject(new Error('Network error')),
ontimeout: () => reject(new Error('Timeout')),
});
});
const json = JSON.parse(resp.responseText);
if (!json || !json.data || !Array.isArray(json.data)) {
throw new Error('API 返回数据格式异常');
}
const db = {};
let count = 0;
// data[i] = [[appid, name], ..., [size]]
json.data.forEach((current) => {
if (!current || !current[0] || !current[3]) return;
const appId = String(current[0][0]);
const name = current[0][1] || '';
const size = current[3][0] || 0;
if (appId && size) {
db[appId] = { name, maxLevel: size };
count++;
}
});
cardDbData = db;
GM_setValue(CARD_DB_CACHE_KEY, { timestamp: Date.now(), data: db });
console.log(`[SGIS] 卡牌数据库加载完成: ${count} 款游戏`);
// v2.9.77: 注入到 SGLVBadge 库, 供勋章页卡牌支持检测复用 (免额外 API 请求)
const _badgeLib = (typeof unsafeWindow !== 'undefined' && unsafeWindow.SGLVBadge)
|| (typeof window !== 'undefined' && window.SGLVBadge);
if (_badgeLib && typeof _badgeLib.setCardDb === 'function') {
_badgeLib.setCardDb(db);
}
return cardDbData;
} catch (e) {
console.warn('[SGIS] 卡牌数据库加载失败(不影响基本功能):', e.message);
return null;
} finally {
cardDbLoading = false;
}
}
// v2.9.0: 桥接卡牌数据库函数到 SGLV_API,供外层 renderGamesList 使用
SGLV_API.getCardDbMaxLevel = getCardDbMaxLevel;
SGLV_API.getCardDbGameName = getCardDbGameName;
SGLV_API.loadCardDatabase = loadCardDatabase;
// v2.9.90: 桥接 SGIS 玩家档案数据到 SGLV_API,供个人展柜标签页复用
SGLV_API.getSGISProfile = () => SGIS.profile || null;
SGLV_API.fetchSGISProfile = () => { renderProfile({ force: false }); };
// v2.9.94: 桥接 SGIS 徽章数据和活动 appid 映射, 供展柜徽章展示复用
SGLV_API.getSGISUserBadges = () => SGIS.userBadges || null;
SGLV_API.getSteamEventAppIdMap = () => typeof STEAM_EVENT_APPID_MAP !== 'undefined' ? STEAM_EVENT_APPID_MAP : null;
// ---- 获取用户勋章/徽章 ----
// v2.3.7: 增加浏览器持久缓存与数据合理性校验, 避免 API 异常时覆盖已有正确缓存
const PROFILE_CACHE_KEY = 'sgis_profile_cache_v1';
const USER_BADGES_CACHE_KEY = 'sgis_user_badges_cache_v1';
const PROFILE_CACHE_TTL = 7 * 24 * 3600 * 1000; // 7 天 (实际过期由失效条件控制)
function readPersistentProfileCache(steamId) {
try {
const raw = GM_getValue(PROFILE_CACHE_KEY, null);
if (!raw) return null;
const obj = JSON.parse(raw);
if (!obj || obj.steamId !== steamId) return null;
if (obj.expires && Date.now() > obj.expires) {
GM_setValue(PROFILE_CACHE_KEY, '');
return null;
}
return obj.data || null;
} catch { return null; }
}
function writePersistentProfileCache(steamId, data) {
try {
GM_setValue(PROFILE_CACHE_KEY, JSON.stringify({ steamId, data, expires: Date.now() + PROFILE_CACHE_TTL }));
} catch { /* ignore quota errors */ }
}
function readPersistentUserBadgesCache(steamId) {
try {
const raw = GM_getValue(USER_BADGES_CACHE_KEY, null);
if (!raw) return null;
const obj = JSON.parse(raw);
if (!obj || obj.steamId !== steamId) return null;
if (obj.expires && Date.now() > obj.expires) {
GM_setValue(USER_BADGES_CACHE_KEY, '');
return null;
}
return obj.data || null;
} catch { return null; }
}
function writePersistentUserBadgesCache(steamId, data) {
try {
GM_setValue(USER_BADGES_CACHE_KEY, JSON.stringify({ steamId, data, expires: Date.now() + PROFILE_CACHE_TTL }));
} catch { /* ignore quota errors */ }
}
// v2.3.7: 校验 GetBadges 数据是否比缓存更"合理"
// 核心规则: 等级进度 (playerXp - curLevelXp) 不应低于缓存值 (经验只增不减)
function isBadgeDataBetterThanCache(newData, cachedData) {
if (!cachedData) return true;
if (!newData) return false;
// 等级不能降低
if ((newData.playerLevel || 0) < (cachedData.playerLevel || 0)) return false;
// 总经验不能降低
if ((newData.playerXp || 0) < (cachedData.playerXp || 0)) return false;
// 计算当前等级进度 (使用公式得出 curLevelXp, 因为 API 阈值可能异常)
function curLevelThreshold(d) {
const L = d.playerLevel || 0;
if (L <= 0) return 0;
let xp = 0;
let bracketStart = 1;
let perLevel = 100;
while (bracketStart < L) {
const bracketEnd = Math.min(bracketStart + 10, L);
xp += (bracketEnd - bracketStart) * perLevel;
bracketStart += 10;
perLevel += 100;
}
return xp;
}
const newProgress = Math.max(0, (newData.playerXp || 0) - curLevelThreshold(newData));
const cachedProgress = Math.max(0, (cachedData.playerXp || 0) - curLevelThreshold(cachedData));
// 如果新进度明显低于缓存进度, 认为 API 数据异常, 不更新缓存
if (newProgress < cachedProgress - 100) return false;
return true;
}
async function fetchUserBadges(steamId, opts = {}) {
const apiKey = storage.getApiKey();
if (!apiKey || !steamId) return null;
const { force = false } = opts;
const cacheKey = 'userBadges_' + steamId;
const ttlCache = cacheGet(cacheKey);
const persistentCache = readPersistentUserBadgesCache(steamId);
const cached = force ? null : (ttlCache || persistentCache);
if (cached && !force) {
return enrichBadgesWithGameNames(cached);
}
try {
const url = `https://api.steampowered.com/IPlayerService/GetBadges/v1/?key=${apiKey}&steamid=${steamId}`;
const data = await fetchJson(url, { timeout: 12000 });
const badges = data?.response?.badges || [];
const playerLevel = data?.response?.player_level || 0;
const playerXp = data?.response?.player_xp || 0;
// v2.3.7: 获取 API 提供的等级 XP 阈值 (权威值, 避免公式计算误差)
const playerXpNeededCurrentLevel = data?.response?.player_xp_needed_current_level || 0;
const playerXpNeededToLevelUp = data?.response?.player_xp_needed_to_level_up || 0;
const result = { badges, playerLevel, playerXp, playerXpNeededCurrentLevel, playerXpNeededToLevelUp, _ts: Date.now() };
// v2.3.7: 数据合理性校验, 如果新数据比缓存差则保留旧缓存
const keepOld = cached && !isBadgeDataBetterThanCache(result, cached);
if (keepOld) {
console.log('[SGIS] GetBadges API 数据异常(进度低于缓存), 保留旧缓存');
return enrichBadgesWithGameNames(cached);
}
cacheSet(cacheKey, result, 6 * 3600 * 1000);
writePersistentUserBadgesCache(steamId, result);
return enrichBadgesWithGameNames(result);
} catch {
// 网络失败时回退到持久缓存
if (persistentCache) return enrichBadgesWithGameNames(persistentCache);
return null;
}
}
// ---- 用本地游戏库补全 badge.gameName (v2.3.1.1 修复) ----
// GetBadges API 不返回 badge 名称, 用 state.ownedGames 反查
// v2.3.6: 增加用 SteamCardExchange API 数据补全名称 (覆盖未拥有但有徽章的游戏)
function enrichBadgesWithGameNames(badgeData) {
if (!badgeData || !badgeData.badges) return badgeData;
const owned = (state.ownedGames && state.ownedGames.length) ? state.ownedGames : storage.getCachedGames();
const ownedMap = {};
owned.forEach(g => { if (g && g.appid) ownedMap[String(g.appid)] = g.name; });
badgeData.badges.forEach(b => {
if (b.appid > 0) {
// v2.3.6: 优先用本地游戏库名称, 回退到卡牌数据库API名称
b._gameName = ownedMap[String(b.appid)] || getCardDbGameName(b.appid) || '';
b._hasGameName = !!b._gameName;
// v2.3.6: 补充最大等级信息
b._maxLevel = getCardDbMaxLevel(b.appid);
}
// 节日徽章 / 社区徽章: 用 communityitemid / type / completion_time 推断
if (b.communityitemid) {
b._isCommunityBadge = true;
}
});
return badgeData;
}
// ---- 节日 / 活动徽章名称映射 (v2.3.5 扩展) ----
// Steam 节日活动 appid 常见列表 (从社区维护)
// v2.3.5: 增加 Steam Awards(Steam 大奖)投票活动 appid + 历年活动
const STEAM_EVENT_APPID_MAP = {
// 2026
4761370: '2026 年夏日特卖',
// 2025
4113600: '2025 年冬日特卖',
4098760: '2025 年夏日特卖',
3902760: '2025 年春季特卖',
// 2024
3567630: '2024 年冬日特卖',
3443010: '2024 年夏日特卖',
3220140: '2024 年春季特卖',
// 2023
3091960: '2023 年冬日特卖',
2881690: '2023 年夏日特卖',
2700500: '2023 年春季特卖',
// 2022
2682730: '2022 年冬日特卖',
2489650: '2022 年夏日特卖',
2320430: '2022 年秋季特卖',
// 2021
2285690: '2021 年冬日特卖',
2111170: '2021 年夏日特卖',
1977630: '2021 年秋季特卖',
// 2020
1919510: '2020 年冬日特卖',
1708140: '2020 年夏日特卖',
1624910: '2020 年秋季特卖',
// 2019
1531430: '2019 年冬日特卖',
1411040: '2019 年夏日特卖',
1283310: '2019 年秋季特卖',
// 2018
1254900: '2018 年冬日特卖',
1111370: '2018 年夏日特卖',
977950: '2018 年秋季特卖',
// 2017
991980: '2017 年冬日特卖',
866930: '2017 年夏日特卖',
748210: '2017 年冬日特卖',
// 2016
630870: '2016 年夏日特卖',
516940: '2016 年冬日特卖',
// 2015
408590: '2015 年夏日特卖',
302200: '2015 年冬日特卖',
// 2014
224260: '2014 年夏日特卖',
161330: '2014 年冬日特卖',
// 2013
104700: '2013 年夏日特卖',
57430: '2013 年冬日特卖',
// 2012
207250: '2012 年假日特卖',
// ===== Steam Awards (Steam 大奖) 投票活动 =====
// Steam Awards 每年秋季特卖期间举办, 有专门的投票 appid
4770200: '2025 Steam 大奖投票',
4149180: '2024 Steam 大奖投票',
3934100: '2023 Steam 大奖投票',
3711000: '2022 Steam 大奖投票',
3510200: '2021 Steam 大奖投票',
3326500: '2020 Steam 大奖投票',
3125400: '2019 Steam 大奖投票',
2944230: '2018 Steam 大奖投票',
// ===== 其他特殊活动 =====
1510: 'Steam 大奖 (2016)',
660: 'Steam 大奖 (2015)',
531: 'Steam 大奖 (2014)',
// ===== Steam 游戏庆祝/周年活动 =====
2519800: 'Steam 20 周年纪念',
1978740: 'Steam 18 周年纪念',
};
// v2.3.7: 活动徽章图标 CDN 基础 URL (借鉴 steam-badges-card-view 脚本)
// Steam 节日活动徽章图标存储在 steamcommunity/public/images/items/{appid}/ 路径下
const ACTIVITY_ICON_CDN = 'https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/items/';
// v2.3.7: 构建活动徽章图标 URL (icon_64x64.png)
function getActivityIconUrl(appid) {
if (!appid) return '';
return ACTIVITY_ICON_CDN + appid + '/icon_64x64.png';
}
// v2.3.5: 推断 badge 是否属于 Steam 节日活动
// 1) 优先查已知 appid 表
// 2) 辅助检测: appid > 3000000 且不在用户游戏库中的, 很可能是节日活动
function detectEventBadge(b) {
if (!b.appid) return null;
const eventName = STEAM_EVENT_APPID_MAP[String(b.appid)];
if (eventName) return { name: eventName, appid: b.appid };
// v2.3.5: 辅助检测 - appid > 3000000 且没有对应游戏名(不在游戏库中)
// Steam 节日活动 appid 通常很大(300万+), 且用户不会"拥有"这些"游戏"
if (b.appid > 3000000 && !b._hasGameName) {
return { name: 'Steam 活动 #' + b.appid, appid: b.appid };
}
return null;
}
// ==================== 概览标签 (用户 Hero) ====================
// v2.3.7: 读取持久化 profile 缓存, 并检查游戏库数量变化标记
function shouldInvalidateProfileCache(steamId) {
try {
const cached = readPersistentProfileCache(steamId);
if (!cached) return true;
// v2.9.2: 使用排除家庭组共享和 DLC 后的游戏数量,确保统计一致性
const currentGameCount = getStatFilteredGames().length;
// 如果游戏库数量变化, 视为缓存失效 (游戏数量可能影响统计展示)
if ((cached.gameCount || 0) !== currentGameCount) return true;
return false;
} catch { return true; }
}
function renderProfile(opts = {}) {
const { force = false } = opts;
const steamId = getActiveSteamId();
// v2.9.3: 异步触发 DLC 数据库加载(首次打开侧栏时),加载完成后刷新统计
// v2.9.6: 同时触发全库存应用类型获取,补充 Barter.vg 未覆盖的 DLC
if (SGLV_API.loadDlcDatabase) {
SGLV_API.loadDlcDatabase().then(() => {
if (SGIS.profile && !SGIS.profileLoading) renderProfileContent();
// v2.9.6: DLC 数据库加载后,启动全库存 type 获取,完成后再次刷新 DLC 数量
if (SGLV_API.enrichOwnedAppTypes) {
SGLV_API.enrichOwnedAppTypes().then(() => {
if (SGIS.profile && !SGIS.profileLoading) renderProfileContent();
}).catch(() => {});
}
}).catch(() => {});
}
// v2.3.7: 非强制刷新时, 先尝试使用内存与持久缓存
if (!force && SGIS.profile) { renderProfileContent(); return; }
if (SGIS.profileLoading) return;
SGIS.profileLoading = true;
renderLoading('正在获取用户档案…');
if (!steamId) {
SGIS.profileLoading = false;
setBody(`
${SGIS_ICONS.user}
未检测到 SteamID
请在设置中配置 SteamID64,或访问你的 Steam 个人主页后重试。
`);
return;
}
// v2.3.7: 检查是否需要强制失效缓存
const cacheInvalid = force || shouldInvalidateProfileCache(steamId);
const cachedProfile = !cacheInvalid ? readPersistentProfileCache(steamId) : null;
if (cachedProfile && cachedProfile.summary) {
SGIS.profile = cachedProfile;
SGIS.profileLoading = false;
renderProfileContent();
return;
}
// 同时获取 userBadges 用于 XP 计算 (force 时强制刷新)
Promise.all([
fetchPlayerSummaries(steamId),
fetchSteamLevel(steamId),
fetchFriendCount(steamId),
fetchUserBadges(steamId, { force }).catch(() => null),
fetchRecentlyPlayedGames(steamId).catch(() => []),
fetchWishlistCount(steamId).catch(() => null), // v2.3.27: 愿望单计数(KPI 卡片)
]).then(([summary, level, friendCount, userBadges, recentGames, wishlistCount]) => {
// v2.9.2: 使用排除家庭组共享和 DLC 后的游戏数量
const gameCount = getStatFilteredGames().length;
const profileData = { summary, level, friendCount, userBadges, steamId, gameCount, recentGames, wishlistCount };
SGIS.profile = profileData;
SGIS.profileLoading = false;
writePersistentProfileCache(steamId, profileData);
renderProfileContent();
}).catch(e => {
SGIS.profileLoading = false;
renderError('用户档案获取失败: ' + e.message);
});
}
function renderProfileContent() {
const p = SGIS.profile;
if (!p || !p.summary) {
setBody(`
${SGIS_ICONS.user}
无法获取用户档案
请确保 API Key 和 SteamID 配置正确
`);
return;
}
const s = p.summary;
const avatarUrl = s.avatarfull || s.avatarmedium || s.avatar || '';
const personaName = s.personaname || '未知玩家';
const level = p.level || '?';
const realName = s.realname || '';
const country = s.loccountrycode || '';
const profileUrl = s.profileurl || `https://steamcommunity.com/profiles/${p.steamId}/`;
const steamIdShort = p.steamId ? String(p.steamId) : '';
// 游戏库统计 (v2.9.2: 排除家庭组共享游戏和 DLC,确保统计准确)
// v2.9.5: 确保 DLC 数据库已从缓存同步加载,避免 isDlc() 不可用导致 DLC 被计入总游戏数
if (SGLV_API.loadDlcDatabaseFromCacheSync) SGLV_API.loadDlcDatabaseFromCacheSync();
const allGames = getStatFilteredGames();
const gameCount = allGames.length;
// v2.9.5: DLC 数量单独统计并缓存(不含在总游戏数内)
const dlcCount = getOwnedDlcCount();
// v2.9.5: DLC 数据库未就绪时显示占位符,避免 0 → 实际值 闪烁
const dlcReady = !!(SGLV_API.isDlcDbReady && SGLV_API.isDlcDbReady());
const dlcDisplay = dlcReady ? dlcCount.toLocaleString() : '—';
const totalPlaytime = allGames.reduce((sum, g) => sum + (g.playtime || 0), 0);
const totalHours = Math.floor(totalPlaytime / 60);
const friendCount = p.friendCount != null ? p.friendCount : '?';
const badgeCount = (p.userBadges && p.userBadges.badges) ? p.userBadges.badges.length : 0;
const totalXp = (p.userBadges && p.userBadges.playerXp) ? p.userBadges.playerXp : 0;
// v2.3.4: playerLevel 优先用 GetBadges API 返回的精确值,降级用 fetchSteamLevel
const playerLevel = (p.userBadges && p.userBadges.playerLevel) ? p.userBadges.playerLevel : (typeof level === 'number' ? level : 0);
// v2.3.27: 好友数量进度环(参考 steam-friend-manager:上限 = 300 + 等级 * 5)
const friendLimit = 300 + playerLevel * 5;
const fcNum = (typeof friendCount === 'number' && isFinite(friendCount)) ? friendCount : 0;
const friendRatio = Math.min(1, fcNum / Math.max(1, friendLimit));
const ringCirc = (Math.PI * 48).toFixed(1);
const ringDash = (friendRatio * Math.PI * 48).toFixed(1);
// v2.3.27: 愿望单计数(KPI 卡片,好友位替换)
const wishlistCount = p.wishlistCount;
const wishlistDisplay = (wishlistCount != null) ? Number(wishlistCount).toLocaleString() : '?';
// v2.3.7: 修正 Steam 官方等级 XP 公式
// Steam 官方升级所需 XP: 每 10 级为一个区间, 每个区间内每级固定 XP, 每升一个区间增加 100 XP
// 1-10 级: 100 XP/级
// 11-20 级: 200 XP/级
// 21-30 级: 300 XP/级
// 31-40 级: 400 XP/级
// ...
// 91-100 级: 1000 XP/级
// 101-110 级: 1100 XP/级
// 111-120 级: 1200 XP/级
// 121-130 级: 1300 XP/级 ...
// xpRequiredToReachLevel(L) = 从 0 升级到 L 级所需累计 XP
function xpRequiredToReachLevel(L) {
if (L <= 1) return 0;
let xp = 0;
let bracketStart = 1;
let perLevel = 100;
while (bracketStart < L) {
const bracketEnd = Math.min(bracketStart + 10, L);
const levelsInBracket = bracketEnd - bracketStart;
xp += levelsInBracket * perLevel;
bracketStart += 10;
perLevel += 100;
}
return xp;
}
// v2.3.7: 优先用 GetBadges API 返回的权威 XP 阈值, 但做合法性校验, 异常时回退公式
// API 字段语义(Steam 官方):
// player_xp_needed_current_level: 升到当前等级所需累计 XP
// player_xp_needed_to_level_up: 从当前等级升到下一级所需 XP (相对值)
const apiCurLevelXp = (p.userBadges && p.userBadges.playerXpNeededCurrentLevel) ? p.userBadges.playerXpNeededCurrentLevel : 0;
const apiLevelUpXp = (p.userBadges && p.userBadges.playerXpNeededToLevelUp) ? p.userBadges.playerXpNeededToLevelUp : 0;
// 验证 API 值是否合法: 当前等级累计 XP 不应超过玩家总 XP 过多, 升级差值应大于 0
let useApiXp = apiCurLevelXp > 0 && apiLevelUpXp > 0;
if (useApiXp && totalXp > 0 && totalXp < apiCurLevelXp - 1000) useApiXp = false; // 累计 XP 偏差过大
const curLevelXp = useApiXp ? apiCurLevelXp : xpRequiredToReachLevel(playerLevel);
const xpDelta = useApiXp ? apiLevelUpXp : (xpRequiredToReachLevel(playerLevel + 1) - curLevelXp);
// v2.3.4: Fallback - 如果 GetBadges API 失败, totalXp = 0 但 playerLevel > 0, 用升级到当前等级所需 XP 估算
const effectiveXp = totalXp > 0 ? totalXp : (playerLevel > 0 ? curLevelXp : 0);
const currentIntoLevel = Math.max(0, effectiveXp - curLevelXp);
// v2.3.7: 如果当前已积累经验超过升级所需, 可能是 API/公式异常, 做截断并回退到公式确保进度合理
let safeCurrentIntoLevel = currentIntoLevel;
let safeXpDelta = Math.max(1, xpDelta);
if (safeCurrentIntoLevel > safeXpDelta) {
// 回退到公式计算, 重新得出合理的 currentIntoLevel
safeCurrentIntoLevel = Math.max(0, effectiveXp - xpRequiredToReachLevel(playerLevel));
safeXpDelta = Math.max(1, xpRequiredToReachLevel(playerLevel + 1) - xpRequiredToReachLevel(playerLevel));
if (safeCurrentIntoLevel > safeXpDelta) safeCurrentIntoLevel = safeXpDelta; // 最多 100%
}
const xpPercent = Math.max(0, Math.min(100, (safeCurrentIntoLevel / safeXpDelta) * 100));
const xpToNext = Math.max(0, safeXpDelta - safeCurrentIntoLevel);
// v2.3.7: Steam 无等级上限; 仅当 playerLevel 极低或公式无法提供下一级阈值时才显示 MAX
const isMaxLevel = playerLevel <= 0;
// 用于底部"累计经验"显示
const totalXpDisplay = effectiveXp;
// 战绩统计(简化版: 用游戏库数据估算)
const playedGames = allGames.filter(g => (g.playtime || 0) > 0).length;
const unplayedGames = Math.max(0, gameCount - playedGames);
// 平均游戏时长
const avgHours = playedGames > 0 ? Math.round(totalHours / playedGames) : 0;
// 完成度评分(简单计算: 时长>10h 的游戏占比)
const longGames = allGames.filter(g => (g.playtime || 0) >= 600).length;
const completeRate = playedGames > 0 ? Math.round((longGames / playedGames) * 100) : 0;
// v2.3.13: 最近游玩
const recentGames = p.recentGames || [];
const recentGamesHtml = recentGames.slice(0, 8).map(game => {
const recentH = formatPlaytimeShort(game.playtime_2weeks);
const totalH = formatPlaytimeShort(game.playtime_forever);
const iconUrl = game.img_icon_url
? `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${game.appid}/${game.img_icon_url}.jpg`
: `https://cdn.cloudflare.steamstatic.com/steam/apps/${game.appid}/capsule_sm_120.jpg`;
return ``;
}).join('');
// v2.9.83: 愿望单促销信息 (筛选当前打折的愿望单游戏, 按折扣力度排序)
const wishlistOnSale = (Array.isArray(state.wishlistGames) ? state.wishlistGames : [])
.filter(g => g && g.discountPct > 0 && !g.isFree)
.sort((a, b) => (b.discountPct || 0) - (a.discountPct || 0))
.slice(0, 8);
const wishlistSaleHtml = wishlistOnSale.length === 0 ? '' : (() => {
const items = wishlistOnSale.map(g => {
const finalStr = g.finalPrice > 0 ? `¥${g.finalPrice.toFixed(2)}` : (isZh ? '免费' : 'Free');
const origStr = g.originalPrice > g.finalPrice ? `¥${g.originalPrice.toFixed(2)}` : '';
return `
${escHtml(g.name)}
-${g.discountPct}%
${finalStr}
${origStr ? `${origStr}` : ''}
`;
}).join('');
return `
${SGIS_ICONS.heart || '💝'} ${isZh ? '愿望单促销' : 'Wishlist on Sale'} ${wishlistOnSale.length} ${isZh ? '款打折中' : 'on sale'}
${items}
`;
})();
setBody(`
${personaName}
Lv.${playerLevel}
${steamIdShort || '?'}
${steamIdShort ? `
` : ''}
${friendCount}/ ${friendLimit}
好友数
${SGIS_ICONS.barChart}
${gameCount.toLocaleString()}
Games
${SGIS_ICONS.package}
${dlcDisplay}
DLC
${SGIS_ICONS.medal}
${badgeCount.toLocaleString()}
Badges
${SGIS_ICONS.heart}
${wishlistDisplay}
Wishlist
${SGIS_ICONS.clock}
${totalHours >= 1000 ? (totalHours / 1000).toFixed(1) + 'k' : totalHours}h
Playtime
Lv.${playerLevel}
→
${isMaxLevel
? `MAX`
: `Lv.${playerLevel + 1}`}
总时长
${totalHours >= 1000 ? (totalHours / 1000).toFixed(1) + 'k' : totalHours}h
${SGIS_ICONS.game} 最近游玩
${recentGames.length === 0 ? '
最近 2 周未游玩任何游戏
' : recentGamesHtml}
${wishlistSaleHtml}
数据来自 Steam Web API · 缓存30分钟
`);
// v2.3.33:异步加载最近游玩游戏中文名
document.querySelectorAll('#sgis-body [data-sglv-appid]').forEach(el => {
loadGameZhName(el, el.getAttribute('data-sglv-appid'), el.textContent);
});
const featureStats = document.getElementById('sgis-feature-stats');
if (featureStats) {
featureStats.addEventListener('click', () => {
// 跳转到动态标签(展示详细统计)
if (typeof SGIS !== 'undefined') {
const panel = document.getElementById('sgis-panel');
if (panel) {
const tab = panel.querySelector('.sgis-tab[data-tab="userAchievements"]');
if (tab) tab.click();
}
}
});
}
const featureAch = document.getElementById('sgis-feature-achievements');
if (featureAch) {
featureAch.addEventListener('click', () => {
if (typeof SGIS !== 'undefined') {
const panel = document.getElementById('sgis-panel');
if (panel) {
const tab = panel.querySelector('.sgis-tab[data-tab="userAchievements"]');
if (tab) tab.click();
}
}
});
}
// ==================== v2.3.27: KPI 卡片 + 进度环点击交互 ====================
// 游戏数量 → 弹出中央游戏库浮窗(先收起侧边栏避免遮挡)
const statGames = document.getElementById('sgis-stat-games');
if (statGames) {
statGames.addEventListener('click', () => {
closePanel();
document.dispatchEvent(new CustomEvent('sglv:open-library'));
});
}
// v2.9.5: DLC 数量 → 弹出中央游戏库浮窗并切换到仅 DLC 筛选
const statDlc = document.getElementById('sgis-stat-dlc');
if (statDlc) {
statDlc.addEventListener('click', () => {
closePanel();
document.dispatchEvent(new CustomEvent('sglv:open-library'));
// 延迟触发仅 DLC 筛选,等待浮窗渲染完成
setTimeout(() => {
const dashCard = document.querySelector('.sglv-stat-dash-card[data-filter="dlconly"]');
if (dashCard) dashCard.click();
}, 300);
});
}
// 徽章数量 → 跳转 Steam 勋章页面
const statBadges = document.getElementById('sgis-stat-badges');
if (statBadges) {
statBadges.addEventListener('click', () => {
window.open(`${profileUrl}badges/`, '_blank');
});
}
// 愿望单数量 → 跳转 Steam 愿望单页面
const statWishlist = document.getElementById('sgis-stat-wishlist');
if (statWishlist) {
statWishlist.addEventListener('click', () => {
const sid = p.steamId || getActiveSteamId();
window.open(sid ? `https://store.steampowered.com/wishlist/profiles/${sid}/` : 'https://store.steampowered.com/wishlist/', '_blank');
});
}
// 好友进度环 → 切换到社交标签页
const friendGauge = document.getElementById('sgis-friend-gauge');
if (friendGauge) {
friendGauge.addEventListener('click', () => {
const panel = document.getElementById('sgis-panel');
const tab = panel ? panel.querySelector('.sgis-tab[data-tab="social"]') : null;
if (tab) tab.click();
});
}
// SteamID 复制按钮
const copySidBtn = document.getElementById('sgis-copy-steamid');
if (copySidBtn) {
copySidBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
copyTextToClipboard(steamIdShort);
sglvToast.success(isZh ? 'SteamID 已复制' : 'SteamID copied');
});
}
// v2.3.27: 旧缓存档案无愿望单计数时,后台补取并局部更新卡片
if (p.wishlistCount == null) {
fetchWishlistCount(p.steamId || getActiveSteamId()).then(n => {
if (n == null) return;
p.wishlistCount = n;
const el = document.getElementById('sgis-stat-wishlist-val');
if (el) el.textContent = Number(n).toLocaleString();
}).catch(() => { /* ignore */ });
}
}
// ==================== 动态标签 (Steam 官方新闻 + 个人/家庭组入库) ====================
function renderActivity() {
const sub = SGIS.activitySubTab || 'personal'; // v2.3.24: 默认个人入库动态
if (sub === 'official') {
if (SGIS.activity) { renderActivityContent(); return; }
if (SGIS.activityLoading) return;
SGIS.activityLoading = true;
renderLoading('正在获取 Steam 官方动态…');
fetchSteamOfficialNews().then(news => {
SGIS.activity = news;
SGIS.activityLoading = false;
renderActivityContent();
}).catch(e => {
SGIS.activityLoading = false;
renderError('动态获取失败: ' + e.message);
});
} else if (sub === 'personal') {
if (SGIS.personalTimeline) { renderActivityContent(); return; }
if (SGIS.personalTimelineLoading) return;
SGIS.personalTimelineLoading = true;
renderLoading('正在加载个人入库历史…');
const steamId = getActiveSteamId();
buildPersonalTimeline(steamId).then(data => {
SGIS.personalTimeline = data;
SGIS.personalTimelineLoading = false;
renderActivityContent();
}).catch(e => {
SGIS.personalTimelineLoading = false;
console.warn('[SGIS] 个人入库历史加载失败:', e);
sglvToast.error(isZh ? '个人入库历史加载失败' : 'Personal timeline load failed');
renderError('个人入库历史加载失败: ' + (e.message || '网络错误'));
});
} else if (sub === 'family') {
if (SGIS.familyTimeline) { renderActivityContent(); return; }
// v2.8.1: 已发起加载但用户切走又切回(如先看 personal 再回 family),
// DOM 可能已被其他 tab 覆盖;此时仍应重新渲染进度条,避免用户看到残留旧内容
if (SGIS.familyTimelineLoading) {
if (!document.getElementById('sgis-load-block')) {
renderProgressLoading({ stage: 3, totalStages: 4, text: '正在加载家庭组入库历史…', skeletonCount: 8 });
}
return;
}
SGIS.familyTimelineLoading = true;
// v2.8.1: 进度条 + 游戏骨架屏(参考 steam-friend-manager 加载体验),让用户在等待中看到结构
const prog = renderProgressLoading({ stage: 1, totalStages: 4, text: '正在准备家庭组数据…', skeletonCount: 8 });
const steamId = getActiveSteamId();
buildFamilyTimeline(steamId, (stage, percent, text) => {
// 防止切走/刷新后旧回调更新到不存在的 DOM
if (!SGIS.familyTimelineLoading) return;
prog.update({ stage, percent, text });
}).then(data => {
SGIS.familyTimeline = data;
SGIS.familyTimelineLoading = false;
renderActivityContent();
}).catch(e => {
SGIS.familyTimelineLoading = false;
console.warn('[SGIS] 家庭组入库历史加载失败:', e);
sglvToast.error(isZh ? '家庭组入库历史加载失败' : 'Family timeline load failed');
renderError('家庭组入库历史加载失败: ' + (e.message || '网络错误'));
});
}
}
function renderActivityLibraryHistory(data) {
if (!data || !data.items || data.items.length === 0) return '暂无入库记录
';
const items = data.items;
let html = '';
let curDate = '';
items.forEach(it => {
const dateKey = it.dateStr || '';
if (dateKey && dateKey !== curDate) {
curDate = dateKey;
html += ``;
}
const iconUrl = it.icon
? `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${it.appid}/${it.icon}.jpg`
: `https://cdn.cloudflare.steamstatic.com/steam/apps/${it.appid}/capsule_sm_120.jpg`;
const timeText = it.ts
? new Date(it.ts).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
: '';
html += ``;
});
return html;
}
function renderActivityContent() {
const sub = SGIS.activitySubTab || 'personal';
// v2.3.24: 默认个人入库动态,官方动态移至最后
const tabs = [
{ key: 'personal', icon: SGIS_ICONS.package, label: '个人入库' },
{ key: 'family', icon: SGIS_ICONS.users, label: '家庭组入库' },
{ key: 'official', icon: SGIS_ICONS.news, label: '官方动态' },
];
const subTabHtml = `${tabs.map(t => ``).join('')}
`;
let contentHtml = '';
let footerText = '';
if (sub === 'official') {
// v2.3.25: 结构化官方动态——促销活动/每日优惠/当前特惠/官方新闻
const data = SGIS.activity || { specials: [], dailyDeals: [], spotlights: [], news: [], totalCount: 0 };
const totalCount = Number(data.totalCount) || 0;
if (totalCount === 0) {
contentHtml = ``;
} else {
// 价格格式化(分 → 元/美元,安全数值转换)
const formatPrice = (cents, currency) => {
const v = Number(cents);
if (isNaN(v) || v === 0) return '免费';
const symbol = (currency === 'USD') ? '$' : '';
return symbol + (v / 100).toFixed(2);
};
// 折扣到期倒计时
const formatExpiration = (expTs) => {
const exp = Number(expTs);
if (!exp || isNaN(exp)) return '';
const now = Math.floor(Date.now() / 1000);
const diff = exp - now;
if (diff <= 0) return '已结束';
const days = Math.floor(diff / 86400);
const hours = Math.floor((diff % 86400) / 3600);
if (days > 0) return `剩余${days}天${hours}小时`;
if (hours > 0) return `剩余${hours}小时`;
return `剩余${Math.floor(diff / 60)}分钟`;
};
// 新闻日期格式化
const formatDate = (ts) => {
if (!ts) return '';
const d = new Date(ts * 1000);
return `${d.getMonth() + 1}月${d.getDate()}日`;
};
let html = '';
// 分区1: 促销活动 (Spotlights — 含活动页URL)
if (Array.isArray(data.spotlights) && data.spotlights.length > 0) {
// v2.9.70: 处理 body 中的 %1$s 占位符——Steam API 返回的本地化模板未格式化
const processSpotlightBody = (sp) => {
if (!sp.body) return '';
let body = sp.body;
// 检测是否包含 %1$s 或类似占位符
if (!body.match(/%\d\$s/)) return body;
// 尝试用实际截止时间替换
let timeStr = '';
if (sp.discountExpiration && sp.discountExpiration > 0) {
timeStr = formatExpiration(sp.discountExpiration);
}
if (!timeStr) {
// 预估截止时间:周末特惠2天(截止明天),其他促销2-3天
const name = (sp.name || '').toLowerCase();
if (name.includes('周末') || name.includes('weekend')) {
timeStr = isZh ? '预计明天截止' : 'Est. ends tomorrow';
} else {
timeStr = isZh ? '预计2-3天后截止' : 'Est. ends in 2-3 days';
}
}
// 替换所有 %N$s 占位符
body = body.replace(/%\d\$s/g, timeStr);
return body;
};
html += ``;
}
// 分区2: 每日优惠 (Daily Deals)
if (Array.isArray(data.dailyDeals) && data.dailyDeals.length > 0) {
html += ``;
html += `
⚡ 每日优惠${data.dailyDeals.length} 款
`;
html += `
`;
}
// 分区3: 当前特惠 (Current Specials — 含折扣到期倒计时)
if (Array.isArray(data.specials) && data.specials.length > 0) {
html += ``;
html += `
🏷️ 当前特惠${data.specials.length} 款
`;
html += `
`;
}
// 分区4: 官方新闻 (ISteamNews)
if (Array.isArray(data.news) && data.news.length > 0) {
html += ``;
}
contentHtml = html;
}
footerText = '数据来自 Steam Storefront API + Steam News API · 缓存30分钟';
} else if (sub === 'personal') {
if (SGIS.personalTimelineLoading) {
contentHtml = ``;
} else if (!SGIS.personalTimeline) {
contentHtml = `暂无数据
`;
} else {
// v2.3.24: 分页渲染——每页 100 条,默认仅第一页,滚动到底自动追加下一页,避免大数据量卡死
const allItems = SGIS.personalTimeline.items || [];
const PAGE_SIZE = 100;
const page = Math.max(1, SGIS.personalTimelinePage || 1);
const visibleItems = allItems.slice(0, page * PAGE_SIZE);
const hasMore = visibleItems.length < allItems.length;
contentHtml = renderActivityLibraryHistory({ items: visibleItems });
if (hasMore) {
contentHtml += `
向下滚动加载更多(已显示 ${visibleItems.length}/${allItems.length})
`;
} else if (allItems.length > 0) {
contentHtml += `已加载全部 ${allItems.length} 条
`;
}
}
const pTotal = (SGIS.personalTimeline && SGIS.personalTimeline.items) ? SGIS.personalTimeline.items.length : 0;
const pShown = Math.min(pTotal, Math.max(1, SGIS.personalTimelinePage || 1) * 100);
footerText = `基于家庭组共享库入库时间(rt_time_acquired)· 按入库时间排序${pTotal > 0 ? ` · 已显示 ${pShown}/${pTotal}` : ''}`;
} else if (sub === 'family') {
if (SGIS.familyTimelineLoading) {
contentHtml = ``;
} else if (!SGIS.familyTimeline) {
contentHtml = `暂无数据
`;
} else {
// v2.3.24: 分页渲染——每页 100 条,默认仅第一页,滚动到底自动追加下一页,避免大数据量卡死
const allItems = SGIS.familyTimeline.items || [];
const PAGE_SIZE = 100;
const page = Math.max(1, SGIS.familyTimelinePage || 1);
const visibleItems = allItems.slice(0, page * PAGE_SIZE);
const hasMore = visibleItems.length < allItems.length;
contentHtml = renderActivityLibraryHistory({ items: visibleItems });
if (hasMore) {
contentHtml += `
向下滚动加载更多(已显示 ${visibleItems.length}/${allItems.length})
`;
} else if (allItems.length > 0) {
contentHtml += `已加载全部 ${allItems.length} 条
`;
}
}
const totalItems = (SGIS.familyTimeline && SGIS.familyTimeline.items) ? SGIS.familyTimeline.items.length : 0;
const shownItems = Math.min(totalItems, Math.max(1, SGIS.familyTimelinePage || 1) * 100);
footerText = `基于家庭组共享库,排除个人已拥有的游戏 · 按入库时间排序${totalItems > 0 ? ` · 已显示 ${shownItems}/${totalItems}` : ''}`;
}
setBody(`
${SGIS_ICONS.news} Steam 动态
${subTabHtml}
${contentHtml}
${footerText}
`);
// v2.3.33:异步加载入库动态游戏中文名
document.querySelectorAll('#sgis-body [data-sglv-appid]').forEach(el => {
loadGameZhName(el, el.getAttribute('data-sglv-appid'), el.textContent);
});
// 绑定子标签切换
const body = document.getElementById('sgis-body');
if (body) {
body.querySelectorAll('.sgis-sub-tab').forEach(btn => {
btn.addEventListener('click', () => {
const newSub = btn.dataset.sub;
if (SGIS.activitySubTab === newSub) return;
SGIS.activitySubTab = newSub;
// v2.3.24: 切换子标签时重置入库分页
SGIS.familyTimelinePage = 1;
SGIS.personalTimelinePage = 1;
renderActivity();
});
});
}
// v2.3.24: 入库动态分页——哨兵进入视口自动加载下一页(每页 100 条),个人/家庭组通用
const bindTimelineLoadMore = (elId, getPage, setPage, getTotal) => {
const el = document.getElementById(elId);
if (!el) return;
const io = new IntersectionObserver(entries => {
if (!entries[0].isIntersecting) return;
io.disconnect();
const nextPage = getPage() + 1;
if (nextPage * 100 - 100 < getTotal()) {
setPage(nextPage);
renderActivityContent();
}
}, { root: document.getElementById('sgis-body'), rootMargin: '300px' });
io.observe(el);
};
if (sub === 'family') {
bindTimelineLoadMore('sgis-family-load-more',
() => SGIS.familyTimelinePage || 1,
p => { SGIS.familyTimelinePage = p; },
() => (SGIS.familyTimeline && SGIS.familyTimeline.items ? SGIS.familyTimeline.items.length : 0));
}
if (sub === 'personal') {
bindTimelineLoadMore('sgis-personal-load-more',
() => SGIS.personalTimelinePage || 1,
p => { SGIS.personalTimelinePage = p; },
() => (SGIS.personalTimeline && SGIS.personalTimeline.items ? SGIS.personalTimeline.items.length : 0));
}
}
// ==================== 勋章标签 (Steam 官方勋章) ====================
function renderUserBadges() {
if (SGIS.userBadges) { renderUserBadgesContent(); return; }
if (SGIS.userBadgesLoading) return;
SGIS.userBadgesLoading = true;
renderLoading('正在获取勋章数据…');
const steamId = getActiveSteamId();
if (!steamId) {
SGIS.userBadgesLoading = false;
setBody(``);
return;
}
fetchUserBadges(steamId).then(data => {
SGIS.userBadges = data;
SGIS.userBadgesLoading = false;
renderUserBadgesContent();
// v2.3.6: 异步加载卡牌数据库, 加载完成后重新 enrich + 渲染以显示 maxLevel
if (!cardDbData && !cardDbLoading) {
loadCardDatabase().then(() => {
if (cardDbData && SGIS.userBadges) {
SGIS.userBadges = enrichBadgesWithGameNames(SGIS.userBadges);
if (SGIS.tab === 'userBadges') renderUserBadgesContent();
}
}).catch(() => { /* 静默失败, 不影响基本功能 */ });
}
}).catch(e => {
SGIS.userBadgesLoading = false;
renderError('勋章获取失败: ' + e.message);
});
}
// v2.3.1.1: 完整重写 - 加入价值分析仪表板 + 智能分析浮窗 + 徽章图标加载
function renderUserBadgesContent() {
const data = SGIS.userBadges;
if (!data || !data.badges) {
setBody(`
${SGIS_ICONS.medal}
无法获取勋章数据
请确保 API Key 和 SteamID 配置正确
`);
return;
}
const badges = data.badges || [];
const playerLevel = data.playerLevel || 0;
const playerXp = data.playerXp || 0;
// ---- 分类 ----
const eventBadges = [];
const gameBadges = [];
const communityBadges = [];
badges.forEach(b => {
if (b.appid > 0) {
const evt = detectEventBadge(b);
if (evt) { b._eventName = evt.name; eventBadges.push(b); }
else { gameBadges.push(b); }
} else {
communityBadges.push(b);
}
});
// 节日活动分组
const eventGroups = {};
eventBadges.forEach(b => {
if (!eventGroups[b._eventName]) eventGroups[b._eventName] = [];
eventGroups[b._eventName].push(b);
});
// 游戏按游戏名分组
const gameGroups = {};
gameBadges.forEach(b => {
const gname = b._gameName || `游戏 #${b.appid}`;
if (!gameGroups[gname]) gameGroups[gname] = { name: gname, appid: b.appid, badges: [] };
gameGroups[gname].badges.push(b);
});
// 排序
const eventNames = Object.keys(eventGroups).sort((a, b) => b.localeCompare(a, 'zh-CN'));
const gameNames = Object.keys(gameGroups).sort((a, b) => gameGroups[b].badges.length - gameGroups[a].badges.length);
// 统计
const totalBadges = badges.length;
const completedCount = badges.filter(b => (b.completion_time || 0) > 0).length;
const gameBadgeCount = gameBadges.length;
const eventBadgeCount = eventBadges.length;
const totalXpFromBadges = badges.reduce((s, b) => s + (b.xp || 0), 0);
// 等级分布
const levelDist = getBadgeLevelDistribution(badges);
const maxLvCount = Math.max(...Object.values(levelDist), 1);
// ---- 价值分析 (异步) ----
// 优先用缓存的市场数据
const markets = SGIS.userBadgeMarkets || {};
const needFetchAppIds = gameBadges
.filter(b => !markets[String(b.appid)])
.map(b => b.appid)
.filter((v, i, a) => a.indexOf(v) === i)
.slice(0, 15);
const valueScores = computeBadgeValueScores(badges, markets);
const topValueBadges = getTopValueBadges(valueScores, 6);
// ---- 工具函数 ----
const formatTime = (ts) => {
if (!ts) return '';
try { return new Date(ts * 1000).toLocaleDateString('zh-CN', { year: '2-digit', month: '2-digit', day: '2-digit' }); }
catch { return ''; }
};
const rarityLabel = (s) => {
if (!s) return null;
if (s >= 5) return { label: '史诗', cls: 'r5' };
if (s >= 4) return { label: '极稀有', cls: 'r4' };
if (s >= 3) return { label: '稀有', cls: 'r3' };
if (s >= 2) return { label: '少见', cls: 'r2' };
return { label: '普通', cls: 'r1' };
};
const scoreColor = (s) => s >= 70 ? 'high' : s >= 40 ? 'mid' : 'low';
// ---- 渲染单条徽章行 (带智能分析 tooltip) ----
// v2.3.7: 修复季节徽章图标 - 活动徽章使用 Steam CDN items 路径, 不再用不存在的商店胶囊图
// v2.3.5: 优先用卡牌封面图 (从市场数据获取第一张卡牌 iconUrl), 回退到游戏胶囊图
const getBadgeIcon = (b) => {
// 1) 优先用该游戏第一张卡牌的封面图 (集换式卡牌实际图片)
const market = markets[String(b.appid)];
if (market && market.cards && market.cards.length > 0) {
const cardWithIcon = market.cards.find(c => c.iconUrl);
if (cardWithIcon) return cardWithIcon.iconUrl;
}
// v2.3.7: 节日活动徽章使用 Steam CDN items 路径 (icon_64x64.png)
// 活动徽章的 appid 不是真实游戏, 商店胶囊图不存在, 用 steamcommunity/public/images/items/ 路径
if (b._eventName && b.appid > 0) {
return getActivityIconUrl(b.appid);
}
// 3) 回退到 Steam 游戏胶囊图 (仅对真实游戏 appid)
if (b.appid > 0) {
return `https://shared.fastly.steamstatic.com/store_item_assets/steam/apps/${b.appid}/capsule_184x69.jpg`;
}
return '';
};
const renderBadgeRow = (b) => {
const gname = b._gameName || (b._eventName ? b._eventName + ' 徽章' : (b.communityitemid ? `社区徽章 #${b.badgeid}` : `游戏 #${b.appid}`));
const eventBadge = b._eventName;
const rar = rarityLabel(b.scarcity);
const icon = eventBadge ? SGIS_ICONS.gift : (b._gameName ? SGIS_ICONS.game : SGIS_ICONS.sparkle);
const valueData = valueScores[String(b.appid)];
const hasValue = !!valueData;
const scoreCls = hasValue ? scoreColor(valueData.score) : '';
// v2.3.5: 检查是否有卡牌封面图
const market = markets[String(b.appid)];
const hasCardIcon = !!(market && market.cards && market.cards.find(c => c.iconUrl));
const tooltip = hasValue ? `
${gname}
价值 ${valueData.score}
${valueData.marketData ? `
市场卡价
${valueData.marketData.cardCount} 张 · 均 ${formatMarketPrice(valueData.marketData.avgPrice)}
中位/税后
${formatMarketPrice(valueData.marketData.medianPrice)} / ${formatMarketPrice(valueData.marketData.netIncome)}
` : '
市场数据加载中...
'}
Lv.${b.level || 1}${b._maxLevel > 0 ? '/' + b._maxLevel : ''}${b.xp || 0} XP
` : '';
return `
${b.appid > 0 ? `
})
${icon}
` : `
${icon}
`}
${gname}
Lv.${b.level || 1}${b._maxLevel > 0 ? '/' + b._maxLevel : ''}
${(b.xp || 0).toLocaleString()} XP
${rar ? `${rar.label}` : ''}
${b.completion_time ? `${formatTime(b.completion_time)}` : ''}
${hasValue ? `${valueData.score}分` : ''}
${b.appid > 0 ? `
${SGIS_ICONS.chevronRight}` : ''}
${tooltip}
`;
};
// ---- 折叠 group ----
const groupId = (k) => 'sgis-bg-' + String(k).replace(/[^\\w]/g, '_').slice(0, 60);
const renderGroup = (id, icon, title, sub, badges, defaultExpanded) => {
const expClass = defaultExpanded ? ' sgis-badge-group-expanded' : '';
const totalXp = badges.reduce((s, b) => s + (b.xp || 0), 0);
const maxLevel = badges.reduce((m, b) => Math.max(m, b.level || 1), 1);
// v2.3.6: 获取游戏最高可达等级 (同一游戏的徽章 _maxLevel 相同)
const realMaxLevel = badges.reduce((m, b) => Math.max(m, b._maxLevel || 0), 0);
const completedN = badges.filter(b => (b.completion_time || 0) > 0).length;
const completedPct = badges.length ? Math.round(completedN / badges.length * 100) : 0;
// v2.9.15: group 头信息重新设计——左侧图标 + 标题/副标题,右侧 stats(数量 + 等级),底部进度条
return `
${icon}
${badges.length}枚
Lv.${maxLevel}${realMaxLevel > 0 ? `/ ${realMaxLevel}` : ''}
${SGIS_ICONS.chevronRight}
${badges.map(renderBadgeRow).join('')}
`;
};
// 折叠状态
const collapseStateKey = 'sgis_badge_group_collapse';
const getCollapseState = () => {
try { return JSON.parse(GM_getValue(collapseStateKey, '{}')); } catch { return {}; }
};
const setCollapseState = (state) => {
try { GM_setValue(collapseStateKey, JSON.stringify(state)); } catch { /* ignore */ }
};
const collapseState = getCollapseState();
// ---- 价值仪表板 (Top 6) ----
// v2.3.5: 价值卡片图标也优先用卡牌封面图, 回退游戏胶囊图
const renderValueBadgeCard = (vd) => {
if (!vd) return '';
const market = vd.marketData;
const avgPrice = market ? formatMarketPrice(market.avgPrice) : '—';
const medianPrice = market && market.medianPrice ? formatMarketPrice(market.medianPrice) : '—';
const netIncome = market && market.netIncome ? formatMarketPrice(market.netIncome) : '—';
const cardCount = market ? `${market.cardCount} 张` : '加载中';
// v2.3.5: 优先用市场数据中的卡牌封面图
let imgSrc = `https://shared.fastly.steamstatic.com/store_item_assets/steam/apps/${vd.appId}/capsule_184x69.jpg`;
if (market && market.cards && market.cards.length > 0) {
const cardWithIcon = market.cards.find(c => c.iconUrl);
if (cardWithIcon) imgSrc = cardWithIcon.iconUrl;
}
return `
${vd.gameName || '游戏 #' + vd.appId}
Lv.${vd.level}${vd.maxLevel > 0 ? '/' + vd.maxLevel : ''} · ${vd.xp} XP
${cardCount} · 均 ${avgPrice}
中位 ${medianPrice} · 税后 ${netIncome}
`;
};
const valueCardsHtml = topValueBadges.length
? `${topValueBadges.map(renderValueBadgeCard).join('')}
`
: `暂无可分析的游戏勋章 (需要先获取游戏库数据)
`;
// ---- HTML 渲染 ----
// v2.9.15: 视觉层次重构——1 个顶部英雄区(总览) + Top 6 价值卡片 + 折叠 group 列表
// 重点突出:大数字 KPI + 价值 Top 6
// 次要:游戏/节日/社区徽章 默认折叠,只 Top 5(按勋章数)游戏展开
// 折叠策略:用户展开过(collapseState=true) → 保持展开;否则默认折叠
// 顶部英雄区:4 个 KPI + 等级分布迷你条
const totalValueScore = badges.reduce((s, b) => {
const v = valueScores[String(b.appid)];
return s + (v ? v.score : 0);
}, 0);
const avgValueScore = totalBadges ? Math.round(totalValueScore / totalBadges) : 0;
const heroHtml = `
${(totalXpFromBadges / 1000).toFixed(1)}k
总 XP
完成进度
${completedCount}/${totalBadges} (${totalBadges ? Math.round(completedCount / totalBadges * 100) : 0}%)
等级分布
${[1, 2, 3, 4, 5].map(lv => {
const count = levelDist[lv] || 0;
const pct = Math.max(3, (count / maxLvCount) * 100);
return `
`;
}).join('')}
`;
// 价值 Top 6 卡片网格(重点)
const valueSectionHtml = `
${SGIS_ICONS.star} 价值 Top ${topValueBadges.length} ${topValueBadges.length > 0 ? `综合价值评分` : ''}
${valueCardsHtml}
`;
// 节日活动:默认全部折叠(用户主动展开才展开)
const eventSectionHtml = `
${SGIS_ICONS.gift} 节日活动 ${eventBadgeCount} 枚 / ${eventNames.length} 个活动
${eventNames.length === 0
? `
暂无节日活动徽章
`
: eventNames.map(name => {
const list = eventGroups[name];
const id = groupId('event_' + name);
// v2.9.15: 默认折叠(用户展开过则保持)
const expanded = collapseState[id] === true;
return renderGroup(id, SGIS_ICONS.gift, name, 'Steam 限定徽章', list, expanded);
}).join('')}
`;
// 游戏勋章:Top 5(按勋章数)默认展开,其余折叠;支持"全部展开"按钮
const TOP_EXPAND = 5;
const gameSectionHtml = `
${SGIS_ICONS.game} 游戏勋章 ${gameBadgeCount} 枚 / ${gameNames.length} 个游戏
${gameNames.length === 0
? `
暂无游戏勋章 (需要先获取游戏库数据)
`
: gameNames.slice(0, 30).map((name, idx) => {
const g = gameGroups[name];
const id = groupId('game_' + g.appid);
// v2.9.15: Top 5 默认展开,其余默认折叠(用户展开过的保持展开)
const userSetExpanded = collapseState[id] === true;
const userSetCollapsed = collapseState[id] === false;
const expanded = userSetExpanded || (!userSetCollapsed && idx < TOP_EXPAND);
return renderGroup(id, SGIS_ICONS.game, g.name, `AppID: ${g.appid}`, g.badges, expanded);
}).join('')}
${gameNames.length > 30 ? `
还有 ${gameNames.length - 30} 个游戏的勋章未显示
` : ''}
`;
// 社区勋章:默认折叠(数量少,优先级低)
let communitySectionHtml = '';
if (communityBadges.length > 0) {
const showComm = communityBadges.slice(0, 12);
const id = 'sgis-bg-community';
const expanded = collapseState[id] === true;
communitySectionHtml = `
${SGIS_ICONS.sparkle} 社区勋章 ${communityBadges.length} 枚
${SGIS_ICONS.sparkle}
${communityBadges.length} 枚
${SGIS_ICONS.chevronRight}
${showComm.map(renderBadgeRow).join('')}
`;
}
// 评分说明:折叠到 footer
const footerHtml = `
`;
// 组装:英雄区 → 价值 Top → 节日 → 游戏 → 社区 → footer
let html = heroHtml + valueSectionHtml + eventSectionHtml + gameSectionHtml + communitySectionHtml + footerHtml;
setBody(html);
// v2.3.5: 市场数据加载完成后, 把原本用游戏胶囊图的徽章行图标刷新为卡牌封面图
// 只更新 data-has-card-icon="0" 的行 (首次渲染时没有卡牌数据的行)
function refreshBadgeIconsToCardCover(newMarkets) {
const bodyEl = document.getElementById('sgis-body');
if (!bodyEl) return;
bodyEl.querySelectorAll('.sgis-badge-row[data-appid]').forEach(row => {
if (row.dataset.hasCardIcon === '1') return; // 已经有卡牌封面, 跳过
const appid = row.dataset.appid;
if (!appid) return;
const market = newMarkets[appid];
if (!market || !market.cards || !market.cards.length) return;
const cardWithIcon = market.cards.find(c => c.iconUrl);
if (!cardWithIcon) return;
const imgEl = row.querySelector('.sgis-badge-row-img');
if (!imgEl) return; // 没有图片元素(社区徽章等), 跳过
// 标记已更新, 避免重复刷新
row.dataset.hasCardIcon = '1';
// 保存原始 src 供 onerror 回退
imgEl.dataset.originalSrc = imgEl.src;
imgEl.src = cardWithIcon.iconUrl;
// 如果新图片加载失败, 回退到原始游戏胶囊图
imgEl.addEventListener('error', function onError() {
imgEl.removeEventListener('error', onError);
if (imgEl.dataset.originalSrc) {
imgEl.src = imgEl.dataset.originalSrc;
delete imgEl.dataset.originalSrc;
}
}, { once: true });
});
}
// 异步抓取市场数据 (后续刷新徽章时显示价格)
if (needFetchAppIds.length > 0) {
fetchBadgeMarkets(needFetchAppIds, { concurrency: 3, maxApps: 15 }).then(newMarkets => {
SGIS.userBadgeMarkets = Object.assign({}, markets, newMarkets);
// v2.3.5: 市场数据加载完成后, 刷新徽章行图标为卡牌封面
// 只更新原本没有卡牌封面(data-has-card-icon="0")的行
refreshBadgeIconsToCardCover(newMarkets);
// 刷新价值分析 (不重渲染, 静默更新)
// 注意: 智能分析浮窗会通过 hover 重新触发
}).catch(e => console.warn('[SGLV] 徽章市场数据获取失败:', e));
}
// 绑定折叠事件
const body = document.getElementById('sgis-body');
if (body) {
body.querySelectorAll('[data-toggle]').forEach(head => {
head.addEventListener('click', () => {
const group = head.closest('.sgis-badge-group');
if (group) {
const isExp = group.classList.toggle('sgis-badge-group-expanded');
const gid = head.getAttribute('data-toggle');
collapseState[gid] = isExp;
setCollapseState(collapseState);
}
});
});
}
}
// ==================== 成就标签 (自制成就系统 + AI 人格分析) ====================
// 炫彩 SVG 成就图标库 (v2.3.1) - 每个成就独立的渐变配色
const ACHIEVEMENT_SVG_ICONS = {
// 收藏家 - 礼物盒 (蓝紫渐变)
collector: ``,
// 收藏家皇冠 (金黄渐变)
kingCollector: ``,
// 时钟入门 (青蓝渐变)
clock: ``,
// 时钟沙漏 (橙红渐变)
hourglass: ``,
// 完美主义 (绿对勾)
perfection: ``,
// 骰子 - 涉猎广泛 (多彩)
dice: ``,
// 早鸟 (太阳/日出)
sunrise: ``,
// 夜猫子 (月亮+星星)
moon: ``,
// 老用户 (钻石)
diamond: ``,
};
// 自制成就定义 (v2.3.1: 炫彩 SVG 图标)
const USER_ACHIEVEMENTS = [
{ id: 'collector_50', icon: ACHIEVEMENT_SVG_ICONS.collector, name: '初级收藏家', desc: '拥有 50 款游戏', threshold: 50, metric: 'gameCount' },
{ id: 'collector_100', icon: ACHIEVEMENT_SVG_ICONS.collector, name: '中级收藏家', desc: '拥有 100 款游戏', threshold: 100, metric: 'gameCount' },
{ id: 'collector_500', icon: ACHIEVEMENT_SVG_ICONS.collector, name: '高级收藏家', desc: '拥有 500 款游戏', threshold: 500, metric: 'gameCount' },
{ id: 'collector_1000', icon: ACHIEVEMENT_SVG_ICONS.kingCollector, name: '游戏大王', desc: '拥有 1000 款游戏', threshold: 1000, metric: 'gameCount' },
{ id: 'playtime_1k', icon: ACHIEVEMENT_SVG_ICONS.clock, name: '入门玩家', desc: '总游戏时长达到 1000 小时', threshold: 1000, metric: 'totalHours' },
{ id: 'playtime_5k', icon: ACHIEVEMENT_SVG_ICONS.hourglass, name: '资深玩家', desc: '总游戏时长达到 5000 小时', threshold: 5000, metric: 'totalHours' },
{ id: 'playtime_10k', icon: ACHIEVEMENT_SVG_ICONS.hourglass, name: '硬核玩家', desc: '总游戏时长达到 10000 小时', threshold: 10000, metric: 'totalHours' },
{ id: 'completionist', icon: ACHIEVEMENT_SVG_ICONS.perfection, name: '完美主义者', desc: '有 10 款游戏达成 100% 成就', threshold: 10, metric: 'perfectGames' },
{ id: 'diverse_10', icon: ACHIEVEMENT_SVG_ICONS.dice, name: '涉猎广泛', desc: '游玩 10 种不同类型的游戏', threshold: 10, metric: 'genres' },
{ id: 'early_bird', icon: ACHIEVEMENT_SVG_ICONS.sunrise, name: '早鸟玩家', desc: '在游戏发行 7 天内入库 5 款游戏', threshold: 5, metric: 'earlyAccess' },
{ id: 'night_owl', icon: ACHIEVEMENT_SVG_ICONS.moon, name: '夜猫子', desc: '总游戏时长超过 5000 小时且拥有 200+ 游戏', threshold: 1, metric: 'nightOwl' },
{ id: 'patron', icon: ACHIEVEMENT_SVG_ICONS.diamond, name: 'Steam 老用户', desc: 'Steam 账号超过 10 年', threshold: 1, metric: 'veteran' },
];
function computeUserAchievements() {
// v2.9.2: 排除家庭组共享游戏和 DLC,确保成就统计准确
const allGames = getStatFilteredGames();
const gameCount = allGames.length;
const totalPlaytimeMin = allGames.reduce((s, g) => s + (g.playtime || 0), 0);
const totalHours = Math.floor(totalPlaytimeMin / 60);
// 完美游戏 (有成就且全部解锁 - 这里用 playtime > 60h 作为近似)
const perfectGames = allGames.filter(g => g.playtime && g.playtime > 3600).length;
// 游戏类型多样性 (从游戏名推断不了,用 0 占位)
const genres = 0;
// 早鸟 (acquiredTime 在发行 7 天内)
const earlyAccess = allGames.filter(g => {
if (!g.acquiredTime || !g.releaseDate) return false;
const diff = g.acquiredTime - g.releaseDate;
return diff >= 0 && diff <= 7 * 86400000;
}).length;
// 夜猫子 (游戏时长 > 5000h 且游戏数 > 200)
const nightOwl = (totalHours > 5000 && gameCount > 200) ? 1 : 0;
// 老用户 (从 SteamID 创建时间估算 - 用 account creation time)
const veteran = 0; // 需要额外 API
const metrics = { gameCount, totalHours, perfectGames, genres, earlyAccess, nightOwl, veteran };
return USER_ACHIEVEMENTS.map(ach => {
const current = metrics[ach.metric] || 0;
const unlocked = current >= ach.threshold;
const progress = Math.min(100, (current / ach.threshold) * 100);
return { ...ach, current, unlocked, progress };
});
}
// ==================== v2.3.8: 社交标签页渲染 (参考 friend-manager) ====================
function renderSocial() {
if (SGIS.friendsList) { renderSocialContent(); return; }
if (SGIS.friendsListLoading) return;
const steamId = getActiveSteamId();
if (!steamId) {
setBody(`
${SGIS_ICONS.social}
无法获取好友列表
请先配置 SteamID 或登录 Steam 商店
`);
return;
}
SGIS.friendsListLoading = true;
renderLoading('正在获取好友列表…');
fetchFriendsList(steamId).then(result => {
SGIS.friendsList = result;
SGIS.friendsListLoading = false;
SGIS.friendsListError = result.error || null;
renderSocialContent();
}).catch(e => {
SGIS.friendsListLoading = false;
SGIS.friendsListError = e.message || '获取好友列表失败';
renderSocialContent();
});
}
function renderSocialContent() {
// 错误状态
if (SGIS.friendsListError && (!SGIS.friendsList || !SGIS.friendsList.friends || SGIS.friendsList.friends.length === 0)) {
setBody(`
${SGIS_ICONS.social}
获取好友列表失败
${SGIS.friendsListError}
`);
const retry = document.getElementById('sgis-social-retry');
if (retry) retry.addEventListener('click', () => {
SGIS.friendsList = null; SGIS.friendsListError = null;
renderSocial();
});
return;
}
const data = SGIS.friendsList || { friends: [], total: 0 };
const friends = data.friends || [];
// ── KPI 计算 ──
const totalCount = friends.length;
const ingameFriends = friends.filter(f => f.gameextrainfo || f.personastate === 6);
const onlineFriends = friends.filter(f => f.personastate > 0 && !f.gameextrainfo);
const vacBanned = friends.filter(f => f.vac_banned);
const newFriends = friends.filter(f => f.friend_days != null && f.friend_days < 30);
const knownLevels = friends.filter(f => f.level != null);
const maxLevel = knownLevels.length > 0 ? Math.max(...knownLevels.map(f => f.level)) : null;
const KPI_GRADIENTS = {
blue: 'linear-gradient(135deg, #3b82f6, #06b6d4)',
cyan: 'linear-gradient(135deg, #06b6d4, #22d3ee)',
purple: 'linear-gradient(135deg, #8b5cf6, #a78bfa)',
amber: 'linear-gradient(135deg, #f59e0b, #fbbf24)',
rose: 'linear-gradient(135deg, #f43f5e, #fb7185)',
green: 'linear-gradient(135deg, #10b981, #34d399)',
};
// ── KPI 卡片 (6 个: 总数/在线/游戏中/VAC/新好友/最高等级) ──
const renderKpi = (icon, label, val, sub, grad, color) => `
${val}
${sub ? `
${sub}
` : ''}
`;
const kpiHtml = `
${renderKpi(SGIS_ICONS.social, '好友总数', totalCount, `在线 ${onlineFriends.length + ingameFriends.length}`, KPI_GRADIENTS.blue, 'blue')}
${renderKpi(SGIS_ICONS.sparkle, '游戏中', ingameFriends.length, '正在玩游戏', KPI_GRADIENTS.cyan, 'cyan')}
${renderKpi(SGIS_ICONS.check, '在线', onlineFriends.length, '未在游戏中', KPI_GRADIENTS.green, 'green')}
${renderKpi(SGIS_ICONS.shield, 'VAC 封禁', vacBanned.length, vacBanned.length > 0 ? `最近 ${vacBanned[0].vac_days_since_last_ban}天` : '安全', vacBanned.length > 0 ? KPI_GRADIENTS.rose : KPI_GRADIENTS.green, vacBanned.length > 0 ? 'rose' : 'green')}
${renderKpi(SGIS_ICONS.clock, '新好友(30天)', newFriends.length, '最近添加', KPI_GRADIENTS.amber, 'amber')}
${renderKpi(SGIS_ICONS.trophy, '最高等级', maxLevel != null ? 'Lv.' + maxLevel : '—', knownLevels.length > 0 ? `${knownFriends(friends)} 人已加载` : '点击下方加载', KPI_GRADIENTS.purple, 'purple')}
`;
// ── 筛选+排序工具栏 ──
const filter = SGIS.friendsFilter || 'all';
const sort = SGIS.friendsSort || 'status';
const search = SGIS.friendsSearch || '';
// v2.3.24: 移除"离线"筛选标签(占比最高且信息价值低),剩余按钮紧凑一行显示
const filterBtns = [
{ id: 'all', label: '全部', count: totalCount },
{ id: 'ingame', label: '游戏中', count: ingameFriends.length },
{ id: 'online', label: '在线', count: onlineFriends.length },
{ id: 'vac', label: 'VAC', count: vacBanned.length },
{ id: 'new', label: '新好友', count: newFriends.length },
];
const sortOptions = [
{ id: 'status', label: '按状态' },
{ id: 'days', label: '按好友天数' },
{ id: 'level', label: '按等级' },
{ id: 'name', label: '按昵称' },
];
// v2.3.13: 搜索+排序单独一行,筛选标签放第二行
const toolbarHtml = `
${filterBtns.map(f => ``).join('')}
`;
// ── 筛选+排序+搜索 ──
let filtered = [...friends];
if (filter === 'ingame') filtered = filtered.filter(f => f.gameextrainfo || f.personastate === 6);
else if (filter === 'online') filtered = filtered.filter(f => f.personastate > 0 && !f.gameextrainfo);
else if (filter === 'offline') filtered = filtered.filter(f => f.personastate === 0);
else if (filter === 'vac') filtered = filtered.filter(f => f.vac_banned);
else if (filter === 'new') filtered = filtered.filter(f => f.friend_days != null && f.friend_days < 30);
if (search) {
const q = search.toLowerCase();
filtered = filtered.filter(f =>
(f.personaname || '').toLowerCase().includes(q) ||
String(f.steamid).includes(q)
);
}
// 排序
if (sort === 'days') filtered.sort((a, b) => (b.friend_days || 0) - (a.friend_days || 0));
else if (sort === 'level') filtered.sort((a, b) => (b.level || 0) - (a.level || 0));
else if (sort === 'name') filtered.sort((a, b) => (a.personaname || '').localeCompare(b.personaname || ''));
else { // status: 游戏中 > 在线 > 离线
const rank = f => f.gameextrainfo ? 0 : (f.personastate > 0 ? 1 : 2);
filtered.sort((a, b) => rank(a) - rank(b));
}
// ── 分组渲染 (status 排序时分组, 其他排序时不分组) ──
let listHtml = '';
if (sort === 'status' && filter === 'all') {
const groups = [
{ id: 'ingame', label: '游戏中', icon: SGIS_ICONS.sparkle, friends: filtered.filter(f => f.gameextrainfo || f.personastate === 6) },
{ id: 'online', label: '在线', icon: SGIS_ICONS.check, friends: filtered.filter(f => f.personastate > 0 && !f.gameextrainfo) },
{ id: 'offline', label: '离线', icon: SGIS_ICONS.clock, friends: filtered.filter(f => f.personastate === 0) },
];
listHtml = groups.map(g => g.friends.length > 0 ? `
${g.icon}${g.label}${g.friends.length}
${g.friends.slice(0, 50).map(renderFriendCard).join('')}
${g.friends.length > 50 ? `
还有 ${g.friends.length - 50} 位好友未显示, 请使用筛选或搜索查看
` : ''}
` : '').join('');
} else {
listHtml = filtered.slice(0, 100).map(renderFriendCard).join('');
if (filtered.length > 100) {
listHtml += `显示前 100 位, 共 ${filtered.length} 位匹配好友
`;
}
}
// ── v2.3.24: 好友游戏数量 TOP 10(参考 steam-friend-manager 社交仪表盘"游戏总数排行") ──
const gcMap = SGIS.friendsGameCounts || cacheGet('friendsGameCounts_' + getActiveSteamId()) || {};
const gcRows = friends
.map(f => ({ f, gc: gcMap[f.steamid] }))
.filter(x => x.gc && x.gc.gc > 0)
.sort((a, b) => b.gc.gc - a.gc.gc)
.slice(0, 10);
const gcLoadedCount = Object.keys(gcMap).length;
const gcBtn = ``;
const gcSectionHtml = `
${SGIS_ICONS.library} ${isZh ? '好友游戏数量 TOP 10' : 'Friend Game Count Top 10'}
${gcBtn}
${gcRows.length > 0 ? gcRows.map((x, i) => {
const pct = Math.max(3, Math.round(x.gc.gc / gcRows[0].gc.gc * 100));
const hours = Math.round((x.gc.tm || 0) / 60);
return `
${i + 1}
${x.f.personaname || ''}
${x.gc.gc}${isZh ? '款' : ''}${hours}h
`;
}).join('') : `
${isZh
? (gcLoadedCount > 0 ? '已加载的好友均无公开游戏库(资料私密)' : '暂无数据——点击右上角"加载游戏数"逐好友获取游戏库统计
(私密资料自动跳过,数据缓存 12 小时)')
: (gcLoadedCount > 0 ? 'No public libraries among loaded friends' : 'No data — click "Load" to fetch per-friend library stats (cached 12h)')}
`}
`;
// ── 同步全部等级按钮 ──
const unknownLevelCount = friends.filter(f => f.level == null).length;
const knownLevelCount = friends.length - unknownLevelCount;
const loadLevelsBtn = ``;
// v2.3.13: 同步封禁状态按钮
const banSyncBtn = ``;
setBody(`
${SGIS_ICONS.social} 好友概览
${banSyncBtn}
${loadLevelsBtn}
${kpiHtml}
${gcSectionHtml}
${SGIS_ICONS.users} 好友列表 (${filtered.length}/${totalCount})
${toolbarHtml}
${listHtml || '
无匹配好友
'}
好友数据缓存 6h · VAC 状态缓存 24h · 等级缓存 7天 · 数据源 Steam Web API
`);
// 绑定事件
// v2.3.8 修复: renderSocialContent 没有 steamId 形参, 这里直接取活动 SteamID
_bindSocialEvents(getActiveSteamId());
}
function knownFriends(friends) {
return friends.filter(f => f.level != null).length;
}
// 渲染单个好友卡片
function renderFriendCard(f) {
const hasGame = !!f.gameextrainfo;
const isIngame = hasGame || f.personastate === 6;
const isOnline = f.personastate > 0 && !hasGame;
const stateCls = isIngame ? 'ingame' : isOnline ? 'online' : 'offline';
const statusText = hasGame ? `🎮 ${f.gameextrainfo}` : getPersonaStateText(f.personastate);
const statusCls = isIngame ? 'ingame' : isOnline ? 'online' : 'offline';
const avatarUrl = f.avatarmedium || f.avatar || '';
const avatarFallback = `data:image/svg+xml;utf8,`;
const levelBadge = f.level != null ? `Lv.${f.level}` : '';
const vacShield = f.vac_banned ? `${SGIS_ICONS.shield}VAC` : '';
// v2.3.8 修复: 用内联 SVG 国旗 + 国家名, 不再依赖外部 CDN, 加载更稳定
const countryBadge = f.country_flag ? `${f.country_flag}${f.country_name || f.loccountrycode}` : '';
return `
${f.personaname}
${levelBadge}
${vacShield}
${statusText}
${f.friend_days_text ? `🤝 ${f.friend_days_text}` : ''}
${countryBadge}
`;
}
// 绑定社交页事件
function _bindSocialEvents(steamId) {
// 搜索
const searchInput = document.getElementById('sgis-social-search');
if (searchInput) {
let timer;
searchInput.addEventListener('input', e => {
clearTimeout(timer);
timer = setTimeout(() => {
SGIS.friendsSearch = e.target.value;
renderSocialContent();
}, 250);
});
}
// 筛选
document.querySelectorAll('.sgis-social-filter').forEach(btn => {
btn.addEventListener('click', () => {
SGIS.friendsFilter = btn.dataset.filter;
renderSocialContent();
});
});
// 排序
const sortSelect = document.getElementById('sgis-social-sort');
if (sortSelect) {
sortSelect.addEventListener('change', e => {
SGIS.friendsSort = e.target.value;
renderSocialContent();
});
}
// 同步全部等级
const loadLevelsBtn = document.getElementById('sgis-social-load-levels');
if (loadLevelsBtn) {
loadLevelsBtn.addEventListener('click', async () => {
if (SGIS.friendsLevelsLoading) return;
const friends = (SGIS.friendsList && SGIS.friendsList.friends) || [];
const unknown = friends.filter(f => f.level == null).length;
if (unknown === 0) {
showToast(isZh ? '所有好友等级已加载' : 'All friend levels already loaded');
return;
}
SGIS.friendsLevelsLoading = true;
SGIS.friendsLevelsProgress = 0;
SGIS.friendsLevelsTotal = unknown;
renderSocialContent();
try {
const totalUnknown = unknown;
// v2.3.12: 100 并发,点击一次自动同步全部未知等级
await fetchFriendsLevels(steamId, { maxCount: Infinity });
const remaining = (SGIS.friendsList.friends || []).filter(f => f.level == null).length;
if (remaining > 0) {
showToast(isZh ? `已同步 ${totalUnknown - remaining}/${totalUnknown} 个等级,${remaining} 位获取失败` : `Synced ${totalUnknown - remaining}/${totalUnknown}, ${remaining} failed`);
} else {
showToast(isZh ? '所有好友等级同步完成' : 'All friend levels synced');
}
} catch (e) {
console.warn('[SGIS] 加载好友等级失败:', e);
showToast(isZh ? `同步失败: ${e.message || '网络错误'}` : `Sync failed: ${e.message || 'network error'}`);
} finally {
SGIS.friendsLevelsLoading = false;
renderSocialContent();
}
});
}
// v2.3.13: 同步封禁状态按钮
const banSyncBtn = document.getElementById('sgis-social-ban-sync');
if (banSyncBtn) {
banSyncBtn.addEventListener('click', async () => {
if (SGIS.friendsBansLoading) return;
const friends = (SGIS.friendsList && SGIS.friendsList.friends) || [];
if (friends.length === 0) {
showToast(isZh ? '暂无好友数据' : 'No friend data');
return;
}
SGIS.friendsBansLoading = true;
SGIS.friendsBansProgress = 0;
SGIS.friendsBansTotal = friends.length;
renderSocialContent();
try {
await fetchFriendsBans(steamId, { force: true });
const vacCount = (SGIS.friendsList.friends || []).filter(f => f.vac_banned).length;
showToast(isZh ? `封禁状态同步完成,发现 ${vacCount} 位封禁好友` : `Ban sync complete, ${vacCount} banned friends found`);
} catch (e) {
console.warn('[SGIS] 同步封禁状态失败:', e);
showToast(isZh ? `同步失败: ${e.message || '网络错误'}` : `Sync failed: ${e.message || 'network error'}`);
} finally {
SGIS.friendsBansLoading = false;
renderSocialContent();
}
});
}
// v2.3.24: 加载好友游戏数量按钮
const gcBtn = document.getElementById('sgis-social-load-gc');
if (gcBtn) {
gcBtn.addEventListener('click', async () => {
if (SGIS.friendsGameCountsLoading) return;
const friends = (SGIS.friendsList && SGIS.friendsList.friends) || [];
if (friends.length === 0) {
showToast(isZh ? '暂无好友数据' : 'No friend data');
return;
}
SGIS.friendsGameCountsLoading = true;
SGIS.friendsGameCountsProgress = 0;
SGIS.friendsGameCountsTotal = 0;
gcBtn.disabled = true;
gcBtn.textContent = isZh ? '加载中…' : 'Loading…';
try {
await fetchFriendsGameCounts(steamId);
const loaded = Object.keys(SGIS.friendsGameCounts || {}).length;
showToast(isZh ? `好友游戏数量加载完成(${loaded} 位,私密资料已跳过)` : `Game counts loaded (${loaded} friends)`);
} catch (e) {
console.warn('[SGIS] 加载好友游戏数量失败:', e);
showToast(isZh ? `加载失败: ${e.message || '网络错误'}` : `Load failed: ${e.message || 'network error'}`);
} finally {
SGIS.friendsGameCountsLoading = false;
renderSocialContent();
}
});
}
// v2.3.24: 游戏数量排行行点击跳转主页
document.querySelectorAll('.sgis-gc-row').forEach(row => {
row.addEventListener('click', () => {
if (row.dataset.profile) window.open(row.dataset.profile, '_blank');
});
});
}
function renderUserAchievements() {
if (SGIS.userAchievements && SGIS.insightData) { renderUserAchievementsContent(); return; }
if (SGIS.userAchievementsLoading) return;
SGIS.userAchievementsLoading = true;
renderLoading('正在分析游戏数据…');
setTimeout(() => {
SGIS.userAchievements = computeUserAchievements();
SGIS.insightData = computeInsightData();
SGIS.userAchievementsLoading = false;
renderUserAchievementsContent();
}, 200);
}
// ==================== v2.3.8: 洞察数据本地计算 (参考 AIPage analyzeLibrary) ====================
// v2.9.50: 改为调用主闭包缓存版本(PCC 持久化)— 跨 session 复用,避免每次开洞察标签页都全量重算
// v2.9.68: 修复 ReferenceError — computeInsightDataCachedSgis 在 SGLV 子闭包内,通过 SGLV_API 桥接调用
function computeInsightData() {
if (typeof SGLV_API.computeInsightDataCachedSgis === 'function') {
return SGLV_API.computeInsightDataCachedSgis();
}
console.warn('[SGIS] computeInsightData: SGLV_API桥接不可用,返回 null');
return null;
}
function renderUserAchievementsContent() {
const data = SGIS.insightData;
// 数据为空时的兜底
if (!data || !data.kpi) {
setBody(`
${SGIS_ICONS.insight}
暂无洞察数据
请先获取游戏库数据
`);
return;
}
const k = data.kpi;
const p = data.persona;
// ── KPI 卡片 (参考 AIPage LocalKpiCard) ──
const KPI_GRADIENTS = {
blue: 'linear-gradient(135deg, #3b82f6, #06b6d4)',
cyan: 'linear-gradient(135deg, #06b6d4, #22d3ee)',
purple: 'linear-gradient(135deg, #8b5cf6, #a78bfa)',
amber: 'linear-gradient(135deg, #f59e0b, #fbbf24)',
rose: 'linear-gradient(135deg, #f43f5e, #fb7185)',
green: 'linear-gradient(135deg, #10b981, #34d399)',
};
const renderKpi = (icon, label, val, sub, grad, color) => `
${val}
${sub ? `
${sub}
` : ''}
`;
const kpiHtml = `
${renderKpi(SGIS_ICONS.barChart, '游戏库', k.gameCount, `${k.over100hCount} 款超 100h`, KPI_GRADIENTS.blue, 'blue')}
${renderKpi(SGIS_ICONS.clock, '总时长', k.totalHours + ' h', `平均 ${k.avgHours}h/款`, KPI_GRADIENTS.cyan, 'cyan')}
${renderKpi(SGIS_ICONS.check, '已启动', k.playedCount, `占比 ${k.gameCount > 0 ? Math.round(k.playedCount / k.gameCount * 100) : 0}%`, KPI_GRADIENTS.purple, 'purple')}
${renderKpi(SGIS_ICONS.sparkle, '未启动', k.unplayedCount, `吃灰率 ${(k.dustRate * 100).toFixed(1)}%`, KPI_GRADIENTS.amber, 'amber')}
${renderKpi(SGIS_ICONS.trophy, '深度游玩', k.longGamesCount, '≥10h 的游戏数', KPI_GRADIENTS.rose, 'rose')}
${renderKpi(SGIS_ICONS.target, '完成度', k.completionRate + '%', '深度游玩/已启动', KPI_GRADIENTS.purple, 'purple')}
`;
// ── 玩家画像区 (参考 AIPage persona 卡片) ──
const personaHtml = `
${SGIS_ICONS.insight}
${p.type}${p.rarity}
${p.tagline}
${p.traits.map(t => `${t}`).join('')}
${SGIS_ICONS.sparkle}
吃灰率
${(k.dustRate * 100).toFixed(1)}%
`;
// ── 维度评分卡片 (参考 AIPage LocalDimensionCard) ──
const getDimTagClass = (s) => s >= 80 ? 't-high' : s >= 60 ? 't-mid' : s >= 40 ? 't-low' : 't-bad';
const getDimProgressColor = (s) => s >= 80 ? '#10b981' : s >= 60 ? '#3b82f6' : s >= 40 ? '#f59e0b' : '#f43f5e';
const dimensionsHtml = `
${data.dimensions.map(d => `
${d.label}
${d.tag}
${d.score}
${d.desc}
`).join('')}
`;
// ── AI 深度分析区 (参考 AIPage "游戏库深度分析") ──
const aiConfigured = !!storage.getAiApiKey();
const aiSectionHtml = SGIS.aiInsight ? `
${SGIS.aiInsight.oneLiner ? `
${SGIS_ICONS.target}
${SGIS.aiInsight.oneLiner}
` : ''}
${SGIS.aiInsight.sections.map((s, i) => {
// v2.9.84: 六维度配色, 新增绿色渐变给"犀利毒舌"
const grad = [KPI_GRADIENTS.blue, KPI_GRADIENTS.cyan, KPI_GRADIENTS.purple, KPI_GRADIENTS.rose, KPI_GRADIENTS.amber, KPI_GRADIENTS.green][i % 6];
return `
${SGIS_ICONS.sparkle}${s.title}
${s.content}
`;
}).join('')}
` : SGIS.aiInsightLoading ? `
${SGIS_ICONS.refresh}
AI 正在分析你的游戏库…
六维度犀利点评生成中, 预计 15-30 秒
` : SGIS.aiInsightError ? `
⚠️
分析失败: ${SGIS.aiInsightError}
` : `
${SGIS_ICONS.brain}
点击「开始深度分析」
AI 将从库存概况、游玩习惯、偏好画像、亮点槽点、购买建议、犀利毒舌六个维度犀利点评
${!aiConfigured ? `
⚠️ 未配置 AI API Key, 请先在设置中配置
` : ''}
`;
const aiHtml = `
${SGIS_ICONS.brain}
AI 深度分析
六维度犀利点评你的 Steam 库存
${aiSectionHtml}
`;
// ── 游戏市场洞察区 (基于卡牌/徽章数据) ──
const marketHtml = renderMarketInsightSection();
// ── 底部成就引导 (v2.9.38: 移除重复展示, 改为简洁跳转入口指向中央面板) ──
const achievements = SGIS.userAchievements || [];
const unlocked = achievements.filter(a => a.unlocked);
const achievementsHtml = `
${SGIS_ICONS.trophy}
${isZh ? '我的成就' : 'My Achievements'}
${isZh ? `已解锁 ${unlocked.length}/${achievements.length} · 共 ${achievements.reduce((s,a)=>s+(a.pts||0),0)} 成就点` : `Unlocked ${unlocked.length}/${achievements.length} · ${achievements.reduce((s,a)=>s+(a.pts||0),0)} pts`}
`;
// v2.9.83: 游戏口味标签画像 — 从愿望单标签 + 游玩模式 + 画像特征推断
const gameTasteHtml = (() => {
// 1) 收集愿望单标签频率 (用户主动添加的标签反映真实兴趣)
const tagFreq = {};
if (Array.isArray(state.wishlistGames)) {
for (const g of state.wishlistGames) {
if (g && Array.isArray(g.tags)) {
for (const t of g.tags) {
const tag = String(t).trim();
if (tag) tagFreq[tag] = (tagFreq[tag] || 0) + 1;
}
}
}
}
// 取频率最高的标签 (至少出现2次)
const sortedTags = Object.entries(tagFreq)
.filter(([, c]) => c >= 2)
.sort((a, b) => b[1] - a[1])
.slice(0, 20);
// 2) 基于游玩模式推断口味标签
const tasteTags = [];
if (k.over100hCount >= 5) tasteTags.push({ label: '长篇沉浸', cat: 'style' });
if (k.gameCount > 0 && k.playedCount / k.gameCount < 0.5) tasteTags.push({ label: '收藏型', cat: 'style' });
if (k.avgHours >= 50) tasteTags.push({ label: '硬核深度', cat: 'style' });
if (k.dustRate > 0.5) tasteTags.push({ label: '吃灰大户', cat: 'style' });
if (k.completionRate >= 60) tasteTags.push({ label: '认真通关', cat: 'feature' });
if (k.gameCount >= 200) tasteTags.push({ label: '量大管饱', cat: 'feature' });
if (k.over500hCount >= 1) tasteTags.push({ label: '极致专注', cat: 'feature' });
// 3) 从 persona traits 提取标签
if (p.traits && p.traits.length) {
p.traits.slice(0, 3).forEach(t => tasteTags.push({ label: t, cat: 'theme' }));
}
// 4) 将愿望单标签映射为口味标签
const genreKeywords = { '动作': 'genre', '冒险': 'genre', '策略': 'genre', 'RPG': 'genre', '独立': 'genre', '模拟': 'genre', '休闲': 'genre', '竞速': 'genre', '体育': 'genre', '恐怖': 'genre', '射击': 'genre', '解谜': 'genre', '平台': 'genre', '开放世界': 'genre', '生存': 'genre', '多人': 'feature', '合作': 'feature', '单机': 'feature', 'PvP': 'feature', '沙盒': 'style', 'Roguelike': 'style', '像素': 'style', '剧情': 'theme', '科幻': 'theme', '奇幻': 'theme', '二次元': 'theme' };
const wlTasteTags = sortedTags.slice(0, 12).map(([tag, count]) => {
let cat = 'theme';
for (const [kw, c] of Object.entries(genreKeywords)) {
if (tag.includes(kw) || kw.includes(tag)) { cat = c; break; }
}
return { label: tag, cat, count };
});
// 5) 合并去重
const allTags = [...tasteTags, ...wlTasteTags];
const seen = new Set();
const dedupedTags = allTags.filter(t => {
if (seen.has(t.label)) return false;
seen.add(t.label);
return true;
}).slice(0, 18);
// 6) 标签大小分级 (基于频率/权重)
const maxCount = Math.max(...sortedTags.map(([, c]) => c), 1);
const tagCloudHtml = dedupedTags.map(t => {
const weight = t.count ? t.count / maxCount : 0.5;
const sizeCls = weight > 0.7 ? 't-xl' : weight > 0.5 ? 't-lg' : weight > 0.3 ? 't-md' : 't-sm';
return `${escHtml(t.label)}`;
}).join('');
// 7) 游玩时长分布条形图 (按维度分数)
const bars = data.dimensions.slice(0, 5).map(d => {
const color = d.score >= 80 ? '#10b981' : d.score >= 60 ? '#3b82f6' : d.score >= 40 ? '#f59e0b' : '#f43f5e';
return `
${escHtml(d.label)}
${d.score} · ${escHtml(d.tag)}
`;
}).join('');
return `
${SGIS_ICONS.tag || '🏷️'} ${isZh ? '游戏口味' : 'Game Taste'} ${isZh ? '标签画像' : 'Profile'}
${isZh ? '基于愿望单标签与游玩习惯推断' : 'Inferred from wishlist tags & play habits'}
${tagCloudHtml}
${bars}
`;
})();
setBody(`
${SGIS_ICONS.barChart} KPI 概览
${kpiHtml}
${personaHtml}
${gameTasteHtml}
${SGIS_ICONS.target} 五维度评分
${dimensionsHtml}
${aiHtml}
${marketHtml}
${SGIS_ICONS.trophy} 成就系统
${achievementsHtml}
洞察数据基于本地游戏库本地计算 · AI 分析需配置 AI API Key · 参考 steam-game-hub-2.0 AIPage 设计
`);
// v2.3.33:异步加载市场洞察游戏中文名
document.querySelectorAll('#sgis-body [data-sglv-appid]').forEach(el => {
loadGameZhName(el, el.getAttribute('data-sglv-appid'), el.textContent);
});
// 绑定 AI 深度分析按钮
const triggerBtn = document.getElementById('sgis-insight-trigger');
if (triggerBtn) triggerBtn.addEventListener('click', triggerAiInsight);
const retryBtn = document.getElementById('sgis-insight-retry');
if (retryBtn) retryBtn.addEventListener('click', () => { SGIS.aiInsightError = null; triggerAiInsight(); });
// 绑定市场洞察按钮
const marketBtn = document.getElementById('sgis-market-trigger');
if (marketBtn) marketBtn.addEventListener('click', triggerAiMarketInsight);
const marketRetry = document.getElementById('sgis-market-retry');
if (marketRetry) marketRetry.addEventListener('click', () => { SGIS.aiMarketInsightError = null; triggerAiMarketInsight(); });
// v2.9.38: 绑定成就引导按钮 — 关闭 SGIS 侧边栏并打开中央面板的"游戏成就"页签(展示该游戏的具体成就)
const achOpenBtn = document.getElementById('sgis-insight-ach-open');
if (achOpenBtn) achOpenBtn.addEventListener('click', () => {
try {
if (typeof closePanel === 'function') closePanel();
// 触发中央面板打开 + 切换到游戏成就标签(展示当前游戏的成就数据)
document.dispatchEvent(new CustomEvent('sglv:open-modal', { detail: { tab: 'achievements' } }));
} catch (e) { /* 静默 */ }
});
}
// ==================== v2.3.8: 游戏市场洞察渲染 (基于 userBadgeMarkets 数据) ====================
function renderMarketInsightSection() {
const markets = SGIS.userBadgeMarkets || {};
const aiConfigured = !!storage.getAiApiKey();
// 统计卡牌市场数据
const gameMarkets = Object.entries(markets)
.filter(([appId, m]) => m && m.cards && m.cards.length > 0)
.map(([appId, m]) => {
const totalValue = (m.cards || []).reduce((s, c) => s + (c.lowestPrice || 0), 0);
const avgValue = totalValue / (m.cards || []).length;
const gameName = (m.gameName || state.ownedGames?.find(g => String(g.appid) === String(appId))?.name || `App ${appId}`);
return { appId, gameName, cardCount: (m.cards || []).length, totalValue, avgValue };
})
.sort((a, b) => b.totalValue - a.totalValue);
const totalCardValue = gameMarkets.reduce((s, g) => s + g.totalValue, 0);
const totalCards = gameMarkets.reduce((s, g) => s + g.cardCount, 0);
const top5 = gameMarkets.slice(0, 5);
const aiMarketHtml = SGIS.aiMarketInsight ? `
${SGIS.aiMarketInsight.summary ? `${SGIS.aiMarketInsight.summary}
` : ''}
${SGIS.aiMarketInsight.recommendations && SGIS.aiMarketInsight.recommendations.length > 0 ? SGIS.aiMarketInsight.recommendations.map(r => `
${r.name}
${r.action}
${r.reason}
`).join('') : ''}
` : SGIS.aiMarketInsightLoading ? `
${SGIS_ICONS.refresh}
AI 正在分析市场数据…
生成投资建议中
` : SGIS.aiMarketInsightError ? `
⚠️
分析失败: ${SGIS.aiMarketInsightError}
` : `
${SGIS_ICONS.market}
点击「生成投资建议」
AI 将基于你的卡牌库分析哪些游戏卡牌值得合成/出售
${!aiConfigured ? `
⚠️ 未配置 AI API Key
` : ''}
${top5.length === 0 ? `
📡 暂无卡牌市场数据, 请先访问「勋章」标签页加载
` : ''}
`;
return `
${SGIS_ICONS.market}
游戏市场洞察
基于你的卡牌库分析市场价值与投资机会
${top5.length > 0 ? `
${gameMarkets.length}
有卡牌游戏
¥${totalCardValue.toFixed(0)}
总价值
价值 Top 5
${top5.map((g, i) => `
${i + 1}
${g.gameName}
¥${g.totalValue.toFixed(2)} · ${g.cardCount}卡
`).join('')}
` : `
暂无卡牌市场数据
请先访问「勋章」标签页加载卡牌数据
`}
${aiMarketHtml}
`;
}
// ==================== v2.3.8: AI 深度分析 (参考 AIPage analyzeLibrary + handleLibraryAnalyze) ====================
async function triggerAiInsight() {
if (SGIS.aiInsightLoading) return;
const apiKey = storage.getAiApiKey();
if (!apiKey) { showToast('未配置 AI API Key,请在设置中配置'); return; }
SGIS.aiInsightLoading = true;
SGIS.aiInsightError = null;
SGIS.aiInsight = null;
renderUserAchievementsContent();
try {
const data = SGIS.insightData || computeInsightData();
const k = data.kpi;
const p = data.persona;
const profile = SGIS.profile?.summary;
const personaName = profile?.personaname || '未知';
const level = SGIS.profile?.level || SGIS.userBadges?.playerLevel || '?';
// 构造输入数据摘要
const topGamesStr = data.topGames.map(g => `${g.name}(${g.hours}h)`).join(', ');
const recentStr = data.recentGames.map(g => `${g.name}(${g.hours}h)`).join(', ');
const dustStr = data.dustCollectors.join(', ');
const dimsStr = data.dimensions.map(d => `${d.label}=${d.score}分(${d.tag})`).join(', ');
const traitsStr = p.traits.join('、');
// v2.9.84: 构建个人口味标签摘要 (与洞察页面"游戏口味"标签画像同步)
const tasteTags = [];
if (k.over100hCount >= 5) tasteTags.push('长篇沉浸');
if (k.gameCount > 0 && k.playedCount / k.gameCount < 0.5) tasteTags.push('收藏型');
if (k.avgHours >= 50) tasteTags.push('硬核深度');
if (k.dustRate > 0.5) tasteTags.push('吃灰大户');
if (k.completionRate >= 60) tasteTags.push('认真通关');
if (k.gameCount >= 200) tasteTags.push('量大管饱');
if (k.over500hCount >= 1) tasteTags.push('极致专注');
// 愿望单标签频率 (用户主动添加的标签反映真实兴趣)
const wlTagFreq = {};
if (Array.isArray(state.wishlistGames)) {
for (const g of state.wishlistGames) {
if (g && Array.isArray(g.tags)) {
for (const t of g.tags) {
const tag = String(t).trim();
if (tag) wlTagFreq[tag] = (wlTagFreq[tag] || 0) + 1;
}
}
}
}
const topWlTags = Object.entries(wlTagFreq)
.filter(([, c]) => c >= 2)
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([t]) => t);
const tasteTagsStr = [...tasteTags, ...p.traits].join('、');
const wlTagsStr = topWlTags.join('、');
const dimsDetailStr = data.dimensions.map(d => `${d.label}=${d.score}分(${d.tag}) - ${d.desc}`).join('\n');
// v2.9.85: 从远程模板加载提示词, 支持随时修改无需更新脚本
let prompt = await getInsightPromptTemplate();
prompt = prompt.replace(/\{PERSONA_NAME\}/g, personaName)
.replace(/\{STEAM_LEVEL\}/g, String(level))
.replace(/\{GAME_COUNT\}/g, String(k.gameCount))
.replace(/\{TOTAL_HOURS\}/g, String(k.totalHours))
.replace(/\{PLAYED_COUNT\}/g, String(k.playedCount))
.replace(/\{PLAYED_PERCENT\}/g, String(k.gameCount > 0 ? Math.round(k.playedCount / k.gameCount * 100) : 0))
.replace(/\{UNPLAYED_COUNT\}/g, String(k.unplayedCount))
.replace(/\{DUST_RATE\}/g, (k.dustRate * 100).toFixed(1))
.replace(/\{OVER_100H_COUNT\}/g, String(k.over100hCount))
.replace(/\{OVER_500H_COUNT\}/g, String(k.over500hCount))
.replace(/\{LONG_GAMES_COUNT\}/g, String(k.longGamesCount))
.replace(/\{AVG_HOURS\}/g, String(k.avgHours))
.replace(/\{PERSONA_TYPE\}/g, p.type)
.replace(/\{PERSONA_RARITY\}/g, p.rarity)
.replace(/\{TRAITS\}/g, traitsStr || '无')
.replace(/\{TASTE_TAGS\}/g, tasteTagsStr || '无')
.replace(/\{WISHLIST_TAGS\}/g, wlTagsStr || '无')
.replace(/\{DIM_SCORES\}/g, dimsStr)
.replace(/\{DIM_DETAILS\}/g, dimsDetailStr)
.replace(/\{TOP_GAMES\}/g, topGamesStr || '无数据')
.replace(/\{RECENT_GAMES\}/g, recentStr || '无数据')
.replace(/\{DUST_EXAMPLES\}/g, dustStr || '无');
const content = await callAiApi(prompt, { temperature: 0.6, timeout: 90000, maxTokens: 6144 });
const result = safeParseAiJson(content, '{');
// 校验结构
if (!result.oneLiner || !Array.isArray(result.sections)) {
throw new Error('AI 返回结构不完整');
}
SGIS.aiInsight = result;
} catch (e) {
SGIS.aiInsightError = e.message || 'AI 分析失败';
} finally {
SGIS.aiInsightLoading = false;
renderUserAchievementsContent();
}
}
// ==================== v2.3.8: AI 市场洞察 (基于卡牌市场数据) ====================
async function triggerAiMarketInsight() {
if (SGIS.aiMarketInsightLoading) return;
const apiKey = storage.getAiApiKey();
if (!apiKey) { showToast('未配置 AI API Key,请在设置中配置'); return; }
const markets = SGIS.userBadgeMarkets || {};
const gameMarkets = Object.entries(markets)
.filter(([appId, m]) => m && m.cards && m.cards.length > 0)
.map(([appId, m]) => {
const totalValue = (m.cards || []).reduce((s, c) => s + (c.lowestPrice || 0), 0);
const gameName = m.gameName || state.ownedGames?.find(g => String(g.appid) === String(appId))?.name || `App ${appId}`;
return { appId, gameName, cardCount: (m.cards || []).length, totalValue, avgValue: totalValue / (m.cards || []).length, cards: m.cards };
})
.sort((a, b) => b.totalValue - a.totalValue)
.slice(0, 10);
if (gameMarkets.length === 0) {
showToast('暂无卡牌市场数据, 请先访问「勋章」标签页加载');
return;
}
SGIS.aiMarketInsightLoading = true;
SGIS.aiMarketInsightError = null;
SGIS.aiMarketInsight = null;
renderUserAchievementsContent();
try {
const totalValue = gameMarkets.reduce((s, g) => s + g.totalValue, 0);
const totalCards = gameMarkets.reduce((s, g) => s + g.cardCount, 0);
const gamesDataStr = gameMarkets.map(g => {
const cardDetails = g.cards.slice(0, 5).map(c => `${c.name || '卡'}(¥${(c.lowestPrice || 0).toFixed(2)})`).join(', ');
return `- ${g.gameName} (AppID:${g.appId}): ${g.cardCount}张卡牌, 总价值¥${g.totalValue.toFixed(2)}, 均价¥${g.avgValue.toFixed(2)} | 卡牌示例: ${cardDetails}`;
}).join('\n');
const prompt = `# 角色\n你是一位 Steam 卡牌市场投资顾问, 精通卡牌合成、徽章升级、市场套利策略。\n\n# 任务\n基于玩家的 Steam 卡牌库数据, 生成市场洞察报告和投资建议。\n\n# 输入数据\n- 有卡牌游戏数: ${gameMarkets.length}\n- 卡牌总数: ${totalCards}\n- 卡牌总价值: ¥${totalValue.toFixed(2)}\n\n## 游戏卡牌详情 (Top ${gameMarkets.length})\n${gamesDataStr}\n\n# 输出格式\n严格按以下 JSON 格式输出, 不要输出任何其他文字。\n{\n "summary": "2-3句话市场总览, 评价卡牌库整体价值、合成潜力、套利空间",\n "recommendations": [\n {"name": "游戏名称", "action": "买入/卖出/持有/合成", "reason": "1-2句话具体建议理由, 包含数据"}\n ]\n}\n\n# 要求\n- recommendations 数组包含 3-5 条建议, 优先选择价值最高或最有套利空间的游戏\n- action 只能是: 买入(补全卡牌合成徽章)、卖出(出售多余卡牌获利)、持有(暂不操作)、合成(立即合成徽章升级)\n- reason 必须包含具体数据支撑\n- 语言简洁有力, 避免空话`;
const content = await callAiApi(prompt, { temperature: 0.4, timeout: 90000 });
const result = safeParseAiJson(content, '{');
if (!result.summary || !Array.isArray(result.recommendations)) {
throw new Error('AI 返回结构不完整');
}
SGIS.aiMarketInsight = result;
} catch (e) {
SGIS.aiMarketInsightError = e.message || 'AI 市场分析失败';
} finally {
SGIS.aiMarketInsightLoading = false;
renderUserAchievementsContent();
}
}
function renderTab(tab, force) {
try {
if (tab === 'overview') {
if (force) { SGIS.overview = null; SGIS.familyShareSupported = null; SGIS.appDetailsExtra = null; SGIS.dlcNames = null; SGIS.similarGames = null; SGIS.dynamicStoreChecked = false; SGIS.drmInfo = null; SGIS.priceChartRange = 'all'; SGIS.gameStatusInfo = null; SGIS.profileFeaturesStatus = null; }
renderOverview();
// 异步检测家庭共享支持
if (!SGIS.familyShareSupported) {
fetchFamilyShareSupport().then(r => {
SGIS.familyShareSupported = r;
if (SGIS.tab === 'overview') renderOverview();
}).catch(() => {});
}
// v2.3.16: 异步拉取 appdetails 增强信息 (工坊/截图/分类/捆绑包等)
if (!SGIS.appDetailsExtra && !SGIS.appDetailsExtraLoading) {
SGIS.appDetailsExtraLoading = true;
fetchAppDetailsExtra().then(extra => {
SGIS.appDetailsExtra = extra;
SGIS.appDetailsExtraLoading = false;
if (SGIS.tab === 'overview') renderOverview();
// v2.3.17: appdetails 到手后, 若有 DLC 则异步获取 DLC 名称
if (extra && extra.dlc && extra.dlc.length && !SGIS.dlcNames && !SGIS.dlcNamesLoading) {
SGIS.dlcNamesLoading = true;
fetchDlcNames(extra.dlc).then(names => {
SGIS.dlcNames = names;
SGIS.dlcNamesLoading = false;
if (SGIS.tab === 'overview') renderOverview();
}).catch(() => { SGIS.dlcNamesLoading = false; });
}
}).catch(() => { SGIS.appDetailsExtraLoading = false; });
} else if (SGIS.appDetailsExtra && SGIS.appDetailsExtra.dlc && SGIS.appDetailsExtra.dlc.length && !SGIS.dlcNames && !SGIS.dlcNamesLoading) {
// v2.3.17: appdetails 已缓存但 DLC 名称未获取
SGIS.dlcNamesLoading = true;
fetchDlcNames(SGIS.appDetailsExtra.dlc).then(names => {
SGIS.dlcNames = names;
SGIS.dlcNamesLoading = false;
if (SGIS.tab === 'overview') renderOverview();
}).catch(() => { SGIS.dlcNamesLoading = false; });
}
// v2.9.11: 异步获取 gamestatus.info 破解状态(独立于 appDetailsExtra)
if (!SGIS.gameStatusInfo && !SGIS.gameStatusLoading) {
SGIS.gameStatusLoading = true;
const gsName = (SGIS.overview && SGIS.overview.name) || '';
fetchGameStatus(APP_ID, gsName).then(() => {
SGIS.gameStatusLoading = false;
if (SGIS.tab === 'overview') renderOverview();
}).catch(() => { SGIS.gameStatusLoading = false; });
}
// v2.9.93: 资料受限状态检测 (DOM 直接检测,首次渲染时已在 renderProfileStatusBadge 中调用)
// 延迟 1.5s 后重检,以防 Steam React SSR 延迟渲染 learning_about 元素
if (HAS_APP_ID && !SGIS._profileFeaturesDelayedCheck) {
SGIS._profileFeaturesDelayedCheck = true;
setTimeout(() => {
SGIS._profileFeaturesDelayedCheck = false;
// 重新检测 (清除缓存让 detectProfileFeaturesStatus 重新读取 DOM)
const prev = SGIS.profileFeaturesStatus;
SGIS.profileFeaturesStatus = null;
const latest = detectProfileFeaturesStatus();
// 仅在状态变化时重渲染 (避免无谓刷新)
if (prev && latest && prev.status !== latest.status && SGIS.tab === 'overview') {
renderOverview();
}
}, 1500);
}
} else if (tab === 'medals') {
if (force) SGIS.cards = null;
renderMedals();
} else if (tab === 'prices') {
if (force) { SGIS.prices = null; SGIS.historyPrices = null; SGIS.prediction = null; SGIS.predictionError = null; SGIS.giftRec = null; SGIS.priceChartRange = 'all'; }
renderPrices();
} else if (tab === 'reviews') {
if (force) { SGIS.reviews = null; SGIS.reviewsExpanded = new Set(); SGIS.reviewsFilter = { rec: 'all', playtime: 'all', language: 'all', purchase: 'all', keyword: '', regexMode: false }; }
renderReviews();
} else if (tab === 'achievements') {
if (force) { SGIS.achievements = null; SGIS.globalAchievements = null; }
renderAchievements();
} else if (tab === 'dynamics') {
if (force) { SGIS.dynamics = null; SGIS.aiSummary = null; SGIS.aiSummaryLoading = false; SGIS.aiSummaryError = null; }
renderDynamics();
} else if (tab === 'playtrend') {
// v2.9.60: 游玩时长趋势 — force 时不清除历史采样数据,仅重置聚合缓存
if (force) { SGIS.playTrendData = null; }
renderPlayTrend();
} else if (tab === 'profile') {
if (force) { SGIS.profile = null; SGIS.profileError = null; }
renderProfile({ force: !!force });
} else if (tab === 'activity') {
if (force) { SGIS.activity = null; SGIS.personalTimeline = null; SGIS.familyTimeline = null; }
renderActivity();
} else if (tab === 'userBadges') {
if (force) { SGIS.userBadges = null; }
renderUserBadges();
} else if (tab === 'social') {
if (force) { SGIS.friendsList = null; SGIS.friendsListError = null; }
renderSocial();
} else if (tab === 'userAchievements') {
if (force) { SGIS.userAchievements = null; SGIS.aiPersona = null; SGIS.aiPersonaError = null; SGIS.insightData = null; SGIS.aiInsight = null; SGIS.aiInsightError = null; SGIS.aiMarketInsight = null; SGIS.aiMarketInsightError = null; }
renderUserAchievements();
}
} catch (e) {
console.error('[SGIS] renderTab error:', e);
renderError('渲染失败: ' + e.message);
}
}
// v2.3.11: 监听 Activity 瀑布流浮窗,浮窗打开时关闭并隐藏个人信息面板
function watchActivityOverlay() {
const overlay = document.getElementById('sf-wf-overlay');
if (!overlay) {
const mo = new MutationObserver((_, obs) => {
if (document.getElementById('sf-wf-overlay')) {
obs.disconnect();
watchActivityOverlay();
}
});
mo.observe(document.body, { childList: true });
addDisposer(() => mo.disconnect());
return;
}
const handle = () => {
if (overlay.classList.contains('sf-wf-open') && SGIS.open) closePanel();
};
handle();
const mo = new MutationObserver(handle);
mo.observe(overlay, { attributes: true, attributeFilter: ['class'] });
addDisposer(() => mo.disconnect());
}
// ---- 初始化 ----
function initSidebar() {
ensureFab();
ensurePanel();
// 后台预加载汇率
refreshRates().catch(() => { /* ignore */ });
// 监听游戏库数据更新事件, 侧边栏打开时自动重新渲染
document.addEventListener('sglv:games-updated', () => {
if (SGIS.open) renderTab(SGIS.tab, true);
});
watchActivityOverlay();
console.log('%c[Steam 个人信息面板 v2.8.0] UI 初始化完成 · AppID:', 'color:#a78bfa;font-weight:bold', APP_ID || '(首页模式)');
}
// ==================== v2.8.0: 评测标签页 ====================
// 评测分数描述映射 (review_score → 中文 + 图标 + 渐变色档)
const REVIEW_SCORE_MAP = {
9: { desc: '好评如潮', icon: '🎉', cls: 'overwhelmingly-positive' },
8: { desc: '特别好评', icon: '👍', cls: 'very-positive' },
7: { desc: '多半好评', icon: '✅', cls: 'mostly-positive' },
6: { desc: '好评', icon: '🙂', cls: 'positive' },
5: { desc: '褒贬不一', icon: '⚖️', cls: 'mixed' },
4: { desc: '差评', icon: '😕', cls: 'negative' },
3: { desc: '多半差评', icon: '❌', cls: 'mostly-negative' },
2: { desc: '特别差评', icon: '👎', cls: 'very-negative' },
1: { desc: '差评如潮', icon: '💢', cls: 'overwhelmingly-negative' },
};
// v2.9.15: cls → 渐变档(pos/mix/neg),用于大字 + 进度条配色
const SCORE_CLS_TO_TIER = {
'overwhelmingly-positive': 'pos', 'very-positive': 'pos', 'mostly-positive': 'pos', 'positive': 'pos',
'mixed': 'mix',
'overwhelmingly-negative': 'neg', 'very-negative': 'neg', 'mostly-negative': 'neg', 'negative': 'neg',
};
// 获取评测数据:摘要 + 详细评测
// API: store.steampowered.com/appreviews/{appid}?json=1&filter=summary&language=schinese&purchase_type=all
// API: store.steampowered.com/appreviews/{appid}?json=1&filter=all&language=schinese&num_per_page=20
async function fetchReviews(appId) {
const cacheKey = 'reviews_' + appId;
const cached = cacheGet(cacheKey);
if (cached) return cached;
// 并行请求摘要和详细评测
const summaryUrl = `https://store.steampowered.com/appreviews/${appId}?json=1&filter=summary&language=schinese&purchase_type=all&day_range=999999`;
const detailUrl = `https://store.steampowered.com/appreviews/${appId}?json=1&filter=all&language=schinese&purchase_type=all&num_per_page=20`;
const [summaryRes, detailRes] = await Promise.all([
fetchJson(summaryUrl, { timeout: 12000 }).catch(() => null),
fetchJson(detailUrl, { timeout: 12000 }).catch(() => null),
]);
// 摘要数据 (全部时间)
const allTimeSummary = summaryRes?.query_summary || {};
// 详细评测数据 (含最近30天摘要)
const recentSummary = detailRes?.query_summary || {};
// v2.9.15: 修复 authorName bug——原代码三目两边都是 'Steam用户' (typo)
// 优先用 author.personaname (Steam 实际昵称),匿名则降级
const reviews = (detailRes?.reviews || []).map(r => {
const a = r.author || {};
const realName = (a.personaname || '').trim();
return {
id: r.recommendationid || '',
steamid: a.steamid || '',
authorName: realName || 'Steam 用户',
profileUrl: a.profileurl || '',
avatar: a.avatar || '',
avatarMedium: a.avatar_medium || a.avatar || '',
avatarFull: a.avatar_full || a.avatar || '',
playtimeForever: a.playtime_forever || 0, // 分钟
playtimeAtReview: a.playtime_at_review || 0,
numGamesOwned: a.num_games_owned || 0,
numReviews: a.num_reviews || 0,
language: r.language || '',
review: r.review || '',
timestampCreated: r.timestamp_created || 0,
timestampUpdated: r.timestamp_updated || 0,
votedUp: !!r.voted_up,
votesUp: r.votes_up || 0,
votesFunny: r.votes_funny || 0,
weightedVoteScore: r.weighted_vote_score || '0',
commentCount: r.comment_count || 0,
steamPurchase: !!r.steam_purchase,
receivedForFree: !!r.received_for_free,
writtenDuringEarlyAccess: !!r.written_during_early_access,
};
});
const result = {
// 全部时间摘要
allTime: {
totalReviews: allTimeSummary.total_reviews || 0,
totalPositive: allTimeSummary.total_positive || 0,
totalNegative: allTimeSummary.total_negative || 0,
reviewScore: allTimeSummary.review_score || 0,
reviewScoreDesc: allTimeSummary.review_score_desc || '无数据',
},
// 最近30天摘要 (来自详细评测请求的 query_summary)
recent: {
numReviews: recentSummary.num_reviews || 0,
totalPositive: recentSummary.total_positive || 0,
totalNegative: recentSummary.total_negative || 0,
totalReviews: recentSummary.total_reviews || 0,
reviewScore: recentSummary.review_score || 0,
reviewScoreDesc: recentSummary.review_score_desc || '',
},
reviews: reviews,
fetchedAt: Date.now(),
};
// v2.9.15: 单一缓存入口(之前双写 cacheSet + GM_setValue 是冗余)
cacheSet(cacheKey, result, CACHE_TTL.reviews);
return result;
}
// 评测标签页渲染入口
function renderReviews() {
if (SGIS.reviewsLoading) return;
if (SGIS.reviews) {
renderReviewsContent(SGIS.reviews);
return;
}
SGIS.reviewsLoading = true;
renderLoading('正在获取评测数据…');
fetchReviews(APP_ID)
.then(data => {
SGIS.reviews = data;
renderReviewsContent(data);
})
.catch(e => renderError('评测获取失败: ' + e.message))
.finally(() => { SGIS.reviewsLoading = false; });
}
// 渲染评测内容(摘要 + 筛选 + 列表)
function renderReviewsContent(data) {
if (!data || (!data.allTime.totalReviews && !data.reviews.length)) {
// v2.9.15: 精致空状态——状态点 + 文字
setBody(`
暂无评测数据
该游戏可能没有中文评测,或 Steam API 暂未返回
`);
return;
}
const num = (v) => { const n = Number(v); return isNaN(n) ? 0 : n; };
const fmt = (v) => num(v).toLocaleString('zh-CN');
const pct = (a, b) => b > 0 ? ((a / b) * 100).toFixed(1) : '0.0';
const allTime = data.allTime;
const recent = data.recent;
const scoreInfo = REVIEW_SCORE_MAP[allTime.reviewScore] || { desc: allTime.reviewScoreDesc, icon: '❓', cls: 'unknown' };
// v2.9.15: 渐变档 (pos/mix/neg) 用于大字 + 进度条配色
const scoreTier = SCORE_CLS_TO_TIER[scoreInfo.cls] || 'mix';
const positiveRate = pct(allTime.totalPositive, allTime.totalReviews);
const positiveBarWidth = positiveRate;
// 评测趋势对比
const recentPositiveRate = recent.totalReviews > 0 ? pct(recent.totalPositive, recent.totalReviews) : null;
let trendHtml = '';
if (recentPositiveRate != null && allTime.totalReviews > 0) {
const diff = (Number(recentPositiveRate) - Number(positiveRate)).toFixed(1);
const trendIcon = Number(diff) > 0 ? '📈' : (Number(diff) < 0 ? '📉' : '➡️');
const trendColor = Number(diff) > 0 ? '#4bb54f' : (Number(diff) < 0 ? '#e63946' : 'var(--sgis-text-2)');
trendHtml = `
最近30天
${recentPositiveRate}% (${fmt(recent.totalReviews)}条)
全部时间
${positiveRate}% (${fmt(allTime.totalReviews)}条)
趋势
${trendIcon} ${diff > 0 ? '+' : ''}${diff}%
`;
}
// 筛选面板
const filter = SGIS.reviewsFilter;
const filterBtn = (group, value, label) => {
const active = filter[group] === value ? 'active' : '';
return ``;
};
// 评测列表(应用筛选)
const filteredReviews = applyReviewFilters(data.reviews, filter);
const reviewsHtml = filteredReviews.length
? filteredReviews.map(r => renderReviewItem(r)).join('')
: '';
setBody(`
${SGIS_ICONS.review} 评测摘要
👍 ${fmt(allTime.totalPositive)} 好评
👎 ${fmt(allTime.totalNegative)} 差评
${trendHtml}
${SGIS_ICONS.target} 多维筛选
${filter.keyword ? '' : ''}
推荐:
${filterBtn('rec', 'all', '全部')}
${filterBtn('rec', 'yes', '好评')}
${filterBtn('rec', 'no', '差评')}
时长:
${filterBtn('playtime', 'all', '全部')}
${filterBtn('playtime', 'lt1', '<1h')}
${filterBtn('playtime', '1to10', '1-10h')}
${filterBtn('playtime', '10to50', '10-50h')}
${filterBtn('playtime', 'gt50', '50h+')}
语言:
${filterBtn('language', 'all', '全部')}
${filterBtn('language', 'schinese', '中文')}
${filterBtn('language', 'english', '英文')}
获取:
${filterBtn('purchase', 'all', '全部')}
${filterBtn('purchase', 'steam', '购买')}
${filterBtn('purchase', 'free', '免费')}
${filterBtn('purchase', 'key', 'Key')}
${SGIS_ICONS.review}
显示 ${filteredReviews.length} / ${data.reviews.length} 条评测
${filteredReviews.length !== data.reviews.length ? '已筛选' : ''}
${SGIS_ICONS.review} 评测列表
${reviewsHtml}
`);
// 绑定筛选事件
bindReviewFilterEvents(data);
// 绑定评测展开事件
bindReviewExpandEvents();
}
// 渲染单条评测
function renderReviewItem(r) {
const num = (v) => { const n = Number(v); return isNaN(n) ? 0 : n; };
const playtimeHours = r.playtimeForever > 0 ? (r.playtimeForever / 60).toFixed(1) : '0';
const recCls = r.votedUp ? 'sgis-review-rec-yes' : 'sgis-review-rec-no';
const recText = r.votedUp ? '推荐' : '不推荐';
// v2.9.15: 相对时间 + 绝对时间双显示(更直观)
const dateStr = r.timestampCreated > 0 ? formatRelativeDate(r.timestampCreated) : '—';
const dateAbs = r.timestampCreated > 0 ? new Date(r.timestampCreated * 1000).toLocaleDateString('zh-CN') : '';
// 获取方式
let purchaseType = '';
if (r.receivedForFree) purchaseType = '免费获取';
else if (r.steamPurchase) purchaseType = 'Steam 购买';
else purchaseType = 'Key 激活';
// 语言
const langMap = { schinese: '中文', tchinese: '繁中', english: '英文', japanese: '日文' };
const langText = langMap[r.language] || r.language || '—';
// v2.9.15: 头像升级——优先 medium 尺寸,加 fallback 链路 + 圆形 + 首字母占位
const avatarSrc = r.avatarMedium || r.avatar || '';
const initial = (r.authorName || '?').trim().charAt(0).toUpperCase();
const avatarHtml = avatarSrc
? `
`
: `${initial}
`;
// v2.9.15: 作者名支持跳转个人主页,匿名作者则不可点
const authorHtml = r.profileUrl
? `${r.authorName}`
: `${r.authorName}`;
// 评测正文(截断3行,点击展开)
const expanded = SGIS.reviewsExpanded.has(r.id) ? 'expanded' : '';
// v2.9.29: 使用全局 escHtml 替代本地 esc(增加单引号转义,更安全)
const esc = escHtml;
const escReview = esc(r.review).replace(/\n/g, '
');
// 评测项卡片化(v2.9.15 + 展开/收起 + 复制按钮)
const isExpanded = SGIS.reviewsExpanded.has(r.id);
const reviewLen = (r.review || '').length;
const isLong = reviewLen > 200; // 超过 200 字符才显示展开按钮
return `
⏱ ${playtimeHours}h
📦 ${purchaseType}
🌐 ${esc(langText)}
${r.numGamesOwned ? `🎮 ${r.numGamesOwned} 款` : ''}
${escReview}
${isLong ? `
` : ''}
👍 ${num(r.votesUp)}
😄 ${num(r.votesFunny)}
${r.commentCount > 0 ? `💬 ${num(r.commentCount)}` : ''}
`;
}
// 筛选评测列表
function applyReviewFilters(reviews, filter) {
// v2.9.9: 预编译正则表达式 (参考 Steam_Buff review-filter-core.js)
let compiledRegex = null;
if (filter.keyword && filter.regexMode) {
try { compiledRegex = new RegExp(filter.keyword, 'i'); }
catch { compiledRegex = null; }
}
return reviews.filter(r => {
// 推荐状态
if (filter.rec === 'yes' && !r.votedUp) return false;
if (filter.rec === 'no' && r.votedUp) return false;
// 游戏时长(分钟)
const minutes = r.playtimeForever;
if (filter.playtime === 'lt1' && minutes >= 60) return false;
if (filter.playtime === '1to10' && (minutes < 60 || minutes >= 600)) return false;
if (filter.playtime === '10to50' && (minutes < 600 || minutes >= 3000)) return false;
if (filter.playtime === 'gt50' && minutes < 3000) return false;
// 语言
if (filter.language === 'schinese' && !r.language.startsWith('schinese')) return false;
if (filter.language === 'english' && !r.language.startsWith('english')) return false;
// 获取方式
if (filter.purchase === 'steam' && !r.steamPurchase) return false;
if (filter.purchase === 'free' && !r.receivedForFree) return false;
if (filter.purchase === 'key' && (r.steamPurchase || r.receivedForFree)) return false;
// v2.9.9: 关键词/正则搜索
if (filter.keyword && filter.keyword.trim()) {
const text = r.review || '';
if (filter.regexMode) {
if (!compiledRegex || !compiledRegex.test(text)) return false;
} else {
if (!text.toLowerCase().includes(filter.keyword.toLowerCase())) return false;
}
}
return true;
});
}
// 绑定筛选按钮事件
function bindReviewFilterEvents(data) {
// v2.9.9: 抽取重渲染逻辑, 供筛选按钮和搜索共用
const rerenderReviews = () => {
const filteredReviews = applyReviewFilters(data.reviews, SGIS.reviewsFilter);
const listEl = document.getElementById('sgis-review-list');
if (listEl) {
listEl.innerHTML = filteredReviews.length
? filteredReviews.map(r => renderReviewItem(r)).join('')
: '';
bindReviewExpandEvents();
}
// v2.9.15: 更新显示计数(用 [data-count-summary] 选择器替代脆弱的内联样式匹配)
const countSummary = document.querySelector('[data-count-summary]');
if (countSummary) {
countSummary.innerHTML = `
${SGIS_ICONS.review}
显示 ${filteredReviews.length} / ${data.reviews.length} 条评测
${filteredReviews.length !== data.reviews.length ? '已筛选' : ''}`;
}
};
document.querySelectorAll('.sgis-review-filter-btn').forEach(btn => {
btn.addEventListener('click', () => {
const group = btn.dataset.filterGroup;
const value = btn.dataset.filterValue;
SGIS.reviewsFilter[group] = value;
document.querySelectorAll(`.sgis-review-filter-btn[data-filter-group="${group}"]`).forEach(b => b.classList.remove('active'));
btn.classList.add('active');
rerenderReviews();
});
});
// v2.9.9: 关键词搜索 (防抖 300ms, 参考 Steam_Buff search-suggestions.js)
const searchInput = document.getElementById('sgis-review-search');
if (searchInput) {
let searchTimer = null;
searchInput.addEventListener('input', () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
SGIS.reviewsFilter.keyword = searchInput.value;
rerenderReviews();
}, 300);
});
}
// v2.9.9: 正则模式切换
const regexToggle = document.getElementById('sgis-review-regex-toggle');
if (regexToggle) {
regexToggle.addEventListener('click', () => {
SGIS.reviewsFilter.regexMode = !SGIS.reviewsFilter.regexMode;
regexToggle.classList.toggle('active', SGIS.reviewsFilter.regexMode);
rerenderReviews();
});
}
// v2.9.15: 清空搜索按钮(只在该按钮存在时绑定)
const clearBtn = document.getElementById('sgis-review-search-clear');
if (clearBtn && searchInput) {
clearBtn.addEventListener('click', () => {
searchInput.value = '';
SGIS.reviewsFilter.keyword = '';
rerenderReviews();
searchInput.focus();
});
}
}
// 绑定评测文本展开/收起事件
function bindReviewExpandEvents() {
// v2.9.15: 展开/收起改由按钮触发(避免点击全行歧义),文本点击只切换(保留旧行为兼容)
document.querySelectorAll('.sgis-review-text').forEach(el => {
el.addEventListener('click', (e) => {
// 点按钮/链接时不展开(让按钮自身处理)
if (e.target.closest('.sgis-review-action-btn, a, button')) return;
const id = el.dataset.reviewId;
toggleReviewExpand(id);
});
});
// 展开/收起按钮
document.querySelectorAll('.sgis-review-expand-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
toggleReviewExpand(btn.dataset.reviewId);
const isExpanded = btn.classList.contains('is-expanded');
btn.classList.toggle('is-expanded', !isExpanded);
btn.querySelector('span').textContent = !isExpanded ? '收起' : '展开';
});
});
// 复制按钮
document.querySelectorAll('.sgis-review-copy-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const id = btn.dataset.reviewId;
const review = (SGIS.reviews?.reviews || []).find(x => String(x.id) === String(id));
if (!review || !review.review) return;
const ok = await copyTextToClipboard(review.review);
btn.classList.add('copied');
const label = btn.querySelector('span');
const origText = label.textContent;
label.textContent = ok ? '已复制' : '失败';
setTimeout(() => { btn.classList.remove('copied'); label.textContent = origText; }, 1500);
});
});
}
function toggleReviewExpand(id) {
const textEl = document.querySelector(`.sgis-review-text[data-review-id="${id}"]`);
if (!textEl) return;
if (SGIS.reviewsExpanded.has(id)) {
SGIS.reviewsExpanded.delete(id);
textEl.classList.remove('expanded');
} else {
SGIS.reviewsExpanded.add(id);
textEl.classList.add('expanded');
}
}
// ==================== v2.8.0: 跨区送礼推荐 ====================
// Steam 跨区送礼限制区域(不能向其他区域送礼的区域)
// 参考 steam-gift-checker: 俄罗斯/CIS 区域有送礼限制
const GIFT_RESTRICTED_REGIONS = new Set(['RU']); // 俄罗斯不能向其他区域送礼
// Steam 跨区送礼价格差异阈值(参考 steam-gift-checker: Math.abs(percentage) <= 15)
const GIFT_PRICE_DIFF_THRESHOLD = 15;
// 计算跨区送礼推荐方案
// 核心逻辑(参考 steam-gift-checker/content.js 的 calculatePriceDifference):
// percentage = (recipientCny - senderCny) / senderCny * 100
// canGift = percentage > 0 && percentage <= 15 (送礼区更便宜且差价在15%以内)
function calculateGiftRecommendation(pricesData) {
if (!pricesData || !pricesData.prices || !pricesData.prices.length) return null;
const num = (v) => { const n = Number(v); return isNaN(n) ? 0 : n; };
const userRegion = (document.cookie.match(/steamCountry=(\w{2})/) || [])[1] || 'CN';
// 找到收礼区(用户当前区域)的价格
const recipientData = pricesData.prices.find(p => p.region === userRegion);
if (!recipientData) {
// 用户区域价格不在数据中,无法计算
return { error: 'no-recipient', userRegion, message: '当前区域价格数据缺失,无法计算送礼推荐' };
}
// 计算 CNY 价格
const recipientCny = recipientData.currency === 'CNY'
? num(recipientData.price)
: (SGIS.rateReady ? num(toCNY(num(recipientData.price), recipientData.currency)) : null);
if (recipientCny == null || recipientCny <= 0) {
return { error: 'no-rate', userRegion, message: '汇率未就绪,无法换算送礼推荐' };
}
// 对每个可送礼区域计算差价
const candidates = [];
for (const p of pricesData.prices) {
// 跳过收礼区自身
if (p.region === userRegion) continue;
// 跳过受限区域(不能向其他区域送礼)
if (GIFT_RESTRICTED_REGIONS.has(p.region)) continue;
const senderCny = p.currency === 'CNY'
? num(p.price)
: (SGIS.rateReady ? num(toCNY(num(p.price), p.currency)) : null);
if (senderCny == null || senderCny <= 0) continue;
// 只考虑送礼区价格低于收礼区的情况(礼物只能从低价区送往高价区)
if (senderCny >= recipientCny) continue;
// 计算差价和百分比(参考 gift-checker: percentage = (recipient - sender) / sender * 100)
const savings = recipientCny - senderCny;
const savingsPercent = (savings / senderCny) * 100;
const withinRule = savingsPercent <= GIFT_PRICE_DIFF_THRESHOLD;
const regionInfo = REGIONS.find(r => r.code === p.region) || { name: p.region, code: p.region };
candidates.push({
region: p.region,
regionName: regionInfo.name,
senderCny: num(senderCny),
recipientCny: num(recipientCny),
savings: num(savings),
savingsPercent: num(savingsPercent),
withinRule: withinRule,
senderPrice: num(p.price),
senderCurrency: p.currency,
senderDiscount: num(p.discount),
canGift: withinRule, // 在15%规则内可以赠送
});
}
if (!candidates.length) {
return { error: 'no-candidates', userRegion, message: '没有找到可送礼的区域(所有区域价格均不低于本区或为受限区域)' };
}
// 排序:优先可赠送的(withinRule=true),然后按节约金额降序
candidates.sort((a, b) => {
if (a.withinRule !== b.withinRule) return a.withinRule ? -1 : 1;
return b.savings - a.savings;
});
const best = candidates[0];
const alternatives = candidates.slice(1, 4); // 备选方案最多3个
return {
best: best,
alternatives: alternatives,
userRegion: userRegion,
userRegionName: (REGIONS.find(r => r.code === userRegion) || { name: userRegion }).name,
recipientCny: num(recipientCny),
allCandidates: candidates.length,
timestamp: Date.now(),
};
}
// 渲染送礼推荐卡片 HTML
function renderGiftRecommendation(giftData) {
if (!giftData || giftData.error) {
const msg = giftData?.message || '无法计算跨区送礼推荐';
return `
${SGIS_ICONS.gift} 跨区送礼推荐
${msg}
`;
}
const num = (v) => { const n = Number(v); return isNaN(n) ? 0 : n; };
const fmt = (v) => num(v).toFixed(2);
const best = giftData.best;
const ruleText = best.withinRule
? '✓ 符合Steam送礼规则(价差≤15%)'
: `⚠ 价差${best.savingsPercent.toFixed(1)}%超过15%限制,可能无法赠送`;
const altText = giftData.alternatives.length
? `其他备选: ${giftData.alternatives.map(a =>
`${a.regionName} (省¥${fmt(a.savings)}, ${a.withinRule ? '可送' : '超限'})`
).join(' · ')}
`
: '';
return `
${SGIS_ICONS.gift} 跨区送礼推荐
${best.regionName} → ${giftData.userRegionName} 赠送最划算
¥${fmt(best.savings)}
节约 ${best.savingsPercent.toFixed(1)}%
送礼区(${best.regionName})
¥${fmt(best.senderCny)}${best.senderDiscount > 0 ? ` (-${best.senderDiscount}%)` : ''}
收礼区(${giftData.userRegionName})
¥${fmt(best.recipientCny)}
${ruleText}
⚠ 注意事项:Steam跨区送礼政策可能随时调整,请确认当前政策。部分区域有送礼限制(如俄罗斯/CIS区域不能向其他区域送礼)。礼物只能从低价区送往高价区,且价格差异需在15%以内。
${altText}
`;
}
// 将送礼推荐追加到价格标签页 body
function appendGiftRecommendationToBody() {
if (!SGIS.giftRec && !SGIS.prices) return;
const bodyEl = document.getElementById('sgis-body');
if (!bodyEl || SGIS.tab !== 'prices') return;
// 如果还没计算过,基于已有价格数据计算
if (!SGIS.giftRec && SGIS.prices) {
SGIS.giftRec = calculateGiftRecommendation(SGIS.prices);
}
if (!SGIS.giftRec) return;
// 移除旧的推荐卡片
const oldCard = bodyEl.querySelector('.sgis-gift-card');
if (oldCard) oldCard.remove();
// 追加新卡片
const temp = document.createElement('div');
temp.innerHTML = renderGiftRecommendation(SGIS.giftRec);
while (temp.firstChild) bodyEl.appendChild(temp.firstChild);
}
// ==================== v2.8.0: CheapShark 历史价格回退 ====================
// 价格历史数据回退链:ITAD → CheapShark → AugmentedSteam
async function fetchHistoryPricesCheapShark(appId, reason) {
try {
// CheapShark API: 按 steamAppID 查询
const url = `https://api.cheapshark.com/api/1.0/games?id=${appId}`;
const data = await fetchJson(url, { timeout: 10000 });
if (!data || !data.deals || !data.deals.length) {
return await fetchHistoryPricesFallback(appId, reason + ' / CheapShark 无数据');
}
// 解析 deals 中的历史价格
const history = data.deals.map(d => ({
price: Number(d.price) || 0,
regular: Number(d.retailPrice) || 0,
currency: 'USD',
cut: d.savings ? Math.round(Number(d.savings)) : 0,
store: d.storeName || '',
date: d.lastChange ? new Date(Number(d.lastChange) * 1000).toISOString() : '',
})).filter(h => h.price > 0).sort((a, b) => new Date(b.date) - new Date(a.date)).slice(0, 20);
// 找到史低
let lowest = null;
if (history.length) {
const minPrice = Math.min(...history.map(h => h.price));
lowest = history.find(h => h.price === minPrice);
if (lowest) lowest = { price: lowest.price, currency: 'USD', store: lowest.store, date: lowest.date, cut: lowest.cut };
}
const result = {
history,
discounts: history.filter(h => h.cut > 0).sort((a, b) => b.cut - a.cut),
lowest,
releaseDate: null,
source: 'cheapshark',
error: reason,
};
cacheSet('historyPrices_' + appId, result, CACHE_TTL.historyPrices);
return result;
} catch (e) {
return await fetchHistoryPricesFallback(appId, reason + ' / CheapShark: ' + e.message);
}
}
initSidebar();
})();
// ==================== 启动 ====================
async function init() {
// v2.9.15: 启动时一次性把 IDB 缓存加载到内存(异步,非阻塞)
try {
await sglvIDB.loadAll();
// 缓存 schema 版本检查:旧版本自动失效
const upgraded = ensureCacheVersions();
if (Object.keys(upgraded).length) {
console.log('[SGLV] 缓存 schema 已升级:', upgraded);
// 失效对应 cache(举例:bundle_db 升级时清空旧 GM v2 缓存)
if (upgraded.bundle_db) {
try { GM_setValue(BUNDLE_DB_CACHE_KEY_V2, null); } catch (e) {}
}
}
// 从老 GM 大对象一次性迁移到 IDB
migrateLegacyGmKeysToIDB();
// v2.9.50: 同步从 IDB hydrate PCC 持久化计算缓存到 _mem,让 getSync 立即可命中
// v2.9.73: _PCC_CACHE 定义在 SGLV 子闭包内,通过 SGLV_API 桥接调用(修复 ReferenceError)
if (SGLV_API.hydratePccCache) SGLV_API.hydratePccCache();
} catch (e) {
console.warn('[SGLV] IDB 初始化失败,降级使用 GM_setValue:', e);
}
SGLV_API.initUI();
SGLV_API.autoScan();
console.log('[Steam 游戏库展示] UI 初始化完成');
// v2.4.0: 注册卸载清理,释放事件监听器/MutationObserver,避免内存泄漏
window.addEventListener('pagehide', runDisposers);
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();