// ==UserScript== // @name Steam 创意工坊增强 // @name:en Steam Workshop Enhancer // @namespace steam-workshop-enhancer // @version 1.1.0 // @description 为新版 Steam 创意工坊补回被改版削掉的信息与批量能力。浏览页:卡片显示订阅数/收藏数/浏览量/文件大小/更新时间、已订阅角标、高级排序(订阅数/大小/更新时间/浏览量)、每页条数 30→200、预览图原图升级、批量勾选订阅/退订。详情页:生命周期订阅数/累计收藏/浏览量/精确时间/完整标签云。艺术作品页(/app/{id}/images/ 与 /screenshots/):卡片显示浏览量/收藏/大小/更新时间、一键下载原图(去尺寸限制,实测 280KB→707KB,完全原图可达 1908×2808)、批量勾选批量下载、复制本页作品链接。核心数据走 Steam 免密钥公开接口 ISteamRemoteStorage/GetPublishedFileDetails(单批 200 条、CORS 直连),不依赖任何私有接口。 // @description:en Restores the information density and bulk tooling that Valve's Workshop redesign removed. Browse page: per-card subscriber/favorite/view counts, file size, update date, subscribed badges, advanced client-side sorting (subscribers/size/updated/views), per-page 30→200, full-resolution previews, multi-select batch subscribe/unsubscribe. Detail page: lifetime subscriptions, cumulative favorites, views, exact timestamps, full tag cloud. Artwork page (/app/{id}/images/ and /screenshots/): per-card views/favorites/size/update date, one-click original-image download (size cap removed; measured 280KB→707KB, full original up to 1908×2808), multi-select batch download, copy all artwork links on the page. Data comes from Steam's keyless public endpoint ISteamRemoteStorage/GetPublishedFileDetails (200 per batch, CORS-friendly) — no private APIs. // @author SmallRob // @match https://steamcommunity.com/workshop/* // @match https://steamcommunity.com/sharedfiles/* // @match https://steamcommunity.com/my/myworkshopfiles* // @match https://steamcommunity.com/profiles/*/myworkshopfiles* // @match https://steamcommunity.com/app/*/images* // @match https://steamcommunity.com/app/*/screenshots* // @grant GM_getValue // @grant GM_setValue // @grant GM_deleteValue // @grant GM_xmlhttpRequest // @grant GM_addStyle // @grant GM_info // @grant GM_setClipboard // @grant GM_download // @grant unsafeWindow // @connect api.steampowered.com // @connect steamcommunity.com // @connect images.steamusercontent.com // @run-at document-idle // @license MIT // @tag Steam // @tag workshop // @icon data:image/svg+xml, // ==/UserScript== /** * Steam 创意工坊增强 v1.0.0 * * 设计依据:docs/workshop-enhancer-design.md(2026-10-27 实测逆向新版创意工坊) * * 核心事实(实测): * 1. 新版浏览页 /workshop/browse/ 是 React SPA,CSS 类名哈希化(UNowfeldbNg- 之类), * **绝不能靠类名定位**。本脚本只用稳定语义锚点:href 模式 / role / aria / SVG 语义类。 * 2. 条目详情页 /sharedfiles/filedetails/ 仍是经典服务端渲染 HTML,类名稳定。 * 3. ISteamRemoteStorage/GetPublishedFileDetails/v1 免 API Key、POST、CORS 反射 Origin, * 单批可取 200 条,返回订阅数/收藏数/浏览量/文件大小/时间戳/标签——远多于新版 UI 显示。 * 4. 旧 URL 参数体系完整保留(browsesort / section / num_per_page / days / requiredtags[] / * searchtext / p),因此「排序 / 每页条数 / 标签筛选」可直接改 URL 实现,不碰私有接口。 * * 纯逻辑(parse / sort / format / query 构造)与 DOM 增强严格分离, * 纯逻辑通过 window.__SWE_TEST_HOOKS__ 暴露给单测。 */ (function (global) { 'use strict'; const VERSION = '1.1.0'; const PREFIX = 'swe'; const API_URL = 'https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/'; // 批量上限实测 200 稳定,再大未验证,故取 200 const API_BATCH_MAX = 200; // 卡片数据增强的合并窗口:攒够 8 张或等 200ms 就发一次,避免每张卡一个请求 const CARD_BATCH_SIZE = 8; const CARD_BATCH_WAIT_MS = 200; // 详情缓存 TTL const CACHE_TTL_MS = 6 * 60 * 60 * 1000; // 「最近更新」高亮阈值(天) const RECENT_DAYS = 7; // ============================================================ // 第 1 层:纯逻辑(可单测,不触碰 DOM / GM API) // ============================================================ /** * 从 filedetails 链接里取条目 id。 * 兼容 ?id=123 与 /filedetails/?id=123&... 两种形态。 */ function parseFileIdFromHref(href) { if (!href || typeof href !== 'string') return ''; const m = href.match(/[?&]id=(\d+)/); return m ? m[1] : ''; } /** * 从条目链接里取 appid。 * 形如 /sharedfiles/filedetails/?id=123 本身不含 appid, * 但 /myworkshopfiles/?appid=440 这类创作者链接含,故一并支持。 */ function parseAppIdFromHref(href) { if (!href || typeof href !== 'string') return ''; const m = href.match(/[?&]appid=(\d+)/); return m ? m[1] : ''; } /** * 解析卡片里的星级评分。 * 新版 UI 用 5 个 SVG 表示:SVGIcon_Star_Filled(实心)/ SVGIcon_Star_Unfilled(空心)。 * 半星用含 Half 的类名(若 Valve 后续启用)。 * @returns {{filled:number, half:boolean, max:number, score:number}} score 为 0..max */ function parseStarRating(cardEl) { const res = { filled: 0, half: false, max: 0, score: 0 }; if (!cardEl || typeof cardEl.querySelectorAll !== 'function') return res; const svgs = cardEl.querySelectorAll('svg[class*="Star"]'); if (!svgs.length) return res; let filled = 0, half = false; for (const svg of svgs) { const cls = (svg.getAttribute('class') || ''); if (/Star_Half/i.test(cls)) half = true; else if (/Star_Filled/i.test(cls)) filled++; } res.filled = filled; res.half = half; res.max = svgs.length; res.score = filled + (half ? 0.5 : 0); return res; } /** * 构造 GetPublishedFileDetails 的 POST body。 * @param {string[]} ids */ function buildDetailQuery(ids) { const list = (ids || []).filter(Boolean).map(String); if (!list.length) return ''; const parts = ['itemcount=' + list.length]; list.forEach((id, i) => parts.push('publishedfileids[' + i + ']=' + encodeURIComponent(id))); return parts.join('&'); } /** * 解析 GetPublishedFileDetails 响应,取出精简条目对象。 * @returns {Object} 以 id 为键 */ function parseDetailsResponse(json) { const out = {}; if (!json || typeof json !== 'object') return out; const root = json.response || json; const list = root.publishedfiledetails || root.publishedfiledetails || []; if (!Array.isArray(list)) return out; for (const d of list) { if (!d || !d.publishedfileid) continue; out[String(d.publishedfileid)] = { id: String(d.publishedfileid), title: d.title || '', description: d.description || '', creator: d.creator || '', appid: String(d.consumer_app_id || d.creator_app_id || ''), fileSize: toNum(d.file_size), previewUrl: d.preview_url || '', timeCreated: toNum(d.time_created), timeUpdated: toNum(d.time_updated), subscriptions: toNum(d.subscriptions), favorited: toNum(d.favorited), lifetimeSubscriptions: toNum(d.lifetime_subscriptions), lifetimeFavorited: toNum(d.lifetime_favorited), views: toNum(d.views), banned: toNum(d.banned), tags: Array.isArray(d.tags) ? d.tags.map(t => (typeof t === 'string' ? t : t && t.tag)).filter(Boolean) : [] }; } return out; } function toNum(v) { const n = Number(v); return Number.isFinite(n) ? n : 0; } /** * 合并缓存与新拉取的数据:新数据优先,但保留缓存里的额外字段。 */ function mergeDetails(cached, fresh) { const out = Object.assign({}, cached || {}); for (const k of Object.keys(fresh || {})) { out[k] = Object.assign({}, out[k] || {}, fresh[k]); } return out; } /** * 找出缓存中已过期的 id。 * @param {Object} cache {id: {..., fetchedAt:number}} * @param {number} now * @param {number} ttl */ function pickStaleIds(cache, now, ttl) { const stale = []; for (const id of Object.keys(cache || {})) { const rec = cache[id]; if (!rec) { stale.push(id); continue; } const at = toNum(rec.fetchedAt); if (!at || (now - at) > ttl) stale.push(id); } return stale; } /** * 高级排序。对当前页内的条目按指定指标重排。 * @param {Array<{id:string, detail:object|null}>} entries * @param {string} key subs|size|updated|views|title * @param {boolean} desc */ function sortEntries(entries, key, desc) { const arr = (entries || []).slice(); const dir = desc === false ? 1 : -1; const val = (e) => { const d = e.detail || {}; switch (key) { case 'subs': return toNum(d.subscriptions) + toNum(d.lifetimeSubscriptions); case 'size': return toNum(d.fileSize); case 'updated': return toNum(d.timeUpdated); case 'views': return toNum(d.views); case 'fav': return toNum(d.favorited) + toNum(d.lifetimeFavorited); case 'title': return String(d.title || e.title || ''); default: return 0; } }; arr.sort((a, b) => { const va = val(a), vb = val(b); if (va === vb) return String(a.id).localeCompare(String(b.id)); return (va > vb ? 1 : -1) * dir; }); return arr; } /** 字节数人类可读 */ function formatBytes(n) { const v = toNum(n); if (v <= 0) return '—'; const units = ['B', 'KB', 'MB', 'GB', 'TB']; let i = 0, x = v; while (x >= 1024 && i < units.length - 1) { x /= 1024; i++; } return (i === 0 ? String(Math.round(x)) : x.toFixed(x < 10 ? 2 : 1)) + ' ' + units[i]; } /** 大数字缩写:1234 → 1.2k */ function formatCount(n) { const v = toNum(n); if (v < 1000) return String(Math.round(v)); if (v < 1000000) return (v / 1000).toFixed(v < 10000 ? 1 : 0).replace(/\.0$/, '') + 'k'; return (v / 1000000).toFixed(1).replace(/\.0$/, '') + 'M'; } /** 时间戳 → YYYY-MM-DD HH:mm(本地时区) */ function formatDateTime(ts) { const v = toNum(ts); if (!v) return '—'; const d = new Date(v * 1000); const p = (n) => String(n).padStart(2, '0'); return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes()); } /** 相对时间:3 天前 / 2 个月前 */ function formatRelative(ts, now) { const v = toNum(ts); if (!v) return '—'; const cur = toNum(now) || Math.floor(Date.now() / 1000); let diff = cur - v; if (diff < 0) diff = 0; const day = 86400; if (diff < 3600) return Math.max(1, Math.round(diff / 60)) + ' 分钟前'; if (diff < day) return Math.round(diff / 3600) + ' 小时前'; if (diff < 30 * day) return Math.round(diff / day) + ' 天前'; if (diff < 365 * day) return Math.round(diff / (30 * day)) + ' 个月前'; return Math.round(diff / (365 * day)) + ' 年前'; } /** 是否在最近 N 天内更新过 */ function isRecentlyUpdated(ts, now, days) { const v = toNum(ts); if (!v) return false; const cur = toNum(now) || Math.floor(Date.now() / 1000); return (cur - v) <= (days || RECENT_DAYS) * 86400; } /** * 把 Steam 预览图 URL 换成更高分辨率版本。 * * 只去掉尺寸限制参数 imw / imh,保留 ima / impolicy / imcolor / letterbox 的 * 裁剪策略(保留裁剪策略可避免方图容器里出现变形或留白差异)。 * * ⚠️ 必须用 URL 解析,不能用正则硬删参数: * 正则删 `?ima=fit` 会把 `?` 一起吃掉,剩余的 `&imcolor=...` 就粘进了 pathname, * 变成 `.../HASH/&imcolor=%23000000` → 实测 **404** → 浏览器显示破图占位。 * (2026-10-28 实测:原 322px 缩略 35KB → 去尺寸后 156KB;错误形态 404) */ function upgradePreviewUrl(url) { if (!url || typeof url !== 'string') return ''; const q = url.indexOf('?'); if (q === -1) return url; const base = url.slice(0, q); const query = url.slice(q + 1); const kept = query.split('&').filter((pair) => { if (!pair) return false; const key = pair.split('=')[0].toLowerCase(); return key !== 'imw' && key !== 'imh'; }); return kept.length ? base + '?' + kept.join('&') : base; } /** * 构造带标签筛选的浏览页 URL。 */ function buildBrowseUrl(base, appid, tag) { const u = new URL(base); if (appid) u.searchParams.set('appid', String(appid)); if (tag) { u.searchParams.delete('requiredtags[]'); u.searchParams.append('requiredtags[]', String(tag)); } return u.toString(); } /** 解析 URL 上的分页 / 排序参数 */ function readBrowseParams(url) { const out = { appid: '', p: 1, numPerPage: 30, browsesort: '', section: '', days: '', searchtext: '' }; try { const u = new URL(url, 'https://steamcommunity.com'); out.appid = u.searchParams.get('appid') || ''; out.p = toNum(u.searchParams.get('p')) || 1; out.numPerPage = toNum(u.searchParams.get('num_per_page')) || 30; out.browsesort = u.searchParams.get('browsesort') || ''; out.section = u.searchParams.get('section') || ''; out.days = u.searchParams.get('days') || ''; out.searchtext = u.searchParams.get('searchtext') || ''; } catch (_) {} return out; } /** 分页条数候选 */ const PAGE_SIZES = [30, 50, 100, 200]; /** 高级排序选项 */ const SORT_OPTIONS = [ { key: 'native', label: 'Steam 默认' }, { key: 'subs', label: '订阅数' }, { key: 'fav', label: '收藏数' }, { key: 'views', label: '浏览量' }, { key: 'size', label: '文件大小' }, { key: 'updated', label: '最近更新' } ]; // ============================================================ // 第 2 层:GM 存储封装(可降级到 localStorage) // ============================================================ const gmGet = (typeof GM_getValue === 'function') ? GM_getValue : null; const gmSet = (typeof GM_setValue === 'function') ? GM_setValue : null; const gmDel = (typeof GM_deleteValue === 'function') ? GM_deleteValue : null; function storeGet(key, fallback) { try { if (gmGet) return gmGet(key, fallback); } catch (_) {} try { const raw = global.localStorage && global.localStorage.getItem(key); return raw == null ? fallback : JSON.parse(raw); } catch (_) {} return fallback; } function storeSet(key, value) { try { if (gmSet) { gmSet(key, value); return true; } } catch (_) {} try { if (global.localStorage) global.localStorage.setItem(key, JSON.stringify(value)); return true; } catch (_) {} return false; } function storeDel(key) { try { if (gmDel) { gmDel(key); return true; } } catch (_) {} try { if (global.localStorage) global.localStorage.removeItem(key); return true; } catch (_) {} return false; } // ============================================================ // 第 3 层:详情缓存 // ============================================================ const CACHE_KEY = PREFIX + ':details'; const SUBS_KEY = PREFIX + ':subscribed'; const FAV_KEY = PREFIX + ':favorites'; const SETTINGS_KEY = PREFIX + ':settings'; let detailCache = null; // { id: {...fields, fetchedAt} } let subscribedSet = null; // { id: true } let favoritedSet = null; // { id: true } const failedIds = new Set(); // 负缓存:本次会话内不再重试 function loadCache() { if (detailCache) return detailCache; detailCache = storeGet(CACHE_KEY, {}) || {}; return detailCache; } function saveCache() { storeSet(CACHE_KEY, detailCache || {}); } function loadSubscribed() { if (subscribedSet) return subscribedSet; subscribedSet = storeGet(SUBS_KEY, {}) || {}; return subscribedSet; } function loadFavorited() { if (favoritedSet) return favoritedSet; favoritedSet = storeGet(FAV_KEY, {}) || {}; return favoritedSet; } const DEFAULT_SETTINGS = { showCardStats: true, showSubscribedBadge: true, showRecentBadge: true, upgradePreview: true, perPage: 0, // 0 = 沿用 Steam 默认 sortKey: 'native', sortDesc: true, batchMode: false, artBatchMode: false // 艺术作品页批量下载模式 }; function loadSettings() { return Object.assign({}, DEFAULT_SETTINGS, storeGet(SETTINGS_KEY, {}) || {}); } function saveSettings(patch) { const s = Object.assign(loadSettings(), patch || {}); storeSet(SETTINGS_KEY, s); return s; } // ============================================================ // 第 4 层:网络层 // ============================================================ /** * 用 fetch 直连 Steam 公开接口。 * CORS 已验证:Origin: https://steamcommunity.com → access-control-allow-origin 反射。 * * 抛出的错误带 status,便于区分「限流/临时故障」与「条目确实不存在」。 */ async function fetchDetailsRaw(ids) { const body = buildDetailQuery(ids); if (!body) return {}; const res = await global.fetch(API_URL, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }, body, credentials: 'omit', cache: 'no-store' }); if (!res.ok) { const err = new Error('HTTP ' + res.status); err.status = res.status; // 429 / 5xx 是限流或临时故障 —— 属「可重试」,不该把 id 拉黑 err.transient = res.status === 429 || res.status >= 500; throw err; } const json = await res.json(); return parseDetailsResponse(json); } /** GM_xmlhttpRequest 兜底(万一某天 CORS 收紧) */ function fetchDetailsViaGm(ids) { return new Promise((resolve, reject) => { if (typeof GM_xmlhttpRequest !== 'function') { reject(new Error('no gm')); return; } GM_xmlhttpRequest({ method: 'POST', url: API_URL, headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }, data: buildDetailQuery(ids), onload: (r) => { try { resolve(parseDetailsResponse(JSON.parse(r.responseText))); } catch (e) { reject(e); } }, onerror: (e) => reject(e || new Error('gm xhr failed')) }); }); } /** * 批量取详情:先吃缓存,只对过期/缺失的 id 发请求。 * @param {string[]} ids * @param {{force?:boolean}} opts */ async function fetchDetails(ids, opts) { const cache = loadCache(); const now = Date.now(); const unique = []; const seen = new Set(); for (const raw of ids || []) { const id = String(raw || ''); if (!id || seen.has(id)) continue; seen.add(id); unique.push(id); } if (!unique.length) return {}; const force = !!(opts && opts.force); const need = []; const out = {}; for (const id of unique) { if (failedIds.has(id)) continue; const rec = cache[id]; const fresh = rec && (now - toNum(rec.fetchedAt)) < CACHE_TTL_MS; if (!force && fresh) { out[id] = rec; continue; } need.push(id); } if (!need.length) return out; // 分批(每批 ≤200) const batches = []; for (let i = 0; i < need.length; i += API_BATCH_MAX) { batches.push(need.slice(i, i + API_BATCH_MAX)); } for (const batch of batches) { let fresh = null; let transient = false; try { fresh = await fetchDetailsRaw(batch); } catch (e) { transient = !!(e && e.transient); try { fresh = await fetchDetailsViaGm(batch); } catch (e2) { if (transient) { // 限流 / 5xx:临时故障,不拉黑 id —— 留待下次访问重试 // (实测 Steam 接口密集访问会返回 502,若拉黑会导致整页永远无数据) continue; } // 确定性失败:记负缓存,避免死循环重试 for (const id of batch) failedIds.add(id); continue; } } const merged = mergeDetails({}, fresh); for (const id of Object.keys(merged)) { merged[id].fetchedAt = now; cache[id] = merged[id]; out[id] = merged[id]; } // 该批里 API 没返回的 id 视为失效(条目不存在) for (const id of batch) { if (!merged[id]) { failedIds.add(id); delete cache[id]; } } } saveCache(); return out; } // ============================================================ // 第 5 层:DOM 定位(只认稳定语义锚点) // ============================================================ const SEL_FILE_LINK = 'a[href*="/sharedfiles/filedetails/?id="]'; const SEL_STAR = 'svg[class*="Star"]'; const SEL_MAG = 'svg.SVGIcon_MagnifyingGlass'; const SEL_CREATOR = 'a[href*="/myworkshopfiles/"]'; /** 找到条目卡片容器:同时包含 filedetails 链接 + 放大镜 SVG 的最近祖先 */ function findCardContainer(anchor) { let node = anchor; for (let i = 0; i < 8 && node && node.parentElement; i++) { node = node.parentElement; if (!node || !node.querySelector) break; // 真正的卡片容器同时具备:缩略图(/ugc/)+ 放大镜按钮 // 只含放大镜的祖先是「信息区」,不是卡片本身 —— 必须排除, // 否则数据条与角标会挂到信息区里而不是整张卡片上。 if (node.querySelector(SEL_MAG) && node.querySelector('img[src*="/ugc/"]')) return node; } // 退化:只含缩略图的最近祖先 node = anchor; for (let i = 0; i < 8 && node && node.parentElement; i++) { node = node.parentElement; if (!node || !node.querySelector) break; if (node.querySelector('img[src*="/ugc/"]')) return node; } return anchor.closest ? (anchor.closest('div') || anchor) : anchor; } /** * 扫描页面上的所有工坊卡片。 * @returns {Map} */ function scanCards(root) { const scope = root || global.document; const map = new Map(); if (!scope || !scope.querySelectorAll) return map; const anchors = scope.querySelectorAll(SEL_FILE_LINK); for (const a of anchors) { const href = a.getAttribute('href') || ''; const id = parseFileIdFromHref(href); if (!id || map.has(id)) continue; const container = findCardContainer(a); if (!container) continue; // 标题链接 = 卡片内不含 的 filedetails 链接;图片链接 = 含 的那个 let titleLink = null, imgLink = null; container.querySelectorAll(SEL_FILE_LINK).forEach((l) => { if (l.querySelector('img')) { if (!imgLink) imgLink = l; } else if (!titleLink) titleLink = l; }); if (!titleLink) titleLink = a; if (!imgLink) imgLink = a; const creatorLink = container.querySelector(SEL_CREATOR); const img = container.querySelector('img[src*="/ugc/"]'); map.set(id, { id, container, titleLink, imgLink, creatorLink, img, rating: parseStarRating(container), title: (titleLink && titleLink.textContent || '').trim() }); } return map; } /** 收集页面上所有条目 id */ function collectIds(root) { const ids = []; for (const id of scanCards(root).keys()) ids.push(id); return ids; } // ============================================================ // 第 6 层:UI 样式 // ============================================================ const CSS = ` .swe-card-stats{display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin-top:6px; font-size:11px;line-height:16px;color:#8f98a0;font-weight:400} .swe-card-stats .swe-stat{white-space:nowrap;display:inline-flex;align-items:center;gap:3px} .swe-card-stats .swe-stat b{color:#c7d5e0;font-weight:600} .swe-card-stats .swe-stat.swe-hot b{color:#f5a623} .swe-card-stats .swe-stat.swe-fresh b{color:#5cba6f} .swe-card-stats .swe-loading{color:#4a5a68;font-style:italic} .swe-badge-subscribed{position:absolute;top:6px;right:6px;z-index:5; background:rgba(28,185,120,.92);color:#04240f;font-size:10px;font-weight:700; padding:2px 6px;border-radius:3px;letter-spacing:.3px;pointer-events:none; box-shadow:0 1px 4px rgba(0,0,0,.4)} .swe-badge-recent{position:absolute;top:6px;left:6px;z-index:5; background:rgba(245,166,35,.92);color:#3a2500;font-size:10px;font-weight:700; padding:2px 6px;border-radius:3px;pointer-events:none;box-shadow:0 1px 4px rgba(0,0,0,.4)} .swe-pick{position:absolute;top:6px;left:6px;z-index:6;width:20px;height:20px; display:none;align-items:center;justify-content:center;cursor:pointer; background:rgba(10,16,24,.82);border:1px solid #66c0f4;border-radius:3px} .swe-batch-on .swe-pick{display:flex} .swe-pick input{width:14px;height:14px;margin:0;cursor:pointer;accent-color:#66c0f4} .swe-pick.on{background:rgba(102,192,244,.9)} .swe-pick.on input{accent-color:#04202e} .swe-toolbar{position:fixed;right:16px;bottom:16px;z-index:2147483000; display:flex;flex-direction:column;gap:8px;align-items:flex-end; font-family:'Motiva Sans','Noto Sans SC',Arial,sans-serif} .swe-panel{background:rgba(27,40,56,.96);border:1px solid #3d5a73;border-radius:6px; padding:10px 12px;box-shadow:0 6px 24px rgba(0,0,0,.5);color:#c7d5e0;font-size:12px; display:flex;flex-direction:column;gap:8px;min-width:212px} .swe-panel h4{margin:0;font-size:12px;color:#66c0f4;font-weight:600;letter-spacing:.4px} .swe-row{display:flex;gap:6px;align-items:center;justify-content:space-between} .swe-panel select,.swe-panel button{background:#1b2838;color:#c7d5e0;border:1px solid #3d5a73; border-radius:3px;padding:4px 8px;font-size:12px;cursor:pointer;font-family:inherit} .swe-panel button:hover{border-color:#66c0f4;color:#fff} .swe-panel button.swe-primary{background:linear-gradient(to bottom,#799905 5%,#536904 95%); color:#fff;border-color:#6a8b04;font-weight:600} .swe-panel button.swe-danger{background:linear-gradient(to bottom,#a44141 5%,#803030 95%); color:#fff;border-color:#803030;font-weight:600} .swe-panel button:disabled{opacity:.45;cursor:not-allowed} .swe-panel .swe-hint{color:#7b8b9a;font-size:11px;line-height:1.5} .swe-toolbar-toggle{width:38px;height:38px;border-radius:50%;border:1px solid #3d5a73; background:rgba(27,40,56,.96);color:#66c0f4;font-size:18px;cursor:pointer; box-shadow:0 4px 16px rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center} .swe-toolbar-toggle:hover{border-color:#66c0f4} .swe-detail-panel{background:rgba(27,40,56,.96);border:1px solid #3d5a73;border-radius:6px; padding:12px 14px;margin:10px 0;color:#c7d5e0;font-size:13px; font-family:'Motiva Sans','Noto Sans SC',Arial,sans-serif} .swe-detail-panel h4{margin:0 0 8px;color:#66c0f4;font-size:13px} .swe-detail-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(118px,1fr));gap:8px 14px} .swe-detail-grid div{display:flex;flex-direction:column} .swe-detail-grid .k{color:#7b8b9a;font-size:11px} .swe-detail-grid .v{color:#e6f0f7;font-size:14px;font-weight:600} .swe-tags{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px} .swe-tag{background:#22384c;border:1px solid #3d5a73;color:#9fd0f0;border-radius:3px; padding:2px 8px;font-size:11px;cursor:pointer;text-decoration:none} .swe-tag:hover{background:#2c4a63;color:#fff} .swe-detail-links{display:flex;gap:10px;margin-top:10px;flex-wrap:wrap} .swe-detail-links a{color:#66c0f4;font-size:12px;text-decoration:none} .swe-detail-links a:hover{text-decoration:underline} .swe-toast{position:fixed;left:50%;transform:translateX(-50%);bottom:22px;z-index:2147483100; background:rgba(16,26,36,.96);border:1px solid #66c0f4;color:#e6f0f7; padding:9px 18px;border-radius:5px;font-size:13px;box-shadow:0 6px 22px rgba(0,0,0,.55); font-family:'Motiva Sans','Noto Sans SC',Arial,sans-serif} .swe-status{color:#7b8b9a;font-size:11px} /* ---------- 艺术作品页 ---------- */ /* 数据条:绝对定位到图片区底部、原生 meta 行上方(位置由 JS 实测几何算出) */ .swe-art-stats{position:absolute;left:8px;max-width:calc(100% - 16px); margin:0;padding:2px 6px;border-radius:3px; background:rgba(12,20,28,.72);backdrop-filter:blur(2px); z-index:4;pointer-events:none} /* 操作按钮放在图片**右上角**——右下角是原生点赞/评分行,左上角空着 */ .swe-art-actions{position:absolute;top:8px;right:8px;z-index:6; display:flex;gap:6px;opacity:0;transition:opacity .15s ease} .apphub_Card:hover .swe-art-actions{opacity:1} .swe-art-actions button{background:rgba(16,26,36,.88);border:1px solid #66c0f4;color:#c7d5e0; border-radius:3px;padding:3px 8px;font-size:11px;cursor:pointer; font-family:'Motiva Sans','Noto Sans SC',Arial,sans-serif} .swe-art-actions button:hover{background:#66c0f4;color:#04202e} /* 批量复选框放在图片左上角(与右上角的操作按钮互不干扰) */ .swe-art-pick{position:absolute;top:8px;left:8px;z-index:7;width:20px;height:20px; display:none;align-items:center;justify-content:center;cursor:pointer; background:rgba(10,16,24,.82);border:1px solid #66c0f4;border-radius:3px} .swe-art-batch-on .swe-art-pick{display:flex} .swe-art-pick input{width:14px;height:14px;margin:0;cursor:pointer;accent-color:#66c0f4} .swe-art-pick.on{background:rgba(102,192,244,.9)} .swe-art-pick.on input{accent-color:#04202e} `; function injectCss() { try { if (typeof GM_addStyle === 'function') { GM_addStyle(CSS); return; } } catch (_) {} try { const st = global.document.createElement('style'); st.id = 'swe-style'; st.textContent = CSS; (global.document.head || global.document.documentElement).appendChild(st); } catch (_) {} } // ============================================================ // 第 7 层:浏览页增强 // ============================================================ let toolbarEl = null; let toastTimer = null; function toast(msg, ms) { try { const doc = global.document; let t = doc.querySelector('.swe-toast'); if (!t) { t = doc.createElement('div'); t.className = 'swe-toast'; doc.body.appendChild(t); } t.textContent = msg; t.style.display = 'block'; clearTimeout(toastTimer); toastTimer = setTimeout(() => { t.style.display = 'none'; }, ms || 2600); } catch (_) {} } function ensurePositioned(container) { if (!container) return; const pos = getComputedStyle(container).position; if (pos === 'static' || pos === '') container.style.position = 'relative'; } /** * 给一张卡片挂数据增强条。 */ function renderCardStats(card, detail, settings) { const c = card.container; if (!c || !c.querySelector) return; ensurePositioned(c); let bar = c.querySelector('.swe-card-stats'); if (!bar) { bar = global.document.createElement('div'); bar.className = 'swe-card-stats'; c.appendChild(bar); } if (!detail) { bar.innerHTML = '无数据'; // 关键:无论是否取到数据都要标记已处理,否则失败卡片会被无限重新入队、反复请求 c.setAttribute('data-swe-processed', '1'); return; } const now = Math.floor(Date.now() / 1000); const parts = []; parts.push('⬇ ' + formatCount(detail.subscriptions) + ' 订阅'); if (detail.favorited) parts.push('★ ' + formatCount(detail.favorited) + ' 收藏'); if (detail.views) parts.push('👁 ' + formatCount(detail.views) + ' 浏览'); if (detail.fileSize) parts.push('💾 ' + formatBytes(detail.fileSize) + ''); if (detail.timeUpdated) { const fresh = isRecentlyUpdated(detail.timeUpdated, now, RECENT_DAYS); parts.push('🕘 ' + formatRelative(detail.timeUpdated, now) + '更新'); } bar.innerHTML = parts.join(''); c.setAttribute('data-swe-processed', '1'); } /** 已订阅 / 最近更新 角标 */ function renderBadges(card, settings) { const c = card.container; if (!c || !c.querySelector) return; const d = loadCache()[card.id]; const wantSub = settings.showSubscribedBadge && !!loadSubscribed()[card.id]; const wantRecent = settings.showRecentBadge && d && isRecentlyUpdated(d.timeUpdated, Math.floor(Date.now() / 1000), RECENT_DAYS); // 幂等:只有状态变化时才动 DOM,避免每次扫描都拆装节点 const hasSub = !!c.querySelector('.swe-badge-subscribed'); const hasRecent = !!c.querySelector('.swe-badge-recent'); if (hasSub !== wantSub) { const old = c.querySelector('.swe-badge-subscribed'); if (old) old.remove(); if (wantSub) { const b = global.document.createElement('div'); b.className = 'swe-badge-subscribed'; b.textContent = '已订阅'; c.appendChild(b); } } if (hasRecent !== wantRecent) { const old = c.querySelector('.swe-badge-recent'); if (old) old.remove(); if (wantRecent) { const b = global.document.createElement('div'); b.className = 'swe-badge-recent'; b.textContent = '刚更新'; c.appendChild(b); } } } /** 预览图原图升级 */ function upgradeCardImage(card, settings) { if (!settings.upgradePreview || !card.img) return; const src = card.img.getAttribute('src') || ''; if (!src || src.indexOf('imw=') === -1) return; const up = upgradePreviewUrl(src); if (up && up !== src) { card.img.setAttribute('src', up); card.img.setAttribute('data-swe-orig', up); } } /** 复选框(批量模式) */ function injectPicker(card, onToggle) { const c = card.container; if (!c || !c.querySelector) return; if (c.querySelector('.swe-pick')) return; ensurePositioned(c); const box = global.document.createElement('label'); box.className = 'swe-pick'; box.innerHTML = ''; c.appendChild(box); box.querySelector('input').addEventListener('change', (e) => { box.classList.toggle('on', e.target.checked); onToggle(card.id, e.target.checked); }); } // ---------- 批量选择状态 ---------- const picked = new Set(); function setBatchMode(on) { saveSettings({ batchMode: !!on }); try { global.document.body.classList.toggle('swe-batch-on', !!on); } catch (_) {} if (!on) { picked.clear(); // 退出批量模式:移除已注入的复选框 global.document.querySelectorAll('.swe-pick').forEach((b) => b.remove()); } else { // 进入批量模式:给**已渲染**的卡片补注入复选框 // (已处理过的卡片不会被 MutationObserver 再次触发,必须显式补) for (const card of scanCards(global.document).values()) { injectPicker(card, onPickToggle); } } syncPickers(); updateToolbarCounts(); } function updateToolbarCounts() { const el = global.document.querySelector('.swe-count'); if (el) el.textContent = '已选 ' + picked.size + ' 项'; } // ---------- 订阅动作 ---------- function getSessionId() { // 经典页面:window.g_sessionID / SESSION_ID;cookie 兜底 const w = global; try { const p = w.unsafeWindow || w; if (p && typeof p.g_sessionID === 'string' && p.g_sessionID) return p.g_sessionID; if (p && typeof p.SESSION_ID === 'string' && p.SESSION_ID) return p.SESSION_ID; } catch (_) {} try { const m = (global.document.cookie || '').match(/sessionid=([^;]+)/); if (m) return m[1]; } catch (_) {} return ''; } /** * 发送一次订阅 / 退订请求。 * * 成败判定(2026-10-28 实测 + Steam 官方 workshop_functions.js 交叉验证): * · Steam 官方代码用 `response.success == 1` 判成功(失败走 default 分支弹错误框) * · 实测未登录/参数缺失时返回 HTTP 401 + `{"success":2}` —— **2 是失败码** * · 所以必须同时满足「HTTP 2xx」且「success == 1」才算成功。 * 只看 HTTP 状态或只看 success 值都会误判(GF 手动订阅工具就栽在这)。 * * @returns {Promise<{ok:boolean, status:number, success:number|null, body:string}>} */ async function postWorkshopAction(path, id, appid, sessionid) { const body = 'id=' + encodeURIComponent(id) + '&appid=' + encodeURIComponent(appid || '') + '&include_dependencies=false' + '&sessionid=' + encodeURIComponent(sessionid || ''); const res = await global.fetch('https://steamcommunity.com/sharedfiles/' + path, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'X-Requested-With': 'XMLHttpRequest' }, body, credentials: 'include' }); const text = await res.text(); let success = null; try { const j = JSON.parse(text); if (j && typeof j.success !== 'undefined') success = Number(j.success); } catch (_) {} return { ok: res.ok && success === 1, status: res.status, success, body: text }; } async function batchSubscribe(ids, appid, unsubscribe) { if (!ids.length) { toast('请先勾选条目'); return; } const sid = getSessionId(); if (!sid) { toast('未取到 sessionid,请先登录 Steam', 3600); return; } const path = unsubscribe ? 'unsubscribe' : 'subscribe'; toast((unsubscribe ? '正在退订 ' : '正在订阅 ') + ids.length + ' 项…', 6000); let ok = 0, fail = 0; for (const id of ids) { try { const res = await postWorkshopAction(path, id, appid, sid); if (res.ok) ok++; else fail++; } catch (e) { fail++; } await new Promise(r => setTimeout(r, 220)); // 轻限速,别打爆接口 } const set = unsubscribe ? loadFavorited() : loadSubscribed(); for (const id of ids) set[id] = !unsubscribe; storeSet(unsubscribe ? FAV_KEY : SUBS_KEY, set); toast((unsubscribe ? '退订' : '订阅') + '完成:成功 ' + ok + ',失败 ' + fail, 4000); refreshBadges(); } function refreshBadges() { const settings = loadSettings(); for (const card of scanCards(global.document).values()) { renderBadges(card, settings); } } // ---------- 工具条 ---------- function buildToolbar() { if (toolbarEl) return toolbarEl; const settings = loadSettings(); const doc = global.document; const bar = doc.createElement('div'); bar.className = 'swe-toolbar'; bar.innerHTML = '' + ''; doc.body.appendChild(bar); toolbarEl = bar; const panel = bar.querySelector('.swe-panel'); const toggle = bar.querySelector('.swe-toolbar-toggle'); toggle.addEventListener('click', () => { const open = panel.style.display === 'none'; panel.style.display = open ? 'flex' : 'none'; }); // 高级排序 const sortSel = bar.querySelector('.swe-sort'); const dirSel = bar.querySelector('.swe-dir'); sortSel.value = settings.sortKey; dirSel.value = settings.sortDesc ? 'desc' : 'asc'; const applySort = () => { saveSettings({ sortKey: sortSel.value, sortDesc: dirSel.value === 'desc' }); applyClientSort(); }; sortSel.addEventListener('change', applySort); dirSel.addEventListener('change', applySort); // 每页条数 const ppSel = bar.querySelector('.swe-pp'); ppSel.value = String(settings.perPage || 30); ppSel.addEventListener('change', () => { const n = toNum(ppSel.value) || 30; saveSettings({ perPage: n }); const u = new URL(global.location.href); u.searchParams.set('num_per_page', String(n)); global.location.href = u.toString(); }); // 批量 bar.querySelector('.swe-select-all').addEventListener('click', () => { for (const id of scanCards(global.document).keys()) picked.add(id); syncPickers(); }); bar.querySelector('.swe-invert').addEventListener('click', () => { for (const id of scanCards(global.document).keys()) { if (picked.has(id)) picked.delete(id); else picked.add(id); } syncPickers(); }); bar.querySelector('.swe-clear').addEventListener('click', () => { picked.clear(); syncPickers(); }); bar.querySelector('.swe-sub').addEventListener('click', () => { batchSubscribe(Array.from(picked), currentAppId(), false); }); bar.querySelector('.swe-unsub').addEventListener('click', () => { batchSubscribe(Array.from(picked), currentAppId(), true); }); const bt = bar.querySelector('.swe-batch-toggle'); bt.addEventListener('click', () => { const on = !loadSettings().batchMode; setBatchMode(on); bt.textContent = on ? '退出批量' : '批量模式'; }); bar.querySelector('.swe-preview').addEventListener('click', () => { const on = !loadSettings().upgradePreview; saveSettings({ upgradePreview: on }); toast(on ? '已切换为原图预览(刷新后完全生效)' : '已恢复默认预览图(刷新后完全生效)', 3200); }); if (settings.batchMode) setBatchMode(true); bt.textContent = settings.batchMode ? '退出批量' : '批量模式'; return bar; } function syncPickers() { global.document.querySelectorAll('.swe-pick').forEach((box) => { const inp = box.querySelector('input'); if (!inp) return; const card = box.parentElement; const link = card && card.querySelector(SEL_FILE_LINK); const id = link ? parseFileIdFromHref(link.getAttribute('href')) : ''; const on = !!id && picked.has(id); inp.checked = on; box.classList.toggle('on', on); }); updateToolbarCounts(); } function currentAppId() { const p = readBrowseParams(global.location.href); if (p.appid) return p.appid; // 从「我的项目」页面的 appid 参数取 const link = global.document.querySelector('a[href*="appid="]'); return link ? parseAppIdFromHref(link.getAttribute('href')) : ''; } /** 客户端二次排序:把卡片按指标重排 */ async function applyClientSort() { const settings = loadSettings(); if (settings.sortKey === 'native') return; // 重排 DOM 会触发 MutationObserver → enhanceBrowsePage → applyClientSort, // 不加锁就是无限循环。这里用 sorting 标志做重入保护。 if (sorting) return; sorting = true; try { const cards = scanCards(global.document); if (!cards.size) return; const ids = Array.from(cards.keys()); const details = await fetchDetails(ids); const entries = ids.map(id => ({ id, title: (cards.get(id).title || ''), detail: details[id] || null })); const sorted = sortEntries(entries, settings.sortKey, settings.sortDesc); // 找到共同父容器,重排 DOM const first = cards.get(sorted[0].id); const container = first && first.container && first.container.parentElement; if (!container) return; for (const e of sorted) { const c = cards.get(e.id); if (c && c.container) container.appendChild(c.container); } toast('已按「' + ((SORT_OPTIONS.find(o => o.key === settings.sortKey) || {}).label || '') + '」重排本页', 1800); } catch (_) { // 排序失败保持原序 } finally { sorting = false; } } // ---------- 卡片数据增强主流程 ---------- const pendingCards = new Map(); let flushTimer = null; let sorting = false; // 客户端重排的重入保护(重排 DOM 会触发 MutationObserver) let enhancing = false; // 扫描/增强的重入保护 function queueCard(card, settings) { if (!card || !card.container) return; if (card.container.getAttribute('data-swe-processed')) return; pendingCards.set(card.id, card); if (pendingCards.size >= CARD_BATCH_SIZE) { flushCards(settings); return; } if (!flushTimer) { flushTimer = setTimeout(() => flushCards(loadSettings()), CARD_BATCH_WAIT_MS); } } async function flushCards(settings) { clearTimeout(flushTimer); flushTimer = null; if (!pendingCards.size) return; const batch = Array.from(pendingCards.values()); pendingCards.clear(); const ids = batch.map(c => c.id); let details = {}; try { details = await fetchDetails(ids); } catch (_) { details = {}; } for (const card of batch) { renderCardStats(card, details[card.id] || null, settings); renderBadges(card, settings); if (settings.batchMode) injectPicker(card, onPickToggle); } } function onPickToggle(id, on) { if (on) picked.add(id); else picked.delete(id); updateToolbarCounts(); } // ============================================================ // 第 8 层:详情页增强 // ============================================================ let detailPanelFor = ''; // 已注入数据面板的条目 id function enhanceDetailPage() { const doc = global.document; if (!doc) return; // 条目 id:页面全局变量优先 let id = ''; try { const p = global.unsafeWindow || global; if (p && p.publishedfileid) id = String(p.publishedfileid); } catch (_) {} if (!id) { const link = doc.querySelector(SEL_FILE_LINK); if (link) id = parseFileIdFromHref(link.getAttribute('href')); } // 再兜底:详情页 URL 本身就带 ?id= if (!id) { try { id = parseFileIdFromHref(global.location.href); } catch (_) {} } if (!id) return; // 同一个条目只注入一次;换条目时清掉旧面板 const old = doc.querySelector('.swe-detail-panel'); if (old) { if (detailPanelFor === id) return; // 已有且是同一个 → 不重复 old.remove(); } detailPanelFor = id; let appid = ''; const ctn = doc.getElementById('sharedfiles_content_ctn'); if (ctn && ctn.getAttribute('data-miniprofile-appid')) appid = ctn.getAttribute('data-miniprofile-appid'); fetchDetails([id]).then(details => { const d = details[id]; if (!d) { detailPanelFor = ''; return; } // 异步返回时页面可能已经切走 —— 确认还在同一个条目上再注入 if (detailPanelFor !== id) return; const panel = doc.createElement('div'); panel.className = 'swe-detail-panel'; const now = Math.floor(Date.now() / 1000); const cell = (k, v, cls) => '
' + k + '' + v + '
'; const grid = [ cell('当前订阅', formatCount(d.subscriptions)), cell('历史订阅', formatCount(d.lifetimeSubscriptions)), cell('当前收藏', formatCount(d.favorited)), cell('累计收藏', formatCount(d.lifetimeFavorited)), cell('浏览量', formatCount(d.views)), cell('文件大小', formatBytes(d.fileSize)), cell('发布时间', formatDateTime(d.timeCreated)), cell('更新时间', formatDateTime(d.timeUpdated)) ].join(''); const tags = (d.tags || []).map(t => '' + escapeHtml(t) + '').join(''); panel.innerHTML = '

创意工坊增强 · 数据面板

' + '
' + grid + '
' + (tags ? '
' + tags + '
' : '') + ''; const anchor = doc.querySelector('.stats_table') || doc.querySelector('#sharedfiles_content_ctn'); if (anchor && anchor.parentElement) anchor.parentElement.insertBefore(panel, anchor); else doc.body.insertBefore(panel, doc.body.firstChild); }).catch(() => {}); } function escapeHtml(s) { return String(s == null ? '' : s) .replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, '''); } // ============================================================ // 第 9 层:艺术作品页(/app/{appid}/images/) // // 与创意工坊浏览页的关键差异(2026-10-28 实测): // · 艺术作品页是**经典服务端渲染**(apphub 体系),类名稳定,不像工坊浏览页那样哈希化 // · 条目 id 直接在 `data-publishedfileid` 属性上,不用从 href 猜 // · 卡片容器就是 `.apphub_Card`,不需要启发式判定 // · **无限滚动**(追加 DOM),所以必须靠 MutationObserver 补挂 // · 图片 URL 用 `?imw=1024&&ima=fit&...`(双 &,无 imh)——去参数必须用 URL 解析 // ============================================================ const SEL_APPHUB_CARD = '.apphub_Card'; /** * 扫描艺术作品卡片。 * @returns {Map} */ function scanArtworkCards(root) { const scope = root || global.document; const map = new Map(); if (!scope || !scope.querySelectorAll) return map; const cards = scope.querySelectorAll(SEL_APPHUB_CARD); for (const card of cards) { const id = card.getAttribute('data-publishedfileid') || parseFileIdFromHref(card.getAttribute('data-modal-content-url') || ''); if (!id || map.has(id)) continue; const img = card.querySelector('img.apphub_CardContentPreviewImage'); map.set(id, { id, card, img, titleEl: card.querySelector('.apphub_CardContentTitle'), ratingEl: card.querySelector('.apphub_CardRating'), commentEl: card.querySelector('.apphub_CardCommentCount'), authorEl: card.querySelector('.apphub_CardContentAuthorName'), typeEl: card.querySelector('.apphub_CardContentType'), title: (card.querySelector('.apphub_CardContentTitle') || {}).textContent || '' }); } return map; } /** * 取作品原图 URL:去掉 imw(尺寸限制),保留 ima/impolicy/imcolor/letterbox 裁剪策略。 * 实测:艺术作品默认 ?imw=1024 → 280 KB;去 imw → 707 KB;完全无参 → 1.8 MB / 1908×2808。 * ⚠️ 必须用 URL 解析(艺术作品 URL 是 `&&` 双 ampersand,正则删会留下 `?&&ima=...` → 404)。 */ function artworkOriginalUrl(img) { if (!img) return ''; const src = img.getAttribute('src') || ''; if (!src) return ''; const noSize = upgradePreviewUrl(src); // 若 src 没有 imw(已经是原图),直接用 return noSize || src; } /** * 渲染艺术作品卡片的数据条。 * * ⚠️ 位置很关键(2026-10-28 实测 apphub 卡片几何): * · `.apphub_Card` 是 `position:relative` 且 **高度固定**(721px) * · `.apphub_CardContentClickable`(图片)静态流,高 636px * · `.apphub_CardMetaData`(点赞/评分/标题行)**绝对定位**,压在图片底部 * · `.apphub_CardContentAuthorBlock`(作者块)**绝对定位**,压在最底部 * → 卡片里**没有任何空闲的流式空间**,把数据条 append 到卡片末尾 * 或插到标题之后,都会与原生控件**重叠**。 * → 正确做法:把数据条**绝对定位**到图片区底部、原生 meta 行**上方**, * 并用实测的 meta 行位置动态计算 top(不写死像素,Valve 改样式也不会错位)。 */ function renderArtworkStats(card, detail, settings) { const c = card.card; if (!c || !c.querySelector) return; let bar = c.querySelector('.swe-card-stats'); if (!bar) { bar = global.document.createElement('div'); bar.className = 'swe-card-stats swe-art-stats'; c.appendChild(bar); positionArtworkBar(c, bar); } if (!detail) { bar.innerHTML = '无数据'; c.setAttribute('data-swe-processed', '1'); return; } const parts = []; if (detail.views) parts.push('👁 ' + formatCount(detail.views) + ' 浏览'); if (detail.favorited) parts.push('★ ' + formatCount(detail.favorited) + ' 收藏'); if (detail.fileSize) parts.push('💾 ' + formatBytes(detail.fileSize) + ''); if (detail.timeUpdated) { parts.push('🕘 ' + formatRelative(detail.timeUpdated, Math.floor(Date.now() / 1000)) + '更新'); } bar.innerHTML = parts.join(''); c.setAttribute('data-swe-processed', '1'); } /** * 把数据条定位到「图片区底部、原生 meta 行上方」。 * 用实测几何动态计算,避免写死像素。 */ function positionArtworkBar(card, bar) { try { const cardRect = card.getBoundingClientRect(); const meta = card.querySelector('.apphub_CardMetaData'); const metaRect = meta ? meta.getBoundingClientRect() : null; // 数据条自身高度约 20px(16px 文字 + 2px×2 内边距),留 4px 间隙 const offset = metaRect ? (metaRect.top - cardRect.top) : (cardRect.height - 64); bar.style.position = 'absolute'; bar.style.left = '8px'; bar.style.top = Math.max(0, offset - 24) + 'px'; } catch (_) {} } /** 注入「下载原图 / 复制链接」按钮 */ function injectArtworkActions(card, settings) { const c = card.card; if (!c || !c.querySelector) return; if (c.querySelector('.swe-art-actions')) return; const box = global.document.createElement('div'); box.className = 'swe-art-actions'; box.innerHTML = '' + ''; c.appendChild(box); const url = artworkOriginalUrl(card.img); box.querySelector('.swe-art-dl').addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); if (!url) { toast('未找到图片地址'); return; } openOriginal(url, card.title); }); box.querySelector('.swe-art-copy').addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); const link = 'https://steamcommunity.com/sharedfiles/filedetails/?id=' + card.id; copyText(link); toast('已复制:' + link); }); } /** 打开原图(优先 GM_download,其次新标签) */ function openOriginal(url, name) { try { if (typeof GM_download === 'function') { const fname = (name || 'steam-artwork').replace(/[\\/:*?"<>|]+/g, '_').slice(0, 60) || 'steam-artwork'; GM_download({ url, name: fname + '.jpg', onerror: () => { global.open(url, '_blank'); } }); return; } } catch (_) {} global.open(url, '_blank', 'noopener'); } /** 复制文本(优先剪贴板 API) */ function copyText(text) { try { if (global.navigator && global.navigator.clipboard && global.navigator.clipboard.writeText) { global.navigator.clipboard.writeText(text).catch(() => {}); return; } } catch (_) {} try { if (typeof GM_setClipboard === 'function') { GM_setClipboard(text); return; } } catch (_) {} } // ---------- 艺术作品页批量下载 ---------- const artPicked = new Set(); function setArtBatchMode(on) { saveSettings({ artBatchMode: !!on }); try { global.document.body.classList.toggle('swe-art-batch-on', !!on); } catch (_) {} if (!on) { artPicked.clear(); global.document.querySelectorAll('.swe-art-pick').forEach((b) => b.remove()); } else { for (const card of scanArtworkCards(global.document).values()) { injectArtPicker(card); } } syncArtPickers(); updateArtCount(); } function injectArtPicker(card) { const c = card.card; if (!c || !c.querySelector) return; if (c.querySelector('.swe-art-pick')) return; const box = global.document.createElement('label'); box.className = 'swe-art-pick'; box.innerHTML = ''; c.appendChild(box); box.querySelector('input').addEventListener('change', (e) => { e.stopPropagation(); box.classList.toggle('on', e.target.checked); if (e.target.checked) artPicked.add(card.id); else artPicked.delete(card.id); updateArtCount(); }); } function syncArtPickers() { global.document.querySelectorAll('.swe-art-pick').forEach((box) => { const inp = box.querySelector('input'); if (!inp) return; const cardEl = box.closest ? box.closest(SEL_APPHUB_CARD) : null; const id = cardEl ? cardEl.getAttribute('data-publishedfileid') : ''; const on = !!id && artPicked.has(id); inp.checked = on; box.classList.toggle('on', on); }); } function updateArtCount() { const el = global.document.querySelector('.swe-art-count'); if (el) el.textContent = '已选 ' + artPicked.size + ' 项'; } async function batchDownloadArtworks() { if (!artPicked.size) { toast('请先勾选作品'); return; } const cards = scanArtworkCards(global.document); const list = []; for (const id of artPicked) { const c = cards.get(id); const url = c ? artworkOriginalUrl(c.img) : ''; if (url) list.push({ url, name: c.title || id }); } if (!list.length) { toast('未找到可下载的图片'); return; } toast('开始下载 ' + list.length + ' 张原图…', 5000); for (let i = 0; i < list.length; i++) { openOriginal(list[i].url, list[i].name); await new Promise(r => setTimeout(r, 350)); // 逐个触发,避开浏览器批量下载拦截 } toast('已触发 ' + list.length + ' 张下载', 4000); } /** 艺术作品页工具条 */ function buildArtworkToolbar() { if (toolbarEl) return toolbarEl; const doc = global.document; const bar = doc.createElement('div'); bar.className = 'swe-toolbar'; bar.innerHTML = '' + ''; doc.body.appendChild(bar); toolbarEl = bar; const panel = bar.querySelector('.swe-panel'); bar.querySelector('.swe-toolbar-toggle').addEventListener('click', () => { panel.style.display = panel.style.display === 'none' ? 'flex' : 'none'; }); bar.querySelector('.swe-art-all').addEventListener('click', () => { for (const id of scanArtworkCards(global.document).keys()) artPicked.add(id); syncArtPickers(); updateArtCount(); }); bar.querySelector('.swe-art-invert').addEventListener('click', () => { for (const id of scanArtworkCards(global.document).keys()) { if (artPicked.has(id)) artPicked.delete(id); else artPicked.add(id); } syncArtPickers(); updateArtCount(); }); bar.querySelector('.swe-art-clear').addEventListener('click', () => { artPicked.clear(); syncArtPickers(); updateArtCount(); }); bar.querySelector('.swe-art-batch').addEventListener('click', batchDownloadArtworks); const modeBtn = bar.querySelector('.swe-art-mode'); const settings = loadSettings(); if (settings.artBatchMode) { setArtBatchMode(true); modeBtn.textContent = '退出批量'; } modeBtn.addEventListener('click', () => { const on = !loadSettings().artBatchMode; setArtBatchMode(on); modeBtn.textContent = on ? '退出批量' : '批量模式'; }); bar.querySelector('.swe-art-copyall').addEventListener('click', () => { const ids = Array.from(scanArtworkCards(global.document).keys()); if (!ids.length) { toast('本页暂无作品'); return; } const text = ids.map(id => 'https://steamcommunity.com/sharedfiles/filedetails/?id=' + id).join('\n'); copyText(text); toast('已复制本页 ' + ids.length + ' 条作品链接', 3000); }); return bar; } const pendingArtCards = new Map(); let artFlushTimer = null; function queueArtworkCard(card, settings) { if (!card || !card.card) return; if (card.card.getAttribute('data-swe-processed')) return; pendingArtCards.set(card.id, card); if (pendingArtCards.size >= CARD_BATCH_SIZE) { flushArtworkCards(settings); return; } if (!artFlushTimer) artFlushTimer = setTimeout(() => flushArtworkCards(loadSettings()), CARD_BATCH_WAIT_MS); } async function flushArtworkCards(settings) { clearTimeout(artFlushTimer); artFlushTimer = null; if (!pendingArtCards.size) return; const batch = Array.from(pendingArtCards.values()); pendingArtCards.clear(); const ids = batch.map(c => c.id); let details = {}; try { details = await fetchDetails(ids); } catch (_) { details = {}; } for (const card of batch) { renderArtworkStats(card, details[card.id] || null, settings); injectArtworkActions(card, settings); if (settings.artBatchMode) injectArtPicker(card); } } function enhanceArtworkPage() { const doc = global.document; const settings = loadSettings(); if (enhancing) return; enhancing = true; try { const cards = scanArtworkCards(doc); for (const card of cards.values()) { // 预览图升级(去 imw → 更高分辨率) if (settings.upgradePreview && card.img) { const src = card.img.getAttribute('src') || ''; if (src && src.indexOf('imw=') !== -1) { const up = upgradePreviewUrl(src); if (up && up !== src) card.img.setAttribute('src', up); } } injectArtworkActions(card, settings); if (settings.artBatchMode) injectArtPicker(card); queueArtworkCard(card, settings); } } finally { enhancing = false; } } // ============================================================ // 第 10 层:「我的项目」页 —— 收集已订阅 / 已收藏 id // ============================================================ function scanMyItems() { const doc = global.document; if (!doc) return; const ids = new Set(); doc.querySelectorAll(SEL_FILE_LINK).forEach(a => { const id = parseFileIdFromHref(a.getAttribute('href')); if (id) ids.add(id); }); if (!ids.size) return; const filter = (readBrowseParams(global.location.href) || {}).browsefilter || (global.location.search.match(/browsefilter=([^&]+)/) || [])[1] || ''; const target = /favorites/.test(filter) ? FAV_KEY : SUBS_KEY; const set = target === FAV_KEY ? loadFavorited() : loadSubscribed(); let changed = false; ids.forEach(id => { if (!set[id]) { set[id] = true; changed = true; } }); if (changed) storeSet(target, set); } // ============================================================ // 第 10 层:启动 // ============================================================ function pageKind() { const path = global.location.pathname || ''; if (/\/sharedfiles\/filedetails/.test(path)) return 'detail'; if (/\/myworkshopfiles/.test(path)) return 'myitems'; // 艺术作品页 / 截图页:/app/{appid}/images/ 与 /app/{appid}/screenshots/ if (/\/app\/\d+\/(images|screenshots)/.test(path)) return 'artwork'; if (/\/workshop\/browse/.test(path) || /^\/workshop\/?$/.test(path)) return 'browse'; return 'other'; } let observer = null; let lastUrl = ''; function enhanceBrowsePage() { const doc = global.document; const settings = loadSettings(); // 重入保护:注入 DOM 本身会触发 MutationObserver if (enhancing) return; enhancing = true; try { const cards = scanCards(doc); for (const card of cards.values()) { upgradeCardImage(card, settings); renderBadges(card, settings); if (settings.batchMode) injectPicker(card, onPickToggle); queueCard(card, settings); } if (settings.sortKey !== 'native') applyClientSort(); } finally { enhancing = false; } } function startObserver() { if (observer) return; const doc = global.document; if (!doc || !doc.body || typeof MutationObserver === 'undefined') return; observer = new MutationObserver((mutations) => { let touched = false; for (const m of mutations) { for (const n of m.addedNodes) { if (n.nodeType !== 1) continue; if (n.matches && (n.matches(SEL_FILE_LINK) || n.matches(SEL_APPHUB_CARD))) { touched = true; break; } if (n.querySelector && (n.querySelector(SEL_FILE_LINK) || n.querySelector(SEL_APPHUB_CARD))) { touched = true; break; } } if (touched) break; } if (!touched) return; const kind = pageKind(); if (kind === 'browse') enhanceBrowsePage(); else if (kind === 'artwork') enhanceArtworkPage(); else if (kind === 'myitems') scanMyItems(); }); observer.observe(doc.body, { childList: true, subtree: true }); } function checkUrlChange() { const url = global.location.href; if (url === lastUrl) return; lastUrl = url; const kind = pageKind(); if (kind === 'browse') enhanceBrowsePage(); else if (kind === 'artwork') enhanceArtworkPage(); else if (kind === 'myitems') scanMyItems(); else if (kind === 'detail') enhanceDetailPage(); } function boot() { try { injectCss(); const kind = pageKind(); if (kind === 'browse') { buildToolbar(); enhanceBrowsePage(); applyPerPageDefault(); } else if (kind === 'artwork') { buildArtworkToolbar(); enhanceArtworkPage(); } else if (kind === 'myitems') { scanMyItems(); } else if (kind === 'detail') { enhanceDetailPage(); } lastUrl = global.location.href; startObserver(); // SPA 内部路由:轮询 URL(React 路由不改 pushState 之外的事件) setInterval(checkUrlChange, 700); global.addEventListener('popstate', checkUrlChange); if (global.history && typeof global.history.pushState === 'function') { const orig = global.history.pushState; global.history.pushState = function () { const r = orig.apply(this, arguments); setTimeout(checkUrlChange, 60); return r; }; } console.log('%c[Steam 创意工坊增强] v' + VERSION + ' 已启动', 'color:#66c0f4;font-weight:bold;font-size:13px'); } catch (e) { console.warn('[Steam 创意工坊增强] 启动失败', e); } } /** 首次访问时把「每页条数」落到 URL(仅当用户没有自定义过) */ function applyPerPageDefault() { const settings = loadSettings(); if (!settings.perPage) return; const p = readBrowseParams(global.location.href); if (p.numPerPage === settings.perPage) return; const u = new URL(global.location.href); u.searchParams.set('num_per_page', String(settings.perPage)); global.location.replace(u.toString()); } // ============================================================ // 导出(单测用) // ============================================================ const TEST_HOOKS = { parseFileIdFromHref, parseAppIdFromHref, parseStarRating, buildDetailQuery, parseDetailsResponse, mergeDetails, pickStaleIds, sortEntries, formatBytes, formatCount, formatDateTime, formatRelative, isRecentlyUpdated, upgradePreviewUrl, buildBrowseUrl, readBrowseParams, // 艺术作品页 scanArtworkCards, artworkOriginalUrl, PAGE_SIZES, SORT_OPTIONS, API_BATCH_MAX, CACHE_TTL_MS, VERSION }; try { global.__SWE_TEST_HOOKS__ = TEST_HOOKS; } catch (_) {} if (global.document && global.document.addEventListener) { if (global.document.readyState === 'loading') { global.document.addEventListener('DOMContentLoaded', boot, { once: true }); } else { setTimeout(boot, 0); } } })(typeof window !== 'undefined' ? window : globalThis);