// ==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 之后只有这份**:`