// ==UserScript==
// @name Steam 创意工坊增强
// @name:en Steam Workshop Enhancer
// @namespace steam-workshop-enhancer
// @version 1.8.3
// @description 为新版 Steam 创意工坊补回被改版削掉的信息与批量能力。浏览页:卡片显示订阅数/收藏数/浏览量/文件大小/更新时间、悬浮按钮(复制 ID/SteamCMD/收藏)、已订阅角标、星级筛选(≥N 星 / 仅已评分 / 最低票数,作用于当前页;0 命中时自动翻下一页继续找)、跨页看 5★(原生 toprated 排序 + 逐页隐藏非 5★)、原生排序(最受好评/最新更新/最热门,跨页生效)、每页条数 10/15/30/50(服务端白名单)+ 100/200(客户端跨页聚合,真的能看这么多条)、预览图原图升级、批量勾选订阅/退订、勾选批量导出(ID/链接/CMD/名称/JSON)。收藏夹管理器:搜索/筛选/检查更新/批量导出文件。订阅备份:翻页拉取全部订阅导出 JSON,导入还原自动批量重订。详情页:生命周期订阅数/累计收藏/浏览量/精确时间/完整标签云/复制 ID 与 SteamCMD。艺术作品页(/app/{id}/images/ 与 /screenshots/):卡片显示浏览量/收藏/大小/更新时间、一键下载原图(去尺寸限制,实测 280KB→707KB,完全原图可达 1908×2808)、批量勾选批量下载、复制本页作品链接;截图页额外提供纯图瀑布流浮窗(全屏铺开社区截图、点图看原图、独立 DOM 不改原页面)。游戏创意工坊首页(/app/{id}/workshop/):分区卡片同样增强。浮动按钮整合为一个贴边图标组,可拖到窗口上/下/左/右任一边缘并记住位置(不再互相遮挡)。核心数据走 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, hover actions (copy ID / SteamCMD / bookmark), subscribed badges, advanced client-side sorting (star rating included), star-level filtering, per-page 10/15/30/50 (server whitelist) plus 100/200 via client-side cross-page aggregation that really shows that many items, full-resolution previews, multi-select batch subscribe/unsubscribe, bulk export of selected items (IDs/links/SteamCMD/names/JSON). Bookmark manager: search, filters, update checking, batch export. Subscription backup: fetch all subscriptions page by page into a JSON snapshot, restore by batch re-subscribing. Detail page: lifetime subscriptions, cumulative favorites, views, exact timestamps, full tag cloud, copy ID/SteamCMD. Artwork page: per-card stats, one-click original-image download, multi-select batch download, copy all artwork links; the screenshots page additionally gets a pure-image waterfall overlay (full-screen community screenshots, click for the original, independent DOM that leaves the original page untouched). Game workshop home (/app/{id}/workshop/): section cards are enhanced too. Floating buttons are unified into one edge-docked icon group that can be dragged to any window edge (top/bottom/left/right) and remembers its position. 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*
// @match https://steamcommunity.com/app/*/workshop*
// @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
// @connect steamuserimages-a.akamaihd.net
// @run-at document-idle
// @license MIT
// @tag Steam
// @tag workshop
// @icon data:image/svg+xml,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%20100%20100'%3E%3Cdefs%3E%3ClinearGradient%20id%3D'bg'%20x1%3D'0'%20y1%3D'0'%20x2%3D'1'%20y2%3D'1'%3E%3Cstop%20offset%3D'0'%20stop-color%3D'%2318293c'%2F%3E%3Cstop%20offset%3D'1'%20stop-color%3D'%230a131e'%2F%3E%3C%2FlinearGradient%3E%3CradialGradient%20id%3D'halo'%20cx%3D'0.5'%20cy%3D'0.44'%20r%3D'0.6'%3E%3Cstop%20offset%3D'0'%20stop-color%3D'%232f5a86'%2F%3E%3Cstop%20offset%3D'1'%20stop-color%3D'%232f5a86'%20stop-opacity%3D'0'%2F%3E%3C%2FradialGradient%3E%3ClinearGradient%20id%3D'mtnBack'%20x1%3D'0'%20y1%3D'1'%20x2%3D'1'%20y2%3D'0'%3E%3Cstop%20offset%3D'0'%20stop-color%3D'%232b5f8f'%2F%3E%3Cstop%20offset%3D'1'%20stop-color%3D'%235c50b8'%2F%3E%3C%2FlinearGradient%3E%3ClinearGradient%20id%3D'mtnMain'%20x1%3D'0'%20y1%3D'1'%20x2%3D'1'%20y2%3D'0'%3E%3Cstop%20offset%3D'0'%20stop-color%3D'%234fe0ff'%2F%3E%3Cstop%20offset%3D'0.55'%20stop-color%3D'%2366c0f4'%2F%3E%3Cstop%20offset%3D'1'%20stop-color%3D'%23a26bff'%2F%3E%3C%2FlinearGradient%3E%3ClinearGradient%20id%3D'sun'%20x1%3D'0'%20y1%3D'0'%20x2%3D'1'%20y2%3D'1'%3E%3Cstop%20offset%3D'0'%20stop-color%3D'%23ffe082'%2F%3E%3Cstop%20offset%3D'1'%20stop-color%3D'%23ff8f00'%2F%3E%3C%2FlinearGradient%3E%3ClinearGradient%20id%3D'spec'%20x1%3D'0'%20y1%3D'0'%20x2%3D'1'%20y2%3D'0'%3E%3Cstop%20offset%3D'0'%20stop-color%3D'%23ff5c8a'%2F%3E%3Cstop%20offset%3D'0.25'%20stop-color%3D'%23f5a623'%2F%3E%3Cstop%20offset%3D'0.5'%20stop-color%3D'%235cba6f'%2F%3E%3Cstop%20offset%3D'0.75'%20stop-color%3D'%234fe0ff'%2F%3E%3Cstop%20offset%3D'1'%20stop-color%3D'%23a26bff'%2F%3E%3C%2FlinearGradient%3E%3C%2Fdefs%3E%3Crect%20width%3D'100'%20height%3D'100'%20rx%3D'22'%20fill%3D'url(%23bg)'%2F%3E%3Ccircle%20cx%3D'50'%20cy%3D'46'%20r%3D'36'%20fill%3D'url(%23halo)'%2F%3E%3Cpath%20d%3D'M6%2072%20L26%2046%20L38%2061%20L47%2050%20L62%2072%20Z'%20fill%3D'url(%23mtnBack)'%20opacity%3D'0.75'%2F%3E%3Cpath%20d%3D'M16%2072%20L41%2033%20L53%2052%20L64%2039%20L88%2072%20Z'%20fill%3D'url(%23mtnMain)'%2F%3E%3Ccircle%20cx%3D'71'%20cy%3D'25'%20r%3D'11'%20fill%3D'url(%23sun)'%20opacity%3D'0.28'%2F%3E%3Ccircle%20cx%3D'71'%20cy%3D'25'%20r%3D'7'%20fill%3D'url(%23sun)'%2F%3E%3Crect%20x%3D'14'%20y%3D'79'%20width%3D'72'%20height%3D'6'%20rx%3D'3'%20fill%3D'url(%23spec)'%2F%3E%3C%2Fsvg%3E
// ==/UserScript==
/**
* Steam 创意工坊增强 v1.8.3
*
* 设计依据: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=toprated / lastupdated / trend、section、requiredtags[]、
* days、p 可用(跨页生效);newest / mostsubscribed / mostdiscussed 会被归一成 trend。
* 因此「原生排序」直接改 URL 实现,不碰私有接口。
*
* ===== 2026-10-30 实测修正:num_per_page 服务端白名单 = [10, 15, 30, 50] =====
* · 逐值实测(appid=570,section=readytouseitems,browsesort=toprated):
* npp=10 → 10 条 npp=15 → 15 条 npp=30 → 30 条 npp=50 → 50 条
* npp=1/5/12/16/20/24/25/35/40/45/48/51/60/70/75/80/90/100/120/150/200/0
* → 一律回落成 30 条(服务端把非法值归一到默认 30)
* · 服务端 SSR 回显的 queryKey 同步证明这一点:请求 npp=100 时
* `["workshop_browse", {..., num_per_page: 30, ...}]` —— 值被改写,不是 100。
* · 客户端 SPA 自己也用同一套白名单:chunk C-Bheh1z.js 里
* `rgNumPerPageOptions ?? [10,15,30,50]`(原生「每页」下拉的可选值)。
* · 结论:**100/200 条每页在服务端不存在**,写进 URL 只会静默变成 30 条。
* 所以 v1.4.0 里「每页条数 30→200」是假的——那行 toast 自己也承认「可能被服务端忽略」。
* · 真正的实现(v1.5.0):10/15/30/50 走服务端 URL 参数(原生分页,正确);
* 100/200 走**客户端跨页聚合**——按 50 条/页抓 p、p+1…p+k 页,把多个页的
* 结果合并渲染到同一个网格里(实测 p=1..3 @50 条 → 150 个唯一 id,零重复)。
*
* ===== 2026-10-30 实测修正:/app/{appid}/workshop/ 是独立路由 =====
* · 该地址(Dota 2 创意工坊首页)此前**根本没被 @match 覆盖**,脚本压根不运行;
* 现在补上 @match + pageKind 分支,并复用浏览页的卡片增强(同一套哈希化卡片 DOM)。
* · 该页用 `workshop_query` 查询(不是 `workshop_browse`),且**没有「每页」下拉**,
* 所以排序/筛选/分页等浏览页专属控件在该页隐藏,只保留卡片增强与批量操作。
*
* ===== 2026-10-31 v1.6.0:浮动图标组(dock)=====
* · 此前「设置」按钮(.swe-toolbar-toggle)与「瀑布流」按钮(.swe-wf-btn)是
* **两个各自 fixed 到窗口右下角**的元素(right/bottom 只差几像素)→ 必然叠在一起。
* · 现在所有浮动圆按钮统一放进一个 dock 容器,由 flex 排布 → 结构上不可能重叠;
* dock 还能整体拖到窗口**上/右/下/左**任一边缘,抬手自动吸附并记住位置。
* · 面板改为相对 dock 定位(absolute),按贴边方向决定朝哪边展开,展开时钳制在视口内。
* · 拖拽用 Pointer Events;位移超过 6px 才算拖拽(否则点按钮会被吃掉)。
*
* ===== 2026-10-31 v1.6.1:面板「−」收起按钮修复 =====
* · 用户反馈「设置浮窗点最小化按钮不关闭」—— 该按钮的 HTML 一直都在,
* 但**从未绑定任何事件**,所以点了毫无反应。
* · v1.6.1 抽出公共函数 bindPanelCollapse(dock),在 initDock 里统一绑定;
* 浏览页与艺术作品页两个面板共用(艺术作品页面板原本连这个按钮都没有,一并补上,
* head 结构统一为 .swe-panel-head + .swe-panel-body)。
* · 已用真实浏览器验证:展开态点「−」→ display:none;再点 ⚙ → 回到 display:flex。
*
* ===== 2026-10-31 v1.6.2:修「拖得动、点不动」的回归 =====
* · 现象:v1.6.1 的图标组能拖,但按钮点了没反应。
* · 根因:pointerdown 里调了 dock.setPointerCapture() —— 一旦捕获指针,
* 按规范后续的兼容性鼠标事件(含 click)会被 retarget 到捕获元素,
* 用户按下的那个按钮根本收不到 click → 处理器永不执行。
* · 修法:**彻底不用 setPointerCapture**;pointermove / pointerup 改挂 window
* (capture 阶段),鼠标移出图标组也能继续跟手
* * · 附带修掉一个隐藏缺陷:原先 move 监听挂在图标组上,快速拖动时第一个
* pointermove 就已出界 → 拖拽直接断掉。挂到 window 后不再断。
* · 为什么之前没测出来:上一版 e2e 用 element.click()(合成事件直接派发到按钮),
* 绕过了真实鼠标的 capture 流程。v1.6.2 起改用 CDP Input.dispatchMouseEvent
* 走完整链路(真实按下 → 真实移动 → 真实抬起)。
*
* ===== 2026-10-31 v1.7.0:截图瀑布流浮窗体验优化 =====
* · 「加载更多」加载不出来 —— 实测根因是 numperpage:
* hidden 里原值是 10(页面自己的每页条数),该值下**第 2 页只回 2 张** → 翻两页就空。
* 实测 npp=30 → 每页 30 张;npp=100 → 每页 100 张;页码 p2..p5 正常推进、id 互不重复。
* 现改为显式请求 numperpage=50,并按 id 去重。
* 另:browsefilter(trend/newest/空)对结果**完全无影响**(返回 id 一模一样),不动它。
* · wfLoadMore 补并发保护 —— 滚动到底会连续触发 scroll,之前会同时发出多个请求、
* 页码与计数互相踩;现在 loading 期间直接忽略重复触发。
* · 空页自动续抓:某一页全是已加载过的(与首屏重叠)时自动再往后试几页。
* · 加载失败不再置为「已加载完」,保留重试机会(状态区提示,可再点)。
* · 标题显示游戏名(实测锚点 .apphub_AppName → "Dota 2")。
* · 新增:骨架屏、加载状态文案、打开即后台补一批、窗口缩放重排列数、
* 全屏看图支持左右方向键翻页与「第 N / 共 M 张」定位。
*
* ===== 2026-11-01 v1.8.0:星级筛选 0 命中自动翻页(星级猎取)+ 刷新循环保险 =====
* · 用户反馈「点仅 5 星什么都没有 / 有时整页灰掉未响应」:
* 星级筛选只作用于当前页,本页一张 5★ 都没有时全部卡片被打上 swe-card-hidden
* (display:none)——新版工坊页面只剩背景大图,看起来「整个灰掉」。
* 且筛选设置持久化,换页/换游戏仍生效 → 空白灰页反复出现。
* · 新增「星级猎取」:筛选生效且本页 0 命中时,自动把 URL 的 p 推进到下一服务端页
* (其余参数原样保留),整页加载后重新筛,直到找到命中页或触发停止条件。
* 仅 rated / ≥3 / ≥4 / 5★ 档位参与;纯最低票数不参与(票数快照可能过期,依据不可靠)。
* · 防失控三保险:
* ① 页码推进受 SSR total_pages 约束(读不到时靠跳页次数上限兜底,默认 20 页);
* ② 「远征」状态(档位签名 + 结果集 + 已跳页数)存 sessionStorage——
* 整页刷新不丢(跳页是整页加载)、换筛选/换结果集自动重置、新标签页不继承;
* ③ sessionStorage 不可用或写入回读校验失败 → 本会话禁用猎取。
* 绝不在无状态下盲跳,否则每页都从 hops=0 重新计数 = 无限重定向循环。
* · applyPerPageDefault 的整页刷新加同 URL 3 秒去重保险:若 SPA 剥掉/改写 num_per_page
* 参数,旧逻辑会无限 location.replace(页面永远在加载、始终灰着点不动)——
* 这是「灰色未响应」最可能的死循环成因模式,现在同一目标 URL 3 秒内只自动重定向一次。
* · readSsrStarData / readSsrStarDataFromHtml 通用遍历加访问节点数硬上限(2 万),
* 防御异常大的 SSR 快照拖慢主线程。
* · 状态行与 toast 全程可见:「本页 0 条,自动跳第 N 页继续筛选…」/「连续 N 页无匹配,
* 已停止自动翻页」——随时把星级改回「全部」即可退出。
*
* ===== 2026-11-01 v1.8.1:筛选后仅剩卡片被纵向拉成长条的布局修复 =====
* · 用户截图:星级筛选后整页只剩一两张卡,卡片被拉成约一屏高的细长条、悬在页面中部。
* · 根因在 Steam 网格自身:新版工坊网格的内容区带 min-height(或被父级撑高),
* CSS Grid 默认 `align-content: stretch` —— 内容只剩一行时,富余高度会被分给
* **唯一的行轨道**;卡片默认 `align-self: stretch` 跟着行一起被纵向撑满。
* 卡片水平居中则与其部分行居中(justify-content: center)一致 —— 都是原生行为,
* 只是「只剩一张卡」这个场景把两者同时暴露了。display:none 的隐藏方式没有问题。
* · 修复:applyStarFilter 给网格容器打 `data-swe-grid-filtered="1"` 标记(幂等),
* CSS 侧把该网格改为 `align-content: start`(多余空间留在网格下方)+
* `grid-auto-rows: min-content`(中和可能的 1fr 行高)+ 直接子项 `align-self: start`。
* 标记只在筛选激活时为 1,关闭筛选恢复原生布局。
* · gridFromCards 从 findGridContainer 抽出(复用已扫描的 cards,避免每轮筛选二次扫描);
* 单卡页面退回卡片直接父节点打标。
*
* ===== 2026-11-01 v1.8.1b:以 app/4348910 真机 e2e 修出的两个猎取链路缺陷 =====
* · ①URL 归一化竞速:Steam 的 React Router 会在 hydration 前后 replaceState 归一化 URL,
* 触发 checkUrlChange;旧版在此**取消**挂着的猎取确认定时器 → 跳页与否取决于
* 「归一化 vs 1.2s 延迟」的竞速。修复:切页只重置导航锁与状态文案,不取消定时器
* (confirm 触发时重读当前 URL/DOM,对 URL 变化天然免疫)。
* · ②假导航:assign 后「URL 已改、文档未重载」——旧文档带着新 URL 继续存活,一切看似
* 死掉(很可能就是用户「整页灰掉未响应」的机制之一)。修复:navigateTo 发起跳转后
* 挂一个 3 秒哨兵,正常导航会销毁文档(定时器永不执行);3 秒后文档还活着 →
* 强制 location.replace 完成跳转。对原生排序 / 每页条数切换同样生效。
* · ③远征状态 sessionStorage → localStorage:e2e 实测假导航环境下 sessionStorage 写入
* **跨文档全部丢失**(每个新文档读到 null → hops 永远从 0 重计 → 无限假导航循环),
* localStorage 无此问题。applyPerPageDefault 的循环保险一并迁移。
* · ④排程看门狗:确认定时器排程 15s 仍未触发(后台节流等)→ 重新排程,
* confirm 触发时重读全部状态,重复排程无副作用。
* · 测试:test/e2e-4348910-starhunt.mjs(apphub 布局 + 浏览页猎取全链路,含深页码
* 强制 0 命中场景)与 test/probe-hunt-churn.mjs(文档切换/状态存续观察)。
*
* ===== 2026-11-01 v1.8.2:瀑布流看图浮窗「显示的是上一次的图」修复 =====
* · 用户反馈:点开的看图浮窗与实际点击的缩略图不是同一张 —— 总是显示上一次
* 看过的(缓存的)图;即"未加载成功之前使用了上次的缓存图片"。
* · 根因(旧版 wfRenderViewer):换图只做 `img.src = item.full`(原图)——
* 原图要几秒才下载解码完,期间 `
` 一直显示上一次的图;
* 且定位用 `images.indexOf(item)`(对象身份),一旦失效返回 -1 时渲染函数
* 直接 return —— 查看器**保留上一次的图片与标题**(陈旧渲染,极具迷惑性)。
* · 修复三件套:
* ①预览图先行 —— 先 `img.src = item.preview`(所点条目自己的缩略图,浏览器
* 已缓存、秒出、必然是同一张),原图后台解码就绪后凭令牌静默升级;
* ②换图令牌(wfState.viewToken)—— 每次渲染自增,慢原图的迟到 onload 与
* 令牌不符 → 直接丢弃,绝不用旧解码结果覆盖后点的图;
* ③条目定位兜底 wfFindImageIndex —— 对象身份 → id → full/preview/url →
* title 逐级匹配;仍找不到则**清空查看器**(移除 src + 提示文案),
* 绝不保留上一次的渲染。
*
* ===== 2026-11-01 v1.8.3:移除页内客户端排序(布局损坏,收益不敌代价) =====
* · 用户实测:物品页用面板排序后卡片挤成两列窄条;回到游戏工坊主页,
* 「物品 · 过去 1 周」分区直接空白,原始页面报废、只能整页刷新。
* · 根因:新版工坊是 React SPA,网格子节点由 React 拥有。客户端排序用
* appendChild 把卡片在真实 DOM 里重排 → React 虚拟树与真实 DOM 顺序失配;
* 此后**任何一次** React 更新(切页/刷新/筛选联动)做 reconciliation 都会
* 按自己记住的顺序增删节点 → 错位、丢失、重复。这不是偶发 bug,是方案级冲突。
* · 利弊分析:排序收益本来就小 —— 只能排「当前页已加载的几十条」而非全量,
* 且服务端「原生排序」(最受好评/最新更新/最热门,跨页生效)已覆盖真实需求。
* 结论:移除,零损失。
* · 处置:删除 applyClientSort 与面板「排序/方向」两行;排序统一走「原生排序」。
* 保留且不受影响:星级筛选 / 最低票数(display:none 方式,不碰 React 子节点
* 结构,安全)、原生排序、看 5★、跨页聚合、星级猎取。
* · 遗留 sortKey/sortDesc 设置字段不再被读取;老用户旧设置无需迁移即自动失效。
*
* 纯逻辑(parse / sort / format / query 构造)与 DOM 增强严格分离,
* 纯逻辑通过 window.__SWE_TEST_HOOKS__ 暴露给单测。
*/
(function (global) {
'use strict';
const VERSION = '1.8.3';
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;
}
/** 星级筛选候选('all' 为关闭;'rated' 隐藏「评分不足」) */
const STAR_FILTER_OPTIONS = [
{ key: 'all', label: '全部' },
{ key: 'rated', label: '仅已评分' },
{ key: '3', label: '≥3 星' },
{ key: '4', label: '≥4 星' },
{ key: '5', label: '仅 5 星' }
];
/**
* 读取浏览页内嵌数据里的星级 / 票数。
*
* 每条 `workshop_browse` 结果带 `star_rating`(0–5,**-1 = 评分不足**)与
* `total_votes` —— 这两个字段 GetPublishedFileDetails 拿不到(`includevotes` 不生效),
* 只能从页面自身的 React Query 数据里读。
*
* 2026-10-30 实测两个来源(按可靠性排序):
* 1. `window.SSR.renderContext.queryData`(JSON 字符串)→ `queries` →
* `queryKey[0] === 'workshop_browse'` → `state.data.results`。
* **hydrate 之后只有这份**:`