
原神玩家社区里存在大量共享造物内容常见形态是卡片式列表每张卡片包含作者、标题、预览图和访问入口。当这些内容越攒越多会出现两类很常见的需求一类是不想再看某个作者或某条造物希望把它“屏蔽”掉但页面往往没有现成入口另一类是自己发布过的旧造物需要清理却只能一条一条点击删除操作重复、耗时长。把这两个需求交给前端来处理本质上是两个不同问题屏蔽是基于本地状态的内容过滤删除是基于账户权限的远端数据变更。下面以“浏览器用户脚本 页面 DOM 操作”的方式从页面结构分析、本地存储设计、屏蔽过滤实现、批量删除队列、验证与排错五个层面把这个小工具完整做出来。1. 先拆分需求屏蔽与删除看似接近工程实现完全不同1.1 屏蔽解决的是“不想看到”不改变远端数据屏蔽某个共享造物并不代表这条内容从服务器上消失它只是不会再出现在“当前浏览器看到的列表”里。可以屏蔽的对象有两种粒度按造物屏蔽某一条具体的共享内容例如某个尘歌壶摹本码、某张同人图。屏蔽后只隐藏这一条。按作者屏蔽某个用户发布的所有共享造物。屏蔽后该作者在列表中出现的任何内容都会被过滤掉。从实现上看屏蔽是一种“视图过滤”。每次列表渲染或动态加载新卡片时脚本都要查询本地保存的屏蔽状态如果当前卡片的造物 ID 或作者 ID 命中屏蔽名单就把这张卡隐藏起来。它不依赖任何接口权限也不需要登录态只影响当前浏览器环境。1.2 一键删除解决的是“批量清理”必须改远端数据一键删除和屏蔽有本质区别。删除的对象只能是“当前账号自己发布的共享造物”删除成功后服务器上的记录会真的消失其他玩家也会看不到。因此一键删除不能只做本地隐藏它必须触达服务端。实现上有两条路线模拟用户操作依次找到每条卡片上的删除按钮点击后在确认弹窗中点击确认。风险最低不需要单独处理接口签名。直接调用页面接口用开发者工具观察删除请求的 URL、请求头和请求体再通过 fetch 批量发送。效率高但要处理 CSRF Token、Cookie、频率限制等问题。对于小工具来说第一条路线更安全。它复用页面自身的权限校验、弹窗提醒和失败兜底脚本只是把成千上百次重复点击串起来。1.3 数据边界与脚本边界要提前定义动手写代码之前必须先确定边界否则功能会越做越偏。核心边界有三条屏蔽只影响本机换浏览器、换设备、清除站点数据后屏蔽列表会丢失。如果想实现账号级屏蔽需要把状态同步到自己的后端服务。删除只允许操作自己的内容脚本必须识别卡片上的“当前作者”标记只把属于当前账号的卡片加入删除队列绝不能去点别人的删除按钮。一键删除本质是把 N 次手工操作串行化如果页面原本要求人工确认脚本也应该等待确认弹窗出现再继续不能通过修改页面逻辑跳过确认。维度屏蔽一键删除数据影响范围当前浏览器本地列表服务端数据记录是否需要登录权限不需要必须使用当前账号权限典型实现方式本地状态过滤 DOM 隐藏触发页面删除按钮或调用同源接口失败影响最多是过滤失效不影响服务端可能删除失败或部分删除需要重试机制扩展复杂度低中高需处理弹窗、加载态、队列重试注意如果脚本运行在某个具体的游戏社区页面上发布前要重新评估该平台的服务条款。示例代码的作用是展示通用实现思路不代表所有页面都允许通过脚本批量操作。2. 页面分析与脚本骨架先确认 DOM 再写功能2.1 在浏览器中观察共享造物列表的 DOM用户脚本直接操作页面 DOM因此写功能前必须先知道列表里每一张卡片的固定结构。打开页面后在共享造物列表中右键某张卡片选择“检查”查看 Elements 面板。重点确认以下信息卡片根节点是什么标签和 class。卡片根节点是否有 data 属性例如>div classcreation-card>const CONFIG { // 列表和卡片 listSelector: #creation-list, cardSelector: .creation-card, // 卡片内字段 creationIdAttr: data-creation-id, authorIdAttr: data-author-id, authorNameSelector: .card-author, titleSelector: .card-title, // 操作按钮 deleteBtnSelector: .card-delete-btn, confirmOkSelector: .confirm-box .confirm-ok, actionBoxSelector: .card-actions, // 本地存储 key storageKey: genshin_shared_creation_tool_state_v1, // 当前登录用户 ID需要从页面已有元素中读取 currentUserId: };这里的核心思路是业务逻辑只读CONFIG不直接写死 class 名。上线时如果页面改版只需要维护配置对象。2.3 用户脚本头与基础能力接入以 Tampermonkey 为例脚本头需要声明运行范围。共享造物列表可能由前端框架渲染建议在document-idle阶段执行避免过早操作还没有生成的卡片。// UserScript // name 原神共享造物屏蔽与一键删除助手 // namespace example.tool.shared-creation // version 0.1.0 // description 为共享造物列表页增加本地屏蔽和一键删除功能 // match https://example.com/* // run-at document-idle // grant GM_addStyle // grant GM_setValue // grant GM_getValue // /UserScriptmatch必须指向你要使用的社区页面域名。这里用https://example.com/*是占位实际要根据目标页面改成真实域名。3. 本地状态存储版本化结构比临时数组更可靠3.1 状态字段设计与版本号屏蔽状态不能只存一个临时数组因为在页面刷新后可能还要判断“这条卡片当初是否被屏蔽”。建议把所有状态封装成一个带版本号的对象{ version: 1, blockedIds: [creation_001, creation_002], blockedAuthors: [author_001], updatedAt: 1735722000000 }字段含义如下字段作用version标识数据结构版本后续字段变更时用于迁移或重建blockedIds被屏蔽的造物 ID 列表只过滤单条blockedAuthors被屏蔽的作者 ID 列表过滤该作者全部内容updatedAt最近一次更新时间的毫秒时间戳便于排查同步问题为什么要加版本号因为用户脚本会持续迭代。今天只有blockedIds明天增加blockedAuthors后天可能把 ID 改成字符串拼接的复合键。如果没有版本号旧数据落到新代码里很可能因为缺字段导致整个解析异常。3.2 读写示例可以使用localStorage保存状态。这个 API 简单、无权限提示适合单个站点使用。function getDefaultState() { return { version: 1, blockedIds: [], blockedAuthors: [], updatedAt: Date.now() }; } function loadState() { try { const raw localStorage.getItem(CONFIG.storageKey); if (!raw) return getDefaultState(); const parsed JSON.parse(raw); if (!parsed || parsed.version ! 1) return getDefaultState(); return { version: 1, blockedIds: Array.isArray(parsed.blockedIds) ? parsed.blockedIds : [], blockedAuthors: Array.isArray(parsed.blockedAuthors) ? parsed.blockedAuthors : [], updatedAt: parsed.updatedAt || Date.now() }; } catch (error) { console.warn([shared-creation-tool] 读取本地状态失败已重置, error); return getDefaultState(); } } function saveState(state) { const clone { version: state.version || 1, blockedIds: Array.from(new Set(state.blockedIds || [])), blockedAuthors: Array.from(new Set(state.blockedAuthors || [])), updatedAt: Date.now() }; localStorage.setItem(CONFIG.storageKey, JSON.stringify(clone)); return clone; }这里写入前用Set去重避免在一次操作中反复 toggle 导致同一条 ID 出现两次。读取时如果 JSON 解析失败直接打印警告并回到默认状态不能让异常把整个脚本中断。3.3 存储异常被吞掉会造成屏蔽失效一个常见错误是保存时只用裸的localStorage.setItem不处理异常。浏览器处于隐私模式、站点限制了存储、或者存储空间已满时写入会抛异常。如果脚本没有捕获后面所有屏蔽逻辑都会中断但用户很难察觉。因此在saveState的外层调用处也要做保护function toggleBlockedId(creationId) { const state loadState(); const index state.blockedIds.indexOf(creationId); if (index 0) { state.blockedIds.splice(index, 1); } else { state.blockedIds.push(creationId); } try { saveState(state); } catch (error) { console.error([shared-creation-tool] 状态保存失败, error); alert(屏蔽状态保存失败请检查浏览器存储是否被限制); } }注意如果采用GM_setValue需要把grant GM_setValue和grant GM_getValue写在脚本头中并注意本方法读写的键是一个字符串不能直接存对象。4. 实现屏蔽状态过滤、按钮注入与懒加载观察4.1 给卡片加屏蔽按钮先让卡片出现一个“屏蔽”按钮。按钮的位置尽量放在操作区不建议用innerHTML拼接动态内容因为作者昵称和标题都可能包含特殊字符必须用textContent和原生 DOM 创建。function ensureBlockButton(cardRoot) { const meta parseCard(cardRoot); if (!meta || !meta.creationId) return null; if (cardRoot.classList.contains(mc-blocked)) { return null; } let existing cardRoot.querySelector(.mc-block-btn); if (existing) return existing; const btn document.createElement(button); btn.type button; btn.className mc-block-btn; btn.textContent 屏蔽; const actionBox cardRoot.querySelector(CONFIG.actionBoxSelector) || cardRoot; actionBox.appendChild(btn); return btn; } function parseCard(cardRoot) { return { creationId: cardRoot.getAttribute(CONFIG.creationIdAttr), authorId: cardRoot.getAttribute(CONFIG.authorIdAttr), title: cardRoot.querySelector(CONFIG.titleSelector)?.textContent?.trim() || , authorName: cardRoot.querySelector(CONFIG.authorName