原神玩家社区里存在大量共享造物内容,常见形态是卡片式列表:每张卡片包含作者、标题、预览图和访问入口。当这些内容越攒越多,会出现两类很常见的需求:一类是不想再看某个作者或某条造物,希望把它“屏蔽”掉,但页面往往没有现成入口;另一类是自己发布过的旧造物需要清理,却只能一条一条点击删除,操作重复、耗时长。把这两个需求交给前端来处理,本质上是两个不同问题:屏蔽是基于本地状态的内容过滤,删除是基于账户权限的远端数据变更。下面以“浏览器用户脚本 + 页面 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 class="creation-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 // ==/UserScript==@match必须指向你要使用的社区页面域名。这里用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